Skip to content

Commit 81150e4

Browse files
authored
fix(groovy): emit ClassRelation for interface inheritance in both engines (#1158)
* fix(groovy): emit ClassRelation for interface inheritance in both engines `interface X extends Y, Z` produced no class-graph edges because both extractors pushed only a Definition for the interface and never inspected the unnamed `extends_interfaces` child. The Rust handler now reuses `collect_interfaces` (which already recurses into `type_list`); the TS handler gains an equivalent helper that mirrors the same traversal. Closes #1115 * fix(groovy): use current node's start line in WASM interface helper (#1158) Align the TypeScript collectGroovyParentInterfaces helper with the Rust collect_interfaces helper, which re-evaluates start_line(interfaces) at each recursive invocation. Previously the WASM extractor forwarded the interface declaration's start line through every recursion level, so a multi-line interface (e.g. interface X\n extends Y, Z {}) produced line=1 in WASM but line=2 in native — the two engines must produce identical results. Add a multi-line line-parity test in both engines to guard against future regressions.
1 parent c524351 commit 81150e4

3 files changed

Lines changed: 119 additions & 2 deletions

File tree

crates/codegraph-core/src/extractors/groovy.rs

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,9 @@ fn collect_interfaces(
158158

159159
fn handle_interface_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) {
160160
let Some(name_node) = node.child_by_field_name("name") else { return };
161+
let iface_name = node_text(&name_node, source).to_string();
161162
symbols.definitions.push(Definition {
162-
name: node_text(&name_node, source).to_string(),
163+
name: iface_name.clone(),
163164
kind: "interface".to_string(),
164165
line: start_line(node),
165166
end_line: Some(end_line(node)),
@@ -168,6 +169,18 @@ fn handle_interface_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols)
168169
cfg: None,
169170
children: None,
170171
});
172+
173+
// `interface X extends Y, Z` — tree-sitter-groovy 0.1.x exposes parent
174+
// interfaces as an unnamed `extends_interfaces` child wrapping a `type_list`.
175+
// collect_interfaces already recurses into `type_list`, so passing the
176+
// wrapper node works without a dedicated helper.
177+
for i in 0..node.child_count() {
178+
let Some(child) = node.child(i) else { continue };
179+
if child.kind() == "extends_interfaces" {
180+
collect_interfaces(&child, &iface_name, source, symbols);
181+
break;
182+
}
183+
}
171184
}
172185

173186
fn handle_enum_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) {
@@ -523,4 +536,37 @@ mod tests {
523536
assert!(rels.iter().any(|c| c.implements.as_deref() == Some("I1")));
524537
assert!(rels.iter().any(|c| c.implements.as_deref() == Some("I2")));
525538
}
539+
540+
#[test]
541+
fn extracts_interface_inheritance() {
542+
// `interface X extends Y, Z` — the grammar exposes parent interfaces
543+
// via an unnamed `extends_interfaces` child (not a field), distinct
544+
// from class declarations which use the `interfaces` field.
545+
let s = parse_groovy("interface Serializable extends Comparable, Cloneable {}");
546+
let rels: Vec<_> = s.classes.iter().filter(|c| c.name == "Serializable").collect();
547+
assert!(
548+
rels.iter().any(|c| c.implements.as_deref() == Some("Comparable")),
549+
"missing implements=Comparable, got: {:?}",
550+
rels
551+
);
552+
assert!(
553+
rels.iter().any(|c| c.implements.as_deref() == Some("Cloneable")),
554+
"missing implements=Cloneable, got: {:?}",
555+
rels
556+
);
557+
}
558+
559+
#[test]
560+
fn interface_inheritance_line_tracks_extends_clause() {
561+
// Engine-parity guard: the relation line should match the
562+
// `extends_interfaces` node's start line, not the `interface_declaration`'s
563+
// — `collect_interfaces` re-evaluates `start_line(interfaces)` on every
564+
// recursive call, and the WASM extractor must match.
565+
let s = parse_groovy("interface Serializable\n extends Comparable, Cloneable {}");
566+
let rels: Vec<_> = s.classes.iter().filter(|c| c.name == "Serializable").collect();
567+
assert!(!rels.is_empty(), "expected at least one ClassRelation");
568+
for rel in &rels {
569+
assert_eq!(rel.line, 2, "line should track the extends clause, got: {:?}", rel);
570+
}
571+
}
526572
}

