pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
292 lines (291 loc) • 11.3 kB
JavaScript
/**
* Internal-import resolution-to-file for tree-sitter languages (#249 follow-up).
*
* The review graph extracts import SOURCES per language (IMPORT_QUERIES), but a
* raw source string ("os.path", "./foo", "github.com/me/p/pkg") isn't a graph
* edge until it's resolved to an in-project FILE. jsts (localImportToFile) and
* cxx (#include) already do this; these resolvers extend it to the tree-sitter
* languages where an import maps cleanly to a file:
*
* - relative file paths : ruby (require_relative), zig (@import), bash
* (source/.), dart (relative `import`)
* - package/module roots : python (dotted → package file), java (package →
* source-root file), go (import path → package DIR's
* .go files)
*
* Languages whose imports are NOT a 1:1 file concept (rust mod-system,
* c#/swift namespaces, kotlin/elixir multi-symbol files) are intentionally not
* resolved — they stay honest `external:` nodes rather than misleading edges.
*
* Every resolver is pure + existence-checked + confined to `cwd`: an
* unresolvable source returns `[]` and the caller keeps the unresolved node, so
* a wrong guess can never fabricate an edge to a file that isn't there.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { normalizeMapKey } from "../path-utils.js";
/** True when `p` is inside (or equal to) `cwd` — blocks resolution escaping the workspace. */
function isWithin(cwd, p) {
const root = path.resolve(cwd);
const rp = path.resolve(p);
return rp === root || rp.startsWith(root + path.sep);
}
function isFile(p) {
try {
return fs.statSync(p).isFile();
}
catch {
return false;
}
}
function isDir(p) {
try {
return fs.statSync(p).isDirectory();
}
catch {
return false;
}
}
/** First candidate that exists as a file within cwd, normalized — or []. */
function firstExistingFile(cwd, candidates) {
for (const c of candidates) {
if (isWithin(cwd, c) && isFile(c))
return [normalizeMapKey(c)];
}
return [];
}
/** All `ext` files directly in `dir` (non-recursive), normalized — or []. */
function sourceFilesIn(cwd, dir, ext) {
if (!isWithin(cwd, dir) || !isDir(dir))
return [];
try {
return fs
.readdirSync(dir)
.filter((n) => n.endsWith(ext))
.map((n) => normalizeMapKey(path.join(dir, n)))
.sort();
}
catch {
return [];
}
}
/** Resolve a path-ish source relative to the importing file's directory. */
function resolveRelative(cwd, filePath, source, exts) {
const base = path.resolve(path.dirname(filePath), source);
return firstExistingFile(cwd, [base, ...exts.map((e) => base + e)]);
}
function resolveDart(cwd, filePath, source) {
// package: / dart: imports are SDK/pub deps, not project files.
if (source.startsWith("package:") || source.startsWith("dart:"))
return [];
return resolveRelative(cwd, filePath, source, [".dart"]);
}
// --- JS/TS -------------------------------------------------------------------
/**
* Resolve a relative ESM import (`./x`, `../y`) to an in-project file, trying the
* ts/tsx/js/jsx extensions and the `index.*` directory form. Mirrors the warm
* graph's `localImportToFile` (builder.ts) — duplicated rather than imported to
* avoid a builder→resolvers cycle. Bare specifiers (`react`, `@scope/pkg`) are
* package deps → external, so they return []. Used only on the COLD module_report
* path: the warm jsts builder resolves imports via the TS compiler and never
* reaches this resolver.
*/
function resolveJsTs(cwd, filePath, source) {
if (!source.startsWith("."))
return [];
const base = path.resolve(path.dirname(filePath), source);
return firstExistingFile(cwd, [
base,
`${base}.ts`,
`${base}.tsx`,
`${base}.js`,
`${base}.jsx`,
path.join(base, "index.ts"),
path.join(base, "index.tsx"),
path.join(base, "index.js"),
path.join(base, "index.jsx"),
]);
}
// --- C / C++ -----------------------------------------------------------------
/**
* Resolve a C/C++ `#include` to an in-project header (#302). A system header
* (`<stdio.h>`, captured with its angle brackets) is a toolchain/library dep →
* external, so it returns []. A quoted local include (`#include "foo.h"` →
* `foo.h` after quote-strip) resolves against the same candidate roots the warm
* graph's `resolveCxxInclude` uses (the including file's dir, then cwd / include /
* src), so cold and warm agree on which file a local include points to.
*/
function resolveCxx(cwd, filePath, source) {
if (source.startsWith("<"))
return [];
const dir = path.dirname(path.resolve(filePath));
return firstExistingFile(cwd, [
path.resolve(dir, source),
path.resolve(cwd, source),
path.resolve(cwd, "include", source),
path.resolve(cwd, "src", source),
]);
}
// --- Python -----------------------------------------------------------------
/** Candidate source roots for an absolute dotted import. */
function pythonRoots(cwd, fileDir) {
// The package root is the first ancestor of the importing file that is NOT
// itself a package (no __init__.py) — that's where a top-level `import a.b`
// is anchored. Add cwd and cwd/src as conventional fallbacks.
let p = fileDir;
const root = path.resolve(cwd);
while (isWithin(cwd, p) && isFile(path.join(p, "__init__.py"))) {
const parent = path.dirname(p);
if (parent === p)
break;
p = parent;
}
const roots = new Set([p, root, path.join(root, "src")]);
return [...roots].filter((r) => isDir(r));
}
function resolvePython(cwd, filePath, source) {
const fileDir = path.dirname(path.resolve(filePath));
if (source.startsWith(".")) {
// Relative import: leading dots = how far up, remainder = dotted subpath.
const m = source.match(/^(\.+)(.*)$/);
if (!m)
return [];
const dots = m[1].length;
let baseDir = fileDir;
for (let i = 1; i < dots; i++)
baseDir = path.dirname(baseDir);
const rest = m[2] ? m[2].split(".") : [];
const target = path.join(baseDir, ...rest);
return firstExistingFile(cwd, [
`${target}.py`,
path.join(target, "__init__.py"),
]);
}
const parts = source.split(".");
for (const root of pythonRoots(cwd, fileDir)) {
const target = path.join(root, ...parts);
const found = firstExistingFile(cwd, [
`${target}.py`,
path.join(target, "__init__.py"),
]);
if (found.length)
return found;
}
return [];
}
// --- Go ---------------------------------------------------------------------
/** Walk up from the importing file to a go.mod and read its `module` path. */
function findGoModule(cwd, filePath) {
let dir = path.dirname(path.resolve(filePath));
const root = path.resolve(cwd);
while (true) {
try {
const content = fs.readFileSync(path.join(dir, "go.mod"), "utf-8");
const m = content.match(/^\s*module\s+(\S+)/m);
if (m)
return { moduleDir: dir, modulePath: m[1] };
}
catch {
// no go.mod here — keep climbing
}
if (dir === root)
break;
const parent = path.dirname(dir);
if (parent === dir || !isWithin(cwd, parent))
break;
dir = parent;
}
return null;
}
function resolveGo(cwd, filePath, source) {
const mod = findGoModule(cwd, filePath);
if (!mod)
return [];
// Only same-module import paths map to a local package directory; stdlib and
// third-party paths (no module prefix) stay external.
if (source !== mod.modulePath && !source.startsWith(`${mod.modulePath}/`)) {
return [];
}
const rel = source === mod.modulePath ? "" : source.slice(mod.modulePath.length + 1);
// A Go package is a directory; edge to every .go file in it (who-imports
// works at file granularity). Exclude nothing — _test.go files import too.
return sourceFilesIn(cwd, path.join(mod.moduleDir, rel), ".go");
}
// --- Java -------------------------------------------------------------------
function javaSourceRoots(cwd, filePath) {
const root = path.resolve(cwd);
const roots = new Set();
for (const c of ["src/main/java", "src/test/java", "src", ""]) {
roots.add(path.join(root, c));
}
// The importing file's own source root is one of its ancestors, so a
// same-project import resolves even on a non-conventional layout.
let p = path.dirname(path.resolve(filePath));
while (true) {
roots.add(p);
if (p === root)
break;
const parent = path.dirname(p);
if (parent === p || !isWithin(cwd, parent))
break;
p = parent;
}
return [...roots].filter((r) => isDir(r));
}
function resolveJava(cwd, filePath, source) {
const parts = source.split(".");
for (const root of javaSourceRoots(cwd, filePath)) {
// import a.b.Foo → a/b/Foo.java
const asFile = firstExistingFile(cwd, [
`${path.join(root, ...parts)}.java`,
]);
if (asFile.length)
return asFile;
// import a.b.* (captured as a.b) → every .java in the package dir
const asPkg = sourceFilesIn(cwd, path.join(root, ...parts), ".java");
if (asPkg.length)
return asPkg;
// static import a.b.Foo.bar → drop the member, resolve the class file
if (parts.length > 1) {
const dropLast = firstExistingFile(cwd, [
`${path.join(root, ...parts.slice(0, -1))}.java`,
]);
if (dropLast.length)
return dropLast;
}
}
return [];
}
/**
* Resolve a single tree-sitter import source to in-project file(s). Returns
* normalized paths (possibly several — a Go/Java package is a directory) or `[]`
* when the source isn't a resolvable in-project file (keep it as external).
*/
export function resolveImportToFiles(cwd, filePath, languageId, source) {
switch (languageId) {
case "typescript":
case "tsx":
case "javascript":
case "jsts":
return resolveJsTs(cwd, filePath, source);
case "c":
case "cpp":
return resolveCxx(cwd, filePath, source);
case "ruby":
return resolveRelative(cwd, filePath, source, [".rb"]);
case "zig":
return resolveRelative(cwd, filePath, source, [".zig"]);
case "bash":
return resolveRelative(cwd, filePath, source, [".sh", ".bash"]);
case "dart":
return resolveDart(cwd, filePath, source);
case "python":
return resolvePython(cwd, filePath, source);
case "go":
return resolveGo(cwd, filePath, source);
case "java":
return resolveJava(cwd, filePath, source);
default:
return [];
}
}