diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/go-extractor.test.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/go-extractor.test.ts index 2cf1b772f..53e1055c9 100644 --- a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/go-extractor.test.ts +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/go-extractor.test.ts @@ -425,6 +425,60 @@ type reader interface { tree.delete(); parser.delete(); }); + + it("exports defined types and type aliases (not just structs/interfaces)", () => { + const { tree, parser, root } = parse(`package main + +type Count int +type Celsius float64 +type ID = string + +type count int +type id = string +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + // Exported defined types and aliases are surfaced... + expect(exportNames).toContain("Count"); + expect(exportNames).toContain("Celsius"); + expect(exportNames).toContain("ID"); + // ...lowercase (unexported) ones are not. + expect(exportNames).not.toContain("count"); + expect(exportNames).not.toContain("id"); + // They are not modeled as classes (no fields/methods). + expect(result.classes.map((c) => c.name)).not.toContain("Count"); + + tree.delete(); + parser.delete(); + }); + + it("captures every kind in a grouped type block (struct, interface, defined, alias)", () => { + const { tree, parser, root } = parse(`package main + +type ( + Foo struct{ x int } + Bar interface{ M() } + Gid = string + N int +) +`); + const result = extractor.extractStructure(root); + + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toEqual( + expect.arrayContaining(["Foo", "Bar", "Gid", "N"]), + ); + // Struct/interface are modeled as classes; defined-type/alias are not. + expect(result.classes.map((c) => c.name)).toEqual( + expect.arrayContaining(["Foo", "Bar"]), + ); + expect(result.classes.map((c) => c.name)).not.toContain("Gid"); + expect(result.classes.map((c) => c.name)).not.toContain("N"); + + tree.delete(); + parser.delete(); + }); }); // ---- Call Graph ---- @@ -596,4 +650,150 @@ func helper(x int) string { parser.delete(); }); }); + + // ---- Generic receivers ---- + + describe("extractStructure - generic receivers", () => { + it("attaches methods on generic receivers to their struct", () => { + const { tree, parser, root } = parse(`package main + +type Stack[T any] struct { + items []T +} + +func (s *Stack[T]) Push(item T) {} + +func (s Stack[T]) Len() int { + return 0 +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Stack"); + expect(result.classes[0].methods.sort()).toEqual(["Len", "Push"]); + + tree.delete(); + parser.delete(); + }); + + it("attaches methods on multi-parameter generic receivers", () => { + const { tree, parser, root } = parse(`package main + +type Box[K comparable, V any] struct { + data map[K]V +} + +func (b *Box[K, V]) Get(k K) V { + var zero V + return zero +} + +func (b Box[K, V]) Len() int { + return 0 +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Box"); + expect(result.classes[0].methods.sort()).toEqual(["Get", "Len"]); + + tree.delete(); + parser.delete(); + }); + + it("attaches methods on constrained generic receivers", () => { + const { tree, parser, root } = parse(`package main + +type Result[T comparable] struct { + value T +} + +func (r Result[T comparable]) Ok() bool { + return true +} +`); + const result = extractor.extractStructure(root); + + expect(result.classes).toHaveLength(1); + expect(result.classes[0].name).toBe("Result"); + expect(result.classes[0].methods).toContain("Ok"); + + tree.delete(); + parser.delete(); + }); + }); + + // ---- Grouped type declarations ---- + + describe("extractStructure - grouped type declarations", () => { + it("handles grouped type declarations", () => { + const { tree, parser, root } = parse(`package main + +type ( + Foo struct { A int } + Bar struct { B int } +) +`); + const result = extractor.extractStructure(root); + + expect(result.classes.map((c) => c.name)).toEqual(["Foo", "Bar"]); + + const exportNames = result.exports.map((e) => e.name); + expect(exportNames).toContain("Foo"); + expect(exportNames).toContain("Bar"); + + tree.delete(); + parser.delete(); + }); + + it("handles a grouped block mixing struct and interface specs", () => { + const { tree, parser, root } = parse(`package main + +type ( + Foo struct { A int } + Bar interface { Read() error } +) +`); + const result = extractor.extractStructure(root); + + expect(result.classes.map((c) => c.name)).toEqual(["Foo", "Bar"]); + + const foo = result.classes.find((c) => c.name === "Foo")!; + expect(foo.properties).toEqual(["A"]); + expect(foo.methods).toEqual([]); + + const bar = result.classes.find((c) => c.name === "Bar")!; + expect(bar.methods).toContain("Read"); + expect(bar.properties).toEqual([]); + + tree.delete(); + parser.delete(); + }); + + it("handles a grouped block of two interfaces", () => { + const { tree, parser, root } = parse(`package main + +type ( + Reader interface { Read() error } + Writer interface { Write(p []byte) (int, error) } +) +`); + const result = extractor.extractStructure(root); + + expect(result.classes.map((c) => c.name)).toEqual(["Reader", "Writer"]); + + const reader = result.classes.find((c) => c.name === "Reader")!; + expect(reader.methods).toEqual(["Read"]); + expect(reader.properties).toEqual([]); + + const writer = result.classes.find((c) => c.name === "Writer")!; + expect(writer.methods).toEqual(["Write"]); + expect(writer.properties).toEqual([]); + + tree.delete(); + parser.delete(); + }); + }); }); diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/go-extractor.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/go-extractor.ts index 53e3e95aa..26c257d66 100644 --- a/understand-anything-plugin/packages/core/src/plugins/extractors/go-extractor.ts +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/go-extractor.ts @@ -50,16 +50,21 @@ function extractReceiverType(receiverNode: TreeSitterNode): string | undefined { const decl = findChild(receiverNode, "parameter_declaration"); if (!decl) return undefined; - // Look for type_identifier directly or inside pointer_type + // Look for type_identifier directly or inside pointer_type / generic_type. + // For generic receivers the receiver is wrapped in a generic_type (and may be + // pointer-wrapped too), e.g. `(s *Stack[T])`, so unwrap those layers before + // grabbing the base type_identifier. for (let i = 0; i < decl.childCount; i++) { - const child = decl.child(i); + let child: TreeSitterNode | null = decl.child(i); if (!child) continue; - if (child.type === "type_identifier") { - return child.text; + while (child && (child.type === "pointer_type" || child.type === "generic_type")) { + child = + child.type === "generic_type" + ? child.childForFieldName("type") + : findChild(child, "type_identifier") ?? findChild(child, "generic_type"); } - if (child.type === "pointer_type") { - const typeId = findChild(child, "type_identifier"); - if (typeId) return typeId.text; + if (child && child.type === "type_identifier") { + return child.text; } } return undefined; @@ -261,17 +266,50 @@ export class GoExtractor implements LanguageExtractor { classes: StructuralAnalysis["classes"], exports: StructuralAnalysis["exports"], ): void { - const typeSpec = findChild(node, "type_spec"); - if (!typeSpec) return; - - const nameNode = typeSpec.childForFieldName("name"); - const typeNode = typeSpec.childForFieldName("type"); - if (!nameNode || !typeNode) return; + // A type_declaration can hold multiple type_spec children when types are + // grouped, e.g. `type ( Foo struct{...}; Bar struct{...} )`. Iterate over + // all of them so every grouped type is captured, not just the first. + // + // We use the per-spec node (not the outer `type_declaration`) as the + // declNode so each grouped type reports its own line range; for a + // non-grouped `type Foo struct{...}` the spec and the `type` keyword sit on + // the same physical line, so the line range is unchanged. + // + // struct_type / interface_type specs are modeled as `classes` (with their + // fields/methods). Defined types (`type Count int`) and type aliases + // (`type MyID = string`, parsed as a separate `type_alias` node) have no + // fields or methods to model, but they are still top-level package + // declarations: when exported they are surfaced in `exports` so public + // types aren't silently dropped from the structural view. + const typeSpecs = findChildren(node, "type_spec"); + for (const typeSpec of typeSpecs) { + const nameNode = typeSpec.childForFieldName("name"); + const typeNode = typeSpec.childForFieldName("type"); + if (!nameNode || !typeNode) continue; + + if (typeNode.type === "struct_type") { + this.extractStruct(typeSpec, nameNode, typeNode, classes, exports); + } else if (typeNode.type === "interface_type") { + this.extractInterface(typeSpec, nameNode, typeNode, classes, exports); + } else if (isExported(nameNode.text)) { + // Defined type, e.g. `type Count int` / `type Celsius float64`. + exports.push({ + name: nameNode.text, + lineNumber: typeSpec.startPosition.row + 1, + }); + } + } - if (typeNode.type === "struct_type") { - this.extractStruct(node, nameNode, typeNode, classes, exports); - } else if (typeNode.type === "interface_type") { - this.extractInterface(node, nameNode, typeNode, classes, exports); + // Type aliases (`type MyID = string`) parse as `type_alias`, a sibling of + // `type_spec` under `type_declaration`, so they are not visited above. + for (const alias of findChildren(node, "type_alias")) { + const aliasName = alias.childForFieldName("name"); + if (aliasName && isExported(aliasName.text)) { + exports.push({ + name: aliasName.text, + lineNumber: alias.startPosition.row + 1, + }); + } } }