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 0947b11..3bab6ea 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 @@ -219,4 +219,35 @@ describe("DartExtractor", () => { parser.delete(); }); }); + + describe("extractStructure - extensions", () => { + it("extracts a named extension on String", () => { + const { tree, parser, root } = parse(`extension StringX on String { + String shout() => toUpperCase() + '!'; +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("StringX"); + expect(result.classes[0].methods).toContain("shout"); + tree.delete(); + parser.delete(); + }); + + it("names an anonymous extension after its target type", () => { + const { tree, parser, root } = parse(`extension on int { + int squared() => this * this; +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + // Anonymous extension on int → "on int" so it isn't dropped. + expect(result.classes[0].name).toBe("on int"); + expect(result.classes[0].methods).toContain("squared"); + 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 20e6cac..dacdfee 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 @@ -238,6 +238,9 @@ export class DartExtractor implements LanguageExtractor { case "mixin_declaration": this.extractClassLikeDeclaration(node, "class_body", classes, functions, exports); break; + case "extension_declaration": + this.extractExtensionDeclaration(node, classes, functions, exports); + break; } } @@ -270,9 +273,9 @@ export class DartExtractor implements LanguageExtractor { * `extension_declaration`. The only difference between these shapes is the * body's node type name, which is passed in via `bodyNodeType`. * - * Anonymous variants (e.g. `extension on Foo` with no name) are handled by - * the caller — this method requires `declNode` to have a leading - * `identifier` child for the name. + * When `nameOverride` is provided, it is used as the entry's name instead of + * looking up a leading `identifier` child — used by anonymous extensions, + * which have no name in the source. */ private extractClassLikeDeclaration( declNode: TreeSitterNode, @@ -280,10 +283,16 @@ export class DartExtractor implements LanguageExtractor { classes: StructuralAnalysis["classes"], functions: StructuralAnalysis["functions"], exports: StructuralAnalysis["exports"], + nameOverride?: string, ): void { - const nameNode = findChild(declNode, "identifier"); - if (!nameNode) return; - const name = nameNode.text; + let name: string; + if (nameOverride !== undefined) { + name = nameOverride; + } else { + const nameNode = findChild(declNode, "identifier"); + if (!nameNode) return; + name = nameNode.text; + } const methods: string[] = []; const properties: string[] = []; @@ -305,6 +314,40 @@ export class DartExtractor implements LanguageExtractor { } } + private extractExtensionDeclaration( + declNode: TreeSitterNode, + classes: StructuralAnalysis["classes"], + functions: StructuralAnalysis["functions"], + exports: StructuralAnalysis["exports"], + ): void { + // Named extension — extractClassLikeDeclaration finds the leading identifier itself. + const idNode = findChild(declNode, "identifier"); + if (idNode) { + this.extractClassLikeDeclaration( + declNode, + "extension_body", + classes, + functions, + exports, + ); + return; + } + + // Anonymous extension — no `identifier` child. The on-type is the first + // `type_identifier`. Name the entry "on " so the graph + // builder doesn't drop it for having an empty name. + const onType = findChild(declNode, "type_identifier"); + if (!onType) return; + this.extractClassLikeDeclaration( + declNode, + "extension_body", + classes, + functions, + exports, + `on ${onType.text}`, + ); + } + extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] { // Implementation lands in a later task. void rootNode;