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
50 changes: 45 additions & 5 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -2371,6 +2371,37 @@ def _has_multiline_error(root) -> bool:
return False


def _csharp_name_without_type_args(node, source: bytes) -> str | None:
"""The bare name of a C# call-site name node, with any type-argument list off.

A call site may spell explicit type arguments -- ``Registry.Fetch<Payload>(...)``,
``Bag<Payload>.Make(...)``, ``Local<Payload>(...)`` -- and tree-sitter wraps those
in a ``generic_name``. Declarations store the bare name (``.Fetch()``, ``Bag``),
so reading the node verbatim yields ``Fetch<Payload>``, which never matches its
declaration and silently drops the ``calls`` edge (#2624). Type inference at the
call site was unaffected, which is why only the explicit form broke.

The grammar exposes no ``name`` field on ``generic_name``, so fall back to the
first ``identifier`` child -- the same order ``_csharp_collect_type_refs`` uses.
"""
if node is None:
return None
if node.type != "generic_name":
return _read_text(node, source)
name_child = node.child_by_field_name("name")
if name_child is None:
for sub in node.children:
if sub.type == "identifier":
name_child = sub
break
if name_child is not None:
return _read_text(name_child, source)
# A qualified generic (`Demo.Bag<Payload>`) has no bare `identifier` child. Keep
# the text and drop only the type-argument list, so such a call is still recorded
# under the name it had before rather than disappearing.
return _read_text(node, source).split("<", 1)[0] or None


