Skip to content

Commit 7b9b459

Browse files
The1nkclaude
andcommitted
feat(extract): emit Route nodes for Blazor @page directives
get_architecture's routes aspect returned nothing for a Blazor application: the entry surface of the app was invisible in the graph even though every @page directive names one. @page lives in markup above the @code block, which tree-sitter's C# grammar never parses, so there is no AST node to read it from. Scan the raw source for it instead. The match is deliberately strict — the directive must be the first token on its line, followed by whitespace and a double-quoted path beginning with '/' — so @pageSize and prose mentions cannot match. The route is attached to the file's module def. A .razor component's class is implicit, so there is no class node to carry it, and the module QN already is the component's identity. insert_def_into_gbuf is label-agnostic and creates Route + HANDLES from route_path. pass_route_nodes.c's ensure_decorator_routes gains "Module" for the same reason. Extraction covers the full-index path on its own; this backstop is what runs on an incremental re-index, so without it a component's Route would appear on a full index and vanish the next time that one file changed. Its loop bound now derives from the labels array rather than borrowing the unrelated RN_STRIP_PASSES, so adding a label cannot silently skip it. Verified no double-creation when both paths can fire. Measured on a real Blazor application (36 .razor files): routes went from 0 to 11, with 11 matching HANDLES edges, and no change to the 363 Class / 1775 Method / 3805 CALLS already extracted from its C#. Covered by two new tests in tests/test_extraction.c. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: The1nk <23087600+The1nk@users.noreply.github.com>
1 parent 1b78a68 commit 7b9b459

3 files changed

Lines changed: 176 additions & 2 deletions

File tree

‎internal/cbm/extract_defs.c‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7524,6 +7524,89 @@ static void walk_defs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec,
75247524
free(s.data);
75257525
}
75267526

