diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/dart-extractor.test.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/dart-extractor.test.ts index 4cf09f2..9954743 100644 --- a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/dart-extractor.test.ts +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/dart-extractor.test.ts @@ -263,4 +263,68 @@ describe("DartExtractor", () => { parser.delete(); }); }); + + describe("extractStructure - imports", () => { + it("extracts a package import with no specifiers", () => { + const { tree, parser, root } = parse(`import 'package:flutter/material.dart';\n`); + const result = extractor.extractStructure(root); + + expect(result.imports).toHaveLength(1); + expect(result.imports[0].source).toBe("package:flutter/material.dart"); + expect(result.imports[0].specifiers).toEqual([]); + tree.delete(); + parser.delete(); + }); + + it("extracts a relative import", () => { + const { tree, parser, root } = parse(`import './foo.dart';\n`); + const result = extractor.extractStructure(root); + + expect(result.imports[0].source).toBe("./foo.dart"); + tree.delete(); + parser.delete(); + }); + + it("extracts a `show` clause as specifiers", () => { + const { tree, parser, root } = parse(`import 'foo.dart' show Bar, Baz;\n`); + const result = extractor.extractStructure(root); + + expect(result.imports[0].source).toBe("foo.dart"); + expect(result.imports[0].specifiers).toEqual(["Bar", "Baz"]); + tree.delete(); + parser.delete(); + }); + + it("extracts an `as` prefix as the sole specifier", () => { + const { tree, parser, root } = parse(`import 'bar.dart' as b;\n`); + const result = extractor.extractStructure(root); + + expect(result.imports[0].source).toBe("bar.dart"); + expect(result.imports[0].specifiers).toEqual(["b"]); + tree.delete(); + parser.delete(); + }); + + it("does NOT include `hide` names as specifiers", () => { + const { tree, parser, root } = parse(`import 'foo.dart' hide Qux;\n`); + const result = extractor.extractStructure(root); + + expect(result.imports[0].source).toBe("foo.dart"); + expect(result.imports[0].specifiers).toEqual([]); + tree.delete(); + parser.delete(); + }); + }); + + describe("extractStructure - exports", () => { + it("extracts a top-level export directive", () => { + const { tree, parser, root } = parse(`export 'shared.dart';\n`); + const result = extractor.extractStructure(root); + + const sharedExport = result.exports.find((e) => e.name === "shared.dart"); + expect(sharedExport).toBeDefined(); + tree.delete(); + parser.delete(); + }); + }); }); diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/dart-extractor.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/dart-extractor.ts index 5e37f46..f65adc2 100644 --- a/understand-anything-plugin/packages/core/src/plugins/extractors/dart-extractor.ts +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/dart-extractor.ts @@ -1,6 +1,6 @@ import type { StructuralAnalysis, CallGraphEntry } from "../../types.js"; import type { LanguageExtractor, TreeSitterNode } from "./types.js"; -import { findChild, findChildren } from "./base-extractor.js"; +import { findChild, findChildren, getStringValue } from "./base-extractor.js"; /** * Whether a Dart name is exported. @@ -113,6 +113,17 @@ function pushMethod( } } +/** + * Unwrap the string-literal text from `uri > string_literal` via + * `base-extractor.getStringValue` so the quote-stripping logic lives in + * exactly one place across all extractors. + */ +function uriText(uriNode: TreeSitterNode): string | null { + const lit = findChild(uriNode, "string_literal"); + if (!lit) return null; + return getStringValue(lit); +} + /** * Build a constructor's method-graph name from a constructor_signature / * factory_constructor_signature node: @@ -244,6 +255,9 @@ export class DartExtractor implements LanguageExtractor { case "enum_declaration": this.extractEnumDeclaration(node, classes, exports); break; + case "import_or_export": + this.extractImportOrExport(node, imports, exports); + break; } } @@ -381,6 +395,90 @@ export class DartExtractor implements LanguageExtractor { } } + private extractImportOrExport( + declNode: TreeSitterNode, + imports: StructuralAnalysis["imports"], + exports: StructuralAnalysis["exports"], + ): void { + const libImport = findChild(declNode, "library_import"); + if (libImport) { + this.extractLibraryImport(libImport, imports); + return; + } + const libExport = findChild(declNode, "library_export"); + if (libExport) { + this.extractLibraryExport(libExport, declNode, exports); + } + } + + private extractLibraryImport( + libImport: TreeSitterNode, + imports: StructuralAnalysis["imports"], + ): void { + const spec = findChild(libImport, "import_specification"); + if (!spec) return; + + const configurable = findChild(spec, "configurable_uri"); + const uri = configurable ? findChild(configurable, "uri") : null; + if (!uri) return; + const source = uriText(uri); + if (!source) return; + + const specifiers: string[] = []; + + // Combinators come in two flavours: + // show Bar, Baz → leading keyword "show", names are specifiers + // hide Qux → leading keyword "hide", names are excluded — skip + const combinators = findChildren(spec, "combinator"); + for (const c of combinators) { + // Inspect the first child to determine show vs hide. The keyword is an + // unnamed token; use `child()` not `namedChild()`. + const first = c.child(0); + if (first && first.type === "hide") continue; + for (const id of findChildren(c, "identifier")) { + specifiers.push(id.text); + } + } + + // `as Foo` → direct `identifier` child of import_specification. + // Only treat as alias when there were no `show`/`hide` specifiers. + const asId = findChild(spec, "identifier"); + if (asId && specifiers.length === 0) { + specifiers.push(asId.text); + } + + imports.push({ + source, + specifiers, + lineNumber: libImport.startPosition.row + 1, + }); + } + + /** + * Extract an `export` directive's URI into `exports[]`. + * + * Takes both `libExport` (the `library_export` node containing the URI) + * and `outerNode` (the wrapping `import_or_export` node). The line number + * uses `outerNode.startPosition` because `library_export` may start one + * child deeper than the `export` keyword, while `import_or_export` is + * guaranteed to start at the keyword. + */ + private extractLibraryExport( + libExport: TreeSitterNode, + outerNode: TreeSitterNode, + exports: StructuralAnalysis["exports"], + ): void { + const configurable = findChild(libExport, "configurable_uri"); + const uri = configurable ? findChild(configurable, "uri") : null; + if (!uri) return; + const source = uriText(uri); + if (!source) return; + exports.push({ + name: source, + lineNumber: outerNode.startPosition.row + 1, + }); + } + extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] { // Implementation lands in a later task. void rootNode;