feat(impact): Razor/Blazor (.cshtml/.razor) markup parser
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<Foo>` → the view-model / base type (.cshtml + .razor)
- `@inject IService svc` → the injected service type
- `@typeof(X)` → the referenced type
- Blazor `<MyComponent/>` 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 `<MyComponent/>` 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
|
||||
|
||||
@@ -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<div class="form">\n <input asp-for="Email" />\n</div>\n`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'Index.razor'),
|
||||
`<div>\n <ToastComponent />\n</div>\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);
|
||||
|
||||
// `<ToastComponent />` → 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 (`<div>`, `<input>`) 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;
|
||||
|
||||
@@ -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 `<Component/>` tags
|
||||
- 1 `BasketComponent` ViewComponent ← `<vc:basket>` / `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<Foo>` | `references` | the model/VM class `Foo` |
|
||||
| P1 | Razor/Blazor | `@inject IBar bar` | `references` | the service type `IBar` |
|
||||
| P2 | Blazor `.razor` | `<MyComponent .../>` (PascalCase element) | `references` | component class (`.razor` or `.cs : ComponentBase`) |
|
||||
| P2 | Blazor `.razor` | `@typeof(MainLayout)`, `@inherits LayoutBase` | `references` | the type |
|
||||
| P3 | Razor `.cshtml` | `<partial name="_X"/>`, `<vc:basket>`, `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
|
||||
`<X/>` 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
|
||||
`<ToastComponent/>` → 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`; `<ToastComponent/>` → 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 `<PascalComponent/>` tags + `@typeof`/`@inherits` + code-behind link.
|
||||
Covers the 6 Blazor `.cs` components + the 7 DTOs (via component params/`@bind`).
|
||||
- **P3:** Razor `<partial>` / `<vc:>` / `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
|
||||
(`<Router>`, `<Found>`, `<LayoutView>`, `<RouteView>`, `<CascadingValue>`) 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 `<Grid TItem="CatalogItem">`:** 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 `<ToastComponent/>` covers it; an HTML
|
||||
`<div>` 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.
|
||||
@@ -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<Language, 'svelte' | 'vue' | 'liquid' | 'yaml' | 'twig' | 'xml' | 'properties' | 'unknown'>;
|
||||
export type GrammarLanguage = Exclude<Language, 'svelte' | 'vue' | 'liquid' | 'razor' | 'yaml' | 'twig' | 'xml' | 'properties' | 'unknown'>;
|
||||
|
||||
/**
|
||||
* WASM filename map — maps each language to its .wasm grammar file
|
||||
@@ -69,6 +69,10 @@ export const EXTENSION_MAP: Record<string, Language> = {
|
||||
'.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',
|
||||
|
||||
@@ -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<Foo>` → the view-model / base type (.cshtml + .razor)
|
||||
* - `@inject IService svc` → the injected service type
|
||||
* - `@typeof(MainLayout)` → the referenced type
|
||||
* - `<MyComponent .../>` (Blazor only) → the component class (.razor or `.cs : ComponentBase`)
|
||||
* - `<Grid TItem="CatalogItem">` → 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<Foo, Baz>` → `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<Foo>` — 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 (`</Foo>`) start with
|
||||
// `</` and are skipped. HTML elements are lowercase → never match.
|
||||
const tagRe = /<([A-Z][A-Za-z0-9_]*)\b([^>]*)>/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: `<Grid TItem="CatalogItem">`.
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -115,6 +115,9 @@ const LANGUAGE_FAMILY: Record<string, string> = {
|
||||
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` /
|
||||
// `<MyComponent/>` 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;
|
||||
|
||||
@@ -75,6 +75,7 @@ export const LANGUAGES = [
|
||||
'c',
|
||||
'cpp',
|
||||
'csharp',
|
||||
'razor',
|
||||
'php',
|
||||
'ruby',
|
||||
'swift',
|
||||
|
||||
Reference in New Issue
Block a user