Skip to content
Closed
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
32 changes: 27 additions & 5 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -3180,6 +3180,28 @@ def _csharp_bare_call_name(name_node, source: bytes) -> str:
return _read_text(name_node, source)


def _csharp_member_call_parts(fn_node):
"""``(name, receiver)`` nodes of a C# member call's ``function``, else None.

`recv.M()` is a member_access_expression (`expression` + `name` fields).
`recv?.M()` is the same call behind a null check: a
conditional_access_expression whose `condition` is the receiver and whose
member_binding_expression child carries the name. Reading both shapes here
keeps `?.` on the receiver-typed path; the raw-text fallback split
`_window?.Refresh` on the dot and recorded the receiver as `_window?`,
which no receiver table types, so the call was dropped (#3797).
"""
if fn_node is None:
return None
if fn_node.type == "member_access_expression":
return fn_node.child_by_field_name("name"), fn_node.child_by_field_name("expression")
if fn_node.type == "conditional_access_expression":
for child in fn_node.named_children:
if child.type == "member_binding_expression":
return child.child_by_field_name("name"), fn_node.child_by_field_name("condition")
return 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 @@ -6332,9 +6354,9 @@ def walk_calls(
# any same-named method in the corpus, silently mis-resolving
# `_server.Save()` to an unrelated `Cache.Save()` (#1609).
fn_node = node.child_by_field_name("function")
if fn_node is not None and fn_node.type == "member_access_expression":
mname = fn_node.child_by_field_name("name")
recv = fn_node.child_by_field_name("expression")
member_parts = _csharp_member_call_parts(fn_node)
if member_parts is not None:
mname, recv = member_parts
if mname is not None:
# `recv.Get<int>(...)`: the name field is a
# generic_name; its raw text carries the type-argument
Expand Down Expand Up @@ -6411,8 +6433,8 @@ def walk_calls(
# out.
if fn_node is not None:
call_tal = None
if fn_node.type == "member_access_expression":
ma_name = fn_node.child_by_field_name("name")
if member_parts is not None:
ma_name = member_parts[0]
if ma_name is not None and ma_name.type == "generic_name":
for tal_child in ma_name.children:
if tal_child.type == "type_argument_list":
Expand Down
12 changes: 12 additions & 0 deletions tests/test_csharp_call_site_generic_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,18 @@ def test_member_call_with_multiple_type_arguments(tmp_path):
)


def test_null_conditional_member_call_with_type_argument(tmp_path):
"""`r?.Do<T>()` carries its type arguments on the member_binding_expression
name, not on a member_access_expression, so it needs the same walk (#3797)."""
refs = _refs(tmp_path, {
"T.cs": _TYPES,
"P.cs": "public class Probe { public void A(Registry r) => r?.Do<IThing>(); }\n",
})
assert (".A()", "IThing") in refs, (
"null-conditional call `recv?.Do<T>()` must emit a generic_arg reference to T"
)


def test_nested_type_argument_in_call_site(tmp_path):
refs = _refs(tmp_path, {
"T.cs": _TYPES,
Expand Down
56 changes: 56 additions & 0 deletions tests/test_csharp_member_calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,62 @@ def test_this_field_receiver_resolves(tmp_path):
assert (commit, cache_save) not in calls


def test_null_conditional_field_receiver_resolves(tmp_path):
"""#3797: `_s?.Save()` is a conditional_access_expression, not a
member_access_expression. It used to fall through to the raw-text split,
which typed the receiver as `_s?` (nothing), so the call was dropped."""
calls, r = _calls(tmp_path, {
"S.cs": (
"public class Server { public bool Save() => true; }\n"
"public class Cache { public bool Save() => false; }\n"
"public class Repo {\n"
" private Server _s = new Server();\n"
" public bool? Commit() { return _s?.Save(); }\n"
"}\n"
)
})
commit = _find(r, ".Commit()", "commit")
server_save = _find(r, ".Save()", "server")
cache_save = _find(r, ".Save()", "cache")
assert (commit, server_save) in calls, "_s?.Save() must resolve like _s.Save()"
assert (commit, cache_save) not in calls


def test_null_conditional_this_field_receiver_resolves(tmp_path):
"""`this._s?.Save()`: the condition is the same this-field access a plain
`this._s.Save()` carries, so it types the same way."""
calls, r = _calls(tmp_path, {
"S.cs": (
"public class Server { public bool Save() => true; }\n"
"public class Cache { public bool Save() => false; }\n"
"public class Repo {\n"
" private Server _s = new Server();\n"
" public bool? Commit() { return this._s?.Save(); }\n"
"}\n"
)
})
commit = _find(r, ".Commit()", "commit")
server_save = _find(r, ".Save()", "server")
cache_save = _find(r, ".Save()", "cache")
assert (commit, server_save) in calls
assert (commit, cache_save) not in calls


def test_null_conditional_cross_file_receiver_resolves(tmp_path):
calls, r = _calls(tmp_path, {
"Server.cs": (
"public class Server { public bool Save() => true; }\n"
"public class Cache { public bool Save() => false; }\n"
),
"Repo.cs": (
"public class Repo { private Server _s = new Server(); "
"public bool? Commit() { return _s?.Save(); } }\n"
),
})
assert any("commit" in s and "server_save" in t for s, t in calls)
assert not any("commit" in s and "cache_save" in t for s, t in calls)


def test_base_receiver_resolves_to_base_class_method(tmp_path):
calls, r = _calls(tmp_path, {
"Base.cs": "public class BaseSvc { public bool Ping() => true; }\n",
Expand Down
Loading