diff --git a/crates/codegraph-core/src/extractors/groovy.rs b/crates/codegraph-core/src/extractors/groovy.rs index 9ef62021..00fa489d 100644 --- a/crates/codegraph-core/src/extractors/groovy.rs +++ b/crates/codegraph-core/src/extractors/groovy.rs @@ -158,8 +158,9 @@ fn collect_interfaces( fn handle_interface_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { let Some(name_node) = node.child_by_field_name("name") else { return }; + let iface_name = node_text(&name_node, source).to_string(); symbols.definitions.push(Definition { - name: node_text(&name_node, source).to_string(), + name: iface_name.clone(), kind: "interface".to_string(), line: start_line(node), end_line: Some(end_line(node)), @@ -168,6 +169,18 @@ fn handle_interface_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) cfg: None, children: None, }); + + // `interface X extends Y, Z` — tree-sitter-groovy 0.1.x exposes parent + // interfaces as an unnamed `extends_interfaces` child wrapping a `type_list`. + // collect_interfaces already recurses into `type_list`, so passing the + // wrapper node works without a dedicated helper. + for i in 0..node.child_count() { + let Some(child) = node.child(i) else { continue }; + if child.kind() == "extends_interfaces" { + collect_interfaces(&child, &iface_name, source, symbols); + break; + } + } } fn handle_enum_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { @@ -523,4 +536,37 @@ mod tests { assert!(rels.iter().any(|c| c.implements.as_deref() == Some("I1"))); assert!(rels.iter().any(|c| c.implements.as_deref() == Some("I2"))); } + + #[test] + fn extracts_interface_inheritance() { + // `interface X extends Y, Z` — the grammar exposes parent interfaces + // via an unnamed `extends_interfaces` child (not a field), distinct + // from class declarations which use the `interfaces` field. + let s = parse_groovy("interface Serializable extends Comparable, Cloneable {}"); + let rels: Vec<_> = s.classes.iter().filter(|c| c.name == "Serializable").collect(); + assert!( + rels.iter().any(|c| c.implements.as_deref() == Some("Comparable")), + "missing implements=Comparable, got: {:?}", + rels + ); + assert!( + rels.iter().any(|c| c.implements.as_deref() == Some("Cloneable")), + "missing implements=Cloneable, got: {:?}", + rels + ); + } + + #[test] + fn interface_inheritance_line_tracks_extends_clause() { + // Engine-parity guard: the relation line should match the + // `extends_interfaces` node's start line, not the `interface_declaration`'s + // — `collect_interfaces` re-evaluates `start_line(interfaces)` on every + // recursive call, and the WASM extractor must match. + let s = parse_groovy("interface Serializable\n extends Comparable, Cloneable {}"); + let rels: Vec<_> = s.classes.iter().filter(|c| c.name == "Serializable").collect(); + assert!(!rels.is_empty(), "expected at least one ClassRelation"); + for rel in &rels { + assert_eq!(rel.line, 2, "line should track the extends clause, got: {:?}", rel); + } + } } diff --git a/src/extractors/groovy.ts b/src/extractors/groovy.ts index 64b69594..8e85db90 100644 --- a/src/extractors/groovy.ts +++ b/src/extractors/groovy.ts @@ -141,14 +141,57 @@ function handleGroovyClassDecl(node: TreeSitterNode, ctx: ExtractorOutput): void function handleGroovyInterfaceDecl(node: TreeSitterNode, ctx: ExtractorOutput): void { const nameNode = node.childForFieldName('name'); if (!nameNode) return; + const ifaceName = nameNode.text; ctx.definitions.push({ - name: nameNode.text, + name: ifaceName, kind: 'interface', line: node.startPosition.row + 1, endLine: nodeEndLine(node), visibility: extractModifierVisibility(node), }); + + // `interface X extends Y, Z` — tree-sitter-groovy 0.1.x exposes parent + // interfaces via an unnamed `extends_interfaces` child (not a field), which + // wraps a `type_list` of `_type` nodes. Mirrors the Rust extractor. + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && child.type === 'extends_interfaces') { + collectGroovyParentInterfaces(child, ifaceName, ctx); + break; + } + } +} + +function collectGroovyParentInterfaces( + parent: TreeSitterNode, + name: string, + ctx: ExtractorOutput, +): void { + // Use the current node's start line at each recursion level — matches the + // Rust `collect_interfaces` helper, which re-evaluates `start_line(interfaces)` + // for whatever node (`extends_interfaces` → `type_list`) is being processed. + const line = parent.startPosition.row + 1; + for (let i = 0; i < parent.childCount; i++) { + const child = parent.child(i); + if (!child) continue; + switch (child.type) { + case 'type_identifier': + case 'identifier': + case 'scoped_type_identifier': { + ctx.classes.push({ name, implements: child.text, line }); + break; + } + case 'generic_type': { + const inner = child.child(0)?.text; + if (inner) ctx.classes.push({ name, implements: inner, line }); + break; + } + case 'type_list': + collectGroovyParentInterfaces(child, name, ctx); + break; + } + } } function handleGroovyEnumDecl(node: TreeSitterNode, ctx: ExtractorOutput): void { diff --git a/tests/parsers/groovy.test.ts b/tests/parsers/groovy.test.ts index 60c86919..42d85753 100644 --- a/tests/parsers/groovy.test.ts +++ b/tests/parsers/groovy.test.ts @@ -50,6 +50,34 @@ describe('Groovy parser', () => { ); }); + it('extracts interface inheritance (extends_interfaces)', () => { + // `interface X extends Y, Z` — the grammar exposes parent interfaces via + // an unnamed `extends_interfaces` child (not a field), distinct from class + // declarations which use the `interfaces` field. + const symbols = parseGroovy(`interface Serializable extends Comparable, Cloneable {}`); + const rels = symbols.classes.filter((c) => c.name === 'Serializable'); + expect(rels).toContainEqual( + expect.objectContaining({ name: 'Serializable', implements: 'Comparable' }), + ); + expect(rels).toContainEqual( + expect.objectContaining({ name: 'Serializable', implements: 'Cloneable' }), + ); + }); + + it('reports line of extends_interfaces clause for multi-line declarations', () => { + // Engine-parity guard: the line should match the `extends_interfaces` + // node's start line, not the `interface_declaration`'s start line, so the + // WASM extractor stays consistent with the Rust `collect_interfaces` + // helper which re-evaluates the current node's `start_line` at each + // recursion level. + const symbols = parseGroovy(`interface Serializable\n extends Comparable, Cloneable {}`); + const rels = symbols.classes.filter((c) => c.name === 'Serializable'); + expect(rels.length).toBeGreaterThan(0); + for (const rel of rels) { + expect(rel.line).toBe(2); + } + }); + it('extracts enum declarations', () => { const symbols = parseGroovy(`enum Color { RED, GREEN, BLUE