Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 37 additions & 17 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand All @@ -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"
}
Expand Down
73 changes: 72 additions & 1 deletion src/graph/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -260,6 +270,18 @@ const JAVA_TYPE_DECLS: ReadonlySet<string> = 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<string> = 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();
Expand All @@ -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<string, string>): 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");
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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") {
Expand Down
48 changes: 46 additions & 2 deletions src/graph/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, string> = {};
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 = {},
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading