diff --git a/graphify/extract.py b/graphify/extract.py
index a9e77c1ac..ae813af01 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -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()` (#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()``: 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:
diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py
index cff0c0687..ed2cfd8d3 100644
--- a/graphify/extractors/engine.py
+++ b/graphify/extractors/engine.py
@@ -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`` -> ``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(...)`` (#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()` — 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()` 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(); 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),)
diff --git a/tests/test_csharp_var_call_return_type.py b/tests/test_csharp_var_call_return_type.py
new file mode 100644
index 000000000..3b7da5e8d
--- /dev/null
+++ b/tests/test_csharp_var_call_return_type.py
@@ -0,0 +1,153 @@
+"""C# `var x = R.M(...)` is typed from M's declared return type (#4266).
+
+`var report = ServiceFactory.Get(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 { public void Add(T item) { } }\n"
+ " public class Box { public T Value() => default; }\n"
+ " public static class ServiceFactory\n {\n"
+ " public static T Get(object scope) where T : new() => new T();\n"
+ " public static Widget Make() => new Widget();\n"
+ " public static List CreateList() => new List();\n"
+ " public static Widget Pick(int a) => null;\n"
+ " public static IReport Pick(string b) => null;\n"
+ " public static T Infer(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(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(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- (); 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(); w.Spin();"
+ " var v = Elsewhere.Get(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(); 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(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