Files
graphify/tests/test_csharp_overloads.py
T
Mpasha17andClaude Opus 4.8 87151e40ac fix(csharp): give each method overload its own node and bind calls by argument count (#4584, #4591)
Applied from PR #4591 (authorship preserved); stale cache-schema bump dropped for one combined bump.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-23 18:15:24 +01:00

270 lines
9.7 KiB
Python

"""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<string, int> 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"}