fix(csharp): type a var from the called method's return type (#4266)

Co-Authored-By: Grok Bot <noreply@x.ai>
(cherry picked from commit 816dcf5927)
This commit is contained in:
Mahaboob pasha authored and safishamsi committed 2026-10-12 13:01:00 +01:00
1 parent d3f8687c00
commit 07f3d0fc3c
3 files changed
+292 -9

No files matched your search

+42
View File
@@ -5050,6 +5050,41 @@ def _resolve_csharp_member_calls(
field_type, owner, (owner or {}).get("source_file", "")
)
# Method return types per class, for `var v = R.M<A>()` (#4266).
method_returns = _bind_member_field_tables(per_file, all_nodes, lang="csharp_returns")
def _call_return_type_nid(call: list, caller: str, caller_node: dict | None,
src_file: str) -> str | None:
"""The type a ``var`` gets from ``R.M<A>()``: M's declared return type.
M must resolve to exactly one declaration (after matching the number of
explicit type arguments); a return type that is M's own type parameter
takes the call-site type argument. Anything unknown yields None.
"""
recv, method, targs = call
if recv == "this" or not recv:
owner = enclosing_type.get(caller) or (caller if caller in type_nids else None)
else:
owner = _resolve_type_name_nid(recv, caller_node, src_file)
method_nid = _method_on_type_or_bases(owner, _key(method)) if owner else None
decl_type = enclosing_type.get(method_nid) if method_nid else None
decls = method_returns.get(decl_type, {}).get(method) if decl_type else None
if not isinstance(decls, list):
return None
if targs:
decls = [d for d in decls if d[1] == len(targs)]
if len(decls) != 1:
return None # missing or an overload we can't choose
ret = decls[0][0]
if isinstance(ret, str) and ret.startswith("#"):
idx = int(ret[1:])
ret = targs[idx] if idx < len(targs) else None # inferred: no guess
return _resolve_type_name_nid(ret, caller_node, src_file)
decl_node = node_by_id.get(decl_type or "")
return _resolve_type_name_nid(
ret, decl_node, (decl_node or {}).get("source_file", "")
)
all_raw_calls: list[dict] = []
for result in per_file:
all_raw_calls.extend(result.get("raw_calls", []))
@@ -5103,6 +5138,13 @@ def _resolve_csharp_member_calls(
_park_if_absent(type_name or receiver, caller_node, rc)
continue
type_qualified = True
elif rc.get("receiver_call") and not rc.get("receiver_type"):
type_nid = _call_return_type_nid(
rc["receiver_call"], caller, caller_node, src_file
)
if not type_nid:
continue
type_qualified = False
else:
type_name = rc.get("receiver_type")
if not type_name:
+97 -9
View File
@@ -2327,11 +2327,67 @@ def _csharp_element_type_name(type_node, source: bytes) -> str | None:
return name
def _csharp_return_info(method_node, source: bytes) -> list:
"""``[return type, type-parameter count]`` of a C# method declaration (#4266).
The return type is the class name a receiver of that type would carry
(``List<T>`` -> ``List``), ``"#i"`` when it is the method's own i-th type
parameter (bound by the call-site type arguments), or None when no class
can be named (``void``, a primitive, a class-level type parameter).
"""
tparams: list[str] = []
tpl = method_node.child_by_field_name("type_parameters")
for param in tpl.named_children if tpl is not None else []:
name_node = param.child_by_field_name("name")
tparams.append(_read_text(name_node, source) if name_node is not None else "")
rtype = method_node.child_by_field_name("returns")
ret = _csharp_receiver_type_name(rtype, source)
if ret is not None and rtype is not None and rtype.type == "identifier" and ret in tparams:
ret = "#%d" % tparams.index(ret)
elif ret in tparams or ret in _csharp_type_parameters_in_scope(method_node, source):
ret = None
return [ret, len(tparams)]
def _csharp_deferred_call(invocation, source: bytes) -> list | None:
"""``[receiver, method, type args]`` for ``var x = R.M<A>(...)`` (#4266).
The variable's type is the return type of the called method, which may be
declared in another file, so the cross-file pass resolves it. ``receiver``
is an identifier, ``"this"`` or ``""`` (unqualified call); each type
argument is its class name or None. Any other call shape returns None.
"""
fn = invocation.child_by_field_name("function")
recv = ""
if fn is not None and fn.type == "member_access_expression":
inner = fn.child_by_field_name("expression")
fn = fn.child_by_field_name("name")
if inner is not None and inner.type in ("this", "this_expression"):
recv = "this"
elif inner is not None and inner.type == "identifier":
recv = _read_text(inner, source)
else:
return None
if fn is None or fn.type not in ("identifier", "generic_name"):
return None
args: list[str | None] = []
if fn.type == "generic_name":
tal = next((c for c in fn.children if c.type == "type_argument_list"), None)
for arg in tal.named_children if tal is not None else []:
args.append(_csharp_receiver_type_name(arg, source))
name_node = next((c for c in fn.children if c.type == "identifier"), None)
else:
name_node = fn
if name_node is None:
return None
return ["call", recv, _read_text(name_node, source), args]
def _csharp_method_receiver_types(
method_node,
source: bytes,
field_types: dict[str, str],
) -> tuple[dict[str, list[tuple[int, int, str | None]]], dict[str, str]]:
) -> tuple[dict[str, list[tuple[int, int, str | list | None]]], dict[str, str]]:
"""Build the SCOPED receiver bindings visible to one C# method (#2299, #2472).
The C# twin of ``_java_method_receiver_types``, but positional: instead of
@@ -2357,11 +2413,12 @@ def _csharp_method_receiver_types(
``out var x`` itself stays untyped — resolving it from the callee's
``out`` parameter signature is a separate, pre-existing gap.
"""
bindings: dict[str, list[tuple[int, int, str | None]]] = {}
bindings: dict[str, list[tuple[int, int, str | list | None]]] = {}
field_poisoned: set[str] = set()
def bind(
name: str | None, type_name: str | None, scope_node, elem_type: str | None = None
name: str | None, type_name: str | list | None, scope_node,
elem_type: str | None = None,
) -> None:
if not name or scope_node is None:
return
@@ -2461,6 +2518,11 @@ def _csharp_method_receiver_types(
g.child_by_field_name("type"), source
)
break
if g.type == "invocation_expression":
# `var v = R.M<A>()` — typed cross-file from
# M's declared return type (#4266).
type_name = _csharp_deferred_call(g, source)
break
bind(_read_text(name_node, source), type_name, scope, declared_elem)
elif node.type in ("declaration_expression", "declaration_pattern"):
# #2346: inline-declared receivers. `out Sect s` is a
@@ -2500,10 +2562,10 @@ def _csharp_method_receiver_types(
def _csharp_scoped_receiver_type(
table: tuple[dict[str, list[tuple[int, int, str | None]]], dict[str, str]] | None,
table: tuple[dict[str, list[tuple[int, int, str | list | None]]], dict[str, str]] | None,
name: str | None,
call_byte: int,
) -> str | None:
) -> str | list | None:
"""Resolve a C# receiver name to its type at a specific call offset (#2472).
``table`` is the (scoped bindings, field base) pair built by
@@ -4783,6 +4845,9 @@ def _extract_generic(
# same-named, explicitly typed receiver in a different method.
csharp_field_types: dict[str, dict[str, str]] = {}
csharp_method_scopes: dict[int, tuple[object, str]] = {}
# class nid -> method name -> one [return type, type-param count] per
# overload, for typing `var v = R.M<A>()` cross-file (#4266).
csharp_method_returns: dict[str, dict[str, list]] = {}
csharp_interface_names: set[str] = set()
if config.ts_module == "tree_sitter_c_sharp":
@@ -6418,6 +6483,10 @@ def _extract_generic(
metadata=ruby_method_metadata,
)
add_edge(parent_class_nid, func_nid, "method", line)
if config.ts_module == "tree_sitter_c_sharp" and t == "method_declaration":
csharp_method_returns.setdefault(parent_class_nid, {}).setdefault(
func_name, []
).append(_csharp_return_info(node, source))
else:
func_nid = _make_id(stem, sanitized_name)
if config.ts_module == "tree_sitter_python":
@@ -7711,7 +7780,7 @@ def _extract_generic(
csharp_table, _read_text(inner, source),
node.start_byte,
)
if root_type:
if isinstance(root_type, str):
csharp_receiver_chain = [
root_type, _read_text(fname, source)
]
@@ -7724,7 +7793,7 @@ def _extract_generic(
csharp_table, _read_text(inner, source) + "[]",
node.start_byte,
)
if elem_type:
if isinstance(elem_type, str):
csharp_receiver_chain = [elem_type]
elif recv is not None and recv.type == "parenthesized_expression":
# ((IStore)x).M() / (x as IStore).M(): the cast names
@@ -8241,7 +8310,22 @@ def _extract_generic(
receiver_type = _csharp_scoped_receiver_type(
receiver_types, member_receiver, node.start_byte
)
if receiver_type:
if isinstance(receiver_type, list):
# `var v = R.M<A>(); v.X()` (#4266): R is typed
# here, M's return type in the cross-file pass.
_, recv, method, targs = receiver_type
if recv and recv != "this":
recv_t = _csharp_scoped_receiver_type(
receiver_types if isinstance(receiver_types, tuple)
else None, recv, node.start_byte,
)
if isinstance(recv_t, str):
recv = recv_t
elif recv_t is not None or not recv[:1].isupper():
recv = None # untyped local: no guess
if recv is not None:
rc_entry["receiver_call"] = [recv, method, targs]
elif receiver_type:
rc_entry["receiver_type"] = receiver_type
if config.ts_module == "tree_sitter_java":
rc_entry["lang"] = "java"
@@ -8692,7 +8776,11 @@ def _extract_generic(
_field_table_export = [
{"lang": _lang, "class_label": _n.get("label"),
"source_file": _n.get("source_file"), "fields": dict(_tbl)}
for _lang, _tables in (("java", java_field_types), ("csharp", csharp_field_types))
for _lang, _tables in (
("java", java_field_types),
("csharp", csharp_field_types),
("csharp_returns", csharp_method_returns),
)
for _cls, _tbl in _tables.items()
if _tbl
for _n in (next((x for x in nodes if x["id"] == _cls), None),)
+153
View File
@@ -0,0 +1,153 @@
"""C# `var x = R.M<A>(...)` is typed from M's declared return type (#4266).
`var report = ServiceFactory.Get<IReport>(scope); report.Build()` had no calls
edge because a `var` was only typed from `new T()`. The variable now takes the
called method's return type, with call-site type arguments substituted, when
the method resolves to exactly one declaration. Anything else stays untyped.
"""
from __future__ import annotations
import os
from pathlib import Path
from graphify.extract import extract
def _calls(tmp_path, files: dict[str, str]):
for name, body in files.items():
(tmp_path / name).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"])
_LIB = (
"namespace Demo\n{\n"
" public interface IReport { string Build(string name); }\n"
" public class Report : IReport { public string Build(string name) => name; }\n"
" public class Widget { public void Spin() { } }\n"
" public class Item { }\n"
" public class List<T> { public void Add(T item) { } }\n"
" public class Box<T> { public T Value() => default; }\n"
" public static class ServiceFactory\n {\n"
" public static T Get<T>(object scope) where T : new() => new T();\n"
" public static Widget Make() => new Widget();\n"
" public static List<T> CreateList<T>() => new List<T>();\n"
" public static Widget Pick(int a) => null;\n"
" public static IReport Pick(string b) => null;\n"
" public static T Infer<T>(T value) => value;\n"
" }\n"
"}\n"
)
def _consumer(body: str) -> str:
return (
"namespace Demo\n{\n"
f" public class Consumer {{ public void Run(object scope) {{ {body} }} }}\n"
"}\n"
)
def _run(r):
return _find(r, ".Run()", "consumer")
def test_generic_factory_same_file(tmp_path):
calls, r = _calls(tmp_path, {
"All.cs": _LIB + _consumer(
"var report = ServiceFactory.Get<IReport>(scope); report.Build(\"daily\");"
),
})
assert (_run(r), _find(r, ".Build()", "ireport")) in calls
assert (_run(r), _find(r, ".Build()", "_report_")) not in calls
def test_generic_factory_cross_file(tmp_path):
calls, r = _calls(tmp_path, {
"Lib.cs": _LIB,
"Consumer.cs": _consumer(
"var report = ServiceFactory.Get<IReport>(scope); report.Build(\"daily\");"
),
})
assert (_run(r), _find(r, ".Build()", "ireport")) in calls
def test_non_generic_return_type(tmp_path):
calls, r = _calls(tmp_path, {
"Lib.cs": _LIB,
"Consumer.cs": _consumer("var w = ServiceFactory.Make(); w.Spin();"),
})
assert (_run(r), _find(r, ".Spin()", "widget")) in calls
def test_generic_list_return_type(tmp_path):
calls, r = _calls(tmp_path, {
"Lib.cs": _LIB,
"Consumer.cs": _consumer(
"var items = ServiceFactory.CreateList<Item>(); items.Add(null);"
),
})
assert (_run(r), _find(r, ".Add()", "list")) in calls
def test_ambiguous_overload_no_edge(tmp_path):
calls, r = _calls(tmp_path, {
"Lib.cs": _LIB,
"Consumer.cs": _consumer("var w = ServiceFactory.Pick(1); w.Spin();"),
})
assert (_run(r), _find(r, ".Spin()", "widget")) not in calls
def test_missing_declaration_no_edge(tmp_path):
calls, r = _calls(tmp_path, {
"Lib.cs": _LIB,
"Consumer.cs": _consumer(
"var w = ServiceFactory.Unknown<Widget>(); w.Spin();"
" var v = Elsewhere.Get<Widget>(scope); v.Spin();"
),
})
assert (_run(r), _find(r, ".Spin()", "widget")) not in calls
def test_inferred_generic_no_edge(tmp_path):
calls, r = _calls(tmp_path, {
"Lib.cs": _LIB,
"Consumer.cs": _consumer(
"var w = ServiceFactory.Infer(new Widget()); w.Spin();"
),
})
assert (_run(r), _find(r, ".Spin()", "widget")) not in calls
def test_class_type_parameter_return_no_edge(tmp_path):
calls, r = _calls(tmp_path, {
"Lib.cs": _LIB,
"Consumer.cs": _consumer(
"var box = new Box<Widget>(); var w = box.Value(); w.Spin();"
),
})
assert (_run(r), _find(r, ".Value()", "box")) in calls
assert (_run(r), _find(r, ".Spin()", "widget")) not in calls
def test_explicit_type_and_new_still_resolve(tmp_path):
calls, r = _calls(tmp_path, {
"Lib.cs": _LIB,
"Consumer.cs": _consumer(
"IReport a = ServiceFactory.Get<IReport>(scope); a.Build(\"x\");"
" var w = new Widget(); w.Spin();"
),
})
assert (_run(r), _find(r, ".Build()", "ireport")) in calls
assert (_run(r), _find(r, ".Spin()", "widget")) in calls