diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 38e9a5420..9c3ed4bac 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -239,9 +239,43 @@ def _csharp_collect_type_refs( if c.is_named: _csharp_collect_type_refs(c, source, generic, out, skip) -def _csharp_attribute_names(method_node, source: bytes) -> list[tuple[str, bool, str]]: - """Collect attribute names from a C# method/declaration's attribute_list children.""" - names: list[tuple[str, bool, str]] = [] +def _csharp_attribute_string_argument(attr_node, source: bytes) -> str | None: + """First string-literal argument of a C# attribute, unquoted. + + ``[HttpGet("Status")]`` -> ``"Status"``. Verbatim literals (``@"..."``) are + unwrapped too. Non-string arguments (``[ApiExplorerSettings(IgnoreApi = true)]``) + and argument-less attributes yield None — only a literal path is useful to a + reader, and anything computed cannot be resolved statically anyway. + """ + args = attr_node.child_by_field_name("arguments") + if args is None: + args = next((c for c in attr_node.children + if c.type == "attribute_argument_list"), None) + if args is None: + return None + stack = list(args.children) + while stack: + node = stack.pop(0) + if node.type in ("string_literal", "verbatim_string_literal"): + text = _read_text(node, source) + if text.startswith("@"): + text = text[1:] + if len(text) >= 2 and text[0] == '"' and text[-1] == '"': + return text[1:-1] + return text + stack.extend(node.children) + return None + + +def _csharp_attribute_names(method_node, source: bytes) -> list[tuple[str, bool, str, str | None]]: + """Collect attribute names from a C# method/declaration's attribute_list children. + + Each entry is ``(name, qualified, qualifier, string_argument)``. The argument + carries an attribute's literal payload — the route template of + ``[Route("api/x")]`` — which the type-reference side ignores but + :func:`_csharp_route_label` needs. + """ + names: list[tuple[str, bool, str, str | None]] = [] skip = _csharp_type_parameters_in_scope(method_node, source) for child in method_node.children: if child.type != "attribute_list": @@ -259,9 +293,104 @@ def _csharp_attribute_names(method_node, source: bytes) -> list[tuple[str, bool, qualified = name_node.type == "qualified_name" prefix, _, text = _read_text(name_node, source).rpartition(".") if text and text not in skip: - names.append((text, qualified, prefix if qualified else "")) + names.append((text, qualified, prefix if qualified else "", + _csharp_attribute_string_argument(attr, source))) return names + +# ASP.NET routing attributes. The verb map doubles as the recognizer: an +# attribute outside this set never mints a route node, so `[Obsolete("...")]` +# and `[Display(Name="x")]` keep their payload out of the graph. +_CSHARP_ROUTE_VERBS = { + "HttpGet": "GET", + "HttpPost": "POST", + "HttpPut": "PUT", + "HttpDelete": "DELETE", + "HttpPatch": "PATCH", + "HttpHead": "HEAD", + "HttpOptions": "OPTIONS", +} +_CSHARP_ROUTE_ATTRIBUTE = "Route" + + +def _csharp_enclosing_class(node): + """Nearest enclosing type declaration of ``node``, or None at file scope.""" + scope = node.parent + while scope is not None: + if scope.type in ("class_declaration", "record_declaration", "struct_declaration"): + return scope + scope = scope.parent + return None + + +def _csharp_expand_route_tokens(template: str, class_node, source: bytes) -> str: + """Expand the conventional ``[controller]`` token. + + ``PresenceController`` + ``api/[controller]`` -> ``api/Presence``. ``[action]`` + is left alone: it resolves per-method and the method name is already the edge's + other endpoint. + """ + if "[controller]" not in template or class_node is None: + return template + name_node = class_node.child_by_field_name("name") + if name_node is None: + return template + name = _read_text(name_node, source) + if name.endswith("Controller") and len(name) > len("Controller"): + name = name[: -len("Controller")] + return template.replace("[controller]", name) + + +def _csharp_route_label(method_node, source: bytes) -> str | None: + """Compose ``" "`` for a method carrying ASP.NET routing attributes. + + The verb comes from an ``Http*`` attribute, the path from that attribute's own + template or from a sibling ``[Route]`` on the same method — the two-attribute + style (``[HttpGet]`` + ``[Route("login")]``) is the dominant one in large + codebases. A class-level ``[Route]`` is the prefix, unless the method template + is absolute (leading ``/`` or ``~/``), per ASP.NET's own rule. A method with a + ``[Route]`` but no verb attribute matches every verb and is labelled ``*``. + + Returns None when the method carries no routing attribute at all. + """ + verb = None + method_template = None + saw_routing_attribute = False + for name, _qualified, _qualifier, argument in _csharp_attribute_names(method_node, source): + if name in _CSHARP_ROUTE_VERBS: + saw_routing_attribute = True + verb = verb or _CSHARP_ROUTE_VERBS[name] + if argument and method_template is None: + method_template = argument + elif name == _CSHARP_ROUTE_ATTRIBUTE: + saw_routing_attribute = True + if argument and method_template is None: + method_template = argument + if not saw_routing_attribute: + return None + + class_node = _csharp_enclosing_class(method_node) + prefix = "" + if class_node is not None: + for name, _q, _qual, argument in _csharp_attribute_names(class_node, source): + if name == _CSHARP_ROUTE_ATTRIBUTE and argument: + prefix = argument + break + + template = method_template or "" + if template.startswith("~/"): + path = template[1:] + elif template.startswith("/"): + path = template + elif prefix and template: + path = f"{prefix.rstrip('/')}/{template.lstrip('/')}" + else: + path = template or prefix + path = _csharp_expand_route_tokens(path, class_node, source) + if not path: + return None + return f"{verb or '*'} {path}" + _JAVA_TYPE_PARAMETER_SCOPE_DECLARATIONS = frozenset({ "class_declaration", "interface_declaration", @@ -4302,7 +4431,7 @@ def scala_base_name(type_node) -> str | None: metadata["ref_qualifier"] = qualifier add_edge(func_nid, target_nid, "references", line, context=ctx, metadata=metadata) - for attr_name, qualified, qualifier in _csharp_attribute_names(node, source): + for attr_name, qualified, qualifier, _argument in _csharp_attribute_names(node, source): target_nid = ensure_named_node(attr_name, line) if target_nid != func_nid: metadata = {"ref_token": attr_name} @@ -4312,6 +4441,15 @@ def scala_base_name(type_node) -> str | None: metadata["ref_qualifier"] = qualifier add_edge(func_nid, target_nid, "references", line, context="attribute", metadata=metadata) + # The endpoint's URL as its own node: `serve.py` indexes a node's + # label (never its metadata), so a route is only reachable by a + # `graphify query "api/..."` when the label IS the route. + route_label = _csharp_route_label(node, source) + if route_label: + route_nid = _make_id(stem, "route", route_label) + add_node(route_nid, route_label, line, node_type="route") + add_edge(func_nid, route_nid, "references", line, + context="route", metadata={"route": route_label}) if config.ts_module == "tree_sitter_java": params_node = node.child_by_field_name("parameters") diff --git a/tests/test_csharp_routes.py b/tests/test_csharp_routes.py new file mode 100644 index 000000000..c0ec32d3b --- /dev/null +++ b/tests/test_csharp_routes.py @@ -0,0 +1,209 @@ +"""ASP.NET routing attributes become queryable route nodes. + +The C# extractor records that a method carries `[HttpGet]` — as a +`references[attribute]` edge to the attribute's type — but discards the +attribute's argument, so the route template itself never reaches the graph. +These tests pin the behaviour that makes a route findable: the template is +captured, the controller-level `[Route]` prefix composes with the method-level +one, and the result is a node whose *label* is the route (the only field +`serve.py` indexes for search). +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + + +def _write(path: Path, text: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _routes(result: dict) -> dict[str, dict]: + """Route nodes by label — the ones a method points at with context='route'.""" + by_id = {n["id"]: n for n in result["nodes"]} + out = {} + for e in result["edges"]: + if e.get("relation") == "references" and e.get("context") == "route": + node = by_id.get(e.get("target")) + if node is not None: + out[node["label"]] = node + return out + + +def _route_source(result: dict, label: str) -> str | None: + """Label of the method that serves ``label``.""" + by_id = {n["id"]: n for n in result["nodes"]} + for e in result["edges"]: + if e.get("relation") == "references" and e.get("context") == "route": + if by_id.get(e.get("target"), {}).get("label") == label: + return by_id.get(e.get("source"), {}).get("label") + return None + + +def test_method_route_template_becomes_a_node(tmp_path: Path): + """`[HttpGet("Status")]` on a method yields a route node labelled with the verb + and the template — today the template is dropped entirely.""" + f = _write( + tmp_path / "c.cs", + "namespace N {\n" + " public class OrdersController {\n" + ' [HttpGet("Status")]\n' + " public int GetStatus() { return 1; }\n" + " }\n" + "}\n", + ) + result = extract([f], cache_root=tmp_path) + assert "GET Status" in _routes(result), ( + f"no route node; got {sorted(_routes(result))}" + ) + + +def test_controller_route_prefix_composes_with_the_method_template(tmp_path: Path): + """A class-level `[Route]` is the endpoint's prefix. Class attributes are not + collected at all today, so the prefix is missing even in principle.""" + f = _write( + tmp_path / "c.cs", + "namespace N {\n" + ' [Route("api/Orders")]\n' + " public class OrdersController {\n" + ' [HttpPost("Add")]\n' + " public int Add() { return 1; }\n" + " }\n" + "}\n", + ) + result = extract([f], cache_root=tmp_path) + assert "POST api/Orders/Add" in _routes(result), ( + f"prefix not composed; got {sorted(_routes(result))}" + ) + + +def test_route_node_points_back_at_its_handler(tmp_path: Path): + """The point of the node: from the route you reach the controller method.""" + f = _write( + tmp_path / "c.cs", + "namespace N {\n" + ' [Route("api/Orders")]\n' + " public class OrdersController {\n" + ' [HttpGet("Items/{orderId}")]\n' + " public int GetItem(string orderId) { return 1; }\n" + " }\n" + "}\n", + ) + result = extract([f], cache_root=tmp_path) + label = "GET api/Orders/Items/{orderId}" + assert _route_source(result, label) is not None, ( + f"route node has no handler; got {sorted(_routes(result))}" + ) + assert "GetItem" in str(_route_source(result, label)) + + +def test_verb_attribute_and_route_attribute_on_the_same_method(tmp_path: Path): + """The dominant style in large ASP.NET codebases: a bare `[HttpGet]` for the + verb and a separate `[Route]` for the path.""" + f = _write( + tmp_path / "c.cs", + "namespace N {\n" + ' [Route("account")]\n' + " public class AccountController {\n" + " [HttpGet]\n" + ' [Route("login")]\n' + " public int Login() { return 1; }\n" + " }\n" + "}\n", + ) + result = extract([f], cache_root=tmp_path) + assert "GET account/login" in _routes(result), ( + f"verb and path came from different attributes; got {sorted(_routes(result))}" + ) + + +def test_absolute_method_template_ignores_the_controller_prefix(tmp_path: Path): + """ASP.NET rule: a template starting with '/' or '~/' is absolute.""" + f = _write( + tmp_path / "c.cs", + "namespace N {\n" + ' [Route("api/Orders")]\n' + " public class OrdersController {\n" + ' [HttpGet("/health")]\n' + " public int Health() { return 1; }\n" + " }\n" + "}\n", + ) + result = extract([f], cache_root=tmp_path) + assert "GET /health" in _routes(result), ( + f"absolute template was prefixed; got {sorted(_routes(result))}" + ) + + +def test_controller_token_expands_to_the_controller_name(tmp_path: Path): + """`[controller]` is the conventional token for the class name minus the + 'Controller' suffix.""" + f = _write( + tmp_path / "c.cs", + "namespace N {\n" + ' [Route("api/[controller]")]\n' + " public class ProductsController {\n" + " [HttpGet]\n" + " public int GetAll() { return 1; }\n" + " }\n" + "}\n", + ) + result = extract([f], cache_root=tmp_path) + assert "GET api/Products" in _routes(result), ( + f"[controller] token not expanded; got {sorted(_routes(result))}" + ) + + +def test_route_node_is_anchored_to_the_controller_file(tmp_path: Path): + """A route node must carry a real source_file: it is a code artifact, not a + sourceless stub, and `serve.py` indexes source_file alongside the label.""" + f = _write( + tmp_path / "c.cs", + "namespace N {\n" + " public class OrdersController {\n" + ' [HttpGet("Status")]\n' + " public int GetStatus() { return 1; }\n" + " }\n" + "}\n", + ) + result = extract([f], cache_root=tmp_path) + node = _routes(result).get("GET Status") + assert node is not None + assert node.get("source_file", "").endswith("c.cs") + assert node.get("file_type") == "code" + + +def test_route_without_a_verb_attribute_matches_every_verb(tmp_path: Path): + """A bare `[Route]` with no `Http*` sibling is verb-agnostic in ASP.NET; the + label says so with `*` rather than guessing a verb.""" + f = _write( + tmp_path / "c.cs", + "namespace N {\n" + " public class ReportsController {\n" + ' [Route("api/reports")]\n' + " public int Summary() { return 1; }\n" + " }\n" + "}\n", + ) + result = extract([f], cache_root=tmp_path) + assert "* api/reports" in _routes(result), ( + f"verb-less route not labelled '*'; got {sorted(_routes(result))}" + ) + + +def test_a_method_without_routing_attributes_mints_no_route_node(tmp_path: Path): + """Only routing attributes produce route nodes — `[Obsolete("...")]` must not.""" + f = _write( + tmp_path / "c.cs", + "namespace N {\n" + " public class Plain {\n" + ' [Obsolete("gone")]\n' + " public int Old() { return 1; }\n" + " }\n" + "}\n", + ) + result = extract([f], cache_root=tmp_path) + assert _routes(result) == {}