feat(csharp): emit a node per enum member (#3063)

A C# enum's members got no graph node, so an enum case was invisible. Emit a member node
per enum_member_declaration with a case_of edge to the enum (mirroring the Java/Kotlin/Swift
enum handlers), deduped against a case-only sibling collision; explicit and implicit values
both work and no builtin types are fabricated.
This commit is contained in:
durmazoguzhan
2026-08-25 15:45:06 +01:00
committed by safishamsi
parent 62371ad28d
commit 0e566345fc
2 changed files with 187 additions and 1 deletions
+24 -1
View File
@@ -2385,7 +2385,30 @@ def _csharp_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path:
nodes: list, edges: list, seen_ids: set, function_bodies: list,
parent_class_nid: str | None, add_node_fn, add_edge_fn,
walk_fn, namespace_stack: list[str], scope_stack: list[str]) -> bool:
"""Handle C# namespaces and transparent class-member wrappers."""
"""Handle C# namespaces, enum members, and transparent class-member wrappers."""
if node.type == "enum_member_declaration" and parent_class_nid:
# `enum_declaration` is in C#'s class_types, so the enum type is a node
# but its members were not, leaving the type a leaf: "which value does
# this consumer branch on" had no answer. Java has emitted a node per
# `enum_constant` with a `case_of` edge since #1719, Kotlin since #1738,
# and Swift does the same for `enum_entry`; C# reaches the members
# through the same walk, so this is the Java shape applied here.
name_node = node.child_by_field_name("name")
if name_node is None:
return True
member_name = _read_text(name_node, source)
if not member_name:
return True
line = node.start_point[0] + 1
member_nid = _make_id(parent_class_nid, member_name)
# C# is case-sensitive, so `enum E { Value, value }` is legal, but the id
# recipe casefolds — both members normalize to one id. Emitting the
# second would hang a duplicate edge on the first member's node, so the
# first declaration keeps it (same guard as the property nodes in #3006).
if member_nid not in seen_ids:
add_node_fn(member_nid, member_name, line)
add_edge_fn(parent_class_nid, member_nid, "case_of", line)
return True
if node.type == "namespace_declaration":
ns_name = _csharp_namespace_name(node, source)
pushed = False
+163
View File
@@ -0,0 +1,163 @@
"""C# enum members get a node and a `case_of` edge, like Java's (#1719).
`enum_declaration` is in `_CSHARP_CONFIG.class_types`, so the enum type was a
node while its members were not — the type sat in the graph as a leaf. Java has
emitted a node per `enum_constant` with a `case_of` edge since #1719 and Kotlin
since #1738 (both from #1700), and Swift does the same for `enum_entry`. The C#
walk already descends into `enum_member_declaration_list` with the enum as
`parent_class_nid`, so this is the Java shape applied where it was missing.
"""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from graphify.extract import extract
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)
r = extract([Path(n) for n in files], cache_root=Path(tempfile.mkdtemp()))
finally:
os.chdir(old)
case_of = {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "case_of"}
return case_of, r
def _find(r, label):
return next(n["id"] for n in r["nodes"] if n["label"] == label)
def _labels(r):
return [n["label"] for n in r["nodes"]]
def test_enum_member_becomes_a_node(tmp_path):
case_of, r = _extract(tmp_path, {"S.cs": (
"public enum ProcessingType {\n"
" Process\n"
"}\n"
)})
assert "Process" in _labels(r)
assert (_find(r, "ProcessingType"), _find(r, "Process")) in case_of
def test_every_member_gets_its_own_node(tmp_path):
case_of, r = _extract(tmp_path, {"S.cs": (
"public enum ProcessingType {\n"
" Process,\n"
" ProcessAndCompleteIndex\n"
"}\n"
)})
enum_id = _find(r, "ProcessingType")
assert (enum_id, _find(r, "Process")) in case_of
assert (enum_id, _find(r, "ProcessAndCompleteIndex")) in case_of
def test_an_explicit_value_does_not_change_the_member(tmp_path):
# `= 1` parses as a `value` field next to `name`; the member is read off
# `name`, so an assigned constant is the same shape as a bare one.
case_of, r = _extract(tmp_path, {"S.cs": (
"public enum EntityType {\n"
" Variant = 1,\n"
" Product = 2\n"
"}\n"
)})
enum_id = _find(r, "EntityType")
assert (enum_id, _find(r, "Variant")) in case_of
assert (enum_id, _find(r, "Product")) in case_of
def test_case_only_sibling_members_do_not_duplicate_an_edge(tmp_path):
# Legal C#, and one id after normalization. The first declaration keeps the
# node rather than a second edge hanging off it.
case_of, r = _extract(tmp_path, {"S.cs": (
"public enum Odd {\n"
" Value,\n"
" value\n"
"}\n"
)})
assert len(case_of) == 1
def test_a_namespaced_enum_still_emits_members(tmp_path):
# File-scoped namespaces are handled in the same walk; the enum must not
# lose its members to the namespace hop.
case_of, r = _extract(tmp_path, {"S.cs": (
"namespace Catalog.Events;\n"
"public enum EntityType {\n"
" Brand\n"
"}\n"
)})
assert (_find(r, "EntityType"), _find(r, "Brand")) in case_of
def test_an_enum_nested_in_a_class_emits_members(tmp_path):
# The member's parent is the enum, not the enclosing class.
case_of, r = _extract(tmp_path, {"S.cs": (
"public class Holder {\n"
" public enum Mode {\n"
" Fast\n"
" }\n"
"}\n"
)})
mode = _find(r, "Mode")
assert (mode, _find(r, "Fast")) in case_of
assert (_find(r, "Holder"), _find(r, "Fast")) not in case_of
def test_the_enum_type_node_is_kept(tmp_path):
# Regression guard: the member nodes are additive. The enum type stays a
# node and stays contained by its file.
_, r = _extract(tmp_path, {"S.cs": (
"public enum ProcessingType {\n"
" Process\n"
"}\n"
)})
enum_id = _find(r, "ProcessingType")
contains = {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "contains"}
assert any(target == enum_id for _, target in contains)
def test_a_member_is_not_a_method(tmp_path):
# A member lands on `case_of`, never on `method` — nothing should read it
# as a callable.
case_of, r = _extract(tmp_path, {"S.cs": (
"public enum ProcessingType {\n"
" Process\n"
"}\n"
)})
member = _find(r, "Process")
methods = {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "method"}
assert (_find(r, "ProcessingType"), member) in case_of
assert (_find(r, "ProcessingType"), member) not in methods
def test_an_empty_enum_emits_no_members(tmp_path):
case_of, r = _extract(tmp_path, {"S.cs": "public enum Empty {\n}\n"})
assert "Empty" in _labels(r)
assert case_of == set()
def test_a_property_and_an_enum_member_sharing_a_name_stay_separate(tmp_path):
# Member ids are scoped to their declaring type, so a class property and an
# enum member with the same name are two nodes, not one shared node.
case_of, r = _extract(tmp_path, {"S.cs": (
"public enum Mode {\n"
" Active\n"
"}\n"
"public class Widget {\n"
" public bool Active { get; set; }\n"
"}\n"
)})
defines = {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "defines"}
enum_member = next(t for s, t in case_of if s == _find(r, "Mode"))
class_member = next(t for s, t in defines if s == _find(r, "Widget"))
assert enum_member != class_member