Skip to content
Open
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
9 changes: 6 additions & 3 deletions packages/core/src/repowise/core/ingestion/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -1317,9 +1317,12 @@ def _extract_calls(
for call in calls:
key = (call.line, call.target_name, call.receiver_name)
existing = deduplicated.get(key)
# Two patterns match a scoped call (one keeps the qualifier, one does
# not) and both dedup to this key, so the richer record has to win
# whichever order they arrive in.
# Two of the three scoped-call patterns can match the same two-part call
# (one keeps the qualifier, one does not) and both dedup to this key, so
# the richer record has to win whichever order they arrive in. The
# three-part pattern (ns::util::fn()) never collides here, it's the
# only pattern that can match a nested qualified_identifier, so it
# always lands as a fresh key.
if (
existing is None
or (existing.receiver_call is None and call.receiver_call is not None)
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/repowise/core/ingestion/queries/cpp.scm
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,25 @@
arguments: (argument_list) @call.arguments
) @call.site

; The same call once more, for a three-part qualifier: ns::util::fn(args).
; The grammar nests qualified_identifier left-recursively (see the two-level
; qualified function definition above), so the outer node's name field is
; itself a qualified_identifier rather than an identifier, neither pattern
; above can match it, and no call site was produced at all for this shape
; (#1918). Capturing the innermost identifier as the target and its adjacent
; scope as the qualifier mirrors how a two-part call is already captured;
; the deeper namespace prefix (ns) is dropped the same way the class-name
; extractor already drops it for definitions.
(call_expression
function: (qualified_identifier
name: (qualified_identifier
scope: (namespace_identifier) @call.scope
name: (identifier) @call.target
)
)
arguments: (argument_list) @call.arguments
) @call.site

; Chained call: obj.method1().method2(args)
(call_expression
function: (field_expression
Expand Down
57 changes: 57 additions & 0 deletions tests/unit/ingestion/test_cpp_scoped_calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,60 @@ def test_qualifier_naming_no_known_class_still_resolves_by_other_tiers(
)
targets = _call_targets(_build(tmp_path), "main.cc")
assert any(t.endswith("Helper") for t in targets)

class TestThreePartQualifiedCallProducesACallSite:
"""A three-part qualified call like ``ns::util::toHex()`` produced no
call site at all, not merely an unresolved one (#1918).

``cpp.scm``'s two scoped-call patterns require ``name: (identifier)``.
tree-sitter nests a three-part qualifier left-recursively, so the outer
node's ``name`` field is itself a ``qualified_identifier`` and neither
pattern matches. The call never reaches the resolver, so it cannot be
resolved, declined, or counted -- it simply is never produced as a
``CallSite``.
"""

def _repo(self, root: Path) -> None:
(root / "util.h").write_text(
"#pragma once\nnamespace ns { namespace util {\nint toHex(int x);\n}}\n"
)
(root / "util.cc").write_text(
'#include "util.h"\nnamespace ns { namespace util {\n'
"int toHex(int x) { return x; }\n}}\n"
)
(root / "main.cc").write_text(
'#include "util.h"\n'
"int callTwoPart() { return util::toHex(1); }\n"
"int callThreePart() { return ns::util::toHex(2); }\n"
)

def test_three_part_call_resolves_to_the_function(self, tmp_path: Path) -> None:
self._repo(tmp_path)
graph = _build(tmp_path)
assert any(
s.endswith("callThreePart") and t.endswith("toHex")
for s, t, d in graph.edges(data=True)
if d.get("edge_type") == "calls"
)

def test_two_part_control_is_unaffected(self, tmp_path: Path) -> None:
"""The three-part pattern must not crowd out the existing two-part
match -- each caller keeps its own distinct edge to toHex."""
self._repo(tmp_path)
graph = _build(tmp_path)
assert any(
s.endswith("callTwoPart") and t.endswith("toHex")
for s, t, d in graph.edges(data=True)
if d.get("edge_type") == "calls"
)

def test_unqualified_control_is_unaffected(self, tmp_path: Path) -> None:
(tmp_path / "lib.h").write_text("#pragma once\nint free_thing(int x);\n")
(tmp_path / "lib.cc").write_text(
'#include "lib.h"\nint free_thing(int x) { return x; }\n'
)
(tmp_path / "caller.cc").write_text(
'#include "lib.h"\nint main() { return free_thing(1); }\n'
)
targets = _call_targets(_build(tmp_path), "caller.cc")
assert any(t.endswith("free_thing") for t in targets)