src/extractors/groovy.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,14 +141,57 @@ function handleGroovyClassDecl(node: TreeSitterNode, ctx: ExtractorOutput): void
141141
function handleGroovyInterfaceDecl(node: TreeSitterNode, ctx: ExtractorOutput): void {
142142
const nameNode = node.childForFieldName('name');
143143
if (!nameNode) return;
144+
const ifaceName = nameNode.text;
144145

145146
ctx.definitions.push({
146-
name: nameNode.text,
147+
name: ifaceName,
147148
kind: 'interface',
148149
line: node.startPosition.row + 1,
149150
endLine: nodeEndLine(node),
150151
visibility: extractModifierVisibility(node),
151152
});
153+
154+
// `interface X extends Y, Z` — tree-sitter-groovy 0.1.x exposes parent
155+
// interfaces via an unnamed `extends_interfaces` child (not a field), which
156+
// wraps a `type_list` of `_type` nodes. Mirrors the Rust extractor.
157+
for (let i = 0; i < node.childCount; i++) {
158+
const child = node.child(i);
159+
if (child && child.type === 'extends_interfaces') {
160+
collectGroovyParentInterfaces(child, ifaceName, ctx);
161+
break;
162+
}
163+
}
164+
}
165+
166+
function collectGroovyParentInterfaces(
167+
parent: TreeSitterNode,
168+
name: string,
169+
ctx: ExtractorOutput,
170+
): void {
171+
// Use the current node's start line at each recursion level — matches the
172+
// Rust `collect_interfaces` helper, which re-evaluates `start_line(interfaces)`
173+
// for whatever node (`extends_interfaces` → `type_list`) is being processed.
174+
const line = parent.startPosition.row + 1;
175+
for (let i = 0; i < parent.childCount; i++) {
176+
const child = parent.child(i);
177+
if (!child) continue;
178+
switch (child.type) {
179+
case 'type_identifier':
180+
case 'identifier':
181+
case 'scoped_type_identifier': {
182+
ctx.classes.push({ name, implements: child.text, line });
183+
break;
184+
}
185+
case 'generic_type': {
186+
const inner = child.child(0)?.text;
187+
if (inner) ctx.classes.push({ name, implements: inner, line });
188+
break;
189+
}
190+
case 'type_list':
191+
collectGroovyParentInterfaces(child, name, ctx);
192+
break;
193+
}
194+
}
152195
}
153196

154197
function handleGroovyEnumDecl(node: TreeSitterNode, ctx: ExtractorOutput): void {

tests/parsers/groovy.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,34 @@ describe('Groovy parser', () => {
5050
);
5151
});
5252

53+
it('extracts interface inheritance (extends_interfaces)', () => {
54+
// `interface X extends Y, Z` — the grammar exposes parent interfaces via
55+
// an unnamed `extends_interfaces` child (not a field), distinct from class
56+
// declarations which use the `interfaces` field.
57+
const symbols = parseGroovy(`interface Serializable extends Comparable, Cloneable {}`);
58+
const rels = symbols.classes.filter((c) => c.name === 'Serializable');
59+
expect(rels).toContainEqual(
60+
expect.objectContaining({ name: 'Serializable', implements: 'Comparable' }),
61+
);
62+
expect(rels).toContainEqual(
63+
expect.objectContaining({ name: 'Serializable', implements: 'Cloneable' }),
64+
);
65+
});
66+
67+
it('reports line of extends_interfaces clause for multi-line declarations', () => {
68+
// Engine-parity guard: the line should match the `extends_interfaces`
69+
// node's start line, not the `interface_declaration`'s start line, so the
70+
// WASM extractor stays consistent with the Rust `collect_interfaces`
71+
// helper which re-evaluates the current node's `start_line` at each
72+
// recursion level.
73+
const symbols = parseGroovy(`interface Serializable\n extends Comparable, Cloneable {}`);
74+
const rels = symbols.classes.filter((c) => c.name === 'Serializable');
75+
expect(rels.length).toBeGreaterThan(0);
76+
for (const rel of rels) {
77+
expect(rel.line).toBe(2);
78+
}
79+
});
80+
5381
it('extracts enum declarations', () => {
5482
const symbols = parseGroovy(`enum Color {
5583
RED, GREEN, BLUE

0 commit comments

Comments
 (0)