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
142 changes: 137 additions & 5 deletions src/graph/emit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,11 +223,36 @@ impl Classifier<'_> {
match self {
// The one Rust node kind that is callable-shaped but bodiless.
Classifier::Rust => node.kind() != "function_signature_item",
// Tags-driven languages capture definitions, and an abstract/interface method that the
// grammar's `tags.scm` reports is still reported as a definition; leave them as targets
// rather than guess per-grammar. Revisit if a language shows the same duplicate-name
// regression Rust would have had.
Classifier::Tagged(_) => true,
// A captured definition of one of these node KINDS with no `body` child is a
// declaration, not an implementation — a call dispatches to an implementation, never to
// the declaration itself (issue #5, the tags-path twin of the Rust case above). Verified
// directly against each grammar's `node-types.json`:
// - Java `method_declaration`: `body` is an OPTIONAL field, present only when the
// method has one — an interface/`abstract` method has none.
// - TypeScript `method_signature` / `abstract_method_signature` (interface and
// abstract-class members) / `function_signature` (ambient/overload declarations):
// these node kinds have no `body` field at all — they are inherently bodiless.
//
// The check is scoped to these specific node kinds rather than applied to every `Tagged`
// definition unconditionally, because `child_by_field_name("body")` can't distinguish "no
// body field on this node kind at all" from "the field exists and is absent" — and several
// JS `tags.scm` patterns anchor `@definition.function` on a WRAPPER node with no `body`
// field concept of its own (`variable_declarator` for `const f = () => {}`,
// `assignment_expression` for `x.f = function(){}`, `pair` for `{ f() {} }`-style object
// methods) even though the function VALUE they wrap always has one. Treating those as
// bodiless would have wrongly dropped every const-arrow-function call target — caught by
// the `javascript-repo` golden gaining a spurious drop before this list was narrowed.
// Every other tagged definition shape (JS/TS bodied methods/functions via any capture
// pattern, and Python's always-bodied `function_definition` — deliberately unaffected,
// since even a `pass`/`...`-bodied abstract method has a body child) has no
// bodiless-declaration concept: leave it as a target, matching the pre-fix behaviour.
Classifier::Tagged(_) => match node.kind() {
"method_declaration"
| "method_signature"
| "abstract_method_signature"
| "function_signature" => node.child_by_field_name("body").is_some(),
_ => true,
},
}
}

Expand Down Expand Up @@ -456,6 +481,113 @@ mod tests {
);
}

#[test]
fn tagged_bodiless_interface_method_is_a_node_with_a_method_edge() {
// REGRESSION (issue #5, the tags-path twin of the Rust
// `signature_only_trait_method_is_a_node_with_a_method_edge` case above): a Java interface
// method (`method_declaration` with no `body` field) used to be treated as a call target
// regardless — it must still be a definition (a node + `method` edge), just not a call
// target (see the next test).
let src = "interface Greeter {\n String greet();\n}\n";
let facts = facts_for("java", "Greeter.java", src);
let iface = facts.nodes.iter().find(|n| n.label == "Greeter").unwrap();
let greet = facts
.nodes
.iter()
.find(|n| n.label == "greet()")
.expect("bodiless interface method must produce a node");
assert_eq!(greet.start_line, 2);
assert!(
facts.contains.iter().any(|e| e.relation == "method"
&& e.source == iface.node_id
&& e.target == greet.node_id),
"Greeter → greet must be a `method` edge; got {:?}",
facts.contains
);
}

#[test]
fn tagged_bodiless_interface_method_is_not_a_call_target() {
// It is callable-SHAPED (the node + `method` edge above) but must not enter the resolver's
// candidate set: a call dispatches to an implementation, never to the declaration. See
// `Classifier::is_call_target`.
let src = "interface Greeter {\n String greet();\n}\n";
let facts = facts_for("java", "Greeter.java", src);
assert!(
facts.callables.is_empty(),
"a bodiless interface method must not be a call target; got {:?}",
facts.callables
);
}

#[test]
fn tagged_bodied_method_next_to_its_interface_declaration_is_still_the_only_call_target() {
// The tags-path twin of the Rust
// `a_bodied_method_next_to_its_declaration_is_still_the_only_call_target` case above: a
// single implementation must remain the ONLY call target even though its declaration is
// also indexed as a node — otherwise a single-impl interface would go from one candidate to
// two the moment declarations were indexed, and the precision-favouring resolver would drop
// every call to it (issue #5).
let src = "\
interface Greeter {
String greet();
}

class EnglishGreeter implements Greeter {
public String greet() { return \"hello\"; }
}
";
let facts = facts_for("java", "Greeter.java", src);
assert_eq!(
facts.callables.len(),
1,
"exactly one call target (the impl) must survive; got {:?}",
facts.callables
);
let surviving_line = facts
.nodes
.iter()
.find(|n| n.node_id == facts.callables[0].node_id)
.expect("callable node id must match an emitted node")
.start_line;
let impl_method_line = facts
.nodes
.iter()
.filter(|n| n.label == "greet()")
.map(|n| n.start_line)
.max()
.expect("both declaration and impl must be nodes");
assert_eq!(
surviving_line, impl_method_line,
"the surviving call target must be the later-declared impl, not the interface \
declaration; got {:?}",
facts.callables
);
// Both defs still exist as nodes — the declaration is indexed, just not dispatched to.
assert_eq!(
facts.nodes.iter().filter(|n| n.label == "greet()").count(),
2,
"declaration and implementation must BOTH be nodes; got {:?}",
facts.nodes
);
}

#[test]
fn tagged_bodied_method_is_still_a_call_target() {
// Sanity check paired with the bodiless tests above: a normal, bodied Java method (`{ ... }`)
// must remain a call target exactly as before this fix — the `body`-field check must not
// exclude ordinary definitions.
let src = "class C {\n void m() {}\n}\n";
let facts = facts_for("java", "C.java", src);
assert_eq!(
facts.callables.len(),
1,
"a bodied method must still be a call target; got {:?}",
facts.callables
);
assert_eq!(facts.callables[0].name, "m");
}

#[test]
fn def_line_numbers_are_one_based() {
let facts = facts_for("rust", "src/m.rs", "\n\nfn add() {}\n"); // add on source line 3
Expand Down
165 changes: 165 additions & 0 deletions src/graph/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -701,3 +701,168 @@ fn a_call_to_a_single_impl_trait_method_still_resolves_to_the_impl() {
"must resolve to the impl in circle.rs, not the declaration; got {targets:?}"
);
}

// ── Tags path: declaration vs. call target (issue #5, the tags-path twin of the Rust case above) ──

#[test]
fn java_call_to_a_single_impl_interface_method_resolves_to_the_implementation() {
// The actual bug (issue #5): before the fix, the interface declaration and its sole
// implementation both registered as call targets under the bare name `greet`, so the
// precision-favouring resolver saw two same-named candidates with no disambiguating qualifier
// and dropped the call as ambiguous. A BARE call is used deliberately (mirroring the Rust
// twin's `c.describe()`, which also carries no qualifier): it isolates the exact mechanism this
// PR fixes — one candidate instead of two — from the tags path's separate qualifier heuristic
// (see `java_call_through_an_interface_typed_variable_needs_a_qualifier_match`, below, for why
// the issue's own `g.greet()` receiver-variable phrasing needs a different fixture).
let greeter = (
"Greeter.java",
"interface Greeter {\n String greet();\n}\n",
);
let english_greeter = (
"EnglishGreeter.java",
"class EnglishGreeter implements Greeter {\n public String greet() { return \"hello\"; }\n}\n",
);
let main = (
"Main.java",
"class Main {\n String run() {\n return greet();\n }\n}\n",
);
let g = graph_of_lang("java", &[greeter, english_greeter, main]);

// Both the declaration and the implementation are indexed as nodes.
let greets: Vec<&GraphNode> = g.nodes.iter().filter(|n| n.label == "greet()").collect();
assert_eq!(
greets.len(),
2,
"declaration and implementation must both be indexed as nodes; got {greets:?}"
);

// ...but the call resolves, and resolves to the IMPLEMENTATION, not the declaration.
let run = node(&g, "run()");
let calls: Vec<_> = g
.edges
.iter()
.filter(|e| e.relation == "calls" && e.source == run.node_id)
.collect();
assert_eq!(
calls.len(),
1,
"the call must resolve to exactly one target; got {calls:?}"
);
let target = g
.nodes
.iter()
.find(|n| n.node_id == calls[0].target)
.expect("call target must be an emitted node");
assert_eq!(
target.source_file, "EnglishGreeter.java",
"must resolve to the implementation, not the interface declaration; edges = {:?}",
g.edges
);
}

#[test]
fn java_call_through_an_interface_typed_variable_needs_a_qualifier_match() {
// FINDING, not fixed here (out of scope — "any change to the resolver's ambiguity policy" is
// excluded from issue #5): the issue's own literal reproduction, `g.greet()` where
// `g: Greeter`, still does NOT resolve after this fix. `resolve::pick`'s single-candidate
// branch rejects a candidate whose scope doesn't textually equal the call's qualifier, and
// `qualifier_from_callee_node` sets the qualifier to the raw receiver identifier — here `g`,
// the PARAMETER name, not `EnglishGreeter`, the implementing type. There is no type inference in
// the tags path, so a receiver variable can never textually match the type that defines the
// method it calls. This test documents the boundary of this PR's fix, not a regression: it
// passed (found nothing) before this change too, for the SAME underlying reason plus the
// declaration-vs-target ambiguity this PR removes — dropping from "ambiguous" to "unresolved"
// is not a functional improvement for this exact call shape. Calling through the class name
// (`EnglishGreeter.greet()`, next test, and the `java-interface-repo` golden fixture) is the
// shape that already works, matching this codebase's existing `Widget.build()`-style qualified
// calls in `tests/fixtures/java-repo`.
let greeter = (
"Greeter.java",
"interface Greeter {\n String greet();\n}\n",
);
let english_greeter = (
"EnglishGreeter.java",
"class EnglishGreeter implements Greeter {\n public String greet() { return \"hello\"; }\n}\n",
);
let main = (
"Main.java",
"class Main {\n void run(Greeter g) {\n g.greet();\n }\n}\n",
);
let g = graph_of_lang("java", &[greeter, english_greeter, main]);
assert!(
!g.edges.iter().any(|e| e.relation == "calls"),
"documents a known, pre-existing, separate limitation — not asserting desired behaviour; \
edges = {:?}",
g.edges
);
}

