Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion crates/codegraph-core/src/extractors/groovy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
}
}
}
45 changes: 44 additions & 1 deletion src/extractors/groovy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Comment on lines +166 to +194
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Line number diverges from Rust engine on multi-line declarations

collectGroovyParentInterfaces receives line once (the interface declaration's startPosition.row + 1) and forwards the same value through every recursive call into type_list children. The Rust collect_interfaces instead evaluates start_line(interfaces) at each recursive invocation, so it tracks the actual start line of whatever node (extends_interfacestype_list) is being processed at that moment.

For a multi-line interface — e.g. interface X\n extends Y, Z {} — the Rust engine would emit line 2 for the relation, while the TS engine always emits line 1. This is currently benign (the downstream HIERARCHY_SOURCE_KINDS filter doesn't include interface), but it means the two engines will produce divergent ClassRelation.line values once that filter is extended.

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 732c82dcollectGroovyParentInterfaces no longer accepts a fixed line parameter; it now derives line = parent.startPosition.row + 1 at each recursion level, matching Rust's collect_interfaces which re-evaluates start_line(interfaces) per call. Added an engine-parity guard test (interface X\n extends Y, Z {} → line 2) in both Rust and WASM test suites so this divergence cannot regress.

}

function handleGroovyEnumDecl(node: TreeSitterNode, ctx: ExtractorOutput): void {
Expand Down
28 changes: 28 additions & 0 deletions tests/parsers/groovy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading