fix(csharp): walk primary-constructor parameters for references and calls (#2829)

A C# 12 primary constructor puts its parameters on the class/record declaration
itself, which the extractor never walked: class Holder(IDep dep) emitted no
references edge Holder->IDep, and because dep was never registered in the class
receiver table, a dep.Method() call inside the class was dropped too. Scan the
declaration's parameter_list, register each param name->type, and emit the
param type reference — mirroring the field/property handlers. Built-in and
type-parameter types are not fabricated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
brobl2008
2026-08-18 16:30:16 +01:00
committed by safishamsi
co-authored by Claude Opus 4.8
parent 7c10a961b0
commit 3e8ec33994
2 changed files with 125 additions and 0 deletions
+53
View File
@@ -3500,6 +3500,59 @@ def _extract_generic(
add_edge(class_nid, target_nid, "references",
cp_line, context=ctx)
# C#: a primary constructor (`class Foo(IBar bar)`, C# 12+) declares
# its dependencies on the type declaration itself rather than in a
# field or property, so neither the field_declaration nor the
# property_declaration handler ever sees them — the parameter type
# got no references edge, and because the name was never registered
# in csharp_field_types, _csharp_method_receiver_types could not type
# the receiver either, so calls through it (`bar.Baz()`) lost their
# calls edge as well. The Scala class_parameters branch directly
# above is the analogue; Kotlin's is #2063. Grammar note: the list is
# an UNNAMED child of the declaration, so child_by_field_name(
# "parameters") returns None and the children must be scanned.
if config.ts_module == "tree_sitter_c_sharp" and t in (
"class_declaration",
"record_declaration",
"struct_declaration",
):
csharp_type_params = _csharp_type_parameters_in_scope(node, source)
for c in node.children:
if c.type != "parameter_list":
continue
for param in c.children:
if param.type != "parameter":
continue
ptype = param.child_by_field_name("type")
if ptype is None:
continue
pname = param.child_by_field_name("name")
p_line = param.start_point[0] + 1
# Receiver binding mirrors the field_declaration rule:
# Pascal-case only (a primitive owns no resolvable
# method) and never a bare type parameter (`T item`).
recv = _csharp_receiver_type_name(ptype, source)
if (pname is not None and recv and recv[:1].isupper()
and recv not in csharp_type_params):
csharp_field_types.setdefault(class_nid, {})[
_read_text(pname, source)
] = recv
refs = []
_csharp_collect_type_refs(
ptype, source, False, refs, csharp_type_params
)
for ref_name, role, qualified, qualifier in refs:
ctx = "generic_arg" if role == "generic_arg" else "field"
target_nid = ensure_named_node(ref_name, p_line)
if target_nid != class_nid:
metadata = {"ref_token": ref_name}
if qualified:
metadata["qualified"] = True
if qualifier:
metadata["ref_qualifier"] = qualifier
add_edge(class_nid, target_nid, "references",
p_line, context=ctx, metadata=metadata)
# C++-specific: inheritance via base_class_clause (class and struct).
# tree-sitter-cpp shape:
# class_specifier / struct_specifier
+72
View File
@@ -668,3 +668,75 @@ 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"
def _refs(r):
return {(e["source"], e["target"]) for e in r["edges"]
if e["relation"] == "references"}
_PRIMARY_CTOR = {
"S.cs": (
"public interface IDep { bool Plain(); }\n"
"public class Holder(IDep dep) {\n"
" public bool Run() { return dep.Plain(); }\n"
"}\n"
)
}
def test_primary_constructor_parameter_emits_references_edge(tmp_path):
"""`class Holder(IDep dep)` declares its dependency on the type declaration
rather than in a field or property, so neither the field_declaration nor the
property_declaration handler sees it — the type still needs a references edge."""
_, r = _calls(tmp_path, _PRIMARY_CTOR)
holder = _find(r, "Holder", "holder")
idep = _find(r, "IDep", "idep")
assert (holder, idep) in _refs(r), \
"a primary-constructor parameter type must produce a references edge"
def test_primary_constructor_parameter_resolves_member_calls(tmp_path):
"""A call through a primary-constructor parameter must resolve to the
parameter's declared type, exactly as a field receiver does."""
calls, r = _calls(tmp_path, _PRIMARY_CTOR)
run = _find(r, ".Run()", "holder")
plain = _find(r, ".Plain()", "idep")
assert (run, plain) in calls, \
"dep.Plain() must resolve through the primary-constructor parameter"
def test_record_positional_parameter_emits_references_edge(tmp_path):
"""Positional record parameters use the same parameter_list shape."""
_, r = _calls(tmp_path, {
"S.cs": (
"public interface IDep { bool Plain(); }\n"
"public record Holder(IDep Dep) {\n"
" public bool Run() { return Dep.Plain(); }\n"
"}\n"
)
})
holder = _find(r, "Holder", "holder")
idep = _find(r, "IDep", "idep")
assert (holder, idep) in _refs(r), \
"a positional record parameter type must produce a references edge"
def test_primary_constructor_type_parameter_is_not_referenced(tmp_path):
"""A bare type parameter (`T item`) names no real type — it must not be
emitted as a phantom referenced node."""
_, r = _calls(tmp_path, {
"S.cs": (
"public interface IDep { bool Plain(); }\n"
"public class Holder<T>(IDep dep, T item) {\n"
" public bool Run() { return dep.Plain(); }\n"
"}\n"
)
})
holder = _find(r, "Holder", "holder")
idep = _find(r, "IDep", "idep")
refs = _refs(r)
assert (holder, idep) in refs, "the real dependency must still be referenced"
labels = {n["id"]: n["label"] for n in r["nodes"]}
assert not [t for s, t in refs if s == holder and labels.get(t) == "T"], \
"a type parameter must not be emitted as a referenced type"