// What a source file says about its place in the repo, found by syntax alone like its description: // the names it exports and the imports that may name project files or folders. Anything that // resolves to nothing in the repo (a standard library, a package) is left out. /** `relative` specs are paths from the file's own folder; the rest are module paths. */ export type Import = { spec: string; relative: boolean; tries: string[] }; export type Outline = { exports: string[]; imports: Import[] }; type Language = { exports: RegExp[]; imports: (line: string, path: string) => Import[]; }; const JS_TRIES = ["", ".ts", ".tsx", ".js", ".mjs", ".jsx", "/index.ts", "/index.js"]; const PY_TRIES = [".py", "/__init__.py"]; const js: Language = { exports: [ /^export\s+(?:default\s+)?(?:async\s+)?(?:function\*?|class|const|let|var|type|interface|enum)\s+([A-Za-z_$][\w$]*)/, ], imports: (line) => [ /^\s*(?:import|export)\s[^'"]*?\bfrom\s*['"]([^'"]+)['"]/, /^\s*import\s*['"]([^'"]+)['"]/, /\brequire\(\s*['"]([^'"]+)['"]\s*\)/, ].flatMap((pattern) => { const spec = pattern.exec(line)?.[1]; return spec?.startsWith(".") ? [{ spec, relative: true, tries: JS_TRIES }] : []; }), }; const LANGUAGES: Record = { zig: { exports: [/^pub\s+(?:inline\s+)?(?:fn|const|var)\s+(\w+)/], imports: (line) => [...line.matchAll(/@import\("([^"]+\.zig)"\)/g)].map((m) => ({ spec: m[1] as string, relative: true, tries: [""], })), }, v: { exports: [ /^pub\s+fn\s+(?:\([^)]*\)\s*)?(\w+)/, /^pub\s+(?:struct|enum|interface|type|const)\s+(\w+)/, ], imports: (line) => dotted(/^import\s+([\w.]+)/.exec(line)?.[1]), }, rs: { exports: [/^pub\s+(?:async\s+)?(?:fn|struct|enum|trait|type|const|static|mod)\s+(\w+)/], imports: (line, path) => { const mod = /^\s*(?:pub\s+)?mod\s+(\w+)\s*;/.exec(line)?.[1]; if (mod) { // `mod x;` in foo.rs names foo/x.rs; in main.rs, lib.rs or mod.rs, a sibling. const name = path.split("/").pop() as string; const base = /^(main|lib|mod)\.rs$/.test(name) ? "." : `./${name.slice(0, -3)}`; return [{ spec: `${base}/${mod}`, relative: true, tries: [".rs", "/mod.rs"] }]; } return dotted(/^\s*(?:pub\s+)?use\s+(?:crate|super|self)::([\w:]+)/.exec(line)?.[1]); }, }, go: { exports: [/^func\s+(?:\([^)]*\)\s*)?([A-Z]\w*)/, /^type\s+([A-Z]\w*)/], // Both `import "x"` and the lines of an `import ( … )` block. imports: (line) => dotted(/^\s*(?:import\s+)?(?:[\w.]+\s+)?"([\w./-]+)"\s*$/.exec(line)?.[1]), }, py: { exports: [/^def\s+([A-Za-z]\w*)/, /^class\s+([A-Za-z]\w*)/], imports: (line) => { const from = /^from\s+(\.+)([\w.]*)\s+import\s+([\w\s,]+)/.exec(line); if (from) { const up = `./${"../".repeat((from[1] as string).length - 1)}`; const modules = from[2] ? [from[2]] : (from[3] as string).split(",").map((name) => name.trim()); return modules .filter(Boolean) .map((mod) => ({ spec: up + mod.replaceAll(".", "/"), relative: true, tries: PY_TRIES })); } return dotted(/^(?:from|import)\s+([\w.]+)/.exec(line)?.[1]); }, }, kt: { exports: [ /^(?:(?:public|internal|open|abstract|sealed|data|enum|annotation|inline|value|suspend|final|static)\s+)*(?:class|object|interface|fun|typealias|record)\s+(?:<[^>]*>\s*)?([A-Za-z]\w*)/, ], imports: (line) => dotted(/^import\s+(?:static\s+)?([\w.]+)/.exec(line)?.[1]), }, c: { exports: [], imports: (line) => { const spec = /^\s*#\s*include\s+"([^"]+)"/.exec(line)?.[1]; return spec ? [{ spec, relative: true, tries: [""] }] : []; }, }, }; for (const ext of "ts tsx mts cts js mjs cjs jsx".split(" ")) LANGUAGES[ext] = js; LANGUAGES.java = LANGUAGES.kt as Language; for (const ext of "h cc cpp cxx hpp hh".split(" ")) LANGUAGES[ext] = LANGUAGES.c as Language; function dotted(spec: string | undefined): Import[] { return spec ? [{ spec, relative: false, tries: [] }] : []; } export function outline(path: string, text: string): Outline | null { const language = LANGUAGES[path.split(".").pop() ?? ""]; if (!language) return null; const exports = new Set(); const imports: Import[] = []; for (const line of text.split("\n")) { for (const pattern of language.exports) { const name = pattern.exec(line)?.[1]; if (name) exports.add(name); } imports.push(...language.imports(line, path)); } return { exports: [...exports], imports }; } /** Test files, as every language here names them. */ export function isTestPath(path: string): boolean { return /(^|\/)(tests?|__tests__)\/|_test\.|\.(test|spec)\.|(^|\/)test_[^/]*\.py$/.test(path); } /** * The repo's files and folders, indexed so a module path finds them: a folder by its path, a * file by its path without the extension, each under every trailing run of its segments. */ export type RepoIndex = { files: Set; folders: Set; bySuffix: Map; }; export function repoIndex(paths: string[]): RepoIndex { const files = new Set(paths); const folders = new Set( paths.flatMap((path) => path .split("/") .slice(0, -1) .map((_, i, parts) => parts.slice(0, i + 1).join("/")), ), ); const bySuffix = new Map(); const add = (key: string, target: string) => { const segments = key.split("/"); for (let i = 0; i < segments.length; i++) { const suffix = segments.slice(i).join("/"); bySuffix.set(suffix, [...(bySuffix.get(suffix) ?? []), target]); } }; for (const path of files) add(path.replace(/\.[^./]+$/, ""), path); for (const folder of folders) add(folder, `${folder}/`); return { files, folders, bySuffix }; } /** The project files (and `folder/`s, for module imports) that `path`'s imports name. */ export function resolveImports(path: string, imports: Import[], index: RepoIndex): string[] { const dir = path.split("/").slice(0, -1); const found = new Set(); for (const imp of imports) { const target = imp.relative ? relativeTarget(dir, imp, index) : moduleTarget(imp.spec, index); if (target && target !== path) found.add(target); } return [...found]; } function relativeTarget(dir: string[], imp: Import, index: RepoIndex): string | null { const parts = [...dir]; for (const segment of imp.spec.split("/")) { if (segment === "..") { if (parts.pop() === undefined) return null; } else if (segment !== "." && segment !== "") parts.push(segment); } const base = parts.join("/"); for (const tail of imp.tries) if (index.files.has(base + tail)) return base + tail; if (index.folders.has(base)) return `${base}/`; // C's `#include "x.h"` also searches include folders (and Zig's build can add paths): a file of // that name found once in the repo is it. if (imp.tries.length !== 1) return null; const named = (index.bySuffix.get(imp.spec.replace(/\.[^./]+$/, "")) ?? []).filter((hit) => hit.endsWith(imp.spec), ); return named.length === 1 ? (named[0] as string) : null; } // Longest run of the module path first, then shorter ones, so `use crate::db::Pool` finds db.rs // and a Go import finds its folder under the module's own name. A single segment only counts from // the start, so `android.content.Context` never lands on a project file named Context. More than // one match at the longest length is ambiguous, and names nothing. function moduleTarget(spec: string, index: RepoIndex): string | null { const segments = spec.split(/[./:]+/).filter(Boolean); for (let length = segments.length; length >= 1; length--) { const hits = new Set(); for (let start = 0; start + length <= segments.length; start++) { if (length === 1 && start > 0) break; const key = segments.slice(start, start + length).join("/"); for (const hit of index.bySuffix.get(key) ?? []) hits.add(hit); } if (hits.size === 1) return [...hits][0] as string; if (hits.size > 1) return null; } return null; }