From f3e287338f3d7eff246d88347e78f76779ce5ecf Mon Sep 17 00:00:00 2001 From: bitnom <14287229+bitnom@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:18:08 -0400 Subject: [PATCH] feat(graph): Rust language support (.rs) as a depth-tier language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of PR #59 rebaked onto the current breadth-tier architecture. Extraction (extract.ts, bindings.ts): functions, structs, enums, traits (interface), type aliases, unions, impl-owned methods with generic-strip ownership, macro_rules! in its own `!` namespace, inline-module scope segments, module and constant nodes. pub/pub(crate)/pub(super) and #[macro_export] drive exports; #[cfg(test)] suppresses them through the whole module subtree, and function-body nesting cannot launder a pub into an export. Macro signatures stop before their token trees; scoped calls carry the leaf name; use forms keep full per-leaf paths; inline modules consume same-file self/super prefixes. Resolution (resolve.ts): a module-tree resolver for crate::/self::/ super:: paths — x.rs and x/mod.rs layouts, the parser-file stem-dir rule, bodyless mod imports, integration-test crate roots under tests/ benches/examples, and path-inferred roots when no Cargo.toml exists. Cargo [package] names map workspace imports (hyphen/underscore-folded, { package = "..." } aliases honored); duplicates stay raw. Scoped calls resolve only against the resolved file's functions, never a bare-name fallback. Bare names resolve in a Rust-only domain, and duplicate same-file trait impls drop as ambiguous while a single impl heritage resolves trait default methods. Routing: .rs leaves the breadth registry (GENERIC_LANGS) for the depth tier, mirroring PHP's move in #157; grammar is tree-sitter-rust 0.24.0 with a $tree-sitter override pin, which also parses &raw const/mut. --- CHANGELOG.md | 12 + package-lock.json | 54 ++-- package.json | 5 + src/graph/bindings.ts | 73 ++++- src/graph/build.ts | 48 ++- src/graph/extract.ts | 458 ++++++++++++++++++++++++++++- src/graph/generic.ts | 5 +- src/graph/resolve.ts | 372 ++++++++++++++++++++---- test/generic-extract.test.ts | 157 +++------- test/graph-invariants.test.ts | 10 +- test/graph-rust-resolve.test.ts | 468 ++++++++++++++++++++++++++++++ test/graph-rust.test.ts | 375 ++++++++++++++++++++++++ test/supported-extensions.test.ts | 4 +- 13 files changed, 1826 insertions(+), 215 deletions(-) create mode 100644 test/graph-rust-resolve.test.ts create mode 100644 test/graph-rust.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bd845ee87..ab3f76560 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,18 @@ working copy against the repo a brain expects, so rules are never mined from the wrong tree. +- **Rust review hardening.** Rust module paths now handle inline-module `self`/ + `super` consumption, auxiliary crate roots, and ambiguous or cross-language + cases conservatively, preserving dropped-edge behavior rather than guessing. + +- **Rust code graphs.** `.rs` files contribute functions, structs, enums, traits, + type aliases, impl-owned methods, modules, constants, and macros. Rust calls, + typed member calls, macro namespaces, crate/module paths, and Cargo workspace + package mappings resolve deterministically without requiring rust-analyzer. + Rust is a depth-tier language now — `.rs` no longer routes through the + generic tags.scm tier, and the grammar is tree-sitter-rust 0.24.0, which + parses `&raw const`/`&raw mut` (0.23 recovered them as syntax errors). + ### Fixed - **A brain refreshes its rules from upkeep** (#343), so a single empty pull no diff --git a/package-lock.json b/package-lock.json index 6c0727d26..77a5f286c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "tree-sitter-php": "^0.23.4", "tree-sitter-python": "^0.21.0", "tree-sitter-r": "npm:@davisvaughan/tree-sitter-r@^1.3.0", + "tree-sitter-rust": "^0.24.0", "tree-sitter-swift": "^0.7.1", "tree-sitter-typescript": "^0.23.2", "tree-sitter-wasm": "^1.1.6", @@ -1239,6 +1240,25 @@ } } }, + "node_modules/tree-sitter-rust": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/tree-sitter-rust/-/tree-sitter-rust-0.24.0.tgz", + "integrity": "sha512-NWemUDf629Tfc90Y0Z55zuwPCAHkLxWnMf2RznYu4iBkkrQl2o/CHGB7Cr52TyN5F1DAx8FmUnDtCy9iUkXZEQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.4" + }, + "peerDependencies": { + "tree-sitter": "^0.22.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, "node_modules/tree-sitter-swift": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/tree-sitter-swift/-/tree-sitter-swift-0.7.1.tgz", @@ -1302,9 +1322,9 @@ } }, "node_modules/tree-sitter-wasm": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/tree-sitter-wasm/-/tree-sitter-wasm-1.1.6.tgz", - "integrity": "sha512-jn/q9cOBIcTOA8QqtrPXNbibislSDuVChO4ws//aFy+DKjrUs9wzE4R6QXFx6yunEAYaxaVvpMW4+yX/ijQ7BQ==", + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/tree-sitter-wasm/-/tree-sitter-wasm-1.1.8.tgz", + "integrity": "sha512-TZ6rZ+8L+cnsc0hXtc+AfzNd7b1CDroBKSgSIF29sJdl/Odaxmx+mZEj2pW0rswKIyWHrA/ioNqZ9RixTSCGwA==", "license": "MIT" }, "node_modules/ts-algebra": { @@ -1347,9 +1367,9 @@ } }, "node_modules/undici": { - "version": "6.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", - "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "version": "6.28.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", + "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", "dev": true, "license": "MIT", "engines": { @@ -1373,28 +1393,28 @@ } }, "node_modules/vscode-languageserver-protocol": { - "version": "3.18.2", - "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.2.tgz", - "integrity": "sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg==", + "version": "3.18.3", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.3.tgz", + "integrity": "sha512-DF49+WeV5py4zO5hhobp60jjsDSK0lAqA0OuKBLBvp423HPWQcCbhZz3JgyfIewsEz2f8U+X75xNIFHdiXZm2w==", "license": "MIT", "dependencies": { - "vscode-jsonrpc": "9.0.1", - "vscode-languageserver-types": "3.18.0" + "vscode-jsonrpc": "9.0.2", + "vscode-languageserver-types": "3.18.3" } }, "node_modules/vscode-languageserver-protocol/node_modules/vscode-jsonrpc": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.1.tgz", - "integrity": "sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.2.tgz", + "integrity": "sha512-SbQSV9yRemARxeXw6LU5sS6Zq0e9/DgCCX5yelH263ZQWukbTk8EF8fjTrr1dziasf4GwlJbvTwFnTrnQFWZXQ==", "license": "MIT", "engines": { "node": ">=14.0.0" } }, "node_modules/vscode-languageserver-types": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.0.tgz", - "integrity": "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==", + "version": "3.18.3", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.3.tgz", + "integrity": "sha512-XIlzJ7Qp/jzSI1ds7/FwPAWrPeTZA7pAtlW4hdJ1J6xXWJL6dR9QYnDhJOdLzdKhUQ5Mm6mvUMw+3DcOQQasPw==", "license": "MIT" }, "node_modules/web-tree-sitter": { diff --git a/package.json b/package.json index 1c801d7f4..99b7bc20c 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "tree-sitter-php": "^0.23.4", "tree-sitter-python": "^0.21.0", "tree-sitter-r": "npm:@davisvaughan/tree-sitter-r@^1.3.0", + "tree-sitter-rust": "^0.24.0", "tree-sitter-swift": "^0.7.1", "tree-sitter-typescript": "^0.23.2", "tree-sitter-wasm": "^1.1.6", @@ -90,6 +91,7 @@ "tree-sitter-go@0.23.4": true, "tree-sitter-javascript@0.23.1": true, "tree-sitter-java@0.23.5": true, + "tree-sitter-rust@0.24.0": true, "esbuild@0.28.2": true, "fsevents@2.3.3": true, "tree-sitter-kotlin@0.3.8": true, @@ -98,6 +100,9 @@ "tree-sitter-swift@0.7.1": true }, "overrides": { + "tree-sitter-rust": { + "tree-sitter": "$tree-sitter" + }, "tree-sitter-swift": { "tree-sitter": "$tree-sitter" } diff --git a/src/graph/bindings.ts b/src/graph/bindings.ts index 96d01089b..bf7ab148e 100644 --- a/src/graph/bindings.ts +++ b/src/graph/bindings.ts @@ -77,6 +77,16 @@ export function defName(node: Parser.SyntaxNode, lang: Language): string | null if (node.type === "anonymous_class") return "{anonymous}"; return null; } + if (lang === "rust") { + // Rust mirrors extract.ts's describeRust: a macro_definition is named with its + // `!` so the definition and its call sites agree on one spelling. + if (node.type === "macro_definition") { + const name = node.childForFieldName("name")?.text; + return name ? `${name}!` : null; + } + if (RUST_DEF_TYPES.has(node.type)) return node.childForFieldName("name")?.text ?? null; + return null; + } const defTypes = lang === "python" ? new Set(["class_definition", "function_definition"]) @@ -260,6 +270,18 @@ const JAVA_TYPE_DECLS: ReadonlySet = new Set([ "annotation_type_declaration", ]); +/** Rust declarations that push a scope segment — mirrors extract.ts's RUST_KINDS, + * minus `macro_definition`, which `defName` names with its trailing `!` instead. */ +const RUST_DEF_TYPES: ReadonlySet = new Set([ + "function_item", + "function_signature_item", + "struct_item", + "enum_item", + "trait_item", + "type_item", + "union_item", +]); + /** Pass 1 over a parsed file: collect variable->type bindings. Pure. */ export function collectBindings(root: Parser.SyntaxNode, lang: Language): FileBindings { const bindings = new FileBindings(); @@ -272,6 +294,8 @@ export function collectBindings(root: Parser.SyntaxNode, lang: Language): FileBi /** Import aliases (`... as F`) can be declared anywhere relative to their use * textually, so this scans the whole tree once, ahead of the scope-aware walk. */ function collectAliases(node: Parser.SyntaxNode, lang: Language, aliases: Map): void { + // Neither Go nor Rust has an import-alias construct this pass reads. + if (lang === "go" || lang === "rust") return; if (lang === "python" && node.type === "aliased_import") { const nameNode = node.childForFieldName("name"); const aliasNode = node.childForFieldName("alias"); @@ -307,9 +331,10 @@ function visit( else if (lang === "java") handleJava(node, scope, classScope, bindings); else if (lang === "swift") handleSwift(node, scope, classScope, bindings); else if (lang === "php") handlePhp(node, scope, bindings); + else if (lang === "rust") handleRust(node, scope, bindings); else handleTs(node, scope, classScope, bindings, aliases); - const name = defName(node, lang); + const name = lang === "rust" ? (rustContextName(node) ?? defName(node, lang)) : defName(node, lang); let childScope = scope; let childClassScope = classScope; if (name !== null) { @@ -689,6 +714,52 @@ function javaNewTypeName(value: Parser.SyntaxNode | null | undefined): string | return javaTypeName(value.childForFieldName("type")); } +function rustContextName(node: Parser.SyntaxNode): string | null { + if (node.type === "impl_item") return rustTypeName(node.childForFieldName("type")); + if (node.type === "mod_item" && node.childForFieldName("body")) { + return node.childForFieldName("name")?.text ?? null; + } + return null; +} + +function rustTypeName(node: Parser.SyntaxNode | null | undefined): string | null { + if (!node) return null; + if (node.type === "reference_type" || node.type === "generic_type") { + return rustTypeName(node.childForFieldName("type")); + } + if (node.type === "scoped_type_identifier" || node.type === "scoped_identifier") { + return node.childForFieldName("name")?.text ?? null; + } + if (node.type === "type_identifier" || node.type === "identifier") return node.text; + return null; +} + +function rustConstructorType(node: Parser.SyntaxNode | null | undefined): string | null { + if (node?.type !== "call_expression") return null; + let fn = node.childForFieldName("function"); + while (fn?.type === "generic_function") fn = fn.childForFieldName("function"); + if (fn?.type !== "scoped_identifier") return null; + const method = fn.childForFieldName("name")?.text ?? ""; + if (method !== "new" && method !== "default" && method !== "from" && !method.startsWith("with_")) return null; + return rustTypeName(fn.childForFieldName("path")); +} + +function handleRust(node: Parser.SyntaxNode, scope: string[], bindings: FileBindings): void { + const scopePath = scope.join("."); + if (node.type === "parameter") { + const pattern = node.childForFieldName("pattern"); + const type = rustTypeName(node.childForFieldName("type")); + if (pattern?.type === "identifier" && type) bindings.set(scopePath, pattern.text, type); + return; + } + if (node.type !== "let_declaration") return; + const pattern = node.childForFieldName("pattern"); + if (pattern?.type !== "identifier") return; + const type = + rustTypeName(node.childForFieldName("type")) ?? rustConstructorType(node.childForFieldName("value")); + if (type) bindings.set(scopePath, pattern.text, type); +} + function handleGo(node: Parser.SyntaxNode, scope: string[], bindings: FileBindings): void { const scopePath = scope.join("."); if (node.type === "var_spec") { diff --git a/src/graph/build.ts b/src/graph/build.ts index b3094051d..06b992eae 100644 --- a/src/graph/build.ts +++ b/src/graph/build.ts @@ -33,7 +33,7 @@ import { import { writeFingerprint } from "./fingerprint.js"; import { seedGraph, type SeedResult } from "./seed.js"; import { filterByOnlyDirs, listSourceStats } from "./source-files.js"; -import { resolveEdges, type GoModule } from "./resolve.js"; +import { resolveEdges, type GoModule, type RustCrate } from "./resolve.js"; import { enrichGraph, type EnrichStats } from "./enrich.js"; import { readGraph, writeGraph, wiringPath } from "./write.js"; import { writeCards, writeIndex, writeCovers, type CardStats } from "./cards.js"; @@ -148,6 +148,47 @@ function readGoModules(root: string, repoFiles: string[]): GoModule[] { return mods; } +/** Every Cargo package in the repo, keyed by its package name and the directory + * containing Cargo.toml. Cargo files can contain several unrelated `name` keys + * (`[[bin]]`, dependencies, package metadata), so only `[package]` contributes. + * Inline-table dependency renames are retained for resolution from the owning + * crate; other TOML dependency forms remain deliberately unsupported. */ +function readCargoCrates(root: string, repoFiles: string[]): RustCrate[] { + const crates: RustCrate[] = []; + for (const f of repoFiles) { + if (basename(f) !== "Cargo.toml") continue; + try { + let section = ""; + let name: string | null = null; + const aliases: Record = {}; + for (const line of readFileSync(f, "utf8").split(/\r?\n/)) { + const header = line.match(/^\s*\[{1,2}\s*([^\]]+?)\s*\]{1,2}\s*(?:#.*)?$/); + if (header) { + section = header[1]; + continue; + } + if (section === "package") { + const declared = line.match(/^\s*name\s*=\s*(["'])(.*?)\1\s*(?:#.*)?$/); + if (declared) name = declared[2]; + continue; + } + if (!/^(?:(?:dev-|build-)?dependencies|target\..+\.(?:dev-|build-)?dependencies)$/.test(section)) { + continue; + } + const dependency = line.match(/^\s*([A-Za-z0-9_-]+)\s*=\s*\{(.*)\}\s*(?:#.*)?$/); + const packageName = dependency?.[2].match(/(?:^|,)\s*package\s*=\s*(["'])(.*?)\1(?:\s*,|\s*$)/); + if (dependency && packageName) aliases[dependency[1]] = packageName[2]; + } + if (!name) continue; + const rel = relPosix(root, dirname(f)); + crates.push({ name, dir: rel === "" ? "." : rel, aliases }); + } catch { + /* unreadable Cargo.toml — skip this crate */ + } + } + return crates.sort((a, b) => a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0); +} + export async function buildGraph( dir: string, opts: GraphBuildOptions = {}, @@ -288,7 +329,10 @@ export async function buildGraph( files: entries, }); - const edges = resolveEdges(nodes, rawEdges, { goModules: readGoModules(root, repoFiles) }); + const edges = resolveEdges(nodes, rawEdges, { + goModules: readGoModules(root, repoFiles), + rustCrates: readCargoCrates(root, repoFiles), + }); // Guard 5 (minimum-substance): node counts aren't known until nodes are // assembled, so the merge-tiny-scopes-into-root guard runs here. diff --git a/src/graph/extract.ts b/src/graph/extract.ts index d0f9dff19..7101c76b5 100644 --- a/src/graph/extract.ts +++ b/src/graph/extract.ts @@ -15,12 +15,24 @@ import Java from "tree-sitter-java"; import Kotlin from "tree-sitter-kotlin"; import Swift from "tree-sitter-swift"; import PHP from "tree-sitter-php"; +// Deep import avoids the package's extensionless-main DEP0151 warning under ESM. +import Rust from "tree-sitter-rust/bindings/node/index.js"; import { basename } from "node:path"; import { contentHash } from "../util/id.js"; import { collectBindings, goReceiverVarOf, resolveRecvType, type FileBindings } from "./bindings.js"; import type { Kind, NodeV1, Relation } from "./types.js"; -export type Language = "typescript" | "tsx" | "python" | "go" | "java" | "kotlin" | "swift" | "php" | "r"; +export type Language = + | "typescript" + | "tsx" + | "python" + | "go" + | "java" + | "kotlin" + | "swift" + | "php" + | "r" + | "rust"; /** * Extension → the tree-sitter grammar that parses it, and the label a human expects @@ -57,6 +69,7 @@ const EXTENSIONS: ReadonlyArray<{ ext: string; grammar: Language; label: string // `entryFor` lower-cases the path before matching, so this one entry covers // both `.R` (the conventional case in real R codebases) and `.r`. { ext: ".r", grammar: "r", label: "r" }, + { ext: ".rs", grammar: "rust", label: "rust" }, ]; function entryFor(path: string): (typeof EXTENSIONS)[number] | undefined { @@ -120,6 +133,10 @@ export interface RawEdge { * no such member — and ONLY then, so a name defined as both a member and a * free function yields the member edge alone, exactly as Swift dispatches it. */ implicitSelf?: boolean; + /** The name-resolution domain an edge belongs to, when a language needs one to + * keep otherwise-ambiguous bare names apart (Rust: `crate::`/`self::`/`super::` + * paths and trait-owned names live in their own namespace — see resolve.ts). */ + lang?: Language; } export interface ExtractResult { @@ -263,6 +280,25 @@ const PHP_KINDS: Record = { enum_declaration: "enum", }; +// Rust: `trait_item` maps to "interface" — a trait is a contract of behaviour, the +// same reading PHP's trait_declaration gets from resolve.ts's kind filters — and +// `macro_definition` to "function" so a macro's call sites have something to land +// on. The name carries a trailing `!` (see describeRust), which is how a macro +// call site spells it too. +const RUST_KINDS: Record = { + function_item: "function", + function_signature_item: "function", + struct_item: "struct", + enum_item: "enum", + trait_item: "interface", + type_item: "type", + union_item: "struct", + macro_definition: "function", + mod_item: "module", + const_item: "constant", + static_item: "constant", +}; + const KINDS_BY_LANG: Record> = { typescript: TS_KINDS, tsx: TS_KINDS, @@ -273,6 +309,7 @@ const KINDS_BY_LANG: Record> = { kotlin: KOTLIN_KINDS, swift: SWIFT_KINDS, php: PHP_KINDS, + rust: RUST_KINDS, }; /** @@ -299,6 +336,10 @@ const CALL_TYPES: Record> = { "scoped_call_expression", ]), r: new Set(["call"]), + // Rust calls are two shapes: an ordinary `foo()` / `T::foo()` call_expression and + // `foo!()` macro_invocation. Both are real edges; the stdlib/`macro_rules` + // builtins are filtered at the call site (see rustSkipsCall). + rust: new Set(["call_expression", "macro_invocation"]), }; const FUNCTION_VALUE_TYPES = new Set([ @@ -321,6 +362,7 @@ const GRAMMARS: Record = { kotlin: Kotlin, swift: Swift, php: PHP.php, + rust: Rust, }; export interface WalkCtx { @@ -354,6 +396,19 @@ export interface WalkCtx { // for the method's whole body, only changing when a genuinely different // class is entered. Null outside any class, or for a class with no parent. rSuperClass: string | null; + // Rust: inside an `impl` block, whose members become methods of the impl'd type + // (there is no `class` node to hang them off — see walk()'s impl_item branch). + rustInImpl: boolean; + // Rust: that impl also names a trait (`impl Draw for Circle`), which makes every + // member public — a trait impl cannot have private members. + rustTraitImpl: boolean; + // Rust: inside `#[cfg(test)]`, where nothing is exported to the crate's users. + rustCfgTest: boolean; + // Rust: how many inline `mod name { … }` blocks enclose the current node. A + // `super::`/`self::` path inside one is relative to the INLINE module, not the + // file, so that many path segments are consumed before the specifier means + // what a file-level path would mean (see rustConsumeInlineModPrefix). + rustInlineModDepth: number; } /** A definition we're about to emit, normalized across the shapes we handle. */ @@ -425,6 +480,10 @@ export function extractFile(rel: string, source: string, lang: Language): Extrac rR6Access: null, rGenerics, rSuperClass: null, + rustInImpl: false, + rustTraitImpl: false, + rustCfgTest: false, + rustInlineModDepth: 0, }; // Every id minted this file, seeded with the file node's own id (`rel`) so a // top-level definition can never collide with it. Threaded as its own @@ -433,6 +492,10 @@ export function extractFile(rel: string, source: string, lang: Language): Extrac // when it's actually accidental shared mutable state across the whole walk. const minted = new Set([rel]); walkNamedChildren(root.namedChildren, ctx, nodes, rawEdges, minted); + // `impl Trait for Type` is a trait *implementation*, not a subclass relationship + // the walk can see: the type and the trait sit in two different top-level items. + // Sweep the finished node list once so the edge can point at the type node. + if (lang === "rust") rawEdges.push(...rustHeritageEdges(root, nodes, rel)); // nodes[0] is the file node; the rest are its symbols. Index the module-level // residual on the file node so a term outside every symbol still surfaces it. nodes[0].body_text = fileResidual(source, nodes.slice(1)); @@ -602,7 +665,9 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd ? swiftExported(node) : ctx.lang === "php" ? phpExported(node) - : tsExported(node), + : ctx.lang === "rust" + ? rustExported(node, ctx) + : tsExported(node), origin: "ast", body_hash: contentHash(desc.hashNode.text), body_text: searchBody(desc.hashNode.text), @@ -615,6 +680,15 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd }); // structural containment edges.push({ source: ctx.parentId, relation: "contains", targetId: id, file: ctx.rel }); + // A bodyless `mod x;` declares a module AND imports its file (`x.rs` or + // `x/mod.rs`) — the depth tier mints both from the same item, never one at + // the cost of the other. `#[path = "…"]` retargets the import somewhere we + // cannot follow, which rustImportSpecifiers reports by yielding nothing. + if (ctx.lang === "rust" && node.type === "mod_item" && !node.childForFieldName("body")) { + for (const spec of rustImportSpecifiers(node, ctx.scope, ctx.rustInlineModDepth)) { + edges.push({ source: ctx.rel, relation: "imports", specifier: spec, file: ctx.rel, lang: "rust" }); + } + } // class heritage — in Java an interface may also `extends`, and a record/enum // may `implements`, so every type declaration is a heritage site, not just a class. const javaTypeDecl = ctx.lang === "java" && JAVA_TYPE_KINDS.has(desc.kind); @@ -625,8 +699,12 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd if (ctx.lang === "php") edges.push(...phpAttributeReferenceEdges(node, id, ctx)); if (ctx.lang === "java") edges.push(...javaAnnotationReferenceEdges(node, id, ctx)); + // Rust: a trait body is the interface reading of a type, so its members take + // the trait's name as their owner — that's what lets `impl Draw for Circle`'s + // `draw()` resolve against `Circle::draw`'s owner-qualified index entry. + const rustTraitDecl = ctx.lang === "rust" && desc.kind === "interface"; const enclosingClass = - desc.kind === "class" || javaTypeDecl || kotlinTypeDecl || swiftTypeDecl + desc.kind === "class" || javaTypeDecl || kotlinTypeDecl || swiftTypeDecl || rustTraitDecl ? desc.name : isGoMethod ? goReceiverType(node) @@ -661,6 +739,16 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd ? swiftSuperClassName(node) : null : ctx.rSuperClass, + // A Rust `mod` is the one definition whose children need extra lexical + // context: `#[cfg(test)]` suppresses exports for the whole subtree, and + // each inline module deepens the scope that `self::`/`super::` paths are + // relative to. Inherited unchanged for every other definition kind. + ...(ctx.lang === "rust" && node.type === "mod_item" + ? { + rustCfgTest: ctx.rustCfgTest || hasRustAttribute(node, "cfg", "test"), + rustInlineModDepth: ctx.rustInlineModDepth + 1, + } + : {}), }; walkNamedChildren(node.namedChildren, childCtx, out, edges, minted); return; @@ -698,6 +786,31 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd } } + // Rust `impl` blocks: the grammar gives them no name of their own, and the type + // they extend may be spelled several ways (`Circle`, `&Circle`, `Circle`), so + // the owner is read off the `type` field. Members descend with the owner as their + // enclosing class — that is what makes them `method` nodes owned by `Circle` + // rather than free functions, and what `Self::new()` / `self.area()` resolve + // against. `rustTraitImpl` marks the members public: a trait impl has no privacy. + if (ctx.lang === "rust" && node.type === "impl_item") { + const owner = rustTypeName(node.childForFieldName("type")); + if (owner) { + const childCtx: WalkCtx = { + ...ctx, + scope: [...ctx.scope, owner], + enclosingClass: owner, + rustInImpl: true, + rustTraitImpl: node.childForFieldName("trait") !== null, + }; + for (const child of node.namedChildren) walk(child, childCtx, out, edges, minted); + return; + } + } + + // Rust inline `mod name { … }` is minted as a module node by describeRust + // (kind "module"), so the generic definition branch above handles it — cfg(test) + // suppression and inline-mod depth threading happen in its childCtx. + // not a definition — capture calls/imports/references, then descend with the same context // R's `call` node is also its ONLY vehicle for library()/require()/source() — // there's no separate import-statement grammar construct to key off, so isImport @@ -705,12 +818,26 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd // captured as a (harmlessly unresolvable, but wrong) `calls` edge instead. const callTypes = CALL_TYPES[ctx.lang]; if (isImport(node, ctx.lang)) { - const spec = importSpecifier(node, ctx.lang); - if (spec) edges.push({ source: ctx.rel, relation: "imports", specifier: spec, file: ctx.rel }); + // Rust `use` re-exports and aliases expand to several specifiers per + // declaration (`use a::{b, c as d};`), so the import branch is a list. + const specs = + ctx.lang === "rust" + ? rustImportSpecifiers(node, ctx.scope, ctx.rustInlineModDepth) + : [importSpecifier(node, ctx.lang)]; + for (const spec of specs) { + if (!spec) continue; + edges.push({ + source: ctx.rel, + relation: "imports", + specifier: spec, + file: ctx.rel, + ...(ctx.lang === "rust" ? { lang: ctx.lang } : {}), + }); + } // Imported identifiers are declarations, not uses. The import-binding pass // above already recorded them, so do not descend and emit false references. return; - } else if (callTypes.has(node.type)) { + } else if (callTypes.has(node.type) && !rustSkipsCall(node, ctx.lang)) { // R6Class(...) / a Phase-5 mixin list(...) is already consumed by its // enclosing binary_operator as the class definition (see describeR) — the // walk still reaches this SAME call node again, recursing generically to @@ -720,7 +847,7 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd const consumedCallee = ctx.lang === "r" && node.type === "call" ? rCalleeName(node) : null; const isConsumedRClassCall = consumedCallee === "R6Class" || (consumedCallee === "list" && rIsMixinContainer(node)); - const callee = isConsumedRClassCall ? null : calleeName(node, ctx.lang); + const callee = isConsumedRClassCall ? null : calleeName(node, ctx.lang, ctx.rustInlineModDepth); if (callee) { const callEdge: RawEdge = { source: ctx.parentId, @@ -729,6 +856,8 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd viaMember: callee.viaMember, file: ctx.rel, ...(callee.kinds ? { kinds: callee.kinds } : {}), + ...(callee.specifier ? { specifier: callee.specifier } : {}), + ...(ctx.lang === "rust" ? { lang: ctx.lang } : {}), }; // Overloading languages: the call site's argument count, to pick the right // overload (see RawEdge.argCount). @@ -765,7 +894,9 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd implicitSelf: true, }); } else { - const recvType = resolveRecvType(callee.receiver, ctx); + // Rust reads the receiver type straight off the callee (`Circle::area`, + // `Self::new`) before falling back to the file's variable bindings. + const recvType = callee.recvType ?? resolveRecvType(callee.receiver, ctx); edges.push(recvType ? { ...callEdge, recvType } : callEdge); } } @@ -780,7 +911,7 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd } return; } else if ( - node.type === "identifier" && + (node.type === "identifier" || (ctx.lang === "rust" && node.type === "type_identifier")) && !isDirectCallee(node, callTypes) && !isDeclarationName(node) ) { @@ -792,6 +923,7 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd name: imported.name, specifier: imported.specifier, file: ctx.rel, + ...(ctx.lang === "rust" ? { lang: ctx.lang } : {}), }); } } @@ -872,6 +1004,34 @@ function collectImportedSymbols( return out; } if (lang === "php") return collectPhpImportedSymbols(root); + // Rust: only UpperCamelCase names are recorded. A lower-case `use` binding is a + // module or a function, and Rust's module paths are resolved separately (see + // rustImportSpecifiers) — treating those as symbol references would make every + // `use std::fmt;` look like a use of a symbol named `fmt`. + if (lang === "rust") { + const out = new Map(); + const visit = (node: Parser.SyntaxNode): void => { + if (node.type === "use_declaration") { + const argument = node.childForFieldName("argument"); + if (!argument) return; + // Same prefix consumption as rustImportSpecifiers: a `use` inside an + // inline module resolves `self::`/`super::` against the inline scope, + // which never forms its own module file. + const depth = rustInlineModDepth(node); + for (const leaf of rustUseLeaves(argument)) { + const specifier = rustConsumeInlineModPrefix(leaf.specifier, depth); + if (!specifier) continue; + if (leaf.name && leaf.local && /^[A-Z]/.test(leaf.name) && /^[A-Z]/.test(leaf.local)) { + out.set(leaf.local, { name: leaf.name, specifier }); + } + } + return; + } + for (const child of node.namedChildren) visit(child); + }; + visit(root); + return out; + } return new Map(); } @@ -1107,6 +1267,7 @@ function isDeclarationName(node: Parser.SyntaxNode): boolean { * TS arrow-consts. */ function describe(node: Parser.SyntaxNode, ctx: WalkCtx): DefDescriptor | null { if (ctx.lang === "go") return describeGo(node, ctx); + if (ctx.lang === "rust") return describeRust(node, ctx); if (ctx.lang === "r") return describeR(node, ctx); if (ctx.lang === "java") return describeJava(node, ctx); if (ctx.lang === "kotlin") return describeKotlin(node, ctx); @@ -2183,7 +2344,18 @@ function javaTypeParameterNames(decl: Parser.SyntaxNode): ReadonlySet { function calleeName( node: Parser.SyntaxNode, lang: Language, -): { name: string; viaMember: boolean; receiver?: string; kinds?: Kind[] } | null { + inlineModDepth = 0, +): { + name: string; + viaMember: boolean; + receiver?: string; + kinds?: Kind[]; + recvType?: string; + specifier?: string; +} | null { + // Rust: the callee shapes are unlike any other language's between them — + // macro_name!(), field_expression receivers, Self::/Type::/module:: paths. + if (lang === "rust") return rustCalleeName(node, inlineModDepth); // Java first: `method_invocation` has NO `function` field (it splits the callee // into `object` + `name`), so the shared lookup below would return null for every // Java call site and the language would extract nodes with no call edges at all. @@ -2484,6 +2656,11 @@ if (lang === "kotlin") return node.type === "import_header"; // PHP: one edge per imported symbol — the clause leaf inside a (possibly // grouped) `use A\B, C\D;` / `use A\{B, C};` declaration. if (lang === "php") return node.type === "namespace_use_clause"; + // Rust: `use` declarations. A bodyless `mod foo;` also imports its module file + // (`foo.rs` / `foo/mod.rs`), but describeRust mints every named `mod` as a + // module node first, so that import edge is emitted in the definition branch + // (same node, both facts) and never reaches this check. + if (lang === "rust") return node.type === "use_declaration"; return node.type === "import_statement" || node.type === "import_from_statement"; } @@ -2559,3 +2736,264 @@ function tsExported(node: Parser.SyntaxNode): boolean { } return false; } + +// --------------------------------------------------------------------------- +// Rust: shared helpers for the depth-tier extractor above. +// --------------------------------------------------------------------------- + +interface RustUseLeaf { + specifier: string; + name?: string; + local?: string; +} + +function rustUseLeaves(node: Parser.SyntaxNode, prefix = ""): RustUseLeaf[] { + if (node.type === "use_list") return node.namedChildren.flatMap((child) => rustUseLeaves(child, prefix)); + if (node.type === "scoped_use_list") { + const path = node.childForFieldName("path")?.text; + const list = node.namedChildren.find((child) => child.type === "use_list"); + return path && list ? rustUseLeaves(list, rustJoinPath(prefix, path)) : []; + } + if (node.type === "use_as_clause") { + const path = node.childForFieldName("path"); + const local = node.childForFieldName("alias")?.text; + const name = path ? rustPathLastName(path) : null; + return path && local && name + ? [{ specifier: rustJoinPath(prefix, path.text), name, local }] + : []; + } + if (node.type === "use_wildcard" || node.type === "self") { + return [{ specifier: rustJoinPath(prefix, node.text) }]; + } + if (node.type === "identifier" || node.type === "scoped_identifier") { + const name = rustPathLastName(node); + return name + ? [{ specifier: rustJoinPath(prefix, node.text), name, local: name }] + : []; + } + return []; +} + +function rustJoinPath(prefix: string, path: string): string { + return prefix ? `${prefix}::${path}` : path; +} + +function rustPathLastName(node: Parser.SyntaxNode): string | null { + return node.childForFieldName("name")?.text ?? (node.type === "identifier" ? node.text : null); +} + + +function describeRust(node: Parser.SyntaxNode, ctx: WalkCtx): DefDescriptor | null { + const mapped = ctx.kinds[node.type]; + if (!mapped) return null; + const rawName = node.childForFieldName("name")?.text; + if (!rawName) return null; + const name = node.type === "macro_definition" ? `${rawName}!` : rawName; + const directImplMember = + ctx.rustInImpl && ctx.enclosingKind !== "function" && ctx.enclosingKind !== "method"; + const kind = + mapped === "function" && + node.type !== "macro_definition" && + (directImplMember || ctx.enclosingKind === "interface") + ? "method" + : mapped; + // A macro's "body" is a token_tree, not a `body` field — the signature must + // stop before it (`macro_rules! log { ()`), never swallow the expansion. + const body = + node.type === "macro_definition" + ? node.descendantsOfType("token_tree")[0] + : node.childForFieldName("body"); + const value = node.type === "const_item" || node.type === "static_item" ? node.childForFieldName("value") : null; + const headerEnd = body ? body.startIndex : value ? value.startIndex : node.type === "mod_item" ? node.childForFieldName("name")?.endIndex ?? node.endIndex : node.endIndex; + return { name, kind, headerEnd, hashNode: node }; +} + +function rustTypeName(node: Parser.SyntaxNode | null | undefined): string | null { + if (!node) return null; + if (node.type === "generic_type" || node.type === "reference_type") { + return rustTypeName(node.childForFieldName("type")); + } + if (node.type === "scoped_type_identifier") { + return node.childForFieldName("name")?.text ?? null; + } + if (node.type === "type_identifier" || node.type === "identifier") return node.text; + return null; +} + +function rustHeritageEdges(root: Parser.SyntaxNode, nodes: NodeV1[], rel: string): RawEdge[] { + const impls: Array<{ owner: string; trait: string }> = []; + const visit = (node: Parser.SyntaxNode): void => { + if (node.type === "impl_item") { + const owner = rustTypeName(node.childForFieldName("type")); + const trait = rustTypeName(node.childForFieldName("trait")); + if (owner && trait) impls.push({ owner, trait }); + } + for (const child of node.namedChildren) visit(child); + }; + visit(root); + const typeKinds = new Set(["class", "struct", "interface", "type", "enum"]); + return impls.flatMap(({ owner, trait }) => { + const target = nodes.find((node) => node.name === owner && typeKinds.has(node.kind)); + return target + ? [{ source: target.id, relation: "extends", name: trait, file: rel, lang: "rust" as const }] + : []; + }); +} + + +function rustCalleeName( + node: Parser.SyntaxNode, + inlineModDepth: number, +): { name: string; viaMember: boolean; receiver?: string; recvType?: string; specifier?: string } | null { + if (node.type === "macro_invocation") { + // `log_it!()` names a macro, but `crate::macros::local_log!()` is a scoped + // path — the bang namespace keys off the LEAF name, never the whole path. + const macro = node.childForFieldName("macro"); + const name = macro ? rustPathLastName(macro) : null; + return name ? { name: `${name}!`, viaMember: false } : null; + } + let fn = node.childForFieldName("function"); + while (fn?.type === "generic_function") fn = fn.childForFieldName("function"); + if (fn?.type === "identifier") return { name: fn.text, viaMember: false }; + if (fn?.type === "field_expression") { + const field = fn.childForFieldName("field"); + if (field?.type !== "field_identifier") return null; + const value = fn.childForFieldName("value"); + const receiver = value?.type === "identifier" || value?.type === "self" ? value.text : undefined; + return { name: field.text, viaMember: true, receiver }; + } + if (fn?.type !== "scoped_identifier") return null; + const name = fn.childForFieldName("name")?.text; + const path = fn.childForFieldName("path"); + if (!name || !path) return null; + if (path.type === "identifier" && path.text === "Self") { + return { name, viaMember: true, receiver: "self" }; + } + if (path.type === "identifier" && /^[A-Z]/.test(path.text)) { + return { name, viaMember: true, recvType: path.text }; + } + // Inside an inline module the leading `super::`/`self::` hops are relative to + // the INLINE scope, which lives in this same file — consumed there, the rest + // of the path is an ordinary module specifier (or none at all, when the whole + // path was hops and the call is bare once the inline scope is resolved away). + const specifier = rustConsumeInlineModPrefix(path.text, inlineModDepth); + return specifier + ? { name, viaMember: false, specifier } + : { name, viaMember: false }; +} + + +/** How many inline `mod name { … }` blocks enclose `node`, read off the syntax + * tree itself (imports are also collected from contexts with no WalkCtx — see + * collectImportedSymbols — so the depth cannot live only on the walk context). */ +function rustInlineModDepth(node: Parser.SyntaxNode): number { + let depth = 0; + for (let parent = node.parent; parent; parent = parent.parent) { + if (parent.type === "mod_item" && parent.childForFieldName("body")) depth++; + } + return depth; +} + +/** + * What a `self::`/`super::`-rooted path means once the enclosing INLINE modules + * are resolved away — they live in this same file, so each one absorbs one hop: + * + * depth 1, "super::helper" -> null (super reaches the inline mod's + * parent, which is this file's module) + * depth 1, "super::super::x" -> "super::x" + * any depth, "crate::x" -> unchanged (crate is absolute) + * + * Null means the path resolves INSIDE the file's own subtree — a bare name + * after consumption, or a `self::`-rooted path (self of an inline mod is the + * inline mod itself, whose contents never form a separate module file). + */ +function rustConsumeInlineModPrefix(specifier: string, depth: number): string | null { + if (depth === 0) return specifier; + const segments = specifier.split("::"); + if (segments[0] === "self") return null; + let supers = 0; + while (segments[supers] === "super") supers++; + if (supers === 0) return specifier; + if (supers <= depth) return null; + return segments.slice(depth).join("::"); +} + +function rustImportSpecifiers(node: Parser.SyntaxNode, scope: string[], inlineModDepth: number): string[] { + if (node.type === "mod_item") { + // `#[path = "…"]` sends the module file outside the crate's module tree, + // which we cannot follow — no import edge rather than a wrong one. + if (hasRustAttribute(node, "path")) return []; + const name = node.childForFieldName("name")?.text; + // `self::` roots the path at the DECLARING module's directory: a top-level + // `mod x;` means `/x.rs`, and a mod nested in an inline module + // spells out the whole inline scope (`self::tests::x`). + return name ? [`self::${[...scope, name].join("::")}`] : []; + } + const argument = node.childForFieldName("argument"); + if (!argument) return []; + return rustUseLeaves(argument) + .map((leaf) => rustConsumeInlineModPrefix(leaf.specifier, inlineModDepth)) + .filter((specifier): specifier is string => specifier !== null); +} + + +const RUST_BUILTIN_MACROS = new Set([ + "println", "print", "eprintln", "eprint", "format", "format_args", "write", "writeln", "vec", "panic", + "assert", "assert_eq", "assert_ne", "debug_assert", "debug_assert_eq", "debug_assert_ne", "todo", + "unimplemented", "unreachable", "matches", "dbg", "include", "include_str", "include_bytes", "concat", + "stringify", "env", "option_env", "cfg", "line", "file", "column", "compile_error", "module_path", + "thread_local", "macro_rules", +]); + +function rustSkipsCall(node: Parser.SyntaxNode, lang: Language): boolean { + if (lang !== "rust" || node.type !== "macro_invocation") return false; + // The LEAF name decides (`std::println!` is the same builtin as `println!`), + // matching how rustCalleeName names the edge. + const macro = node.childForFieldName("macro"); + const name = macro ? rustPathLastName(macro) : null; + return name !== null && RUST_BUILTIN_MACROS.has(name); +} + +function hasRustAttribute(node: Parser.SyntaxNode, name: string, argument?: string): boolean { + let sibling = node.previousNamedSibling; + while (sibling?.type === "attribute_item") { + const attribute = sibling.namedChildren.find((child) => child.type === "attribute"); + const identifiers = attribute?.descendantsOfType("identifier") ?? []; + // The argument is compared as TEXT, not by identifier membership: + // `#[cfg(test)]` and `#[cfg(not(test))]` descend to the same identifier set, + // and only the former is "inside cfg(test)". Whitespace is folded away so a + // multi-line attribute still reads as one token tree. + const tokenTree = attribute?.namedChildren.find((child) => child.type === "token_tree"); + const argumentMatches = !argument || tokenTree?.text.replace(/\s+/g, "") === `(${argument})`; + if (identifiers[0]?.text === name && argumentMatches) { + return true; + } + sibling = sibling.previousNamedSibling; + } + return false; +} + +function rustExported(node: Parser.SyntaxNode, ctx: WalkCtx): boolean { + if (ctx.rustCfgTest) return false; + if (node.type === "macro_definition") return hasRustAttribute(node, "macro_export"); + if (ctx.enclosingKind === "function" || ctx.enclosingKind === "method") return false; + // Items declared inside a function body are local (never exported), but the + // innermost enclosing DEFINITION stops being the function once an inline `mod` + // sits between them — the module's own walk context says "module". Walk the + // syntax as well, so nesting cannot launder `pub` into an export. + for (let parent = node.parent; parent; parent = parent.parent) { + if (parent.type === "function_item" || parent.type === "function_signature_item") return false; + } + if (ctx.rustInImpl && ctx.rustTraitImpl) return true; + if (ctx.enclosingKind === "interface") { + let parent = node.parent; + while (parent && parent.type !== "trait_item") parent = parent.parent; + return parent ? rustHasPublicVisibility(parent) : false; + } + return rustHasPublicVisibility(node); +} + +function rustHasPublicVisibility(node: Parser.SyntaxNode): boolean { + const visibility = node.namedChildren.find((child) => child.type === "visibility_modifier"); + return visibility !== undefined && !visibility.namedChildren.some((child) => child.type === "self"); +} diff --git a/src/graph/generic.ts b/src/graph/generic.ts index b599deb77..050a01e41 100644 --- a/src/graph/generic.ts +++ b/src/graph/generic.ts @@ -39,9 +39,10 @@ export interface GenericLang { } /** The breadth registry. Add a row + a queries/.scm to support a language. - * Extensions here must NOT collide with the depth tier's EXTENSIONS (extract.ts). */ + * When a language later gains a hand-written (depth-tier) extractor, its row + * moves out of this list wholesale — a stale row is dead weight that misstates + * the tier split (PHP's was removed in #157, Rust's in PR #59). */ export const GENERIC_LANGS: readonly GenericLang[] = [ - { name: "rust", exts: [".rs"], wasm: "rust" }, { name: "java", exts: [".java"], wasm: "java" }, { name: "c", exts: [".c", ".h"], wasm: "c" }, { name: "cpp", exts: [".cpp", ".cc", ".cxx", ".hpp", ".hh"], wasm: "cpp" }, diff --git a/src/graph/resolve.ts b/src/graph/resolve.ts index c78a61e6c..7d6144fd6 100644 --- a/src/graph/resolve.ts +++ b/src/graph/resolve.ts @@ -15,7 +15,7 @@ import { posix } from "node:path"; import { toPosixPath } from "../util/paths.js"; import type { EdgeV1, Kind, NodeV1, Relation } from "./types.js"; -import { languageOf, type RawEdge } from "./extract.js"; +import { languageOf, type Language, type RawEdge } from "./extract.js"; import { genericLangOf } from "./generic.js"; const IMPORT_EXTS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".py"]; @@ -91,11 +91,22 @@ export interface GoModule { dir: string; } +/** A Cargo package discovered in the repo: its `[package]` name and the + * repo-relative directory containing Cargo.toml. */ +export interface RustCrate { + name: string; + dir: string; + aliases?: Record; +} + export interface ResolveOptions { /** The Go modules found in the repo. Enables mapping Go import package paths * (`example.com/app/pkg/util`) to the in-repo directory they name, relative to the * owning module's `go.mod` location. Empty/absent → Go imports stay external strings. */ goModules?: GoModule[]; + /** Cargo packages found in the repo. Enables `crate::` and workspace-crate + * module paths to resolve against source file nodes already present in byId. */ + rustCrates?: RustCrate[]; } export function resolveEdges( @@ -105,6 +116,11 @@ export function resolveEdges( ): EdgeV1[] { const byId = new Map(nodes.map((n) => [n.id, n])); const globalName = new Map(); + // Rust names live in their own domain: a `use`d symbol, a `mod` path and a bare + // crate-local function are not reachable from another language's bare-name call, + // and a unique Rust name must not bind a Python or TS call either (rustGlobalName + // is consulted whenever the edge's own lang is "rust" — see resolveName). + const rustGlobalName = new Map(); const perFileName = new Map>(); // Owner-qualified method index: "Owner.method" → candidate method nodes, for // typed member-call resolution (recvType + name → a specific class's method). @@ -120,10 +136,6 @@ export function resolveEdges( // file node ids, so an `#include` reached through an `-I` dir (not relative to the // including file) still resolves to the in-repo header when the suffix is unique. const cFilesBySuffix = new Map(); - // Rust crate roots: the directory holding a `lib.rs` or `main.rs`. A `use crate::a::b` - // resolves to `/a/b.rs` (or `.../a/b/mod.rs`), relative to the crate the - // importing file belongs to — so a workspace with several crates stays unambiguous. - const rustCrateRoots: string[] = []; // PHP class resolution: a file's path-suffix (`Models/User.php`, `User.php`) → its file // node ids. A `use App\Models\User` names a PSR-4 class whose file mirrors the namespace // tail under some (unknown) source root, so the suffix is the portable key. @@ -150,14 +162,10 @@ export function resolveEdges( const parts = toPosixPath(n.path).split("/"); for (let i = 0; i < parts.length; i++) push(phpFilesBySuffix, parts.slice(i).join("/"), n.id); } - { - const p = toPosixPath(n.path); - if (p === "lib.rs" || p === "main.rs") rustCrateRoots.push(""); - else if (p.endsWith("/lib.rs") || p.endsWith("/main.rs")) rustCrateRoots.push(posix.dirname(p)); - } continue; } - push(globalName, n.name, n); + if (isRustPath(n.path)) push(rustGlobalName, n.name, n); + else push(globalName, n.name, n); let fileMap = perFileName.get(n.path); if (!fileMap) perFileName.set(n.path, (fileMap = new Map())); push(fileMap, n.name, n); @@ -213,7 +221,7 @@ export function resolveEdges( : C_EXT.test(e.file) ? resolveCInclude(e.specifier, e.file, byId, cFilesBySuffix) : e.file.endsWith(".rs") - ? resolveRustUse(e.specifier, e.file, byId, rustCrateRoots) + ? resolveRustImport(e.specifier, e.file, byId, opts.rustCrates ?? []) : e.file.endsWith(".php") ? resolvePhpUse(e.specifier, phpFilesBySuffix) : resolveImport(e.specifier, e.file, byId); @@ -222,7 +230,7 @@ export function resolveEdges( // `implements` also resolves to a `trait` — PHP models trait composition // (`use SomeTrait;`) as an implements edge, and a trait is a valid target. const kinds: Kind[] = e.relation === "implements" ? ["interface", "trait"] : ["class", "interface"]; - const hit = resolveName(e.name!, e.file, kinds, perFileName, globalName); + const hit = resolveName(e.name!, e.file, kinds, perFileName, globalName, rustGlobalName, e.lang); // an unresolved base is usually an external/imported type — keep the name. add(e.source, hit?.id ?? e.name!, e.relation, hit?.confidence ?? "inferred"); } else if (e.relation === "references" && e.name) { @@ -232,14 +240,16 @@ export function resolveEdges( // same-named symbol elsewhere in the repo cannot become a false edge. const targetFile = e.file.endsWith(".php") ? resolvePhpUse(e.specifier, phpFilesBySuffix) - : resolveImport(e.specifier, e.file, byId); + : e.file.endsWith(".rs") + ? resolveRustImport(e.specifier, e.file, byId, opts.rustCrates ?? []) + : resolveImport(e.specifier, e.file, byId); if (!byId.has(targetFile)) continue; // external or unresolved module const candidates = perFileName.get(targetFile)?.get(e.name) ?? []; if (candidates.length === 1) add(e.source, candidates[0].id, "references", "extracted"); } else if (e.file.endsWith(".php") && byId.get(e.source)?.origin === "ast") { // PHP attribute without a `use` import (same-file or globally unique class). const refKinds: Kind[] = ["class", "interface", "trait", "enum"]; - const hit = resolveName(e.name, e.file, refKinds, perFileName, globalName); + const hit = resolveName(e.name, e.file, refKinds, perFileName, globalName, rustGlobalName); if (hit && hit.id !== e.source) add(e.source, hit.id, "references", hit.confidence); } else if (e.file.endsWith(".java") && byId.get(e.source)?.origin === "ast") { // Java annotation without a specifier (same-file or globally unique @@ -252,7 +262,7 @@ export function resolveEdges( // JsonAdapter`). Unresolved targets keep the bare name, matching // heritage, rather than dropping the way PHP attributes do. const refKinds: Kind[] = ["interface"]; - const hit = resolveName(e.name, e.file, refKinds, perFileName, globalName); + const hit = resolveName(e.name, e.file, refKinds, perFileName, globalName, rustGlobalName); const anno = hit ? byId.get(hit.id) : undefined; if (hit && hit.id !== e.source && anno?.signature?.includes("@interface")) add(e.source, hit.id, "references", hit.confidence); @@ -264,13 +274,29 @@ export function resolveEdges( // generic origin so depth-tier references (which always carry a specifier) are // provably untouched. const refKinds: Kind[] = ["class", "interface", "struct", "enum", "type", "module"]; - const hit = resolveName(e.name, e.file, refKinds, perFileName, globalName); + const hit = resolveName(e.name, e.file, refKinds, perFileName, globalName, rustGlobalName); if (hit && hit.id !== e.source) add(e.source, hit.id, "references", hit.confidence); } } else if (e.relation === "calls") { + // Rust scoped calls (`crate::worker::run()`, `some_mod::run()`) name the + // target's FILE outright — resolve the module path, then require a unique + // function of that name in it. Bare-name fallback is deliberately absent: + // a scoped path that resolves nowhere is dropped, never stripped to the + // callee's bare name (which would bind a same-named local function). + if (e.lang === "rust" && e.specifier) { + const targetFile = e.file.endsWith(".rs") + ? resolveRustImport(e.specifier, e.file, byId, opts.rustCrates ?? []) + : e.specifier; + if (!byId.has(targetFile)) continue; + const candidates = (perFileName.get(targetFile)?.get(e.name!) ?? []).filter( + (candidate) => candidate.kind === "function", + ); + if (candidates.length === 1) add(e.source, candidates[0].id, "calls", "extracted"); + continue; + } if (e.viaMember) { if (!e.recvType) continue; - const hit = resolveTypedMember(e.recvType, e.name!, e.file, ownerMethod, classParents, classTraits, e.argCount); + const hit = resolveTypedMember(e.recvType, e.name!, e.file, ownerMethod, classParents, classTraits, e.argCount, e.lang); if (hit === "ambiguous") continue; // drop — never guess past an ambiguous owner if (hit) { add(e.source, hit.id, "calls", hit.confidence); @@ -317,7 +343,7 @@ export function resolveEdges( : e.file.endsWith(".java") ? ["class", "struct", "enum", "interface"] : ["function"]); - let hit = resolveName(e.name!, e.file, callKinds, perFileName, globalName); + let hit = resolveName(e.name!, e.file, callKinds, perFileName, globalName, rustGlobalName, e.lang); // Python is the Java case without the `new` to mark it: `Widget()` is an // ordinary call node, so a constructor edge dies against the function-only // index. Java can widen to types outright; Python has free functions, so @@ -325,10 +351,10 @@ export function resolveEdges( // not a swap — types are tried only once functions have found nothing, and // resolveName's same-file-then-unique-global rule still drops the ambiguous. if (!hit && PY_EXT.test(e.file)) { - hit = resolveName(e.name!, e.file, PY_CTOR_KINDS, perFileName, globalName); + hit = resolveName(e.name!, e.file, PY_CTOR_KINDS, perFileName, globalName, rustGlobalName); } if (!hit && SWIFT_EXT.test(e.file)) { - hit = resolveName(e.name!, e.file, SWIFT_CTOR_KINDS, perFileName, globalName); + hit = resolveName(e.name!, e.file, SWIFT_CTOR_KINDS, perFileName, globalName, rustGlobalName); } if (hit) add(e.source, hit.id, "calls", hit.confidence); // drop unresolved calls (too noisy) } @@ -342,6 +368,10 @@ function push(map: Map, key: string, val: T): void { else map.set(key, [val]); } +function isRustPath(path: string): boolean { + return languageOf(path) === "rust"; +} + /** Derive a method's owner from its dotted id when extract did not stamp `owner` * (PHP trait/interface methods today). `app.php#Loggable.log` → `Loggable`. */ function ownerFromMethodId(id: string): string | undefined { @@ -353,6 +383,11 @@ function ownerFromMethodId(id: string): string | undefined { /** * Resolve a bare symbol name: same-file match first (certain → `extracted`), * else a unique cross-file match (→ `inferred`), else null (ambiguous/unknown). + * + * Rust edges resolve in their OWN domain: after the same-file tiers, only + * `rustGlobalName` is consulted — a unique Rust name must not bind (or be bound + * by) another language's bare-name call, and a Rust call must never land on a + * non-Rust definition. Ambiguity inside the Rust domain drops, as everywhere. */ function resolveName( name: string, @@ -360,6 +395,8 @@ function resolveName( kinds: Kind[], perFileName: Map>, globalName: Map, + rustGlobalName: Map, + lang?: Language, ): { id: string; confidence: EdgeV1["confidence"] } | null { const local = (perFileName.get(file)?.get(name) ?? []).filter((n) => kinds.includes(n.kind)); // Same-file requires a UNIQUE match, exactly as the cross-file branch below does. @@ -368,6 +405,12 @@ function resolveName( // first in document order — and labelled it `extracted`, i.e. certain. That is the // guess this module's header says it does not make. if (local.length === 1) return { id: local[0].id, confidence: "extracted" }; + if (lang === "rust") { + // Never fall through to the mixed global tier: a Rust name that isn't + // uniquely Rust stays unresolved rather than borrowing another language's. + const rustGlobal = (rustGlobalName.get(name) ?? []).filter((n) => kinds.includes(n.kind)); + return rustGlobal.length === 1 ? { id: rustGlobal[0].id, confidence: "inferred" } : null; + } // Cross-file: also require a language that could actually reach this one. // Without it a unique name match ANYWHERE in the repo wins, which is how a Go // builtin ended up resolving into a TypeScript test — see FAMILIES above. @@ -422,12 +465,28 @@ function resolveTypedMember( classParents: Map, classTraits: Map, argCount?: number, + lang?: Language, ): { id: string; confidence: EdgeV1["confidence"] } | "ambiguous" | null { const MAX_DEPTH = 3; const visited = new Set([recvType]); let frontier = [recvType]; for (let depth = 0; depth <= MAX_DEPTH && frontier.length; depth++) { + // Rust: `Type::method()` / `Self::method()` names the owner outright, so the + // owner-qualified index is exact — no same-file tiebreak to apply (the impl + // block and the call site are often in different files) and no trait/ancestor + // guesswork beyond the `impl Trait for Type` extends chain below. + if (lang === "rust") { + const level = frontier.flatMap((type) => + (ownerMethod.get(`${type}.${name}`) ?? []).filter((candidate) => isRustPath(candidate.path)), + ); + if (level.length > 1) return "ambiguous"; + if (level.length === 1) { + const candidate = level[0]; + return { id: candidate.id, confidence: candidate.path === file ? "extracted" : "inferred" }; + } + } for (const type of frontier) { + if (lang === "rust") continue; const all = ownerMethod.get(`${type}.${name}`)?.filter((c) => reachable(file, c.path)); if (all && all.length > 0) { const candidates = narrowByArity(all, argCount); @@ -582,51 +641,240 @@ function resolvePhpUse(fqn: string, bySuffix: Map): string { } /** - * Resolve a Rust `crate`-relative module path (`crate/a/b`, from `use crate::a::b::Item`) - * to the in-repo module file — `/a/b.rs` or `/a/b/mod.rs`, where - * the crate root is the `lib.rs`/`main.rs` directory the importing file lives under. The - * per-file crate root keeps a multi-crate workspace unambiguous. Not found or ambiguous - * (two roots, both `a/b.rs` and `a/b/mod.rs`) stays a `crate::…` string — never a guess. + * Resolve a Rust module path to the in-repo module file that declares it — or + * return the raw specifier when it names something outside the repo (an external + * crate) or something we cannot pin down (an ambiguous workspace name). Never a + * guess: every branch below either finds exactly one file or keeps the string. + * + * The path grammars, in resolution order: + * - `crate::a::b` — anchored at the OWNING crate's source root: the crate whose + * `src/` (or crate dir) contains the declaring file, per Cargo; with no Cargo + * data, the nearest directory holding a `lib.rs`/`main.rs` (the breadth tier's + * own root rule). A file under `tests/`/`benches/`/`examples/` is a separate + * crate when it IS that directory's root (`tests/foo.rs`), and its deeper + * support modules keep their paths raw — their declaring root is ambiguous. + * - `self::a` — the declaring module's own directory. + * - `super::…` — the declaring module's directory, one `dirname` per `super`. + * - `some_crate::a`— a Cargo workspace package by its `[package]` name + * (hyphen/underscore-folded; `{ package = "…" }` dependency aliases honored). + * - bare `mod x;` — single-segment paths try the declaring module's child dir. + * + * `x.rs` and `x/mod.rs` are both honored; `src/parser.rs`'s children live in + * `src/parser/` (the stem-dir rule), while crate-root files and `mod.rs` own + * their containing directory. */ -function resolveRustUse(spec: string, file: string, byId: Map, crateRoots: string[]): string { - const full = spec.replace(/^crate\/?/, ""); // "crate/a/b/Item" → "a/b/Item"; "crate" → "" - const fpath = toPosixPath(file); - // the crate this file belongs to: the longest root that contains it (workspace-safe) - const owning = crateRoots - .filter((r) => r === "" ? true : fpath === r || fpath.startsWith(`${r}/`)) - .sort((a, b) => b.length - a.length); - // No owning crate root (e.g. an integration test under `tests/`, whose `crate::` is the - // TEST binary's own root, not a lib) → do NOT search every crate in a workspace: that - // resolves `crate::util` to some unrelated crate's util.rs. Keep it a string instead. - if (owning.length === 0) return full === "" ? "crate" : `crate::${full.replace(/\//g, "::")}`; - const roots = [owning[0]]; - const segs = full === "" ? [] : full.split("/"); - const hitsFor = (rels: string[]): Set => { - const hits = new Set(); - for (const r of roots) for (const rel of rels) { - const cand = r === "" ? rel : `${r}/${rel}`; - if (byId.has(cand)) hits.add(cand); +function resolveRustImport( + spec: string, + file: string, + byId: Map, + crates: RustCrate[], +): string { + const segments = spec.split("::").filter(Boolean); + if (segments.length === 0) return spec; + + let baseDir: string; + let remaining: string[]; + let rootFile: string | null = null; + if (segments[0] === "crate") { + const crate = owningRustCrate(file, crates); + if (crate) { + if (isRustAuxiliaryCrateRoot(file, crates)) { + rootFile = toPosixPath(file); + baseDir = posix.dirname(rootFile); + } else if (rustAuxiliaryDir(file, crate)) { + // Deeper support under an auxiliary root (e.g. `tests/common/mod.rs`): + // its `crate::` names the test binary's root, which we cannot infer — + // keep the path a string rather than borrow the library crate's files. + return spec; + } else { + rootFile = rustCrateRoot(crate, byId); + baseDir = rustCrateSrcDir(crate); + } + } else { + // No Cargo package claims this file — no Cargo.toml anywhere, or a manifest + // whose `[package]` name never parsed. Fall back to the breadth tier's + // path-inferred root so `crate::` keeps resolving; a file outside every + // root (an integration test beside src/) owns its own test binary, so its + // `crate::…` deliberately stays a string. + const root = owningInferredRustRoot(file, inferredRustCrateRoots(byId)); + if (root === null) return spec; + rootFile = rustRootFile(root, byId); + baseDir = root; } - return hits; - }; - // Try the longest module prefix first, shrinking toward — but NOT past — the first - // segment. `crate::a::b::C` resolves as module `a/b` (C is the item); `crate::net` as - // module `net`. The first prefix naming exactly one in-repo file wins; two matches at a - // level are ambiguous → drop rather than guess. - for (let k = segs.length; k >= 1; k--) { - const modPath = segs.slice(0, k).join("/"); - const hits = hitsFor([`${modPath}.rs`, `${modPath}/mod.rs`]); - if (hits.size === 1) return [...hits][0]; - if (hits.size > 1) break; + if (!rootFile) return spec; + remaining = segments.slice(1); + } else if (segments[0] === "self") { + baseDir = rustModuleDir(file, crates); + remaining = segments.slice(1); + } else if (segments[0] === "super") { + baseDir = rustModuleDir(file, crates); + let index = 0; + while (segments[index] === "super") { + baseDir = posix.dirname(baseDir); + index++; + } + remaining = segments.slice(index); + } else { + const crate = matchingRustCrate(segments[0], file, crates); + if (crate) { + rootFile = rustCrateRoot(crate, byId); + if (!rootFile) return spec; + baseDir = rustCrateSrcDir(crate); + remaining = segments.slice(1); + } else if (segments.length === 1) { + // Bodyless `mod x;` has no syntactic marker by resolution time, but its + // single-segment shape is enough to try the declaring module's child dir. + baseDir = rustModuleDir(file, crates); + remaining = segments; + } else { + return spec; + } + } + + if (remaining.length === 0) return rootFile ?? spec; + return resolveRustModule(baseDir, remaining, byId) ?? spec; +} + +/** A Rust module's child directory. Crate-root files and mod.rs own their + * containing directory; every other module file owns a same-named directory. */ +function rustModuleDir(file: string, crates: RustCrate[]): string { + const normalized = toPosixPath(file); + const dir = posix.dirname(normalized); + const base = posix.basename(normalized); + if ( + base === "lib.rs" || + base === "main.rs" || + base === "mod.rs" || + isRustAuxiliaryCrateRoot(normalized, crates) + ) { + return dir; + } + return posix.join(dir, base.slice(0, -3)); +} + +function rustAuxiliaryDir(file: string, crate: RustCrate): string | null { + const normalized = toPosixPath(file); + for (const name of ["tests", "benches", "examples"]) { + const dir = crate.dir === "." ? name : posix.join(crate.dir, name); + if (pathIsWithin(normalized, dir)) return dir; + } + return null; +} + +function isRustAuxiliaryCrateRoot(file: string, crates: RustCrate[]): boolean { + const normalized = toPosixPath(file); + const crate = owningRustCrate(normalized, crates); + if (!crate || !normalized.endsWith(".rs")) return false; + const auxiliaryDir = rustAuxiliaryDir(normalized, crate); + return auxiliaryDir !== null && posix.dirname(normalized) === auxiliaryDir; +} + +function rustCrateSrcDir(crate: RustCrate): string { + return crate.dir === "." ? "src" : posix.join(crate.dir, "src"); +} + +/** The crate-root file (`lib.rs` preferred, else `main.rs`) inside `root`, where + * `root` is "" for the repo root. Null when the directory holds neither. */ +function rustRootFile(root: string, byId: Map): string | null { + const lib = root === "" ? "lib.rs" : posix.join(root, "lib.rs"); + if (byId.has(lib)) return lib; + const main = root === "" ? "main.rs" : posix.join(root, "main.rs"); + return byId.has(main) ? main : null; +} + +function pathIsWithin(path: string, dir: string): boolean { + return dir === "." || path === dir || path.startsWith(`${dir}/`); +} + +function rustCrateRoot(crate: RustCrate, byId: Map): string | null { + return rustRootFile(rustCrateSrcDir(crate), byId); +} + +/** Crate roots inferred from source paths alone — every directory holding a + * `lib.rs`/`main.rs` (the repo root encoded as `""`). This is the breadth tier's + * rule (`use crate::a::b` → `/a/b.rs`), kept as the fallback for files no + * Cargo package claims: a repo with no `Cargo.toml`, or one whose manifest we + * could not read, must still resolve `crate::` paths instead of losing them. + * Cargo data stays authoritative whenever it exists. */ +function inferredRustCrateRoots(byId: Map): string[] { + const roots = new Set(); + for (const id of byId.keys()) { + const hash = id.indexOf("#"); + const path = toPosixPath(hash === -1 ? id : id.slice(0, hash)); + if (path === "lib.rs" || path === "main.rs") roots.add(""); + else if (path.endsWith("/lib.rs") || path.endsWith("/main.rs")) roots.add(posix.dirname(path)); + } + return [...roots].sort(); +} + +/** The longest inferred crate root whose directory contains `file` (the root + * itself, or a file strictly under it — a bare `""` root does NOT own a + * `tests/it.rs`, whose `crate::` names the test binary's own root, not the lib). + * Null means "not owned by any inferable crate": keep the path unresolved rather + * than searching every crate in a workspace and wiring an unrelated file. */ +function owningInferredRustRoot(file: string, roots: string[]): string | null { + const path = toPosixPath(file); + let best: string | null = null; + for (const root of roots) { + const owned = root === "" ? !path.includes("/") : path.startsWith(`${root}/`); + if (!owned) continue; + if (best === null || root.length > best.length) best = root; + } + return best; +} + +function owningRustCrate(file: string, crates: RustCrate[]): RustCrate | null { + const normalized = toPosixPath(file); + let best: { crate: RustCrate; prefixLength: number } | null = null; + for (const crate of crates) { + const srcDir = rustCrateSrcDir(crate); + const prefix = pathIsWithin(normalized, srcDir) + ? srcDir + : pathIsWithin(normalized, crate.dir) + ? crate.dir + : null; + if (prefix === null) continue; + if (!best || prefix.length > best.prefixLength) best = { crate, prefixLength: prefix.length }; } - // The crate root (`lib.rs`/`main.rs`) is a target ONLY for `use crate::Item` or - // `use crate::{…}` — a deeper path whose module chain didn't resolve is genuinely - // unknown (a re-export, an inline `mod`, or out-of-tree), so keep it as a string. - if (segs.length <= 1) { - const hits = hitsFor(["lib.rs", "main.rs"]); - if (hits.size === 1) return [...hits][0]; + return best?.crate ?? null; +} + +/** The Cargo package a path's first segment names: hyphens and underscores fold + * to the same unit (Rust renders `foo-bar` as `foo_bar` in paths), and a + * dependency alias (`renamed = { package = "real" }`) resolves from the owning + * crate only. Two crates sharing one name stay unresolved — the alias form is + * the language's own disambiguator, and guessing a workspace sibling is not. */ +function matchingRustCrate(segment: string, file: string, crates: RustCrate[]): RustCrate | null { + const normalized = segment.replace(/-/g, "_"); + const owner = owningRustCrate(file, crates); + const alias = owner + ? Object.entries(owner.aliases ?? {}).find(([name]) => name.replace(/-/g, "_") === normalized)?.[1] + : undefined; + if (alias) { + const aliased = crates.filter((crate) => crate.name.replace(/-/g, "_") === alias.replace(/-/g, "_")); + if (aliased.length === 1) return aliased[0]; + if (aliased.length > 1) return null; } - return full === "" ? "crate" : `crate::${full.replace(/\//g, "::")}`; + const direct = crates.filter((crate) => crate.name.replace(/-/g, "_") === normalized); + return direct.length === 1 ? direct[0] : null; +} + +/** Walk module segments to a file: the longest stem wins, `x.rs` over + * `x/mod.rs` (Rust 2018's own preference), tried at every trailing-segment + * length so an ITEM-suffixed path (`crate::a::B`) retries as its module. */ +function resolveRustModule( + baseDir: string, + segments: string[], + byId: Map, +): string | null { + for (let length = segments.length; length >= 1; length--) { + const stem = posix.join(baseDir, ...segments.slice(0, length)); + const flat = `${stem}.rs`; + if (byId.has(flat)) return flat; + const nested = posix.join(stem, "mod.rs"); + if (byId.has(nested)) return nested; + } + return null; } /** diff --git a/test/generic-extract.test.ts b/test/generic-extract.test.ts index aa7498264..9d74f2ab4 100644 --- a/test/generic-extract.test.ts +++ b/test/generic-extract.test.ts @@ -26,63 +26,13 @@ import { checkGraph } from "../src/graph/check.js"; import { contextDirFor } from "../src/context/node-file.js"; import { skeleton } from "../src/ask/ask.js"; -const RUST = `pub struct Config { - name: String, -} - -pub fn load() -> Config { - let c = parse(); - Config { name: c } -} - -fn parse() -> String { - helper() -} - -fn helper() -> String { - String::new() -} -`; - -test("genericLangOf routes .rs to the breadth tier (and not depth-tier extensions)", () => { - assert.equal(genericLangOf("src/main.rs")?.name, "rust"); +test("genericLangOf routes .rs to the depth tier (and not depth-tier extensions)", () => { + assert.equal(genericLangOf("src/main.rs"), null); // depth tier owns .rs (PR #59) assert.equal(genericLangOf("src/init.lua")?.name, "lua"); assert.equal(genericLangOf("src/app.ts"), null); // depth tier owns .ts assert.equal(genericLangOf("README.md"), null); }); -test("extractGeneric emits nodes + bare-name call edges for Rust (no rust-specific code)", async () => { - await warmGenericGrammars(["rust"]); - assert.ok(isWarm("rust"), "rust grammar should warm"); - const { nodes, rawEdges } = extractGeneric("lib.rs", RUST, "rust"); - - const kinds = nodes.filter((n) => n.kind !== "file").map((n) => `${n.kind}:${n.name}`).sort(); - assert.deepEqual(kinds, ["function:helper", "function:load", "function:parse", "struct:Config"]); - - // every non-file node carries the generic provenance + a span + a signature - for (const n of nodes.filter((n) => n.kind !== "file")) { - assert.equal(n.origin, "generic"); - assert.match(n.span, /^L\d+-L\d+$/); - assert.ok(n.signature && n.signature.length > 0, `${n.name} has a signature`); - } - - // calls are bare-name raw edges attributed to their enclosing function - const calls = rawEdges.filter((e) => e.relation === "calls"); - const pairs = calls.map((e) => `${e.source.split("#")[1]}→${e.name}`).sort(); - assert.ok(pairs.includes("load→parse"), `load calls parse (got ${pairs.join(", ")})`); - assert.ok(pairs.includes("parse→helper"), "parse calls helper"); -}); - -test("the EXISTING resolver resolves generic-tier Rust calls by name", async () => { - await warmGenericGrammars(["rust"]); - const { nodes, rawEdges } = extractGeneric("lib.rs", RUST, "rust"); - const edges = resolveEdges(nodes, rawEdges); - const call = edges.find((e) => e.relation === "calls" && e.source.endsWith("#load")); - assert.ok(call, "load's call edge survived resolution"); - assert.equal(call?.target, "lib.rs#parse", "resolved to the same-file parse definition"); - assert.equal(call?.confidence, "extracted", "same-file → extracted"); -}); - // One inline snippet per breadth language, each exercising a definition + a call // that must resolve. Java/Ruby/C# calls target METHODS — proving the generic-origin // call-kind widening (resolve.ts) works, not just function-target languages. @@ -213,55 +163,28 @@ test("breadth tier: C #include becomes a resolved file→file import (local only assert.deepEqual(targetsOf("src/amb.c"), ["util.h"], "ambiguous include kept external, never guessed"); }); -// Rust `use crate::…` → a file→module import, resolved against the file's crate root -// (the lib.rs/main.rs dir). The longest-prefix rule disambiguates a module from an item -// and a `foo.rs` from a `foo/mod.rs`; std/super/external/glob are skipped, and an -// unresolvable in-crate path is kept as a `crate::…` string, never guessed. -test("breadth tier: Rust use crate::… resolves to the in-repo module (longest-prefix, drop-rather-than-guess)", async () => { - await warmGenericGrammars(["rust"]); - const files: Record = { - "src/lib.rs": "pub mod error;\npub mod net;\n", - "src/error.rs": "pub struct Error;\n", - "src/net/mod.rs": "pub mod tcp;\n", - "src/net/tcp.rs": "pub struct Stream;\n", - "src/app.rs": - "use crate::error::Error;\n" + // item under a module → src/error.rs - "use crate::net::tcp::Stream;\n" + // nested module → src/net/tcp.rs - "use crate::net;\n" + // single-segment module → src/net/mod.rs - "use crate::missing::Thing;\n" + // in-crate but no such file → kept as string - "use std::io::Read;\n" + // external — skipped - "use super::sibling::Thing;\n" + // relative — skipped - "use serde::Serialize;\n" + // external crate — skipped - "fn go() {}\n", - // an integration test lives OUTSIDE the crate root (src/); its `crate::` is the test - // binary's own root, not the lib — so it must NOT resolve across to src/error.rs. - "tests/it.rs": "use crate::error::Error;\nfn t() {}\n", - }; - const nodes = [], raw = []; - for (const [rel, src] of Object.entries(files)) { - const r = extractGeneric(rel, src, "rust"); - nodes.push(...r.nodes); raw.push(...r.rawEdges); - } - const imports = resolveEdges(nodes, raw).filter((e) => e.relation === "imports" && e.source === "src/app.rs"); - const targets = imports.map((e) => e.target).sort(); - - assert.ok(targets.includes("src/error.rs"), "crate::error::Error → error.rs"); - assert.ok(targets.includes("src/net/tcp.rs"), "crate::net::tcp::Stream → net/tcp.rs"); - assert.ok(targets.includes("src/net/mod.rs"), "crate::net (single-segment module) → net/mod.rs"); - // in-crate but unresolvable → the full path kept as a string, never a guessed file edge - // (crucially NOT silently resolved to the crate-root lib.rs) - assert.ok(targets.includes("crate::missing::Thing"), "unresolved in-crate path kept as a string"); - // std / super / external crate are not in-crate imports — no edge at all - assert.ok(!targets.some((t) => /io|sibling|serde|Read|Serialize/.test(t)), "external/relative use skipped"); - assert.equal(imports.length, 4, "exactly the 4 crate:: uses became edges"); - - // an integration test's `crate::error::Error` must NOT cross into src/error.rs — its - // crate root is unknown (it's the test binary), so it stays a string, never a false edge - const fromTest = resolveEdges(nodes, raw).filter((e) => e.relation === "imports" && e.source === "tests/it.rs"); - assert.deepEqual(fromTest.map((e) => e.target), ["crate::error::Error"], "test-binary crate:: never resolves into the lib"); -}); +// The depth-tier Rust extractor, end to end through buildGraph + checkGraph: a +// repo whose only source is .rs must build (origin "ast"), resolve its bare-name +// call edges, and read as in-sync on the very next check — no warmup asymmetry. +const RUST = `pub struct Config { + name: String, +} + +pub fn load() -> Config { + let c = parse(); + Config { name: c } +} + +fn parse() -> String { + helper() +} + +fn helper() -> String { + String::new() +} +`; -test("buildGraph + checkGraph handle a breadth-tier (.rs) repo end-to-end", async () => { +test("buildGraph + checkGraph handle a depth-tier (.rs) repo end-to-end", async () => { const dir = mkdtempSync(join(tmpdir(), "graft-rust-")); mkdirSync(join(dir, "src"), { recursive: true }); writeFileSync(join(dir, "src", "lib.rs"), RUST); @@ -271,13 +194,13 @@ test("buildGraph + checkGraph handle a breadth-tier (.rs) repo end-to-end", asyn assert.ok(g, "graph built"); const rust = g!.nodes.filter((n) => n.path.endsWith(".rs") && n.kind !== "file"); assert.ok(rust.length >= 4, `indexed the rust defs (got ${rust.length})`); - assert.ok(rust.every((n) => n.origin === "generic"), "rust nodes are generic-tier"); + assert.ok(rust.every((n) => n.origin === "ast"), "rust nodes are depth-tier"); assert.ok(g!.edges.some((e) => e.relation === "calls"), "rust call edges resolved"); - // check must agree with build — the async warmup means .rs files are re-extracted - // identically, so a fresh graph reads as in-sync, not perpetually stale. + // check must agree with build — the file is parsed identically on both paths, + // so a fresh graph reads as in-sync, not perpetually stale. const chk = await checkGraph(dir); - assert.equal(chk.ok, true, `check OK on a breadth-tier repo (added=${chk.added}, removed=${chk.removed})`); + assert.equal(chk.ok, true, `check OK on a depth-tier repo (added=${chk.added}, removed=${chk.removed})`); }); // #134: Dart is a breadth-tier language with no vendored tags.scm, so the @@ -430,44 +353,44 @@ const THROWING_GRAMMAR = { }; test("extractGeneric rethrows a throwing grammar with the language named (#139)", async () => { - await warmGenericGrammars(["rust"]); // initialises web-tree-sitter - const prev = swapGrammarForTest("rust", THROWING_GRAMMAR); + await warmGenericGrammars(["dart"]); // initialises web-tree-sitter + const prev = swapGrammarForTest("dart", THROWING_GRAMMAR); try { assert.throws( - () => extractGeneric("src/lib.rs", "pub fn f() {}\n", "rust"), + () => extractGeneric("src/main.dart", "void f() {}\n", "dart"), (err: unknown): boolean => { assert.ok(err instanceof Error, "throws an Error"); - assert.match(err.message, /^rust grammar threw: /, "names the language"); + assert.match(err.message, /^dart grammar threw: /, "names the language"); assert.match(err.message, /memory access out of bounds/, "keeps the original message"); return true; }, ); } finally { - swapGrammarForTest("rust", prev); + swapGrammarForTest("dart", prev); } }); test("a throwing grammar is a per-file build error, cached as a failure (#139)", async () => { const dir = mkdtempSync(join(tmpdir(), "graft-throwing-grammar-")); - writeFileSync(join(dir, "lib.rs"), "pub fn f() {}\n"); - await warmGenericGrammars(["rust"]); // so buildGraph's own warm call is a no-op - const prev = swapGrammarForTest("rust", THROWING_GRAMMAR); + writeFileSync(join(dir, "main.dart"), "void f() {}\n"); + await warmGenericGrammars(["dart"]); // so buildGraph's own warm call is a no-op + const prev = swapGrammarForTest("dart", THROWING_GRAMMAR); try { const first = await buildGraph(dir, { reuse: false }); assert.equal(first.errors.length, 1, `one build error (got: ${first.errors.join("; ")})`); - assert.match(first.errors[0], /lib\.rs: parse failed/); - assert.match(first.errors[0], /rust grammar threw: memory access out of bounds/); + assert.match(first.errors[0], /main\.dart: parse failed/); + assert.match(first.errors[0], /dart grammar threw: memory access out of bounds/); const g = readGraph(wiringPath(contextDirFor(dir))); assert.ok(g, "graph built"); - assert.ok(!g!.nodes.some((n) => n.path === "lib.rs"), "failed file has no file node"); + assert.ok(!g!.nodes.some((n) => n.path === "main.dart"), "failed file has no file node"); // The extract cache must remember the failure, not an empty success: an // incremental rebuild of the unchanged file replays the error. const second = await buildGraph(dir, { reuse: true }); assert.equal(second.parsed, 0, "unchanged file is not re-parsed"); assert.equal(second.errors.length, 1, `error replayed (got: ${second.errors.join("; ")})`); - assert.match(second.errors[0], /rust grammar threw/); + assert.match(second.errors[0], /dart grammar threw/); } finally { - swapGrammarForTest("rust", prev); + swapGrammarForTest("dart", prev); } }); diff --git a/test/graph-invariants.test.ts b/test/graph-invariants.test.ts index 1bc8f2e2f..1cfed2542 100644 --- a/test/graph-invariants.test.ts +++ b/test/graph-invariants.test.ts @@ -40,6 +40,12 @@ function writeFixture(dir: string): void { ` n.kind !== "file").map((n) => n.origin)); - assert.ok(origins.has("ast"), "TS/PHP depth-tier nodes present"); - assert.ok(origins.has("generic"), "Rust breadth-tier nodes present"); + assert.ok(origins.has("ast"), "TS/PHP/Rust depth-tier nodes present"); + assert.ok(origins.has("generic"), "Ruby breadth-tier nodes present"); // and that resolution actually produced the edge kinds this gate is meant to guard const rels = new Set(g!.edges.map((e) => e.relation)); assert.ok(rels.has("calls"), "call edges resolved"); diff --git a/test/graph-rust-resolve.test.ts b/test/graph-rust-resolve.test.ts new file mode 100644 index 000000000..b649cce96 --- /dev/null +++ b/test/graph-rust-resolve.test.ts @@ -0,0 +1,468 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { buildGraph } from "../src/graph/build.js"; +import { extractFile } from "../src/graph/extract.js"; +import { resolveEdges } from "../src/graph/resolve.js"; +import { readGraph } from "../src/graph/write.js"; +import type { GraphV1, NodeV1 } from "../src/graph/types.js"; + +function write(root: string, rel: string, source: string): void { + const path = join(root, rel); + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, source); +} + +async function buildFixture(root: string): Promise { + const result = await buildGraph(root, { + contextDir: join(root, ".graft-test"), + graphOnly: true, + reuse: false, + }); + const graph = readGraph(result.graphPath); + assert.ok(graph); + return graph; +} + +function hasEdge(graph: GraphV1, source: string, target: string, relation: string): boolean { + return graph.edges.some((edge) => edge.source === source && edge.target === target && edge.relation === relation); +} + +function node( + id: string, + kind: NodeV1["kind"], + name = id.includes("#") ? id.split("#")[1].split(".").at(-1)! : id, + owner?: string, +): NodeV1 { + return { + id, + name, + kind, + ...(owner ? { owner } : {}), + path: id.split("#")[0], + span: "L1-L1", + signature: null, + exported: true, + origin: "ast", + body_hash: "h", + summary_state: "pending", + summary: null, + crux: null, + }; +} + +test("Rust module paths resolve file modules, items, scoped leaves, and scoped calls", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-rust-resolve-")); + try { + write(dir, "Cargo.toml", "[package]\nname = 'root-crate'\n"); + write( + dir, + "src/lib.rs", + [ + "mod alpha;", + "mod nested;", + "mod parser;", + "use crate::a::B;", + "use crate::config::env;", + "use crate::bundle::{one::One, two::Two};", + "use serde::Serialize;", + "fn run() {}", + "fn invoke() { crate::worker::run(); crate::missing::run(); }", + "fn use_imports() { let _ = B; let _ = One; let _ = Two; }", + "", + ].join("\n"), + ); + write(dir, "src/main.rs", "mod command;\n"); + write(dir, "src/alpha.rs", "pub fn alpha() {}\n"); + write(dir, "src/nested/mod.rs", "mod leaf;\nuse super::shared;\n"); + write(dir, "src/nested/leaf.rs", "pub fn leaf() {}\n"); + write(dir, "src/shared.rs", "pub fn shared() {}\n"); + write(dir, "src/command.rs", "pub fn command() {}\n"); + write(dir, "src/parser.rs", "mod lexer;\n"); + write(dir, "src/parser/lexer.rs", "pub fn lex() {}\n"); + write(dir, "src/lexer.rs", "pub fn wrong_lexer() {}\n"); + write(dir, "src/a.rs", "pub struct B;\n"); + write(dir, "src/config/env.rs", "pub fn load() {}\n"); + write(dir, "src/bundle/one.rs", "pub struct One;\n"); + write(dir, "src/bundle/two.rs", "pub struct Two;\n"); + write(dir, "src/worker.rs", "pub fn run() {}\n"); + write(dir, "src/feature/mod.rs", "use self::local;\n"); + write(dir, "src/feature/local.rs", "pub fn local() {}\n"); + write(dir, "src/feature/thing.rs", "use super::util::helper;\n"); + write(dir, "src/feature/util.rs", "pub fn helper() {}\n"); + + const graph = await buildFixture(dir); + + assert.ok(hasEdge(graph, "src/lib.rs", "src/alpha.rs", "imports"), "mod x resolves x.rs"); + assert.ok(hasEdge(graph, "src/lib.rs", "src/nested/mod.rs", "imports"), "mod x resolves x/mod.rs"); + assert.ok(hasEdge(graph, "src/parser.rs", "src/parser/lexer.rs", "imports")); + assert.ok(!hasEdge(graph, "src/parser.rs", "src/lexer.rs", "imports"), "non-root modules use their stem dir"); + assert.ok(hasEdge(graph, "src/main.rs", "src/command.rs", "imports")); + assert.ok(hasEdge(graph, "src/nested/mod.rs", "src/nested/leaf.rs", "imports")); + assert.ok(hasEdge(graph, "src/nested/mod.rs", "src/shared.rs", "imports")); + assert.ok(hasEdge(graph, "src/feature/mod.rs", "src/feature/local.rs", "imports")); + assert.ok(hasEdge(graph, "src/feature/thing.rs", "src/feature/util.rs", "imports")); + + assert.ok(hasEdge(graph, "src/lib.rs", "src/a.rs", "imports"), "item suffix retries to its module"); + assert.ok(hasEdge(graph, "src/lib.rs", "src/config/env.rs", "imports"), "module leaf is not stripped early"); + assert.ok(hasEdge(graph, "src/lib.rs", "src/bundle/one.rs", "imports")); + assert.ok(hasEdge(graph, "src/lib.rs", "src/bundle/two.rs", "imports")); + assert.ok(hasEdge(graph, "src/lib.rs", "serde::Serialize", "imports"), "external crates remain raw"); + assert.ok(hasEdge(graph, "src/lib.rs#use_imports", "src/a.rs#B", "references")); + assert.ok(hasEdge(graph, "src/lib.rs#use_imports", "src/bundle/one.rs#One", "references")); + assert.ok(hasEdge(graph, "src/lib.rs#use_imports", "src/bundle/two.rs#Two", "references")); + + assert.ok(hasEdge(graph, "src/lib.rs#invoke", "src/worker.rs#run", "calls")); + assert.ok(!hasEdge(graph, "src/lib.rs#invoke", "src/lib.rs#run", "calls"), "scoped calls never use bare fallback"); + assert.equal( + graph.edges.filter((edge) => edge.source === "src/lib.rs#invoke" && edge.relation === "calls").length, + 1, + "an unresolvable scoped call is dropped", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("Rust crate paths distinguish source tests, direct auxiliary roots, and deeper support modules", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-rust-integration-root-")); + try { + write(dir, "Cargo.toml", "[package]\nname = 'root-crate'\n"); + write(dir, "src/lib.rs", "mod shared;\n"); + write(dir, "src/shared.rs", "pub fn helper() {}\n"); + write(dir, "src/tests/foo.rs", "use crate::shared::helper;\n"); + write(dir, "src/tests/shared.rs", "pub fn wrong() {}\n"); + write( + dir, + "tests/foo.rs", + "mod common;\nuse crate::common::helper;\nfn exercise() { helper(); }\n", + ); + write(dir, "tests/common/mod.rs", "use crate::shared::helper;\n"); + + const graph = await buildFixture(dir); + + assert.ok( + hasEdge(graph, "tests/foo.rs", "tests/common/mod.rs", "imports"), + "a direct integration-test crate root resolves mod and crate paths from its own directory", + ); + assert.ok( + hasEdge(graph, "src/tests/foo.rs", "src/shared.rs", "imports"), + "a src/tests child is an ordinary source module anchored to the package crate", + ); + assert.ok(!hasEdge(graph, "src/tests/foo.rs", "src/tests/shared.rs", "imports")); + assert.ok( + hasEdge(graph, "tests/common/mod.rs", "crate::shared::helper", "imports"), + "crate paths in deeper auxiliary support modules stay raw because their declaring root is ambiguous", + ); + assert.ok(!hasEdge(graph, "tests/common/mod.rs", "src/shared.rs", "imports")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("Rust inline-module super calls resolve from the declaring file", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-rust-inline-call-")); + try { + write(dir, "Cargo.toml", "[package]\nname = 'root-crate'\n"); + write(dir, "src/lib.rs", "mod foo;\n"); + write(dir, "src/foo.rs", "mod helper;\nmod tests { fn t() { super::helper::go(); } }\n"); + write(dir, "src/foo/helper.rs", "pub fn go() {}\n"); + + const graph = await buildFixture(dir); + assert.ok(hasEdge(graph, "src/foo.rs#tests.t", "src/foo/helper.rs#go", "calls")); + assert.ok(!hasEdge(graph, "src/foo.rs#tests.t", "src/helper.rs#go", "calls")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("Rust mod declarations stay crate-local and resolve nested inline-module paths", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-rust-mod-marker-")); + try { + write(dir, "crates/main/Cargo.toml", "[package]\nname = 'main'\n"); + write(dir, "crates/main/src/lib.rs", "mod parser;\nmod inline { mod leaf; }\n"); + write(dir, "crates/main/src/parser.rs", "pub fn local() {}\n"); + write(dir, "crates/main/src/inline/leaf.rs", "pub fn nested() {}\n"); + write(dir, "crates/parser/Cargo.toml", "[package]\nname = 'parser'\n"); + write(dir, "crates/parser/src/lib.rs", "pub fn external() {}\n"); + + const graph = await buildFixture(dir); + assert.ok(hasEdge(graph, "crates/main/src/lib.rs", "crates/main/src/parser.rs", "imports")); + assert.ok(!hasEdge(graph, "crates/main/src/lib.rs", "crates/parser/src/lib.rs", "imports")); + assert.ok(hasEdge(graph, "crates/main/src/lib.rs", "crates/main/src/inline/leaf.rs", "imports")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("Rust workspace aliases resolve declared packages and duplicate package names stay raw", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-rust-crate-map-")); + try { + write( + dir, + "crates/app/Cargo.toml", + "[package]\nname = 'app'\n\n[dependencies]\nrenamed = { package = 'real-package', path = '../real' }\n", + ); + write(dir, "crates/app/src/lib.rs", "use renamed::thing;\nuse duplicate::module;\n"); + write(dir, "crates/real/Cargo.toml", "[package]\nname = 'real-package'\n"); + write(dir, "crates/real/src/lib.rs", "pub mod thing;\n"); + write(dir, "crates/real/src/thing.rs", "pub fn work() {}\n"); + write(dir, "crates/dup-a/Cargo.toml", "[package]\nname = 'duplicate'\n"); + write(dir, "crates/dup-a/src/lib.rs", "pub mod module;\n"); + write(dir, "crates/dup-a/src/module.rs", "pub fn first() {}\n"); + write(dir, "crates/dup-b/Cargo.toml", "[package]\nname = 'duplicate'\n"); + write(dir, "crates/dup-b/src/lib.rs", "pub mod module;\n"); + write(dir, "crates/dup-b/src/module.rs", "pub fn second() {}\n"); + + const graph = await buildFixture(dir); + assert.ok(hasEdge(graph, "crates/app/src/lib.rs", "crates/real/src/thing.rs", "imports")); + assert.ok(hasEdge(graph, "crates/app/src/lib.rs", "duplicate::module", "imports")); + assert.ok(!hasEdge(graph, "crates/app/src/lib.rs", "crates/dup-a/src/module.rs", "imports")); + assert.ok(!hasEdge(graph, "crates/app/src/lib.rs", "crates/dup-b/src/module.rs", "imports")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("Rust owner indexes cannot satisfy a Python typed-member call", () => { + const nodes = [ + node("worker.rs", "file"), + node("worker.rs#Worker.run", "method", "run", "Worker"), + node("caller.py", "file"), + node("caller.py#invoke", "function"), + ]; + const edges = resolveEdges(nodes, [ + { + source: "caller.py#invoke", + relation: "calls", + name: "run", + file: "caller.py", + viaMember: true, + recvType: "Worker", + }, + ]); + assert.equal(edges.filter((edge) => edge.relation === "calls").length, 0); +}); + +test("Rust inline mods resolve bodyless mods and consumed super calls end to end", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-rust-inline-e2e-")); + try { + write(dir, "Cargo.toml", "[package]\nname = 'root-crate'\n"); + write(dir, "src/lib.rs", "mod outer;\n"); + write(dir, "src/outer.rs", "mod x;\nmod y;\nmod tests { mod x; fn t() { super::y::z(); } }\n"); + write(dir, "src/outer/x.rs", "pub fn root_x() {}\n"); + write(dir, "src/outer/y.rs", "pub fn z() {}\n"); + write(dir, "src/outer/tests/x.rs", "pub fn nested_x() {}\n"); + + const graph = await buildFixture(dir); + assert.ok(hasEdge(graph, "src/outer.rs", "src/outer/tests/x.rs", "imports")); + assert.ok(hasEdge(graph, "src/outer.rs#tests.t", "src/outer/y.rs#z", "calls")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("Rust crate paths resolve from path-inferred roots when no Cargo.toml exists", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-rust-no-cargo-")); + try { + write(dir, "src/lib.rs", "mod worker;\nuse crate::worker::run;\nfn invoke() { crate::worker::run(); }\n"); + write(dir, "src/worker.rs", "pub fn run() {}\n"); + write(dir, "src/net/mod.rs", "use crate::shared::helper;\n"); + write(dir, "src/shared.rs", "pub fn helper() {}\n"); + write(dir, "crates/lib-a/src/lib.rs", "use crate::util::helper;\n"); + write(dir, "crates/lib-a/src/util.rs", "pub fn helper() {}\n"); + write(dir, "crates/lib-b/src/main.rs", "use crate::worker::run;\nmod local;\n"); + write(dir, "crates/lib-b/src/local.rs", "pub fn l() {}\n"); + write(dir, "tests/it.rs", "use crate::worker::run;\nfn t() {}\n"); + + const graph = await buildFixture(dir); + + assert.ok( + hasEdge(graph, "src/lib.rs", "src/worker.rs", "imports"), + "a lib.rs-implied root resolves crate:: for its own subtree", + ); + assert.ok(hasEdge(graph, "src/net/mod.rs", "src/shared.rs", "imports"), "nested modules keep the owning root"); + assert.ok(hasEdge(graph, "src/lib.rs#invoke", "src/worker.rs#run", "calls")); + assert.ok( + hasEdge(graph, "crates/lib-a/src/lib.rs", "crates/lib-a/src/util.rs", "imports"), + "the longest inferred root wins", + ); + assert.ok(hasEdge(graph, "crates/lib-b/src/main.rs", "crates/lib-b/src/local.rs", "imports"), "mod imports need no Cargo"); + assert.ok( + hasEdge(graph, "crates/lib-b/src/main.rs", "crate::worker::run", "imports"), + "an in-crate path with no matching file stays raw instead of borrowing another crate's worker.rs", + ); + assert.ok(!hasEdge(graph, "crates/lib-b/src/main.rs", "src/worker.rs", "imports")); + assert.ok( + hasEdge(graph, "tests/it.rs", "crate::worker::run", "imports"), + "a file outside every inferred root keeps its own test-binary crate::", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("Rust module and constant nodes are never call targets", () => { + const { nodes, rawEdges } = extractFile( + "mods.rs", + "mod helper;\nconst value: u8 = 1;\nfn invoke() { helper(); value(); }\n", + "rust", + ); + const edges = resolveEdges(nodes, rawEdges); + assert.equal(edges.filter((edge) => edge.relation === "calls").length, 0); +}); + +test("Rust graph builds are byte-deterministic", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-rust-deterministic-")); + try { + write(dir, "Cargo.toml", "[package]\nname = 'root-crate'\n"); + write(dir, "src/lib.rs", "mod worker;\nfn invoke() { crate::worker::run(); }\n"); + write(dir, "src/worker.rs", "pub fn run() {}\n"); + + const first = await buildGraph(dir, { + contextDir: join(dir, ".graft-test"), + graphOnly: true, + reuse: false, + }); + const before = readFileSync(first.graphPath); + const second = await buildGraph(dir, { + contextDir: join(dir, ".graft-test"), + graphOnly: true, + reuse: false, + }); + assert.deepEqual(readFileSync(second.graphPath), before); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("Rust workspace crate names resolve across crates and ignore non-package Cargo names", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-rust-workspace-")); + try { + write(dir, "crates/foo-bar/Cargo.toml", "[package]\nname = 'foo-bar'\n"); + write(dir, "crates/foo-bar/src/lib.rs", "use baz::local;\npub mod thing;\n"); + write(dir, "crates/foo-bar/src/thing.rs", "pub fn work() {}\n"); + write( + dir, + "crates/baz/Cargo.toml", + "[[bin]]\nname = \"foo_bar\"\npath = \"src/main.rs\"\n\n[package]\nname = \"baz\"\n", + ); + write(dir, "crates/baz/src/main.rs", "use foo_bar::thing;\nmod local;\nfn main() {}\n"); + write(dir, "crates/baz/src/local.rs", "pub fn local() {}\n"); + + const graph = await buildFixture(dir); + + assert.ok( + hasEdge(graph, "crates/baz/src/main.rs", "crates/foo-bar/src/thing.rs", "imports"), + "hyphenated package name matches its underscored Rust path", + ); + assert.ok( + hasEdge(graph, "crates/foo-bar/src/lib.rs", "crates/baz/src/local.rs", "imports"), + "the [[bin]] name does not shadow the [package] name", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("Rust typed members drop duplicate same-file trait implementations but keep a single candidate", () => { + const singleExtract = extractFile( + "single.rs", + "struct Frame;\nimpl Frame { fn from() {} }\nfn use_frame() { Frame::from(); }\n", + "rust", + ); + const single = resolveEdges(singleExtract.nodes, singleExtract.rawEdges); + assert.equal(single.find((edge) => edge.relation === "calls")?.target, "single.rs#Frame.from"); + + const ambiguousExtract = extractFile( + "frame.rs", + [ + "trait First { fn from(); }", + "trait Second { fn from(); }", + "struct Frame;", + "impl First for Frame { fn from() {} }", + "impl Second for Frame { fn from() {} }", + "fn use_frame() { Frame::from(); }", + "", + ].join("\n"), + "rust", + ); + assert.equal( + ambiguousExtract.nodes.filter((candidate) => candidate.owner === "Frame" && candidate.name === "from").length, + 2, + ); + const ambiguous = resolveEdges(ambiguousExtract.nodes, ambiguousExtract.rawEdges); + assert.equal(ambiguous.filter((edge) => edge.relation === "calls").length, 0); +}); + +test("Rust trait default methods resolve through same-file impl heritage", () => { + const { nodes, rawEdges } = extractFile( + "frame.rs", + "pub trait Paint { fn render(&self) {} }\npub struct Frame;\nimpl Paint for Frame {}\nfn use_frame() { Frame::render(); }\n", + "rust", + ); + const edges = resolveEdges(nodes, rawEdges); + assert.ok( + edges.some( + (edge) => + edge.source === "frame.rs#use_frame" && + edge.target === "frame.rs#Paint.render" && + edge.relation === "calls", + ), + ); + + const twoDefaults = extractFile( + "ambiguous-default.rs", + [ + "trait First { fn render(&self) {} }", + "trait Second { fn render(&self) {} }", + "struct Frame;", + "impl First for Frame {}", + "impl Second for Frame {}", + "fn use_frame() { Frame::render(); }", + "", + ].join("\n"), + "rust", + ); + const ambiguous = resolveEdges(twoDefaults.nodes, twoDefaults.rawEdges); + assert.equal( + ambiguous.filter((edge) => edge.source === "ambiguous-default.rs#use_frame" && edge.relation === "calls").length, + 0, + "two default methods at the same ancestor level are ambiguous", + ); +}); + +test("Rust and non-Rust bare-name resolution stay in separate global domains", () => { + const nodes = [ + node("rust_def.rs", "file"), + node("rust_def.rs#shared", "function"), + node("python_def.py", "file"), + node("python_def.py#shared", "function"), + node("rust_call.rs", "file"), + node("rust_call.rs#invoke", "function"), + node("python_call.py", "file"), + node("python_call.py#invoke", "function"), + ]; + const edges = resolveEdges(nodes, [ + { source: "rust_call.rs#invoke", relation: "calls", name: "shared", file: "rust_call.rs", lang: "rust" }, + { source: "python_call.py#invoke", relation: "calls", name: "shared", file: "python_call.py" }, + ]); + + assert.ok(edges.some((edge) => edge.source === "rust_call.rs#invoke" && edge.target === "rust_def.rs#shared")); + assert.ok(edges.some((edge) => edge.source === "python_call.py#invoke" && edge.target === "python_def.py#shared")); + assert.ok(!edges.some((edge) => edge.source === "rust_call.rs#invoke" && edge.target.endsWith(".py#shared"))); + assert.ok(!edges.some((edge) => edge.source === "python_call.py#invoke" && edge.target.endsWith(".rs#shared"))); +}); + +test("Rust macro calls use the bang namespace while ordinary format functions still resolve", () => { + const { nodes, rawEdges } = extractFile( + "macros.rs", + "macro_rules! log_it { () => {} }\nfn format() {}\nfn invoke() { log_it!(); format(); }\n", + "rust", + ); + const edges = resolveEdges(nodes, rawEdges); + assert.ok(edges.some((edge) => edge.source === "macros.rs#invoke" && edge.target === "macros.rs#log_it!")); + assert.ok(edges.some((edge) => edge.source === "macros.rs#invoke" && edge.target === "macros.rs#format")); +}); diff --git a/test/graph-rust.test.ts b/test/graph-rust.test.ts new file mode 100644 index 000000000..f55309089 --- /dev/null +++ b/test/graph-rust.test.ts @@ -0,0 +1,375 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import Parser from "tree-sitter"; +import Rust from "tree-sitter-rust/bindings/node/index.js"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { collectBindings } from "../src/graph/bindings.js"; +import { buildGraph } from "../src/graph/build.js"; +import { extractFile } from "../src/graph/extract.js"; +import { readGraph } from "../src/graph/write.js"; + +test("Rust extraction: definitions, owners, module scopes, exports, and trait heritage", () => { + const src = ` +pub struct Cache { value: T } +struct Private; +pub(self) struct SelfOnly; +pub(crate) enum Mode { Fast } +pub trait Backend { + fn required(&self); + fn defaulted(&self) {} +} +pub type Alias = Cache; +pub union Bits { raw: u32 } + +impl Cache { + pub fn open(&self) {} + pub(crate) fn crate_visible(&self) {} + pub(self) fn self_only(&self) {} + fn private(&self) {} +} + +impl Backend for Cache { + fn required(&self) {} +} + +#[macro_export] +macro_rules! exported_macro { () => {} } +macro_rules! local_macro { () => {} } + +mod imp { pub fn open() {} } +#[cfg(test)] +mod tests { pub fn hidden() {} } +#[cfg(not(test))] +mod production { pub fn visible() {} } +`; + const { nodes, rawEdges } = extractFile("defs.rs", src, "rust"); + const byId = (id: string) => nodes.find((node) => node.id === id); + + assert.equal(byId("defs.rs#Cache")?.kind, "struct"); + assert.equal(byId("defs.rs#Private")?.kind, "struct"); + assert.equal(byId("defs.rs#SelfOnly")?.kind, "struct"); + assert.equal(byId("defs.rs#Mode")?.kind, "enum"); + assert.equal(byId("defs.rs#Backend")?.kind, "interface"); + assert.equal(byId("defs.rs#Alias")?.kind, "type"); + assert.equal(byId("defs.rs#Bits")?.kind, "struct"); + assert.equal(byId("defs.rs#exported_macro!")?.kind, "function"); + assert.equal(byId("defs.rs#local_macro!")?.kind, "function"); + + assert.equal(byId("defs.rs#Cache.open")?.kind, "method"); + assert.equal(byId("defs.rs#Cache.open")?.owner, "Cache"); + assert.equal(byId("defs.rs#Cache.required")?.kind, "method"); + assert.equal(byId("defs.rs#Cache.required")?.owner, "Cache"); + assert.equal(byId("defs.rs#Backend.required")?.kind, "method"); + assert.equal(byId("defs.rs#Backend.required")?.owner, "Backend"); + assert.equal(byId("defs.rs#Backend.defaulted")?.owner, "Backend"); + assert.equal(byId("defs.rs#imp.open")?.kind, "function"); + assert.equal(byId("defs.rs#tests.hidden")?.exported, false); + assert.equal(byId("defs.rs#production.visible")?.exported, true); + + assert.equal(byId("defs.rs#Cache")?.exported, true); + assert.equal(byId("defs.rs#Private")?.exported, false); + assert.equal(byId("defs.rs#SelfOnly")?.exported, false); + assert.equal(byId("defs.rs#Mode")?.exported, true); + assert.equal(byId("defs.rs#Cache.open")?.exported, true); + assert.equal(byId("defs.rs#Cache.crate_visible")?.exported, true); + assert.equal(byId("defs.rs#Cache.self_only")?.exported, false); + assert.equal(byId("defs.rs#Cache.private")?.exported, false); + assert.equal(byId("defs.rs#Cache.required")?.exported, true); + assert.equal(byId("defs.rs#Backend.required")?.exported, true); + assert.equal(byId("defs.rs#exported_macro!")?.exported, true); + assert.equal(byId("defs.rs#local_macro!")?.exported, false); + + assert.ok( + rawEdges.some( + (edge) => + edge.relation === "extends" && + edge.source === "defs.rs#Cache" && + edge.name === "Backend" && + edge.lang === "rust", + ), + ); +}); + +test("Rust extraction: calls carry receiver types, module specifiers, macro namespaces, and guards", () => { + const src = ` +struct Worker { value: T } +struct Factory; +fn bare() {} +fn compute() {} + +impl Worker { + fn helper(&self) {} + fn inside(&self) { + Self::helper(self); + } +} + +fn exercise(param: &mut Worker, tuple: (fn(),)) { + bare(); + bare::(); + let typed: &mut Worker = param; + typed.run(); + typed.run::(); + param.run(); + let made = Factory::with_name(); + made.make(); + Worker::build(); + crate::worker::run(); + some_mod::run(); + super::run(); + tuple.0(); + println!("ignored"); + assert_eq!(compute(), 1); + log_it!(compute()); + std::println!("ignored"); + crate::macros::local_log!(); +} +`; + const calls = extractFile("calls.rs", src, "rust").rawEdges.filter((edge) => edge.relation === "calls"); + const named = (name: string) => calls.filter((edge) => edge.name === name); + + assert.equal(named("bare").length, 2); + assert.deepEqual(named("run").map((edge) => edge.recvType), ["Worker", "Worker", "Worker", undefined, undefined, undefined]); + assert.deepEqual(named("run").slice(3).map((edge) => edge.specifier), ["crate::worker", "some_mod", "super"]); + assert.ok(named("run").slice(0, 3).every((edge) => edge.viaMember)); + assert.ok(named("run").slice(3).every((edge) => !edge.viaMember)); + assert.equal(named("make")[0]?.recvType, "Factory"); + assert.equal(named("build")[0]?.recvType, "Worker"); + assert.equal(named("helper")[0]?.recvType, "Worker"); + assert.equal(named("log_it!").length, 1); + assert.equal(named("local_log!").length, 1); + assert.equal(named("println!").length, 0); + assert.equal(named("assert_eq!").length, 0); + assert.equal(named("compute").length, 0, "macro token trees stay opaque"); + assert.equal(named("0").length, 0, "tuple indexes are not method names"); + assert.ok(calls.every((edge) => edge.lang === "rust")); +}); + +test("Rust extraction: use forms retain full per-leaf paths and bodyless mods import", () => { + const src = ` +use foo; +use crate::models::Thing; +use crate::models::{Widget, helper, Thing as Renamed, self}; +use crate::models::*; +use crate::Original as Alias; +mod x; +#[path = "alternate.rs"] +mod redirected; +mod inline { + mod leaf; + pub fn nested() {} +} + +fn uses() { + let _a = Thing; + let _b = Renamed; + let _c = Alias; + let _d = helper; +} +`; + const { nodes, rawEdges } = extractFile("imports.rs", src, "rust"); + const imports = rawEdges.filter((edge) => edge.relation === "imports"); + assert.deepEqual( + imports.map((edge) => edge.specifier), + [ + "foo", + "crate::models::Thing", + "crate::models::Widget", + "crate::models::helper", + "crate::models::Thing", + "crate::models::self", + "crate::models::*", + "crate::Original", + "self::x", + "self::inline::leaf", + ], + ); + assert.ok(imports.every((edge) => edge.lang === "rust")); + assert.equal(nodes.find((node) => node.id === "imports.rs#inline.nested")?.kind, "function"); + + const references = rawEdges.filter((edge) => edge.relation === "references"); + assert.deepEqual( + references.map((edge) => ({ name: edge.name, specifier: edge.specifier })), + [ + { name: "Thing", specifier: "crate::models::Thing" }, + { name: "Thing", specifier: "crate::models::Thing" }, + { name: "Original", specifier: "crate::Original" }, + ], + ); + assert.ok(!references.some((edge) => edge.name === "helper")); +}); + +test("Rust extraction: inline-module prefixes are consumed for scoped calls", () => { + const calls = extractFile( + "src/foo.rs", + "mod helper;\nmod tests { fn t() { super::helper::go(); } }\n", + "rust", + ).rawEdges.filter((edge) => edge.relation === "calls"); + + assert.deepEqual( + calls.map((edge) => ({ source: edge.source, name: edge.name, specifier: edge.specifier })), + [{ source: "src/foo.rs#tests.t", name: "go", specifier: undefined }], + ); +}); + +test("Rust extraction: macro signatures stop before bodies and qualified calls use the leaf name", () => { + const { nodes, rawEdges } = extractFile( + "macros.rs", + "macro_rules! local_log { () => { 42 } }\nfn invoke() { std::println!(\"x\"); crate::macros::local_log!(); }\n", + "rust", + ); + + assert.equal(nodes.find((node) => node.id === "macros.rs#local_log!")?.signature, "macro_rules! local_log { ()"); + assert.deepEqual( + rawEdges.filter((edge) => edge.relation === "calls").map((edge) => edge.name), + ["local_log!"], + ); +}); + +test("Rust extraction handles CRLF input", () => { + const result = extractFile("crlf.rs", "pub fn from_crlf() {\r\n helper();\r\n}\r\n", "rust"); + assert.equal(result.nodes.find((node) => node.name === "from_crlf")?.kind, "function"); +}); + +test("Rust extraction parses definitions beyond the 32 KB chunk boundary", () => { + const padding = Array.from({ length: 3_000 }, (_, i) => `// padding ${i}`).join("\n"); + const source = `${padding}\npub fn after_boundary() {}\n`; + assert.ok(source.length > 32_768); + assert.equal( + extractFile("large.rs", source, "rust").nodes.find((node) => node.name === "after_boundary")?.kind, + "function", + ); +}); + +test("Rust builds tolerate malformed files and keep recoverable definitions", async () => { + const dir = mkdtempSync(join(tmpdir(), "graft-rust-malformed-")); + try { + writeFileSync(join(dir, "broken.rs"), "pub fn before() {}\nfn broken( {\npub fn after() {}\n"); + const result = await buildGraph(dir, { + contextDir: join(dir, ".graft-test"), + graphOnly: true, + reuse: false, + }); + assert.deepEqual(result.errors, []); + const graph = readGraph(result.graphPath); + assert.equal(graph?.nodes.find((node) => node.name === "before")?.kind, "function"); + assert.equal(graph?.nodes.find((node) => node.name === "after")?.kind, "function"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("Rust extraction degrades gracefully on &raw syntax", () => { + const { nodes } = extractFile( + "raw.rs", + "static VALUE: u8 = 1;\nfn pointer() { let _ptr = &raw const VALUE; }\npub fn after_raw() {}\n", + "rust", + ); + assert.equal(nodes.find((node) => node.name === "pointer")?.kind, "function"); + assert.equal(nodes.find((node) => node.name === "after_raw")?.kind, "function"); +}); + +test("Rust extraction: inline modules consume same-file self and super prefixes", () => { + const src = ` +struct Item; +mod tests { + use super::*; + use super::Item; + use self::Local; + use super::super::external::Thing; + use crate::absolute::CrateItem; + + fn uses() { + let _item = Item; + let _local = Local; + let _thing = Thing; + let _crate_item = CrateItem; + } +} +`; + const { rawEdges } = extractFile("inline.rs", src, "rust"); + const imports = rawEdges.filter((edge) => edge.relation === "imports"); + assert.deepEqual( + imports.map((edge) => edge.specifier), + ["super::external::Thing", "crate::absolute::CrateItem"], + ); + + const references = rawEdges.filter((edge) => edge.relation === "references"); + assert.deepEqual( + references.map((edge) => ({ name: edge.name, specifier: edge.specifier })), + [ + { name: "Thing", specifier: "super::external::Thing" }, + { name: "CrateItem", specifier: "crate::absolute::CrateItem" }, + ], + ); +}); + +test("Rust extraction mints module and constant nodes, matching the breadth tier's rust.scm", () => { + const src = ` +pub mod inline { pub fn nested() {} } +mod bodyless; +#[path = "alternate.rs"] +mod redirected; +pub const LIMIT: u8 = 1; +static PRIVATE_FLAG: bool = false; +const fn build() {} +#[cfg(test)] +mod tests { pub const INNER: u8 = 2; } +`; + const { nodes, rawEdges } = extractFile("mods.rs", src, "rust"); + const byId = (id: string) => nodes.find((node) => node.id === id); + + // `mod` mints a module node — inline and bodyless alike — with the name, not + // the body, as its signature. + assert.equal(byId("mods.rs#inline")?.kind, "module"); + assert.equal(byId("mods.rs#inline")?.signature, "pub mod inline"); + assert.equal(byId("mods.rs#inline")?.exported, true); + assert.equal(byId("mods.rs#bodyless")?.kind, "module"); + assert.equal(byId("mods.rs#bodyless")?.signature, "mod bodyless"); + assert.equal(byId("mods.rs#redirected")?.kind, "module"); + assert.ok( + rawEdges.some( + (edge) => + edge.relation === "contains" && edge.source === "mods.rs#inline" && edge.targetId === "mods.rs#inline.nested", + ), + "an inline module structurally owns its children", + ); + assert.equal(byId("mods.rs#inline.nested")?.kind, "function"); + + // const/static items are constants; the initializer is the body, never the signature. + assert.equal(byId("mods.rs#LIMIT")?.kind, "constant"); + assert.equal(byId("mods.rs#LIMIT")?.signature, "pub const LIMIT: u8"); + assert.equal(byId("mods.rs#LIMIT")?.exported, true); + assert.equal(byId("mods.rs#PRIVATE_FLAG")?.kind, "constant"); + assert.equal(byId("mods.rs#PRIVATE_FLAG")?.exported, false); + assert.equal(byId("mods.rs#build")?.kind, "function", "a const-qualified fn is still a function"); + + // cfg(test) suppression reaches through the module node. + assert.equal(byId("mods.rs#tests")?.exported, false); + assert.equal(byId("mods.rs#tests.INNER")?.kind, "constant"); + assert.equal(byId("mods.rs#tests.INNER")?.exported, false); + + // The bodyless form still imports its module file; #[path] retargets that out of reach. + assert.deepEqual( + rawEdges.filter((edge) => edge.relation === "imports").map((edge) => edge.specifier), + ["self::bodyless"], + ); +}); + +test("Rust bindings: typed lets, constructor lets, and typed parameters strip wrappers and generics", () => { + const parser = new Parser(); + parser.setLanguage(Rust as never); + const root = parser.parse(` +fn use_workers(param: &mut Worker) { + let typed: &Worker = param; + let made = Factory::with_name(); +} +`).rootNode; + const bindings = collectBindings(root, "rust"); + + assert.equal(bindings.lookup(["use_workers"], "param"), "Worker"); + assert.equal(bindings.lookup(["use_workers"], "typed"), "Worker"); + assert.equal(bindings.lookup(["use_workers"], "made"), "Factory"); +}); diff --git a/test/supported-extensions.test.ts b/test/supported-extensions.test.ts index 68862a92a..9f432b1d5 100644 --- a/test/supported-extensions.test.ts +++ b/test/supported-extensions.test.ts @@ -16,9 +16,9 @@ import { supportedExtensions, unsupportedExtensions } from "../src/graph/source- test("supportedExtensions covers both tiers, sorted and de-duped", () => { const exts = supportedExtensions(); // depth tier - for (const e of [".ts", ".tsx", ".py", ".go", ".java", ".js", ".php", ".kt", ".kts", ".swift"]) assert.ok(exts.includes(e), `depth ${e}`); + for (const e of [".ts", ".tsx", ".py", ".go", ".java", ".js", ".php", ".kt", ".kts", ".swift", ".rs"]) assert.ok(exts.includes(e), `depth ${e}`); // breadth tier - for (const e of [".rs", ".rb", ".c", ".cpp"]) assert.ok(exts.includes(e), `breadth ${e}`); + for (const e of [".rb", ".c", ".cpp"]) assert.ok(exts.includes(e), `breadth ${e}`); // container tier assert.ok(exts.includes(".vue"), "container .vue"); // de-duped (.java is in BOTH tiers but must appear once) and sorted