Skip to content

Commit fd73c34

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 44caa4c commit fd73c34

3 files changed

Lines changed: 176 additions & 12 deletions

File tree

‎internal/cbm/extract_defs.c‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7902,6 +7902,89 @@ void cbm_extract_definitions_without_module(CBMExtractCtx *ctx) {
79027902
extract_variables(ctx, ctx->root, spec);
79037903
}
79047904

7905+
/* True when rel_path names a Blazor component file. */
7906+
static bool cbm_path_is_razor(const char *rel_path) {
7907+
if (!rel_path) {
7908+
return false;
7909+
}
7910+
size_t len = strlen(rel_path);
7911+
static const char suffix[] = ".razor";
7912+
size_t slen = sizeof(suffix) - 1U;
7913+
return len > slen && strcmp(rel_path + (len - slen), suffix) == 0;
7914+
}
7915+
7916+
/* Match `@page "/route"` on ONE line; returns the route text or NULL.
7917+
*
7918+
* Deliberately strict: the directive must be the first token on the line and be
7919+
* followed by whitespace and a double-quoted path beginning with '/', so
7920+
* neither `@pageSize` nor a `@page` mentioned in markup prose can match.
7921+
*
7922+
* A blank line is rejected up front rather than falling through the length
7923+
* check, which keeps every later comparison reachable on some path — the
7924+
* all-whitespace case would otherwise leave `line_end - p` provably zero. */
7925+
static const char *razor_page_route_on_line(CBMArena *a, const char *line, const char *line_end) {
7926+
static const char directive[] = "@page";
7927+
const size_t dlen = sizeof(directive) - 1U;
7928+
7929+
const char *p = line;
7930+
while (p < line_end && (*p == ' ' || *p == '\t')) {
7931+
p++;
7932+
}
7933+
if (p == line_end) {
7934+
return NULL; /* blank line — nothing can follow */
7935+
}
7936+
if ((size_t)(line_end - p) <= dlen || strncmp(p, directive, dlen) != 0) {
7937+
return NULL;
7938+
}
7939+
p += dlen;
7940+
if (*p != ' ' && *p != '\t') {
7941+
return NULL; /* `@pageSize` and friends */
7942+
}
7943+
while (p < line_end && (*p == ' ' || *p == '\t')) {
7944+
p++;
7945+
}
7946+
if (p == line_end || *p != '"') {
7947+
return NULL;
7948+
}
7949+
p++;
7950+
const char *route = p;
7951+
while (p < line_end && *p != '"') {
7952+
p++;
7953+
}
7954+
if (p == line_end || p == route || *route != '/') {
7955+
return NULL; /* unterminated, empty, or not a rooted path */
7956+
}
7957+
return cbm_arena_strndup(a, route, (size_t)(p - route));
7958+
}
7959+
7960+
/* Blazor route directive: `@page "/counter"` lives in MARKUP above the `@code`
7961+
* block. Tree-sitter's C# grammar recovers `@code` but never parses the
7962+
* directive, so there is no AST node to read it from — this scans the raw
7963+
* source instead. That is why routes need no Razor grammar.
7964+
*
7965+
* A component may declare several routes; the first is taken, because
7966+
* CBMDefinition carries a single route_path. */
7967+
static const char *cbm_razor_page_route(CBMArena *a, const char *source, int source_len) {
7968+
if (!source || source_len <= 0) {
7969+
return NULL;
7970+
}
7971+
const char *end = source + source_len;
7972+
const char *line = source;
7973+
7974+
while (line < end) {
7975+
const char *nl = memchr(line, '\n', (size_t)(end - line));
7976+
const char *route = razor_page_route_on_line(a, line, nl ? nl : end);
7977+
if (route) {
7978+
return route;
7979+
}
7980+
if (!nl) {
7981+
break;
7982+
}
7983+
line = nl + 1;
7984+
}
7985+
return NULL;
7986+
}
7987+
79057988
void cbm_extract_definitions(CBMExtractCtx *ctx) {
79067989
const CBMLangSpec *spec = cbm_lang_spec(ctx->language);
79077990
if (!spec) {
@@ -7923,6 +8006,17 @@ void cbm_extract_definitions(CBMExtractCtx *ctx) {
79238006
mod.is_test = ctx->result->is_test_file;
79248007
// #519: index what a config file declares itself to be, not only its path.
79258008
mod.docstring = extract_config_module_description(ctx);
8009+
/* A routable Blazor component carries its route on the module def: the
8010+
* component's class is implicit in a .razor file, so there is no class node
8011+
* to hang it on, and the module QN already is the component's identity.
8012+
* insert_def_into_gbuf creates Route+HANDLES for any def with route_path. */
8013+
if (ctx->language == CBM_LANG_CSHARP && cbm_path_is_razor(ctx->rel_path)) {
8014+
const char *route = cbm_razor_page_route(a, ctx->source, ctx->source_len);
8015+
if (route) {
8016+
mod.route_path = route;
8017+
mod.route_method = "GET"; /* a routable page is reached by navigation */
8018+
}
8019+
}
79268020
cbm_defs_push(&ctx->result->defs, a, mod);
79278021

79288022
cbm_extract_definitions_without_module(ctx);

‎src/pipeline/pass_route_nodes.c‎

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

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

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

‎tests/test_extraction.c‎

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3906,6 +3906,75 @@ TEST(extract_java_jaxrs_path_composition_issue1005) {
39063906
PASS();
39073907
}
39083908

3909+
/* Return the file's Module definition (extraction pushes it first), or NULL. */
3910+
static const CBMDefinition *find_module_def(CBMFileResult *r) {
3911+
for (int i = 0; i < r->defs.count; i++) {
3912+
if (r->defs.items[i].label && strcmp(r->defs.items[i].label, "Module") == 0) {
3913+
return &r->defs.items[i];
3914+
}
3915+
}
3916+
return NULL;
3917+
}
3918+
3919+
/* Blazor: a routable component declares its route with a `@page` directive in
3920+
* MARKUP, above the `@code` block. The C# grammar recovers `@code` (that is why
3921+
* .razor already yields methods via extra_extensions) but never sees the
3922+
* directive, so a routable page contributes no Route node and
3923+
* get_architecture(routes) is empty for a whole Blazor app.
3924+
*
3925+
* The route hangs off the file's Module definition, not off a class: a .razor
3926+
* component's class is implicit — it is never written in the source — so there
3927+
* is no class node to carry it. The Module's qualified name already IS the
3928+
* component's identity (t.Pages.Counter), and insert_def_into_gbuf creates
3929+
* Route+HANDLES for any definition carrying route_path, whatever its label. */
3930+
TEST(extract_blazor_page_directive_routes_component) {
3931+
CBMFileResult *r = extract("@page \"/counter\"\n"
3932+
"@inject NavigationManager Nav\n"
3933+
"\n"
3934+
"<h1>Counter</h1>\n"
3935+
"<button @onclick=\"Increment\">Click</button>\n"
3936+
"\n"
3937+
"@code {\n"
3938+
" private int count;\n"
3939+
" private void Increment() { count++; }\n"
3940+
"}\n",
3941+
CBM_LANG_CSHARP, "t", "Pages/Counter.razor");
3942+
ASSERT_NOT_NULL(r);
3943+
ASSERT_FALSE(r->has_error);
3944+
/* The markup must not cost us the @code block we already extract today. */
3945+
ASSERT_NOT_NULL(find_def_by_name(r, "Increment"));
3946+
const CBMDefinition *mod = find_module_def(r);
3947+
ASSERT_NOT_NULL(mod);
3948+
ASSERT_NOT_NULL(mod->route_path);
3949+
ASSERT_STR_EQ(mod->route_path, "/counter");
3950+
/* A routable Blazor page is reached by navigation, i.e. GET. */
3951+
ASSERT_NOT_NULL(mod->route_method);
3952+
ASSERT_STR_EQ(mod->route_method, "GET");
3953+
cbm_free_result(r);
3954+
PASS();
3955+
}
3956+
3957+
/* The directive scan must not fire on every .razor file. A non-routable
3958+
* component (no @page) has to stay route-free, or every shared component in the
3959+
* tree becomes a bogus Route node. */
3960+
TEST(extract_blazor_component_without_page_has_no_route) {
3961+
CBMFileResult *r = extract("@inject IJSRuntime JS\n"
3962+
"\n"
3963+
"<div class=\"card\">@Title</div>\n"
3964+
"\n"
3965+
"@code {\n"
3966+
" private void Refresh() { }\n"
3967+
"}\n",
3968+
CBM_LANG_CSHARP, "t", "Shared/Card.razor");
3969+
ASSERT_NOT_NULL(r);
3970+
ASSERT_FALSE(r->has_error);
3971+
const CBMDefinition *mod = find_module_def(r);
3972+
ASSERT_NOT_NULL(mod);
3973+
ASSERT_NULL(mod->route_path);
3974+
cbm_free_result(r);
3975+
PASS();
3976+
}
3977+
39093978
/* A comment between decorators must not drop the decorators above it.
39103979
* Comments are NAMED tree-sitter nodes, so the prev-sibling walk used to stop
39113980
* at one — a documented route (@Post + @HttpCode above an explanatory comment)
@@ -6291,16 +6360,6 @@ TEST(iris_export_xml_multi_class) {
62916360
* question asked in words could not reach either. Both now carry the prose in
62926361
* `docstring`, which is what nodes_fts indexes into its `body` column. */
62936362

6294-
/* First Module-labelled definition, or NULL. */
6295-
static const CBMDefinition *find_module_def(CBMFileResult *r) {
6296-
for (int i = 0; i < r->defs.count; i++) {
6297-
if (strcmp(r->defs.items[i].label, "Module") == 0) {
6298-
return &r->defs.items[i];
6299-
}
6300-
}
6301-
return NULL;
6302-
}
6303-
63046363
TEST(markdown_section_body_becomes_docstring_issue518) {
63056364
CBMFileResult *r = extract("# Installation\n"
63066365
"Run the bootstrap script to provision a workstation.\n"
@@ -6808,6 +6867,8 @@ SUITE(extraction) {
68086867
RUN_TEST(arkts_lazy_import);
68096868
RUN_TEST(arkts_ts_compat);
68106869
RUN_TEST(extract_java_jaxrs_path_composition_issue1005);
6870+
RUN_TEST(extract_blazor_page_directive_routes_component);
6871+
RUN_TEST(extract_blazor_component_without_page_has_no_route);
68116872
RUN_TEST(extract_ts_template_string_url_issue1006);
68126873
RUN_TEST(extract_go_binary_concat_url_issue1249);
68136874
RUN_TEST(extract_go_binary_concat_url_no_literal_suffix_issue1249);

0 commit comments

Comments
 (0)