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 ce44fc9..5aa3445 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 @@ -74,4 +74,78 @@ describe("DartExtractor", () => { parser.delete(); }); }); + + describe("extractStructure - classes", () => { + it("extracts a class with fields and methods", () => { + const { tree, parser, root } = parse(`class Counter { + int count = 0; + String? label; + void increment() { count++; } + int get value => count; +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Counter"); + expect(result.classes[0].methods).toContain("increment"); + // method declarations land in functions[] too (matching Kotlin convention) + expect(result.functions.map((f) => f.name)).toContain("increment"); + // Field extraction: `int count = 0;` and `String? label;` both parse as + // declaration > initialized_identifier_list > initialized_identifier > identifier + expect(result.classes[0].properties).toEqual( + expect.arrayContaining(["count", "label"]), + ); + // Getters appear as `method_signature > getter_signature`, a separate node + // type from `function_signature` — not yet surfaced (documented limitation). + expect(result.classes[0].methods).not.toContain("value"); + + tree.delete(); + parser.delete(); + }); + + it("extracts an empty class", () => { + const { tree, parser, root } = parse(`class Empty {}\n`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Empty"); + expect(result.classes[0].methods).toEqual([]); + + tree.delete(); + parser.delete(); + }); + + it("extracts an abstract class with method requirements", () => { + const { tree, parser, root } = parse(`abstract class Shape { + double area(); +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Shape"); + expect(result.classes[0].methods).toContain("area"); + + tree.delete(); + parser.delete(); + }); + + it("extracts a class with extends + with + implements clauses", () => { + const { tree, parser, root } = parse(`class Square extends Shape with Comparable implements Cloneable { + double side; + Square(this.side); + double area() => side * side; +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Square"); + expect(result.classes[0].methods).toContain("area"); + + 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 94de626..443af3d 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 @@ -16,10 +16,12 @@ function isExported(name: string): boolean { /** * Extract the identifier name from a `function_signature` node. * - * NOTE: for `method_signature` (class-body method declarations), callers - * must first unwrap to the inner `function_signature` child before invoking - * this helper — the Dart grammar layers `method_signature > function_signature` - * and `findChild(..., "identifier")` would otherwise miss the function name. + * NOTE: this helper expects a `function_signature` node. The Dart grammar + * wraps the function_signature inside two different parent shapes: + * - `method_signature > function_signature` for CONCRETE class methods. + * - `declaration > function_signature` for ABSTRACT class methods (no body). + * Callers (`collectClassBody`) unwrap to the inner `function_signature` + * before invoking this helper. */ function extractFunctionName(sig: TreeSitterNode): string | null { const id = findChild(sig, "identifier"); @@ -86,6 +88,83 @@ function extractReturnType(sig: TreeSitterNode): string | undefined { return parts.length > 0 ? parts.join("") : undefined; } +/** + * Push a method/function entry. Used by `collectClassBody` for both + * `method_signature` and `declaration > function_signature` shapes so a + * future change to the entry's fields lands in one place. + */ +function pushMethod( + declNode: TreeSitterNode, + sig: TreeSitterNode, + name: string, + methods: string[], + functions: StructuralAnalysis["functions"], + exports: StructuralAnalysis["exports"], +): void { + methods.push(name); + functions.push({ + name, + lineRange: [declNode.startPosition.row + 1, declNode.endPosition.row + 1], + params: extractParams(sig), + returnType: extractReturnType(sig), + }); + if (isExported(name)) { + exports.push({ name, lineNumber: declNode.startPosition.row + 1 }); + } +} + +/** + * Walk a `class_body` (or `extension_body` / `enum_body`) and collect + * `method_signature` declarations into the class's `methods` array AND the + * top-level `functions` array, mirroring KotlinExtractor.collectClassBody. + * + * Field extraction: `int count = 0;` and `String? label;` inside a class body + * both parse as `declaration > initialized_identifier_list > initialized_identifier + * > identifier`. The nullable `?` is an unnamed sibling of `type_identifier`, + * so it does not affect this path. + */ +function collectClassBody( + body: TreeSitterNode, + methods: string[], + properties: string[], + functions: StructuralAnalysis["functions"], + exports: StructuralAnalysis["exports"], +): void { + for (let i = 0; i < body.childCount; i++) { + const member = body.child(i); + if (!member) continue; + + if (member.type === "method_signature") { + // Concrete method: `method_signature > function_signature`. + // NOTE: `getter_signature` also nests under `method_signature` but is a + // separate node type — getters are not yet surfaced (documented limitation). + const inner = findChild(member, "function_signature"); + if (!inner) continue; + const name = extractFunctionName(inner); + if (!name) continue; + pushMethod(member, inner, name, methods, functions, exports); + } else if (member.type === "declaration") { + // Abstract method declarations (e.g. `double area();`) appear as + // `declaration > function_signature` — not wrapped in `method_signature`. + const fnSig = findChild(member, "function_signature"); + if (fnSig) { + const name = extractFunctionName(fnSig); + if (name) { + pushMethod(member, fnSig, name, methods, functions, exports); + } + continue; + } + // Field declaration — surface initialized_identifier names as properties. + const list = findChild(member, "initialized_identifier_list"); + if (!list) continue; + for (const init of findChildren(list, "initialized_identifier")) { + const id = findChild(init, "identifier"); + if (id) properties.push(id.text); + } + } + } +} + /** * Dart extractor for tree-sitter structural analysis + call graph. * @@ -112,6 +191,9 @@ export class DartExtractor implements LanguageExtractor { case "function_signature": this.extractTopLevelFunction(node, functions, exports); break; + case "class_definition": + this.extractClassDefinition(node, classes, functions, exports); + break; } } @@ -138,6 +220,36 @@ export class DartExtractor implements LanguageExtractor { } } + private extractClassDefinition( + declNode: TreeSitterNode, + classes: StructuralAnalysis["classes"], + functions: StructuralAnalysis["functions"], + exports: StructuralAnalysis["exports"], + ): void { + const nameNode = findChild(declNode, "identifier"); + if (!nameNode) return; + const name = nameNode.text; + + const methods: string[] = []; + const properties: string[] = []; + + const body = findChild(declNode, "class_body"); + if (body) { + collectClassBody(body, methods, properties, functions, exports); + } + + classes.push({ + name, + lineRange: [declNode.startPosition.row + 1, declNode.endPosition.row + 1], + methods, + properties, + }); + + if (isExported(name)) { + exports.push({ name, lineNumber: declNode.startPosition.row + 1 }); + } + } + extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] { // Implementation lands in a later task. void rootNode;