def _read_csharp_type_name(node, source: bytes) -> tuple[str, bool, str] | None:
"""Resolve a C# type name, whether it was qualified, and its qualifier prefix."""
if node is None:
Expand Down Expand Up @@ -4647,10 +4678,16 @@ def walk_calls(
mname = fn_node.child_by_field_name("name")
recv = fn_node.child_by_field_name("expression")
if mname is not None:
callee_name = _read_text(mname, source)
# `name` is a generic_name when the call site spells explicit
# type arguments (`recv.Read<Payload>()`); the declaration is
# stored bare, so strip the type-argument list (#2624).
callee_name = _csharp_name_without_type_args(mname, source)
is_member_call = True
if recv is not None and recv.type == "identifier":
member_receiver = _read_text(recv, source)
if recv is not None and recv.type in ("identifier", "generic_name"):
# generic_name: a constructed generic type as the receiver
# (`Bag<Payload>.Make()`). Type nodes are stored under the
# bare name, so this binds like any `Type.M()` (#2624).
member_receiver = _csharp_name_without_type_args(recv, source)
elif recv is not None and recv.type in ("this", "this_expression"):
member_receiver = "this"
elif recv is not None and recv.type in ("base", "base_expression"):
Expand All @@ -4671,8 +4708,11 @@ def walk_calls(
and fname.type == "identifier"
):
member_receiver = _read_text(fname, source)
elif fn_node is not None and fn_node.type == "identifier":
callee_name = _read_text(fn_node, source)
elif fn_node is not None and fn_node.type in ("identifier", "generic_name"):
# generic_name: an unqualified call carrying explicit type
# arguments (`Local<Payload>()`), which otherwise fell through to
# the raw-text fallback below and kept the `<...>` (#2624).
callee_name = _csharp_name_without_type_args(fn_node, source)
else:
# Fallback: original name-field / first-named-child scan.
name_node = node.child_by_field_name("name")
Expand Down
98 changes: 98 additions & 0 deletions tests/test_csharp_member_calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -668,3 +668,101 @@ def test_sibling_pattern_rebind_conflict_poisons(tmp_path):
twig_go = _find(r, ".Go()", "twig")
assert (r_a, sect_go) not in calls, "conflicting pattern bindings must poison the name"
assert (r_a, twig_go) not in calls, "conflicting pattern bindings must poison the name"


# --- #2624: explicit type arguments at the call site -------------------------
# `X.M<T>(...)` spells a type-argument list, which tree-sitter wraps in a
# `generic_name`. Declarations are stored bare (`.Fetch()`, `Bag`), so reading the
# node verbatim gave `Fetch<Payload>` and the lookup never matched — the edge
# silently vanished. Type inference at the call site (`Registry.Pick(item)`) was
# unaffected, which is why only the explicit form broke.

_GENERIC = (
"public class Payload { public int Size; }\n"
"public static class Registry {\n"
" public static bool Fetch<TItem>(string key) { return true; }\n"
" public static bool Has(string key) { return true; }\n"
"}\n"
"public class Box { public bool Read<TItem>(string key) { return true; } }\n"
"public static class Bag<TItem> { public static bool Make(string key) { return true; } }\n"
)


def test_explicit_type_argument_on_type_receiver_call(tmp_path):
"""`Type.M<T>()` binds like `Type.M()` — type arguments are not part of the
method name, and the non-generic control must keep working (#2624)."""
calls, r = _calls(tmp_path, {"S.cs": _GENERIC + (
"public class Consumer {\n"
" public bool A() { return Registry.Fetch<Payload>(\"k\"); }\n"
" public bool C() { return Registry.Has(\"k\"); }\n"
"}\n"
)})
a, c = _find(r, ".A()", "consumer"), _find(r, ".C()", "consumer")
assert (a, _find(r, ".Fetch()", "registry")) in calls, \
"explicit type arguments must not drop the calls edge"
assert (c, _find(r, ".Has()", "registry")) in calls, "control: non-generic call"


def test_explicit_type_argument_on_typed_local_receiver(tmp_path):
"""A receiver typed through the method-scoped table still resolves when the
call carries explicit type arguments (#2624)."""
calls, r = _calls(tmp_path, {"S.cs": _GENERIC + (
"public class Consumer {\n"
" public bool B() { Box box = new Box(); return box.Read<Payload>(\"k\"); }\n"
"}\n"
)})
b = _find(r, ".B()", "consumer")
assert (b, _find(r, ".Read()", "box")) in calls


def test_constructed_generic_type_as_receiver(tmp_path):
"""`Bag<Payload>.Make()` — the RECEIVER carries the type arguments. Type nodes
are stored under the bare name, so it must bind like any `Type.M()` (#2624).

Split across two files on purpose: in a single file the callee resolves by a
plain in-file label match and never reaches receiver typing, so a same-file
version of this test passes even with the receiver dropped.
"""
calls, r = _calls(tmp_path, {
"Lib.cs": _GENERIC,
"Consumer.cs": (
"public class Consumer {\n"
" public bool E() { return Bag<Payload>.Make(\"k\"); }\n"
"}\n"
),
})
e = _find(r, ".E()", "consumer")
assert (e, _find(r, ".Make()", "bag")) in calls


def test_unqualified_generic_call_and_this_receiver(tmp_path):
"""`Local<T>()` and `this.Local<T>()` — the same defect reached calls with no
receiver at all, which fell through to the raw-text scan and kept the `<...>`."""
calls, r = _calls(tmp_path, {"S.cs": _GENERIC + (
"public class Consumer {\n"
" public bool Local<TItem>(string k) { return true; }\n"
" public bool F() { return Local<Payload>(\"k\"); }\n"
" public bool H() { return this.Local<Payload>(\"k\"); }\n"
"}\n"
)})
local = _find(r, ".Local()", "consumer")
assert (_find(r, ".F()", "consumer"), local) in calls
assert (_find(r, ".H()", "consumer"), local) in calls


def test_type_arguments_do_not_bypass_receiver_typing(tmp_path):
"""Stripping the type-argument list must not weaken resolution: a generic call
on a typed field still binds to that field's type, never a same-named method on
an unrelated class (the #1609 guard, now exercised through a generic call)."""
calls, r = _calls(tmp_path, {"S.cs": (
"public class Server { public bool Save<T>() => true; }\n"
"public class Cache { public bool Save<T>() => false; }\n"
"public class Repo {\n"
" private Server _server = new Server();\n"
" public bool Commit() { return _server.Save<int>(); }\n"
"}\n"
)})
commit = _find(r, ".Commit()", "commit")
assert (commit, _find(r, ".Save()", "server")) in calls
assert (commit, _find(r, ".Save()", "cache")) not in calls, \
"a generic call must not mis-bind to an unrelated same-named method"
Loading