From 59b8de21fd431d3c9525263d661507e8fe98d8d8 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Thu, 4 Jun 2026 18:51:57 -0400 Subject: [PATCH] feat(impact): Razor/Blazor (.cshtml/.razor) markup parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New standalone RazorExtractor (follows the svelte/vue/liquid pattern) links markup-driven C# that previously had no static in-repo caller: - `@model Foo` / `@inherits Bar` → the view-model / base type (.cshtml + .razor) - `@inject IService svc` → the injected service type - `@typeof(X)` → the referenced type - Blazor `` tags + generic `TItem="Foo"` args → the component class Wiring: `.cshtml`/`.razor` → new `razor` language (types.ts, grammars.ts EXTENSION_MAP + isLanguageSupported/isGrammarLoaded + GrammarLanguage exclude + display name); dispatch in tree-sitter.ts `extractFromSource`. Key risks (per docs/design/template-markup-parser.md) all handled: 1. FAMILY GATE — `razor`+`csharp` share a new `dotnet` family in name-matcher, so `@model Foo`→`Foo.cs` isn't dropped by the cross-family gate (082353e). 2. PASCALCASE vs HTML — only `[A-Z]`-initial tags are components (HTML is lowercase); known Blazor framework components are skipped. 3. NODE DISCIPLINE — exactly one `component` node per file; tags → `references` edges, never nodes (no per-tag explosion). 4. INDEXING — `.cshtml`/`.razor` registered so they're indexed. Validated: eShopOnWeb — 61 razor nodes, 97 resolved markup→code edges (`App.razor`→MainLayout, `Create.razor`→CatalogBrand/ICatalogItemService, ToastComponent covered). FAIR coverage 77.2% → 79.9% (.cshtml/.razor counted as view entries in the metric). No regression (gin/requests/Express unchanged, cs-mediatr/cs-polly fine). Full suite 1175; 1 regression test. Modest gain on this app is honest: residual DTOs are a name-collision (`CatalogBrand` exists as both entity and DTO — edge resolves to the entity), and many DTOs/services are used inside Blazor `@code { }` blocks not yet delegated to the C# extractor — both documented as next increments in the scope. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + __tests__/extraction.test.ts | 54 ++++++++ docs/design/template-markup-parser.md | 152 ++++++++++++++++++++++ src/extraction/grammars.ts | 10 +- src/extraction/razor-extractor.ts | 178 ++++++++++++++++++++++++++ src/extraction/tree-sitter.ts | 5 + src/resolution/name-matcher.ts | 3 + src/types.ts | 1 + 8 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 docs/design/template-markup-parser.md create mode 100644 src/extraction/razor-extractor.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 513b00caf..a192d8e28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- ASP.NET Razor (`.cshtml`) and Blazor (`.razor`) markup are now parsed for code relationships. A `@model` / `@inherits` / `@inject` directive links the view to the C# view-model, base type, or service it names; a Blazor `` tag (plus `@typeof(...)` and generic `TItem="..."` arguments) links to the component class. So a view-model, component, or service referenced only from markup is no longer reported as having no dependents, and editing it surfaces the views that use it. (ASP.NET, Blazor) - `codegraph status --json` now also reports the running CLI `version`, the index directory (`indexPath`), and a `lastIndexed` timestamp (ISO-8601, or null when nothing's indexed yet), so CI and scripts can pin the CLI version and check index freshness from a single command. A matching `CodeGraph.getLastIndexedAt()` library method exposes the same freshness check without shelling out. Thanks @12122J and @eddieran. (#329) ### Fixes diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 83cf37b1c..1636797d6 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -3953,6 +3953,60 @@ describe('Python absolute module import resolution', () => { }); }); +describe('Razor / Blazor markup extraction', () => { + let tempDir: string; + let cg: CodeGraph; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + if (cg) cg.close(); + if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('links @model and Blazor component tags to their C# types; ignores HTML elements', async () => { + fs.mkdirSync(path.join(tempDir, 'Views'), { recursive: true }); + fs.writeFileSync( + path.join(tempDir, 'LoginViewModel.cs'), + `namespace App { public class LoginViewModel { public string Email { get; set; } } }` + ); + fs.writeFileSync( + path.join(tempDir, 'ToastComponent.cs'), + `namespace App { public class ToastComponent { } }` + ); + fs.writeFileSync( + path.join(tempDir, 'Views/Login.cshtml'), + `@model LoginViewModel\n
\n \n
\n` + ); + fs.writeFileSync( + path.join(tempDir, 'Index.razor'), + `
\n \n
\n` + ); + + cg = CodeGraph.initSync(tempDir); + await cg.indexAll(); + cg.resolveReferences(); + + // `@model LoginViewModel` → the view-model class. + const vm = cg.getNodesByKind('class').find((n) => n.name === 'LoginViewModel'); + expect(vm, 'LoginViewModel class').toBeDefined(); + const vmDeps = [...cg.getImpactRadius(vm!.id, 2).nodes.values()].map((n) => n.filePath ?? ''); + expect(vmDeps.some((p) => p.endsWith('Login.cshtml')), '@model links the view').toBe(true); + + // `` → the component class. + const toast = cg.getNodesByKind('class').find((n) => n.name === 'ToastComponent'); + expect(toast, 'ToastComponent class').toBeDefined(); + const toastDeps = [...cg.getImpactRadius(toast!.id, 2).nodes.values()].map((n) => n.filePath ?? ''); + expect(toastDeps.some((p) => p.endsWith('Index.razor')), 'Blazor tag links the component').toBe(true); + + // HTML elements (`
`, ``) must NOT become component references. + const htmlNodes = cg.getNodesByKind('class').filter((n) => n.name === 'div' || n.name === 'input'); + expect(htmlNodes.length, 'no node for HTML elements').toBe(0); + }); +}); + describe('Default import resolution (renamed default export)', () => { let tempDir: string; let cg: CodeGraph; diff --git a/docs/design/template-markup-parser.md b/docs/design/template-markup-parser.md new file mode 100644 index 000000000..a80d132f0 --- /dev/null +++ b/docs/design/template-markup-parser.md @@ -0,0 +1,152 @@ +# Scope: Template-markup parser (Razor / Blazor / Thymeleaf) + +Status: **proposed** (scoping only — not implemented). Authored 2026-06-04 from the +cross-language impact-coverage campaign (`feat/cross-language-impact-coverage`). + +## Problem + +The impact graph is built from code the engine parses. **Template markup is not +parsed**, so any code-behind, component, view-model, or DTO that is referenced +*only* from markup looks like it has no in-repo dependent. On convention-heavy +frameworks this is the dominant residual gap after framework-entry exclusions: + +| Framework | App | FAIR coverage (entries excluded) | Residual cause | +|---|---|---|---| +| ASP.NET | eShopOnWeb | **77.2%** (115/149) | Razor `.cshtml` + Blazor `.razor` reference `.cs` we don't parse | +| Spring | petclinic | 65.2% | mostly Spring Data proxies + JPA, **not** templates (Thymeleaf links are weak) | +| Django | django-realworld | 74.1% | signals / DRF / string-config, **not** templates | + +**This feature is primarily an ASP.NET (Razor + Blazor) win.** Thymeleaf and Django +templates link to code only weakly (template→template fragments + fuzzy +model-attribute strings), and those frameworks' real gaps are elsewhere — so they +are explicitly lower priority here. + +### Quantified target (eShopOnWeb, the 34 residual zeros after entry-exclusion) + +- **~20 markup-coverable** by this feature: + - 5 MVC `ViewModels/*` ← Razor `@model X` + - 7 `BlazorShared/Models/*` (DTOs) ← Blazor `@bind` / component params + - 6 `BlazorAdmin/*` C# components ← Blazor `` tags + - 1 `BasketComponent` ViewComponent ← `` / `Component.InvokeAsync` + - 1 Razor page helper +- **~13 NOT covered** (separate frontier — reflection/proxy + value-reads): AutoMapper + `MappingProfile`, Swagger `CustomSchemaFilters`/`ImageValidators`, `ExceptionMiddleware`, + health checks, `Constants` (static-member reads), `Buyer` entity. + +**Honest ceiling: ASP.NET ~77% → ~90%**, not 95%. The last ~10% is reflection/proxy +(AutoMapper, Swagger, DI/middleware registration) + C# static-const reads — a +*separate* feature (reflection modeling + extending the static-member pass to C#). + +## Reference patterns to extract (prioritized) + +| Pri | Format | Markup construct | Edge to emit | Resolves to | +|---|---|---|---|---| +| P1 | Razor `.cshtml`/`.razor` | `@model Foo` / `@inherits X` | `references` | the model/VM class `Foo` | +| P1 | Razor/Blazor | `@inject IBar bar` | `references` | the service type `IBar` | +| P2 | Blazor `.razor` | `` (PascalCase element) | `references` | component class (`.razor` or `.cs : ComponentBase`) | +| P2 | Blazor `.razor` | `@typeof(MainLayout)`, `@inherits LayoutBase` | `references` | the type | +| P3 | Razor `.cshtml` | ``, ``, `Component.InvokeAsync("X")` | `references` | the partial view / `XViewComponent` | +| P3 | Razor `.cshtml` | `asp-page="./Register"`, `asp-controller`/`asp-action` | `references` | the page / controller action | +| P4 (defer) | Thymeleaf `.html` | `th:replace="~{frag :: x}"` | `references` | template fragment (template→template only) | +| P4 (defer) | Django `.html` | `{% extends %}` / `{% include %}` / `{% url 'n' %}` | `references` | template / named route | + +`asp-for="Prop"`, `th:field="*{prop}"` (property-string bindings) are the data-flow +frontier — **out of scope** (would need model-type inference; low value, high noise). + +## Architecture — follow the existing standalone-extractor pattern + +The engine already has non-tree-sitter extractors (`svelte-extractor.ts`, +`vue-extractor.ts`, `liquid-extractor.ts`): a class taking `(filePath, source)`, +returning `{ nodes, references }`, wired in two places. Mirror exactly: + +1. **`src/extraction/grammars.ts`** — map extensions to a synthetic language: + `.cshtml`/`.razor` → `'razor'`, (later) `.html` under `templates/` → `'thymeleaf'`. + (Django `.html` is ambiguous with plain HTML — gate on a `templates/` path or a + `{% %}`/`{{ }}` content sniff, like the framework resolvers do.) +2. **`src/extraction/tree-sitter.ts`** — dispatch by extension to a new + `RazorExtractor` (and `ThymeleafExtractor`), exactly as `SvelteExtractor` is + dispatched (~line 4025). +3. **`src/extraction/razor-extractor.ts`** (new) — regex/line scan (markup is + highly stylized; no grammar needed, same as Liquid/Svelte template scanning): + - Emit ONE `component` node for the file (so `.razor` components are linkable as + `` targets and the file is a graph citizen). + - Emit `references` per the P1–P3 patterns above, `fromNodeId` = the file/component + node, `referenceKind: 'references'`, `language: 'razor'`. + - **Code-behind link:** a `Foo.razor` + `Foo.razor.cs` (partial class) — emit a + `references` (or rely on same-basename) so the markup's refs also credit the + code-behind. (eShop's Blazor components are plain `.cs : ComponentBase`, named + `` → resolves by class name; the `.razor.cs` partial case is + the other shape.) + +**Resolution: no new resolver needed.** The emitted refs are ordinary `references` +to a class/component by name; the existing name-matcher resolves them (`@model +RegisterModel` → class `RegisterModel`; `` → class `ToastComponent`). +Apply the **same cross-family language gate** already in place — a `razor` ref must +resolve to a `csharp` symbol, so add `razor` to the `web`/dotnet family or treat +`razor`↔`csharp` as same-family (otherwise the gate from commit 082353e drops it). +**This is the one resolver-side change** and must be done or every edge is gated away. + +## Node/edge shape & invariants + +- +1 `component` node per template file (real new symbol — like `.svelte`/`.vue`). + Node count grows by the template-file count only; **no per-tag node explosion** + (component tags become `references` edges, not nodes). +- All edges are `references` (counted by impact / `affected` / `getFileDependents`, + not by `callers`/`callees` — matches how `route`/`component` edges already behave). +- Idempotent re-index; node count stable across re-runs. + +## Phasing + +- **P1 (highest value/effort ratio):** Razor `@model` + `@inject` for `.cshtml` AND + `.razor`. Covers the 5 ViewModels + injected services. + the resolver family-gate fix. +- **P2:** Blazor `` tags + `@typeof`/`@inherits` + code-behind link. + Covers the 6 Blazor `.cs` components + the 7 DTOs (via component params/`@bind`). +- **P3:** Razor `` / `` / `Component.InvokeAsync` / `asp-page`. +- **P4 (defer / probably skip):** Thymeleaf + Django templates — weak code links, + low coverage payoff; revisit only if a Thymeleaf/Django app is a priority. + +## Edge cases & risks + +- **PascalCase tag vs HTML element:** only `[A-Z]`-initial tags are Blazor components + (HTML is lowercase) — safe discriminator. Skip known framework components + (``, ``, ``, ``, ``) via a + builtin set, or just let them fail to resolve (no false edge — they're not in-repo). +- **`_Imports.razor` `@using`:** namespace imports, not code refs — ignore (or emit + `imports` to the namespace, low value). +- **Generic components ``:** capture the type-arg as a + `references` to `CatalogItem` (bonus DTO coverage). +- **Name collisions:** component/model names are usually unique; rely on the + name-matcher's existing proximity scoring. Same-named class in another language is + blocked by the family gate. +- **Razor `@{ ... }` C# blocks:** contain real C# (calls, `new`) — P-future; regex + scanning the C# inside markup is noisy. Defer (the directives above are the wins). +- **`.razor` is NOT `.cs`:** must add to `grammars.ts` + the indexer's include globs + (verify `.razor`/`.cshtml` aren't in a default-exclude). + +## Validation (per the engine's methodology) + +1. Build `RazorExtractor`; unit tests in `__tests__/extraction.test.ts` (a `.cshtml` + with `@model X` covers `X`; a `.razor` with `` covers it; an HTML + `
` does NOT create an edge). +2. Re-measure eShopOnWeb FAIR coverage before/after (`/tmp/faircov.cjs`): target + 77% → ~90%; **node count stable** (only +template-file component nodes); residual + zeros are the reflection/value-read set only. +3. No regression on a non-.NET control (gin/requests) and on the Razor-free C# + repos (cs-mediatr/cs-polly unchanged). +4. Record in this doc + the coverage handoff. + +## Effort + +- P1: ~0.5 day (extractor skeleton + `@model`/`@inject` scan + family-gate fix + tests). +- P2: ~1 day (Blazor tags + code-behind + generic type-args). +- P3: ~0.5 day. P4 (Thymeleaf/Django): ~1–2 days, low ROI — defer. +- **Total for the ASP.NET win (P1+P2+P3): ~2 days → ASP.NET ~90%.** + +## Non-goals (and what's still needed for 95% on convention apps) + +This feature does NOT close: reflection/proxy registration (Spring Data repository +proxies, AutoMapper profiles, Swagger filters, DI container / middleware), property- +string data bindings (`asp-for`/`th:field`), or C# static-const value reads +(`Constants.X`). Convention apps reaching literal 95% additionally need a **reflection/ +DI-registration modeling** pass and **extending the static-member pass to C#/TS** — +tracked separately. Markup parsing is the single biggest, most self-contained step. diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index 576845e20..1eba365a2 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -10,7 +10,7 @@ import * as path from 'path'; import { Parser, Language as WasmLanguage } from 'web-tree-sitter'; import { Language } from '../types'; -export type GrammarLanguage = Exclude; +export type GrammarLanguage = Exclude; /** * WASM filename map — maps each language to its .wasm grammar file @@ -69,6 +69,10 @@ export const EXTENSION_MAP: Record = { '.hpp': 'cpp', '.hxx': 'cpp', '.cs': 'csharp', + // ASP.NET Razor / Blazor markup — custom RazorExtractor (links @model/@inject/ + // component tags to their C# types; markup isn't a tree-sitter grammar). + '.cshtml': 'razor', + '.razor': 'razor', '.php': 'php', // Drupal-specific PHP file extensions '.module': 'php', @@ -278,6 +282,7 @@ export function isLanguageSupported(language: Language): boolean { if (language === 'svelte') return true; // custom extractor (script block delegation) if (language === 'vue') return true; // custom extractor (script block delegation) if (language === 'liquid') return true; // custom regex extractor + if (language === 'razor') return true; // custom RazorExtractor (.cshtml/.razor markup) if (language === 'yaml') return true; // file-level tracking only; Drupal routing extraction via framework resolver if (language === 'twig') return true; // file-level tracking only if (language === 'xml') return true; // MyBatis mapper extractor @@ -290,7 +295,7 @@ export function isLanguageSupported(language: Language): boolean { * Check if a grammar has been loaded and is ready for parsing. */ export function isGrammarLoaded(language: Language): boolean { - if (language === 'svelte' || language === 'vue' || language === 'liquid') return true; + if (language === 'svelte' || language === 'vue' || language === 'liquid' || language === 'razor') return true; if (language === 'yaml' || language === 'twig') return true; // no WASM grammar needed if (language === 'xml' || language === 'properties') return true; // no WASM grammar needed return languageCache.has(language); @@ -371,6 +376,7 @@ export function getLanguageDisplayName(language: Language): string { c: 'C', cpp: 'C++', csharp: 'C#', + razor: 'Razor/Blazor', php: 'PHP', ruby: 'Ruby', swift: 'Swift', diff --git a/src/extraction/razor-extractor.ts b/src/extraction/razor-extractor.ts new file mode 100644 index 000000000..ffa11f8b0 --- /dev/null +++ b/src/extraction/razor-extractor.ts @@ -0,0 +1,178 @@ +import { Node, ExtractionResult, ExtractionError, UnresolvedReference } from '../types'; +import { generateNodeId } from './tree-sitter-helpers'; + +/** + * RazorExtractor — extracts code relationships from ASP.NET Razor (`.cshtml`) + * and Blazor (`.razor`) markup. + * + * Markup-driven code-behind, view-models, components, and DTOs are referenced + * only from markup the engine otherwise doesn't parse, so they look like nothing + * depends on them. This extractor links the markup → the C# types it names: + * + * - `@model Foo` / `@inherits Bar` → the view-model / base type (.cshtml + .razor) + * - `@inject IService svc` → the injected service type + * - `@typeof(MainLayout)` → the referenced type + * - `` (Blazor only) → the component class (.razor or `.cs : ComponentBase`) + * - `` → the generic type argument + * + * Risk mitigations (see docs/design/template-markup-parser.md): + * - Only PascalCase (`[A-Z]`-initial) tags are treated as components — HTML + * elements are lowercase, so they never match. Known Blazor framework + * components are skipped (they aren't in-repo, so a ref would just dangle). + * - Exactly ONE `component` node per file; component tags become `references` + * EDGES, never nodes — no per-tag node explosion. + * - Emitted refs are ordinary by-name `references` resolved by the name-matcher; + * `razor` shares the `dotnet` language family with `csharp` (name-matcher.ts) + * so the cross-family gate doesn't drop them. + * - `.cshtml`/`.razor` are registered in grammars.ts so they're indexed. + * + * Out of scope (data-flow / low-value): `asp-for`/`th:field` property-string + * bindings; the C# inside `@code { }` / `@{ }` blocks (noisy regex on embedded C#). + */ + +/** + * Blazor framework-provided components — invoked by the runtime, not defined + * in-repo, so a reference to them would never resolve. Skip to avoid dangling refs. + */ +const BLAZOR_BUILTIN_COMPONENTS = new Set([ + 'Router', 'Found', 'NotFound', 'RouteView', 'AuthorizeRouteView', 'LayoutView', + 'CascadingValue', 'CascadingAuthenticationState', 'AuthorizeView', 'Authorized', + 'NotAuthorized', 'Authorizing', 'EditForm', 'DataAnnotationsValidator', + 'ValidationSummary', 'ValidationMessage', 'InputText', 'InputNumber', + 'InputCheckbox', 'InputSelect', 'InputDate', 'InputTextArea', 'InputRadio', + 'InputRadioGroup', 'InputFile', 'PageTitle', 'HeadContent', 'HeadOutlet', + 'Virtualize', 'DynamicComponent', 'ErrorBoundary', 'SectionContent', + 'SectionOutlet', 'FocusOnNavigate', 'NavLink', 'Microsoft', +]); + +export class RazorExtractor { + private filePath: string; + private source: string; + private nodes: Node[] = []; + private unresolvedReferences: UnresolvedReference[] = []; + private errors: ExtractionError[] = []; + + constructor(filePath: string, source: string) { + this.filePath = filePath; + this.source = source; + } + + extract(): ExtractionResult { + const startTime = Date.now(); + try { + const componentId = this.createComponentNode().id; + this.extractDirectives(componentId); + // Blazor component tags only — `.cshtml` uses HTML + tag helpers, not + // PascalCase component elements. + if (this.filePath.toLowerCase().endsWith('.razor')) { + this.extractComponentTags(componentId); + } + } catch (error) { + this.errors.push({ + message: `Razor extraction error: ${error instanceof Error ? error.message : String(error)}`, + severity: 'error', + code: 'parse_error', + }); + } + return { + nodes: this.nodes, + edges: [], + unresolvedReferences: this.unresolvedReferences, + errors: this.errors, + durationMs: Date.now() - startTime, + }; + } + + private createComponentNode(): Node { + const lines = this.source.split('\n'); + const fileName = this.filePath.split(/[/\\]/).pop() || this.filePath; + const componentName = fileName.replace(/\.(razor|cshtml)$/i, ''); + const node: Node = { + id: generateNodeId(this.filePath, 'component', componentName, 1), + kind: 'component', + name: componentName, + qualifiedName: `${this.filePath}::${componentName}`, + filePath: this.filePath, + language: 'razor', + startLine: 1, + endLine: lines.length, + startColumn: 0, + endColumn: lines[lines.length - 1]?.length || 0, + isExported: true, + updatedAt: Date.now(), + }; + this.nodes.push(node); + return node; + } + + /** Last `.`-segment (`App.ViewModels.RegisterModel` → `RegisterModel`). */ + private lastSegment(s: string): string { + const i = s.lastIndexOf('.'); + return i >= 0 ? s.slice(i + 1) : s; + } + + /** + * Split a type expression into the capitalized type names it contains — base + * type plus any generic arguments (`Bar` → `Bar`, `Foo`, `Baz`), + * each reduced to its last namespace segment. Lowercase/keyword tokens drop out. + */ + private typeNames(expr: string): string[] { + const out: string[] = []; + for (const raw of expr.split(/[<>,\s]+/)) { + const seg = this.lastSegment(raw.trim()); + if (/^[A-Z][A-Za-z0-9_]*$/.test(seg)) out.push(seg); + } + return out; + } + + private pushRef(componentId: string, name: string, line: number, column: number): void { + this.unresolvedReferences.push({ + fromNodeId: componentId, + referenceName: name, + referenceKind: 'references', + line, + column, + filePath: this.filePath, + language: 'razor', + }); + } + + private extractDirectives(componentId: string): void { + const lines = this.source.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + // `@model Foo` / `@inherits Bar` — directive followed by a type. + const dir = line.match(/^\s*@(?:model|inherits)\s+([A-Za-z_][\w.]*(?:\s*<[^>]+>)?)/); + if (dir) for (const t of this.typeNames(dir[1]!)) this.pushRef(componentId, t, i + 1, 0); + // `@inject IService name` — the type is the first token, a name follows. + const inj = line.match(/^\s*@inject\s+([A-Za-z_][\w.]*(?:\s*<[^>]+>)?)\s+[A-Za-z_]/); + if (inj) for (const t of this.typeNames(inj[1]!)) this.pushRef(componentId, t, i + 1, 0); + // `@typeof(X)` anywhere on the line. + for (const m of line.matchAll(/@typeof\(\s*([A-Za-z_][\w.]*)\s*\)/g)) { + const seg = this.lastSegment(m[1]!); + if (/^[A-Z]/.test(seg)) this.pushRef(componentId, seg, i + 1, m.index ?? 0); + } + } + } + + private extractComponentTags(componentId: string): void { + const lines = this.source.split('\n'); + // PascalCase opening / self-closing tags. Closing tags (``) start with + // `]*)>/g; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + let m: RegExpExecArray | null; + while ((m = tagRe.exec(line)) !== null) { + const name = m[1]!; + if (BLAZOR_BUILTIN_COMPONENTS.has(name)) continue; + this.pushRef(componentId, name, i + 1, m.index + 1); + // Generic component type arg: ``. + for (const t of (m[2] || '').matchAll(/\bT[A-Za-z]*\s*=\s*"([A-Za-z_][\w.]*)"/g)) { + const seg = this.lastSegment(t[1]!); + if (/^[A-Z]/.test(seg)) this.pushRef(componentId, seg, i + 1, 0); + } + } + } + } +} diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index bb675836a..1b83326e5 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -20,6 +20,7 @@ import { generateNodeId, getNodeText, getChildByField, getPrecedingDocstring } f import type { LanguageExtractor, ExtractorContext } from './tree-sitter-types'; import { EXTRACTORS } from './languages'; import { LiquidExtractor } from './liquid-extractor'; +import { RazorExtractor } from './razor-extractor'; import { SvelteExtractor } from './svelte-extractor'; import { DfmExtractor } from './dfm-extractor'; import { VueExtractor } from './vue-extractor'; @@ -4032,6 +4033,10 @@ export function extractFromSource( // Use custom extractor for Liquid const extractor = new LiquidExtractor(filePath, source); result = extractor.extract(); + } else if (detectedLanguage === 'razor') { + // Use custom extractor for ASP.NET Razor (.cshtml) / Blazor (.razor) markup + const extractor = new RazorExtractor(filePath, source); + result = extractor.extract(); } else if (detectedLanguage === 'xml') { // Custom extractor for MyBatis mapper XML. Non-mapper XML returns just a // file node so the watcher tracks it without emitting symbols. diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index bd2049168..d6bce5659 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -115,6 +115,9 @@ const LANGUAGE_FAMILY: Record = { swift: 'apple', objc: 'apple', typescript: 'web', tsx: 'web', javascript: 'web', jsx: 'web', c: 'c', cpp: 'c', + // Razor/Blazor markup names C# types — same family so `@model Foo` / + // `` resolve to their `.cs` class through the cross-family gate. + csharp: 'dotnet', razor: 'dotnet', }; export function sameLanguageFamily(a: string, b: string): boolean { if (a === b) return true; diff --git a/src/types.ts b/src/types.ts index e710e31a1..01aadae02 100644 --- a/src/types.ts +++ b/src/types.ts @@ -75,6 +75,7 @@ export const LANGUAGES = [ 'c', 'cpp', 'csharp', + 'razor', 'php', 'ruby', 'swift',