links.ts

7.7 KB · raw

1// What a source file says about its place in the repo, found by syntax alone like its description:2// the names it exports and the imports that may name project files or folders. Anything that3// resolves to nothing in the repo (a standard library, a package) is left out.45/** `relative` specs are paths from the file's own folder; the rest are module paths. */6export 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				// `mod x;` in foo.rs names foo/x.rs; in main.rs, lib.rs or mod.rs, a sibling.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		// Both `import "x"` and the lines of an `import ( … )` block.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}119120/** Test files, as every language here names them. */121export function isTestPath(path: string): boolean {122	return /(^|\/)(tests?|__tests__)\/|_test\.|\.(test|spec)\.|(^|\/)test_[^/]*\.py$/.test(path);123}124125/**126 * The repo's files and folders, indexed so a module path finds them: a folder by its path, a127 * file by its path without the extension, each under every trailing run of its segments.128 */129export 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}157158/** The project files (and `folder/`s, for module imports) that `path`'s imports name. */159export 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	// C's `#include "x.h"` also searches include folders (and Zig's build can add paths): a file of180	// that name found once in the repo is it.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}187188// Longest run of the module path first, then shorter ones, so `use crate::db::Pool` finds db.rs189// and a Go import finds its folder under the module's own name. A single segment only counts from190// the start, so `android.content.Context` never lands on a project file named Context. More than191// one match at the longest length is ambiguous, and names nothing.192function 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}