diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 6e6cd0c60..26e0b0c82 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -7908,13 +7908,23 @@ void cbm_extract_definitions_without_module(CBMExtractCtx *ctx) { /* True when rel_path names a Blazor component file. */ static bool cbm_path_is_razor(const char *rel_path) { + /* Both Razor file types, not just components. `@page` is what DEFINES a + * Razor Page, so a .cshtml route is at least as worth extracting as a + * .razor one. Deliberately not .aspx/.ascx: Web Forms is a different + * templating syntax (`<%@ %>`, `runat="server"`) with no `@page` + * directive, so neither the C# recovery nor the scan below applies. */ if (!rel_path) { return false; } + static const char *const suffixes[] = {".razor", ".cshtml"}; 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; + for (size_t i = 0; i < sizeof(suffixes) / sizeof(suffixes[0]); i++) { + size_t slen = strlen(suffixes[i]); + if (len > slen && strcmp(rel_path + (len - slen), suffixes[i]) == 0) { + return true; + } + } + return false; } /* Match `@page "/route"` on ONE line; returns the route text or NULL. diff --git a/src/discover/language.c b/src/discover/language.c index a9b49b863..b5ebd00a8 100644 --- a/src/discover/language.c +++ b/src/discover/language.c @@ -55,6 +55,14 @@ static const ext_entry_t EXT_TABLE[] = { * parse_partial, which is why this is a best-effort mapping rather * than a dedicated grammar. */ {".razor", CBM_LANG_CSHARP}, + /* Razor Pages / MVC views. Same Razor syntax and the same C# host as + * .razor, and equally unmapped before this: an ASP.NET Core app's views + * produced no nodes at all. The `@page` directive that defines a Razor + * Page lives in this file type, so the route extraction below matters + * more here than it does for components. Best-effort on the same terms: + * the C# grammar recovers the @{ } / @functions blocks, the surrounding + * markup lands in ERROR regions and is reported via parse_partial. */ + {".cshtml", CBM_LANG_CSHARP}, /* Clojure */ {".clj", CBM_LANG_CLOJURE}, diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 7ea702c3a..b3232f0e5 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -4045,6 +4045,51 @@ TEST(extract_blazor_component_without_page_has_no_route) { PASS(); } +/* Razor Pages: `@page` is what turns a .cshtml view INTO a page — it is the + * defining directive of the model, not an optional annotation as it is on a + * Blazor component. So an ASP.NET Core app's routable surface lives entirely + * in file types that were unmapped until now, and every one of those routes + * was invisible. + * + * Same mechanism as the .razor case: the directive sits in markup above any + * code block, where the C# grammar never reaches, so it is read from raw + * source and hangs off the file's Module definition. */ +TEST(extract_razor_page_directive_routes_cshtml_view) { + CBMFileResult *r = extract("@page \"/orders\"\n" + "@model OrderIndexModel\n" + "\n" + "

Orders

\n" + "
@Model.Count
\n", + CBM_LANG_CSHARP, "t", "Pages/Orders/Index.cshtml"); + ASSERT_NOT_NULL(r); + const CBMDefinition *mod = find_module_def(r); + ASSERT_NOT_NULL(mod); + ASSERT_NOT_NULL(mod->route_path); + ASSERT_STR_EQ(mod->route_path, "/orders"); + /* A Razor Page is reached by navigation, i.e. GET — same as a component. */ + ASSERT_NOT_NULL(mod->route_method); + ASSERT_STR_EQ(mod->route_method, "GET"); + cbm_free_result(r); + PASS(); +} + +/* The overwhelming majority of .cshtml files are layouts, partials and views + * with no `@page` at all. If the scan fired on those, an ASP.NET app would + * gain a bogus Route node per view — worse than the missing routes it set out + * to fix, because a wrong route looks authoritative. */ +TEST(extract_razor_layout_without_page_has_no_route) { + CBMFileResult *r = extract("@model LayoutModel\n" + "\n" + "@RenderBody()\n", + CBM_LANG_CSHARP, "t", "Pages/Shared/_Layout.cshtml"); + ASSERT_NOT_NULL(r); + 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) @@ -7208,6 +7253,8 @@ SUITE(extraction) { 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_razor_page_directive_routes_cshtml_view); + RUN_TEST(extract_razor_layout_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 1bcd5e040..20529108f 100644 --- a/tests/test_language.c +++ b/tests/test_language.c @@ -97,6 +97,12 @@ TEST(lang_ext_razor) { ASSERT_EQ(cbm_language_for_extension(".razor"), CBM_LANG_CSHARP); PASS(); } +/* Razor Pages / MVC views were unmapped for the same reason .razor was, so an + * ASP.NET Core app's entire view layer was invisible to discovery. */ +TEST(lang_ext_cshtml) { + ASSERT_EQ(cbm_language_for_extension(".cshtml"), CBM_LANG_CSHARP); + PASS(); +} TEST(lang_ext_php) { ASSERT_EQ(cbm_language_for_extension(".php"), CBM_LANG_PHP); PASS(); @@ -1206,6 +1212,7 @@ SUITE(language) { RUN_TEST(lang_ext_ixx); RUN_TEST(lang_ext_csharp); RUN_TEST(lang_ext_razor); + RUN_TEST(lang_ext_cshtml); RUN_TEST(lang_ext_php); RUN_TEST(lang_ext_lua); RUN_TEST(lang_ext_scala);