diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/rust-extractor.test.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/rust-extractor.test.ts index 06879602c..189fc53d6 100644 --- a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/rust-extractor.test.ts +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/rust-extractor.test.ts @@ -384,6 +384,151 @@ use crate::config::Settings; parser.delete(); }); + it("handles `use ... as` rename clauses", () => { + const { tree, parser, root } = parse(` +use foo::Bar as Baz; +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(1); + expect(result.imports[0].source).toBe("foo"); + expect(result.imports[0].specifiers).toEqual(["Baz"]); + expect(result.imports[0].lineNumber).toBe(2); + + tree.delete(); + parser.delete(); + }); + + it("captures renamed specifiers inside use-lists", () => { + const { tree, parser, root } = parse(` +use std::io::{Read as R, Write}; +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(1); + expect(result.imports[0].source).toBe("std::io"); + expect(result.imports[0].specifiers).toEqual(["R", "Write"]); + + tree.delete(); + parser.delete(); + }); + + it("keeps source path for single-segment wildcard imports", () => { + { + const { tree, parser, root } = parse(` +use crate::*; +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(1); + expect(result.imports[0].source).toBe("crate"); + expect(result.imports[0].specifiers).toEqual(["*"]); + + tree.delete(); + parser.delete(); + } + + { + const { tree, parser, root } = parse(` +use foo::*; +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(1); + expect(result.imports[0].source).toBe("foo"); + expect(result.imports[0].specifiers).toEqual(["*"]); + + tree.delete(); + parser.delete(); + } + }); + + it("expands nested grouped use-lists", () => { + const { tree, parser, root } = parse(` +use a::{b::{C, D}}; +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(1); + expect(result.imports[0].source).toBe("a"); + expect(result.imports[0].specifiers).toEqual(["C", "D"]); + + tree.delete(); + parser.delete(); + }); + + it("expands nested grouped use-lists mixed with flat members", () => { + const { tree, parser, root } = parse(` +use std::{io::{Read, Write}, fmt}; +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(1); + expect(result.imports[0].source).toBe("std"); + expect(result.imports[0].specifiers).toEqual(["Read", "Write", "fmt"]); + + tree.delete(); + parser.delete(); + }); + + it("pins behavior for bare-prefix renames (`use self::Foo as F;`)", () => { + const { tree, parser, root } = parse(` +use self::Foo as F; +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(1); + // self::Foo parses as scoped_identifier(path: self, name: Foo); + // source is the `self` prefix, the binding is the alias `F`. + expect(result.imports[0].source).toBe("self"); + expect(result.imports[0].specifiers).toEqual(["F"]); + + tree.delete(); + parser.delete(); + }); + + it("pins behavior for crate-prefixed renames (`use crate::Foo as F;`)", () => { + const { tree, parser, root } = parse(` +use crate::Foo as F; +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(1); + expect(result.imports[0].source).toBe("crate"); + expect(result.imports[0].specifiers).toEqual(["F"]); + + tree.delete(); + parser.delete(); + }); + + it("records `extern crate` declarations as imports", () => { + const { tree, parser, root } = parse(` +extern crate serde; +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(1); + expect(result.imports[0].source).toBe("serde"); + expect(result.imports[0].specifiers).toEqual(["serde"]); + + tree.delete(); + parser.delete(); + }); + + it("uses the alias as the specifier for `extern crate ... as`", () => { + const { tree, parser, root } = parse(` +extern crate serde as s; +`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(1); + expect(result.imports[0].source).toBe("serde"); + expect(result.imports[0].specifiers).toEqual(["s"]); + + tree.delete(); + parser.delete(); + }); + it("reports correct import line numbers", () => { const { tree, parser, root } = parse(` use std::collections::HashMap; @@ -458,6 +603,58 @@ impl Config { parser.delete(); }); + it("surfaces `pub use` renamed re-exports in exports", () => { + const { tree, parser, root } = parse(` +pub use foo::Bar as Baz; +use internal::Hidden; +`); + const result = extractor.extractStructure(root); + + // Still recorded as an import... + expect(result.imports.map((i) => i.specifiers)).toContainEqual(["Baz"]); + + // ...and the public re-export is also surfaced as an export. + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("Baz"); + // Private `use` must not leak into exports. + expect(exportNames).not.toContain("Hidden"); + + tree.delete(); + parser.delete(); + }); + + it("surfaces `pub use` list and scoped re-exports in exports", () => { + const { tree, parser, root } = parse(` +pub use std::io::{Read, Write}; +pub use crate::config::Settings; +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("Read"); + expect(exportNames).toContain("Write"); + expect(exportNames).toContain("Settings"); + + tree.delete(); + parser.delete(); + }); + + it("does not surface glob re-exports or `self` as named exports", () => { + const { tree, parser, root } = parse(` +pub use foo::*; +pub use std::io::{self, Read}; +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).not.toContain("*"); + expect(exportNames).not.toContain("self"); + expect(exportNames).toContain("Read"); + + tree.delete(); + parser.delete(); + }); + it("reports correct export line numbers", () => { const { tree, parser, root } = parse(` pub struct Config { diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/rust-extractor.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/rust-extractor.ts index 98ab38ff9..fc116b77e 100644 --- a/understand-anything-plugin/packages/core/src/plugins/extractors/rust-extractor.ts +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/rust-extractor.ts @@ -127,7 +127,11 @@ export class RustExtractor implements LanguageExtractor { break; case "use_declaration": - this.extractUseDeclaration(node, imports); + this.extractUseDeclaration(node, imports, exports); + break; + + case "extern_crate_declaration": + this.extractExternCrate(node, imports, exports); break; } } @@ -429,18 +433,27 @@ export class RustExtractor implements LanguageExtractor { private extractUseDeclaration( node: TreeSitterNode, imports: StructuralAnalysis["imports"], + exports: StructuralAnalysis["exports"], ): void { const argument = node.childForFieldName("argument"); if (!argument) return; + const lineNumber = node.startPosition.row + 1; + // `pub use foo::Bar as Baz;` is a public re-export — every binding it + // introduces (the alias `Baz`, or each specifier name) is also an export. + const isReExport = isPublic(node); + switch (argument.type) { case "identifier": // `use foo;` imports.push({ source: argument.text, specifiers: [argument.text], - lineNumber: node.startPosition.row + 1, + lineNumber, }); + if (isReExport) { + exports.push({ name: argument.text, lineNumber }); + } break; case "scoped_identifier": { @@ -449,8 +462,11 @@ export class RustExtractor implements LanguageExtractor { imports.push({ source: path, specifiers: [name], - lineNumber: node.startPosition.row + 1, + lineNumber, }); + if (isReExport && name) { + exports.push({ name, lineNumber }); + } break; } @@ -462,36 +478,60 @@ export class RustExtractor implements LanguageExtractor { const specifiers: string[] = []; if (listNode) { - for (let j = 0; j < listNode.childCount; j++) { - const ch = listNode.child(j); - if (!ch) continue; - if (ch.type === "self" || ch.type === "identifier") { - specifiers.push(ch.text); - } else if (ch.type === "scoped_identifier") { - // Nested scoped identifier inside a use list - specifiers.push(ch.text); - } - } + this.collectUseListSpecifiers(listNode, specifiers); } imports.push({ source, specifiers, - lineNumber: node.startPosition.row + 1, + lineNumber, }); + if (isReExport) { + for (const spec of specifiers) { + if (spec !== "self" && spec !== "*") { + exports.push({ name: spec, lineNumber }); + } + } + } break; } case "use_wildcard": { - // `use std::prelude::*;` - // The path is the scoped_identifier child - const scopedId = findChild(argument, "scoped_identifier"); - const source = scopedId ? scopedId.text : ""; + // `use std::prelude::*;`, `use crate::*;`, `use foo::*;` + // The path is the first named child (scoped_identifier, identifier, + // or crate); only `*` itself is excluded. + const pathNode = argument.namedChild(0); + const source = + pathNode && pathNode.type !== "*" ? pathNode.text : ""; imports.push({ source, specifiers: ["*"], - lineNumber: node.startPosition.row + 1, + lineNumber, }); + // A `pub use ...::*;` glob re-export introduces no statically-known + // binding names, so there is nothing concrete to add to `exports`. + break; + } + + case "use_as_clause": { + // `use foo::Bar as Baz;` + const pathNode = argument.childForFieldName("path"); + const aliasNode = argument.childForFieldName("alias"); + const alias = aliasNode ? aliasNode.text : ""; + // Source is the path minus its final segment; specifier is the alias. + const { path, name } = + pathNode && pathNode.type === "scoped_identifier" + ? extractScopedPath(pathNode) + : { path: "", name: pathNode ? pathNode.text : "" }; + const specifier = alias || name; + imports.push({ + source: path || name, + specifiers: [specifier], + lineNumber, + }); + if (isReExport && specifier) { + exports.push({ name: specifier, lineNumber }); + } break; } @@ -500,10 +540,88 @@ export class RustExtractor implements LanguageExtractor { imports.push({ source: argument.text, specifiers: [argument.text], - lineNumber: node.startPosition.row + 1, + lineNumber, }); break; } } } + + /** + * Collect the binding names from a `use_list` node into `specifiers`, + * recursing into nested grouped lists. + * + * tree-sitter-rust parses `use a::{b::{C, D}};` as a `scoped_use_list` + * whose `list` is a `use_list` containing a *nested* `scoped_use_list` + * (`b::{C, D}`). Without recursion the nested group matches none of the + * leaf branches and its members (`C`, `D`) are silently dropped. This + * helper walks both `scoped_use_list` and bare `use_list` children so all + * leaf specifiers surface regardless of nesting depth. + */ + private collectUseListSpecifiers( + listNode: TreeSitterNode, + specifiers: string[], + ): void { + for (let j = 0; j < listNode.childCount; j++) { + const ch = listNode.child(j); + if (!ch) continue; + if ( + ch.type === "self" || + ch.type === "identifier" || + ch.type === "scoped_identifier" + ) { + specifiers.push(ch.text); + } else if (ch.type === "use_as_clause") { + // Renamed member: `Read as R` + const aliasNode = ch.childForFieldName("alias"); + const pathNode = ch.childForFieldName("path"); + specifiers.push( + aliasNode ? aliasNode.text : pathNode ? pathNode.text : ch.text, + ); + } else if (ch.type === "scoped_use_list") { + // Nested group: `b::{C, D}` — recurse into its inner `use_list`. + const innerList = ch.childForFieldName("list"); + if (innerList) { + this.collectUseListSpecifiers(innerList, specifiers); + } + } else if (ch.type === "use_list") { + // Bare nested list (defensive — recurse directly). + this.collectUseListSpecifiers(ch, specifiers); + } else if (ch.type === "use_wildcard") { + // Glob inside a group: `a::{b::*, C}`. + specifiers.push("*"); + } + } + } + + /** + * Handle `extern crate serde;` and `extern crate serde as s;`. + * + * Parsed as `extern_crate_declaration` with a `name` field (the crate) and + * an optional `alias` field. The crate is recorded as an import; an `as` + * alias is the local binding, so it is used as the specifier. + */ + private extractExternCrate( + node: TreeSitterNode, + imports: StructuralAnalysis["imports"], + exports: StructuralAnalysis["exports"], + ): void { + const nameNode = node.childForFieldName("name"); + if (!nameNode) return; + + const aliasNode = node.childForFieldName("alias"); + const lineNumber = node.startPosition.row + 1; + const specifier = aliasNode ? aliasNode.text : nameNode.text; + + imports.push({ + source: nameNode.text, + specifiers: [specifier], + lineNumber, + }); + + // `pub extern crate serde as s;` re-exports the binding. + if (isPublic(node)) { + exports.push({ name: specifier, lineNumber }); + } + } }