123456export type Import = { spec: string; relative: boolean; tries: string[] };7export type Outline = { exports: string[]; imports: Import[] };89type Language = {10 exports: RegExp[];11 imports: (line: string, path: string) => Import[];12};1314const JS_TRIES = ["", ".ts", ".tsx", ".js", ".mjs", ".jsx", "/index.ts", "/index.js"];15const PY_TRIES = [".py", "/__init__.py"];1617const js: Language = {18 exports: [19 /^export\s+(?:default\s+)?(?:async\s+)?(?:function\*?|class|const|let|var|type|interface|enum)\s+([A-Za-z_$][\w$]*)/,20 ],21 imports: (line) =>22 [23 /^\s*(?:import|export)\s[^'"]*?\bfrom\s*['"]([^'"]+)['"]/,24 /^\s*import\s*['"]([^'"]+)['"]/,25 /\brequire\(\s*['"]([^'"]+)['"]\s*\)/,26 ].flatMap((pattern) => {27 const spec = pattern.exec(line)?.[1];28 return spec?.startsWith(".") ? [{ spec, relative: true, tries: JS_TRIES }] : [];29 }),30};3132const LANGUAGES: Record<string, Language> = {33 zig: {34 exports: [/^pub\s+(?:inline\s+)?(?:fn|const|var)\s+(\w+)/],35 imports: (line) =>36 [...line.matchAll(/@import\("([^"]+\.zig)"\)/g)].map((m) => ({37 spec: m[1] as string,38 relative: true,39 tries: [""],40 })),41 },42 v: {43 exports: [44 /^pub\s+fn\s+(?:\([^)]*\)\s*)?(\w+)/,45 /^pub\s+(?:struct|enum|interface|type|const)\s+(\w+)/,46 ],47 imports: (line) => dotted(/^import\s+([\w.]+)/.exec(line)?.[1]),48 },49 rs: {50 exports: [/^pub\s+(?:async\s+)?(?:fn|struct|enum|trait|type|const|static|mod)\s+(\w+)/],51 imports: (line, path) => {52 const mod = /^\s*(?:pub\s+)?mod\s+(\w+)\s*;/.exec(line)?.[1];53 if (mod) {54 55 const name = path.split("/").pop() as string;56 const base = /^(main|lib|mod)\.rs$/.test(name) ? "." : `./${name.slice(0, -3)}`;57 return [{ spec: `${base}/${mod}`, relative: true, tries: [".rs", "/mod.rs"] }];58 }59 return dotted(/^\s*(?:pub\s+)?use\s+(?:crate|super|self)::([\w:]+)/.exec(line)?.[1]);60 },61 },62 go: {63 exports: [/^func\s+(?:\([^)]*\)\s*)?([A-Z]\w*)/, /^type\s+([A-Z]\w*)/],64 65 imports: (line) => dotted(/^\s*(?:import\s+)?(?:[\w.]+\s+)?"([\w./-]+)"\s*$/.exec(line)?.[1]),66 },67 py: {68 exports: [/^def\s+([A-Za-z]\w*)/, /^class\s+([A-Za-z]\w*)/],69 imports: (line) => {70 const from = /^from\s+(\.+)([\w.]*)\s+import\s+([\w\s,]+)/.exec(line);71 if (from) {72 const up = `./${"../".repeat((from[1] as string).length - 1)}`;73 const modules = from[2]74 ? [from[2]]75 : (from[3] as string).split(",").map((name) => name.trim());76 return modules77 .filter(Boolean)78 .map((mod) => ({ spec: up + mod.replaceAll(".", "/"), relative: true, tries: PY_TRIES }));79 }80 return dotted(/^(?:from|import)\s+([\w.]+)/.exec(line)?.[1]);81 },82 },83 kt: {84 exports: [85 /^(?:(?: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*)/,86 ],87 imports: (line) => dotted(/^import\s+(?:static\s+)?([\w.]+)/.exec(line)?.[1]),88 },89 c: {90 exports: [],91 imports: (line) => {92 const spec = /^\s*#\s*include\s+"([^"]+)"/.exec(line)?.[1];93 return spec ? [{ spec, relative: true, tries: [""] }] : [];94 },95 },96};97for (const ext of "ts tsx mts cts js mjs cjs jsx".split(" ")) LANGUAGES[ext] = js;98LANGUAGES.java = LANGUAGES.kt as Language;99for (const ext of "h cc cpp cxx hpp hh".split(" ")) LANGUAGES[ext] = LANGUAGES.c as Language;100101function dotted(spec: string | undefined): Import[] {102 return spec ? [{ spec, relative: false, tries: [] }] : [];103}104105export function outline(path: string, text: string): Outline | null {106 const language = LANGUAGES[path.split(".").pop() ?? ""];107 if (!language) return null;108 const exports = new Set<string>();109 const imports: Import[] = [];110 for (const line of text.split("\n")) {111 for (const pattern of language.exports) {112 const name = pattern.exec(line)?.[1];113 if (name) exports.add(name);114 }115 imports.push(...language.imports(line, path));116 }117 return { exports: [...exports], imports };118}119120121export function isTestPath(path: string): boolean {122 return /(^|\/)(tests?|__tests__)\/|_test\.|\.(test|spec)\.|(^|\/)test_[^/]*\.py$/.test(path);123}124125126127128129export type RepoIndex = {130 files: Set<string>;131 folders: Set<string>;132 bySuffix: Map<string, string[]>;133};134135export function repoIndex(paths: string[]): RepoIndex {136 const files = new Set(paths);137 const folders = new Set(138 paths.flatMap((path) =>139 path140 .split("/")141 .slice(0, -1)142 .map((_, i, parts) => parts.slice(0, i + 1).join("/")),143 ),144 );145 const bySuffix = new Map<string, string[]>();146 const add = (key: string, target: string) => {147 const segments = key.split("/");148 for (let i = 0; i < segments.length; i++) {149 const suffix = segments.slice(i).join("/");150 bySuffix.set(suffix, [...(bySuffix.get(suffix) ?? []), target]);151 }152 };153 for (const path of files) add(path.replace(/\.[^./]+$/, ""), path);154 for (const folder of folders) add(folder, `${folder}/`);155 return { files, folders, bySuffix };156}157158159export function resolveImports(path: string, imports: Import[], index: RepoIndex): string[] {160 const dir = path.split("/").slice(0, -1);161 const found = new Set<string>();162 for (const imp of imports) {163 const target = imp.relative ? relativeTarget(dir, imp, index) : moduleTarget(imp.spec, index);164 if (target && target !== path) found.add(target);165 }166 return [...found];167}168169function relativeTarget(dir: string[], imp: Import, index: RepoIndex): string | null {170 const parts = [...dir];171 for (const segment of imp.spec.split("/")) {172 if (segment === "..") {173 if (parts.pop() === undefined) return null;174 } else if (segment !== "." && segment !== "") parts.push(segment);175 }176 const base = parts.join("/");177 for (const tail of imp.tries) if (index.files.has(base + tail)) return base + tail;178 if (index.folders.has(base)) return `${base}/`;179 180 181 if (imp.tries.length !== 1) return null;182 const named = (index.bySuffix.get(imp.spec.replace(/\.[^./]+$/, "")) ?? []).filter((hit) =>183 hit.endsWith(imp.spec),184 );185 return named.length === 1 ? (named[0] as string) : null;186}187188189190191192function moduleTarget(spec: string, index: RepoIndex): string | null {193 const segments = spec.split(/[./:]+/).filter(Boolean);194 for (let length = segments.length; length >= 1; length--) {195 const hits = new Set<string>();196 for (let start = 0; start + length <= segments.length; start++) {197 if (length === 1 && start > 0) break;198 const key = segments.slice(start, start + length).join("/");199 for (const hit of index.bySuffix.get(key) ?? []) hits.add(hit);200 }201 if (hits.size === 1) return [...hits][0] as string;202 if (hits.size > 1) return null;203 }204 return null;205}