diff --git a/graphify/extract.py b/graphify/extract.py index da93a4ec5..e22c2fd37 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -183,7 +183,7 @@ from graphify.extractors.resolution import ( # noqa: E402,F401 from graphify.symbol_resolution import resolve_bash_source_edges # noqa: E402 -from graphify.extractors.engine import PYTEST_FIXTURE_UNTYPED, REFERENCE_CONTEXTS, _CSHARP_TYPE_PARAMETER_SCOPE_DECLARATIONS, _C_PRIMITIVE_TYPE_NODES, _JAVA_BUILTIN_TYPES, _JAVA_TYPE_PARAMETER_SCOPE_DECLARATIONS, _JS_FUNCTION_VALUE_TYPES, _JS_SCOPE_BOUNDARY, _PYTHON_ANNOTATION_NOISE, _PYTHON_TYPE_CONTAINERS, _RUBY_CLASS_FACTORIES, _c_collect_type_refs, _cpp_collect_type_refs, _cpp_declarator_name, _cpp_local_var_types, _csharp_attribute_names, _csharp_classify_base, _csharp_collect_type_refs, _csharp_extra_walk, _csharp_namespace_id, _csharp_namespace_name, _csharp_pre_scan_interfaces, _csharp_type_parameters_in_scope, _dynamic_import_js, _extract_generic, _find_body, _find_require_call, _get_cpp_func_name, _java_annotation_names, _java_collect_type_refs, _java_extra_walk, _java_type_parameters_in_scope, _js_collect_pattern_idents, _js_dispatch_value_idents, _js_extra_walk, _js_local_bound_names, _js_member_assignment_target, _js_module_bound_names, _kotlin_collect_type_refs, _kotlin_function_return_type_node, _kotlin_property_type_node, _kotlin_user_type_name, _php_collect_type_refs, _php_method_return_type_node, _php_name_text, _python_collect_assignment_targets, _python_collect_param_refs, _python_collect_type_refs, _python_local_bound_names, _python_module_bound_names, _python_param_names, _read_csharp_type_name, _require_imports_js, _ruby_const_last_name, _ruby_extra_walk, _ruby_local_class_bindings, _ruby_new_class_name, _scala_collect_type_refs, _semantic_reference_edge, _source_location, _swift_classify_base, _swift_collect_type_refs, _swift_constructor_type, _swift_declaration_keyword, _swift_extra_walk, _swift_local_var_types, _swift_pre_scan, _swift_property_name, _swift_property_type_node, _swift_receiver_name, _swift_user_type_name, _ts_decorator_name, _ts_descendant_decorators, _ts_emit_decorator_edges, _ts_extra_walk, _ts_method_name, _ts_receiver_type_table # noqa: E402,F401 +from graphify.extractors.engine import PYTEST_FIXTURE_UNTYPED, REFERENCE_CONTEXTS, _CSHARP_TYPE_PARAMETER_SCOPE_DECLARATIONS, _C_PRIMITIVE_TYPE_NODES, _JAVA_BUILTIN_TYPES, _JAVA_TYPE_PARAMETER_SCOPE_DECLARATIONS, _JS_FUNCTION_VALUE_TYPES, _JS_SCOPE_BOUNDARY, _PYTHON_ANNOTATION_NOISE, _PYTHON_TYPE_CONTAINERS, _RUBY_CLASS_FACTORIES, _c_collect_type_refs, _cpp_collect_type_refs, _cpp_declarator_name, _cpp_local_var_types, _csharp_attribute_names, _csharp_classify_base, _csharp_collect_type_refs, _csharp_extra_walk, _csharp_namespace_id, _csharp_namespace_name, _csharp_pick_overload, _csharp_pre_scan_interfaces, _csharp_type_parameters_in_scope, _dynamic_import_js, _extract_generic, _find_body, _find_require_call, _get_cpp_func_name, _java_annotation_names, _java_collect_type_refs, _java_extra_walk, _java_type_parameters_in_scope, _js_collect_pattern_idents, _js_dispatch_value_idents, _js_extra_walk, _js_local_bound_names, _js_member_assignment_target, _js_module_bound_names, _kotlin_collect_type_refs, _kotlin_function_return_type_node, _kotlin_property_type_node, _kotlin_user_type_name, _php_collect_type_refs, _php_method_return_type_node, _php_name_text, _python_collect_assignment_targets, _python_collect_param_refs, _python_collect_type_refs, _python_local_bound_names, _python_module_bound_names, _python_param_names, _read_csharp_type_name, _require_imports_js, _ruby_const_last_name, _ruby_extra_walk, _ruby_local_class_bindings, _ruby_new_class_name, _scala_collect_type_refs, _semantic_reference_edge, _source_location, _swift_classify_base, _swift_collect_type_refs, _swift_constructor_type, _swift_declaration_keyword, _swift_extra_walk, _swift_local_var_types, _swift_pre_scan, _swift_property_name, _swift_property_type_node, _swift_receiver_name, _swift_user_type_name, _ts_decorator_name, _ts_descendant_decorators, _ts_emit_decorator_edges, _ts_extra_walk, _ts_method_name, _ts_receiver_type_table # noqa: E402,F401 from graphify.extractors.pascal import _PAS_BEGIN_END_TOKEN_RE, _PAS_CALL_RE, _PAS_END_SEMI_RE, _PAS_IMPL_HEADER_RE, _PAS_KEYWORDS, _PAS_METHOD_DECL_RE, _PAS_MODULE_RE, _PAS_TOKEN_RE, _PAS_TYPE_HEADER_RE, _PAS_USES_RE, _extract_pascal_regex, _pascal_find_body, _pascal_split_bases, _pascal_split_sections, _pascal_split_uses, _pascal_strip_comments, extract_pascal # noqa: E402,F401 @@ -5534,6 +5534,46 @@ def _resolve_cpp_member_calls( }) +def _csharp_overload_index(nodes: list[dict], edges: list[dict]) -> dict: + """Method nodes by id and their owning type, for C# methods with an arity.""" + node_by_id = {n.get("id"): n for n in nodes} + return { + "node": node_by_id, + "owner": { + e.get("target"): e.get("source") for e in edges + if e.get("relation") == "method" + and "arity" in ((node_by_id.get(e.get("target")) or {}).get("metadata") or {}) + }, + } + + +def _csharp_collapse_overloads(candidates: list[str], argc: object, index: dict) -> list[str]: + """Fold the overloads of one C# method among ``candidates`` into one (#4584). + + Overloads of a name on one type are separate nodes, so a bare-name lookup + sees them as rival definitions and, all in one file, no tie-breaker can + choose. The call's argument count picks the overload when exactly one fits; + otherwise the first declared one stands for the name, as it did when the + overloads were a single node. ``index`` is ``_csharp_overload_index``. + """ + owner = index["owner"] + groups: dict[tuple, list[str]] = {} + for c in candidates: + groups.setdefault((owner[c],) if c in owner else (None, c), []).append(c) + if len(groups) == len(candidates): + return candidates + keep: set[str] = set() + for members in groups.values(): + if len(members) == 1: + keep.add(members[0]) + continue + picked = _csharp_pick_overload( + [index["node"][m] for m in members], argc if isinstance(argc, int) else None + ) + keep.add(picked["id"] if picked is not None else members[0]) + return [c for c in candidates if c in keep] + + def _resolve_csharp_member_calls( per_file: list[dict], all_nodes: list[dict], @@ -5595,6 +5635,7 @@ def _resolve_csharp_member_calls( # (type_node_id, method_key) -> method_node_id, and caller -> enclosing type. # C# owns its methods via `method` edges. method_index: dict[tuple[str, str], str] = {} + method_peers: dict[tuple[str, str], list[dict]] = {} enclosing_type: dict[str, str] = {} for e in all_edges: if e.get("relation") != "method": @@ -5604,7 +5645,11 @@ def _resolve_csharp_member_calls( if tnode is None: continue enclosing_type.setdefault(tgt, src) - method_index[(src, _key(tnode.get("label", "")))] = tgt + # Overloads share a key: the first declared one stands for the name, + # and the call's argument count picks among them below (#4584). + method_key = (src, _key(tnode.get("label", ""))) + method_index.setdefault(method_key, tgt) + method_peers.setdefault(method_key, []).append(tnode) # Base-class chain from `inherits` edges (C# files only). The type-reference # pass has already re-pointed each resolvable base to its real definition and @@ -5834,6 +5879,11 @@ def _resolve_csharp_member_calls( method_nid = _method_on_type_or_bases(type_nid, _key(callee)) if not method_nid: continue # receiver typed, but the type has no such method — skip + peers = method_peers.get((enclosing_type.get(method_nid), _key(callee)), []) + if len(peers) > 1: + picked = _csharp_pick_overload(peers, rc.get("arity")) + if picked is not None: + method_nid = picked["id"] if method_nid == caller or (caller, method_nid) in existing_pairs: continue existing_pairs.add((caller, method_nid)) @@ -10507,6 +10557,7 @@ def extract( _csharp_stub_resolver: CsharpNameResolver | None = None # built on first use _csharp_method_owner: dict[str, str] | None = None # method nid -> type nid, built on first use _csharp_outer_type: dict[str, str] | None = None # nested type nid -> outer type label, built on first use + _csharp_overload_cache: dict = {} # filled on first use (#4584) for rc in all_raw_calls: if rc.get("_ambiguous_python_import"): continue @@ -10560,6 +10611,14 @@ def extract( candidates = global_label_to_nids_ci.get(callee.lower(), []) if not candidates: continue + if rc.get("lang") == "csharp" and not rc.get("csharp_new") and len(candidates) > 1: + if not _csharp_overload_cache: + _csharp_overload_cache.update(_csharp_overload_index( + resolution_nodes, all_edges + list(resolution_context_edges or []) + )) + candidates = _csharp_collapse_overloads( + candidates, rc.get("arity"), _csharp_overload_cache + ) if rc.get("csharp_new") or rc.get("lang") == "csharp": # An enum member or a field/property is never a valid call target # either — not just never what `new X()` constructs. A bare call diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index c9afec974..80280fc0d 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -190,6 +190,85 @@ def _csharp_type_name_for_id(node, class_name: str) -> str: return f"{class_name}`{arity}" if arity else class_name +def _csharp_method_signature(node, source: bytes) -> tuple[str, dict]: + """A C# method's parameter signature and the argument counts it accepts. + + The signature (``"`1:this Foo,ref int,params int[]"``) tells overloads + apart; the metadata (``arity``, plus ``min_arity`` for optional, ``params`` + and extension parameters, and ``variadic`` for ``params``) lets a call site + pick the overload its argument count fits (#4584). + """ + parts: list[str] = [] + arity = required = 0 + variadic = extension = False + params = node.child_by_field_name("parameters") + children = params.children if params is not None else [] + for i, c in enumerate(children): + if c.type == "parameter": + mods = [_read_text(m, source) for m in c.children if m.type == "modifier"] + type_node = c.child_by_field_name("type") + # No whitespace in a type, so `Dictionary`, a line + # break inside it, or CRLF never changes the id. + type_text = "".join(_read_text(type_node, source).split()) if type_node is not None else "" + parts.append(" ".join(mods + [type_text])) + arity += 1 + if "this" in mods and arity == 1: + extension = True + if not any(x.type == "=" for x in c.children): + required += 1 + elif c.type == "params": + type_node = children[i + 1] if i + 1 < len(children) else None + parts.append("params " + ("".join(_read_text(type_node, source).split()) if type_node is not None else "")) + arity += 1 + variadic = True + type_params = sum( + 1 for c in node.children if c.type == "type_parameter_list" + for _p in c.children if _p.type in ("type_parameter", "identifier") + ) + signature = ",".join(parts) + if type_params: + signature = f"`{type_params}:{signature}" + meta: dict = {"arity": arity} + # An extension method is also called as `x.M(rest)`, one argument short. + min_arity = required - 1 if extension else required + if min_arity < arity: + meta["min_arity"] = min_arity + if variadic: + meta["variadic"] = True + return signature, meta + + +def _csharp_argc(call_node) -> int | None: + """Argument count of a C# invocation, or None for anything else.""" + if call_node.type != "invocation_expression": + return None + args = call_node.child_by_field_name("arguments") + return sum(1 for c in args.children if c.type == "argument") if args is not None else 0 + + +def _csharp_pick_overload(overloads: list[dict], argc: int | None) -> dict | None: + """The one overload a call with ``argc`` arguments can mean, or None. + + ``overloads`` are the method nodes of one name on one type. Only the + argument count is known at the call site, so two overloads that both + accept it (``M(int)`` / ``M(string)``, or ``M(int)`` / ``M(params int[])``) + stay undecided; C# would pick by argument types, which are not known here + (#4584). + """ + if argc is None: + return None + fits = [] + for n in overloads: + meta = n.get("metadata") or {} + arity = meta.get("arity") + if not isinstance(arity, int): + return None + low = meta.get("min_arity", arity) + if low <= argc and (argc <= arity or meta.get("variadic")): + fits.append(n) + return fits[0] if len(fits) == 1 else None + + def _csharp_pre_scan_name_collisions( root_node, source: bytes, stem: str ) -> tuple[dict[str, set[str]], dict[str, set[str]]]: @@ -6073,6 +6152,9 @@ def _extract_generic( # 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]] = {} + # plain method nid -> {signature: node} for the overloads of one name on + # one type, in declaration order (#4584). + csharp_overloads: dict[str, dict[str, dict]] = {} csharp_interface_names: set[str] = set() csharp_type_groups: dict[str, set[str]] = {} @@ -7754,6 +7836,18 @@ def _extract_generic( func_nid = _underscore_salted_nid( func_nid, sanitized_name, csharp_member_groups ) + if t == "method_declaration": + # Overloads share a name, so they minted one id and + # every later overload vanished into the first (#4584). + # The first declaration keeps the plain id; each other + # signature gets its own, salted like #3302/#4415. + csharp_sig, csharp_sig_meta = _csharp_method_signature(node, source) + group = csharp_overloads.setdefault(func_nid, {}) + if group and csharp_sig not in group: + salt = hashlib.sha1( + csharp_sig.encode("utf-8"), usedforsecurity=False + ).hexdigest()[:6] + func_nid = _make_id(func_nid, salt) elif (config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript") and t in _JS_MEMBER_METHOD_TYPES): func_nid = _js_member_nid( @@ -7779,12 +7873,18 @@ def _extract_generic( existing_metadata = dict(existing_method.get("metadata") or {}) existing_metadata.update(ruby_method_metadata) existing_method["metadata"] = sanitize_metadata(existing_metadata) + method_metadata = ruby_method_metadata + if config.ts_module == "tree_sitter_c_sharp" and t == "method_declaration": + method_metadata = {**csharp_sig_meta, "signature": csharp_sig} add_node( func_nid, f".{func_name}()", line, - metadata=ruby_method_metadata, + metadata=method_metadata, ) + if (config.ts_module == "tree_sitter_c_sharp" and t == "method_declaration" + and csharp_sig not in group and nodes and nodes[-1]["id"] == func_nid): + group[csharp_sig] = nodes[-1] 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( @@ -8602,6 +8702,13 @@ def _extract_generic( label_to_nid.setdefault(normalised, n["id"]) label_to_nid_ci.setdefault(normalised.lower(), n["id"]) + # method nid -> its overloads (declaration order), for the call walk. + csharp_overload_peers: dict[str, list[dict]] = { + peer["id"]: list(group.values()) + for group in csharp_overloads.values() if len(group) > 1 + for peer in group.values() + } + seen_call_pairs: set[tuple[str, str]] = set() seen_indirect_pairs: set[tuple[str, str]] = set() # Python indirect_call dedup seen_dyn_import_pairs: set[tuple[str, str]] = set() @@ -9704,6 +9811,12 @@ def _extract_generic( # file (an annotation elsewhere in this file created it), so the # call still goes to raw_calls for cross-file resolution, which # skips source-less candidates on its own (#3888). + # C#: the name found one overload; the argument count picks + # the one this call means, else the first declared (#4584). + if tgt_nid in csharp_overload_peers: + peers = csharp_overload_peers[tgt_nid] + picked = _csharp_pick_overload(peers, _csharp_argc(node)) + tgt_nid = (picked or peers[0])["id"] external_stub_target = bool(tgt_nid and not nid_to_sf.get(tgt_nid)) if tgt_nid and not external_stub_target: pair = (caller_nid, tgt_nid) @@ -9807,6 +9920,9 @@ def _extract_generic( # class fields/properties are the base scope. if config.ts_module == "tree_sitter_c_sharp": rc_entry["lang"] = "csharp" + argc = _csharp_argc(node) + if argc is not None: + rc_entry["arity"] = argc # `new X()` can only construct a type, never an enum # member or property that happens to share X's name. if node.type == "object_creation_expression": diff --git a/graphify/interface_dispatch.py b/graphify/interface_dispatch.py index 172f5d28e..bd84b1e88 100644 --- a/graphify/interface_dispatch.py +++ b/graphify/interface_dispatch.py @@ -18,7 +18,8 @@ implementers the answer is all N, not none. Each implementer is still subject to the single-owner guard `resolve_pascal_inherited_calls` and `resolve_ruby_member_calls` already use: an implementer that itself declares more than one method of the same name is ambiguous and is skipped on its own, -independently of its siblings. +independently of its siblings, unless the overloads pair up one-to-one by +signature or parameter count (C#, #4584). The join is per method rather than per call site, so one edge per implementer reconnects every call that reaches the interface method. Confidence is @@ -168,28 +169,56 @@ def resolve_interface_dispatch( for impl_nid, impl_node in gated_impls: impl_methods = methods_of.get(impl_nid, {}) for name, declared in interface_methods.items(): - if len(declared) != 1: - continue - # Ambiguity is per implementer: one implementer overloading a - # name does not disqualify its siblings from a clean match. - candidates = impl_methods.get(name, set()) - if len(candidates) != 1: - continue - source = next(iter(declared)) - target = next(iter(candidates)) - if source == target or (source, target) in existing_pairs: - continue - existing_pairs.add((source, target)) - new_edges.append({ - "source": source, - "target": target, - "relation": DISPATCH_RELATION, - "context": "call", - "confidence": "INFERRED", - "confidence_score": score, - "source_file": str(impl_node.get("source_file", "")), - "source_location": impl_node.get("source_location"), - "weight": 1.0, - }) + for source, target in _overload_pairs( + declared, impl_methods.get(name, set()), node_by_id + ): + if source == target or (source, target) in existing_pairs: + continue + existing_pairs.add((source, target)) + new_edges.append({ + "source": source, + "target": target, + "relation": DISPATCH_RELATION, + "context": "call", + "confidence": "INFERRED", + "confidence_score": score, + "source_file": str(impl_node.get("source_file", "")), + "source_location": impl_node.get("source_location"), + "weight": 1.0, + }) all_edges.extend(new_edges) + + +def _overload_pairs( + declared: set[str], candidates: set[str], node_by_id: dict +) -> list[tuple[str, str]]: + """Pair an interface's methods of one name with an implementer's. + + One of each pairs up as always. Otherwise (C# overloads, #4584) each + interface method pairs with the one implementer method of the same declared + signature, else of the same parameter count among those no signature + claimed; no unique match, no edge. An implementer method takes at most one + interface method, so an overload implemented on a base class is not pinned + on a sibling of the same count. + """ + if len(declared) == 1 and len(candidates) == 1: + return [(next(iter(declared)), next(iter(candidates)))] + + def _meta(nid: str) -> dict: + meta = (node_by_id.get(nid) or {}).get("metadata") + return meta if isinstance(meta, dict) else {} + + pairs: dict[str, str] = {} + for key in ("signature", "arity"): + taken = set(pairs.values()) + for source in sorted(declared): + want = _meta(source).get(key) + if source in pairs or want is None: + continue + hits = [c for c in candidates if c not in taken and _meta(c).get(key) == want] + if len(hits) == 1: + pairs[source] = hits[0] + # Two interface methods on one implementer method: neither is certain. + targets = list(pairs.values()) + return [(s, t) for s, t in pairs.items() if targets.count(t) == 1] diff --git a/graphify/watch.py b/graphify/watch.py index 5483e9d89..4ee6fc1db 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -2061,6 +2061,11 @@ def _rebuild_code( "module", "name", "arity", + # C# overloads (#4584): which one a call's + # argument count fits. + "min_arity", + "variadic", + "signature", ) if key in metadata } diff --git a/tests/test_csharp_interface_dispatch.py b/tests/test_csharp_interface_dispatch.py index d5f42636f..906e7a0d7 100644 --- a/tests/test_csharp_interface_dispatch.py +++ b/tests/test_csharp_interface_dispatch.py @@ -127,10 +127,9 @@ def test_three_implementers_floor_at_the_weak_inference_tier(tmp_path): def test_one_ambiguous_implementer_does_not_suppress_its_siblings(tmp_path): - # B owns two distinct nodes named `.M()` (synthetic: the real extractor - # collapses same-named overloads into one node, so this models a case - # where it would not, e.g. partial classes merged incorrectly). B's - # ambiguity must stay scoped to B and not suppress A's clean match. + # B owns two distinct nodes named `.M()` with nothing (signature or + # arity) to tell them apart. B's ambiguity must stay scoped to B and not + # suppress A's clean match. from graphify.csharp_dispatch import resolve_csharp_interface_dispatch nodes = [ @@ -163,11 +162,9 @@ def test_no_matching_member_produces_no_edge(tmp_path): assert not dispatch -def test_overloads_collapsed_into_one_node_still_link(tmp_path): - # The extractor mints one node per method name, so two overloads share it. - # The guard counts distinct nodes, not `method` edge multiplicity, so this - # is one candidate rather than a tie. Same reasoning as the dedup comment in - # resolve_pascal_inherited_calls. +def test_overloaded_implementer_still_links(tmp_path): + # A's overloads are two nodes since #4584; the interface's M() pairs with + # the one of the same parameter count. dispatch, r = _extract(tmp_path, {"S.cs": ( "public interface IR { void M(); }\n" "public class A : IR { public void M() { } public void M(int x) { } }\n" diff --git a/tests/test_csharp_overloads.py b/tests/test_csharp_overloads.py new file mode 100644 index 000000000..2392d83d4 --- /dev/null +++ b/tests/test_csharp_overloads.py @@ -0,0 +1,269 @@ +"""C# overloads get a node each, and calls pick theirs by argument count (#4584). + +Overloads share a name, so they minted one id: ``add_node`` kept the first +declaration, the others vanished, every caller of any overload pointed at the +first one, and a later overload's own calls were credited to it. Now the first +declared overload keeps the plain id (so a type without overloads, and the +first overload, keep the ids they had) and every other signature gets a salted +one. A call binds to the overload its argument count fits; when that is not +unique (``M(int)`` / ``M(string)``) it binds to the first, as before. +""" +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + +from graphify.extract import extract + +_MESSAGES = """namespace Demo +{ + public static class Messages + { + public static string Banner(int code) + { + return Format(code); + } + + public static string Banner(int code, string format) + { + return Pad(format); + } + + private static string Format(int code) { return code.ToString(); } + private static string Pad(string text) { return text; } + } +} +""" + +_CALLER = """namespace Demo +{ + public class Caller + { + public string OneArg() { return Messages.Banner(1); } + public string TwoArgs() { return Messages.Banner(1, "x"); } + } +} +""" + + +def _extract(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) + return extract([Path(n) for n in files], cache_root=Path(tempfile.mkdtemp())) + finally: + os.chdir(old) + + +def _by_line(r, label): + return { + n["source_location"]: n["id"] + for n in r["nodes"] if n["label"] == label and n.get("source_file") + } + + +def _edges(r, relation="calls"): + return {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == relation} + + +def _id(r, label): + ids = [n["id"] for n in r["nodes"] if n["label"] == label and n.get("source_file")] + assert len(ids) == 1, (label, ids) + return ids[0] + + +def test_issue_repro_each_overload_keeps_its_node_callers_and_callees(tmp_path): + r = _extract(tmp_path, {"Messages.cs": _MESSAGES, "Caller.cs": _CALLER}) + banner = _by_line(r, ".Banner()") + assert set(banner) == {"L5", "L10"} + one, two = banner["L5"], banner["L10"] + # The first overload keeps the id the single node had. + assert one == "messages_demo_messages_banner" + calls = _edges(r) + assert (_id(r, ".OneArg()"), one) in calls + assert (_id(r, ".TwoArgs()"), two) in calls + assert (_id(r, ".OneArg()"), two) not in calls + assert (_id(r, ".TwoArgs()"), one) not in calls + assert (one, _id(r, ".Format()")) in calls + assert (two, _id(r, ".Pad()")) in calls + assert (one, _id(r, ".Pad()")) not in calls + + +def test_method_without_overloads_is_unchanged(tmp_path): + r = _extract(tmp_path, {"Messages.cs": _MESSAGES}) + fmt = next(n for n in r["nodes"] if n["label"] == ".Format()") + assert fmt["id"] == "messages_demo_messages_format" + + +def test_in_file_and_instance_calls_pick_by_argument_count(tmp_path): + r = _extract(tmp_path, { + "Svc.cs": ( + "public class Svc {\n" + " public void Run() { }\n" + " public void Run(int a) { Run(); }\n" + " public void Twice() { Run(1); }\n" + "}\n" + ), + "Use.cs": ( + "public class Use {\n" + " public void Go(Svc s) { s.Run(); }\n" + " public void Go1(Svc s) { s.Run(2); }\n" + "}\n" + ), + }) + run = _by_line(r, ".Run()") + calls = _edges(r) + assert (run["L3"], run["L2"]) in calls + assert (_id(r, ".Twice()"), run["L3"]) in calls + assert (_id(r, ".Twice()"), run["L2"]) not in calls + assert (_id(r, ".Go()"), run["L2"]) in calls + assert (_id(r, ".Go1()"), run["L3"]) in calls + + +def test_same_count_overloads_bind_to_the_first(tmp_path): + r = _extract(tmp_path, {"S.cs": ( + "public static class S {\n" + " public static void Put(int x) { }\n" + " public static void Put(string x) { }\n" + " public static void Call() { Put(1); }\n" + "}\n" + )}) + put = _by_line(r, ".Put()") + assert len(put) == 2 + calls = _edges(r) + assert (_id(r, ".Call()"), put["L2"]) in calls + assert (_id(r, ".Call()"), put["L3"]) not in calls + + +def test_optional_and_params_arguments_count(tmp_path): + r = _extract(tmp_path, {"S.cs": ( + "public static class S {\n" + " public static void Opt(string s) { }\n" + " public static void Opt(int a, int b, int c = 1) { }\n" + " public static void Many(int a) { }\n" + " public static void Many(int a, int b, params int[] rest) { }\n" + " public static void Call() { Opt(1, 2); Many(1, 2, 3, 4); }\n" + "}\n" + )}) + calls = _edges(r) + caller = _id(r, ".Call()") + assert (caller, _by_line(r, ".Opt()")["L3"]) in calls + assert (caller, _by_line(r, ".Many()")["L5"]) in calls + + +def test_same_file_partial_halves_keep_their_overloads(tmp_path): + r = _extract(tmp_path, {"P.cs": ( + "public partial class P { public void M(int a) { } }\n" + "public partial class P { public void M(int a, int b) { M(a); } }\n" + )}) + m = _by_line(r, ".M()") + assert set(m) == {"L1", "L2"} + assert (m["L2"], m["L1"]) in _edges(r) + + +def test_identical_signature_behind_preprocessor_stays_one_node(tmp_path): + r = _extract(tmp_path, {"S.cs": ( + "public class S {\n" + "#if A\n" + " public void M(int a) { }\n" + "#else\n" + " public void M(int a) { }\n" + "#endif\n" + "}\n" + )}) + assert len(_by_line(r, ".M()")) == 1 + + +def test_interface_overloads_dispatch_to_their_own_implementation(tmp_path): + r = _extract(tmp_path, {"S.cs": ( + "public interface IR { void M(int a); void M(int a, string b); }\n" + "public class A : IR {\n" + " public void M(int a) { }\n" + " public void M(int a, string b) { }\n" + "}\n" + )}) + iface = _by_line(r, ".M()") + dispatch = _edges(r, "dispatches_to") + ir1 = next(n["id"] for n in r["nodes"] if n["id"].startswith("s_ir_m") and n["source_location"] == "L1" + and n["metadata"].get("arity") == 1) + ir2 = next(n["id"] for n in r["nodes"] if n["id"].startswith("s_ir_m") + and n["metadata"].get("arity") == 2) + assert dispatch == {(ir1, iface["L3"]), (ir2, iface["L4"])} + + +def test_overload_ids_ignore_whitespace_and_line_endings(tmp_path): + src = ( + "public class S {\n" + " public void M(int a) { }\n" + " public void M(System.Collections.Generic.Dictionary d) { }\n" + "}\n" + ) + a = _extract(tmp_path / "a", {"S.cs": src}) + b = _extract(tmp_path / "b", {"S.cs": src.replace(", int>", ",\n int>").replace("\n", "\r\n")}) + assert sorted(_by_line(a, ".M()").values()) == sorted(_by_line(b, ".M()").values()) + + +def test_interface_overload_implemented_on_a_base_does_not_dispatch_to_a_sibling(tmp_path): + # Impl declares only M(int); M(string) comes from Base. IR.M(string) must + # not be pinned on Impl.M(int) just because both take one argument. + r = _extract(tmp_path, {"D.cs": ( + "public interface IR { void M(int a); void M(string a); }\n" + "public class Base { public void M(string a) { } }\n" + "public class Impl : Base, IR { public void M(int a) { } }\n" + )}) + by_sig = { + n["metadata"].get("signature"): n["id"] + for n in r["nodes"] if n["id"].startswith("d_ir_m") + } + impl = next(n["id"] for n in r["nodes"] if n["id"] == "d_impl_m") + assert _edges(r, "dispatches_to") == {(by_sig["int"], impl)} + + +def test_collapse_keeps_distinct_names_on_one_type_apart(tmp_path): + # Review: overloads are folded per owner type, but only among candidates + # of one name, so `Put` / `_Put` / `put` stay separate targets. + r = _extract(tmp_path, { + "A.cs": ( + "public partial class S {\n" + " public void Put(int a) { }\n" + " public void _Put(int a, int b) { }\n" + " public void put(int a, int b, int c) { }\n" + " public void Put(string a, int b) { }\n" + "}\n" + ), + "B.cs": ( + "public partial class S {\n" + " public void Go() { Put(1); _Put(1, 2); put(1, 2, 3); Put(\"x\", 1); }\n" + "}\n" + ), + }) + go = _id(r, ".Go()") + calls = {t for s, t in _edges(r) if s == go} + put = _by_line(r, ".Put()") + assert calls == {put["L2"], put["L5"], _id(r, "._Put()"), _id(r, ".put()")} + + +def test_params_parameter_is_variadic_and_optional(tmp_path): + r = _extract(tmp_path, {"P.cs": ( + "public static class P {\n" + " public static void Log(string m) { }\n" + " public static void Log(string m, params object[] args) { }\n" + "}\n" + )}) + meta = {n["source_location"]: n["metadata"] for n in r["nodes"] if n["label"] == ".Log()"} + assert meta["L3"]["arity"] == 2 and meta["L3"]["min_arity"] == 1 and meta["L3"]["variadic"] + assert "variadic" not in meta["L2"] + + +def test_overloads_in_struct_record_and_beside_other_members(tmp_path): + r = _extract(tmp_path, {"L.cs": ( + "public struct St { public void M(int a) { } public int M2 { get; } public void M(int a, int b) { } }\n" + "public record R { public void N() { } class Inner { public void N(int x) { } } public void N(int x) { } }\n" + )}) + assert len([n for n in r["nodes"] if n["id"].startswith("l_st_m") and n["label"] == ".M()"]) == 2 + assert {n["id"] for n in r["nodes"] if n["label"] == ".N()"} == {"l_r_n", "l_r_n_46f8ab", "l_inner_n"} diff --git a/tests/test_csharp_underscore_collisions.py b/tests/test_csharp_underscore_collisions.py index de23f0596..59d5a2cfe 100644 --- a/tests/test_csharp_underscore_collisions.py +++ b/tests/test_csharp_underscore_collisions.py @@ -116,14 +116,19 @@ def test_case_only_method_pair_keeps_both_methods(tmp_path): assert _one(r, ".Load()") != _one(r, ".load()") -def test_overloads_stay_one_node(tmp_path): +def test_overloads_are_not_an_underscore_collision(tmp_path): + # Overloads are one name, so this rule leaves them alone; they get a node + # each through the overload rule instead (#4584), the first keeping the + # plain id. r = _extract(tmp_path, {"S.cs": ( "public class Host {\n" " public void Save() { }\n" " public void Save(int x) { }\n" "}\n" )}) - _one(r, ".Save()") + ids = _ids(r, ".Save()") + assert len(ids) == 2 + assert "s_host_save" in ids def test_member_behind_preprocessor_directive_is_counted(tmp_path):