7527+
/* True when rel_path names a Blazor component file. */
7528+
static bool cbm_path_is_razor(const char *rel_path) {
7529+
if (!rel_path) {
7530+
return false;
7531+
}
7532+
size_t len = strlen(rel_path);
7533+
static const char suffix[] = ".razor";
7534+
size_t slen = sizeof(suffix) - 1U;
7535+
return len > slen && strcmp(rel_path + (len - slen), suffix) == 0;
7536+
}
7537+
7538+
/* Match `@page "/route"` on ONE line; returns the route text or NULL.
7539+
*
7540+
* Deliberately strict: the directive must be the first token on the line and be
7541+
* followed by whitespace and a double-quoted path beginning with '/', so
7542+
* neither `@pageSize` nor a `@page` mentioned in markup prose can match.
7543+
*
7544+
* A blank line is rejected up front rather than falling through the length
7545+
* check, which keeps every later comparison reachable on some path — the
7546+
* all-whitespace case would otherwise leave `line_end - p` provably zero. */
7547+
static const char *razor_page_route_on_line(CBMArena *a, const char *line, const char *line_end) {
7548+
static const char directive[] = "@page";
7549+
const size_t dlen = sizeof(directive) - 1U;
7550+
7551+
const char *p = line;
7552+
while (p < line_end && (*p == ' ' || *p == '\t')) {
7553+
p++;
7554+
}
7555+
if (p == line_end) {
7556+
return NULL; /* blank line — nothing can follow */
7557+
}
7558+
if ((size_t)(line_end - p) <= dlen || strncmp(p, directive, dlen) != 0) {
7559+
return NULL;
7560+
}
7561+
p += dlen;
7562+
if (*p != ' ' && *p != '\t') {
7563+
return NULL; /* `@pageSize` and friends */
7564+
}
7565+
while (p < line_end && (*p == ' ' || *p == '\t')) {
7566+
p++;
7567+
}
7568+
if (p == line_end || *p != '"') {
7569+
return NULL;
7570+
}
7571+
p++;
7572+
const char *route = p;
7573+
while (p < line_end && *p != '"') {
7574+
p++;
7575+
}
7576+
if (p == line_end || p == route || *route != '/') {
7577+
return NULL; /* unterminated, empty, or not a rooted path */
7578+
}
7579+
return cbm_arena_strndup(a, route, (size_t)(p - route));
7580+
}
7581+
7582+
/* Blazor route directive: `@page "/counter"` lives in MARKUP above the `@code`
7583+
* block. Tree-sitter's C# grammar recovers `@code` but never parses the
7584+
* directive, so there is no AST node to read it from — this scans the raw
7585+
* source instead. That is why routes need no Razor grammar.
7586+
*
7587+
* A component may declare several routes; the first is taken, because
7588+
* CBMDefinition carries a single route_path. */
7589+
static const char *cbm_razor_page_route(CBMArena *a, const char *source, int source_len) {
7590+
if (!source || source_len <= 0) {
7591+
return NULL;
7592+
}
7593+
const char *end = source + source_len;
7594+
const char *line = source;
7595+
7596+
while (line < end) {
7597+
const char *nl = memchr(line, '\n', (size_t)(end - line));
7598+
const char *route = razor_page_route_on_line(a, line, nl ? nl : end);
7599+
if (route) {
7600+
return route;
7601+
}
7602+
if (!nl) {
7603+
break;
7604+
}
7605+
line = nl + 1;
7606+
}
7607+
return NULL;
7608+
}
7609+
75277610
void cbm_extract_definitions(CBMExtractCtx *ctx) {
75287611
const CBMLangSpec *spec = cbm_lang_spec(ctx->language);
75297612
if (!spec) {
@@ -7543,6 +7626,17 @@ void cbm_extract_definitions(CBMExtractCtx *ctx) {
75437626
mod.end_line = ts_node_end_point(ctx->root).row + TS_LINE_OFFSET;
75447627
mod.is_exported = true;
75457628
mod.is_test = ctx->result->is_test_file;
7629+
/* A routable Blazor component carries its route on the module def: the
7630+
* component's class is implicit in a .razor file, so there is no class node
7631+
* to hang it on, and the module QN already is the component's identity.
7632+
* insert_def_into_gbuf creates Route+HANDLES for any def with route_path. */
7633+
if (ctx->language == CBM_LANG_CSHARP && cbm_path_is_razor(ctx->rel_path)) {
7634+
const char *route = cbm_razor_page_route(a, ctx->source, ctx->source_len);
7635+
if (route) {
7636+
mod.route_path = route;
7637+
mod.route_method = "GET"; /* a routable page is reached by navigation */
7638+
}
7639+
}
75467640
cbm_defs_push(&ctx->result->defs, a, mod);
75477641

75487642
// Walk AST for function/class definitions

‎src/pipeline/pass_route_nodes.c‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -481,10 +481,19 @@ static int ensure_one_decorator_route(cbm_gbuf_t *gb, const cbm_gbuf_node_t *fun
481481

482482
/* Phase 2a: Ensure all functions with route_path properties have Route+HANDLES edges. */
483483
static void ensure_decorator_routes(cbm_gbuf_t *gb) {
484-
const char *labels[] = {"Function", "Method"};
484+
/* "Module" is here for Blazor: a .razor component's class is implicit, so
485+
* its @page route is carried by the file's Module def. Extraction's own
486+
* insert_def_into_gbuf is label-agnostic and creates the Route either way —
487+
* this backstop is what runs on an INCREMENTAL re-index, so leaving Module
488+
* out would make a component's Route appear on a full index and vanish the
489+
* next time that one file changed.
490+
* Bound comes from the array, not the unrelated RN_STRIP_PASSES it used to
491+
* borrow, so adding a label cannot silently skip it. */
492+
const char *labels[] = {"Function", "Method", "Module"};
493+
const int label_count = (int)(sizeof(labels) / sizeof(labels[0]));
485494
int created = 0;
486495

487-
for (int li = 0; li < RN_STRIP_PASSES; li++) {
496+
for (int li = 0; li < label_count; li++) {
488497
const cbm_gbuf_node_t **nodes = NULL;
489498
int count = 0;
490499
if (cbm_gbuf_find_by_label(gb, labels[li], &nodes, &count) != 0) {

‎tests/test_extraction.c‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3336,6 +3336,75 @@ TEST(extract_java_jaxrs_path_composition_issue1005) {
33363336
PASS();
33373337
}
33383338

3339+
/* Return the file's Module definition (extraction pushes it first), or NULL. */
3340+
static const CBMDefinition *find_module_def(CBMFileResult *r) {
3341+
for (int i = 0; i < r->defs.count; i++) {
3342+
if (r->defs.items[i].label && strcmp(r->defs.items[i].label, "Module") == 0) {
3343+
return &r->defs.items[i];
3344+
}
3345+
}
3346+
return NULL;
3347+
}
3348+
3349+
/* Blazor: a routable component declares its route with a `@page` directive in
3350+
* MARKUP, above the `@code` block. The C# grammar recovers `@code` (that is why
3351+
* .razor already yields methods via extra_extensions) but never sees the
3352+
* directive, so a routable page contributes no Route node and
3353+
* get_architecture(routes) is empty for a whole Blazor app.
3354+
*
3355+
* The route hangs off the file's Module definition, not off a class: a .razor
3356+
* component's class is implicit — it is never written in the source — so there
3357+
* is no class node to carry it. The Module's qualified name already IS the
3358+
* component's identity (t.Pages.Counter), and insert_def_into_gbuf creates
3359+
* Route+HANDLES for any definition carrying route_path, whatever its label. */
3360+
TEST(extract_blazor_page_directive_routes_component) {
3361+
CBMFileResult *r = extract("@page \"/counter\"\n"
3362+
"@inject NavigationManager Nav\n"
3363+
"\n"
3364+
"<h1>Counter</h1>\n"
3365+
"<button @onclick=\"Increment\">Click</button>\n"
3366+
"\n"
3367+
"@code {\n"
3368+
" private int count;\n"
3369+
" private void Increment() { count++; }\n"
3370+
"}\n",
3371+
CBM_LANG_CSHARP, "t", "Pages/Counter.razor");
3372+
ASSERT_NOT_NULL(r);
3373+
ASSERT_FALSE(r->has_error);
3374+
/* The markup must not cost us the @code block we already extract today. */
3375+
ASSERT_NOT_NULL(find_def_by_name(r, "Increment"));
3376+
const CBMDefinition *mod = find_module_def(r);
3377+
ASSERT_NOT_NULL(mod);
3378+
ASSERT_NOT_NULL(mod->route_path);
3379+
ASSERT_STR_EQ(mod->route_path, "/counter");
3380+
/* A routable Blazor page is reached by navigation, i.e. GET. */
3381+
ASSERT_NOT_NULL(mod->route_method);
3382+
ASSERT_STR_EQ(mod->route_method, "GET");
3383+
cbm_free_result(r);
3384+
PASS();
3385+
}
3386+
3387+
/* The directive scan must not fire on every .razor file. A non-routable
3388+
* component (no @page) has to stay route-free, or every shared component in the
3389+
* tree becomes a bogus Route node. */
3390+
TEST(extract_blazor_component_without_page_has_no_route) {
3391+
CBMFileResult *r = extract("@inject IJSRuntime JS\n"
3392+
"\n"
3393+
"<div class=\"card\">@Title</div>\n"
3394+
"\n"
3395+
"@code {\n"
3396+
" private void Refresh() { }\n"
3397+
"}\n",
3398+
CBM_LANG_CSHARP, "t", "Shared/Card.razor");
3399+
ASSERT_NOT_NULL(r);
3400+
ASSERT_FALSE(r->has_error);
3401+
const CBMDefinition *mod = find_module_def(r);
3402+
ASSERT_NOT_NULL(mod);
3403+
ASSERT_NULL(mod->route_path);
3404+
cbm_free_result(r);
3405+
PASS();
3406+
}
3407+
33393408
/* A comment between decorators must not drop the decorators above it.
33403409
* Comments are NAMED tree-sitter nodes, so the prev-sibling walk used to stop
33413410
* at one — a documented route (@Post + @HttpCode above an explanatory comment)
@@ -5783,6 +5852,8 @@ SUITE(extraction) {
57835852
RUN_TEST(python_regular_module_qn_unchanged);
57845853
RUN_TEST(extract_java_method_annotations_issue382);
57855854
RUN_TEST(extract_java_jaxrs_path_composition_issue1005);
5855+
RUN_TEST(extract_blazor_page_directive_routes_component);
5856+
RUN_TEST(extract_blazor_component_without_page_has_no_route);
57865857
RUN_TEST(extract_ts_template_string_url_issue1006);
57875858
RUN_TEST(extract_go_binary_concat_url_issue1249);
57885859
RUN_TEST(extract_go_binary_concat_url_no_literal_suffix_issue1249);

0 commit comments

Comments
 (0)