diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md
index fae64574..e177eee6 100644
--- a/THIRD-PARTY-NOTICES.md
+++ b/THIRD-PARTY-NOTICES.md
@@ -66,6 +66,8 @@ Copied into the VSIX at `bin/all/` alongside the sidecar executable.
| Serilog | 4.4.0 | Apache-2.0 | Serilog Contributors |
| Serilog.Sinks.File | 7.0.0 | Apache-2.0 | Serilog Contributors |
| Outcome | 1.0.0 | MIT | Christian Findlay |
+| Microsoft.CodeAnalysis.Razor.Compiler and Microsoft.AspNetCore.Razor.Utilities.Shared (the Razor compiler, copied from the .NET SDK) | 10.0.0-preview.26377.103 (SDK 10.0.303) | MIT | .NET Foundation and Contributors |
+| Microsoft.Extensions.ObjectPool (copied from the .NET SDK with the Razor compiler) | 10.0.1 | MIT | .NET Foundation and Contributors |
> `Microsoft.Build`, `Microsoft.Build.Framework`, and `Microsoft.NET.StringTools`
> are referenced **compile-time only** (`ExcludeAssets="runtime"`); MSBuildLocator
diff --git a/coverage-thresholds.json b/coverage-thresholds.json
index ffb1b25d..89bada40 100644
--- a/coverage-thresholds.json
+++ b/coverage-thresholds.json
@@ -12,7 +12,7 @@
"language": "dotnet"
},
"sharplsp-sidecar-csharp": {
- "threshold": 95,
+ "threshold": 95.39,
"language": "dotnet"
},
"sharplsp-sidecar-fsharp": {
diff --git a/docs/plans/RAZOR-PLAN.md b/docs/plans/RAZOR-PLAN.md
new file mode 100644
index 00000000..66e28729
--- /dev/null
+++ b/docs/plans/RAZOR-PLAN.md
@@ -0,0 +1,89 @@
+# Razor and Blazor Implementation Plan
+
+**Spec:** [RAZOR-SPEC.md](../specs/RAZOR-SPEC.md)
+
+## Context
+
+SharpLsp has not supported `.razor` or `.cshtml` files until now. Research confirmed three things ([RAZOR-RESEARCH]):
+
+- The SDK's own Razor source generator already runs inside the C# sidecar's design-time workspace.
+- Its enhanced `#line` directives map every C# fragment back to Razor.
+- The public compiler IR supplies everything the mapping misses.
+
+So the C# sidecar owns Razor end to end, the host routes the documents to it, and the editors sync and forward HTML.
+
+The phases below are in dependency order. Each item is checked only when a test that exercises it passes.
+
+## TODO
+
+### Phase 1: Foundation
+
+- [x] Fixture solution `src/sharplsp/tests/fixtures/razor/` with `BlazorApp`, `Widgets` (RCL), `PagesApp` (Pages + MVC) and `Domain` (F#), isolated by its own `Directory.Build.props` ([RAZOR-TESTING])
+- [x] Host: add `LangId::Razor` for `.razor` and `.cshtml`, owned by the C# sidecar ([RAZOR-ARCHITECTURE-ROUTING])
+- [x] Host: route `documentSymbol`, `foldingRange` and `linkedEditingRange` for Razor to the sidecar, and answer `selectionRange` empty
+- [x] Host: add `<` and `@` completion triggers, and short-circuit them for C# and F# documents
+- [x] Host: publish Razor diagnostics under the source `sharplsp-razor`
+- [x] Sidecar: bundle the Razor compiler from the build SDK, and list it in `THIRD-PARTY-NOTICES.md` ([RAZOR-ARCHITECTURE-ENGINE])
+- [x] Sidecar: find Razor files as additional documents, and sync their text in every framework context, including the pending-edit replay ([RAZOR-ARCHITECTURE-DOCUMENTS])
+- [x] Sidecar: build the C# projection — find the generated document by its checksum pragma, and build the verified span map in both directions ([RAZOR-ARCHITECTURE-PROJECTION])
+ - [x] Verify the checksum lookup, the mappings that never map (hidden, fileless, foreign, outside either text), carets at a fragment's end, and rename's check that a span holds the old name — `RazorProjectionTests`: 6 passing cases over hand-written generator output
+- [x] Sidecar: build the Razor engine per project — configuration from the analyzer and parse options, an in-memory file system, tag helpers per compilation, the capture pass and markup facts
+ - [x] Verify configuration fallbacks, target-path metadata, current file/import reads, missing files, missing paths, cancellation and failure recovery — `RazorEngineSettingsTests`: 12 passing cases; settings, filesystem and engine each have 100% line coverage
+- [x] Sidecar: build the HTML projection and the markup-context tokenizer ([RAZOR-ARCHITECTURE-HTML])
+ - [x] HTML projection from compiler IR, preserving offsets/newlines and current unsaved text — `RazorHtmlProjectionTests`: 19 passing fixture cases (Blazor and MVC, comments, UTF-16, incomplete tags, unsaved edits)
+ - [x] HTML markup-context tokenizer — `HtmlMarkupContextTests`: 36 passing cases with 100% line coverage, including malformed start/end tag recovery; the three character-reconsumption regressions were verified failing before the fix and pass unchanged afterward
+ - [x] Host: `sharplsp/razor/htmlProjection` answers `{ text }`, or `null` for a file no project lists — `test_full_stack_razor_routing_session`
+ - [x] Verify `sharplsp/razor/htmlProjection` through editor integration — `razor-lsp.test.ts`: the projection keeps the editor's offsets, line breaks and unsaved text, and feeds VS Code's HTML completion and hover
+
+**Validation (2026-10-20):** the Release sidecar solution builds with zero warnings and errors; 671 C#, 440 F# and 125 Common tests pass, and all three coverage gates pass. C# coverage is 96.39%, with all 1,530 new C# executable lines covered. `RazorSidecarTests` exercises the real IPC contract and malformed-request recovery; `RazorCompletionEditsTests` covers mapped edits, import deduplication, aliases, static imports and LF/CRLF insertion.
+
+**Editor validation (2026-10-20):** the final Razor chunk passes all 6 cases, the workspace chunk passes all 446 cases (including server-start lifecycle), the debugger chunk passes all 80 cases, and editor lint and formatting pass. The debugger group-outcome test that previously timed out passes unchanged in the full chunk in 6.5 seconds.
+
+**Template validation (2026-10-20):** the real host and sidecars run over a fresh `dotnet new blazor` app (.NET 10). Its files report no errors and keep their shape in the HTML projection. Hover, definition, references, member completion and signature help answer on its real code: layouts, `@inherits`, `@Body`, `NavLinkMatch`, `@Assets`, `typeof(Program)`. The one false report found was usings marked unnecessary: every using in `_Imports.razor`, and, as review showed, a component's usings that only its markup needs. Razor files no longer report unnecessary usings (`Import_files_never_report_their_usings_as_unnecessary`, `Components_never_report_a_using_their_markup_may_need_as_unnecessary`).
+
+### Phase 2: C# Inside Razor
+
+- [ ] Diagnostics: C# diagnostics from projections, `RZ` diagnostics from facts, and Razor files in solution-wide diagnostics ([RAZOR-FEATURES-DIAGNOSTICS])
+- [x] Hover at mapped positions ([RAZOR-FEATURES-HOVER])
+- [ ] Definition, type definition, declaration and implementation from mapped positions, with generated component classes landing in their `.razor` file ([RAZOR-FEATURES-DEFINITION])
+- [x] References and document highlights across projections, with hidden scaffolding dropped ([RAZOR-FEATURES-REFERENCES])
+- [x] C# completion, with mapped edits and `using` → `@using` ([RAZOR-FEATURES-COMPLETION])
+- [x] Signature help ([RAZOR-FEATURES-SIGNATURE-HELP])
+- [x] Semantic tokens for C# fragments ([RAZOR-FEATURES-SEMANTIC-TOKENS])
+- [ ] Rename and prepareRename — Razor usages and `@code` declarations, from `.cs` and `.razor` origins ([RAZOR-FEATURES-RENAME])
+
+### Phase 3: Blazor Markup
+
+- [ ] Component tags: hover, definition, references, highlights and linked editing
+- [ ] Component attributes and `@bind-X`: hover, definition, references and rename
+- [ ] Directive attributes and directives: hover
+- [ ] Completion: components, parameters (with `[EditorRequired]` first), `@bind-X`, directive attributes and their parameters, and directives
+- [ ] Semantic tokens for markup: component tags, attributes, directive attributes and directives
+- [ ] Folding ranges and document symbols ([RAZOR-FEATURES-STRUCTURE])
+
+### Phase 4: Razor Pages and MVC
+
+- [ ] `@model`, `@inject`, `@section` and `@functions` through the projection ([RAZOR-MVC])
+- [ ] Tag helper elements and attributes: hover and completion
+- [ ] `_ViewImports.cshtml` imports resolved by the engine
+
+### Phase 5: Editors
+
+- [x] VS Code: sync `razor` and `aspnetcorerazor` documents, and activate on `onLanguage:razor` ([RAZOR-EDITORS]) — `razor-lsp.test.ts`: a Razor file opens as Razor, and its unsaved edits reach the server
+- [x] VS Code: forward HTML completion and hover over `sharplsp/razor/htmlProjection` ([RAZOR-FEATURES-HTML]) — `razor-lsp.test.ts`: element names and HTML hovers in markup, C# completion and hover in code
+- [x] VS Code: add a `razor` end-to-end chunk on both platforms — `test-chunks.json`, in both matrices
+- [x] Zed: Razor language registration (Zed has no built-in Razor grammar) — `the_server_attaches_to_the_csharp_fsharp_and_razor_languages`
+
+### Phase 6: F#
+
+- [ ] Hover, completion and definition from Razor into F# types of a referenced F# project ([RAZOR-FSHARP])
+- [ ] F#-origin rename that includes Razor usages, mapped back from the projections
+
+### Deferred ([RAZOR-FEATURES-DEFERRED])
+
+- [ ] Razor formatting
+- [ ] Selection ranges
+- [ ] Code actions: extract a block to code-behind, extract a component, generate an event handler, add `@using`
+- [ ] Inlay hints, code lens, and call and type hierarchy from Razor positions
+- [ ] `.razor.css` and `.razor.js` collocation awareness
+- [ ] Blazor WebAssembly debugging and Razor Hot Reload
diff --git a/docs/specs/DESIGN-SYSTEM.md b/docs/specs/DESIGN-SYSTEM.md
index 2cf5196c..61b5471c 100644
--- a/docs/specs/DESIGN-SYSTEM.md
+++ b/docs/specs/DESIGN-SYSTEM.md
@@ -137,9 +137,26 @@ Screenshots and video of a SharpLsp feature show the real extension, never a moc
- Every autoplaying video pauses and shows its controls under `prefers-reduced-motion: reduce`.
- Every video ships a WebP poster, the films and the recordings alike.
- **Narration.** A walkthrough may be narrated (`kit/sound/`). Its reviewed script (`films/.narration.json`) is spoken by Kokoro-82M through `kokoro-js` on the capturing machine, never by a speech service or the operating system's voices, whose licences forbid publishing. Each line starts just after its scene's cut, and a scene holds its last frame until its line ends. The music is an original score synthesised from code (`kit/sound/score.mjs`), so no film carries licensed or sampled music, and it ducks under the voice. A narrated film ships AAC in its MP4 and Opus in its WebM, plays unmuted with controls, and carries WebVTT captions in every site language (`..vtt`, the page's own language shown by default), listed under `captions` in the catalogue.
-- **One catalogue.** Every video on the site is declared once in `src/website/src/_data/videos.json`: encodings, size, length, upload date, and its label and caption in each language. Pages place a video with the `{% video "" %}` shortcode (`src/website/video-figure.js`) and never repeat `` markup. The shortcode renders in the page's language and emits a schema.org `VideoObject` whose name is the player's label, so search results describe exactly what a reader hears.
+- **One catalogue.** Every video on the site is declared once in `src/website/src/_data/videos.json`: encodings, size, length, upload date, the `page` that explains it, and its label and caption in each language. Every video also ships a 1200x630 JPEG social card, `-card.jpg`, cut from its poster frame by `kit/encode.mjs`. Pages place a video with the `{% video "" %}` shortcode (`src/website/video-figure.js`) and never repeat `` markup. The shortcode renders in the page's language and emits a schema.org `VideoObject` whose name is the player's label, so search results describe exactly what a reader hears.
- **Placement.** A video goes wherever a page talks about what it shows: the home page (the profiler film autoplaying in the hero, the CPU recording in the profiler section), every docs page with a feature to show (its own narrated walkthrough, after the page's introduction; the profiler docs add both recordings, the F# language page the F# profiling walkthrough too), and every blog post that discusses the profiler or the editor experience, in every language the page is published in. The Zed page has no film: Zed is a native application, and filming it would mean recording the real desktop.
+## [WEB-DESIGN-MEDIA-DISCOVERY] Video Search and Sharing
+
+Every catalogued video can be found in Google's video results and plays in social previews. The rules follow Google's [video SEO best practices](https://developers.google.com/search/docs/appearance/video), [VideoObject structured data](https://developers.google.com/search/docs/appearance/structured-data/video) and [video sitemaps](https://developers.google.com/search/docs/crawling-indexing/sitemaps/video-sitemaps).
+
+- **Watch pages.** Google only shows a video in video results from a page where it is the main content, and a docs page is not one. Each video therefore has a watch page per site language at `/videos//` (`//videos//`), generated from the catalogue. It holds the video's label as the title and heading, the player, a link to the `page` that explains it, and the narration's transcript, read from its captions. It is dated by its video's upload, in its `datePublished` and its sitemap `lastmod`, never by a build. Docs and article figures link to their video's watch page.
+- **Embed pages.** `/videos//embed/` is the bare player and nothing else (`noindex`). It is the `embedUrl` in structured data, the `player_loc` in the sitemap, and the `twitter:player` an X card frames.
+- **Structured data.** Every figure's `VideoObject` carries `name`, `description`, `thumbnailUrl` (the WebP poster, the one thumbnail per video everywhere), `contentUrl` (the MP4), `embedUrl`, `uploadDate` with its time zone, `duration`, `width` and `height`, which must match the real files. A watch page adds `transcript` and a `SeekToAction` whose `?t=` deep link starts the film at that second (`custom.js`), so Google can offer key moments.
+- **Sitemap.** `sitemap.xml` lists every page, including every docs page, and every watch page carries a `` entry: thumbnail, title, description, content and player locations, duration in seconds and publication date. It is the site's own template (`overrides/sitemap.njk`).
+- **Crawling.** `robots.txt` never disallows `/assets/`: Google must fetch video files and thumbnails, and X, LinkedIn and Slack read the generic `User-agent: *` group before fetching card images and videos.
+- **Social previews.** A docs page previews the first catalogued video whose `page` it is, and a watch page previews its own video. The preview uses:
+ - `og:image` and `twitter:image`: the 1200x630 card, with its alt text;
+ - `og:video`: the MP4, with its type and size, for networks that play it inline;
+ - an X `player` card: the embed page, with the MP4 as its stream.
+
+ A watch page is `og:type` `video.other`, with `video:duration` and `video:release_date`. A page without a film shares the site's `og-image.png`, with its alt text.
+- **Docs metadata.** Every docs page has its own `description` in every language, and its `` ends in `| SharpLsp`.
+
## [WEB-DESIGN-RESPONSIVE] Responsive Behavior
Design mobile-first: content order, meaning, and actions must work in a single column without hover. `768px` is the primary responsive boundary; at and below it:
diff --git a/docs/specs/DISTRIBUTION-SPEC.md b/docs/specs/DISTRIBUTION-SPEC.md
index 2524895f..03728857 100644
--- a/docs/specs/DISTRIBUTION-SPEC.md
+++ b/docs/specs/DISTRIBUTION-SPEC.md
@@ -685,9 +685,12 @@ Every rule below binds the upstream server option and the dev extension alike.
1. **A language server and nothing else.** The server id is `sharplsp` in both
extensions, so `lsp.sharplsp.*` settings are the same in either. It declares
no grammar: the C# and F# extensions define the `CSharp` and `FSharp`
- languages it attaches to, with LSP language ids `csharp` and `fsharp`. Users
- choose it with Zed's per-language `language_servers` setting. Zed has
- removed extension slash commands, so it declares none.
+ languages it attaches to, with LSP language ids `csharp` and `fsharp`. It
+ also attaches to `Razor`, with LSP language id `razor`: Zed has no built-in
+ Razor language, and the community Razor extensions all name theirs `Razor`
+ ([RAZOR-EDITORS]). Users choose it with Zed's per-language
+ `language_servers` setting. Zed has removed extension slash commands, so it
+ declares none.
2. **Never bundled.** The server resolves from `lsp.sharplsp.binary.path`, then
`sharplsp` on the worktree's PATH, then a download: the latest stable release's
[DIST-ARCHIVE] asset for this platform, unpacked into the extension's work
diff --git a/docs/specs/RAZOR-SPEC.md b/docs/specs/RAZOR-SPEC.md
new file mode 100644
index 00000000..f8cfed01
--- /dev/null
+++ b/docs/specs/RAZOR-SPEC.md
@@ -0,0 +1,424 @@
+# [RAZOR-SPEC] Razor: Blazor Components, Razor Pages and MVC Views
+
+**Parent:** [SHARPLSP-SPEC.md](SHARPLSP-SPEC.md) · **Plan:** [RAZOR-PLAN.md](../plans/RAZOR-PLAN.md)
+
+## [RAZOR-OVERVIEW] Overview
+
+SharpLsp edits Razor files with the same compiler-backed intelligence it gives C#. That covers `.razor` Blazor components, and `.cshtml` Razor Pages and MVC views.
+
+Blazor support is complete, not a subset. Every component construct answers through the same features:
+
+- **Constructs:** components and their parameters, generic components, child content, cascading values, event callbacks, two-way binding, directive attributes, render modes, and the C# inside all of them.
+- **Features:** hover, navigation, completion, references, rename, diagnostics and highlighting.
+
+Razor's semantics belong to the Razor compiler. SharpLsp never re-implements Razor parsing or code generation. It runs the compiler that ships in the .NET SDK and reads that compiler's public outputs.
+
+## [RAZOR-SCOPE] Scope
+
+### [RAZOR-SCOPE-VERSIONS] Versions
+
+Only the latest release is supported: ASP.NET Core 10 on the .NET 10 SDK. Its projects compile Razor through the source generator at Razor language version 10.0, with the Roslyn tokenizer turned on.
+
+- **net8.0 and net9.0:** these projects compile Razor through the same source generator, so they work too, but they are not verified.
+- **Before net6.0:** these projects compile Razor without a source generator, so there is no C# projection ([RAZOR-ARCHITECTURE-PROJECTION]). They are out of scope.
+- **Other out-of-scope tooling:** ASP.NET MVC 5 and Web Pages Razor on .NET Framework (`System.Web.Razor`), Web Forms (`.aspx`, `.ascx`) and Razor runtime compilation.
+
+### [RAZOR-SCOPE-FILES] File Kinds
+
+| File | Razor kind | Role |
+|---|---|---|
+| `*.razor` | Component | A Blazor component |
+| `_Imports.razor` | Component import | `@using`, `@inject`, `@layout`, … applied to every component in its folder and below |
+| `*.cshtml` | Legacy | A Razor Page (`@page`), MVC view, partial view or layout |
+| `_ViewImports.cshtml` | Legacy import | Directives applied to every view and page in its folder and below |
+| `_ViewStart.cshtml` | Legacy | The code that runs before each view |
+
+The supported project SDKs are `Microsoft.NET.Sdk.Web`, `Microsoft.NET.Sdk.Razor` (Razor class libraries) and `Microsoft.NET.Sdk.BlazorWebAssembly`.
+
+### [RAZOR-SCOPE-LANGUAGE] Code Language
+
+Razor's code language is C#, because the Razor compiler generates only C#. [RAZOR-FSHARP] states what F# gets.
+
+## [RAZOR-RESEARCH] How the Razor Toolchain Works
+
+These findings come from research against SDK 10.0.303 in October 2026. They are the facts this design rests on.
+
+1. **The compiler ships as a source generator.** It is `sdk//Sdks/Microsoft.NET.Sdk.Razor/source-generators/Microsoft.CodeAnalysis.Razor.Compiler.dll`, a netstandard2.0 Roslyn generator; SDK 10.0.303's copy is built against Roslyn 5.5. No current NuGet package carries it: `Microsoft.AspNetCore.Razor.Language` stops at 6.0.36, and `Microsoft.Net.Compilers.Razor.Toolset` only has a few old previews.
+2. **A design-time build already runs it.** The SDK target `_PrepareRazorSourceGenerators` wires the generator in:
+ - It adds the generator as an `Analyzer`.
+ - It passes every Razor file as an `AdditionalFiles` item. Each item carries `TargetPath` metadata (base64-encoded) and `CssScope` metadata.
+ - It exposes `RazorLangVersion`, `RootNamespace`, `SupportLocalizedComponentNames`, `GenerateRazorMetadataSourceChecksumAttributes` and `MSBuildProjectDirectory` as `build_property.*` analyzer options.
+ - For net10.0 it adds `use-roslyn-tokenizer=true` to the C# parse options' `Features`.
+
+ No switch suppresses the generator. A design-time build through `MSBuildWorkspace` gives a project whose compilation runs it, just as `dotnet build` does.
+3. **Every C# fragment is mapped back to its Razor source.** Each Razor file gets one generated file, named after its target path, such as `Pages/Board_razor.g.cs`. That file opens with `#pragma checksum ""`. The C# in each fragment is copied verbatim and wrapped in an enhanced `#line (line,col)-(line,col) [offset] ""` directive:
+
+ | Mapped to C# | Not mapped |
+ |---|---|
+ | `@using`, `@page` route, `@inherits`, `@implements`, `@inject` type and name, `@model`, `@typeparam`, `@rendermode`, `@attribute` | component tag names (``) |
+ | implicit `@expr` and explicit `@(expr)` expressions | `@bind-X` and directive attribute names (`@onclick`) |
+ | code blocks `@{ }`, control flow `@if/@foreach/…`, `@code` and `@functions` blocks | HTML, Razor comments, transitions |
+ | component parameter names (as `nameof(Component.Param)`) and values | the `Changed`/setter half of `@bind` |
+ | event handler expressions, `@ref` targets, `@key` values, generic type arguments | tag helper element and attribute names (`asp-for`) |
+ | tag helper attribute values (`asp-for="Title"` → `__model => __model.Title`) | |
+
+4. **Roslyn reads those directives through public APIs.** `SyntaxTree.GetLineMappings()` exposes each directive, and `Location.GetMappedLineSpan()` maps any generated location back to the Razor file. Roslyn reports a mapped span's end one character past the fragment, so SharpLsp measures each fragment by comparing the source text with the generated text instead.
+5. **Roslyn's IDE services work on generated documents.** Completion, symbol lookup, references and classification all work on a `SourceGeneratedDocument`. A reference that Roslyn finds in generated code carries the Razor path through the mapping. A reference inside hidden scaffolding, such as `__builder` calls and `@bind` setters, has no mapped path.
+6. **The compiler's public API covers what the C# mapping misses.**
+ - **Public:** `RazorProjectEngine` and its configuration; parser options that list the directives active for a file kind; the intermediate representation (`Microsoft.AspNetCore.Razor.Language.Intermediate`); the `IntermediateNodePassBase` extension point; tag-helper descriptors; and `CompilationTagHelperFeature`.
+ - **Internal:** the syntax tree nodes and the generated C# document.
+ - **What the IR carries:** component tag names (`ComponentIntermediateNode.StartTagSpan`), attribute name spans (`OriginalAttributeSpan`), `@bind-X` pairs, elements, directives and Razor diagnostics.
+7. **Microsoft's own tooling is not used.** Its Razor language server, now cohosted inside Roslyn's language server, depends on internal compiler APIs. SharpLsp reimplements from the public surface above ([SHARPLSP-MISSION-PRINCIPLES]).
+8. **VS Code already knows Razor files.** It ships a built-in `razor` language for `.cshtml` and `.razor` with a TextMate grammar. HTML language features reach embedded HTML through request forwarding, as described in the VS Code guide [Embedded Programming Languages](https://code.visualstudio.com/api/language-extensions/embedded-languages).
+
+## [RAZOR-ARCHITECTURE] Architecture
+
+```mermaid
+flowchart LR
+ Editor -- "LSP (razor, aspnetcorerazor)" --> Host["Rust host LangId::Razor"]
+ Host -- "MessagePack IPC" --> Sidecar["C# sidecar"]
+ subgraph Sidecar["C# sidecar"]
+ Additional["Razor file (AdditionalDocument)"] --> Generator["SDK Razor source generator (run by Roslyn)"]
+ Generator --> Projection["C# projection (SourceGeneratedDocument + #line map)"]
+ Additional --> Engine["Bundled Razor engine (IR capture pass)"]
+ Compilation["Project compilation"] --> Engine
+ Engine --> Facts["Markup facts (components, attributes, directives, diagnostics)"]
+ end
+ Projection --> Answers["C# answers mapped to Razor"]
+ Facts --> Answers2["Markup answers"]
+```
+
+### [RAZOR-ARCHITECTURE-ROUTING] Host Routing
+
+- **Ownership:** `.razor` and `.cshtml` URIs are `LangId::Razor`, and the C# sidecar owns them. It receives their `didOpen` and `didChange` text and their diagnostics pulls, and it answers every request about them.
+- **Syntax requests:** for C#, the host answers `documentSymbol`, `foldingRange`, `selectionRange` and `linkedEditingRange` from tree-sitter. For a Razor file it forwards `documentSymbol`, `foldingRange` and `linkedEditingRange` to the sidecar instead, because the Razor compiler is the parser and no tree-sitter Razor grammar is trusted. `selectionRange` on a Razor file answers empty until it is implemented ([RAZOR-FEATURES-DEFERRED]).
+- **Completion triggers:** the server adds `<` and `@` as completion triggers. A C# or F# document triggered by one of these two characters answers `null` at once, without reaching a sidecar.
+- **Diagnostic source:** Razor diagnostics publish under the source `sharplsp-razor`.
+
+### [RAZOR-ARCHITECTURE-DOCUMENTS] Razor Documents in the Workspace
+
+- **Where a Razor file lives:** it is an `AdditionalDocument` of every project that lists it, with one copy per target framework.
+- **Edits:** `didOpen` and `didChange` text replaces that additional document's text in every framework context, just as for C# documents ([NETFX-CONTEXT]). An edit that arrives before the workspace loads is replayed once it does.
+- **Which context answers:** a Razor file answers from the project context that is active ([NETFX-PROJECTS-CSHARP]).
+- **Unlisted files:** a Razor file that no project lists has no semantics, so every request about it answers empty.
+
+### [RAZOR-ARCHITECTURE-PROJECTION] C# Projection
+
+- **The projection:** a Razor file's projection is the generated document whose `#pragma checksum` names that file. `_Imports.razor` `@using` lines also appear in every component's generated document, but only `_Imports.razor`'s own generated document is its projection.
+- **The map:** the map is built from the projection's line mappings whose path is the Razor file. Each entry pairs a Razor span with a generated span. Both spans hold the same text, and SharpLsp measures each one by comparing that text.
+- **Razor to C#:** a Razor offset `o` is C# when some entry's Razor span contains `o` or ends at `o`. A caret just after `@curr` is C#, so completion works while typing. The generated offset is `generatedStart + (o − razorStart)`. When `o` ends one entry and starts the next, the entry that contains `o` wins.
+- **C# to Razor:**
+ - A location maps through `Location.GetMappedLineSpan()` when the result has a mapped path.
+ - A location inside a projection with no mapped path is hidden scaffolding and is dropped.
+ - There is one exception. The declaration of a component's own generated class maps to the start of its Razor file, because a component *is* its file.
+- **Text edits:**
+ - An edit computed on a projection maps back only when it lies entirely inside one entry.
+ - An edit that adds a C# `using` becomes an `@using` line in the Razor file ([RAZOR-FEATURES-COMPLETION]).
+ - Any other edit that cannot be mapped drops the item or action that carries it.
+
+### [RAZOR-ARCHITECTURE-ENGINE] The Razor Engine
+
+- **The bundled compiler:** SharpLsp bundles the Razor compiler assemblies from the SDK that builds it: `Microsoft.CodeAnalysis.Razor.Compiler`, `Microsoft.AspNetCore.Razor.Utilities.Shared` and `Microsoft.Extensions.ObjectPool`, all MIT-licensed.
+- **Two compilers, two jobs:** the project's own SDK generator still produces the C# projection, so C# answers always match the user's build. The bundled engine reads markup structure only, and a newer or older SDK cannot break it.
+- **Configuration:** each project's engine reads the same inputs the generator reads:
+ - the analyzer options `build_property.RazorLangVersion`, `RootNamespace` and `SupportLocalizedComponentNames`;
+ - the C# parse options: language version, preprocessor symbols and the `use-roslyn-tokenizer` feature;
+ - each file's decoded `TargetPath`.
+- **Features:**
+ - `CompilerFeatures.Register` provides components;
+ - MVC's `RazorExtensions.Register` provides pages, views and tag helpers;
+ - tag helpers are discovered by `CompilationTagHelperFeature` over the project's compilation, generated components included, and over every reference of that compilation, project references included;
+ - capture passes read the IR while the compiler builds it ([RAZOR-ARCHITECTURE-ENGINE-CAPTURE]).
+- **File system:** the engine reads files through an in-memory Razor file system over the project's Razor documents. Imports therefore resolve as the compiler resolves them: the default imports, then hierarchical `_Imports.razor` or `_ViewImports.cshtml`, all from the workspace's current text.
+- **Markup facts:** processing a file returns its markup facts ([RAZOR-ARCHITECTURE-ENGINE-FACTS]).
+- **State lifetime:** tag helpers are compiler state derived from one Roslyn compilation, and they are held only as long as that compilation lives (keyed to its instance). They are never held per request, and they are never feature results ([SHARPLSP-ARCHITECTURE-CACHING]).
+
+#### [RAZOR-ARCHITECTURE-ENGINE-CAPTURE] Capture Passes
+
+Later passes rewrite the IR, so each capture pass copies what it needs the moment it runs:
+
+| Pass | Runs | Reads |
+|---|---|---|
+| Directives | First in the directive-classifier phase, before `@code` becomes plain C# | Directives, `@using` included, and the HTML projection |
+| Tags | First in the optimization phase, before components are lowered and attribute names lose their spans | Elements, attributes, and the namespaces `@using` imports |
+| Diagnostics | Last in the optimization phase | `RZ` diagnostics |
+
+- **Spans:** every span comes from the compiler. Where a node has no span for a part (an end tag's name, a directive's name, a directive attribute's `@`), the part's span is derived from the node's span and kept only when the text there is that part.
+- **Imports:** directives and tags of imported files are skipped. A diagnostic is kept only when it lies in the processed file.
+
+#### [RAZOR-ARCHITECTURE-ENGINE-FACTS] Markup Facts
+
+```typediagram
+typeDiagram
+
+# A Razor file's markup facts, read from the compiler's IR.
+type RazorMarkup {
+ file_kind: RazorFileKind
+ # The namespace the file's class is generated in.
+ namespace: Option
+ # Namespaces the file and its imports bring in with `@using`.
+ imported_namespaces: List
+ tags: List
+ attributes: List
+ directives: List
+ diagnostics: List
+ # Directives the file kind accepts.
+ available_directives: List
+ # Every tag helper the project's compilation provides, components included.
+ tag_helpers: List
+ # The HTML projection ([RAZOR-ARCHITECTURE-HTML]).
+ html_text: String
+}
+
+# An element bound to tag helpers, or any element of a component file.
+type MarkupTag {
+ name: String
+ element: TextSpan
+ start_name: TextSpan
+ # Absent for a self-closing or unclosed element.
+ end_name: Option
+ # The component, child content and MVC tag helpers bound to the element.
+ tag_helpers: List
+}
+
+union MarkupAttributeKind {
+ # A component parameter or tag helper property: `Title="…"`, `asp-for="…"`.
+ Property
+ # A directive attribute: `@onclick`, `@bind-Value`, `@ref`.
+ Directive
+ # A directive attribute parameter: `@bind:event`, `@onclick:preventDefault`.
+ DirectiveParameter
+ # An attribute no tag helper binds.
+ Html
+}
+
+type MarkupAttribute {
+ kind: MarkupAttributeKind
+ name: String
+ name_span: Option
+ owner: MarkupTag
+ tag_helper: Option
+ bound: Option
+ parameter: Option
+}
+
+# A directive the file declares: `@page`, `@inject`, `@code`, `@using`.
+type MarkupDirective {
+ name: String
+ span: TextSpan
+ name_span: TextSpan
+ descriptor: Option
+}
+
+type MarkupDiagnostic {
+ # The `RZ` code.
+ id: String
+ message: String
+ severity: RazorDiagnosticSeverity
+ span: TextSpan
+}
+
+# A span of the file's text in UTF-16 code units (Roslyn's `TextSpan`).
+type TextSpan {
+ start: Int
+ length: Int
+}
+
+union RazorFileKind {
+ # A `.razor` component.
+ Component
+ # An `_Imports.razor` file.
+ ComponentImport
+ # A `.cshtml` page, view or `_ViewImports.cshtml`.
+ Legacy
+}
+
+union RazorDiagnosticSeverity {
+ Warning
+ Error
+}
+
+# The Razor compiler's descriptors, opaque here.
+type TagHelperDescriptor {}
+type BoundAttributeDescriptor {}
+type BoundAttributeParameterDescriptor {}
+type DirectiveDescriptor {}
+```
+
+The facts hold no C# fragment spans: C# positions come from the projection's map ([RAZOR-ARCHITECTURE-PROJECTION]).
+
+### [RAZOR-ARCHITECTURE-HTML] HTML Projection
+
+- **What it is:** the HTML projection is the Razor text with every non-HTML character replaced by a space, newlines kept, so offsets and positions are unchanged. The replaced characters are C# fragments with their transitions and delimiters, directives, and Razor comments.
+- **HTML comments:** a component's IR drops HTML comments. The engine therefore parses a component a second time with component parsing off, which keeps each comment's `` tokens, and copies those comment spans back into the projection.
+- **How it is served:** `sharplsp/razor/htmlProjection` (`{ textDocument }` → `{ text }`) returns the projection for the document's current text.
+- **Markup context:** the server finds the markup context at a caret by running an HTML tokenizer over the projection. The tokenizer follows the WHATWG tokenizer states. The possible contexts are a tag name, an attribute name, an attribute value, or content.
+
+## [RAZOR-FEATURES] Features
+
+### [RAZOR-FEATURES-DIAGNOSTICS] Diagnostics
+
+- **C#:** every diagnostic of the file's projection whose mapped path is the Razor file. Diagnostics in hidden scaffolding are dropped. A Razor file never calls a using unnecessary (`CS8019`, `IDE0005`), because Roslyn sees only the projection. Markup resolves component tags through usings that the generated C# never needs, since it names each component in full. An import file's (`_Imports.razor`, `_ViewImports.cshtml`) usings serve the files beside and below it.
+- **Razor:** the `RZ` diagnostics among the file's markup facts. Razor's error severity maps to LSP Error and its warning severity to LSP Warning.
+- **Solution-wide:** `workspace/diagnostics/all` reports Razor files as well.
+
+### [RAZOR-FEATURES-HOVER] Hover
+
+| Position | Answer |
+|---|---|
+| C# fragment | The C# hover at the mapped position, with its range mapped |
+| Component tag (start or end) | The component type's hover, ranged on the tag name |
+| Component attribute, `@bind-X` | The bound parameter property's hover |
+| Directive attribute (`@onclick`, `@bind`, `@key`, `@ref`, `@rendermode`, `@formname`, `@attributes`) | The descriptor's documentation |
+| Directive name (`@page`, `@inject`, …) | The directive's description |
+| MVC tag helper element or attribute | The tag helper type's or property's hover |
+| HTML | Nothing from the server. Clients forward HTML ([RAZOR-FEATURES-HTML]) |
+
+### [RAZOR-FEATURES-DEFINITION] Definition
+
+- **C# symbols:** resolved as in C#, with every result mapped back.
+- **Component tags, start or end:**
+ - A component authored in Razor goes to its `.razor` file, plus any code-behind partial class.
+ - A component from metadata goes to decompiled source ([DEFINITION-CSHARP]).
+- **Component attributes and `@bind-X`:** go to the parameter property.
+- **From C#:** `typeof(Counter)` or `new Counter()` also lands in `Counter.razor`, not in the generated file.
+
+### [RAZOR-FEATURES-REFERENCES] References and Highlights
+
+- **C# symbols:** references include every Razor usage through the projections, mapped back, with hidden scaffolding dropped and duplicates removed. This holds for requests from `.cs` files too.
+- **Component types:** references also include every start and end tag that instantiates the component, in every Razor file of the solution.
+- **Parameter properties:** references also include every `@bind-X` attribute that names the property.
+- **Document highlights:** the same rules, kept to the file. On a component tag, the start and end tag names highlight together.
+
+### [RAZOR-FEATURES-COMPLETION] Completion
+
+| Context at the caret | Items |
+|---|---|
+| C# fragment, or the end of one | Roslyn completion at the mapped position. Edits are mapped back, and an added `using` becomes an `@using` line |
+| After `<` in markup | Components in scope, each by its short name when its namespace is imported and by its full name otherwise, with documentation. Inside a component, its child-content parameters (``) |
+| Attribute name in a component start tag | Parameters not yet set, with `[EditorRequired]` ones first and marked; `@bind-X` for each bindable pair; directive attributes |
+| Attribute name in an element start tag | Directive attributes: `@on{event}`, `@bind`, `@bind-value`, `@key`, `@ref`, `@attributes`, `@rendermode`, `@formname` |
+| `@bind:` and `@on{event}:` | Parameters: `event`, `format`, `culture`, `get`, `set`, `after`, `preventDefault`, `stopPropagation` |
+| `@` at the start of a markup line | The directives active for the file kind, from the parser options |
+| MVC element start tag | Tag helper attributes (`asp-for`, `asp-page`, …) |
+
+The client adds HTML element and attribute names ([RAZOR-FEATURES-HTML]). The host's postfix templates (`.if`, `.foreach`, …) never apply in a Razor file, where the same text can be markup.
+
+### [RAZOR-FEATURES-SIGNATURE-HELP] Signature Help
+
+Signature help in a C# fragment is computed at the mapped position.
+
+### [RAZOR-FEATURES-RENAME] Rename
+
+- **C# symbols:**
+ - C# documents take Roslyn's rename edits.
+ - Each Razor usage takes an edit at its mapped span, and a declaration in an `@code` block is edited in the Razor file.
+ - A `@bind-Old` attribute becomes `@bind-New`.
+- **prepareRename:**
+ - On a C# fragment it answers the mapped identifier range.
+ - On markup it refuses with a message.
+ - A component type is renamed by renaming its file, so prepareRename refuses on a component tag.
+- **Cross-language:** renaming an F# symbol renames its Razor usages, and renaming a C# symbol from a Razor file renames its F# usages ([RAZOR-FSHARP], [RENAME-CROSSLANGUAGE]).
+
+### [RAZOR-FEATURES-SEMANTIC-TOKENS] Semantic Tokens
+
+- **C# fragments:** Roslyn's classification of the projection, mapped back.
+- **Markup tokens:**
+ - component and child-content tag names are `class`;
+ - component attribute names are `property`;
+ - directive attribute names (`@onclick`, `@bind-Value`) are `decorator`;
+ - directive names (`@page`, `@code`) are `keyword`.
+- **Everything else:** the client's TextMate grammar colors the rest.
+
+### [RAZOR-FEATURES-STRUCTURE] Folding, Symbols and Linked Editing
+
+- **Folding:** elements that span lines, `@code` and `@functions` blocks, C# code blocks, and `@section` blocks. A fold ends on the line before its last line, so the closing tag or brace stays visible.
+- **Document symbols:**
+ - directives (`@page`, `@inject`, `@code`, …) are `Key` symbols, named with their `@`;
+ - elements bound to components or tag helpers are `Class` symbols, named by their tag;
+ - symbols nest by containment, so a component inside another nests under it;
+ - the methods, properties and fields declared in `@code` and `@functions` nest under their block, and an `@inject` property under its directive.
+- **Linked editing:** the start and end tag names of a component, a tag helper element, or any element of a component file. A plain HTML element of a `.cshtml` file is not an element in the compiler's IR, so it does not link.
+
+### [RAZOR-FEATURES-HTML] HTML in Editors
+
+- **VS Code:** completion and hover in markup are forwarded to the built-in HTML language service over the HTML projection, following VS Code's request-forwarding pattern. The server's items come first and the HTML service's items are appended. Hover uses the HTML service only when the server has no answer.
+- **Other editors:** they attach their own HTML language server to `razor` files.
+
+### [RAZOR-FEATURES-DEFERRED] Not Yet Supported
+
+- **Editing features:** formatting; selection ranges; code actions (extract a block to code-behind, extract a component, generate an event handler, add `@using`); inlay hints; code lens; call and type hierarchy from Razor positions.
+- **Collocated files:** awareness of `.razor.css` CSS isolation and `.razor.js` collocated JavaScript.
+- **Runtime features:** Blazor WebAssembly debugging and Razor Hot Reload.
+
+Each of these is a separate plan item.
+
+## [RAZOR-BLAZOR] Blazor Coverage
+
+| Construct | Hover | Definition | Completion | References / rename | Diagnostics |
+|---|---|---|---|---|---|
+| `` tags | ✓ | ✓ | ✓ | ✓ / file rename | RZ10012 unknown element |
+| Parameters (`Param="…"`) | ✓ | ✓ | ✓ | ✓ / ✓ | RZ2012 missing `[EditorRequired]` |
+| Parameter values, event handlers, lambdas | ✓ (C#) | ✓ | ✓ | ✓ / ✓ | C# |
+| Generic components (`@typeparam`, `TItem="int"`) | ✓ | ✓ | ✓ | ✓ / ✓ | C# |
+| `@bind`, `@bind-X`, `@bind:event/format/get/set/after` | ✓ | ✓ | ✓ | ✓ / ✓ | RZ |
+| `@on{event}`, `:preventDefault`, `:stopPropagation` | ✓ | — | ✓ | — | RZ |
+| `@key`, `@ref`, `@attributes`, `@rendermode`, `@formname` | ✓ | ✓ (C# value) | ✓ | ✓ (C# value) | RZ |
+| Child content, `RenderFragment`, `Context=` | ✓ | ✓ | ✓ | ✓ | C# |
+| Cascading values and parameters | ✓ (C#) | ✓ | ✓ | ✓ / ✓ | C# |
+| Directives (`@page`, `@using`, `@inject`, `@inherits`, `@implements`, `@layout`, `@attribute`, `@namespace`, `@preservewhitespace`, `@rendermode`, `@typeparam`, `@code`) | ✓ | ✓ (C# tokens) | ✓ | ✓ (C# tokens) | RZ |
+| `_Imports.razor` | ✓ | ✓ | ✓ | ✓ | C# |
+| Code-behind partial classes (`.razor.cs`) | ✓ | ✓ | ✓ | ✓ / ✓ | C# |
+| Razor class libraries | ✓ | ✓ | ✓ | ✓ | C# |
+
+## [RAZOR-MVC] Razor Pages and MVC Views
+
+- **Directives:** `@page`, `@model`, `@inject`, `@inherits`, `@functions`, `@section`, `@namespace`, `@addTagHelper`, `@removeTagHelper` and `@tagHelperPrefix` follow the directive rows above. `@model`'s type is C#, and so is `Model.X`.
+- **Tag helpers:** a tag helper element or attribute (``, `asp-for`, `asp-page`, `asp-route-*`) answers hover and completion from its descriptor. An `asp-for` value is a model expression, so it gets C# features.
+- **Imports:** `_ViewImports.cshtml` is an import. `_ViewStart.cshtml`, layouts and partials are ordinary legacy files.
+
+## [RAZOR-FSHARP] F# in Razor Solutions
+
+Blazor front ends often sit over F# domain libraries. A Razor project that references an F# project reads it as its built DLL, like any C# project does ([DEFINITION-CROSSLANG]).
+
+- **Hover, completion and signature help:** these work on F# types and members used in Razor through that metadata.
+- **Definition:** definition on an F# symbol from Razor lands where it lands from C#: in decompiled metadata-as-source for the symbol's type ([DEFINITION-CROSSLANG]).
+- **Rename from F#:** renaming an F# symbol renames its Razor usages too. The Rust host asks the C# sidecar for the C# usages of the symbol ([RENAME-CROSSLANGUAGE]). Those usages include the projections, and each one is mapped back to its Razor file, never edited in generated code.
+- **References from F#:** references that start in F# reach C# and Razor usages only where they reach C# usages today ([DEFINITION-CROSSLANG]).
+- **No F# Razor:** F# is not a Razor code language. A Razor `@code` block is C#.
+
+## [RAZOR-EDITORS] Editors
+
+- **VS Code:**
+ - The client syncs `razor` documents (VS Code's built-in id) and `aspnetcorerazor` documents (the C# extension's id).
+ - The extension activates on `onLanguage:razor`.
+ - It forwards HTML requests ([RAZOR-FEATURES-HTML]).
+- **Zed:** Zed has no built-in Razor language. The server attaches to `Razor`, the language community Razor extensions define, with language id `razor` ([DIST-ZED-REGISTRY]).
+- **Other LSP clients:** the language id is `razor`, and the file types are `.razor` and `.cshtml`.
+
+## [RAZOR-ERRORS] Degradation
+
+- **Engine unavailable:** if the bundled engine fails to load or to process a file, markup features answer empty and the C# projection features keep working. The failure is logged once per project, with the project name and the exception type.
+- **No projection:** a file whose project does not run the generator has no projection, so its C# features answer empty. Its markup facts still answer.
+- **SDK newer than the sidecar's Roslyn:** the generator comes from the SDK that resolves for the project, which is the newest installed SDK when no `global.json` pins one. Roslyn does not load a generator built against a newer Roslyn than the sidecar bundles, so the project's Razor files have no projection, as above. The tests keep the repository's pin in their fixture copies. GitHub issue #399 tracks making the generator load with any SDK.
+- **Nothing at the position:** a position that is neither in a C# fragment nor on a markup fact answers `null` or an empty list, never an error.
+- **No content logged:** no Razor or generated text is ever logged. Logs carry only paths, spans and lengths.
+
+## [RAZOR-TESTING] Tests
+
+- **Fixture:** `src/sharplsp/tests/fixtures/razor/RazorFixtures.slnx` is a real solution of net10.0 projects, all of them buildable offline:
+
+ | Project | What it is |
+ |---|---|
+ | `BlazorApp` | Components, generic components, binding, code-behind |
+ | `Widgets` | A Razor class library used by `BlazorApp` |
+ | `PagesApp` | Razor Pages, an MVC view and tag helpers |
+ | `Domain` | An F# library used by `BlazorApp` |
+
+ The C# projects use framework references only. `Domain` restores only `FSharp.Core`, from the local NuGet cache in CI as every F# fixture does. The folder's own `Directory.Build.props` and `Directory.Build.targets` shield it from the repository's build settings.
+- **Sidecar tests:**
+ - map unit tests over real generator output;
+ - engine facts;
+ - every feature in [RAZOR-FEATURES] against the fixture.
+- **Rust end-to-end tests:** the full stack over the fixture: open, edit, diagnostics, hover, definition, references, completion and rename.
+- **VS Code end-to-end tests:** a `razor` chunk that drives the real extension through the same features, including HTML forwarding.
diff --git a/lspkit b/lspkit
index ccc59bfc..e6e41727 160000
--- a/lspkit
+++ b/lspkit
@@ -1 +1 @@
-Subproject commit ccc59bfc42eaa62980578317c749251098f2338b
+Subproject commit e6e417275a76568d559ef26f9f68752f7a0f4454
diff --git a/src/editors/vscode/package.json b/src/editors/vscode/package.json
index 4ece2a0b..dcfa9613 100644
--- a/src/editors/vscode/package.json
+++ b/src/editors/vscode/package.json
@@ -40,6 +40,7 @@
"workspaceContains:**/*.slnx",
"workspaceContains:**/*.csproj",
"workspaceContains:**/*.fsproj",
+ "onLanguage:razor",
"onDebugResolve:sharplsp-coreclr",
"onDebugDynamicConfigurations:sharplsp-coreclr"
],
diff --git a/src/editors/vscode/src/client.ts b/src/editors/vscode/src/client.ts
index a1fc5a43..ec83bc3e 100644
--- a/src/editors/vscode/src/client.ts
+++ b/src/editors/vscode/src/client.ts
@@ -23,6 +23,7 @@ import { getErrorMessage } from './utils.js';
import * as config from './config.js';
import * as log from './log.js';
import { createOpenSync, type OpenSync } from './open-sync.js';
+import { createRazorHtml, RAZOR_LANGUAGES, serverProjection } from './razor-html.js';
import { createAnsiStrippingChannel } from './output-filter.js';
import { serverStdioOptions } from './server-stderr.js';
import { detectRuntimePlatform } from './platform.js';
@@ -53,12 +54,16 @@ class SharpLspClient extends LanguageClient {
}
}
-/** The documents the client syncs to the server, and holds requests about. */
+/**
+ * The documents the client syncs to the server, and holds requests about. A Razor
+ * file is only ever a file: one no project lists has no semantics ([RAZOR-EDITORS]).
+ */
export const DOCUMENT_SELECTOR = [
{ scheme: 'file', language: 'csharp' },
{ scheme: 'file', language: 'fsharp' },
{ scheme: 'untitled', language: 'csharp' },
{ scheme: 'untitled', language: 'fsharp' },
+ ...RAZOR_LANGUAGES.map((language) => ({ scheme: 'file', language })),
];
export interface DeploymentPaths {
@@ -101,6 +106,9 @@ export async function start(
const serverOptions: ServerOptions = { run, debug: run };
const openSync = createOpenSync(DOCUMENT_SELECTOR);
+ // The middleware is built before the client it forwards for, and asks it for
+ // projections only once requests flow through it. [RAZOR-FEATURES-HTML]
+ const razorHtml = createRazorHtml(serverProjection(() => client));
const clientOptions: LanguageClientOptions = {
documentSelector: DOCUMENT_SELECTOR,
@@ -127,8 +135,9 @@ export async function start(
traceOutputChannel: log.trace(),
errorHandler: makeErrorHandler(statusBar),
// After every (re)start a request waits for its document's didOpen, so it
- // never reaches a fresh server ahead of the document it is about.
- middleware: openSync.middleware,
+ // never reaches a fresh server ahead of the document it is about. Razor
+ // markup adds the HTML service's completion and hover answers.
+ middleware: { ...openSync.middleware, ...razorHtml.middleware },
};
const client = new SharpLspClient(EXTENSION_ID, EXTENSION_NAME, serverOptions, clientOptions);
@@ -136,7 +145,14 @@ export async function start(
wireClientState(client, statusBar, openSync, context);
statusBar.setState(ServerState.Starting);
- await client.start();
+ try {
+ await client.start();
+ } catch (error: unknown) {
+ // A server that never started serves no projections. [RAZOR-FEATURES-HTML]
+ razorHtml.provider.dispose();
+ throw error;
+ }
+ context.subscriptions.push(razorHtml.provider);
return client;
}
diff --git a/src/editors/vscode/src/paths.ts b/src/editors/vscode/src/paths.ts
index c5e8752a..cb4cc648 100644
--- a/src/editors/vscode/src/paths.ts
+++ b/src/editors/vscode/src/paths.ts
@@ -10,6 +10,15 @@ export function fileUri(value: string): Uri {
return Uri.file(value);
}
+/**
+ * The address of a virtual document embedded in `host`: under `scheme`, named after the
+ * host's whole URI with `extension`, so VS Code reads it as that language, and told
+ * apart by `query`. [SHARPLSP-ARCHITECTURE-PATHS] [RAZOR-FEATURES-HTML]
+ */
+export function embeddedUri(scheme: string, host: Uri, extension: string, query: string): Uri {
+ return Uri.from({ scheme, path: `/${encodeURIComponent(host.toString())}${extension}`, query });
+}
+
/** The platform's directory separator. [SHARPLSP-ARCHITECTURE-PATHS] */
export const pathSeparator = nodePath.sep;
diff --git a/src/editors/vscode/src/razor-html.ts b/src/editors/vscode/src/razor-html.ts
new file mode 100644
index 00000000..14f6d782
--- /dev/null
+++ b/src/editors/vscode/src/razor-html.ts
@@ -0,0 +1,160 @@
+// Implements [RAZOR-FEATURES-HTML] and [RAZOR-EDITORS]. HTML completion and hover in
+// Razor markup come from VS Code's built-in HTML language service, which reads the
+// server's HTML projection of the Razor file: the same text with every non-HTML
+// character blanked, so a Razor position is the same position in the projection.
+// This follows VS Code's request-forwarding pattern for embedded languages:
+// https://code.visualstudio.com/api/language-extensions/embedded-languages
+import {
+ CompletionList,
+ Disposable,
+ commands,
+ workspace,
+ type CompletionItem,
+ type Hover,
+ type Position,
+ type TextDocument,
+ type Uri,
+} from 'vscode';
+import { State, type LanguageClient, type Middleware } from 'vscode-languageclient/node';
+import * as log from './log.js';
+import { embeddedUri } from './paths.js';
+import { isRecord } from './utils.js';
+
+/** Razor's language ids: VS Code's built-in `razor`, and the C# extension's own. */
+export const RAZOR_LANGUAGES: readonly string[] = ['razor', 'aspnetcorerazor'];
+
+/** The scheme of the projections the HTML service reads. */
+const PROJECTION_SCHEME = 'sharplsp-razor-html';
+
+/** The server request that answers a Razor document's HTML projection. */
+const PROJECTION_REQUEST = 'sharplsp/razor/htmlProjection';
+
+/** The server's HTML projection of the document at `uri`; undefined when it has none. */
+export type ProjectionSource = (uri: string) => Promise;
+
+/**
+ * Projections from the running server. A server that cannot project the document
+ * answers null; one that fails is logged and treated the same, so the server's own
+ * completion and hover answers still reach the editor.
+ */
+export function serverProjection(client: () => LanguageClient | undefined): ProjectionSource {
+ return async (uri) => {
+ const running = client();
+ if (running?.state !== State.Running) return undefined;
+ try {
+ const answer: unknown = await running.sendRequest(PROJECTION_REQUEST, {
+ textDocument: { uri },
+ });
+ return isRecord(answer) && typeof answer.text === 'string' ? answer.text : undefined;
+ } catch (error) {
+ log.warn(
+ JSON.stringify({ event: 'razor_html_projection_failed', uri, errorKind: typeof error }),
+ );
+ return undefined;
+ }
+ };
+}
+
+/** The forwarding middleware, and the provider that serves the projections. */
+export interface RazorHtml {
+ readonly middleware: Pick;
+ readonly provider: Disposable;
+}
+
+/** What a completion provider answers. */
+type Completions = CompletionItem[] | CompletionList | null | undefined;
+
+/**
+ * The HTML forwarding for Razor documents. The server answers first; HTML completion
+ * items are appended to its own, and an HTML hover answers only when the server has none.
+ */
+export function createRazorHtml(source: ProjectionSource): RazorHtml {
+ // Each Razor document's latest projection, by the projection's address.
+ const projections = new Map();
+ const provider = Disposable.from(
+ workspace.registerTextDocumentContentProvider(PROJECTION_SCHEME, {
+ provideTextDocumentContent: (uri) =>
+ [...projections.values()].find((entry) => entry.address === uri.toString())?.text ?? '',
+ }),
+ // A closed document's projection is never asked for again.
+ workspace.onDidCloseTextDocument((document) => projections.delete(document.uri.toString())),
+ );
+ const project = async (document: TextDocument): Promise => {
+ const text = await source(document.uri.toString());
+ if (text === undefined) return undefined;
+ const uri = projectionUri(document);
+ projections.set(document.uri.toString(), { address: uri.toString(), text });
+ return uri;
+ };
+ return { middleware: forwardingMiddleware(project), provider };
+}
+
+/** The middleware that adds the HTML service's answers to the server's. */
+function forwardingMiddleware(
+ project: (document: TextDocument) => Promise,
+): RazorHtml['middleware'] {
+ return {
+ provideCompletionItem: async (document, position, context, token, next) => {
+ const own = await next(document, position, context, token);
+ if (!isRazor(document)) return own;
+ const uri = await project(document);
+ return uri === undefined
+ ? own
+ : appended(own, await htmlCompletions(uri, position, context.triggerCharacter));
+ },
+ provideHover: async (document, position, token, next) => {
+ const own = await next(document, position, token);
+ if (own !== null && own !== undefined) return own;
+ const uri = isRazor(document) ? await project(document) : undefined;
+ return uri === undefined ? own : await htmlHover(uri, position);
+ },
+ };
+}
+
+/** Whether `document` is a Razor file. */
+function isRazor(document: TextDocument): boolean {
+ return RAZOR_LANGUAGES.includes(document.languageId);
+}
+
+/**
+ * The projection's address: an `.html` path, so VS Code reads it as HTML, carrying the
+ * Razor URI it projects and the version it projects, so no answer reads stale text.
+ */
+export function projectionUri(document: TextDocument): Uri {
+ return embeddedUri(PROJECTION_SCHEME, document.uri, '.html', `v=${document.version.toString()}`);
+}
+
+/**
+ * The HTML service's completion items at `position` in the projection. The command
+ * always resolves to a list, an empty one when no provider answers.
+ */
+async function htmlCompletions(
+ uri: Uri,
+ position: Position,
+ trigger: string | undefined,
+): Promise {
+ const list = await commands.executeCommand(
+ 'vscode.executeCompletionItemProvider',
+ uri,
+ position,
+ trigger,
+ );
+ return list.items;
+}
+
+/** The HTML service's hover at `position` in the projection, if it has one. */
+async function htmlHover(uri: Uri, position: Position): Promise {
+ const hovers = await commands.executeCommand(
+ 'vscode.executeHoverProvider',
+ uri,
+ position,
+ );
+ return hovers?.[0];
+}
+
+/** The server's items first, then `extra`, keeping the server's incompleteness. */
+function appended(own: Completions, extra: CompletionItem[]): CompletionList {
+ const items = Array.isArray(own) ? own : (own?.items ?? []);
+ const incomplete = !Array.isArray(own) && own?.isIncomplete === true;
+ return new CompletionList([...items, ...extra], incomplete);
+}
diff --git a/src/editors/vscode/src/test/suite/razor-kit.ts b/src/editors/vscode/src/test/suite/razor-kit.ts
new file mode 100644
index 00000000..d5f39aee
--- /dev/null
+++ b/src/editors/vscode/src/test/suite/razor-kit.ts
@@ -0,0 +1,76 @@
+// The Razor fixture solution ([RAZOR-TESTING]) for the VS Code Razor suite: the
+// repository's src/sharplsp/tests/fixtures/razor, copied without its build output
+// to a scratch root, built so the SDK's Razor generator has run, and loaded
+// through the real extension.
+import * as assert from 'node:assert/strict';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import type * as vscode from 'vscode';
+import { findGlobalJson } from '../../global-json.js';
+import { serverProjection } from '../../razor-html.js';
+import { dotnet } from './dotnet-project-kit';
+import { type LoadedFixture, useLoadedFixture } from './netfx-language-kit';
+import { lspClient } from './profiler-kit';
+
+/** The fixture in the repository. */
+const SOURCE = path.resolve(__dirname, '../../../../../sharplsp/tests/fixtures/razor');
+
+/** Build output a copy leaves behind: a copied `obj` would point back at the source. */
+const BUILD_OUTPUT = new Set(['bin', 'obj']);
+
+/** The Razor files the suite opens, relative to the fixture root. */
+export const RAZOR_FILES = {
+ counter: 'BlazorApp/Components/Pages/Counter.razor',
+ board: 'BlazorApp/Components/Pages/Board.razor',
+} as const;
+
+/** The Razor file the suite writes where no project lists it. */
+export const STRAY_RAZOR = 'Stray.razor';
+
+/**
+ * Pin `root` to the SDK the fixture has in the repository. A scratch copy has no
+ * `global.json` above it, so it would build and load with the newest installed SDK,
+ * whose Razor generator can need a newer Roslyn than the sidecar's.
+ */
+function keepSdkPin(root: string): void {
+ const pin = findGlobalJson(SOURCE);
+ assert.ok(pin, 'the repository pins the fixture SDK in a global.json');
+ fs.copyFileSync(pin, path.join(root, 'global.json'));
+}
+
+/** Copy the fixture to `root`, with its SDK pin, and build it. */
+async function writeRazorFixture(root: string): Promise {
+ fs.cpSync(SOURCE, root, {
+ recursive: true,
+ filter: (source) => !BUILD_OUTPUT.has(path.basename(source)),
+ });
+ keepSdkPin(root);
+ const solution = path.join(root, 'RazorFixtures.slnx');
+ await dotnet(['build', solution], root);
+ return {
+ root,
+ solution,
+ ready: { file: RAZOR_FILES.counter, anchor: ['Current count: @currentCount', 'currentCount'] },
+ };
+}
+
+/** The Razor fixture, built and loaded for the suite that calls this. */
+export function useRazorFixture(): () => LoadedFixture {
+ return useLoadedFixture('sharplsp-razor-', writeRazorFixture);
+}
+
+/**
+ * Write `text` as a Razor file that no project lists: alone in a scratch folder, outside
+ * the fixture and every workspace folder, so no watcher reloads a project. Returns the folder.
+ */
+export function writeStrayRazor(text: string): string {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sharplsp-razor-stray-'));
+ fs.writeFileSync(path.join(root, STRAY_RAZOR), text);
+ return root;
+}
+
+/** The server's HTML projection of `uri`, asked for as the extension asks. [RAZOR-FEATURES-HTML] */
+export function htmlProjection(uri: vscode.Uri): Promise {
+ return serverProjection(lspClient)(uri.toString());
+}
diff --git a/src/editors/vscode/src/test/suite/razor-lsp.test.ts b/src/editors/vscode/src/test/suite/razor-lsp.test.ts
new file mode 100644
index 00000000..3daf7a96
--- /dev/null
+++ b/src/editors/vscode/src/test/suite/razor-lsp.test.ts
@@ -0,0 +1,281 @@
+// End-to-end tests for Razor in VS Code ([RAZOR-EDITORS], [RAZOR-FEATURES-HTML]), over
+// the real Razor fixture solution ([RAZOR-TESTING]). The extension activates on and
+// syncs Razor documents; the C# sidecar answers the C# inside them; and markup gets
+// the built-in HTML service's completion and hover over the server's HTML projection
+// (`sharplsp/razor/htmlProjection`, [RAZOR-ARCHITECTURE-HTML]).
+import * as assert from 'node:assert/strict';
+import * as fs from 'node:fs';
+import * as vscode from 'vscode';
+import { DOCUMENT_SELECTOR } from '../../client.js';
+import { logFilePath } from '../../log.js';
+import { projectionUri, serverProjection } from '../../razor-html.js';
+import { completionList, hoverText } from './filebased-package-kit';
+import { lspClient } from './profiler-kit';
+import { openRepoFile, positionOf } from './real-repo-helpers';
+import { completionLabels } from './real-repo-kit';
+import {
+ htmlProjection,
+ RAZOR_FILES,
+ STRAY_RAZOR,
+ useRazorFixture,
+ writeStrayRazor,
+} from './razor-kit';
+import { packageJson } from './run-debug-kit';
+import {
+ closeAllEditors,
+ pollProvider,
+ pollUntilResult,
+ removeDirRecursive,
+ waitForHoverResult,
+} from './test-helpers';
+import { LSP_RESPONSE_MS } from './test-timeouts';
+
+/** The C# expression the Counter page shows, and the field it reads. */
+const COUNT: readonly [string, string] = ['Current count: @currentCount', 'currentCount'];
+
+/**
+ * Elements the HTML service offers at any tag name that no fixture file holds as a
+ * word, so VS Code's word-based suggestions can never supply them.
+ */
+const HTML_ONLY: readonly string[] = ['blockquote', 'figcaption', 'fieldset'];
+
+/** The line the log holds for `uri`, once it holds one. */
+function loggedLine(uri: string): Promise {
+ return pollUntilResult(
+ async () =>
+ (await fs.promises.readFile(logFilePath(), 'utf8'))
+ .split('\n')
+ .find((line) => line.includes(uri)),
+ (line) => line !== undefined,
+ LSP_RESPONSE_MS,
+ undefined,
+ `a log line naming ${uri}`,
+ );
+}
+
+/** Hover text at `position`, once it satisfies `until`. */
+async function hoverWhen(
+ uri: vscode.Uri,
+ position: vscode.Position,
+ until: (text: string) => boolean,
+): Promise {
+ const hovers = await pollProvider(
+ 'vscode.executeHoverProvider',
+ [uri, position],
+ (reply) => until(hoverText(reply)),
+ );
+ return hoverText(hovers);
+}
+
+suite('Razor in VS Code', () => {
+ const fixture = useRazorFixture();
+
+ teardown(async () => {
+ await closeAllEditors();
+ });
+
+ test('a Razor file opens as Razor, and its unsaved edits reach the server', async function () {
+ this.timeout(LSP_RESPONSE_MS * 3);
+ // Interaction 1 — the manifest activates on Razor, and the client syncs both of
+ // Razor's language ids: VS Code's own and the C# extension's.
+ const events: string[] = packageJson().activationEvents ?? [];
+ assert.ok(events.includes('onLanguage:razor'), 'activates on Razor');
+ const synced = DOCUMENT_SELECTOR.filter((entry) => entry.scheme === 'file').map(
+ (entry) => entry.language,
+ );
+ assert.ok(synced.includes('razor'), 'syncs VS Code’s razor documents');
+ assert.ok(synced.includes('aspnetcorerazor'), 'syncs the C# extension’s Razor documents');
+
+ // Interaction 2 — a component opens as Razor, and the C# sidecar answers the
+ // C# inside it at the Razor position.
+ const { doc, uri } = await openRepoFile(fixture().root, RAZOR_FILES.counter);
+ assert.equal(doc.languageId, 'razor', 'a .razor file is VS Code’s razor language');
+ const hover = hoverText(await waitForHoverResult(uri, positionOf(doc, ...COUNT)));
+ assert.match(hover, /\bint\b/, 'the field’s C# type');
+ assert.match(hover, /currentCount/, 'the field’s name');
+
+ // Interaction 3 — an unsaved line above moves the field down one line: the
+ // server's hover and projection both follow the editor's text.
+ const edit = new vscode.WorkspaceEdit();
+ edit.insert(uri, new vscode.Position(6, 0), 'Edited
\n');
+ assert.ok(await vscode.workspace.applyEdit(edit), 'the edit applies');
+ const moved = positionOf(doc, ...COUNT);
+ assert.equal(moved.line, 8, 'the field moved down a line');
+ assert.match(await hoverWhen(uri, moved, (text) => text.includes('currentCount')), /\bint\b/);
+ const projection = (await htmlProjection(uri)) ?? '';
+ assert.ok(projection.includes('Edited
'), 'the projection holds the unsaved line');
+ assert.equal(projection.length, doc.getText().length, 'and the unsaved length');
+ await vscode.commands.executeCommand('workbench.action.files.revert');
+ });
+
+ test('the server projects Razor as HTML of the same shape', async function () {
+ this.timeout(LSP_RESPONSE_MS * 2);
+ const { doc, uri } = await openRepoFile(fixture().root, RAZOR_FILES.counter);
+ const text = doc.getText();
+ const projection = await htmlProjection(uri);
+ assert.ok(projection !== undefined, 'a listed Razor file has an HTML projection');
+
+ // Interaction 1 — every offset and line break is where the Razor file has it,
+ // so a Razor position is the same position in the projection.
+ assert.equal(projection.length, text.length, 'the same UTF-16 length');
+ assert.deepEqual(
+ projection.split('\n').map((line) => line.length),
+ text.split('\n').map((line) => line.length),
+ );
+
+ // Interaction 2 — markup stays, at its own offset.
+ const heading = text.indexOf('Counter ');
+ assert.ok(heading > 0, 'the fixture has its heading');
+ assert.equal(projection.indexOf('Counter '), heading, 'markup keeps its offset');
+ assert.ok(projection.includes('Current count: '), 'markup around C# stays');
+
+ // Interaction 3 — C# is blanked: the implicit expression and the @code block.
+ const count = text.indexOf(COUNT[0]) + 'Current count: '.length;
+ assert.equal(
+ projection.slice(count, count + '@currentCount'.length).trim(),
+ '',
+ 'an expression',
+ );
+ assert.ok(!projection.includes('currentCount++'), 'a @code statement');
+ assert.ok(!projection.includes('private int'), 'a @code field');
+ });
+
+ test('markup completes from the HTML service, and C# from the server', async function () {
+ this.timeout(LSP_RESPONSE_MS * 3);
+ const { doc, uri } = await openRepoFile(fixture().root, RAZOR_FILES.counter);
+
+ // Interaction 1 — in a tag name, the HTML service's element names arrive through
+ // the projection, and no C# member does.
+ const tag = completionLabels(
+ await completionList(uri, positionOf(doc, '
Counter', 'h1'), 'div'),
+ );
+ for (const element of ['div', 'span', 'h1']) assert.ok(tag.has(element), `offers <${element}>`);
+ assert.ok(!tag.has('currentCount'), 'markup offers no C# member');
+
+ // Interaction 2 — in @code, the server's C# members answer, and the HTML
+ // service adds no element name: the projection blanks the C#.
+ const code = completionLabels(
+ await completionList(uri, positionOf(doc, 'currentCount++;', 'currentCount'), 'currentCount'),
+ );
+ assert.ok(code.has('currentCount'), 'the field');
+ assert.ok(code.has('IncrementCount'), 'the method');
+ assert.ok(!code.has('div'), 'C# offers no HTML element');
+ });
+
+ test('markup hovers come from the HTML service when the server has none', async function () {
+ this.timeout(LSP_RESPONSE_MS * 3);
+ const { doc, uri } = await openRepoFile(fixture().root, RAZOR_FILES.counter);
+ const fromMdn = (text: string): boolean => text.includes('developer.mozilla.org');
+
+ // Interaction 1 — an HTML element: the HTML service describes the heading.
+ const heading = await hoverWhen(uri, positionOf(doc, 'Counter', 'h1'), fromMdn);
+ assert.match(heading, /heading/i, 'the element’s description');
+ assert.ok(fromMdn(heading), 'with its MDN reference');
+
+ // Interaction 2 — an HTML attribute: the HTML service describes `class`.
+ const attribute = await hoverWhen(uri, positionOf(doc, 'Stray\nNo project lists this file.
\n');
+ try {
+ const listed = await openRepoFile(fixture().root, RAZOR_FILES.counter);
+ const stray = await openRepoFile(root, STRAY_RAZOR);
+ const heading = positionOf(stray.doc, 'Stray', 'h2');
+
+ // Interaction 1 — at a tag name, a listed file gets the HTML service's elements;
+ // a file no project lists has no projection, so the server's items stand alone.
+ const offered = completionLabels(
+ await completionList(listed.uri, positionOf(listed.doc, 'Counter', 'h1'), 'fieldset'),
+ );
+ for (const element of HTML_ONLY) assert.ok(offered.has(element), `listed: <${element}>`);
+ assert.equal(
+ await htmlProjection(stray.uri),
+ undefined,
+ 'an unlisted file has no projection',
+ );
+ const own = completionLabels(
+ await vscode.commands.executeCommand(
+ 'vscode.executeCompletionItemProvider',
+ stray.uri,
+ heading,
+ ),
+ );
+ for (const element of HTML_ONLY) assert.ok(!own.has(element), `unlisted: no <${element}>`);
+
+ // Interaction 2 — hover: the server has none there, and the HTML service is never
+ // asked; the address the file's projection would have reads as empty HTML.
+ const hovers = await vscode.commands.executeCommand(
+ 'vscode.executeHoverProvider',
+ stray.uri,
+ heading,
+ );
+ assert.equal(hoverText(hovers), '', 'no hover from the server or the HTML service');
+ const address = await vscode.workspace.openTextDocument(projectionUri(stray.doc));
+ assert.equal(address.languageId, 'html', 'a projection address reads as HTML');
+ assert.equal(address.getText(), '', 'and holds nothing for a file never projected');
+
+ // Interaction 3 — the server refuses a URI that names no file: the refusal is
+ // logged and answers no projection, as a stopped client does, while the running
+ // server still projects the listed file.
+ const unreadable = `untitled:Stray-${Date.now()}.razor`;
+ assert.equal(await serverProjection(lspClient)(unreadable), undefined, 'a refusal');
+ assert.match(
+ (await loggedLine(unreadable)) ?? '',
+ /\[WARN\] \{"event":"razor_html_projection_failed","uri":"untitled:/,
+ 'is logged as a warning, by event and URI',
+ );
+ assert.equal(await serverProjection(() => undefined)(listed.uri.toString()), undefined);
+ const projection = await serverProjection(lspClient)(listed.uri.toString());
+ assert.ok(projection?.includes('Counter '), 'the running server projects');
+ } finally {
+ await closeAllEditors();
+ removeDirRecursive(root);
+ }
+ });
+
+ test('references to a field in Razor land in its Razor file, never generated code', async function () {
+ this.timeout(LSP_RESPONSE_MS * 2);
+ const { doc, uri } = await openRepoFile(fixture().root, RAZOR_FILES.board);
+ const usages = ['@bind-Label="caption"', 'Text="@caption"', '@bind="caption"'];
+ const lines = usages.map((usage) => positionOf(doc, usage, 'caption').line);
+ const declaration = positionOf(doc, 'private string caption', 'caption');
+
+ // Interaction 1 — from the @code declaration: the declaration, a component
+ // parameter, a bound attribute and a two-way binding.
+ const references = await pollProvider(
+ 'vscode.executeReferenceProvider',
+ [uri, declaration],
+ (found) =>
+ lines.every((line) => found.some((location) => location.range.start.line === line)),
+ );
+ for (const location of references) {
+ assert.equal(location.uri.toString(), uri.toString(), 'every usage is in Board.razor');
+ assert.equal(doc.getText(location.range), 'caption', 'and spans the name');
+ }
+ assert.ok(
+ references.some((location) => location.range.start.line === declaration.line),
+ 'the declaration',
+ );
+
+ // Interaction 2 — from a usage in markup, the same set.
+ const fromUsage = await pollProvider(
+ 'vscode.executeReferenceProvider',
+ [uri, positionOf(doc, 'Text="@caption"', 'caption')],
+ (found) => found.length === references.length,
+ );
+ assert.deepEqual(
+ fromUsage.map((location) => location.range.start.line).sort((a, b) => a - b),
+ references.map((location) => location.range.start.line).sort((a, b) => a - b),
+ 'every usage finds the same references',
+ );
+ });
+});
diff --git a/src/editors/vscode/test-chunks.json b/src/editors/vscode/test-chunks.json
index 4a2c60fb..57db9df9 100644
--- a/src/editors/vscode/test-chunks.json
+++ b/src/editors/vscode/test-chunks.json
@@ -48,6 +48,12 @@
"fsharp-lsp-cross-language-rename.test.js"
]
},
+ "razor": {
+ "description": "Razor and Blazor over the real Razor fixture solution, built once per job ([RAZOR-EDITORS], [RAZOR-TESTING]): Razor documents activate the extension and sync, unsaved edits included; the C# sidecar answers the C# inside them at Razor positions, references landing in the Razor file and never in generated code; and markup gets the built-in HTML service's completion and hover over the server's HTML projection ([RAZOR-FEATURES-HTML]).",
+ "files": [
+ "razor-lsp.test.js"
+ ]
+ },
"debug": {
"description": "Getting a program to START, and nothing about what happens once it has: the F5 / no-launch.json resolve contract, launchSettings.json + .run.json profile parsing, the netcoredbg adapter factory, manifest conformance for the debugger, breakpoint, task-definition, command and menu contributions, the [SCRIPT-CONE] launch-target walk with MSBuild output resolution, and the F5 / Ctrl+F5 / sharplsp.runProgram gestures over real projects, C# file-based apps and .fsx scripts.",
"files": [
diff --git a/src/editors/zed/extension.toml b/src/editors/zed/extension.toml
index 07f1a753..f3e352f3 100644
--- a/src/editors/zed/extension.toml
+++ b/src/editors/zed/extension.toml
@@ -6,15 +6,17 @@ schema_version = 1
authors = ["SharpLsp "]
repository = "https://github.com/Nimblesite/SharpLsp"
-# Pair with the C# and F# extensions, which define these languages and their
-# grammars. [DIST-ZED-REGISTRY]
+# Pair with the C# and F# extensions and a Razor extension, which define these
+# languages and their grammars. Zed has no built-in Razor language, and the
+# community Razor extensions all name theirs `Razor`. [DIST-ZED-REGISTRY]
[language_servers.sharplsp]
name = "SharpLsp"
-languages = ["CSharp", "FSharp"]
+languages = ["CSharp", "FSharp", "Razor"]
[language_servers.sharplsp.language_ids]
CSharp = "csharp"
FSharp = "fsharp"
+Razor = "razor"
# The server is never bundled: absent from PATH, the extension downloads the
# release archive for this platform from this repository only.
diff --git a/src/editors/zed/src/lib.rs b/src/editors/zed/src/lib.rs
index 18bf0e2e..14109507 100644
--- a/src/editors/zed/src/lib.rs
+++ b/src/editors/zed/src/lib.rs
@@ -232,10 +232,12 @@ mod manifest_tests {
);
}
- /// Zed matches servers to languages by the names the C# and F# extensions define:
- /// `CSharp` and `FSharp`. `C#` or `F#` silently attaches to nothing.
+ /// Zed matches servers to languages by the names the language extensions define:
+ /// `CSharp`, `FSharp`, and `Razor` from the Razor extensions, since Zed has no
+ /// Razor of its own. `C#` or `F#` silently attaches to nothing.
+ /// [DIST-ZED-REGISTRY] [RAZOR-EDITORS]
#[test]
- fn the_server_attaches_to_the_csharp_and_fsharp_languages() {
+ fn the_server_attaches_to_the_csharp_fsharp_and_razor_languages() {
let manifest = manifest();
let server = &manifest["language_servers"][LANGUAGE_SERVER];
@@ -243,9 +245,10 @@ mod manifest_tests {
.as_array()
.map(|names| names.iter().filter_map(toml::Value::as_str).collect())
.unwrap_or_default();
- assert_eq!(languages, ["CSharp", "FSharp"]);
+ assert_eq!(languages, ["CSharp", "FSharp", "Razor"]);
assert_eq!(server["language_ids"]["CSharp"].as_str(), Some("csharp"));
assert_eq!(server["language_ids"]["FSharp"].as_str(), Some("fsharp"));
+ assert_eq!(server["language_ids"]["Razor"].as_str(), Some("razor"));
assert_eq!(server["name"].as_str(), Some("SharpLsp"));
}
diff --git a/src/sharplsp/src/diagnostics.rs b/src/sharplsp/src/diagnostics.rs
index e59eb6e8..04955440 100644
--- a/src/sharplsp/src/diagnostics.rs
+++ b/src/sharplsp/src/diagnostics.rs
@@ -277,14 +277,21 @@ async fn publish_once(
fn source_tag_for_uri(uri: &Uri) -> String {
match crate::tree_sitter_parse::LangId::from_uri(uri) {
Some(crate::tree_sitter_parse::LangId::FSharp) => "sharplsp-fsharp".to_string(),
+ Some(crate::tree_sitter_parse::LangId::Razor) => RAZOR_SOURCE.to_string(),
_ => "sharplsp-csharp".to_string(),
}
}
+/// The diagnostic source of every Razor diagnostic, C# and `RZ` alike.
+/// [RAZOR-ARCHITECTURE-ROUTING]
+const RAZOR_SOURCE: &str = "sharplsp-razor";
+
/// Determine the diagnostic source tag from a native document path.
fn source_tag_for_path(file_path: &str) -> &'static str {
if crate::paths::has_extension(file_path, &["fs", "fsx", "fsi", "fsscript"]) {
"sharplsp-fsharp"
+ } else if crate::paths::has_extension(file_path, &["razor", "cshtml"]) {
+ RAZOR_SOURCE
} else {
"sharplsp-csharp"
}
@@ -623,6 +630,28 @@ fn map_severity(severity: &str) -> DiagnosticSeverity {
mod tests {
use super::*;
+ /// Razor diagnostics publish under their own source, whether the file is
+ /// named by URI (push) or by path (pull and solution-wide).
+ /// [RAZOR-ARCHITECTURE-ROUTING]
+ #[test]
+ fn razor_files_publish_under_the_razor_source() {
+ let component: Uri = "file:///c:/app/Board.razor".parse().unwrap();
+ let view: Uri = "file:///c:/app/Index.cshtml".parse().unwrap();
+ let code: Uri = "file:///c:/app/Program.cs".parse().unwrap();
+ assert_eq!(source_tag_for_uri(&component), "sharplsp-razor");
+ assert_eq!(source_tag_for_uri(&view), "sharplsp-razor");
+ assert_eq!(source_tag_for_uri(&code), "sharplsp-csharp");
+ assert_eq!(
+ source_tag_for_path("/app/Pages/Board.razor"),
+ "sharplsp-razor"
+ );
+ assert_eq!(
+ source_tag_for_path("/app/Views/Index.CSHTML"),
+ "sharplsp-razor"
+ );
+ assert_eq!(source_tag_for_path("/app/Library.fs"), "sharplsp-fsharp");
+ }
+
#[test]
fn map_severity_error() {
assert_eq!(map_severity("Error"), DiagnosticSeverity::ERROR);
diff --git a/src/sharplsp/src/document_symbols.rs b/src/sharplsp/src/document_symbols.rs
index 9f672e29..ec932fce 100644
--- a/src/sharplsp/src/document_symbols.rs
+++ b/src/sharplsp/src/document_symbols.rs
@@ -1,10 +1,11 @@
-//! F# document symbols (`textDocument/documentSymbol`).
+//! Sidecar document symbols (`textDocument/documentSymbol`) for F# and Razor.
//!
//! C# document symbols are answered syntactically by tree-sitter in
-//! [`crate::handlers::handle_document_symbols`]. F# has no tree-sitter grammar
-//! in the host, so F# symbols are sourced from the sidecar's FCS navigation
-//! items and mapped here into nested LSP [`DocumentSymbol`]s. The extraction
-//! contract is `[SE-FSHARP-SYMBOLS]`.
+//! [`crate::handlers::handle_document_symbols`]. F# symbols are sourced from the
+//! sidecar's FCS navigation items (`[SE-FSHARP-SYMBOLS]`), and Razor symbols
+//! from the C# sidecar's Razor compiler (`[RAZOR-FEATURES-STRUCTURE]`); both
+//! arrive in one wire shape and are mapped here into nested LSP
+//! [`DocumentSymbol`]s.
use std::sync::Arc;
@@ -16,18 +17,19 @@ use tracing::warn;
use crate::sidecar::manager::SidecarManager;
use crate::utils::SidecarFileReq;
-/// Handle `textDocument/documentSymbol` for an F# file via the sidecar. `[FS-DOCSYMBOL]`
-pub fn handle_fsharp(
+/// Handle `textDocument/documentSymbol` for an F# or Razor file via the sidecar
+/// that owns it. `[FS-DOCSYMBOL]` `[RAZOR-ARCHITECTURE-ROUTING]`
+pub fn handle_via_sidecar(
req: Request,
runtime: &tokio::runtime::Runtime,
sidecar: Option<&Arc>,
) -> Result {
- // F# has no host tree-sitter grammar; symbols are only available when the
- // FCS sidecar is running. With no sidecar (F# disabled / no workspace root)
- // the request cannot be served — surface that as an error rather than a
- // misleading empty outline. [SE-FSHARP-SYMBOLS]
+ // F# and Razor have no host tree-sitter grammar; symbols are only available
+ // when the owning sidecar is running. With no sidecar (language disabled / no
+ // workspace root) the request cannot be served — surface that as an error
+ // rather than a misleading empty outline. [SE-FSHARP-SYMBOLS]
let Some(sidecar) = sidecar else {
- anyhow::bail!("F# sidecar unavailable; cannot compute document symbols");
+ anyhow::bail!("sidecar unavailable; cannot compute document symbols");
};
let params: DocumentSymbolParams = serde_json::from_value(req.params)?;
let file_path = crate::paths::uri_to_path(params.text_document.uri.as_str())?;
@@ -161,6 +163,8 @@ fn parse_document_symbol_kind(kind: &str) -> SymbolKind {
"EnumMember" => SymbolKind::ENUM_MEMBER,
"Constructor" => SymbolKind::CONSTRUCTOR,
"Function" => SymbolKind::FUNCTION,
+ "Method" => SymbolKind::METHOD,
+ "Key" => SymbolKind::KEY,
"Property" => SymbolKind::PROPERTY,
"Constant" => SymbolKind::CONSTANT,
"Variable" => SymbolKind::VARIABLE,
@@ -241,6 +245,9 @@ mod tests {
assert_eq!(parse_document_symbol_kind("Function"), SymbolKind::FUNCTION);
assert_eq!(parse_document_symbol_kind("Property"), SymbolKind::PROPERTY);
assert_eq!(parse_document_symbol_kind("Field"), SymbolKind::FIELD);
+ // Razor outline kinds: `@code` methods and directives. [RAZOR-FEATURES-STRUCTURE]
+ assert_eq!(parse_document_symbol_kind("Method"), SymbolKind::METHOD);
+ assert_eq!(parse_document_symbol_kind("Key"), SymbolKind::KEY);
}
#[test]
@@ -295,14 +302,14 @@ mod tests {
}
#[test]
- fn handle_fsharp_without_sidecar_errors() {
+ fn handle_via_sidecar_without_sidecar_errors() {
let req = Request {
id: lsp_server::RequestId::from(1),
method: "textDocument/documentSymbol".to_string(),
params: serde_json::Value::Null,
};
let runtime = tokio::runtime::Runtime::new().unwrap();
- // No sidecar → F# symbols cannot be computed → error (not empty).
- assert!(handle_fsharp(req, &runtime, None).is_err());
+ // No sidecar → F# or Razor symbols cannot be computed → error (not empty).
+ assert!(handle_via_sidecar(req, &runtime, None).is_err());
}
}
diff --git a/src/sharplsp/src/main.rs b/src/sharplsp/src/main.rs
index f409ef15..99ba9ab8 100644
--- a/src/sharplsp/src/main.rs
+++ b/src/sharplsp/src/main.rs
@@ -25,6 +25,7 @@ mod paths;
mod postfix_completion;
mod profiler;
mod pull_diagnostics;
+mod razor;
mod semantic;
mod semantic_tokens;
mod sidecar;
@@ -386,8 +387,14 @@ fn build_capabilities(client: &lsp_types::ClientCapabilities) -> ServerCapabilit
resolve_provider: Some(true),
// `.` must be advertised so editors auto-send textDocument/completion
// on member access (e.g. `this.`). Without it the popup never appears
- // after a dot. Applies to both C# and F# member access.
- trigger_characters: Some(vec![".".to_string()]),
+ // after a dot. Applies to both C# and F# member access. `<` and `@`
+ // complete Razor markup. [RAZOR-ARCHITECTURE-ROUTING]
+ trigger_characters: Some(
+ std::iter::once(".")
+ .chain(razor::MARKUP_TRIGGERS)
+ .map(str::to_string)
+ .collect(),
+ ),
..lsp_types::CompletionOptions::default()
}),
hover_provider: Some(HoverProviderCapability::Simple(true)),
@@ -802,15 +809,24 @@ fn handle_request(
// deliberately served by the sidecar's FCS items (richer symbol data
// than a syntax tree can give). The other syntax features —
// foldingRange, selectionRange, linkedEditingRange — run on the F#
- // tree-sitter grammar for both languages.
- // [SHARPLSP-FEATURES-NAVIGATION]
- DocumentSymbolRequest::METHOD => {
- if is_fsharp_request(&req) {
+ // tree-sitter grammar for both languages. Razor has no grammar, so the
+ // C# sidecar answers its syntax requests, and selection ranges are not
+ // served yet. [SHARPLSP-FEATURES-NAVIGATION] [RAZOR-ARCHITECTURE-ROUTING]
+ DocumentSymbolRequest::METHOD => match request_lang(&req) {
+ Some(LangId::FSharp | LangId::Razor) => {
let sidecar = pick_sidecar(&req, csharp_sidecar, fsharp_sidecar);
- document_symbols::handle_fsharp(req, runtime, sidecar)
- } else {
- handlers::handle_document_symbols(req, vfs, parsers, trees)
+ document_symbols::handle_via_sidecar(req, runtime, sidecar)
}
+ _ => handlers::handle_document_symbols(req, vfs, parsers, trees),
+ },
+ FoldingRangeRequest::METHOD if request_lang(&req) == Some(LangId::Razor) => {
+ razor::handle_folding_ranges(req, runtime, csharp_sidecar)
+ }
+ SelectionRangeRequest::METHOD if request_lang(&req) == Some(LangId::Razor) => {
+ Ok(serde_json::json!([]))
+ }
+ LinkedEditingRange::METHOD if request_lang(&req) == Some(LangId::Razor) => {
+ razor::handle_linked_editing_range(req, runtime, csharp_sidecar)
}
FoldingRangeRequest::METHOD => handlers::handle_folding_ranges(req, vfs, parsers, trees),
SelectionRangeRequest::METHOD => {
@@ -821,8 +837,14 @@ fn handle_request(
}
// Semantic (sidecar)
Completion::METHOD => {
- let sidecar = pick_sidecar(&req, csharp_sidecar, fsharp_sidecar);
- semantic::handle_completion(req, runtime, sidecar, vfs)
+ if serde_json::from_value(req.params.clone())
+ .is_ok_and(|params| razor::is_markup_trigger_outside_razor(¶ms))
+ {
+ Ok(serde_json::Value::Null)
+ } else {
+ let sidecar = pick_sidecar(&req, csharp_sidecar, fsharp_sidecar);
+ semantic::handle_completion(req, runtime, sidecar, vfs)
+ }
}
ResolveCompletionItem::METHOD => {
let sidecar = pick_sidecar(&req, csharp_sidecar, fsharp_sidecar);
@@ -1142,6 +1164,7 @@ fn handle_custom_request(
"sharplsp/sortMembers" => handle_sort_members(req, parsers, vfs),
"sharplsp/statementStop" => handle_statement_stop(req, parsers, vfs),
"sharplsp/hotReload" => hot_reload::handle(req, runtime, csharp_sidecar),
+ razor::HTML_PROJECTION => razor::handle_html_projection(req, runtime, csharp_sidecar),
// NuGet package management
"sharplsp/nuget/targets" => nuget::handlers::handle_targets(req),
"sharplsp/nuget/search" => nuget::handlers::handle_search(req, runtime),
@@ -1228,9 +1251,9 @@ fn extract_document_uri(req: &Request) -> Option {
.or_else(|| nested_uri(&req.params, "data"))
}
-/// Whether a request targets an F# document (by file extension).
-fn is_fsharp_request(req: &Request) -> bool {
- extract_document_uri(req).and_then(|uri| LangId::from_uri(&uri)) == Some(LangId::FSharp)
+/// The language of the document a request targets (by file extension).
+fn request_lang(req: &Request) -> Option {
+ extract_document_uri(req).and_then(|uri| LangId::from_uri(&uri))
}
/// Parse `params..uri` into a [`Uri`], if present and valid.
@@ -1342,7 +1365,7 @@ fn handle_standard_workspace_symbol(
let mut symbols = Vec::new();
for entry in vfs.iter() {
let uri = entry.key().clone();
- let Some(lang) = LangId::from_uri(&uri) else {
+ let Some(lang) = LangId::from_uri(&uri).filter(|lang| lang.has_grammar()) else {
continue;
};
if matches!(lang, LangId::FSharp) {
@@ -1664,7 +1687,7 @@ fn trigger_diagnostics(
/// (index out of bounds) for shorter content.
#[expect(clippy::mutable_key_type, reason = "see main_loop")]
fn reparse(parsers: &TsParsers, trees: &mut HashMap, uri: &Uri, source: &str) {
- let Some(lang) = LangId::from_uri(uri) else {
+ let Some(lang) = LangId::from_uri(uri).filter(|lang| lang.has_grammar()) else {
return;
};
match parsers.parse(lang, source, None) {
diff --git a/src/sharplsp/src/postfix_completion.rs b/src/sharplsp/src/postfix_completion.rs
index 4e6282b3..24eee8cf 100644
--- a/src/sharplsp/src/postfix_completion.rs
+++ b/src/sharplsp/src/postfix_completion.rs
@@ -250,6 +250,7 @@ pub fn get_postfix_completions(
let snippet = match lang {
LangId::CSharp => t.csharp_snippet,
LangId::FSharp => t.fsharp_snippet,
+ LangId::Razor => None,
}?;
let expanded = snippet.replace("{expr}", &expr_text);
@@ -345,6 +346,30 @@ mod tests {
assert!(text.contains("if (foo)"));
}
+ /// Postfix templates never expand in Razor files, where `foo.if` can be
+ /// markup text; C# there completes from the projection.
+ /// [RAZOR-FEATURES-COMPLETION]
+ #[test]
+ fn razor_documents_get_no_postfix_templates() {
+ assert!(
+ get_postfix_completions("foo.if", 0, 6, LangId::Razor).is_empty(),
+ "a C# template stays out of Razor"
+ );
+ assert!(
+ get_postfix_completions("xs.match", 0, 8, LangId::Razor).is_empty(),
+ "an F# template stays out of Razor"
+ );
+ assert!(
+ get_postfix_completions("items.", 0, 6, LangId::Razor).is_empty(),
+ "a bare dot lists no templates in Razor"
+ );
+ assert_eq!(
+ get_postfix_completions("foo.if", 0, 6, LangId::CSharp).len(),
+ 1,
+ "the same text in C# offers the template"
+ );
+ }
+
#[test]
fn generates_fsharp_match_postfix() {
let items = get_postfix_completions("xs.match", 0, 8, LangId::FSharp);
diff --git a/src/sharplsp/src/razor.rs b/src/sharplsp/src/razor.rs
new file mode 100644
index 00000000..7be98729
--- /dev/null
+++ b/src/sharplsp/src/razor.rs
@@ -0,0 +1,265 @@
+//! Razor documents (`.razor`, `.cshtml`) in the host.
+//!
+//! The host has no Razor grammar, so the syntax requests it answers from
+//! tree-sitter for C# go to the C# sidecar for Razor, whose Razor compiler is
+//! the parser. The two Razor completion triggers are useless in C# and F#
+//! documents, so those answer at once. Implements [RAZOR-ARCHITECTURE-ROUTING].
+
+use std::sync::Arc;
+
+use anyhow::Result;
+use lsp_server::Request;
+use lsp_types::{
+ CompletionParams, FoldingRange, FoldingRangeParams, LinkedEditingRangeParams,
+ LinkedEditingRanges, Position, Range, TextDocumentIdentifier, Uri,
+};
+
+use crate::semantic::SidecarLocationListResult;
+use crate::sidecar::manager::SidecarManager;
+use crate::tree_sitter_parse::LangId;
+use crate::utils::{SidecarFileReq, SidecarPositionReq};
+
+/// Completion trigger characters that only Razor markup uses.
+pub const MARKUP_TRIGGERS: [&str; 2] = ["<", "@"];
+
+/// The request that answers a Razor document's HTML projection.
+pub const HTML_PROJECTION: &str = "sharplsp/razor/htmlProjection";
+
+/// Params of [`HTML_PROJECTION`]: the Razor document.
+#[derive(serde::Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct HtmlProjectionParams {
+ /// The Razor document to project.
+ text_document: TextDocumentIdentifier,
+}
+
+/// Whether `uri` names a Razor document.
+pub fn is_razor(uri: &Uri) -> bool {
+ LangId::from_uri(uri) == Some(LangId::Razor)
+}
+
+/// Whether a completion request was triggered by a Razor-only character in a
+/// document that is not Razor, so it can answer `null` without a sidecar.
+pub fn is_markup_trigger_outside_razor(params: &CompletionParams) -> bool {
+ let triggered_by_markup = params
+ .context
+ .as_ref()
+ .and_then(|context| context.trigger_character.as_deref())
+ .is_some_and(|character| MARKUP_TRIGGERS.contains(&character));
+ triggered_by_markup && !is_razor(¶ms.text_document_position.text_document.uri)
+}
+
+/// Handle `textDocument/foldingRange` for a Razor document via the C# sidecar.
+pub fn handle_folding_ranges(
+ req: Request,
+ runtime: &tokio::runtime::Runtime,
+ sidecar: Option<&Arc>,
+) -> Result {
+ let params: FoldingRangeParams = serde_json::from_value(req.params)?;
+ let request = file_request(¶ms.text_document.uri)?;
+ let folds: Vec<(u32, u32)> = fetch(runtime, sidecar, "textDocument/foldingRange", &request)?;
+ let ranges: Vec = folds.into_iter().map(to_folding_range).collect();
+ Ok(serde_json::to_value(ranges)?)
+}
+
+/// Handle [`HTML_PROJECTION`] via the C# sidecar: `{ text }`, the document's
+/// text with every non-HTML character blanked, or `null` when no project
+/// lists the document. Implements [RAZOR-ARCHITECTURE-HTML].
+pub fn handle_html_projection(
+ req: Request,
+ runtime: &tokio::runtime::Runtime,
+ sidecar: Option<&Arc>,
+) -> Result {
+ let params: HtmlProjectionParams = serde_json::from_value(req.params)?;
+ let request = file_request(¶ms.text_document.uri)?;
+ let projection: Option<(String,)> = fetch(runtime, sidecar, HTML_PROJECTION, &request)?;
+ Ok(projection_answer(projection))
+}
+
+/// The wire answer for a sidecar projection: `{ text }`, or `null` for none.
+fn projection_answer(projection: Option<(String,)>) -> serde_json::Value {
+ projection.map_or(
+ serde_json::Value::Null,
+ |(text,)| serde_json::json!({ "text": text }),
+ )
+}
+
+/// The sidecar request naming the document at `uri`.
+fn file_request(uri: &Uri) -> Result {
+ Ok(SidecarFileReq {
+ file_path: crate::paths::uri_to_path(uri.as_str())?,
+ })
+}
+
+/// Handle `textDocument/linkedEditingRange` for a Razor document via the C#
+/// sidecar: the start and end tag names of the element at the position.
+pub fn handle_linked_editing_range(
+ req: Request,
+ runtime: &tokio::runtime::Runtime,
+ sidecar: Option<&Arc>,
+) -> Result {
+ let params: LinkedEditingRangeParams = serde_json::from_value(req.params)?;
+ let at = ¶ms.text_document_position_params;
+ let request = SidecarPositionReq::at(&at.text_document.uri, at.position)?;
+ let found: SidecarLocationListResult = fetch(
+ runtime,
+ sidecar,
+ "textDocument/linkedEditingRange",
+ &request,
+ )?;
+ linked_ranges(&found)
+}
+
+/// The linked ranges of a sidecar answer, or `null` when it found none.
+fn linked_ranges(found: &SidecarLocationListResult) -> Result {
+ let ranges: Vec = found.locations.iter().map(to_range).collect();
+ Ok(if ranges.is_empty() {
+ serde_json::Value::Null
+ } else {
+ serde_json::to_value(LinkedEditingRanges {
+ ranges,
+ word_pattern: None,
+ })?
+ })
+}
+
+/// Send one request to the sidecar that owns Razor and decode its answer.
+fn fetch(
+ runtime: &tokio::runtime::Runtime,
+ sidecar: Option<&Arc>,
+ method: &str,
+ request: &impl serde::Serialize,
+) -> Result {
+ let Some(sidecar) = sidecar else {
+ anyhow::bail!("C# sidecar unavailable; cannot answer {method} for a Razor document");
+ };
+ let payload = rmp_serde::to_vec(request)?;
+ let bytes = runtime.block_on(sidecar.request(method, payload))?;
+ Ok(rmp_serde::from_slice(&bytes)?)
+}
+
+/// A sidecar fold `(start_line, end_line)` as an LSP folding range.
+const fn to_folding_range((start_line, end_line): (u32, u32)) -> FoldingRange {
+ FoldingRange {
+ start_line,
+ start_character: None,
+ end_line,
+ end_character: None,
+ kind: None,
+ collapsed_text: None,
+ }
+}
+
+/// A sidecar location's range.
+fn to_range(location: &crate::semantic::SidecarLocationResult) -> Range {
+ Range::new(
+ Position::new(location.line, location.character),
+ Position::new(location.end_line, location.end_character),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ #![expect(
+ clippy::expect_used,
+ reason = "test code — panics are the correct failure mode"
+ )]
+ use super::*;
+
+ fn completion(uri: &str, trigger: Option<&str>) -> CompletionParams {
+ serde_json::from_value(serde_json::json!({
+ "textDocument": { "uri": uri },
+ "position": { "line": 0, "character": 1 },
+ "context": { "triggerKind": 2, "triggerCharacter": trigger },
+ }))
+ .expect("valid completion params")
+ }
+
+ /// `<` and `@` complete markup only in Razor documents; C# and F#
+ /// documents answer at once. [RAZOR-ARCHITECTURE-ROUTING]
+ #[test]
+ fn markup_triggers_short_circuit_only_outside_razor() {
+ assert!(is_markup_trigger_outside_razor(&completion(
+ "file:///a/Program.cs",
+ Some("<")
+ )));
+ assert!(is_markup_trigger_outside_razor(&completion(
+ "file:///a/Lib.fs",
+ Some("@")
+ )));
+ assert!(!is_markup_trigger_outside_razor(&completion(
+ "file:///a/Board.razor",
+ Some("<")
+ )));
+ assert!(!is_markup_trigger_outside_razor(&completion(
+ "file:///a/Index.cshtml",
+ Some("@")
+ )));
+ assert!(!is_markup_trigger_outside_razor(&completion(
+ "file:///a/Program.cs",
+ Some(".")
+ )));
+ assert!(!is_markup_trigger_outside_razor(&completion(
+ "file:///a/Program.cs",
+ None
+ )));
+ }
+
+ /// A projection answers `{ text }`; a document no project lists answers
+ /// `null`. [RAZOR-ARCHITECTURE-HTML]
+ #[test]
+ fn projections_answer_text_or_null() {
+ assert_eq!(
+ projection_answer(Some(("
".to_string(),))),
+ serde_json::json!({ "text": "
" })
+ );
+ assert_eq!(projection_answer(None), serde_json::Value::Null);
+ }
+
+ /// Without the C# sidecar the projection request fails and names itself,
+ /// rather than answering an empty projection.
+ #[test]
+ fn html_projection_without_sidecar_errors() {
+ let req = Request::new(
+ 1.into(),
+ HTML_PROJECTION.into(),
+ serde_json::json!({ "textDocument": { "uri": "file:///a/Board.razor" } }),
+ );
+ let runtime = tokio::runtime::Runtime::new().expect("runtime");
+ let error = handle_html_projection(req, &runtime, None).expect_err("no sidecar");
+ assert!(error.to_string().contains(HTML_PROJECTION), "{error}");
+ }
+
+ /// Without the C# sidecar the structure requests fail and name themselves,
+ /// rather than answering that a file has no folds or linked tags.
+ /// [RAZOR-FEATURES-STRUCTURE]
+ #[test]
+ fn structure_requests_without_sidecar_error() {
+ let runtime = tokio::runtime::Runtime::new().expect("runtime");
+ let board = serde_json::json!({ "uri": "file:///a/Board.razor" });
+ let linked = Request::new(
+ 1.into(),
+ "textDocument/linkedEditingRange".into(),
+ serde_json::json!({ "textDocument": board, "position": { "line": 6, "character": 2 } }),
+ );
+ let error = handle_linked_editing_range(linked, &runtime, None).expect_err("no sidecar");
+ assert!(error.to_string().contains("linkedEditingRange"), "{error}");
+
+ let folds = Request::new(
+ 2.into(),
+ "textDocument/foldingRange".into(),
+ serde_json::json!({ "textDocument": board }),
+ );
+ let error = handle_folding_ranges(folds, &runtime, None).expect_err("no sidecar");
+ assert!(error.to_string().contains("foldingRange"), "{error}");
+ }
+
+ /// A sidecar fold keeps its lines and claims no characters or kind.
+ #[test]
+ fn sidecar_folds_become_line_folding_ranges() {
+ let fold = to_folding_range((6, 9));
+ assert_eq!((fold.start_line, fold.end_line), (6, 9));
+ assert_eq!((fold.start_character, fold.end_character), (None, None));
+ assert!(fold.kind.is_none());
+ }
+}
diff --git a/src/sharplsp/src/semantic.rs b/src/sharplsp/src/semantic.rs
index 4070e504..d7c01b71 100644
--- a/src/sharplsp/src/semantic.rs
+++ b/src/sharplsp/src/semantic.rs
@@ -752,24 +752,24 @@ struct SidecarHoverResult {
/// A single location result from the sidecar (definition, references, etc.).
#[derive(serde::Deserialize)]
-struct SidecarLocationResult {
+pub(crate) struct SidecarLocationResult {
/// Absolute filesystem path of the target file.
file_path: String,
/// Start line of the target range.
- line: u32,
+ pub(crate) line: u32,
/// Start character of the target range.
- character: u32,
+ pub(crate) character: u32,
/// End line of the target range.
- end_line: u32,
+ pub(crate) end_line: u32,
/// End character of the target range.
- end_character: u32,
+ pub(crate) end_character: u32,
}
/// Sidecar response containing a list of locations.
#[derive(serde::Deserialize)]
-struct SidecarLocationListResult {
+pub(crate) struct SidecarLocationListResult {
/// The resolved locations.
- locations: Vec,
+ pub(crate) locations: Vec,
}
/// Sidecar request for find-references at a given position.
diff --git a/src/sharplsp/src/tree_sitter_parse.rs b/src/sharplsp/src/tree_sitter_parse.rs
index da0ae1dd..14021dd4 100644
--- a/src/sharplsp/src/tree_sitter_parse.rs
+++ b/src/sharplsp/src/tree_sitter_parse.rs
@@ -15,6 +15,10 @@ pub enum LangId {
CSharp,
/// F# (.fs, .fsx, .fsi files).
FSharp,
+ /// Razor (.razor components, .cshtml pages and views). The C# sidecar owns
+ /// it, and its Razor compiler is the only parser: there is no tree-sitter
+ /// Razor grammar. Implements [RAZOR-ARCHITECTURE-ROUTING].
+ Razor,
}
impl LangId {
@@ -36,9 +40,16 @@ impl LangId {
match crate::paths::extension_key(path)?.as_str() {
"CS" => Some(Self::CSharp),
"FS" | "FSX" | "FSI" => Some(Self::FSharp),
+ "RAZOR" | "CSHTML" => Some(Self::Razor),
_ => None,
}
}
+
+ /// Whether the host parses this language with tree-sitter. Razor has no
+ /// grammar, so its syntax features come from the sidecar instead.
+ pub const fn has_grammar(self) -> bool {
+ !matches!(self, Self::Razor)
+ }
}
/// Manages tree-sitter parsers and parse trees.
@@ -74,6 +85,7 @@ impl TsParsers {
.set_language(&self.fsharp_language)
.context("failed to set F# language")?;
}
+ LangId::Razor => anyhow::bail!("Razor has no tree-sitter grammar"),
}
parser
.parse(source, old_tree)
@@ -117,4 +129,25 @@ mod tests {
let unknown: Uri = "file:///c:/dir/readme.txt".parse().expect("valid uri");
assert_eq!(LangId::from_uri(&unknown), None);
}
+
+ /// Razor components and Razor pages and views are one language, owned by the
+ /// C# sidecar and never parsed by tree-sitter. [RAZOR-ARCHITECTURE-ROUTING]
+ #[test]
+ fn razor_and_cshtml_files_are_razor_without_a_grammar() {
+ let component: Uri = "file:///c:/app/Pages/Counter.razor"
+ .parse()
+ .expect("valid uri");
+ let view: Uri = "file:///c:/app/Views/Home/Index.CSHTML"
+ .parse()
+ .expect("valid uri");
+ assert_eq!(LangId::from_uri(&component), Some(LangId::Razor));
+ assert_eq!(LangId::from_uri(&view), Some(LangId::Razor));
+ assert!(!LangId::Razor.has_grammar());
+ assert!(LangId::CSharp.has_grammar() && LangId::FSharp.has_grammar());
+ let parsed = TsParsers::new().parse(LangId::Razor, "@x
", None);
+ assert!(
+ parsed.is_err(),
+ "Razor must never reach a tree-sitter parser"
+ );
+ }
}
diff --git a/src/sharplsp/tests/e2e_modules/fixtures_razor.rs b/src/sharplsp/tests/e2e_modules/fixtures_razor.rs
new file mode 100644
index 00000000..36ab569d
--- /dev/null
+++ b/src/sharplsp/tests/e2e_modules/fixtures_razor.rs
@@ -0,0 +1,77 @@
+//! The Razor fixture solution (`tests/fixtures/razor`): a Blazor app, a Razor
+//! class library, a Razor Pages and MVC app and an F# library, copied to a
+//! temp directory and restored so the SDK's Razor generator runs in the
+//! sidecar's design-time build. [RAZOR-TESTING]
+
+use super::*;
+use std::path::{Path, PathBuf};
+
+/// A restored copy of the Razor fixture solution.
+pub struct RazorWorkspace {
+ /// Owns the temp directory; dropping it deletes the copy.
+ pub dir: tempfile::TempDir,
+}
+
+impl RazorWorkspace {
+ /// The copy's root directory, which holds `RazorFixtures.slnx`.
+ pub fn root(&self) -> &Path {
+ self.dir.path()
+ }
+
+ /// A file in the copy, by its path segments under the root.
+ pub fn file(&self, segments: &[&str]) -> PathBuf {
+ segments
+ .iter()
+ .fold(self.root().to_path_buf(), |path, segment| {
+ path.join(segment)
+ })
+ }
+
+ /// The `vscode`-shaped URI of a file in the copy.
+ pub fn uri(&self, segments: &[&str]) -> String {
+ path_to_vscode_uri(&self.file(segments))
+ }
+
+ /// The text of a file in the copy.
+ pub fn text(&self, segments: &[&str]) -> String {
+ std::fs::read_to_string(self.file(segments)).expect("read Razor fixture file")
+ }
+}
+
+/// Copy the Razor fixture solution to a temp directory, with the repository's SDK
+/// pin, and restore it.
+pub fn create_razor_workspace() -> RazorWorkspace {
+ let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/razor");
+ let dir = tempfile::tempdir().expect("create Razor temp dir");
+ copy_fixture_tree(&source, dir.path());
+ keep_sdk_pin(dir.path());
+ restore_project(dir.path());
+ RazorWorkspace { dir }
+}
+
+/// Pin `destination` to the SDK the repository builds with. A copy under the temp
+/// root has no `global.json` above it, so it would restore and load with the newest
+/// installed SDK, whose Razor generator can need a newer Roslyn than the sidecar
+/// bundles. [RAZOR-TESTING]
+fn keep_sdk_pin(destination: &Path) {
+ let pin = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../global.json");
+ let _ = std::fs::copy(&pin, destination.join("global.json"))
+ .expect("copy the repository's global.json into the Razor fixture");
+}
+
+/// Copy a fixture tree, leaving out build output (`bin`, `obj`).
+fn copy_fixture_tree(source: &Path, destination: &Path) {
+ std::fs::create_dir_all(destination).expect("create fixture directory");
+ for entry in std::fs::read_dir(source).expect("read fixture directory") {
+ let entry = entry.expect("read fixture entry");
+ let name = entry.file_name();
+ let target = destination.join(&name);
+ if entry.file_type().expect("fixture entry type").is_dir() {
+ if name != "bin" && name != "obj" {
+ copy_fixture_tree(&entry.path(), &target);
+ }
+ } else {
+ let _ = std::fs::copy(entry.path(), &target).expect("copy fixture file");
+ }
+ }
+}
diff --git a/src/sharplsp/tests/e2e_modules/mod.rs b/src/sharplsp/tests/e2e_modules/mod.rs
index d8c0cb5c..40451186 100644
--- a/src/sharplsp/tests/e2e_modules/mod.rs
+++ b/src/sharplsp/tests/e2e_modules/mod.rs
@@ -35,6 +35,7 @@ pub use built_binary::built_sharplsp;
pub mod fixtures;
pub mod fixtures_cross_language;
pub mod fixtures_medium;
+pub mod fixtures_razor;
pub mod nav_helpers;
pub mod session_helpers;
@@ -66,6 +67,7 @@ pub mod multi_solution;
pub mod nuget_unused_full_stack;
pub mod profiler_shared;
pub mod pull_diagnostics;
+pub mod razor;
pub mod references;
pub mod selection;
pub mod semantic_coverage;
@@ -89,6 +91,7 @@ pub mod workspace_symbols_selection;
pub use fixtures::*;
pub use fixtures_cross_language::*;
pub use fixtures_medium::*;
+pub use fixtures_razor::*;
pub use nav_helpers::*;
pub use session_helpers::*;
diff --git a/src/sharplsp/tests/e2e_modules/razor.rs b/src/sharplsp/tests/e2e_modules/razor.rs
new file mode 100644
index 00000000..90b34ec1
--- /dev/null
+++ b/src/sharplsp/tests/e2e_modules/razor.rs
@@ -0,0 +1,223 @@
+//! Full-stack Razor session against the real fixture solution: the host
+//! routes a `.razor` file to the C# sidecar, forwards the syntax requests it
+//! cannot parse, short-circuits markup triggers elsewhere, and publishes
+//! Razor diagnostics under their own source. [RAZOR-ARCHITECTURE-ROUTING]
+
+use super::*;
+
+/// `Board.razor`, the fixture's richest component.
+const BOARD: &[&str] = &["BlazorApp", "Components", "Pages", "Board.razor"];
+
+/// `SymbolKind.Class` and `SymbolKind.Key` on the wire.
+const CLASS: u64 = 5;
+const KEY: u64 = 20;
+
+#[test]
+fn test_full_stack_razor_routing_session() {
+ require_dotnet();
+ let ws = create_razor_workspace();
+ let mut client = LspClient::start_verbose();
+ let init = client.initialize_with_root(json!(path_to_file_uri(ws.root())));
+ let triggers = &init["result"]["capabilities"]["completionProvider"]["triggerCharacters"];
+ assert_eq!(
+ triggers,
+ &json!([".", "<", "@"]),
+ "Razor triggers advertised"
+ );
+
+ let board = ws.uri(BOARD);
+ let board_text = ws.text(BOARD);
+ client.open_document(&board, &board_text);
+
+ // ── Readiness: a C# fragment's hover arrives through the projection ──
+ let hover = poll_hover_until_ready(&mut client, &board, 11, 16, Duration::from_mins(3));
+ let markdown = hover["contents"]["value"].as_str().unwrap_or_default();
+ assert!(
+ markdown.contains("caption") && markdown.contains("string"),
+ "hover on `caption` in `@bind` must show the field: {markdown}"
+ );
+
+ assert_syntax_requests_reach_the_sidecar(&mut client, &board);
+ assert_html_projection(&mut client, &ws, &board, &board_text);
+ assert_markup_triggers_only_complete_razor(&mut client, &ws);
+ assert_diagnostics_publish_as_razor(&mut client, &board, &board_text);
+
+ client.shutdown_and_exit();
+ client.wait_with_timeout();
+}
+
+/// documentSymbol, foldingRange and linkedEditingRange come from the sidecar's
+/// Razor compiler; selectionRange answers empty. [RAZOR-FEATURES-STRUCTURE]
+fn assert_syntax_requests_reach_the_sidecar(client: &mut LspClient, board: &str) {
+ let symbols = document_request(client, "textDocument/documentSymbol", board);
+ assert_rpc_ok(&symbols, "documentSymbol(Board.razor)");
+ let names: Vec<&str> = symbols["result"]
+ .as_array()
+ .expect("nested symbols")
+ .iter()
+ .filter_map(|symbol| symbol["name"].as_str())
+ .collect();
+ assert_eq!(
+ names,
+ [
+ "@page",
+ "@implements",
+ "@inject",
+ "PageTitle",
+ "Panel",
+ "input",
+ "button",
+ "@code"
+ ]
+ );
+ let panel = find_symbol(&symbols["result"], "Panel").expect("Panel symbol");
+ assert_eq!(panel["kind"], CLASS);
+ assert_eq!(
+ panel["selectionRange"]["start"],
+ json!({ "line": 6, "character": 1 })
+ );
+ assert_eq!(
+ find_symbol(&symbols["result"], "@code").expect("@code")["kind"],
+ KEY
+ );
+ assert!(
+ find_symbol(&panel["children"], "Badge").is_some(),
+ "Badge nests in Tally"
+ );
+ assert_eq!(
+ find_symbol(&symbols["result"], "Add").expect("Add")["kind"],
+ 6
+ );
+
+ let folds = document_request(client, "textDocument/foldingRange", board);
+ assert_rpc_ok(&folds, "foldingRange(Board.razor)");
+ let lines: Vec<(u64, u64)> = folds["result"]
+ .as_array()
+ .expect("folding ranges")
+ .iter()
+ .map(|fold| {
+ (
+ fold["startLine"].as_u64().unwrap(),
+ fold["endLine"].as_u64().unwrap(),
+ )
+ })
+ .collect();
+ assert_eq!(lines, [(6, 9), (7, 8), (14, 24)]);
+
+ let linked = position_request(client, "textDocument/linkedEditingRange", board, (6, 3));
+ assert_rpc_ok(&linked, "linkedEditingRange(>();
+ assert_eq!(widths(html), widths(text), "every line keeps its width");
+ let lines: Vec<&str> = html.lines().collect();
+ assert_eq!(
+ lines[4], "Board ",
+ "`@Id` is blanked"
+ );
+ assert!(lines[6].starts_with("");
+ assert!(
+ lines[0].trim().is_empty() && lines[14].trim().is_empty(),
+ "`@page` and `@code {{` are blanked"
+ );
+
+ let program = ws.uri(&["BlazorApp", "Program.cs"]);
+ let none = document_request(client, "sharplsp/razor/htmlProjection", &program);
+ assert_rpc_ok(&none, "htmlProjection(Program.cs)");
+ assert_eq!(none["result"], Value::Null, "a C# file has no projection");
+}
+
+/// `<` and `@` complete in Razor but answer `null` at once in C#.
+fn assert_markup_triggers_only_complete_razor(client: &mut LspClient, ws: &RazorWorkspace) {
+ let program = ws.uri(&["BlazorApp", "Program.cs"]);
+ client.open_document(&program, &ws.text(&["BlazorApp", "Program.cs"]));
+ for trigger in ["<", "@"] {
+ let completion = client.request(
+ "textDocument/completion",
+ json!({
+ "textDocument": { "uri": program },
+ "position": { "line": 0, "character": 0 },
+ "context": { "triggerKind": 2, "triggerCharacter": trigger }
+ }),
+ );
+ assert_rpc_ok(&completion, "completion(Program.cs, markup trigger)");
+ assert_eq!(
+ completion["result"],
+ Value::Null,
+ "`{trigger}` in C# answers null"
+ );
+ }
+ let counter = ws.uri(&["BlazorApp", "Components", "Pages", "Counter.razor"]);
+ client.open_document(
+ &counter,
+ &ws.text(&["BlazorApp", "Components", "Pages", "Counter.razor"]),
+ );
+ let at = client.request(
+ "textDocument/completion",
+ json!({
+ "textDocument": { "uri": counter },
+ "position": { "line": 0, "character": 1 },
+ "context": { "triggerKind": 2, "triggerCharacter": "@" }
+ }),
+ );
+ assert_rpc_ok(&at, "completion(Counter.razor, @)");
+}
+
+/// A C# error in a Razor file publishes under `sharplsp-razor`, and clears.
+fn assert_diagnostics_publish_as_razor(client: &mut LspClient, board: &str, text: &str) {
+ let broken = text.replace("score + Id)", "score + Idd)");
+ client.change_document(board, 2, &broken);
+ let errors = poll_error_diagnostics_until(
+ client,
+ board,
+ "diagnostics(Board.razor)",
+ Duration::from_mins(2),
+ |errors| errors.iter().any(|d| d["code"] == "CS0103"),
+ );
+ let unknown = errors
+ .iter()
+ .find(|d| d["code"] == "CS0103")
+ .expect("CS0103");
+ assert_eq!(unknown["source"], "sharplsp-razor");
+ assert_eq!(unknown["range"]["start"]["line"], 22);
+ assert!(unknown["message"]
+ .as_str()
+ .unwrap_or_default()
+ .contains("Idd"));
+
+ client.change_document(board, 3, text);
+ let _ = poll_error_diagnostics_until(
+ client,
+ board,
+ "diagnostics(Board.razor) after the fix",
+ Duration::from_mins(2),
+ <[Value]>::is_empty,
+ );
+}
diff --git a/src/sharplsp/tests/fixtures/razor/.editorconfig b/src/sharplsp/tests/fixtures/razor/.editorconfig
new file mode 100644
index 00000000..dc74dd92
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/.editorconfig
@@ -0,0 +1,4 @@
+# Stops the repository's .editorconfig, which raises every analyzer diagnostic to an
+# error, from reaching the Razor fixtures. With Directory.Build.props they build as a
+# user's projects would. [RAZOR-TESTING]
+root = true
diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/BlazorApp.csproj b/src/sharplsp/tests/fixtures/razor/BlazorApp/BlazorApp.csproj
new file mode 100644
index 00000000..8d76db6b
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/BlazorApp.csproj
@@ -0,0 +1,14 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/App.razor b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/App.razor
new file mode 100644
index 00000000..3b7bb3e1
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/App.razor
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Pages/Board.razor b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Pages/Board.razor
new file mode 100644
index 00000000..622811d0
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Pages/Board.razor
@@ -0,0 +1,26 @@
+@page "/board/{Id:int}"
+@implements IDisposable
+@inject NavigationManager Navigation
+
+Board @Id
+
+
+
+
+
+
+
+ Add(1)" disabled="@(scores.Count > 9)">Add
+
+@code {
+ /// The board's number, from the route.
+ [Parameter] public int Id { get; set; }
+
+ private readonly List scores = [];
+ private string caption = "Board";
+ private Tally? tally;
+
+ private void Add(int score) => scores.Add(score + Id);
+
+ public void Dispose() => scores.Clear();
+}
diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Pages/Counter.razor b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Pages/Counter.razor
new file mode 100644
index 00000000..0d9d43ad
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Pages/Counter.razor
@@ -0,0 +1,19 @@
+@page "/counter"
+@rendermode InteractiveServer
+
+Counter
+
+Counter
+
+Current count: @currentCount
+
+Click me
+
+@code {
+ private int currentCount = 0;
+
+ private void IncrementCount()
+ {
+ currentCount++;
+ }
+}
diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Pages/Leaders.razor b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Pages/Leaders.razor
new file mode 100644
index 00000000..46d61fd9
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Pages/Leaders.razor
@@ -0,0 +1,14 @@
+@page "/leaders"
+@using Domain
+
+
+ @foreach (var player in players)
+ {
+ @player.Name: @player.Score
+ }
+
+Total: @Scoring.total(players)
+
+@code {
+ private readonly Player[] players = [new Player("Ada", 3), new Player("Grace", 5)];
+}
diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Routes.razor b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Routes.razor
new file mode 100644
index 00000000..06570ce6
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Routes.razor
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Badge.razor b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Badge.razor
new file mode 100644
index 00000000..98c2475f
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Badge.razor
@@ -0,0 +1 @@
+@Text
diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Badge.razor.cs b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Badge.razor.cs
new file mode 100644
index 00000000..e77a66dd
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Badge.razor.cs
@@ -0,0 +1,10 @@
+using Microsoft.AspNetCore.Components;
+
+namespace BlazorApp.Components.Shared;
+
+/// A small label beside a tally.
+public partial class Badge
+{
+ /// The badge's text.
+ [Parameter] public string Text { get; set; } = "";
+}
diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Tally.razor b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Tally.razor
new file mode 100644
index 00000000..9b9c2045
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Tally.razor
@@ -0,0 +1,27 @@
+@typeparam TItem
+
+
+ @foreach (var item in Items)
+ {
+ @Format(item)
+ }
+
+@Label
+@ChildContent
+
+@code {
+ /// The items to tally.
+ [Parameter, EditorRequired] public IReadOnlyList Items { get; set; } = [];
+
+ /// How each item renders.
+ [Parameter] public Func Format { get; set; } = item => item?.ToString() ?? "";
+
+ /// The tally's caption.
+ [Parameter] public string Label { get; set; } = "";
+
+ /// Raised when the caption changes.
+ [Parameter] public EventCallback LabelChanged { get; set; }
+
+ /// Content under the tally.
+ [Parameter] public RenderFragment? ChildContent { get; set; }
+}
diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/_Imports.razor b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/_Imports.razor
new file mode 100644
index 00000000..27a50d73
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/_Imports.razor
@@ -0,0 +1,6 @@
+@using Microsoft.AspNetCore.Components.Forms
+@using Microsoft.AspNetCore.Components.Routing
+@using Microsoft.AspNetCore.Components.Web
+@using static Microsoft.AspNetCore.Components.Web.RenderMode
+@using BlazorApp.Components.Shared
+@using Widgets
diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Program.cs b/src/sharplsp/tests/fixtures/razor/BlazorApp/Program.cs
new file mode 100644
index 00000000..965fe901
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Program.cs
@@ -0,0 +1,9 @@
+using BlazorApp.Components;
+
+var builder = WebApplication.CreateBuilder(args);
+builder.Services.AddRazorComponents().AddInteractiveServerComponents();
+
+var app = builder.Build();
+app.UseAntiforgery();
+app.MapRazorComponents().AddInteractiveServerRenderMode();
+app.Run();
diff --git a/src/sharplsp/tests/fixtures/razor/Directory.Build.props b/src/sharplsp/tests/fixtures/razor/Directory.Build.props
new file mode 100644
index 00000000..37008cbc
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/Directory.Build.props
@@ -0,0 +1,5 @@
+
+
+
diff --git a/src/sharplsp/tests/fixtures/razor/Directory.Build.targets b/src/sharplsp/tests/fixtures/razor/Directory.Build.targets
new file mode 100644
index 00000000..05d7b527
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/Directory.Build.targets
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/sharplsp/tests/fixtures/razor/Domain/Domain.fsproj b/src/sharplsp/tests/fixtures/razor/Domain/Domain.fsproj
new file mode 100644
index 00000000..d3ff295d
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/Domain/Domain.fsproj
@@ -0,0 +1,12 @@
+
+
+
+ net10.0
+ true
+
+
+
+
+
+
+
diff --git a/src/sharplsp/tests/fixtures/razor/Domain/Scores.fs b/src/sharplsp/tests/fixtures/razor/Domain/Scores.fs
new file mode 100644
index 00000000..11578364
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/Domain/Scores.fs
@@ -0,0 +1,9 @@
+namespace Domain
+
+/// A player on the leader board.
+type Player = { Name: string; Score: int }
+
+/// Scoring rules for the leader board.
+module Scoring =
+ /// The sum of every player's score.
+ let total (players: seq) = players |> Seq.sumBy (fun player -> player.Score)
diff --git a/src/sharplsp/tests/fixtures/razor/PagesApp/Controllers/HomeController.cs b/src/sharplsp/tests/fixtures/razor/PagesApp/Controllers/HomeController.cs
new file mode 100644
index 00000000..ad308667
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/PagesApp/Controllers/HomeController.cs
@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Mvc;
+using PagesApp.Models;
+
+namespace PagesApp.Controllers;
+
+/// Serves the home page.
+public class HomeController : Controller
+{
+ /// Shows the featured item.
+ public IActionResult Index() => View(new Item("Lamp", 12.5m));
+}
diff --git a/src/sharplsp/tests/fixtures/razor/PagesApp/Models/Item.cs b/src/sharplsp/tests/fixtures/razor/PagesApp/Models/Item.cs
new file mode 100644
index 00000000..de185bb1
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/PagesApp/Models/Item.cs
@@ -0,0 +1,4 @@
+namespace PagesApp.Models;
+
+/// An item the home page shows.
+public record Item(string Name, decimal Price);
diff --git a/src/sharplsp/tests/fixtures/razor/PagesApp/Pages/Edit.cshtml b/src/sharplsp/tests/fixtures/razor/PagesApp/Pages/Edit.cshtml
new file mode 100644
index 00000000..f1eafe4e
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/PagesApp/Pages/Edit.cshtml
@@ -0,0 +1,15 @@
+@page "{id:int}"
+@model EditModel
+@inject ILogger Log
+
+
+@Shout(Model.Title)
+
+@functions {
+ string Shout(string text) => text.ToUpperInvariant();
+}
diff --git a/src/sharplsp/tests/fixtures/razor/PagesApp/Pages/Edit.cshtml.cs b/src/sharplsp/tests/fixtures/razor/PagesApp/Pages/Edit.cshtml.cs
new file mode 100644
index 00000000..fd292929
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/PagesApp/Pages/Edit.cshtml.cs
@@ -0,0 +1,18 @@
+using System.ComponentModel.DataAnnotations;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+
+namespace PagesApp.Pages;
+
+/// Edits one item.
+public class EditModel : PageModel
+{
+ /// The item's id.
+ public int Id { get; set; }
+
+ /// The item's title.
+ [Required]
+ public string Title { get; set; } = "";
+
+ /// Loads the item.
+ public void OnGet(int id) => Id = id;
+}
diff --git a/src/sharplsp/tests/fixtures/razor/PagesApp/Pages/_ViewImports.cshtml b/src/sharplsp/tests/fixtures/razor/PagesApp/Pages/_ViewImports.cshtml
new file mode 100644
index 00000000..708c8ee1
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/PagesApp/Pages/_ViewImports.cshtml
@@ -0,0 +1,3 @@
+@using PagesApp
+@namespace PagesApp.Pages
+@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
diff --git a/src/sharplsp/tests/fixtures/razor/PagesApp/PagesApp.csproj b/src/sharplsp/tests/fixtures/razor/PagesApp/PagesApp.csproj
new file mode 100644
index 00000000..a3a34b64
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/PagesApp/PagesApp.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/src/sharplsp/tests/fixtures/razor/PagesApp/Program.cs b/src/sharplsp/tests/fixtures/razor/PagesApp/Program.cs
new file mode 100644
index 00000000..2e184d93
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/PagesApp/Program.cs
@@ -0,0 +1,8 @@
+var builder = WebApplication.CreateBuilder(args);
+builder.Services.AddRazorPages();
+builder.Services.AddControllersWithViews();
+
+var app = builder.Build();
+app.MapRazorPages();
+app.MapDefaultControllerRoute();
+app.Run();
diff --git a/src/sharplsp/tests/fixtures/razor/PagesApp/Views/Home/Index.cshtml b/src/sharplsp/tests/fixtures/razor/PagesApp/Views/Home/Index.cshtml
new file mode 100644
index 00000000..371b4305
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/PagesApp/Views/Home/Index.cshtml
@@ -0,0 +1,8 @@
+@model Item
+@{
+ ViewData["Title"] = Model.Name;
+}
+
+@Model.Name
+@Model.Price.ToString("C")
+Home
diff --git a/src/sharplsp/tests/fixtures/razor/PagesApp/Views/_ViewImports.cshtml b/src/sharplsp/tests/fixtures/razor/PagesApp/Views/_ViewImports.cshtml
new file mode 100644
index 00000000..4f8ab5b7
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/PagesApp/Views/_ViewImports.cshtml
@@ -0,0 +1,2 @@
+@using PagesApp.Models
+@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
diff --git a/src/sharplsp/tests/fixtures/razor/RazorFixtures.slnx b/src/sharplsp/tests/fixtures/razor/RazorFixtures.slnx
new file mode 100644
index 00000000..52d17369
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/RazorFixtures.slnx
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/src/sharplsp/tests/fixtures/razor/Widgets/Panel.razor b/src/sharplsp/tests/fixtures/razor/Widgets/Panel.razor
new file mode 100644
index 00000000..740c0f38
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/Widgets/Panel.razor
@@ -0,0 +1,12 @@
+
+ @Title
+ @ChildContent
+
+
+@code {
+ /// The panel's heading.
+ [Parameter, EditorRequired] public string Title { get; set; } = "";
+
+ /// What the panel shows.
+ [Parameter] public RenderFragment? ChildContent { get; set; }
+}
diff --git a/src/sharplsp/tests/fixtures/razor/Widgets/Widgets.csproj b/src/sharplsp/tests/fixtures/razor/Widgets/Widgets.csproj
new file mode 100644
index 00000000..c9a32e66
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/Widgets/Widgets.csproj
@@ -0,0 +1,13 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/src/sharplsp/tests/fixtures/razor/Widgets/_Imports.razor b/src/sharplsp/tests/fixtures/razor/Widgets/_Imports.razor
new file mode 100644
index 00000000..66ebfa5d
--- /dev/null
+++ b/src/sharplsp/tests/fixtures/razor/Widgets/_Imports.razor
@@ -0,0 +1 @@
+@using Microsoft.AspNetCore.Components.Web
diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/AdhocRazorProject.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/AdhocRazorProject.cs
new file mode 100644
index 00000000..ee9b0cc5
--- /dev/null
+++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/AdhocRazorProject.cs
@@ -0,0 +1,144 @@
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Text;
+using SharpLsp.Sidecar.Common;
+
+namespace SharpLsp.Sidecar.CSharp.Tests;
+
+///
+/// An in-memory C# project for Razor unit tests, shaped the way MSBuild hands a project to the
+/// sidecar: Razor files as additional documents, MSBuild properties and file metadata in a
+/// global analyzer config, and generated sources standing in for the SDK's Razor generator.
+/// Implements [RAZOR-TESTING].
+///
+internal static class AdhocRazorProject
+{
+ /// The project's directory.
+ public static string Directory { get; } = NativePaths.Temp("AdhocRazor");
+
+ /// A path inside the project's directory.
+ public static string PathOf(params string[] segments)
+ {
+ return NativePaths.Resolve(Directory, segments);
+ }
+
+ ///
+ /// The project App : are its additional documents,
+ /// its MSBuild analyzer options, and
+ /// what its generator adds.
+ ///
+ public static Project Create(
+ AdhocWorkspace workspace,
+ IEnumerable<(string Path, string Text)> files,
+ string globalConfig = "",
+ IReadOnlyList<(string Hint, string Source)>? generated = null
+ )
+ {
+ var id = ProjectId.CreateNewId();
+ var solution = workspace
+ .CurrentSolution.AddProject(Info(id, generated ?? []))
+ .AddAnalyzerConfigDocument(
+ DocumentId.CreateNewId(id),
+ ".globalconfig",
+ SourceText.From("is_global = true\n" + globalConfig),
+ filePath: PathOf(".globalconfig")
+ );
+ return files
+ .Aggregate(solution, (current, file) => WithFile(current, id, file))
+ .GetProject(id)!;
+ }
+
+ /// The additional document at .
+ public static TextDocument Additional(Project project, string path)
+ {
+ return project.AdditionalDocuments.Single(document =>
+ NativePaths.AreEqual(document.FilePath, path)
+ );
+ }
+
+ private static ProjectInfo Info(
+ ProjectId id,
+ IReadOnlyList<(string Hint, string Source)> generated
+ )
+ {
+ return ProjectInfo.Create(
+ id,
+ VersionStamp.Default,
+ "App",
+ "App",
+ LanguageNames.CSharp,
+ filePath: PathOf("App.csproj"),
+ analyzerReferences: [new GeneratorReference(new FixedSources(generated))]
+ );
+ }
+
+ private static Solution WithFile(
+ Solution solution,
+ ProjectId id,
+ (string Path, string Text) file
+ )
+ {
+ return solution.AddAdditionalDocument(
+ DocumentId.CreateNewId(id),
+ NativePaths.NameOf(file.Path),
+ SourceText.From(file.Text),
+ filePath: file.Path
+ );
+ }
+}
+
+/// An analyzer reference that supplies .
+internal sealed class GeneratorReference(params IIncrementalGenerator[] generators)
+ : AnalyzerReference
+{
+ private readonly ImmutableArray _generators =
+ [
+ .. generators.Select(GeneratorExtensions.AsSourceGenerator),
+ ];
+
+ ///
+ public override string FullPath => "";
+
+ ///
+ public override object Id { get; } = Guid.NewGuid();
+
+ ///
+ public override ImmutableArray GetAnalyzers(string language)
+ {
+ return [];
+ }
+
+ ///
+ public override ImmutableArray GetAnalyzersForAllLanguages()
+ {
+ return [];
+ }
+
+ ///
+ public override ImmutableArray GetGenerators(string language)
+ {
+ return _generators;
+ }
+}
+
+///
+/// A generator that adds the same sources every run, as the SDK's Razor generator adds one
+/// projection per Razor file. With no sources it stands for a project's other generators,
+/// which add nothing but still make Roslyn compile the project to run them.
+///
+internal sealed class FixedSources(IReadOnlyList<(string Hint, string Source)> sources)
+ : IIncrementalGenerator
+{
+ ///
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ context.RegisterPostInitializationOutput(output =>
+ {
+ foreach (var (hint, source) in sources)
+ {
+ output.AddSource(hint, source);
+ }
+ });
+ }
+}
diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HtmlMarkupContextTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HtmlMarkupContextTests.cs
new file mode 100644
index 00000000..c266559e
--- /dev/null
+++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HtmlMarkupContextTests.cs
@@ -0,0 +1,285 @@
+#pragma warning disable CA1515 // Public xUnit discovery type
+#pragma warning disable CA2007 // xUnit executes without a synchronization context
+
+using SharpLsp.Sidecar.CSharp.Razor;
+
+namespace SharpLsp.Sidecar.CSharp.Tests;
+
+///
+/// The markup context at carets across projected HTML: complete and incomplete tags, quoted and
+/// unquoted attributes, comments, declarations and raw text. Implements [RAZOR-TESTING] for
+/// [RAZOR-ARCHITECTURE-HTML].
+///
+public sealed class HtmlMarkupContextTests
+{
+ [Fact]
+ public void Text_between_and_around_complete_tags_is_content()
+ {
+ Assert.Equal(MarkupContext.Content, Of("|Hello
"));
+ Assert.Equal(MarkupContext.TagName, Of("<|p>Hello
"));
+ Assert.Equal(MarkupContext.Content, Of("|Hello
"));
+ Assert.Equal(MarkupContext.Content, Of("Hello|
"));
+ Assert.Equal(MarkupContext.Content, Of("a
|b "));
+ Assert.Equal(MarkupContext.Content, Of("a
|b "));
+ Assert.Equal(MarkupContext.Content, Of("a
b |"));
+ }
+
+ [Fact]
+ public void Tag_name_runs_from_the_open_bracket_to_the_first_break()
+ {
+ Assert.Equal(MarkupContext.TagName, Of("<|"));
+ Assert.Equal(MarkupContext.TagName, Of(""));
+ Assert.Equal(MarkupContext.TagName, Of("|"));
+ Assert.Equal(MarkupContext.TagName, Of(" "));
+ Assert.Equal(MarkupContext.TagName, Of(""));
+ Assert.Equal(MarkupContext.Content, Of("|"));
+ Assert.Equal(MarkupContext.Content, Of("
|"));
+ }
+
+ [Fact]
+ public void A_space_after_the_tag_name_opens_the_attribute_area()
+ {
+ Assert.Equal(MarkupContext.AttributeName, Of("
"));
+ Assert.Equal(MarkupContext.AttributeName, Of("
"));
+ Assert.Equal(MarkupContext.AttributeName, Of("
"));
+ Assert.Equal(MarkupContext.AttributeName, Of("
"));
+ Assert.Equal(MarkupContext.AttributeName, Of("
"));
+ Assert.Equal(MarkupContext.AttributeName, Of("
"));
+ Assert.Equal(MarkupContext.AttributeName, Of("
"));
+ }
+
+ [Fact]
+ public void Attribute_values_hold_quoted_and_unquoted_forms()
+ {
+ Assert.Equal(MarkupContext.AttributeValue, Of("
"));
+ Assert.Equal(MarkupContext.AttributeValue, Of("
"));
+ Assert.Equal(MarkupContext.AttributeValue, Of("
"));
+ }
+
+ [Fact]
+ public void A_greater_than_inside_a_quoted_value_does_not_close_the_tag()
+ {
+ Assert.Equal(MarkupContext.AttributeValue, Of("
b|\""));
+ Assert.Equal(MarkupContext.AttributeValue, Of("
\">"));
+ Assert.Equal(MarkupContext.AttributeName, Of("
b\"|>"));
+ Assert.Equal(MarkupContext.Content, Of("
b\">|"));
+ }
+
+ [Fact]
+ public void Incomplete_tags_keep_their_context_at_the_end_of_the_projection()
+ {
+ Assert.Equal(MarkupContext.TagName, Of("
2"));
+ Assert.Equal(MarkupContext.Content, Of("< |div"));
+ Assert.Equal(MarkupContext.TagName, Of("a <|b"));
+ Assert.Equal(MarkupContext.TagName, Of("a <|"));
+ }
+
+ [Fact]
+ public void Comments_hold_content_until_their_close()
+ {
+ Assert.Equal(MarkupContext.Content, Of(""));
+ Assert.Equal(MarkupContext.Content, Of(""));
+ Assert.Equal(MarkupContext.Content, Of("|"));
+ Assert.Equal(MarkupContext.Content, Of("|
b --><|div "));
+ }
+
+ [Fact]
+ public void Abrupt_and_bang_comments_close_like_the_tokenizer_closes_them()
+ {
+ Assert.Equal(MarkupContext.Content, Of("|
"));
+ Assert.Equal(MarkupContext.Content, Of("|"));
+ }
+
+ [Fact]
+ public void Declarations_and_processing_instructions_stay_content()
+ {
+ Assert.Equal(MarkupContext.Content, Of(""));
+ Assert.Equal(MarkupContext.AttributeName, Of("
|"));
+ Assert.Equal(MarkupContext.TagName, Of("
"));
+ Assert.Equal(MarkupContext.Content, Of(""));
+ Assert.Equal(MarkupContext.AttributeName, Of("var a = \"
\";"));
+ Assert.Equal(MarkupContext.Content, Of(""));
+ Assert.Equal(MarkupContext.AttributeName, Of("
p > q| { }"));
+ Assert.Equal(MarkupContext.TagName, Of("
"));
+ Assert.Equal(MarkupContext.Content, Of("