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