Skip to content

Commit b187e85

Browse files
fix(csharp): reference the type arguments of a qualified generic type (#3510)
Review finding on #3523: `global::IFoo<Bar>` 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<Bar>` references Bar, a qualified `Acme.IFoo<Bar>` 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.
1 parent 9feaa14 commit b187e85

2 files changed

Lines changed: 61 additions & 0 deletions

File tree

‎graphify/extractors/engine.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,9 +240,23 @@ def _csharp_collect_type_refs(
240240
out.append((name, "generic_arg" if generic else "type", False, ""))
241241
return
242242
if t in ("qualified_name", "alias_qualified_name"):
243+
# A qualified_name keeps its top-level dot, so `prefix` is never empty
244+
# there; only the undotted alias form (`global::IFoo`) is unqualified.
243245
prefix, _, text = _csharp_dotted_name(_read_text(node, source)).rpartition(".")
244246
if text and text not in skip:
245247
out.append((text, "generic_arg" if generic else "type", bool(prefix), prefix))
248+
# Its type arguments are references too, at any depth of the name
249+
# (`Acme.Box<Widget>` -> Box, and Widget as a generic_arg), the way the
250+
# bare generic_name branch below already reads `Box<Widget>`.
251+
pending = list(node.children)
252+
while pending:
253+
sub = pending.pop()
254+
if sub.type == "type_argument_list":
255+
for arg in sub.children:
256+
if arg.is_named:
257+
_csharp_collect_type_refs(arg, source, True, out, skip)
258+
else:
259+
pending.extend(sub.children)
246260
return
247261
if t == "generic_name":
248262
name_child = node.child_by_field_name("name")

‎tests/test_csharp_extension_blocks.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,53 @@ def test_dotted_generic_argument_does_not_split_a_field_or_base_type(tmp_path):
314314
assert not [lbl for lbl in _labels(r) if "<" in lbl or ">" in lbl or "&" in lbl]
315315

316316

317+
def _generic_arg_refs(r, source_id):
318+
return {e["target"] for e in r["edges"]
319+
if e["relation"] == "references" and e["source"] == source_id
320+
and e.get("context") == "generic_arg"}
321+
322+
323+
@pytest.mark.parametrize("type_expr", [
324+
"Acme.IFoo<Bar>", # qualified
325+
"global::Acme.IFoo<Bar>", # alias-qualified, dotted
326+
"global::IFoo2<Bar>", # alias-qualified, undotted
327+
"Acme.IFoo<System.Collections.Generic.List<Bar>>", # nested, dotted argument
328+
])
329+
def test_qualified_generic_arguments_are_referenced(tmp_path, type_expr):
330+
"""A qualified or alias-qualified generic type references its type
331+
arguments the way a bare `IFoo<Bar>` always has; `global::IFoo<Bar>` used
332+
to reach `Bar` only by accident, next to a phantom `global` node."""
333+
_, r = _calls(tmp_path, {"S.cs": (
334+
"namespace Acme { public interface IFoo<T> { } }\n"
335+
"public interface IFoo2<T> { }\n"
336+
"public class Bar { }\n"
337+
f"public class Holder {{ private {type_expr} f; }}\n"
338+
)})
339+
holder = _find(r, "Holder", "holder")
340+
assert _find(r, "Bar", "bar") in _generic_arg_refs(r, holder), type_expr
341+
assert "global" not in _labels(r)
342+
343+
344+
def test_extension_receiver_generic_arguments_are_referenced(tmp_path):
345+
"""The receiver of a constructor-shaped block goes through the same node
346+
path, so `Acme.IFoo<Bar>` references Bar as well as IFoo."""
347+
calls, r = _calls(tmp_path, {"S.cs": (
348+
"namespace Acme { public interface IFoo<T> { string? Next(); } }\n"
349+
"public class Bar { }\n"
350+
"public static class E\n"
351+
"{\n"
352+
" extension(Acme.IFoo<Bar> spec)\n"
353+
" {\n"
354+
" public string? Url() => spec.Next();\n"
355+
" }\n"
356+
"}\n"
357+
)})
358+
e = _find(r, "E", "_e")
359+
assert (e, _find(r, "IFoo", "ifoo")) in _refs(r)
360+
assert _find(r, "Bar", "bar") in _generic_arg_refs(r, e)
361+
assert (_find(r, ".Url()", "_e_url"), _find(r, ".Next()", "ifoo")) in calls
362+
363+
317364
def test_nested_type_of_a_generic_outer_keeps_its_own_name(tmp_path):
318365
"""`Outer<int>.Inner`: the type-argument list belongs to the qualifier and
319366
must not eat the split — `Inner`, qualified by `Outer`."""

0 commit comments

Comments
 (0)