#[test]
fn java_call_to_an_interface_method_with_no_implementation_does_not_resolve() {
// The direct converse of the fix: a declaration alone — no implementation anywhere — must never
// be treated as a call target. Without the fix this would have been the sole (and therefore
// "successfully resolving") candidate, silently dispatching a call to a declaration that has no
// body to run.
let g = graph_of_lang(
"java",
&[(
"Greeter.java",
"interface Greeter {\n String greet();\n}\nclass Main {\n String run() {\n return greet();\n }\n}\n",
)],
);
assert!(
!g.edges.iter().any(|e| e.relation == "calls"),
"a call to a declaration-only method must not resolve; edges = {:?}",
g.edges
);
}

#[test]
fn typescript_call_to_a_single_impl_interface_method_resolves_to_the_implementation() {
// The TypeScript twin of the Java case above. Verified against `tree-sitter-typescript`'s
// `node-types.json`: `method_signature` (interface members) and `abstract_method_signature`
// (abstract-class members) have no `body` field at all, while `method_definition` requires one
// — the same bodiless-declaration shape as Java, captured by the SAME `is_call_target` check.
let greeter = (
"greeter.ts",
"export interface Greeter {\n greet(): string;\n}\n",
);
let english_greeter = (
"english-greeter.ts",
"export class EnglishGreeter implements Greeter {\n greet(): string {\n return 'hello';\n }\n}\n",
);
let main = (
"main.ts",
"function run(): string {\n return greet();\n}\n",
);
let g = graph_of_lang("typescript", &[greeter, english_greeter, main]);

let greets: Vec<&GraphNode> = g.nodes.iter().filter(|n| n.label == "greet()").collect();
assert_eq!(
greets.len(),
2,
"declaration and implementation must both be indexed as nodes; got {greets:?}"
);

let run = node(&g, "run()");
let calls: Vec<_> = g
.edges
.iter()
.filter(|e| e.relation == "calls" && e.source == run.node_id)
.collect();
assert_eq!(
calls.len(),
1,
"the call must resolve to exactly one target; got {calls:?}"
);
let target = g
.nodes
.iter()
.find(|n| n.node_id == calls[0].target)
.expect("call target must be an emitted node");
assert_eq!(
target.source_file, "english-greeter.ts",
"must resolve to the implementation, not the interface declaration; edges = {:?}",
g.edges
);
}
5 changes: 5 additions & 0 deletions tests/fixtures/java-interface-repo/EnglishGreeter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class EnglishGreeter implements Greeter {
public String greet() {
return "hello";
}
}
3 changes: 3 additions & 0 deletions tests/fixtures/java-interface-repo/Greeter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
interface Greeter {
String greet();
}
14 changes: 14 additions & 0 deletions tests/fixtures/java-interface-repo/Main.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class Main {
// Qualified call, matching this fixture set's house style (see java-repo's `Widget.build()`).
String run() {
return EnglishGreeter.greet();
}

// Bare call to the SAME single-implementation interface method. Before the fix, `greet` had two
// candidates (the `Greeter` declaration and the `EnglishGreeter` implementation) and no qualifier
// to disambiguate them, so this was dropped as ambiguous — issue #5. A declaration is now excluded
// from the candidate set, leaving exactly one, so this resolves.
String runBare() {
return greet();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class EnglishGreeter implements Greeter {
greet(): string {
return 'hello';
}
}
3 changes: 3 additions & 0 deletions tests/fixtures/typescript-interface-repo/src/greeter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface Greeter {
greet(): string;
}
14 changes: 14 additions & 0 deletions tests/fixtures/typescript-interface-repo/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { EnglishGreeter } from './english-greeter';

// Qualified call, matching this fixture set's house style (see typescript-repo's `Widget.build()`).
function run(): string {
return EnglishGreeter.greet();
}

// Bare call to the SAME single-implementation interface method. Before the fix, `greet` had two
// candidates (the `Greeter` declaration and the `EnglishGreeter` implementation) and no qualifier to
// disambiguate them, so this was dropped as ambiguous — issue #5. A declaration is now excluded from
// the candidate set, leaving exactly one, so this resolves.
function runBare(): string {
return greet();
}
Loading