diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 4994bd7b8..5ccc1f47a 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -7524,6 +7524,89 @@ static void walk_defs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec, free(s.data); } +/* True when rel_path names a Blazor component file. */ +static bool cbm_path_is_razor(const char *rel_path) { + if (!rel_path) { + return false; + } + size_t len = strlen(rel_path); + static const char suffix[] = ".razor"; + size_t slen = sizeof(suffix) - 1U; + return len > slen && strcmp(rel_path + (len - slen), suffix) == 0; +} + +/* Match `@page "/route"` on ONE line; returns the route text or NULL. + * + * Deliberately strict: the directive must be the first token on the line and be + * followed by whitespace and a double-quoted path beginning with '/', so + * neither `@pageSize` nor a `@page` mentioned in markup prose can match. + * + * A blank line is rejected up front rather than falling through the length + * check, which keeps every later comparison reachable on some path — the + * all-whitespace case would otherwise leave `line_end - p` provably zero. */ +static const char *razor_page_route_on_line(CBMArena *a, const char *line, const char *line_end) { + static const char directive[] = "@page"; + const size_t dlen = sizeof(directive) - 1U; + + const char *p = line; + while (p < line_end && (*p == ' ' || *p == '\t')) { + p++; + } + if (p == line_end) { + return NULL; /* blank line — nothing can follow */ + } + if ((size_t)(line_end - p) <= dlen || strncmp(p, directive, dlen) != 0) { + return NULL; + } + p += dlen; + if (*p != ' ' && *p != '\t') { + return NULL; /* `@pageSize` and friends */ + } + while (p < line_end && (*p == ' ' || *p == '\t')) { + p++; + } + if (p == line_end || *p != '"') { + return NULL; + } + p++; + const char *route = p; + while (p < line_end && *p != '"') { + p++; + } + if (p == line_end || p == route || *route != '/') { + return NULL; /* unterminated, empty, or not a rooted path */ + } + return cbm_arena_strndup(a, route, (size_t)(p - route)); +} + +/* Blazor route directive: `@page "/counter"` lives in MARKUP above the `@code` + * block. Tree-sitter's C# grammar recovers `@code` but never parses the + * directive, so there is no AST node to read it from — this scans the raw + * source instead. That is why routes need no Razor grammar. + * + * A component may declare several routes; the first is taken, because + * CBMDefinition carries a single route_path. */ +static const char *cbm_razor_page_route(CBMArena *a, const char *source, int source_len) { + if (!source || source_len <= 0) { + return NULL; + } + const char *end = source + source_len; + const char *line = source; + + while (line < end) { + const char *nl = memchr(line, '\n', (size_t)(end - line)); + const char *route = razor_page_route_on_line(a, line, nl ? nl : end); + if (route) { + return route; + } + if (!nl) { + break; + } + line = nl + 1; + } + return NULL; +} + void cbm_extract_definitions(CBMExtractCtx *ctx) { const CBMLangSpec *spec = cbm_lang_spec(ctx->language); if (!spec) { @@ -7543,6 +7626,17 @@ void cbm_extract_definitions(CBMExtractCtx *ctx) { mod.end_line = ts_node_end_point(ctx->root).row + TS_LINE_OFFSET; mod.is_exported = true; mod.is_test = ctx->result->is_test_file; + /* A routable Blazor component carries its route on the module def: the + * component's class is implicit in a .razor file, so there is no class node + * to hang it on, and the module QN already is the component's identity. + * insert_def_into_gbuf creates Route+HANDLES for any def with route_path. */ + if (ctx->language == CBM_LANG_CSHARP && cbm_path_is_razor(ctx->rel_path)) { + const char *route = cbm_razor_page_route(a, ctx->source, ctx->source_len); + if (route) { + mod.route_path = route; + mod.route_method = "GET"; /* a routable page is reached by navigation */ + } + } cbm_defs_push(&ctx->result->defs, a, mod); // Walk AST for function/class definitions diff --git a/src/discover/language.c b/src/discover/language.c index 9854aa80c..0dc148bc4 100644 --- a/src/discover/language.c +++ b/src/discover/language.c @@ -49,6 +49,11 @@ static const ext_entry_t EXT_TABLE[] = { /* C# */ {".cs", CBM_LANG_CSHARP}, + /* Blazor components. The C# grammar recovers the @code block; the + * surrounding markup parses as ERROR regions and is reported via + * parse_partial, which is why this is a best-effort mapping rather + * than a dedicated grammar. */ + {".razor", CBM_LANG_CSHARP}, /* Clojure */ {".clj", CBM_LANG_CLOJURE}, diff --git a/src/pipeline/pass_route_nodes.c b/src/pipeline/pass_route_nodes.c index 664c8252c..91a9f4122 100644 --- a/src/pipeline/pass_route_nodes.c +++ b/src/pipeline/pass_route_nodes.c @@ -481,10 +481,19 @@ static int ensure_one_decorator_route(cbm_gbuf_t *gb, const cbm_gbuf_node_t *fun /* Phase 2a: Ensure all functions with route_path properties have Route+HANDLES edges. */ static void ensure_decorator_routes(cbm_gbuf_t *gb) { - const char *labels[] = {"Function", "Method"}; + /* "Module" is here for Blazor: a .razor component's class is implicit, so + * its @page route is carried by the file's Module def. Extraction's own + * insert_def_into_gbuf is label-agnostic and creates the Route either way — + * this backstop is what runs on an INCREMENTAL re-index, so leaving Module + * out would make a component's Route appear on a full index and vanish the + * next time that one file changed. + * Bound comes from the array, not the unrelated RN_STRIP_PASSES it used to + * borrow, so adding a label cannot silently skip it. */ + const char *labels[] = {"Function", "Method", "Module"}; + const int label_count = (int)(sizeof(labels) / sizeof(labels[0])); int created = 0; - for (int li = 0; li < RN_STRIP_PASSES; li++) { + for (int li = 0; li < label_count; li++) { const cbm_gbuf_node_t **nodes = NULL; int count = 0; if (cbm_gbuf_find_by_label(gb, labels[li], &nodes, &count) != 0) { diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 88796c70d..a9d303447 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -3336,6 +3336,75 @@ TEST(extract_java_jaxrs_path_composition_issue1005) { PASS(); } +/* Return the file's Module definition (extraction pushes it first), or NULL. */ +static const CBMDefinition *find_module_def(CBMFileResult *r) { + for (int i = 0; i < r->defs.count; i++) { + if (r->defs.items[i].label && strcmp(r->defs.items[i].label, "Module") == 0) { + return &r->defs.items[i]; + } + } + return NULL; +} + +/* Blazor: a routable component declares its route with a `@page` directive in + * MARKUP, above the `@code` block. The C# grammar recovers `@code` (that is why + * .razor already yields methods via extra_extensions) but never sees the + * directive, so a routable page contributes no Route node and + * get_architecture(routes) is empty for a whole Blazor app. + * + * The route hangs off the file's Module definition, not off a class: a .razor + * component's class is implicit — it is never written in the source — so there + * is no class node to carry it. The Module's qualified name already IS the + * component's identity (t.Pages.Counter), and insert_def_into_gbuf creates + * Route+HANDLES for any definition carrying route_path, whatever its label. */ +TEST(extract_blazor_page_directive_routes_component) { + CBMFileResult *r = extract("@page \"/counter\"\n" + "@inject NavigationManager Nav\n" + "\n" + "

Counter

\n" + "\n" + "\n" + "@code {\n" + " private int count;\n" + " private void Increment() { count++; }\n" + "}\n", + CBM_LANG_CSHARP, "t", "Pages/Counter.razor"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + /* The markup must not cost us the @code block we already extract today. */ + ASSERT_NOT_NULL(find_def_by_name(r, "Increment")); + const CBMDefinition *mod = find_module_def(r); + ASSERT_NOT_NULL(mod); + ASSERT_NOT_NULL(mod->route_path); + ASSERT_STR_EQ(mod->route_path, "/counter"); + /* A routable Blazor page is reached by navigation, i.e. GET. */ + ASSERT_NOT_NULL(mod->route_method); + ASSERT_STR_EQ(mod->route_method, "GET"); + cbm_free_result(r); + PASS(); +} + +/* The directive scan must not fire on every .razor file. A non-routable + * component (no @page) has to stay route-free, or every shared component in the + * tree becomes a bogus Route node. */ +TEST(extract_blazor_component_without_page_has_no_route) { + CBMFileResult *r = extract("@inject IJSRuntime JS\n" + "\n" + "
@Title
\n" + "\n" + "@code {\n" + " private void Refresh() { }\n" + "}\n", + CBM_LANG_CSHARP, "t", "Shared/Card.razor"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMDefinition *mod = find_module_def(r); + ASSERT_NOT_NULL(mod); + ASSERT_NULL(mod->route_path); + cbm_free_result(r); + PASS(); +} + /* A comment between decorators must not drop the decorators above it. * Comments are NAMED tree-sitter nodes, so the prev-sibling walk used to stop * at one — a documented route (@Post + @HttpCode above an explanatory comment) @@ -5783,6 +5852,8 @@ SUITE(extraction) { RUN_TEST(python_regular_module_qn_unchanged); RUN_TEST(extract_java_method_annotations_issue382); RUN_TEST(extract_java_jaxrs_path_composition_issue1005); + RUN_TEST(extract_blazor_page_directive_routes_component); + RUN_TEST(extract_blazor_component_without_page_has_no_route); RUN_TEST(extract_ts_template_string_url_issue1006); RUN_TEST(extract_go_binary_concat_url_issue1249); RUN_TEST(extract_go_binary_concat_url_no_literal_suffix_issue1249); diff --git a/tests/test_language.c b/tests/test_language.c index e84bbc1b8..f81468407 100644 --- a/tests/test_language.c +++ b/tests/test_language.c @@ -89,6 +89,14 @@ TEST(lang_ext_csharp) { ASSERT_EQ(cbm_language_for_extension(".cs"), CBM_LANG_CSHARP); PASS(); } +/* Blazor components were unmapped, so a .razor file was never discovered at + * all: indexing a Blazor app produced no nodes for any component, and reaching + * them required an undocumented extra_extensions entry in a per-project + * .codebase-memory.json. */ +TEST(lang_ext_razor) { + ASSERT_EQ(cbm_language_for_extension(".razor"), CBM_LANG_CSHARP); + PASS(); +} TEST(lang_ext_php) { ASSERT_EQ(cbm_language_for_extension(".php"), CBM_LANG_PHP); PASS(); @@ -1079,6 +1087,7 @@ SUITE(language) { RUN_TEST(lang_ext_h); RUN_TEST(lang_ext_ixx); RUN_TEST(lang_ext_csharp); + RUN_TEST(lang_ext_razor); RUN_TEST(lang_ext_php); RUN_TEST(lang_ext_lua); RUN_TEST(lang_ext_scala);