Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 143 additions & 5 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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 ``"<VERB> <path>"`` 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",
Expand Down Expand Up @@ -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}
Expand All @@ -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")
Expand Down
209 changes: 209 additions & 0 deletions tests/test_csharp_routes.py
Original file line number Diff line number Diff line change
@@ -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) == {}
Loading