From 3d31cbe3e9046adc20eda48c34e17f8d242c3855 Mon Sep 17 00:00:00 2001 From: Ayush Kumar Jha Date: Sun, 13 Sep 2026 04:24:48 +0530 Subject: [PATCH 1/4] =?UTF-8?q?fix(csharp):=20recover=20C#=2014=20extensio?= =?UTF-8?q?n=20blocks=20=E2=80=94=20bind=20the=20receiver,=20keep=20member?= =?UTF-8?q?s=20in=20the=20class=20(#3510)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tree-sitter-c-sharp 0.23.5 predates `extension(Receiver r) { … }`. A generic header lands in an ERROR node (members kept, receiver lost); a non-generic one parses clean as a constructor named `extension` (members dropped, no warning); upstream's coming `extension_declaration` was an unknown wrapper (members demoted to file level). Recognise all three: the receiver binds and is referenced like a primary-constructor parameter, and the body is a transparent container like the preproc_ wrappers. A same-named field or a second block of another type drops the name rather than guessing. --- graphify/extractors/engine.py | 169 ++++++++++- tests/test_csharp_extension_blocks.py | 401 ++++++++++++++++++++++++++ 2 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 tests/test_csharp_extension_blocks.py diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 3db8e7b09f..815c92719b 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -161,6 +161,8 @@ def _csharp_classify_base(name: str, interface_names: set[str]) -> str: "record_declaration", "struct_declaration", "method_declaration", + # C# 14 `extension(...) { }` block; a grammar newer than 0.23.5 (#3510). + "extension_declaration", }) def _csharp_type_parameters_in_scope(node, source: bytes) -> frozenset[str]: @@ -3170,6 +3172,116 @@ def _csharp_namespace_name(node, source: bytes) -> str: return _read_text(child, source).strip() return "" +# C# 14 `extension(Receiver r) { … }` blocks (#3510). The bundled +# tree-sitter-c-sharp 0.23.5 predates the feature, so a block reaches the tree +# in one of two error-recovery shapes; upstream master parses it as a real +# `extension_declaration`, which the `<0.25` pin picks up on its next release. +# All three get one recovery: the receiver binds like a primary-constructor +# parameter, and the members belong to the enclosing static class. +_CSHARP_PARAMETER_MODIFIERS = frozenset({"ref", "in", "out", "this", "scoped", "readonly", "params"}) + +def _csharp_is_static_class(node, source: bytes) -> bool: + return any(c.type == "modifier" and _read_text(c, source) == "static" for c in node.children) + +def _csharp_extension_body(node, source: bytes): + """Return the member list of a C# 14 ``extension`` block, or None. + + With no type-parameter list, ``extension(IFoo x) { … }`` parses clean under + 0.23.5 as a ``constructor_declaration`` named ``extension`` whose ``block`` + holds the members as ``local_function_statement``s; a newer grammar gives + an ``extension_declaration`` with an ``extension_body``. A static class has + no instance constructor and its static one carries the class name, so the + constructor shape is unambiguous there; elsewhere it is left alone. + """ + if node.type == "extension_declaration": + return next((c for c in node.children if c.type == "extension_body"), None) + if node.type == "constructor_declaration": + name_node = node.child_by_field_name("name") + enclosing = node.parent.parent if node.parent is not None else None + if (name_node is not None and _read_text(name_node, source) == "extension" + and enclosing is not None and _csharp_is_static_class(enclosing, source)): + return node.child_by_field_name("body") + return None + +def _csharp_extension_blocks(class_node, source: bytes): + """Yield ``(receiver_type, type_refs, receiver_name, line)`` per C# 14 + ``extension`` block declared in ``class_node``. + + ``receiver_type`` is the receiver's outer type name (None when it is a type + parameter), ``type_refs`` the ``_csharp_collect_type_refs`` tuples to + reference, ``receiver_name`` None for the unnamed ``extension(IFoo)`` form. + + Beside the two shapes ``_csharp_extension_body`` recognises, 0.23.5 reads + the generic form ``extension(IFoo x) { … }`` as an ERROR node holding + a ``variable_declaration`` (``generic_name`` ``extension`` plus a + ``tuple_pattern`` for the receiver) and adopts the block's ``{ }`` as the + class body, so the members are already class members. The ERROR sits + beside the class body when the block opens it and inside otherwise. Only + the receiver's outer name survives as text (its generic arguments are + dropped tokens), so that shape yields the outer name alone. + """ + body = class_node.child_by_field_name("body") + candidates = list(class_node.children) + (list(body.children) if body is not None else []) + for child in candidates: + line = child.start_point[0] + 1 + param = None + if child.type == "extension_declaration": + param = next((c for c in child.children if c.type == "receiver_parameter"), None) + elif child.type == "constructor_declaration" and _csharp_extension_body(child, source) is not None: + params = child.child_by_field_name("parameters") + if params is not None: + param = next((c for c in params.children if c.type == "parameter"), None) + if param is not None: + type_node = param.child_by_field_name("type") + name_node = param.child_by_field_name("name") + # `parameter` requires a name, so the unnamed `extension(IFoo)` + # form lands its type in the name field. + if type_node is None and child.type == "constructor_declaration": + type_node, name_node = name_node, None + if type_node is None: + continue + skip = _csharp_type_parameters_in_scope(type_node, source) + refs: list[tuple[str, str, bool, str]] = [] + _csharp_collect_type_refs(type_node, source, False, refs, skip) + recv = _csharp_receiver_type_name(type_node, source) + yield ( + recv if recv and recv not in skip else None, + refs, + _read_text(name_node, source) if name_node is not None else None, + line, + ) + continue + if child.type != "ERROR": + continue + for decl in child.children: + if decl.type != "variable_declaration": + continue + generic = next((c for c in decl.children if c.type == "generic_name"), None) + if generic is None or not generic.children or _read_text(generic.children[0], source) != "extension": + continue + declarator = next((c for c in decl.children if c.type == "variable_declarator"), None) + receiver = ( + next((c for c in declarator.children if c.type == "tuple_pattern"), None) + if declarator is not None else None + ) + if receiver is None: + continue + skip = set(_csharp_type_parameters_in_scope(child, source)) + for tal in generic.children: + if tal.type == "type_argument_list": + skip.update(_read_text(a, source) for a in tal.children if a.type == "identifier") + inner = _read_text(receiver, source).strip()[1:-1].strip() + parts = inner.rsplit(None, 1) + recv_name = parts[1] if len(parts) == 2 and parts[1].isidentifier() else None + type_text = parts[0] if recv_name else inner + outer = next((tok for tok in type_text.split() if tok not in _CSHARP_PARAMETER_MODIFIERS), "") + qualifier, _, outer = outer.split("<", 1)[0].split("[", 1)[0].rstrip("?").rpartition(".") + # Pascal-case only: a predefined type (`string`) is not a node + # type here to tell apart, and never owns a resolvable member. + if not outer.isidentifier() or not outer[:1].isupper() or outer in skip: + continue + yield (outer, [(outer, "type", bool(qualifier), qualifier)], recv_name, line) + def _csharp_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, nodes: list, edges: list, seen_ids: set, function_bodies: list, parent_class_nid: str | None, add_node_fn, add_edge_fn, @@ -3241,6 +3353,14 @@ def _csharp_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: for child in node.children: walk_fn(child, parent_class_nid) return True + if parent_class_nid and node.type in ("extension_declaration", "constructor_declaration"): + # A C# 14 `extension(...) { … }` block is likewise a container, not a + # scope: its members are members of the enclosing static class (#3510). + body = _csharp_extension_body(node, source) + if body is not None: + for child in body.children: + walk_fn(child, parent_class_nid) + return True return False def _swift_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, @@ -5184,6 +5304,41 @@ def scala_base_name(type_node) -> str | None: ruby_lexical_scopes.pop() if ruby_segments: del ruby_namespace[-len(ruby_segments):] + + # C# 14 `extension(IFoo spec) { … }` (#3510): the receiver is in + # scope for every member of the block the way a primary-constructor + # parameter is for the class, so it is referenced and bound by that + # rule — after the body walk, so a same-named field or property + # (recorded above) that disagrees on the type drops the name instead + # of being clobbered: no edge, never a guess. The bundled grammar + # predates the syntax; _csharp_extension_blocks covers its shapes. + if (config.ts_module == "tree_sitter_c_sharp" and t == "class_declaration" + and _csharp_is_static_class(node, source)): + receivers: dict[str, str | None] = {} + for recv, recv_refs, recv_name, r_line in _csharp_extension_blocks(node, source): + if recv_name and recv and recv[:1].isupper(): + # Two blocks binding one name to different types + # (`extension(IFoo x)` + `extension(IBar x)`) are a + # guess either way: drop the name. + receivers[recv_name] = recv if receivers.get(recv_name, recv) == recv else None + for ref_name, role, qualified, qualifier in recv_refs: + ctx = "generic_arg" if role == "generic_arg" else "parameter_type" + target_nid = ensure_named_node(ref_name, r_line) + if target_nid != class_nid: + ref_metadata: dict = {"ref_token": ref_name} + if qualified: + ref_metadata["qualified"] = True + if qualifier: + ref_metadata["ref_qualifier"] = qualifier + add_edge(class_nid, target_nid, "references", + r_line, context=ctx, metadata=ref_metadata) + if receivers: + table = csharp_field_types.setdefault(class_nid, {}) + for recv_name, recv in receivers.items(): + if recv is not None and table.get(recv_name, recv) == recv: + table[recv_name] = recv + else: + table.pop(recv_name, None) return # Event listener property arrays: $listen = [Event::class => [Listener::class]] @@ -5704,6 +5859,14 @@ def scala_base_name(type_node) -> str | None: ruby_singleton_context_depth -= 1 return + # A C# 14 extension member the 0.23.5 grammar read as a + # `local_function_statement` (see _csharp_extension_body) is a method of + # the class it arrives with. An ordinary local function is only ever + # reached through the default recurse, with no class parent (#3510). + if (config.ts_module == "tree_sitter_c_sharp" and t == "local_function_statement" + and parent_class_nid): + t = "method_declaration" + # Function types if t in config.function_types: # Swift deinit/subscript have no name field — resolve before generic fallback @@ -5859,7 +6022,11 @@ def scala_base_name(type_node) -> str | None: metadata["ref_qualifier"] = qualifier add_edge(func_nid, target_nid, "references", line, context=ctx, metadata=metadata) - return_node = node.child_by_field_name("returns") + # `local_function_statement` (an extension member under 0.23.5, + # #3510) names its return type `type`; method_declaration has no + # such field, so the fallback is inert for it. + return_node = (node.child_by_field_name("returns") + or node.child_by_field_name("type")) if return_node is not None: refs: list[tuple[str, str, bool, str]] = [] _csharp_collect_type_refs( diff --git a/tests/test_csharp_extension_blocks.py b/tests/test_csharp_extension_blocks.py new file mode 100644 index 0000000000..1d50b3149c --- /dev/null +++ b/tests/test_csharp_extension_blocks.py @@ -0,0 +1,401 @@ +"""C# 14 `extension(Receiver r) { … }` blocks (#3510). + +The bundled tree-sitter-c-sharp 0.23.5 predates the syntax. A block with a +type-parameter list (`extension(IFoo spec)`) lands its header in an ERROR +node and its `{ }` is adopted as the class body, so the members survive as +class methods but the receiver's type is lost: no `references` edge to it and +`spec.Next()` cannot resolve. A block without one (`extension(IFoo spec)`) +parses clean as a constructor named `extension` holding local functions, so +its members vanished outright, with no syntax-error warning. A newer grammar +parses a real `extension_declaration`, which was an unknown wrapper: members +dropped to file level. + +In every shape the receiver now binds like a primary-constructor parameter +(a `references` edge from the class, and receiver-typed member calls) and the +members belong to the enclosing static class. +""" +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from graphify.extract import extract + + +def _calls(tmp_path, files: dict[str, str]): + for name, body in files.items(): + p = tmp_path / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body) + old = os.getcwd() + try: + os.chdir(tmp_path) + r = extract([Path(n) for n in files], cache_root=tmp_path / ".cache") + finally: + os.chdir(old) + calls = {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "calls"} + return calls, r + + +def _find(r, label, id_contains): + return next(n["id"] for n in r["nodes"] + if n["label"] == label and id_contains in n["id"]) + + +def _refs(r): + return {(e["source"], e["target"]) for e in r["edges"] + if e["relation"] == "references"} + + +def _methods(r): + return {(e["source"], e["target"]) for e in r["edges"] + if e["relation"] == "method"} + + +def _labels(r): + return {n["label"] for n in r["nodes"]} + + +# Two receiver candidates with a same-named method: a bare-name match would be a +# WRONG edge, so resolution has to go through the receiver's declared type. +_TYPES = ( + "public interface IFoo { string? Next(); }\n" + "public class Other { public string? Next() => null; }\n" +) + + +def _generic_block(members: str = " public string? Url() => spec.Next();\n") -> str: + # The reporter's shape: type-parameter list + where clause. + return ( + _TYPES + + "public static class E\n" + "{\n" + " extension(IFoo spec) where T : class\n" + " {\n" + members + " }\n" + "}\n" + ) + + +def _plain_block(members: str = " public string? Url() => spec.Next();\n") -> str: + return ( + _TYPES + + "public static class E\n" + "{\n" + " extension(IFoo spec)\n" + " {\n" + members + " }\n" + "}\n" + ) + + +def _assert_receiver_recovered(calls, r): + e = _find(r, "E", "_e") + ifoo = _find(r, "IFoo", "ifoo") + url = _find(r, ".Url()", "_e_url") + assert (e, url) in _methods(r), "the member must belong to the static class" + assert (e, ifoo) in _refs(r), "the receiver type must produce a references edge" + assert (url, _find(r, ".Next()", "ifoo")) in calls, \ + "spec.Next() must resolve through the receiver's declared type" + assert (url, _find(r, ".Next()", "other")) not in calls, \ + "must NOT mis-bind to an unrelated same-named method" + + +def test_generic_receiver_block_binds_the_receiver(tmp_path): + """The issue's shape: `extension(IFoo spec) where T : class`.""" + calls, r = _calls(tmp_path, {"S.cs": _generic_block()}) + _assert_receiver_recovered(calls, r) + + +def test_plain_receiver_block_keeps_its_members(tmp_path): + """`extension(IFoo spec)` parses clean as a constructor named `extension`; + its members used to vanish without even a partial-parse warning.""" + calls, r = _calls(tmp_path, {"S.cs": _plain_block()}) + _assert_receiver_recovered(calls, r) + assert "parse_errors" not in r + + +def test_generic_receiver_type_arguments_are_not_fabricated(tmp_path): + """`IFoo` under the ERROR shape: the outer name binds, `T` is the block's + own type parameter and must not become a phantom type node.""" + calls, r = _calls(tmp_path, {"S.cs": ( + "public interface IFoo { string? Next(); }\n" + "public static class E\n" + "{\n" + " extension(IFoo spec) where T : class\n" + " {\n" + " public string? Url() => spec.Next();\n" + " }\n" + "}\n" + )}) + e = _find(r, "E", "_e") + assert (e, _find(r, "IFoo", "ifoo")) in _refs(r) + assert (_find(r, ".Url()", "_e_url"), _find(r, ".Next()", "ifoo")) in calls + assert "T" not in _labels(r) + + +def test_bare_type_parameter_receiver_binds_nothing(tmp_path): + """`extension(T item)` names no real type.""" + _, r = _calls(tmp_path, {"S.cs": _TYPES + ( + "public static class E\n" + "{\n" + " extension(T item)\n" + " {\n" + " public string? Url() => item.ToString();\n" + " }\n" + "}\n" + )}) + e = _find(r, "E", "_e") + assert not [t for s, t in _refs(r) if s == e], "no references from a type-parameter receiver" + assert "T" not in _labels(r) + + +def test_predefined_receiver_type_is_not_fabricated(tmp_path): + """`extension(string s)` / `extension(string s)`: a built-in never + becomes a referenced node (same rule as primary-constructor parameters).""" + for header in ("extension(string s)", "extension(string s)"): + _, r = _calls(tmp_path, {"S.cs": ( + "public static class E\n" + "{\n" + f" {header}\n" + " {\n" + " public int Len() => s.Length;\n" + " }\n" + "}\n" + )}) + assert "string" not in _labels(r), header + assert "String" not in _labels(r), header + + +def test_unnamed_receiver_references_the_type(tmp_path): + """`extension(IFoo)` declares static extension members: nothing to bind, + but the type is still a dependency and the members still belong to E.""" + _, r = _calls(tmp_path, {"S.cs": _TYPES + ( + "public static class E\n" + "{\n" + " extension(IFoo)\n" + " {\n" + " public static IFoo Create() => null!;\n" + " }\n" + "}\n" + )}) + e = _find(r, "E", "_e") + ifoo = _find(r, "IFoo", "ifoo") + create = _find(r, ".Create()", "_e_create") + assert (e, create) in _methods(r) + assert (e, ifoo) in _refs(r) + assert (create, ifoo) in _refs(r), "a member's return type is still referenced" + + +def test_receiver_modifiers_are_not_read_as_the_type(tmp_path): + for header in ("extension(ref IFoo spec)", "extension(in IFoo spec) where T : class"): + calls, r = _calls(tmp_path, {"S.cs": _TYPES + ( + "public static class E\n" + "{\n" + f" {header}\n" + " {\n" + " public string? Url() => spec.Next();\n" + " }\n" + "}\n" + )}) + _assert_receiver_recovered(calls, r) + assert not ({"ref", "in"} & _labels(r)), header + + +def test_qualified_receiver_type_keeps_its_qualifier(tmp_path): + """Under the ERROR shape `Acme.IFoo` survives only as text; the edge must + still point at IFoo and carry the qualifier like every other C# type ref.""" + _, r = _calls(tmp_path, {"S.cs": ( + "namespace Acme { public interface IFoo { string? Next(); } }\n" + "public static class E\n" + "{\n" + " extension(Acme.IFoo spec)\n" + " {\n" + " public string? Url() => spec.Next();\n" + " }\n" + "}\n" + )}) + e = _find(r, "E", "_e") + edge = next(x for x in r["edges"] + if x["relation"] == "references" and x["source"] == e) + assert edge["metadata"]["ref_token"] == "IFoo" + assert edge["metadata"]["ref_qualifier"] == "Acme" + assert "Acme" not in {n["label"] for n in r["nodes"] if n.get("type") != "namespace"} + + +def test_two_blocks_with_distinct_receivers_both_resolve(tmp_path): + calls, r = _calls(tmp_path, {"S.cs": ( + "public interface IFoo { string? Next(); }\n" + "public interface IBar { int Size(); }\n" + "public static class E\n" + "{\n" + " extension(IFoo foo)\n" + " {\n" + " public string? Url() => foo.Next();\n" + " }\n" + " extension(IBar bar)\n" + " {\n" + " public int Count() => bar.Size();\n" + " }\n" + "}\n" + )}) + assert (_find(r, ".Url()", "_e_url"), _find(r, ".Next()", "ifoo")) in calls + assert (_find(r, ".Count()", "_e_count"), _find(r, ".Size()", "ibar")) in calls + + +def test_same_receiver_name_with_different_types_is_never_a_guess(tmp_path): + """Two blocks binding `x` to different types: a call through `x` could go + either way, so it goes nowhere — a missing edge, never a wrong one.""" + calls, r = _calls(tmp_path, {"S.cs": ( + "public interface IFoo { string? Next(); }\n" + "public interface IBar { string? Next(); }\n" + "public static class E\n" + "{\n" + " extension(IFoo x)\n" + " {\n" + " public string? A() => x.Next();\n" + " }\n" + " extension(IBar x)\n" + " {\n" + " public string? B() => x.Next();\n" + " }\n" + "}\n" + )}) + e = _find(r, "E", "_e") + assert {(e, _find(r, "IFoo", "ifoo")), (e, _find(r, "IBar", "ibar"))} <= _refs(r) + a, b = _find(r, ".A()", "_e_a"), _find(r, ".B()", "_e_b") + assert not [t for s, t in calls if s in (a, b)] + + +def test_receiver_shadowed_by_a_field_of_another_type_is_dropped(tmp_path): + """A static field named like the receiver but typed differently: the + class-wide table cannot tell a call in the block from one outside it, so + the name binds to nothing rather than to either type.""" + calls, r = _calls(tmp_path, {"S.cs": ( + "public interface IFoo { string? Next(); }\n" + "public interface IBar { string? Next(); }\n" + "public static class E\n" + "{\n" + " private static IBar x = null!;\n" + " extension(IFoo x)\n" + " {\n" + " public string? A() => x.Next();\n" + " }\n" + " public static string? B() => x.Next();\n" + "}\n" + )}) + a, b = _find(r, ".A()", "_e_a"), _find(r, ".B()", "_e_b") + assert not [t for s, t in calls if s in (a, b)] + + +def test_receiver_shadowed_by_a_field_of_the_same_type_still_binds(tmp_path): + calls, r = _calls(tmp_path, {"S.cs": _plain_block().replace( + "public static class E\n{\n", + "public static class E\n{\n private static IFoo spec = null!;\n", + )}) + _assert_receiver_recovered(calls, r) + + +def test_member_declared_before_a_generic_block(tmp_path): + """With a member ahead of it the ERROR lands inside the class body rather + than beside it; the receiver is found either way and both members stay E's.""" + calls, r = _calls(tmp_path, {"S.cs": _TYPES + ( + "public static class E\n" + "{\n" + " public static int H() => 3;\n" + " extension(IFoo spec) where T : class\n" + " {\n" + " public string? Url() => spec.Next();\n" + " }\n" + "}\n" + )}) + _assert_receiver_recovered(calls, r) + assert (_find(r, "E", "_e"), _find(r, ".H()", "_e_h")) in _methods(r) + + +def test_namespaced_file_shape_from_the_issue(tmp_path): + calls, r = _calls(tmp_path, {"S.cs": ( + "using System;\n" + "namespace Acme.Persistence;\n" + "public interface ICompositeCursorPagedSpec { string? Next(); }\n" + "public static class CompositeCursorPaginatedResponseInfoExtensions\n" + "{\n" + " extension(ICompositeCursorPagedSpec spec) where T : class\n" + " {\n" + " public string? TryGetNextUrl() => spec.Next();\n" + " }\n" + "}\n" + )}) + cls = _find(r, "CompositeCursorPaginatedResponseInfoExtensions", "extensions") + spec = _find(r, "ICompositeCursorPagedSpec", "icompositecursorpagedspec") + helper = _find(r, ".TryGetNextUrl()", "trygetnexturl") + assert (cls, helper) in _methods(r) + assert (cls, spec) in _refs(r) + assert (helper, _find(r, ".Next()", "icompositecursorpagedspec")) in calls + assert not ({"T", "TKey"} & _labels(r)) + + +def test_plain_block_member_signature_types_are_referenced(tmp_path): + """A local_function_statement names its return type `type`, not `returns`; + the parameter list is shared. Both must reach the member's references.""" + _, r = _calls(tmp_path, {"S.cs": ( + "public interface IFoo { }\n" + "public interface IBar { }\n" + "public interface IBaz { }\n" + "public static class E\n" + "{\n" + " extension(IFoo spec)\n" + " {\n" + " public IBar Make(IBaz baz) => null!;\n" + " }\n" + "}\n" + )}) + make = _find(r, ".Make()", "_e_make") + assert {(make, _find(r, "IBar", "ibar")), (make, _find(r, "IBaz", "ibaz"))} <= _refs(r) + + +def test_property_inside_a_plain_block_does_not_break_the_methods(tmp_path): + """0.23.5 turns a property in the constructor-shaped block into error soup; + whatever it becomes, the methods around it are still extracted.""" + calls, r = _calls(tmp_path, {"S.cs": _plain_block( + " public int Count => 1;\n" + " public string? Url() => spec.Next();\n" + )}) + _assert_receiver_recovered(calls, r) + + +def test_ordinary_local_functions_are_not_promoted(tmp_path): + """Only a block member arrives with a class parent. A local function inside + a real constructor, or in a non-static class that happens to be named + `extension`, is reached by the default recurse and stays as it was.""" + _, r = _calls(tmp_path, {"S.cs": ( + "public class Holder\n" + "{\n" + " public Holder() { int Local() => 1; }\n" + "}\n" + "public class extension\n" + "{\n" + " public extension(int x) { int Inner() => x; }\n" + "}\n" + )}) + assert not ({".Local()", "Local()", ".Inner()", "Inner()"} & _labels(r)) + + +def _grammar_has_extension_declaration() -> bool: + import tree_sitter_c_sharp + from tree_sitter import Language + return bool(Language(tree_sitter_c_sharp.language()) + .id_for_node_kind("extension_declaration", True)) + + +@pytest.mark.skipif(not _grammar_has_extension_declaration(), + reason="installed tree-sitter-c-sharp predates C# 14 extension_declaration") +def test_extension_declaration_grammar_shape(tmp_path): + """tree-sitter-c-sharp > 0.23.5 parses a real `extension_declaration`. It is + a container, not a scope: members stay E's, the receiver binds, and the + block's own `` is a type parameter, not a type.""" + for src in (_generic_block(), _plain_block()): + calls, r = _calls(tmp_path, {"S.cs": src}) + _assert_receiver_recovered(calls, r) + assert "parse_errors" not in r + assert "T" not in _labels(r) From ac2eb32d169fd2bd2f5096bc48396cbae2580196 Mon Sep 17 00:00:00 2001 From: Ayush Kumar Jha Date: Sun, 13 Sep 2026 04:58:19 +0530 Subject: [PATCH 2/4] fix(csharp): read alias-qualified type names (`global::A.B`, `X::B`) as their dotted form (#3510) Review finding on #3523: an alias-qualified extension receiver was dropped on the ERROR-shape text path (`::` was not a qualifier separator). The shared node helpers had the same gap, pre-existing and wider: with no `alias_qualified_name` branch, `global::IFoo` in any C# type position fabricated a `global` type node and bound the receiver to it, and the dotted `global::Acme.IFoo` carried `global::Acme` as its qualifier, which no namespace matches, so the reference repointed to a dangling stub. Fold the qualifier once in `_csharp_unalias`: `global::` is the root namespace (already in every scope list) and a using/extern alias `X::B` names what `X.B` does. Applied in `_read_csharp_type_name`, `_csharp_collect_type_refs`, and the receiver text path, so extension receivers, primary-constructor parameters, fields, and base lists all read the same way. Identity on text without `::`. --- graphify/extractors/engine.py | 27 ++++++++--- tests/test_csharp_extension_blocks.py | 64 +++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 815c92719b..6a5fbfea10 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -191,6 +191,18 @@ def _csharp_type_parameters_in_scope(node, source: bytes) -> frozenset[str]: scope = scope.parent return frozenset(names) +def _csharp_unalias(text: str) -> str: + """Fold a C# alias qualifier (``::``) into dotted form. + + ``global::A.B`` is ``A.B`` anchored at the root namespace, which every scope + list already contains; a using/extern alias ``X::B`` names what ``X.B`` + does. Either way the resolver's qualified lookup then applies unchanged, + instead of the alias being read as a type of its own (#3510). + """ + if text.startswith("global::"): + return text[len("global::"):] + return text.replace("::", ".", 1) + def _csharp_collect_type_refs( node, source: bytes, @@ -211,11 +223,11 @@ def _csharp_collect_type_refs( if name and name not in skip: out.append((name, "generic_arg" if generic else "type", False, "")) return - if t == "qualified_name": - prefix, _, text = _read_text(node, source).rpartition(".") + if t in ("qualified_name", "alias_qualified_name"): + prefix, _, text = _csharp_unalias(_read_text(node, source)).rpartition(".") text = text.split("<", 1)[0] if text and text not in skip: - out.append((text, "generic_arg" if generic else "type", True, prefix)) + out.append((text, "generic_arg" if generic else "type", bool(prefix), prefix)) return if t == "generic_name": name_child = node.child_by_field_name("name") @@ -3275,7 +3287,8 @@ class body, so the members are already class members. The ERROR sits recv_name = parts[1] if len(parts) == 2 and parts[1].isidentifier() else None type_text = parts[0] if recv_name else inner outer = next((tok for tok in type_text.split() if tok not in _CSHARP_PARAMETER_MODIFIERS), "") - qualifier, _, outer = outer.split("<", 1)[0].split("[", 1)[0].rstrip("?").rpartition(".") + outer = _csharp_unalias(outer.split("<", 1)[0].split("[", 1)[0].rstrip("?")) + qualifier, _, outer = outer.rpartition(".") # Pascal-case only: a predefined type (`string`) is not a node # type here to tell apart, and never owns a resolvable member. if not outer.isidentifier() or not outer[:1].isupper() or outer in skip: @@ -3668,10 +3681,10 @@ def _read_csharp_type_name(node, source: bytes) -> tuple[str, bool, str] | None: return None if node.type in ("identifier", "predefined_type"): return (_read_text(node, source), False, "") - if node.type == "qualified_name": - prefix, _, tail = _read_text(node, source).rpartition(".") + if node.type in ("qualified_name", "alias_qualified_name"): + prefix, _, tail = _csharp_unalias(_read_text(node, source)).rpartition(".") tail = tail.split("<", 1)[0] - return (tail, True, prefix) + return (tail, bool(prefix), prefix) if node.type == "generic_name": name_node = node.child_by_field_name("name") if name_node is not None: diff --git a/tests/test_csharp_extension_blocks.py b/tests/test_csharp_extension_blocks.py index 1d50b3149c..b9ff312986 100644 --- a/tests/test_csharp_extension_blocks.py +++ b/tests/test_csharp_extension_blocks.py @@ -223,6 +223,70 @@ def test_qualified_receiver_type_keeps_its_qualifier(tmp_path): assert "Acme" not in {n["label"] for n in r["nodes"] if n.get("type") != "namespace"} +@pytest.mark.parametrize("header", [ + "extension(global::Acme.IFoo spec) where T : class", # ERROR shape, dotted + "extension(Alias::IFoo spec) where T : class", # ERROR shape, using-alias + "extension(global::Acme.IFoo spec)", # constructor shape, dotted + "extension(Alias::IFoo spec)", # constructor shape, using-alias +]) +def test_alias_qualified_receiver_resolves_to_the_real_type(tmp_path, header): + """`global::A.B` is `A.B` at the root and `X::B` is what `X.B` names, so the + receiver must reach the real definition — not a dangling stub, and never a + phantom `global` / `Alias` type node.""" + calls, r = _calls(tmp_path, {"S.cs": ( + "using Alias = Acme;\n" + "namespace Acme { public interface IFoo { string? Next(); } }\n" + "namespace Acme { public class Other { public string? Next() => null; } }\n" + "public static class E\n" + "{\n" + f" {header}\n" + " {\n" + " public string? Url() => spec.Next();\n" + " }\n" + "}\n" + )}) + e = _find(r, "E", "_e") + ifoo = _find(r, "IFoo", "ifoo") + assert next(n for n in r["nodes"] if n["id"] == ifoo)["source_file"], "must be the real node" + assert (e, ifoo) in _refs(r) + assert (_find(r, ".Url()", "_e_url"), _find(r, ".Next()", "ifoo")) in calls + assert (_find(r, ".Url()", "_e_url"), _find(r, ".Next()", "other")) not in calls + assert not ({"global", "Alias"} & _labels(r)) + + +def test_root_anchored_receiver_is_unqualified(tmp_path): + """`global::IFoo` on a root-namespace type carries no qualifier at all.""" + calls, r = _calls(tmp_path, {"S.cs": _TYPES + ( + "public static class E\n" + "{\n" + " extension(global::IFoo spec)\n" + " {\n" + " public string? Url() => spec.Next();\n" + " }\n" + "}\n" + )}) + _assert_receiver_recovered(calls, r) + e = _find(r, "E", "_e") + edge = next(x for x in r["edges"] if x["relation"] == "references" and x["source"] == e) + assert "qualified" not in edge["metadata"] and "ref_qualifier" not in edge["metadata"] + assert "global" not in _labels(r) + + +def test_alias_qualifier_is_not_a_type_on_the_shared_node_path(tmp_path): + """The same `::` reading applies to every C# type reference, so the + primary-constructor precedent stops fabricating a `global` type node.""" + calls, r = _calls(tmp_path, {"S.cs": _TYPES + ( + "public class Holder(global::IFoo dep)\n" + "{\n" + " public string? Run() => dep.Next();\n" + "}\n" + )}) + holder = _find(r, "Holder", "holder") + assert (holder, _find(r, "IFoo", "ifoo")) in _refs(r) + assert (_find(r, ".Run()", "holder"), _find(r, ".Next()", "ifoo")) in calls + assert "global" not in _labels(r) + + def test_two_blocks_with_distinct_receivers_both_resolve(tmp_path): calls, r = _calls(tmp_path, {"S.cs": ( "public interface IFoo { string? Next(); }\n" From 9feaa14578304150c1f8242efdb613a035983084 Mon Sep 17 00:00:00 2001 From: Ayush Kumar Jha Date: Sun, 13 Sep 2026 05:22:19 +0530 Subject: [PATCH 3/4] fix(csharp): split a qualified type name on its own dots, not one inside a type argument (#3510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding on #3523, pre-existing in the shared helpers: the qualified branch of `_read_csharp_type_name` / `_csharp_collect_type_refs` ran `rpartition(".")` over the whole node text, so `Acme.IFoo` (a field, a base list, a receiver) became a phantom `String>` type qualified by `Acme.IFoo.Inner` carried `Outer` as its qualifier. Every `<...>` list is now dropped, nesting included, before the split — folded into the same normaliser as the `::` alias handling (`_csharp_dotted_name`), which the ERROR-shape receiver text path uses too, so the three sites read a name identically. --- graphify/extractors/engine.py | 42 +++++++++++++------- tests/test_csharp_extension_blocks.py | 55 +++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 14 deletions(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 6a5fbfea10..75b04f2091 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -191,17 +191,33 @@ def _csharp_type_parameters_in_scope(node, source: bytes) -> frozenset[str]: scope = scope.parent return frozenset(names) -def _csharp_unalias(text: str) -> str: - """Fold a C# alias qualifier (``::``) into dotted form. - - ``global::A.B`` is ``A.B`` anchored at the root namespace, which every scope - list already contains; a using/extern alias ``X::B`` names what ``X.B`` - does. Either way the resolver's qualified lookup then applies unchanged, - instead of the alias being read as a type of its own (#3510). +def _csharp_dotted_name(text: str) -> str: + """Reduce a C# type name to its dotted path, ready for ``rpartition(".")``. + + Every ``<...>`` type-argument list goes, nesting included, so the split + lands on the name's own dots and never on one inside an argument + (``Acme.IFoo`` -> ``Acme.IFoo``; ``Outer.Inner`` -> + ``Outer.Inner``). An alias qualifier folds into dotted form: ``global::A.B`` + is ``A.B`` anchored at the root namespace, which every scope list already + contains, and a using/extern alias ``X::B`` names what ``X.B`` does — so + the resolver's qualified lookup applies unchanged instead of the alias + being read as a type of its own (#3510). """ if text.startswith("global::"): - return text[len("global::"):] - return text.replace("::", ".", 1) + text = text[len("global::"):] + text = text.replace("::", ".", 1) + if "<" not in text: + return text + kept: list[str] = [] + depth = 0 + for ch in text: + if ch == "<": + depth += 1 + elif ch == ">": + depth = max(depth - 1, 0) + elif depth == 0: + kept.append(ch) + return "".join(kept) def _csharp_collect_type_refs( node, @@ -224,8 +240,7 @@ def _csharp_collect_type_refs( out.append((name, "generic_arg" if generic else "type", False, "")) return if t in ("qualified_name", "alias_qualified_name"): - prefix, _, text = _csharp_unalias(_read_text(node, source)).rpartition(".") - text = text.split("<", 1)[0] + prefix, _, text = _csharp_dotted_name(_read_text(node, source)).rpartition(".") if text and text not in skip: out.append((text, "generic_arg" if generic else "type", bool(prefix), prefix)) return @@ -3287,7 +3302,7 @@ class body, so the members are already class members. The ERROR sits recv_name = parts[1] if len(parts) == 2 and parts[1].isidentifier() else None type_text = parts[0] if recv_name else inner outer = next((tok for tok in type_text.split() if tok not in _CSHARP_PARAMETER_MODIFIERS), "") - outer = _csharp_unalias(outer.split("<", 1)[0].split("[", 1)[0].rstrip("?")) + outer = _csharp_dotted_name(outer).split("[", 1)[0].rstrip("?") qualifier, _, outer = outer.rpartition(".") # Pascal-case only: a predefined type (`string`) is not a node # type here to tell apart, and never owns a resolvable member. @@ -3682,8 +3697,7 @@ def _read_csharp_type_name(node, source: bytes) -> tuple[str, bool, str] | None: if node.type in ("identifier", "predefined_type"): return (_read_text(node, source), False, "") if node.type in ("qualified_name", "alias_qualified_name"): - prefix, _, tail = _csharp_unalias(_read_text(node, source)).rpartition(".") - tail = tail.split("<", 1)[0] + prefix, _, tail = _csharp_dotted_name(_read_text(node, source)).rpartition(".") return (tail, bool(prefix), prefix) if node.type == "generic_name": name_node = node.child_by_field_name("name") diff --git a/tests/test_csharp_extension_blocks.py b/tests/test_csharp_extension_blocks.py index b9ff312986..ad240a3e56 100644 --- a/tests/test_csharp_extension_blocks.py +++ b/tests/test_csharp_extension_blocks.py @@ -272,6 +272,61 @@ def test_root_anchored_receiver_is_unqualified(tmp_path): assert "global" not in _labels(r) +@pytest.mark.parametrize("header", [ + "extension(Acme.IFoo> spec) where T : class", + "extension(Acme.IFoo spec)", +]) +def test_dotted_generic_argument_does_not_split_the_receiver_type(tmp_path, header): + """A qualified name splits on its own dots, never on one inside a type + argument: `Acme.IFoo` is `IFoo` in `Acme`, not `String>` + in `Acme.IFoo { string? Next(); } }\n" + "public static class E\n" + "{\n" + f" {header}\n" + " {\n" + " public string? Url() => spec.Next();\n" + " }\n" + "}\n" + )}) + e = _find(r, "E", "_e") + edge = next(x for x in r["edges"] if x["relation"] == "references" and x["source"] == e) + assert edge["metadata"]["ref_token"] == "IFoo" + assert edge["metadata"]["ref_qualifier"] == "Acme" + assert (_find(r, ".Url()", "_e_url"), _find(r, ".Next()", "ifoo")) in calls + assert not [lbl for lbl in _labels(r) if "<" in lbl or ">" in lbl or "&" in lbl] + + +def test_dotted_generic_argument_does_not_split_a_field_or_base_type(tmp_path): + """The same rule serves every C# type reference through the shared + helpers — a field type and a base list here.""" + _, r = _calls(tmp_path, {"S.cs": ( + "namespace Acme { public interface IFoo { } }\n" + "public class Holder { private Acme.IFoo f; }\n" + "public class Impl : Acme.IFoo { }\n" + )}) + ifoo = _find(r, "IFoo", "ifoo") + assert (_find(r, "Holder", "holder"), ifoo) in _refs(r) + assert (_find(r, "Impl", "impl"), ifoo) in { + (e["source"], e["target"]) for e in r["edges"] if e["relation"] == "implements" + } + assert not [lbl for lbl in _labels(r) if "<" in lbl or ">" in lbl or "&" in lbl] + + +def test_nested_type_of_a_generic_outer_keeps_its_own_name(tmp_path): + """`Outer.Inner`: the type-argument list belongs to the qualifier and + must not eat the split — `Inner`, qualified by `Outer`.""" + _, r = _calls(tmp_path, {"S.cs": ( + "public class Outer { public class Inner { } }\n" + "public class Holder { private Outer.Inner f; }\n" + )}) + holder = _find(r, "Holder", "holder") + edge = next(x for x in r["edges"] if x["relation"] == "references" and x["source"] == holder) + assert edge["metadata"]["ref_token"] == "Inner" + assert edge["metadata"]["ref_qualifier"] == "Outer" + + def test_alias_qualifier_is_not_a_type_on_the_shared_node_path(tmp_path): """The same `::` reading applies to every C# type reference, so the primary-constructor precedent stops fabricating a `global` type node.""" From b187e858d91ea8c12ec3b0c498d995a8035fdf3b Mon Sep 17 00:00:00 2001 From: Ayush Kumar Jha Date: Sun, 13 Sep 2026 05:40:01 +0530 Subject: [PATCH 4/4] fix(csharp): reference the type arguments of a qualified generic type (#3510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding on #3523: `global::IFoo` used to reach `Bar` only by accident — the unhandled alias_qualified_name fell into child recursion, which emitted the phantom `global` and `Bar` alongside `IFoo` — so routing it through the qualified branch dropped `Bar`. That branch had never collected type arguments at all: a bare `IFoo` references Bar, a qualified `Acme.IFoo` did not. Collect every type_argument_list at any depth of a qualified or alias-qualified name, the way the generic_name branch reads a bare one, so both spellings yield the same generic_arg references. --- graphify/extractors/engine.py | 14 ++++++++ tests/test_csharp_extension_blocks.py | 47 +++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 75b04f2091..2fc13ffae3 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -240,9 +240,23 @@ def _csharp_collect_type_refs( out.append((name, "generic_arg" if generic else "type", False, "")) return if t in ("qualified_name", "alias_qualified_name"): + # A qualified_name keeps its top-level dot, so `prefix` is never empty + # there; only the undotted alias form (`global::IFoo`) is unqualified. prefix, _, text = _csharp_dotted_name(_read_text(node, source)).rpartition(".") if text and text not in skip: out.append((text, "generic_arg" if generic else "type", bool(prefix), prefix)) + # Its type arguments are references too, at any depth of the name + # (`Acme.Box` -> Box, and Widget as a generic_arg), the way the + # bare generic_name branch below already reads `Box`. + pending = list(node.children) + while pending: + sub = pending.pop() + if sub.type == "type_argument_list": + for arg in sub.children: + if arg.is_named: + _csharp_collect_type_refs(arg, source, True, out, skip) + else: + pending.extend(sub.children) return if t == "generic_name": name_child = node.child_by_field_name("name") diff --git a/tests/test_csharp_extension_blocks.py b/tests/test_csharp_extension_blocks.py index ad240a3e56..96c569ce57 100644 --- a/tests/test_csharp_extension_blocks.py +++ b/tests/test_csharp_extension_blocks.py @@ -314,6 +314,53 @@ def test_dotted_generic_argument_does_not_split_a_field_or_base_type(tmp_path): assert not [lbl for lbl in _labels(r) if "<" in lbl or ">" in lbl or "&" in lbl] +def _generic_arg_refs(r, source_id): + return {e["target"] for e in r["edges"] + if e["relation"] == "references" and e["source"] == source_id + and e.get("context") == "generic_arg"} + + +@pytest.mark.parametrize("type_expr", [ + "Acme.IFoo", # qualified + "global::Acme.IFoo", # alias-qualified, dotted + "global::IFoo2", # alias-qualified, undotted + "Acme.IFoo>", # nested, dotted argument +]) +def test_qualified_generic_arguments_are_referenced(tmp_path, type_expr): + """A qualified or alias-qualified generic type references its type + arguments the way a bare `IFoo` always has; `global::IFoo` used + to reach `Bar` only by accident, next to a phantom `global` node.""" + _, r = _calls(tmp_path, {"S.cs": ( + "namespace Acme { public interface IFoo { } }\n" + "public interface IFoo2 { }\n" + "public class Bar { }\n" + f"public class Holder {{ private {type_expr} f; }}\n" + )}) + holder = _find(r, "Holder", "holder") + assert _find(r, "Bar", "bar") in _generic_arg_refs(r, holder), type_expr + assert "global" not in _labels(r) + + +def test_extension_receiver_generic_arguments_are_referenced(tmp_path): + """The receiver of a constructor-shaped block goes through the same node + path, so `Acme.IFoo` references Bar as well as IFoo.""" + calls, r = _calls(tmp_path, {"S.cs": ( + "namespace Acme { public interface IFoo { string? Next(); } }\n" + "public class Bar { }\n" + "public static class E\n" + "{\n" + " extension(Acme.IFoo spec)\n" + " {\n" + " public string? Url() => spec.Next();\n" + " }\n" + "}\n" + )}) + e = _find(r, "E", "_e") + assert (e, _find(r, "IFoo", "ifoo")) in _refs(r) + assert _find(r, "Bar", "bar") in _generic_arg_refs(r, e) + assert (_find(r, ".Url()", "_e_url"), _find(r, ".Next()", "ifoo")) in calls + + def test_nested_type_of_a_generic_outer_keeps_its_own_name(tmp_path): """`Outer.Inner`: the type-argument list belongs to the qualifier and must not eat the split — `Inner`, qualified by `Outer`."""