From bae92bcb18353de388e254866c19f2007a401157 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:15:54 +1100 Subject: [PATCH 01/38] Razor foundation: spec, plan, fixture solution, and C# features inside Razor through the SDK generator's projection Bundles the SDK's Razor compiler for markup facts, maps hover, definition, references, highlights, completion, signature help, rename and semantic tokens between .razor/.cshtml files and their generated C#. Refs #397 --- THIRD-PARTY-NOTICES.md | 2 + docs/plans/RAZOR-PLAN.md | 77 +++++ docs/specs/RAZOR-SPEC.md | 309 ++++++++++++++++++ .../fixtures/razor/BlazorApp/BlazorApp.csproj | 14 + .../razor/BlazorApp/Components/App.razor | 12 + .../BlazorApp/Components/Pages/Board.razor | 26 ++ .../BlazorApp/Components/Pages/Counter.razor | 19 ++ .../BlazorApp/Components/Pages/Leaders.razor | 14 + .../razor/BlazorApp/Components/Routes.razor | 5 + .../BlazorApp/Components/Shared/Badge.razor | 1 + .../Components/Shared/Badge.razor.cs | 10 + .../BlazorApp/Components/Shared/Tally.razor | 27 ++ .../razor/BlazorApp/Components/_Imports.razor | 6 + .../tests/fixtures/razor/BlazorApp/Program.cs | 9 + .../fixtures/razor/Directory.Build.props | 5 + .../fixtures/razor/Directory.Build.targets | 3 + .../tests/fixtures/razor/Domain/Domain.fsproj | 12 + .../tests/fixtures/razor/Domain/Scores.fs | 9 + .../PagesApp/Controllers/HomeController.cs | 11 + .../fixtures/razor/PagesApp/Models/Item.cs | 4 + .../fixtures/razor/PagesApp/Pages/Edit.cshtml | 15 + .../razor/PagesApp/Pages/Edit.cshtml.cs | 18 + .../razor/PagesApp/Pages/_ViewImports.cshtml | 3 + .../fixtures/razor/PagesApp/PagesApp.csproj | 9 + .../tests/fixtures/razor/PagesApp/Program.cs | 8 + .../razor/PagesApp/Views/Home/Index.cshtml | 8 + .../razor/PagesApp/Views/_ViewImports.cshtml | 2 + .../tests/fixtures/razor/RazorFixtures.slnx | 6 + .../tests/fixtures/razor/Widgets/Panel.razor | 12 + .../fixtures/razor/Widgets/Widgets.csproj | 13 + .../fixtures/razor/Widgets/_Imports.razor | 1 + .../RazorCSharpFeatureTests.cs | 281 ++++++++++++++++ .../RazorFixture.cs | 118 +++++++ .../RazorMarkupEngineTests.cs | 126 +++++++ .../SharpLsp.Sidecar.CSharp.Tests.csproj | 3 + .../Razor/RazorCompletionEdits.cs | 96 ++++++ .../Razor/RazorEngineSettings.cs | 107 ++++++ .../SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs | 227 +++++++++++++ .../Razor/RazorLocations.cs | 130 ++++++++ .../Razor/RazorMarkup.cs | 128 ++++++++ .../Razor/RazorMarkupCapture.cs | 144 ++++++++ .../Razor/RazorMarkupEngine.cs | 99 ++++++ .../Razor/RazorMarkupSymbols.cs | 97 ++++++ .../Razor/RazorProjection.cs | 107 ++++++ .../Razor/RazorRenameEdits.cs | 93 ++++++ .../Razor/RazorResults.cs | 98 ++++++ .../Razor/RazorSemanticTokens.cs | 45 +++ .../Razor/RazorSpanMap.cs | 145 ++++++++ .../Razor/RazorTagHelpers.cs | 60 ++++ .../Razor/WorkspaceRazorFileSystem.cs | 101 ++++++ .../RazorCompiler.props | 15 + .../SharpLsp.Sidecar.CSharp.csproj | 3 + .../Workspace/DocumentText.cs | 26 ++ .../Workspace/ReferenceResolver.cs | 33 +- .../Workspace/SemanticTokensResolver.cs | 41 ++- .../Workspace/SolutionPaths.cs | 14 + .../Workspace/TargetFrameworks.cs | 46 ++- .../Workspace/WorkspaceManager.Diagnostics.cs | 11 +- .../Workspace/WorkspaceManager.Features.cs | 35 +- .../WorkspaceManager.ForeignRename.cs | 32 +- .../Workspace/WorkspaceManager.Helpers.cs | 85 ++++- .../Workspace/WorkspaceManager.Razor.cs | 205 ++++++++++++ .../Workspace/WorkspaceManager.Rename.cs | 48 ++- .../WorkspaceManager.SignatureHelp.cs | 14 +- .../Workspace/WorkspaceManager.cs | 158 ++++++--- .../NativePaths.Razor.cs | 27 ++ 66 files changed, 3553 insertions(+), 115 deletions(-) create mode 100644 docs/plans/RAZOR-PLAN.md create mode 100644 docs/specs/RAZOR-SPEC.md create mode 100644 src/sharplsp/tests/fixtures/razor/BlazorApp/BlazorApp.csproj create mode 100644 src/sharplsp/tests/fixtures/razor/BlazorApp/Components/App.razor create mode 100644 src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Pages/Board.razor create mode 100644 src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Pages/Counter.razor create mode 100644 src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Pages/Leaders.razor create mode 100644 src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Routes.razor create mode 100644 src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Badge.razor create mode 100644 src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Badge.razor.cs create mode 100644 src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Tally.razor create mode 100644 src/sharplsp/tests/fixtures/razor/BlazorApp/Components/_Imports.razor create mode 100644 src/sharplsp/tests/fixtures/razor/BlazorApp/Program.cs create mode 100644 src/sharplsp/tests/fixtures/razor/Directory.Build.props create mode 100644 src/sharplsp/tests/fixtures/razor/Directory.Build.targets create mode 100644 src/sharplsp/tests/fixtures/razor/Domain/Domain.fsproj create mode 100644 src/sharplsp/tests/fixtures/razor/Domain/Scores.fs create mode 100644 src/sharplsp/tests/fixtures/razor/PagesApp/Controllers/HomeController.cs create mode 100644 src/sharplsp/tests/fixtures/razor/PagesApp/Models/Item.cs create mode 100644 src/sharplsp/tests/fixtures/razor/PagesApp/Pages/Edit.cshtml create mode 100644 src/sharplsp/tests/fixtures/razor/PagesApp/Pages/Edit.cshtml.cs create mode 100644 src/sharplsp/tests/fixtures/razor/PagesApp/Pages/_ViewImports.cshtml create mode 100644 src/sharplsp/tests/fixtures/razor/PagesApp/PagesApp.csproj create mode 100644 src/sharplsp/tests/fixtures/razor/PagesApp/Program.cs create mode 100644 src/sharplsp/tests/fixtures/razor/PagesApp/Views/Home/Index.cshtml create mode 100644 src/sharplsp/tests/fixtures/razor/PagesApp/Views/_ViewImports.cshtml create mode 100644 src/sharplsp/tests/fixtures/razor/RazorFixtures.slnx create mode 100644 src/sharplsp/tests/fixtures/razor/Widgets/Panel.razor create mode 100644 src/sharplsp/tests/fixtures/razor/Widgets/Widgets.csproj create mode 100644 src/sharplsp/tests/fixtures/razor/Widgets/_Imports.razor create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCSharpFeatureTests.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorFixture.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMarkupEngineTests.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCompletionEdits.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorEngineSettings.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorLocations.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCapture.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupEngine.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupSymbols.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorProjection.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorRenameEdits.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorResults.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSemanticTokens.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSpanMap.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorTagHelpers.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/WorkspaceRazorFileSystem.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/RazorCompiler.props create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Razor.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.Common/NativePaths.Razor.cs 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/docs/plans/RAZOR-PLAN.md b/docs/plans/RAZOR-PLAN.md new file mode 100644 index 00000000..4a242ecb --- /dev/null +++ b/docs/plans/RAZOR-PLAN.md @@ -0,0 +1,77 @@ +# 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]) +- [ ] Host: add `LangId::Razor` for `.razor` and `.cshtml`, owned by the C# sidecar ([RAZOR-ARCHITECTURE-ROUTING]) +- [ ] Host: route `documentSymbol`, `foldingRange` and `linkedEditingRange` for Razor to the sidecar, and answer `selectionRange` empty +- [ ] Host: add `<` and `@` completion triggers, and short-circuit them for C# and F# documents +- [ ] 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] 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 +- [ ] Sidecar: build the HTML projection and the markup-context tokenizer ([RAZOR-ARCHITECTURE-HTML]) + +### 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 + +- [ ] VS Code: sync `razor` and `aspnetcorerazor` documents, and activate on `onLanguage:razor` ([RAZOR-EDITORS]) +- [ ] VS Code: forward HTML completion and hover over `sharplsp/razor/htmlProjection` ([RAZOR-FEATURES-HTML]) +- [ ] VS Code: add a `razor` end-to-end chunk on both platforms +- [ ] Zed: Razor language registration (Zed has no built-in Razor grammar) + +### 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/RAZOR-SPEC.md b/docs/specs/RAZOR-SPEC.md new file mode 100644 index 00000000..09bfb80f --- /dev/null +++ b/docs/specs/RAZOR-SPEC.md @@ -0,0 +1,309 @@ +# [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, plus its metadata references; + - a capture pass runs last in the optimization phase. +- **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: + - **Components:** type, descriptor, tag-name span, element span. + - **Component attributes:** bound property, descriptor, name span, and the `@bind-X` origin. + - **Directive attributes:** kind and name span. + - **MVC tag helpers:** each tag helper and its bound attributes. + - **Other markup:** plain elements, directives with their tokens, C# fragment spans, and Razor diagnostics. +- **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-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. +- **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. +- **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]). + +### [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. +- **Document symbols:** the component's class, with the members declared in its `@code` and `@functions` blocks beneath it. +- **Linked editing:** the start and end tag names of an element or component. + +### [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, so Zed support is a plan item. +- **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. +- **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/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 + + + + + + + + + +@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

+ + + +@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 + +
+ + + + Reload +
+

@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/RazorCSharpFeatureTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCSharpFeatureTests.cs new file mode 100644 index 00000000..c2019514 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCSharpFeatureTests.cs @@ -0,0 +1,281 @@ +using SharpLsp.Sidecar.Common; + +#pragma warning disable CA1515 // Public xUnit discovery type +#pragma warning disable CA2007 // xUnit executes without a synchronization context +#pragma warning disable RS1035 // Real-file reads are intentional in this end-to-end suite +#pragma warning disable IDE0042 // Positions read better as (Line, Character) pairs + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// +/// C# inside Razor, answered from the projection the SDK's Razor generator produces, against +/// the real fixture solution. Implements [RAZOR-TESTING] for [RAZOR-ARCHITECTURE-PROJECTION], +/// [RAZOR-FEATURES-HOVER], [RAZOR-FEATURES-DEFINITION], [RAZOR-FEATURES-REFERENCES], +/// [RAZOR-FEATURES-SIGNATURE-HELP] and [RAZOR-FEATURES-DIAGNOSTICS]. +/// +public sealed class RazorCSharpFeatureTests : IClassFixture +{ + private readonly RazorFixture _fixture; + + public RazorCSharpFeatureTests(RazorFixture fixture) + { + _fixture = fixture; + } + + private static readonly string[] CaptionUses = + [ + "@bind-Label=", + " _fixture.Path("BlazorApp", "Components", "Pages", "Board.razor"); + + [Fact] + public async Task Hover_answers_csharp_fragments_with_ranges_in_the_razor_file() + { + using var manager = await _fixture.OpenManagerAsync(); + + var caption = RazorFixture.Locate(Board, "", "@Id", 1); + var idHover = Ok(await manager.GetHoverAsync(Board, id.Line, id.Character)); + Assert.NotNull(idHover); + Assert.Contains("The board's number", idHover.Contents, StringComparison.Ordinal); + Assert.Contains("int", idHover.Contents, StringComparison.Ordinal); + + var markup = RazorFixture.Locate(Board, " Add(1)", "Add(1)"); + var definition = Ok(await manager.GetDefinitionAsync(Board, call.Line, call.Character)); + var declared = Assert.Single(definition.Locations); + Assert.True(NativePaths.AreEqual(Board, declared.FilePath), declared.FilePath); + Assert.Equal( + RazorFixture.Locate(Board, "private void Add", "Add"), + (declared.Line, declared.Character) + ); + Assert.Equal("Add", TextAt(declared)); + + var program = _fixture.Path("BlazorApp", "Program.cs"); + var app = RazorFixture.Locate(program, "MapRazorComponents", "App>"); + var component = Ok(await manager.GetDefinitionAsync(program, app.Line, app.Character)); + var file = Assert.Single(component.Locations); + Assert.True( + NativePaths.AreEqual( + _fixture.Path("BlazorApp", "Components", "App.razor"), + file.FilePath + ), + file.FilePath + ); + Assert.Equal((0, 0), (file.Line, file.Character)); + } + + [Fact] + public async Task References_and_highlights_cover_every_razor_use_and_drop_scaffolding() + { + using var manager = await _fixture.OpenManagerAsync(); + var field = RazorFixture.Locate(Board, "private string caption", "caption"); + var expected = CaptionPositions(); + + var references = Ok( + await manager.GetReferencesAsync(Board, field.Line, field.Character, true) + ); + Assert.All(references.Locations, location => Assert.Equal("caption", TextAt(location))); + Assert.Equal(expected, references.Locations.Select(At).Order()); + + var use = expected[1]; + var highlights = Ok( + await manager.GetDocumentHighlightsAsync(Board, use.Line, use.Character) + ); + Assert.Equal( + expected, + highlights.Highlights.Select(h => (h.StartLine, h.StartCharacter)).Order() + ); + Assert.All( + highlights.Highlights, + h => Assert.Equal("caption".Length, h.EndCharacter - h.StartCharacter) + ); + } + + [Fact] + public async Task Signature_help_answers_inside_a_razor_call() + { + using var manager = await _fixture.OpenManagerAsync(); + var argument = RazorFixture.Locate(Board, "() => Add(1)", "1)"); + + var help = Ok( + await manager.GetSignatureHelpAsync(Board, argument.Line, argument.Character) + ); + Assert.NotNull(help); + var signature = Assert.Single(help.Signatures); + Assert.Contains("Add(int score)", signature.Label, StringComparison.Ordinal); + Assert.Equal(0, help.ActiveParameter); + } + + [Fact] + public async Task Diagnostics_map_csharp_errors_into_the_razor_file_and_the_solution_scan() + { + using var manager = await _fixture.OpenManagerAsync(); + Assert.DoesNotContain( + Ok(await manager.GetDiagnosticsAsync(Board)), + diagnostic => diagnostic.Severity == "Error" + ); + + var original = await File.ReadAllTextAsync(Board); + var broken = original.Replace("score + Id)", "score + Idd)", StringComparison.Ordinal); + _ = Ok(await manager.UpdateDocumentTextAsync(Board, broken)); + + var error = Assert.Single( + Ok(await manager.GetDiagnosticsAsync(Board)), + d => d.Code == "CS0103" + ); + Assert.True(NativePaths.AreEqual(Board, error.FilePath), error.FilePath); + Assert.Equal( + RazorFixture.Locate(Board, "private void Add", "Id)"), + (error.StartLine, error.StartCharacter) + ); + Assert.Equal(error.StartCharacter + "Idd".Length, error.EndCharacter); + + var all = Ok(await manager.GetAllDiagnosticsAsync([])); + var key = Assert.Single(all.Keys, path => NativePaths.AreEqual(path, Board)); + Assert.Contains(all[key], d => d.Code == "CS0103" && d.StartLine == error.StartLine); + Assert.DoesNotContain(all.Keys, path => NativePaths.HasExtension(path, ".g.cs")); + } + + [Fact] + public async Task Completion_in_an_expression_offers_members_with_edits_in_the_razor_file() + { + using var manager = await _fixture.OpenManagerAsync(); + var counter = _fixture.Path("BlazorApp", "Components", "Pages", "Counter.razor"); + var typed = RazorFixture.Locate(counter, "Current count:", "currentCount", 4); + + var items = Ok(await manager.GetCompletionsAsync(counter, typed.Line, typed.Character)); + var field = Assert.Single(items, item => item.Label == "currentCount"); + Assert.NotNull(field.TextEdit); + Assert.Equal( + (typed.Line, typed.Character - 4), + (field.TextEdit.StartLine, field.TextEdit.StartCharacter) + ); + Assert.Equal(typed.Character - 4 + "currentCount".Length, field.TextEdit.EndCharacter); + Assert.Contains(items, item => item.Label == "IncrementCount"); + Assert.All(items, item => Assert.Equal(typed.Line, item.TextEdit?.StartLine ?? typed.Line)); + } + + [Fact] + public async Task Rename_of_a_code_block_field_edits_every_razor_use_and_prepares_its_range() + { + using var manager = await _fixture.OpenManagerAsync(); + var use = RazorFixture.Locate(Board, " Assert.Equal("heading", e.NewText)); + Assert.Equal( + CaptionPositions(), + document.Edits.Select(e => (e.StartLine, e.StartCharacter)) + ); + + var markup = RazorFixture.Locate(Board, " t == (RazorFixture.Locate(Board, "private readonly List", "private"), 7, 15) + ); + Assert.Contains( + tokens, + t => t == (RazorFixture.Locate(Board, "private readonly List", "List"), 4, 2) + ); + Assert.Contains( + tokens, + t => t == (RazorFixture.Locate(Board, "private void Add", "Add"), 3, 13) + ); + Assert.Contains(tokens, t => t == (RazorFixture.Locate(Board, "", "Id"), 2, 9)); + Assert.DoesNotContain( + tokens, + t => t.At.Line == RazorFixture.Locate(Board, "", "Panel").Line + ); + + var code = RazorFixture.Locate(Board, "private void Add", "private"); + var range = Decode( + Ok( + await manager.GetSemanticTokensRangeAsync(Board, code.Line, 0, code.Line + 1, 0) + ).Data + ); + Assert.NotEmpty(range); + Assert.All(range, t => Assert.Equal(code.Line, t.At.Line)); + } + + /// LSP semantic token data as absolute (position, length, type) triples. + private static List<((int Line, int Character) At, int Length, int Type)> Decode(int[] data) + { + var tokens = new List<((int, int), int, int)>(); + var (line, character) = (0, 0); + for (var i = 0; i + 4 < data.Length; i += 5) + { + line += data[i]; + character = data[i] == 0 ? character + data[i + 1] : data[i + 1]; + tokens.Add(((line, character), data[i + 2], data[i + 3])); + } + + return tokens; + } + + /// Every place Board.razor names its caption field, in file order. + private (int Line, int Character)[] CaptionPositions() + { + return [.. CaptionUses.Select(anchor => RazorFixture.Locate(Board, anchor, "caption"))]; + } + + private static string TextAt(LocationResult location) + { + var line = File.ReadAllLines(location.FilePath)[location.Line]; + return line[location.Character..location.EndCharacter]; + } + + private static (int Line, int Character) At(LocationResult location) + { + return (location.Line, location.Character); + } + + private static T Ok(Outcome.Result result) + { + return result.Match(value => value, error => throw new Xunit.Sdk.XunitException(error)); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorFixture.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorFixture.cs new file mode 100644 index 00000000..c2fe7bcf --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorFixture.cs @@ -0,0 +1,118 @@ +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Workspace; + +#pragma warning disable CA1515 // Public xUnit fixture type +#pragma warning disable RS1035 // Real-process/temp-path use is intentional: the fixture is built for real +#pragma warning disable CA2007 // xUnit executes without a synchronization context +#pragma warning disable IDE0058 // Directory.CreateDirectory's result is not needed + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// +/// A built copy of the Razor fixture solution ([RAZOR-TESTING]): BlazorApp, the Widgets +/// Razor class library, PagesApp and the F# Domain library. Copied to a temp directory so +/// tests can edit it, and built so the F# DLL that BlazorApp references exists. +/// +public sealed class RazorFixture : IDisposable +{ + private readonly string _root = NativePaths.Temp($"sharplsp-razor-{Guid.NewGuid():N}"); + + public RazorFixture() + { + CopyTree(FindRepositoryFixture(), _root); + SolutionPath = Path("RazorFixtures.slnx"); + var (exitCode, output) = DotnetBuild.Run(SolutionPath); + Assert.True(exitCode == 0, $"Razor fixture build failed ({exitCode}):{output}"); + } + + /// The fixture's solution file. + public string SolutionPath { get; } + + /// A fixture file by its path relative to the solution directory. + public string Path(params string[] relative) + { + return NativePaths.Join([_root, .. relative]); + } + + /// A workspace manager with the fixture solution loaded. + internal async Task OpenManagerAsync() + { + var manager = new WorkspaceManager(); +#pragma warning disable CS0618 // OpenAsync remains the real workspace-loading boundary + var open = await manager.OpenAsync(SolutionPath); +#pragma warning restore CS0618 + Assert.False(open.IsError, open.Match(_ => "ok", error => error)); + return manager; + } + + /// + /// The position of on the first line of + /// holding , plus characters. + /// + public static (int Line, int Character) Locate( + string file, + string anchor, + string token, + int offset = 0 + ) + { + var lines = File.ReadAllLines(file); + var line = Array.FindIndex(lines, text => text.Contains(anchor, StringComparison.Ordinal)); + Assert.True(line >= 0, $"missing line anchor {anchor}"); + var character = lines[line].IndexOf(token, StringComparison.Ordinal); + Assert.True(character >= 0, $"missing token {token}"); + return (line, character + offset); + } + + public void Dispose() + { + try + { + Directory.Delete(_root, true); + } + catch (IOException) { } + } + + private static void CopyTree(string source, string destination) + { + foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) + { + var relative = NativePaths.RelativeTo(source, file); + if ( + NativePaths.HasDirectory(relative, "bin") + || NativePaths.HasDirectory(relative, "obj") + ) + { + continue; + } + + var target = NativePaths.Join(destination, relative); + Directory.CreateDirectory(NativePaths.DirectoryOf(target)); + File.Copy(file, target); + } + } + + private static string FindRepositoryFixture() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = NativePaths.Join( + directory.FullName, + "src", + "sharplsp", + "tests", + "fixtures", + "razor" + ); + if (File.Exists(NativePaths.Join(candidate, "RazorFixtures.slnx"))) + { + return candidate; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("The Razor fixture solution was not found"); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMarkupEngineTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMarkupEngineTests.cs new file mode 100644 index 00000000..a1ab1d06 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMarkupEngineTests.cs @@ -0,0 +1,126 @@ +using Microsoft.AspNetCore.Razor.Language; +using SharpLsp.Sidecar.CSharp.Razor; + +#pragma warning disable CA1515 // Public xUnit discovery type +#pragma warning disable CA2007 // xUnit executes without a synchronization context +#pragma warning disable IDE0042 // Positions read better as (Line, Character) pairs +#pragma warning disable RS1035 // Real-file reads are intentional in this end-to-end suite + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// +/// The bundled engine's markup facts for the fixture's real files. Implements [RAZOR-TESTING] +/// for [RAZOR-ARCHITECTURE-ENGINE]. +/// +public sealed class RazorMarkupEngineTests : IClassFixture +{ + private readonly RazorFixture _fixture; + + public RazorMarkupEngineTests(RazorFixture fixture) + { + _fixture = fixture; + } + + private string Board => _fixture.Path("BlazorApp", "Components", "Pages", "Board.razor"); + + [Fact] + public async Task Components_from_the_app_and_its_class_library_bind_with_name_spans() + { + using var manager = await _fixture.OpenManagerAsync(); + var markup = await manager.FindRazorMarkupAsync(Board, default); + Assert.NotNull(markup); + Assert.Equal(RazorFileKind.Component, markup.FileKind); + Assert.Equal("BlazorApp.Components.Pages", markup.Namespace); + Assert.Contains("BlazorApp.Components.Shared", markup.ImportedNamespaces); + Assert.Contains("Widgets", markup.ImportedNamespaces); + + var panel = Assert.Single(markup.Tags, tag => tag.Name == "Panel"); + Assert.Equal("Widgets.Panel", panel.Component?.TypeName); + Assert.Equal("Panel", Slice(markup, panel.StartName)); + Assert.Equal("Panel", Slice(markup, panel.EndName!.Value)); + Assert.Equal( + RazorFixture.Locate(Board, "", "Panel").Line, + Line(markup, panel.EndName.Value) + ); + + var tally = Assert.Single(markup.Tags, tag => tag.Name == "Tally"); + Assert.Equal("BlazorApp.Components.Shared.Tally", tally.Component?.TypeName); + var badge = Assert.Single(markup.Tags, tag => tag.Name == "Badge"); + Assert.Null(badge.EndName); + Assert.Equal("Badge", Slice(markup, badge.StartName)); + } + + [Fact] + public async Task Attributes_directives_and_their_parameters_carry_their_descriptors() + { + using var manager = await _fixture.OpenManagerAsync(); + var markup = await manager.FindRazorMarkupAsync(Board, default); + Assert.NotNull(markup); + + var title = Assert.Single(markup.Attributes, a => a.Name == "Title"); + Assert.Equal(MarkupAttributeKind.Property, title.Kind); + Assert.Equal("Title", title.Bound?.PropertyName); + Assert.Equal("Title", Slice(markup, title.NameSpan!.Value)); + + var bind = Assert.Single( + markup.Attributes, + a => + a.Name == "@bind-Label" + && a.TagHelper?.Kind == TagHelperKind.Bind + && a.Owner.Name == "Tally" + && a.TagHelper.Name != "Bind" + ); + Assert.Equal("@bind-Label", Slice(markup, bind.NameSpan!.Value)); + + var parameter = Assert.Single( + markup.Attributes, + a => a.Kind == MarkupAttributeKind.DirectiveParameter + ); + Assert.Equal("@bind:event", parameter.Name); + Assert.Equal("event", parameter.Parameter?.Name); + Assert.Equal("input", parameter.Owner.Name); + + var click = Assert.Single(markup.Attributes, a => a.Name == "@onclick"); + Assert.Equal(TagHelperKind.EventHandler, click.TagHelper?.Kind); + Assert.Equal("@onclick", Slice(markup, click.NameSpan!.Value)); + Assert.Contains( + markup.Attributes, + a => a.Name == "disabled" && a.Kind == MarkupAttributeKind.Html + ); + } + + [Fact] + public async Task Directives_and_razor_diagnostics_come_from_the_compiler() + { + using var manager = await _fixture.OpenManagerAsync(); + var markup = await manager.FindRazorMarkupAsync(Board, default); + Assert.NotNull(markup); + Assert.Equal( + ["page", "implements", "inject", "code"], + markup.Directives.Where(d => d.Name != "using").Select(d => d.Name) + ); + Assert.All(markup.Directives, d => Assert.Equal(d.Name, Slice(markup, d.NameSpan))); + Assert.Contains(markup.AvailableDirectives, d => d.Directive == "rendermode"); + Assert.Empty(markup.Diagnostics); + + var text = await File.ReadAllTextAsync(Board); + var broken = text.Replace(" d.Id == "RZ10012"); + Assert.Equal(RazorDiagnosticSeverity.Warning, unknown.Severity); + Assert.Contains("Bdage", unknown.Message, StringComparison.Ordinal); + Assert.DoesNotContain(edited.Tags, tag => tag.Name == "Badge"); + } + + private static string Slice(RazorMarkup markup, Microsoft.CodeAnalysis.Text.TextSpan span) + { + return markup.Text.ToString(span); + } + + private static int Line(RazorMarkup markup, Microsoft.CodeAnalysis.Text.TextSpan span) + { + return markup.Text.Lines.GetLineFromPosition(span.Start).LineNumber; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/SharpLsp.Sidecar.CSharp.Tests.csproj b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/SharpLsp.Sidecar.CSharp.Tests.csproj index df6bd517..e7607b26 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/SharpLsp.Sidecar.CSharp.Tests.csproj +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/SharpLsp.Sidecar.CSharp.Tests.csproj @@ -44,4 +44,7 @@ + + + diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCompletionEdits.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCompletionEdits.cs new file mode 100644 index 00000000..eccd1d7f --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCompletionEdits.cs @@ -0,0 +1,96 @@ +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Workspace; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// A completion's additional edits, moved from a projection into its Razor file: an edit +/// inside one C# fragment maps across, an added C# using becomes an @using line +/// after the file's own, and anything else cannot be placed and is dropped. +/// Implements [RAZOR-ARCHITECTURE-PROJECTION] and [RAZOR-FEATURES-COMPLETION]. +/// +internal static class RazorCompletionEdits +{ + /// The Razor edits for computed on 's projection. + public static IEnumerable Map( + RazorProjection origin, + IEnumerable changes + ) + { + var existing = OwnUsings(origin).Select(Spelling).ToHashSet(StringComparer.Ordinal); + foreach (var change in changes) + { + if (origin.Map.ToRazor(change.Span) is { } span) + { + yield return DocumentText.ToTextEdit( + origin.RazorText, + new TextChange(span, change.NewText ?? "") + ); + continue; + } + + foreach (var added in AddedUsings(change).Where(existing.Add)) + { + yield return UsingLine(origin, added); + } + } + } + + /// The using directives a change inserts: its whole text parses as usings and nothing else. + private static IEnumerable AddedUsings(TextChange change) + { + var unit = SyntaxFactory.ParseCompilationUnit(change.NewText ?? ""); + return unit.ContainsDiagnostics || unit.Members.Count > 0 || unit.AttributeLists.Count > 0 + ? [] + : unit.Usings.Select(Spelling); + } + + /// The usings this Razor file declares itself, as its projection carries them. + private static IEnumerable OwnUsings(RazorProjection origin) + { + return origin + .GeneratedRoot.DescendantNodes() + .OfType() + .Where(directive => IsOwn(origin, directive)); + } + + private static bool IsOwn(RazorProjection origin, UsingDirectiveSyntax directive) + { + var mapped = directive.NamespaceOrType.GetLocation().GetMappedLineSpan(); + return mapped.HasMappedPath && NativePaths.AreEqual(mapped.Path, origin.RazorPath); + } + + /// A using directive as Razor spells it after @using. + private static string Spelling(UsingDirectiveSyntax directive) + { + var isStatic = + directive.StaticKeyword.RawKind == (int)SyntaxKind.StaticKeyword ? "static " : ""; + var alias = directive.Alias is { } named ? named.Name.Identifier.ValueText + " = " : ""; + return isStatic + alias + directive.NamespaceOrType.ToString(); + } + + /// An @using line inserted after the file's last own using, else at its start. + private static TextEditResult UsingLine(RazorProjection origin, string spelling) + { + var after = OwnUsings(origin) + .Select(directive => directive.NamespaceOrType.GetLocation().GetMappedLineSpan()) + .Select(span => span.EndLinePosition.Line + 1) + .DefaultIfEmpty(0) + .Max(); + var line = Math.Min(after, origin.RazorText.Lines.Count - 1); + var at = origin.RazorText.Lines[line].Start; + var text = "@using " + spelling + LineBreak(origin.RazorText); + return DocumentText.ToTextEdit(origin.RazorText, new TextChange(new TextSpan(at, 0), text)); + } + + private static string LineBreak(SourceText text) + { + var first = text.Lines[0]; + return first.SpanIncludingLineBreak.Length > first.Span.Length + ? text.ToString(TextSpan.FromBounds(first.End, first.EndIncludingLineBreak)) + : "\n"; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorEngineSettings.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorEngineSettings.cs new file mode 100644 index 00000000..158c9aa4 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorEngineSettings.cs @@ -0,0 +1,107 @@ +using System.Text; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using SharpLsp.Sidecar.Common; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// A project's Razor configuration, read from the same inputs the SDK's generator reads: the +/// build_property analyzer options, the C# parse options, and each file's +/// TargetPath. Implements [RAZOR-ARCHITECTURE-ENGINE]. +/// +internal sealed record RazorEngineSettings( + RazorConfiguration Configuration, + string RootNamespace, + bool LocalizedComponentNames, + string ProjectDirectory +) +{ + private const string TargetPathKey = "build_metadata.AdditionalFiles.TargetPath"; + + /// The settings of , which has a project file. + public static RazorEngineSettings Read(Project project) + { + var options = project.AnalyzerOptions.AnalyzerConfigOptionsProvider.GlobalOptions; + var parse = project.ParseOptions as CSharpParseOptions ?? CSharpParseOptions.Default; + var configuration = new RazorConfiguration( + LanguageVersion(options), + "default", + [], + parse.LanguageVersion, + UseConsolidatedMvcViews: true, + SuppressAddComponentParameter: false, + UseRoslynTokenizer: Flag(parse.Features, "use-roslyn-tokenizer"), + PreprocessorSymbols: [.. parse.PreprocessorSymbolNames] + ); + return new RazorEngineSettings( + configuration, + Property(options, "RootNamespace") ?? project.DefaultNamespace ?? project.AssemblyName, + string.Equals( + Property(options, "SupportLocalizedComponentNames"), + "true", + StringComparison.OrdinalIgnoreCase + ), + NativePaths.DirectoryOf(project.FilePath ?? "") + ); + } + + /// + /// The Razor project path of : its TargetPath when MSBuild + /// gave one, else its path inside the project directory. + /// + public string RazorPathOf(TextDocument razor) + { + var target = TargetPath(razor); + return target is null + ? NativePaths.ToRazorProjectPath(ProjectDirectory, razor.FilePath ?? "") + : "/" + NativePaths.Slashed(target).TrimStart('/'); + } + + private static string? TargetPath(TextDocument razor) + { + var options = razor.Project.AnalyzerOptions; + var additional = options.AdditionalFiles.FirstOrDefault(file => + NativePaths.AreEqual(file.Path, razor.FilePath) + ); + return + additional is not null + && options + .AnalyzerConfigOptionsProvider.GetOptions(additional) + .TryGetValue(TargetPathKey, out var encoded) + ? Decode(encoded) + : null; + } + + private static string? Decode(string encoded) + { + var bytes = new byte[encoded.Length]; + return Convert.TryFromBase64String(encoded, bytes, out var written) + ? Encoding.UTF8.GetString(bytes, 0, written) + : null; + } + + private static RazorLanguageVersion LanguageVersion(AnalyzerConfigOptions options) + { + return + Property(options, "RazorLangVersion") is { } value + && RazorLanguageVersion.TryParse(value, out var version) + ? version + : RazorLanguageVersion.Latest; + } + + private static string? Property(AnalyzerConfigOptions options, string name) + { + return options.TryGetValue("build_property." + name, out var value) && value.Length > 0 + ? value + : null; + } + + private static bool Flag(IReadOnlyDictionary features, string name) + { + return features.TryGetValue(name, out var value) + && string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs new file mode 100644 index 00000000..2ea4f3b8 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs @@ -0,0 +1,227 @@ +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Intermediate; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.Common; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// Facts read from single IR nodes. Every span comes from the compiler; where a node lacks +/// one (an end tag's name, a directive's name) it is derived from the node's span and then +/// verified against the text, so a span the compiler did not shape that way is never +/// reported. Implements [RAZOR-ARCHITECTURE-ENGINE]. +/// +internal static class RazorIr +{ + /// Every node under , parents before children. + public static IEnumerable Descendants(IntermediateNode root) + { + var pending = new Stack([root]); + while (pending.TryPop(out var node)) + { + yield return node; + foreach (var child in node.Children.Reverse()) + { + pending.Push(child); + } + } + } + + /// The directive a node declares: a DirectiveIntermediateNode or an @using. + public static MarkupDirective? Directive(IntermediateNode node, SourceText text) + { + return node switch + { + DirectiveIntermediateNode { Source: { } source } directive => Named( + directive.DirectiveName, + Span(source), + 1, + directive.Directive, + text + ), + UsingDirectiveIntermediateNode { Source: { } source } => Named( + "using", + Span(source), + 0, + null, + text + ), + _ => null, + }; + } + + /// The namespace an @using imports, without a global:: qualifier; null for an alias or static using. + public static string? Namespace(UsingDirectiveIntermediateNode use) + { + var name = SyntaxFactory.ParseName(use.Content ?? ""); + if (name.ContainsDiagnostics || name.FullSpan.Length != (use.Content ?? "").Length) + { + return null; + } + + var global = name.DescendantNodesAndSelf() + .OfType() + .FirstOrDefault(); + return (global is null ? name : name.ReplaceNode(global, global.Name)).ToString(); + } + + /// The tag a node is: an element bound to tag helpers, or a plain component-file element. + public static MarkupTag? Tag(IntermediateNode node, SourceText text) + { + return node switch + { + TagHelperIntermediateNode { Source: { } source, StartTagSpan: { } start } bound => + new MarkupTag( + bound.TagName, + Span(source), + Span(start), + bound.TagMode == TagMode.StartTagAndEndTag + ? EndName(Span(source), bound.TagName, text) + : null, + [.. bound.TagHelpers] + ), + MarkupElementIntermediateNode { Source: { } source } element => PlainTag( + element.TagName, + Span(source), + text + ), + _ => null, + }; + } + + /// The attributes of a tag node, each with what binds it. + public static IEnumerable Attributes( + IntermediateNode node, + MarkupTag tag, + SourceText text + ) + { + return node.Children.Select(child => Attribute(child, tag, text)).OfType(); + } + + /// Whether a diagnostic belongs to the processed file, not to one of its imports. + public static bool IsIn(RazorDiagnostic diagnostic, string? path, SourceText text) + { + var span = diagnostic.Span; + return span.AbsoluteIndex >= 0 + && span.AbsoluteIndex + span.Length <= text.Length + && (string.IsNullOrEmpty(span.FilePath) || NativePaths.AreEqual(span.FilePath, path)); + } + + private static MarkupAttribute? Attribute( + IntermediateNode child, + MarkupTag tag, + SourceText text + ) + { + return child switch + { + TagHelperPropertyIntermediateNode property => new MarkupAttribute( + MarkupAttributeKind.Property, + property.AttributeName, + property.OriginalAttributeSpan is { } span ? Span(span) : null, + tag, + property.TagHelper, + property.BoundAttribute, + null + ), + TagHelperDirectiveAttributeIntermediateNode directive => new MarkupAttribute( + MarkupAttributeKind.Directive, + directive.OriginalAttributeName, + DirectiveName( + directive.OriginalAttributeSpan, + directive.OriginalAttributeName, + text + ), + tag, + directive.TagHelper, + directive.BoundAttribute, + null + ), + TagHelperDirectiveAttributeParameterIntermediateNode parameter => new MarkupAttribute( + MarkupAttributeKind.DirectiveParameter, + parameter.OriginalAttributeName, + DirectiveName( + parameter.OriginalAttributeSpan, + parameter.OriginalAttributeName, + text + ), + tag, + parameter.TagHelper, + parameter.BoundAttribute, + parameter.BoundAttributeParameter + ), + TagHelperHtmlAttributeIntermediateNode html => Html(html.AttributeName, tag), + HtmlAttributeIntermediateNode html => Html(html.AttributeName, tag), + _ => null, + }; + } + + private static MarkupAttribute Html(string name, MarkupTag tag) + { + return new MarkupAttribute(MarkupAttributeKind.Html, name, null, tag, null, null, null); + } + + private static MarkupTag? PlainTag(string name, TextSpan element, SourceText text) + { + var start = new TextSpan(element.Start + 1, name.Length); + return Holds(text, start, name) + ? new MarkupTag(name, element, start, EndName(element, name, text), []) + : null; + } + + /// A directive attribute's name, @ included, from the span of what follows the @. + private static TextSpan? DirectiveName(SourceSpan? original, string name, SourceText text) + { + if (original is not { } span) + { + return null; + } + + var withTransition = new TextSpan(span.AbsoluteIndex - 1, name.Length); + return withTransition.Start >= 0 && Holds(text, withTransition, name) + ? withTransition + : Span(span); + } + + /// The name of an element's end tag, </Name> closing . + private static TextSpan? EndName(TextSpan element, string name, SourceText text) + { + var end = new TextSpan(element.End - 1 - name.Length, name.Length); + return + end.Start >= element.Start + 2 + && Holds(text, new TextSpan(end.Start - 2, 2), "") + ? end + : null; + } + + private static MarkupDirective? Named( + string name, + TextSpan span, + int nameOffset, + DirectiveDescriptor? descriptor, + SourceText text + ) + { + var nameSpan = new TextSpan(span.Start + nameOffset, name.Length); + return Holds(text, nameSpan, name) + ? new MarkupDirective(name, span, nameSpan, descriptor) + : null; + } + + private static bool Holds(SourceText text, TextSpan span, string expected) + { + return span.End <= text.Length + && string.Equals(text.ToString(span), expected, StringComparison.Ordinal); + } + + private static TextSpan Span(SourceSpan span) + { + return new TextSpan(span.AbsoluteIndex, span.Length); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorLocations.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorLocations.cs new file mode 100644 index 00000000..a4b208d2 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorLocations.cs @@ -0,0 +1,130 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Workspace; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// Moves C# answers out of Razor projections. Roslyn already maps a location inside a C# +/// fragment to its Razor file; a location still in a projection is hidden scaffolding and is +/// dropped, except a component's own class declaration, which lands at the start of its Razor +/// file because a component is its file. Implements [RAZOR-ARCHITECTURE-PROJECTION]. +/// +internal static class RazorLocations +{ + /// with every projection location mapped or dropped. + public static async Task MapAsync( + Solution solution, + LocationListResult result, + CancellationToken ct + ) + { + // One lookup per file this request touches, not per location. + var projections = new Dictionary(NativePaths.Comparer); + var mapped = new List(); + foreach (var location in result.Locations) + { + if (!projections.TryGetValue(location.FilePath, out var projection)) + { + projection = await FindProjectionAsync(solution, location.FilePath, ct) + .ConfigureAwait(false); + projections[location.FilePath] = projection; + } + + if (Map(projection, location) is { } kept) + { + mapped.Add(kept); + } + } + + return new LocationListResult { Locations = [.. mapped.DistinctBy(Key)] }; + } + + /// mapped out of a projection, or null when it is hidden scaffolding. + public static async Task MapAsync( + Solution solution, + LocationResult? location, + CancellationToken ct + ) + { + if (location is null) + { + return null; + } + + var projection = await FindProjectionAsync(solution, location.FilePath, ct) + .ConfigureAwait(false); + return Map(projection, location); + } + + private static LocationResult? Map(RazorProjection? projection, LocationResult location) + { + return projection is null ? location : ComponentFileStart(projection, location); + } + + /// + /// The projection whose generated document lives at ; null for a + /// regular document, decompiled source, or a generated document that is not a projection. + /// + public static async Task FindProjectionAsync( + Solution solution, + string path, + CancellationToken ct + ) + { + if (SolutionPaths.FindDocument(solution, path) is not null) + { + return null; + } + + foreach (var project in solution.Projects) + { + var generated = await project + .GetSourceGeneratedDocumentsAsync(ct) + .ConfigureAwait(false); + if ( + generated.FirstOrDefault(document => + NativePaths.AreEqual(document.FilePath, path) + ) is + { } found + ) + { + return await RazorProjection.ForGeneratedAsync(found, ct).ConfigureAwait(false); + } + } + + return null; + } + + private static LocationResult? ComponentFileStart( + RazorProjection projection, + LocationResult location + ) + { + var position = new LinePosition(location.Line, location.Character); + return + DocumentText.TryOffset(projection.GeneratedText, position) is { } offset + && DeclaresComponentClass(projection.GeneratedRoot.FindToken(offset)) + ? new LocationResult { FilePath = projection.RazorPath } + : null; + } + + private static bool DeclaresComponentClass(SyntaxToken token) + { + return token.Parent is ClassDeclarationSyntax declaration + && declaration.Identifier == token; + } + + private static (string, int, int, int, int) Key(LocationResult location) + { + return ( + NativePaths.IdentityOf(location.FilePath), + location.Line, + location.Character, + location.EndLine, + location.EndCharacter + ); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs new file mode 100644 index 00000000..4dfc0275 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs @@ -0,0 +1,128 @@ +using System.Collections.Immutable; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis.Text; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// What a markup attribute is to the compiler. +internal enum 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, +} + +/// An element of the file, with the tag helpers (components included) the compiler bound to it. +internal sealed record MarkupTag( + string Name, + TextSpan Element, + TextSpan StartName, + TextSpan? EndName, + ImmutableArray TagHelpers +) +{ + /// The component this tag instantiates, if any. + public TagHelperDescriptor? Component => + TagHelpers.FirstOrDefault(helper => helper.Kind == TagHelperKind.Component); + + /// The child-content parameter this tag sets, if any (<ChildContent>). + public TagHelperDescriptor? ChildContent => + TagHelpers.FirstOrDefault(helper => helper.Kind == TagHelperKind.ChildContent); + + /// The MVC tag helpers bound to this tag. + public IEnumerable MvcTagHelpers => + TagHelpers.Where(helper => + helper.Kind is TagHelperKind.ITagHelper or TagHelperKind.ViewComponent + ); + + /// Whether lies on the start or end tag's name. + public bool NameContains(int offset) + { + return Touches(StartName, offset) || (EndName is { } end && Touches(end, offset)); + } + + private static bool Touches(TextSpan span, int offset) + { + return span.Start <= offset && offset <= span.End; + } +} + +/// An attribute of a tag, with what binds it. +internal sealed record MarkupAttribute( + MarkupAttributeKind Kind, + string Name, + TextSpan? NameSpan, + MarkupTag Owner, + TagHelperDescriptor? TagHelper, + BoundAttributeDescriptor? Bound, + BoundAttributeParameterDescriptor? Parameter +); + +/// A directive the file declares: @page, @inject, @code, @using. +internal sealed record MarkupDirective( + string Name, + TextSpan Span, + TextSpan NameSpan, + DirectiveDescriptor? Descriptor +); + +/// A Razor (RZ) diagnostic of the file. +internal sealed record MarkupDiagnostic( + string Id, + string Message, + RazorDiagnosticSeverity Severity, + TextSpan Span +); + +/// +/// A Razor file's markup facts, read from the compiler's intermediate representation. +/// Implements [RAZOR-ARCHITECTURE-ENGINE]. +/// +internal sealed record RazorMarkup( + SourceText Text, + RazorFileKind FileKind, + string? Namespace, + ImmutableArray ImportedNamespaces, + ImmutableArray Tags, + ImmutableArray Attributes, + ImmutableArray Directives, + ImmutableArray Diagnostics, + ImmutableArray AvailableDirectives, + TagHelperCollection TagHelpers +) +{ + /// The innermost tag whose start or end name holds . + public MarkupTag? TagNamedAt(int offset) + { + return Tags.Where(tag => tag.NameContains(offset)) + .OrderBy(tag => tag.Element.Length) + .FirstOrDefault(); + } + + /// The bound attribute whose name holds . + public MarkupAttribute? AttributeNamedAt(int offset) + { + return Attributes.FirstOrDefault(attribute => + attribute.Kind != MarkupAttributeKind.Html + && attribute.NameSpan is { } name + && name.Start <= offset + && offset <= name.End + ); + } + + /// The directive whose name holds . + public MarkupDirective? DirectiveNamedAt(int offset) + { + return Directives.FirstOrDefault(directive => + directive.NameSpan.Start <= offset && offset <= directive.NameSpan.End + ); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCapture.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCapture.cs new file mode 100644 index 00000000..e57102d7 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCapture.cs @@ -0,0 +1,144 @@ +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Intermediate; +using Microsoft.CodeAnalysis.Text; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// Reads markup facts out of the compiler's IR while it is built. Directives are read first +/// in the directive-classifier phase, before @code becomes plain C#; tags and +/// attributes first in the optimization phase, before components are lowered and attribute +/// names lose their spans; diagnostics last. The IR is mutated after each read, so each +/// pass copies what it needs at once. Implements [RAZOR-ARCHITECTURE-ENGINE]. +/// +internal sealed class RazorMarkupCapture +{ + private readonly List _directives = []; + private readonly List _tags = []; + private readonly List _attributes = []; + private readonly List _diagnostics = []; + private readonly List _namespaces = []; + private RazorCodeDocument? _codeDocument; + + /// Add the capture passes to an engine. + public void Register(RazorProjectEngineBuilder builder) + { + builder.Features.Add(new DirectivePass(this)); + builder.Features.Add(new TagPass(this)); + builder.Features.Add(new DiagnosticPass(this)); + } + + /// The facts captured while the engine processed one file. + public RazorMarkup? ToMarkup(TagHelperCollection tagHelpers) + { + return _codeDocument is not { } document + ? null + : new RazorMarkup( + document.Source.Text, + document.FileKind, + document.TryGetNamespace(true, out var ns) ? ns : null, + [.. _namespaces.Distinct(StringComparer.Ordinal)], + [.. _tags], + [.. _attributes], + [.. _directives], + [.. _diagnostics.Distinct()], + document.ParserOptions.Directives, + tagHelpers + ); + } + + private void ReadDirectives(RazorCodeDocument document, DocumentIntermediateNode root) + { + var text = document.Source.Text; + foreach (var node in RazorIr.Descendants(root).Where(node => !node.IsImported)) + { + if (RazorIr.Directive(node, text) is { } directive) + { + _directives.Add(directive); + } + } + } + + private void ReadTags(RazorCodeDocument document, DocumentIntermediateNode root) + { + _codeDocument = document; + var text = document.Source.Text; + foreach (var node in RazorIr.Descendants(root)) + { + if (node is UsingDirectiveIntermediateNode use && RazorIr.Namespace(use) is { } ns) + { + _namespaces.Add(ns); + } + else if (!node.IsImported && RazorIr.Tag(node, text) is { } tag) + { + _tags.Add(tag); + _attributes.AddRange(RazorIr.Attributes(node, tag, text)); + } + } + } + + private void ReadDiagnostics(RazorCodeDocument document, DocumentIntermediateNode root) + { + var path = document.Source.FilePath; + _diagnostics.AddRange( + RazorIr + .Descendants(root) + .SelectMany(node => node.Diagnostics) + .Where(diagnostic => RazorIr.IsIn(diagnostic, path, document.Source.Text)) + .Select(diagnostic => new MarkupDiagnostic( + diagnostic.Id, + diagnostic.GetMessage(System.Globalization.CultureInfo.InvariantCulture), + diagnostic.Severity, + new TextSpan(diagnostic.Span.AbsoluteIndex, diagnostic.Span.Length) + )) + ); + } + + private sealed class DirectivePass(RazorMarkupCapture capture) + : IntermediateNodePassBase, + IRazorDirectiveClassifierPass + { + public override int Order => int.MinValue; + + protected override void ExecuteCore( + RazorCodeDocument codeDocument, + DocumentIntermediateNode documentNode, + CancellationToken cancellationToken + ) + { + capture.ReadDirectives(codeDocument, documentNode); + } + } + + private sealed class TagPass(RazorMarkupCapture capture) + : IntermediateNodePassBase, + IRazorOptimizationPass + { + public override int Order => int.MinValue; + + protected override void ExecuteCore( + RazorCodeDocument codeDocument, + DocumentIntermediateNode documentNode, + CancellationToken cancellationToken + ) + { + capture.ReadTags(codeDocument, documentNode); + } + } + + private sealed class DiagnosticPass(RazorMarkupCapture capture) + : IntermediateNodePassBase, + IRazorOptimizationPass + { + public override int Order => int.MaxValue - 1; + + protected override void ExecuteCore( + RazorCodeDocument codeDocument, + DocumentIntermediateNode documentNode, + CancellationToken cancellationToken + ) + { + capture.ReadDiagnostics(codeDocument, documentNode); + } + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupEngine.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupEngine.cs new file mode 100644 index 00000000..bb66f992 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupEngine.cs @@ -0,0 +1,99 @@ +using System.Collections.Concurrent; +using Microsoft.AspNetCore.Mvc.Razor.Extensions; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Razor; +using Serilog; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// Runs the bundled Razor compiler over one file of a project and returns its markup facts. +/// The project's own SDK generator still produces the C# projection; this engine only reads +/// markup structure. A failure answers null, so markup features answer empty while C# +/// features keep working, and is logged once per project. Implements +/// [RAZOR-ARCHITECTURE-ENGINE] and [RAZOR-ERRORS]. +/// +internal static class RazorMarkupEngine +{ + private static readonly ConcurrentDictionary LoggedProjects = new( + StringComparer.Ordinal + ); + + /// The markup facts of , or null when the engine cannot read it. + public static async Task ProcessAsync(TextDocument razor, CancellationToken ct) + { + try + { + return await ProcessCoreAsync(razor, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + LogOnce(razor.Project, ex); + return null; + } + } + + private static async Task ProcessCoreAsync( + TextDocument razor, + CancellationToken ct + ) + { + var compilation = await razor.Project.GetCompilationAsync(ct).ConfigureAwait(false); + if (compilation is null || razor.Project.FilePath is null || razor.FilePath is null) + { + return null; + } + + var settings = RazorEngineSettings.Read(razor.Project); + var fileSystem = await WorkspaceRazorFileSystem + .CreateAsync(razor, settings, ct) + .ConfigureAwait(false); + var tagHelpers = RazorTagHelpers.Of(compilation); + var capture = new RazorMarkupCapture(); + var engine = CreateEngine(settings, fileSystem, tagHelpers, capture); + _ = engine.Process(fileSystem.GetItem(settings.RazorPathOf(razor), null), ct); + return capture.ToMarkup(tagHelpers); + } + + private static RazorProjectEngine CreateEngine( + RazorEngineSettings settings, + RazorProjectFileSystem fileSystem, + TagHelperCollection tagHelpers, + RazorMarkupCapture capture + ) + { + return RazorProjectEngine.Create( + settings.Configuration, + fileSystem, + builder => + { + builder.Features.Add(new RazorTagHelpers.Feature(tagHelpers)); + CompilerFeatures.Register(builder); + RazorExtensions.Register(builder); + capture.Register(builder); + _ = builder.SetRootNamespace(settings.RootNamespace); + _ = builder.SetCSharpLanguageVersion(settings.Configuration.CSharpLanguageVersion); + _ = settings.LocalizedComponentNames + ? builder.SetSupportLocalizedComponentNames() + : builder; + } + ); + } + + private static void LogOnce(Project project, Exception ex) + { + if (LoggedProjects.TryAdd(project.FilePath ?? project.Name, 0)) + { + Log.Warning( + "[Razor] Markup engine unavailable for {Project}: {ExceptionType}", + project.Name, + ex.GetType().Name + ); + } + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupSymbols.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupSymbols.cs new file mode 100644 index 00000000..7c6b10f6 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupSymbols.cs @@ -0,0 +1,97 @@ +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// The C# symbols behind markup: the type a component or tag helper descriptor names, and +/// the property a bound attribute sets. Implements [RAZOR-FEATURES-HOVER] and +/// [RAZOR-FEATURES-DEFINITION]. +/// +internal static class RazorMarkupSymbols +{ + /// The type names in . + public static INamedTypeSymbol? TypeOf(Compilation compilation, TagHelperDescriptor descriptor) + { + var name = SyntaxFactory.ParseTypeName(descriptor.TypeName) switch + { + QualifiedNameSyntax qualified => qualified.Right, + SimpleNameSyntax simple => simple, + _ => null, + }; + var arity = name is GenericNameSyntax generic ? generic.Arity : 0; + var prefix = string.IsNullOrEmpty(descriptor.TypeNamespace) + ? "" + : descriptor.TypeNamespace + "."; + var suffix = arity > 0 ? "`" + arity : ""; + return name is null + ? null + : compilation.GetTypeByMetadataName(prefix + descriptor.TypeNameIdentifier + suffix); + } + + /// The property named on or a base type. + public static IPropertySymbol? PropertyOf(INamedTypeSymbol type, string name) + { + for (var current = type; current is not null; current = current.BaseType) + { + if (current.GetMembers(name).OfType().FirstOrDefault() is { } found) + { + return found; + } + } + + return null; + } + + /// The type a tag instantiates: its component, else its first MVC tag helper. + public static INamedTypeSymbol? TypeOf(Compilation compilation, MarkupTag tag) + { + var descriptor = tag.Component ?? tag.MvcTagHelpers.FirstOrDefault(); + return descriptor is null ? null : TypeOf(compilation, descriptor); + } + + /// + /// The property an attribute sets: a component parameter, a tag helper property, or the + /// parameter a @bind-X binds. Directive attributes such as @onclick set none. + /// + public static IPropertySymbol? PropertyOf(Compilation compilation, MarkupAttribute attribute) + { + var type = attribute.TagHelper is { } helper ? TypeOf(compilation, helper) : null; + return type is null || BoundProperty(attribute) is not { } name + ? null + : PropertyOf(type, name); + } + + /// The parameter a <ChildContent>-style tag sets on its enclosing component. + public static IPropertySymbol? ChildContentOf( + Compilation compilation, + RazorMarkup markup, + MarkupTag tag + ) + { + var parent = markup + .Tags.Where(candidate => + candidate.Component is not null + && candidate.Element.Contains(tag.Element) + && candidate.Element != tag.Element + ) + .OrderBy(candidate => candidate.Element.Length) + .FirstOrDefault(); + var type = parent is null ? null : TypeOf(compilation, parent); + return type is null ? null : PropertyOf(type, tag.Name); + } + + private static string? BoundProperty(MarkupAttribute attribute) + { + var binds = + attribute.Kind == MarkupAttributeKind.Property + || ( + attribute.Kind == MarkupAttributeKind.Directive + && attribute.TagHelper is { Kind: TagHelperKind.Bind } helper + && helper.TypeName != "Bind" + ); + return binds ? attribute.Bound?.PropertyName : null; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorProjection.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorProjection.cs new file mode 100644 index 00000000..2b12f916 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorProjection.cs @@ -0,0 +1,107 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Workspace; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// A Razor file and its C# projection: the source-generated document whose +/// #pragma checksum names that file, with the verified map between the two. +/// Implements [RAZOR-ARCHITECTURE-PROJECTION]. +/// +internal sealed record RazorProjection( + TextDocument Razor, + SourceText RazorText, + SourceGeneratedDocument Generated, + SourceText GeneratedText, + SyntaxNode GeneratedRoot, + RazorSpanMap Map +) +{ + /// The Razor file's path. + public string RazorPath => Razor.FilePath ?? ""; + + /// The projection of , or null when its project generates none. + public static async Task ForRazorAsync( + TextDocument razor, + CancellationToken ct + ) + { + var generatedDocuments = await razor + .Project.GetSourceGeneratedDocumentsAsync(ct) + .ConfigureAwait(false); + foreach (var generated in generatedDocuments) + { + var root = await generated.GetSyntaxRootAsync(ct).ConfigureAwait(false); + if (NativePaths.AreEqual(ChecksumPath(root), razor.FilePath)) + { + return await CreateAsync(razor, generated, root!, ct).ConfigureAwait(false); + } + } + + return null; + } + + /// The projection that is, or null when it names no Razor file. + public static async Task ForGeneratedAsync( + SourceGeneratedDocument generated, + CancellationToken ct + ) + { + var root = await generated.GetSyntaxRootAsync(ct).ConfigureAwait(false); + var path = ChecksumPath(root); + var razor = generated.Project.AdditionalDocuments.FirstOrDefault(document => + NativePaths.IsRazor(path) && NativePaths.AreEqual(document.FilePath, path) + ); + return razor is null + ? null + : await CreateAsync(razor, generated, root!, ct).ConfigureAwait(false); + } + + /// The file a generated document's #pragma checksum names. + internal static string? ChecksumPath(SyntaxNode? root) + { + return + root is CSharpSyntaxNode node + && node.GetFirstDirective(directive => directive is PragmaChecksumDirectiveTriviaSyntax) + is PragmaChecksumDirectiveTriviaSyntax pragma + ? pragma.File.ValueText + : null; + } + + /// The generated position of a Razor position inside a C# fragment. + public LinePosition? ToGenerated(LinePosition razorPosition) + { + return + DocumentText.TryOffset(RazorText, razorPosition) is { } offset + && Map.ToGenerated(offset) is { } generated + ? GeneratedText.Lines.GetLinePosition(generated) + : null; + } + + /// The Razor range of a generated range that lies inside one fragment. + public LinePositionSpan? ToRazor(LinePositionSpan generatedSpan) + { + return + DocumentText.TrySpan(GeneratedText, generatedSpan) is { } span + && Map.ToRazor(span) is { } razor + ? RazorText.Lines.GetLinePositionSpan(razor) + : null; + } + + private static async Task CreateAsync( + TextDocument razor, + SourceGeneratedDocument generated, + SyntaxNode root, + CancellationToken ct + ) + { + var razorText = await razor.GetTextAsync(ct).ConfigureAwait(false); + var generatedText = await generated.GetTextAsync(ct).ConfigureAwait(false); + var map = RazorSpanMap.Build(root.SyntaxTree, generatedText, razorText, razor.FilePath!); + return new RazorProjection(razor, razorText, generated, generatedText, root, map); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorRenameEdits.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorRenameEdits.cs new file mode 100644 index 00000000..d7ed5b92 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorRenameEdits.cs @@ -0,0 +1,93 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.FindSymbols; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Workspace; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// The Razor half of a rename. Roslyn's renamer edits regular documents only, never the +/// projections, so each declaration and use that Roslyn maps into a Razor file is edited +/// there, at its mapped span, when that span holds the old name. Hidden scaffolding maps to +/// no Razor span and is never edited. Implements [RAZOR-FEATURES-RENAME]. +/// +internal static class RazorRenameEdits +{ + /// The Razor edits that rename to . + public static async Task> FindAsync( + Solution solution, + ISymbol symbol, + string newName, + CancellationToken ct + ) + { + var referenced = await SymbolFinder + .FindReferencesAsync(symbol, solution, ct) + .ConfigureAwait(false); + return await FromReferencesAsync(solution, referenced, (symbol.Name, newName), ct) + .ConfigureAwait(false); + } + + /// The Razor edits among that rewrite names.Old. + public static async Task> FromReferencesAsync( + Solution solution, + IEnumerable referenced, + (string Old, string New) names, + CancellationToken ct + ) + { + var spans = referenced + .SelectMany(reference => + reference.Definition.Locations.Concat( + reference.Locations.Select(use => use.Location) + ) + ) + .Where(location => location.IsInSource) + .Select(location => location.GetMappedLineSpan()) + .Where(span => span.HasMappedPath && NativePaths.IsRazor(span.Path)); + var edits = new List(); + foreach (var file in spans.GroupBy(span => span.Path, NativePaths.Comparer)) + { + if (await EditFileAsync(solution, file, names, ct).ConfigureAwait(false) is { } edit) + { + edits.Add(edit); + } + } + + return edits; + } + + private static async Task EditFileAsync( + Solution solution, + IGrouping file, + (string Old, string New) names, + CancellationToken ct + ) + { + var razor = SolutionPaths.AdditionalDocumentsAt(solution, file.Key).FirstOrDefault(); + if (razor is null) + { + return null; + } + + var text = await razor.GetTextAsync(ct).ConfigureAwait(false); + var edits = file.Select(span => span.Span) + .Distinct() + .Where(span => Holds(text, span, names.Old)) + .OrderBy(span => span.Start) + .Select(span => RazorResults.Edit(span, names.New)) + .ToList(); + return edits.Count == 0 + ? null + : new DocumentEditResult { FilePath = file.Key, Edits = edits }; + } + + /// Whether of is exactly . + private static bool Holds(SourceText text, LinePositionSpan span, string name) + { + return DocumentText.TrySpan(text, span) is { } found + && found.Length == name.Length + && string.Equals(text.ToString(found), name, StringComparison.Ordinal); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorResults.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorResults.cs new file mode 100644 index 00000000..147ec9c5 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorResults.cs @@ -0,0 +1,98 @@ +using Microsoft.CodeAnalysis.Text; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// Maps answers computed on a projection back to its Razor file. A range that does not lie +/// inside one C# fragment cannot be placed, so it is dropped with whatever carries it. +/// Implements [RAZOR-ARCHITECTURE-PROJECTION]. +/// +internal static class RazorResults +{ + /// Completion items whose edit maps into the Razor file; the rest are dropped. + public static List MapCompletions( + List items, + RazorProjection? origin + ) + { + return origin is null + ? items + : [.. items.Select(item => MapCompletion(item, origin)).OfType()]; + } + + /// A text edit placed in the Razor file, or null when it crosses a fragment's edge. + public static TextEditResult? MapEdit(TextEditResult edit, RazorProjection origin) + { + return origin.ToRazor(SpanOf(edit)) is { } razor ? Edit(razor, edit.NewText) : null; + } + + /// The prepared range in the Razor file; a range outside every fragment cannot be renamed there. + public static PrepareRenameResult MapPrepareRename( + PrepareRenameResult prepared, + RazorProjection? origin + ) + { + if (origin is null || !prepared.CanRename) + { + return prepared; + } + + var span = new LinePositionSpan( + new LinePosition(prepared.StartLine, prepared.StartCharacter), + new LinePosition(prepared.EndLine, prepared.EndCharacter) + ); + return origin.ToRazor(span) is { } razor + ? Prepared(razor, prepared.Placeholder) + : new PrepareRenameResult { CanRename = false }; + } + + /// A text edit over . + public static TextEditResult Edit(LinePositionSpan span, string newText) + { + return new TextEditResult + { + StartLine = span.Start.Line, + StartCharacter = span.Start.Character, + EndLine = span.End.Line, + EndCharacter = span.End.Character, + NewText = newText, + }; + } + + private static CompletionItem? MapCompletion(CompletionItem item, RazorProjection origin) + { + return item.TextEdit is null ? item + : MapEdit(item.TextEdit, origin) is { } edit + ? new CompletionItem + { + Label = item.Label, + Kind = item.Kind, + Detail = item.Detail, + InsertText = item.InsertText, + Index = item.Index, + TextEdit = edit, + } + : null; + } + + private static PrepareRenameResult Prepared(LinePositionSpan span, string placeholder) + { + return new PrepareRenameResult + { + CanRename = true, + StartLine = span.Start.Line, + StartCharacter = span.Start.Character, + EndLine = span.End.Line, + EndCharacter = span.End.Character, + Placeholder = placeholder, + }; + } + + private static LinePositionSpan SpanOf(TextEditResult edit) + { + return new LinePositionSpan( + new LinePosition(edit.StartLine, edit.StartCharacter), + new LinePosition(edit.EndLine, edit.EndCharacter) + ); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSemanticTokens.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSemanticTokens.cs new file mode 100644 index 00000000..f1e060bf --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSemanticTokens.cs @@ -0,0 +1,45 @@ +using Microsoft.CodeAnalysis.Classification; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.CSharp.Workspace; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// Semantic tokens for a Razor file's C# fragments: Roslyn's classification of each +/// fragment in the projection, shifted to the fragment's place in the Razor file. +/// Implements [RAZOR-FEATURES-SEMANTIC-TOKENS]. +/// +internal static class RazorSemanticTokens +{ + /// The typed spans of every C# fragment of , in Razor offsets. + public static async Task> CSharpSpansAsync( + RazorProjection projection, + CancellationToken ct + ) + { + var spans = new List(); + foreach (var mapping in projection.Map.Mappings.Where(mapping => mapping.Length > 0)) + { + var generated = new TextSpan(mapping.GeneratedStart, mapping.Length); + var classified = await Classifier + .GetClassifiedSpansAsync(projection.Generated, generated, ct) + .ConfigureAwait(false); + spans.AddRange(classified.Select(span => Shift(span, mapping)).OfType()); + } + + return spans; + } + + /// A classified generated span moved into the Razor file, when it lies inside its fragment. + private static SemanticSpan? Shift(ClassifiedSpan span, RazorMapping mapping) + { + var start = Math.Max(span.TextSpan.Start, mapping.GeneratedStart); + var end = Math.Min(span.TextSpan.End, mapping.GeneratedEnd); + return end > start + ? new SemanticSpan( + new TextSpan(mapping.RazorStart + (start - mapping.GeneratedStart), end - start), + SemanticTokensResolver.MapClassification(span.ClassificationType) + ) + : null; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSpanMap.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSpanMap.cs new file mode 100644 index 00000000..06dba1fb --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSpanMap.cs @@ -0,0 +1,145 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.Common; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// One C# fragment: the same text at a Razor offset and at a generated offset. +internal readonly record struct RazorMapping(int RazorStart, int GeneratedStart, int Length) +{ + public int RazorEnd => RazorStart + Length; + + public int GeneratedEnd => GeneratedStart + Length; +} + +/// +/// The verified map between a Razor file and its C# projection, built from the projection's +/// #line mappings. Every entry pairs two spans that hold the same text, measured by +/// comparing that text, because Roslyn reports a mapped span's end one character past the +/// fragment. Implements [RAZOR-ARCHITECTURE-PROJECTION]. +/// +internal sealed class RazorSpanMap(ImmutableArray mappings) +{ + public ImmutableArray Mappings => mappings; + + /// Build the map from the 's mappings into . + public static RazorSpanMap Build( + SyntaxTree tree, + SourceText generated, + SourceText razor, + string razorPath + ) + { + return new([ + .. tree.GetLineMappings() + .Where(mapping => Targets(mapping, razorPath)) + .Select(mapping => Measure(mapping, generated, razor)) + .OfType() + .OrderBy(mapping => mapping.RazorStart), + ]); + } + + /// + /// The generated offset of a Razor offset that some entry contains or ends at; the + /// containing entry wins, so a caret just after @curr still maps. + /// + public int? ToGenerated(int razorOffset) + { + var containing = mappings.FirstOrDefault(entry => + entry.RazorStart <= razorOffset && razorOffset < entry.RazorEnd + ); + var entry = containing.Length > 0 ? containing : EndingAt(razorOffset); + return entry is { } found ? found.GeneratedStart + (razorOffset - found.RazorStart) : null; + } + + /// The Razor span of a generated span that lies entirely inside one entry. + public TextSpan? ToRazor(TextSpan generated) + { + foreach (var entry in mappings) + { + if (entry.GeneratedStart <= generated.Start && generated.End <= entry.GeneratedEnd) + { + return new TextSpan( + entry.RazorStart + (generated.Start - entry.GeneratedStart), + generated.Length + ); + } + } + + return null; + } + + private RazorMapping? EndingAt(int razorOffset) + { + foreach (var entry in mappings) + { + if (entry.RazorEnd == razorOffset) + { + return entry; + } + } + + return null; + } + + private static bool Targets(LineMapping mapping, string razorPath) + { + return !mapping.IsHidden + && mapping.MappedSpan.HasMappedPath + && NativePaths.AreEqual(mapping.MappedSpan.Path, razorPath); + } + + private static RazorMapping? Measure( + LineMapping mapping, + SourceText generated, + SourceText razor + ) + { + var razorSpan = mapping.MappedSpan.Span; + if (!Fits(razor, razorSpan.Start) || !Fits(generated, mapping.Span.Start)) + { + return null; + } + + var razorStart = razor.Lines.GetPosition(razorSpan.Start); + var generatedLine = generated.Lines[mapping.Span.Start.Line]; + var generatedStart = generatedLine.Start + (mapping.CharacterOffset ?? 0); + var bound = Bound(razor, razorSpan.End) - razorStart; + var length = CommonLength(razor, razorStart, generated, generatedStart, bound); + return new RazorMapping(razorStart, generatedStart, length); + } + + private static bool Fits(SourceText text, LinePosition position) + { + return position.Line < text.Lines.Count + && position.Character <= text.Lines[position.Line].SpanIncludingLineBreak.Length; + } + + private static int Bound(SourceText razor, LinePosition end) + { + return Fits(razor, end) ? razor.Lines.GetPosition(end) : razor.Length; + } + + private static int CommonLength( + SourceText razor, + int razorStart, + SourceText generated, + int generatedStart, + int bound + ) + { + var length = 0; + while ( + length < bound + && razorStart + length < razor.Length + && generatedStart + length < generated.Length + && razor[razorStart + length] == generated[generatedStart + length] + ) + { + length++; + } + + return length; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorTagHelpers.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorTagHelpers.cs new file mode 100644 index 00000000..42cf09fc --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorTagHelpers.cs @@ -0,0 +1,60 @@ +using System.Runtime.CompilerServices; +using Microsoft.AspNetCore.Mvc.Razor.Extensions; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Razor; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// The tag helpers a project can use, components included: discovered by the compiler's own +/// over the project's compilation (its generated +/// components among them) and every reference it compiles against, project references +/// included. They are compiler state held only as long as that compilation instance lives, +/// never a feature result. Implements [RAZOR-ARCHITECTURE-ENGINE]. +/// +internal static class RazorTagHelpers +{ + private static readonly ConditionalWeakTable ByCompilation = + []; + + /// The tag helpers of . + public static TagHelperCollection Of(Compilation compilation) + { + return ByCompilation.GetValue(compilation, Discover); + } + + private static TagHelperCollection Discover(Compilation compilation) + { + var references = new List { compilation.ToMetadataReference() }; + references.AddRange(compilation.References); + var engine = RazorProjectEngine.Create( + RazorConfiguration.Default, + RazorProjectFileSystem.Empty, + builder => + { + builder.Features.Add( + new DefaultMetadataReferenceFeature { References = references } + ); + builder.Features.Add(new CompilationTagHelperFeature()); + CompilerFeatures.Register(builder); + RazorExtensions.Register(builder); + } + ); + return engine + .Engine.GetFeatures()[0] + .GetTagHelpers(CancellationToken.None); + } + + /// Hands an engine tag helpers already discovered for its compilation. + internal sealed class Feature(TagHelperCollection tagHelpers) + : RazorEngineFeatureBase, + ITagHelperFeature + { + /// + public TagHelperCollection GetTagHelpers(CancellationToken cancellationToken) + { + return tagHelpers; + } + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/WorkspaceRazorFileSystem.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/WorkspaceRazorFileSystem.cs new file mode 100644 index 00000000..9e076bc0 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/WorkspaceRazorFileSystem.cs @@ -0,0 +1,101 @@ +using System.Text; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.Common; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// The Razor engine's view of a project's files: the file being processed and every import +/// file (_Imports.razor, _ViewImports.cshtml), all with the workspace's current +/// text, so imports resolve as the compiler resolves them, unsaved edits included. +/// Implements [RAZOR-ARCHITECTURE-ENGINE]. +/// +internal sealed class WorkspaceRazorFileSystem : RazorProjectFileSystem +{ + private readonly Dictionary _items; + private readonly string _projectDirectory; + + private WorkspaceRazorFileSystem( + string projectDirectory, + Dictionary items + ) + { + _projectDirectory = projectDirectory; + _items = items; + } + + /// The file system for processing . + public static async Task CreateAsync( + TextDocument target, + RazorEngineSettings settings, + CancellationToken ct + ) + { + var items = new Dictionary(NativePaths.Comparer); + var documents = target.Project.AdditionalDocuments.Where(document => + document.Id == target.Id || IsImport(document.FilePath) + ); + foreach (var document in documents) + { + var text = await document.GetTextAsync(ct).ConfigureAwait(false); + var item = new WorkspaceRazorItem( + settings.RazorPathOf(document), + document.FilePath!, + text + ); + items[NativePaths.FromRazorProjectPath(settings.ProjectDirectory, item.FilePath)] = + item; + } + + return new WorkspaceRazorFileSystem(settings.ProjectDirectory, items); + } + + /// + public override IEnumerable EnumerateItems(string basePath) + { + return _items.Values; + } + + /// + public override RazorProjectItem GetItem(string path, RazorFileKind? fileKind) + { + var physical = NativePaths.FromRazorProjectPath(_projectDirectory, path); + return _items.TryGetValue(physical, out var item) + ? item + : new WorkspaceRazorItem(path, physical, null); + } + + private static bool IsImport(string? path) + { + return path is not null + && ( + NativePaths.SameName(path, "_Imports.razor") + || NativePaths.SameName(path, "_ViewImports.cshtml") + ); + } +} + +/// One Razor file as the engine reads it; null text is a file that does not exist. +internal sealed class WorkspaceRazorItem(string filePath, string physicalPath, SourceText? text) + : RazorProjectItem +{ + /// + public override string BasePath => "/"; + + /// + public override string FilePath => filePath; + + /// + public override string PhysicalPath => physicalPath; + + /// + public override bool Exists => text is not null; + + /// + public override Stream Read() + { + return new MemoryStream(Encoding.UTF8.GetBytes(text?.ToString() ?? ""), writable: false); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/RazorCompiler.props b/src/sidecars/SharpLsp.Sidecar.CSharp/RazorCompiler.props new file mode 100644 index 00000000..db17aefb --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/RazorCompiler.props @@ -0,0 +1,15 @@ + + + + $(MSBuildExtensionsPath)/Sdks/Microsoft.NET.Sdk.Razor/source-generators/ + + + + + + + diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/SharpLsp.Sidecar.CSharp.csproj b/src/sidecars/SharpLsp.Sidecar.CSharp/SharpLsp.Sidecar.CSharp.csproj index f5632c4c..04271bd7 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/SharpLsp.Sidecar.CSharp.csproj +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/SharpLsp.Sidecar.CSharp.csproj @@ -77,4 +77,7 @@ + + + diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DocumentText.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DocumentText.cs index de28be8b..0fe6f789 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DocumentText.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DocumentText.cs @@ -32,6 +32,32 @@ CancellationToken ct return (text, TextSpan.FromBounds(start, end)); } + /// + /// The offset of in ; null when the + /// line or character lies outside it, as a client position past a file's end does. + /// + public static int? TryOffset(SourceText text, LinePosition position) + { + return + position.Line >= 0 + && position.Line < text.Lines.Count + && position.Character >= 0 + && position.Character <= text.Lines[position.Line].Span.Length + ? text.Lines[position.Line].Start + position.Character + : null; + } + + /// The span of in ; null when either end lies outside it. + public static TextSpan? TrySpan(SourceText text, LinePositionSpan span) + { + return + TryOffset(text, span.Start) is { } start + && TryOffset(text, span.End) is { } end + && start <= end + ? TextSpan.FromBounds(start, end) + : null; + } + /// /// Project a single into a , /// resolving its span to start/end (line, character) coordinates against diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/ReferenceResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/ReferenceResolver.cs index d4a601c5..24943642 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/ReferenceResolver.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/ReferenceResolver.cs @@ -3,6 +3,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.FindSymbols; using Serilog; +using SharpLsp.Sidecar.Common; namespace SharpLsp.Sidecar.CSharp.Workspace; @@ -67,16 +68,21 @@ await scope.FindReferencesAsync(symbol, ct).ConfigureAwait(false), .ConfigureAwait(false); } - /// Find document highlights for the symbol at a position: this document only. + /// + /// Find document highlights for the symbol at a position: this document only, and only + /// where its locations map to . For a Razor file the document + /// is its projection and the answer path is the Razor file, so hidden scaffolding and + /// _Imports.razor lines drop out. [RAZOR-FEATURES-REFERENCES] + /// public static async Task> ResolveDocumentHighlightsAsync( Document document, - int line, - int character, + (int Line, int Character) at, + string answerPath, CancellationToken ct ) { var symbol = await DefinitionResolver - .ResolveSymbolAsync(document, line, character, ct) + .ResolveSymbolAsync(document, at.Line, at.Character, ct) .ConfigureAwait(false); if (symbol is null) { @@ -90,7 +96,7 @@ CancellationToken ct return [ .. referenced - .SelectMany(reference => HighlightsIn(document, reference)) + .SelectMany(reference => HighlightsIn(document, reference, answerPath)) .DistinctBy(highlight => ( highlight.StartLine, @@ -214,7 +220,8 @@ .. locations.DistinctBy(location => /// One referenced symbol's declaration (a write) and its uses in the document. private static IEnumerable HighlightsIn( Document document, - ReferencedSymbol reference + ReferencedSymbol reference, + string answerPath ) { var declarations = reference @@ -223,10 +230,12 @@ ReferencedSymbol reference && location.IsInSource && location.SourceTree?.FilePath == document.FilePath ) - .Select(location => ToHighlight(location, WriteKind)); + .Select(location => ToHighlight(location, WriteKind, answerPath)); var uses = reference .Locations.Where(use => use.Document.Id == document.Id) - .Select(use => ToHighlight(use.Location, IsWriteReference(use) ? WriteKind : ReadKind)); + .Select(use => + ToHighlight(use.Location, IsWriteReference(use) ? WriteKind : ReadKind, answerPath) + ); return declarations.Concat(uses).OfType(); } @@ -240,10 +249,14 @@ private static bool DeclaresItself(ReferencedSymbol reference) return reference.Definition is not IMethodSymbol { AssociatedSymbol: not null }; } - private static DocumentHighlightResult? ToHighlight(Location location, int kind) + private static DocumentHighlightResult? ToHighlight( + Location location, + int kind, + string answerPath + ) { var span = location.GetMappedLineSpan(); - return span.IsValid + return span.IsValid && NativePaths.AreEqual(span.Path, answerPath) ? new DocumentHighlightResult { StartLine = span.StartLinePosition.Line, diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SemanticTokensResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SemanticTokensResolver.cs index 03d43999..2c2b6649 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SemanticTokensResolver.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SemanticTokensResolver.cs @@ -4,12 +4,27 @@ namespace SharpLsp.Sidecar.CSharp.Workspace; +/// A span of text and its token type, the legend index. +internal readonly record struct SemanticSpan(TextSpan Span, int Type); + /// /// Computes semantic tokens for a document using Roslyn's Classifier API. /// Returns LSP-encoded token data (relative line, char, length, type, modifiers). /// internal static class SemanticTokensResolver { + /// The legend index of the class token type. + internal const int ClassType = 2; + + /// The legend index of the property token type. + internal const int PropertyType = 9; + + /// The legend index of the keyword token type. + internal const int KeywordType = 15; + + /// The legend index of the decorator token type. + internal const int DecoratorType = 22; + // LSP semantic token types — index into this array is the token type ID. private static readonly string[] TokenTypes = [ @@ -93,23 +108,38 @@ CancellationToken ct } private static int[] EncodeTokens(IEnumerable spans, SourceText text) + { + return Encode( + spans.Select(span => new SemanticSpan( + span.TextSpan, + MapClassification(span.ClassificationType) + )), + text + ); + } + + /// + /// LSP-encode typed spans of in document order. A span with no + /// token type (negative) is skipped. Shared by C# documents and Razor files. + /// + internal static int[] Encode(IEnumerable spans, SourceText text) { var data = new List(); var prevLine = 0; var prevChar = 0; - foreach (var span in spans.OrderBy(s => s.TextSpan.Start)) + foreach (var span in spans.OrderBy(s => s.Span.Start)) { - var tokenType = MapClassification(span.ClassificationType); + var tokenType = span.Type; if (tokenType < 0) { continue; } - var linePos = text.Lines.GetLinePosition(span.TextSpan.Start); + var linePos = text.Lines.GetLinePosition(span.Span.Start); var line = linePos.Line; var character = linePos.Character; - var length = span.TextSpan.Length; + var length = span.Span.Length; var deltaLine = line - prevLine; var deltaChar = deltaLine == 0 ? character - prevChar : character; @@ -127,7 +157,8 @@ private static int[] EncodeTokens(IEnumerable spans, SourceText return [.. data]; } - private static int MapClassification(string classification) + /// The token type of a Roslyn classification; -1 when it has none. + internal static int MapClassification(string classification) { return classification switch { diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SolutionPaths.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SolutionPaths.cs index 7c2d1dc7..a71ec531 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SolutionPaths.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SolutionPaths.cs @@ -29,6 +29,20 @@ internal static IEnumerable DocumentsAt(Solution solution, string file .Where(document => NativePaths.AreEqual(document.FilePath, filePath)); } + /// + /// Every additional document at : a Razor file is one, once per + /// target framework of each project that lists it. [RAZOR-ARCHITECTURE-DOCUMENTS] + /// + internal static IEnumerable AdditionalDocumentsAt( + Solution solution, + string filePath + ) + { + return solution + .Projects.SelectMany(project => project.AdditionalDocuments) + .Where(document => NativePaths.AreEqual(document.FilePath, filePath)); + } + /// Find a loaded project by its project-file path. internal static Project? FindProject(Solution solution, string projectPath) { diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/TargetFrameworks.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/TargetFrameworks.cs index 5810f9f7..0f5c7b86 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/TargetFrameworks.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/TargetFrameworks.cs @@ -73,11 +73,55 @@ ConcurrentDictionary chosen ConcurrentDictionary chosen ) { - var candidates = SolutionPaths.DocumentsAt(solution, filePath).ToList(); + return FindActive(solution, SolutionPaths.DocumentsAt(solution, filePath), chosen); + } + + /// + /// The additional document (a Razor file) at in its project's + /// active framework. [RAZOR-ARCHITECTURE-DOCUMENTS] + /// + internal static TextDocument? FindActiveAdditionalDocument( + Solution solution, + string filePath, + ConcurrentDictionary chosen + ) + { + return FindActive( + solution, + SolutionPaths.AdditionalDocumentsAt(solution, filePath), + chosen + ); + } + + private static T? FindActive( + Solution solution, + IEnumerable documents, + ConcurrentDictionary chosen + ) + where T : TextDocument + { + var candidates = documents.ToList(); return candidates.Find(document => IsActive(solution, document.Project, chosen)) ?? candidates.FirstOrDefault(); } + /// + /// applied to EVERY framework's copy of the additional document + /// (a Razor file) at . [RAZOR-ARCHITECTURE-DOCUMENTS] + /// + internal static Solution WithAdditionalTextInEveryContext( + Solution solution, + string filePath, + SourceText text + ) + { + return SolutionPaths + .AdditionalDocumentsAt(solution, filePath) + .Select(document => document.Id) + .ToList() + .Aggregate(solution, (current, id) => current.WithAdditionalDocumentText(id, text)); + } + /// /// applied to EVERY framework's copy of the document: /// an edit that reached only one context would leave the others stale. diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Diagnostics.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Diagnostics.cs index 312d54f8..1026d920 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Diagnostics.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Diagnostics.cs @@ -1,5 +1,6 @@ using Microsoft.CodeAnalysis; using Serilog; +using SharpLsp.Sidecar.Common; using DiagnosticsResult = Outcome.Result< System.Collections.Generic.List, string @@ -44,8 +45,14 @@ public async Task GetDiagnosticsAsync( // the same instant, or a slow semantic-model computation can pair a // pre-restore compilation with a post-restore notice state and present // phantom package errors as final. [SCRIPT-FILEBASED-REFERENCES-FALLBACK] - var state = await CaptureDiagnosticsStateAsync(filePath, ct).ConfigureAwait(false); - var diagnostics = await DiagnoseAsync(filePath, state, ct).ConfigureAwait(false); + var diagnostics = NativePaths.IsRazor(filePath) + ? await DiagnoseRazorAsync(filePath, ct).ConfigureAwait(false) + : await DiagnoseAsync( + filePath, + await CaptureDiagnosticsStateAsync(filePath, ct).ConfigureAwait(false), + ct + ) + .ConfigureAwait(false); return new DiagnosticsResult.Ok, string>(diagnostics); } catch (Exception ex) diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Features.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Features.cs index 921b5b56..1c3feb9e 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Features.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Features.cs @@ -1,3 +1,5 @@ +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.Common; using CodeLensesResult = Outcome.Result< System.Collections.Generic.List, string @@ -101,17 +103,19 @@ public Task GetSemanticTokensFullAsync( CancellationToken ct = default ) { - return RunDocumentQueryAsync( - filePath, - new SemanticTokensResult(), - async document => new SemanticTokensResult - { - Data = await SemanticTokensResolver - .GetFullAsync(document, ct) - .ConfigureAwait(false), - }, - ct - ); + return NativePaths.IsRazor(filePath) + ? GetRazorSemanticTokensAsync(filePath, null, ct) + : RunDocumentQueryAsync( + filePath, + new SemanticTokensResult(), + async document => new SemanticTokensResult + { + Data = await SemanticTokensResolver + .GetFullAsync(document, ct) + .ConfigureAwait(false), + }, + ct + ); } /// Get semantic tokens for a range. @@ -124,6 +128,15 @@ public Task GetSemanticTokensRangeAsync( CancellationToken ct = default ) { + if (NativePaths.IsRazor(filePath)) + { + var range = new LinePositionSpan( + new LinePosition(startLine, startCharacter), + new LinePosition(endLine, endCharacter) + ); + return GetRazorSemanticTokensAsync(filePath, range, ct); + } + return RunDocumentQueryAsync( filePath, new SemanticTokensResult(), diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignRename.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignRename.cs index 3d475547..3b2c4ca2 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignRename.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignRename.cs @@ -3,6 +3,7 @@ using Microsoft.CodeAnalysis.FindSymbols; using Microsoft.CodeAnalysis.Text; using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Razor; using RenameEditResult = Outcome.Result; using RenameIdentityQueryResult = Outcome.Result< SharpLsp.Sidecar.CSharp.RenameIdentityResultWire, @@ -144,7 +145,35 @@ ForeignRenameRequest request request.CancellationToken ) .ConfigureAwait(false); - return ForeignRenameSuccess(edits); + var razor = await ForeignRazorEditsAsync(request).ConfigureAwait(false); + return ForeignRenameSuccess([.. edits, .. razor]); + } + + /// + /// The Razor usages of the foreign symbol, edited in their Razor files: its C# usages in + /// the projections are hidden from the editable references above. [RAZOR-FSHARP] + /// + private static async Task> ForeignRazorEditsAsync( + ForeignRenameRequest request + ) + { + var edits = new List(); + var symbols = await ResolveForeignSymbolsAsync( + request.Solution, + request.Identity, + request.CancellationToken + ) + .ConfigureAwait(false); + foreach (var symbol in symbols) + { + edits.AddRange( + await RazorRenameEdits + .FindAsync(request.Solution, symbol, request.NewName, request.CancellationToken) + .ConfigureAwait(false) + ); + } + + return edits; } private static bool IsForeignRequestValid(string assemblyName, string xmlDocSig, string newName) @@ -253,6 +282,7 @@ IEnumerable found private static bool IsEditableReference(ReferenceLocation location) { return location.Location.IsInSource + && location.Document is not SourceGeneratedDocument && location.Location.SourceSpan.Length > 0 && location.Document.FilePath is not null && IsCSharpProject(location.Document.Project); diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs index 8f315619..7a5eb38b 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs @@ -2,6 +2,7 @@ using Microsoft.CodeAnalysis.Completion; using Microsoft.CodeAnalysis.Text; using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Razor; namespace SharpLsp.Sidecar.CSharp.Workspace; @@ -12,6 +13,7 @@ internal sealed partial class WorkspaceManager { private CompletionList? _lastCompletionList; private Document? _lastCompletionDocument; + private RazorProjection? _lastCompletionOrigin; private static List MapDiagnostics( string filePath, @@ -22,8 +24,10 @@ CancellationToken ct var results = new List(); foreach (var diag in model.GetDiagnostics(cancellationToken: ct)) { + // A diagnostic belongs to the file its location maps to: a projection's hidden + // scaffolding maps nowhere and drops out. [RAZOR-FEATURES-DIAGNOSTICS] var span = diag.Location.GetMappedLineSpan(); - if (!span.IsValid) + if (!span.IsValid || !NativePaths.AreEqual(span.Path, filePath)) { continue; } @@ -60,14 +64,17 @@ private async Task> GetCompletionsCoreAsync( CancellationToken ct ) { - var document = await FindDocumentAsync(filePath, ct).ConfigureAwait(false); - if (document is null) + if ( + await ResolveQueryTargetAsync(filePath, (line, character), ct).ConfigureAwait(false) + is not { } target + ) { return []; } + var document = target.Document; var text = await document.GetTextAsync(ct).ConfigureAwait(false); - var position = text.Lines.GetPosition(new LinePosition(line, character)); + var position = text.Lines.GetPosition(new LinePosition(target.Line, target.Character)); var service = CompletionService.GetService(document); if (service is null) @@ -85,8 +92,12 @@ CancellationToken ct _lastCompletionList = completions; _lastCompletionDocument = document; + _lastCompletionOrigin = target.Origin; var editSpan = ComputeCompletionEditSpan(service, text, position); - return MapCompletionItems(completions, editSpan); + return RazorResults.MapCompletions( + MapCompletionItems(completions, editSpan), + target.Origin + ); } // Roslyn's default completion span covers the typed prefix to the LEFT of the @@ -184,16 +195,14 @@ _lastCompletionList is null var text = await _lastCompletionDocument.GetTextAsync(ct).ConfigureAwait(false); var completionSpan = roslynItem.Span; - foreach (var textChange in change.TextChanges) - { - if (textChange.Span.IntersectsWith(completionSpan)) - { - continue; - } - - result.AdditionalEdits.Add(DocumentText.ToTextEdit(text, textChange)); - } - + var additional = change.TextChanges.Where(textChange => + !textChange.Span.IntersectsWith(completionSpan) + ); + result.AdditionalEdits.AddRange( + _lastCompletionOrigin is { } origin + ? RazorCompletionEdits.Map(origin, additional) + : additional.Select(textChange => DocumentText.ToTextEdit(text, textChange)) + ); return result; } @@ -228,8 +237,8 @@ CancellationToken ct foreach (var tree in compilation.SyntaxTrees) { ct.ThrowIfCancellationRequested(); - var filePath = tree.FilePath; - if (string.IsNullOrEmpty(filePath) || IsGeneratedBuildOutput(filePath)) + var filePath = await AnswerPathAsync(tree, ct).ConfigureAwait(false); + if (filePath is null) { continue; } @@ -243,6 +252,20 @@ CancellationToken ct } } + /// + /// The file a tree's diagnostics belong to: the Razor file a projection names, else the + /// tree's own file; null for build output, whose diagnostics are never the user's. + /// [RAZOR-FEATURES-DIAGNOSTICS] + /// + private static async Task AnswerPathAsync(SyntaxTree tree, CancellationToken ct) + { + var root = await tree.GetRootAsync(ct).ConfigureAwait(false); + var razor = RazorProjection.ChecksumPath(root); + return NativePaths.IsRazor(razor) ? razor + : string.IsNullOrEmpty(tree.FilePath) || IsGeneratedBuildOutput(tree.FilePath) ? null + : tree.FilePath; + } + /// /// MSBuild-generated files under obj/ (AssemblyInfo, GlobalUsings, /// AssemblyAttributes) belong to the build system, not the user. Diagnostics @@ -326,6 +349,34 @@ CancellationToken ct } } + /// + /// A position query that searches beyond the document: also + /// gets each project's active framework to search, and its locations come back mapped + /// out of Razor projections. [NETFX-PROJECTS-CSHARP] [RAZOR-FEATURES-REFERENCES] + /// + private Task> RunScopedLocationQueryAsync( + string filePath, + (int Line, int Character) at, + Func> resolve, + CancellationToken ct + ) + { + return RunPositionQueryAsync( + filePath, + at, + new LocationListResult(), + async target => + { + var scope = await SearchScope + .OfAsync(target.Document, _activeFrameworks, ct) + .ConfigureAwait(false); + var found = await resolve(target, scope).ConfigureAwait(false); + return await MapLocationsAsync(found, ct).ConfigureAwait(false); + }, + ct + ); + } + /// /// for a query that searches beyond the /// document: also gets each project's active framework to diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Razor.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Razor.cs new file mode 100644 index 00000000..4288c4df --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Razor.cs @@ -0,0 +1,205 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Razor; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +/// +/// Where a position query runs: a document, the position in it, and the Razor file the +/// request named when the document is that file's projection. +/// +internal readonly record struct QueryTarget( + Document Document, + int Line, + int Character, + RazorProjection? Origin +) +{ + /// The file the answer belongs to: the Razor file, else the document itself. + public string AnswerPath => Origin?.RazorPath ?? Document.FilePath ?? ""; +} + +/// +/// Razor files in the workspace: their text, and position queries translated into their C# +/// projections. Implements [RAZOR-ARCHITECTURE-DOCUMENTS] and [RAZOR-ARCHITECTURE-PROJECTION]. +/// +internal sealed partial class WorkspaceManager +{ + /// The projection of the Razor file at in its active framework. + internal async Task FindRazorProjectionAsync( + string razorPath, + CancellationToken ct + ) + { + var razor = FindRazorDocument(razorPath); + return razor is null + ? null + : await RazorProjection.ForRazorAsync(razor, ct).ConfigureAwait(false); + } + + /// + /// The markup facts of the Razor file at ; null when no project + /// lists it or the engine cannot read it. [RAZOR-ARCHITECTURE-ENGINE] + /// + internal async Task FindRazorMarkupAsync(string razorPath, CancellationToken ct) + { + var razor = FindRazorDocument(razorPath); + return razor is null + ? null + : await RazorMarkupEngine.ProcessAsync(razor, ct).ConfigureAwait(false); + } + + /// The Razor file at in its active framework; null when no project lists it. + internal TextDocument? FindRazorDocument(string razorPath) + { + return _solution is null + ? null + : TargetFrameworks.FindActiveAdditionalDocument( + _solution, + razorPath, + _activeFrameworks + ); + } + + /// + /// Run where the position lands: the document itself, or, for a + /// Razor file, its projection at the mapped position. A position outside every C# + /// fragment, or a file outside the workspace, answers . + /// + private async Task> RunPositionQueryAsync( + string filePath, + (int Line, int Character) at, + T empty, + Func> query, + CancellationToken ct + ) + { + try + { + var target = await ResolveQueryTargetAsync(filePath, at, ct).ConfigureAwait(false); + var value = target is { } found ? await query(found).ConfigureAwait(false) : empty; + return new Outcome.Result.Ok(value); + } + catch (Exception ex) + { + return Outcome.Result.Failure(ex.Message); + } + } + + private async Task ResolveQueryTargetAsync( + string filePath, + (int Line, int Character) at, + CancellationToken ct + ) + { + if (!NativePaths.IsRazor(filePath)) + { + var document = await FindDocumentAsync(filePath, ct).ConfigureAwait(false); + return document is null ? null : new QueryTarget(document, at.Line, at.Character, null); + } + + var projection = await FindRazorProjectionAsync(filePath, ct).ConfigureAwait(false); + return projection?.ToGenerated(new LinePosition(at.Line, at.Character)) is { } mapped + ? new QueryTarget(projection.Generated, mapped.Line, mapped.Character, projection) + : null; + } + + /// + /// Replace a Razor file's text in every framework context. Caller holds + /// . A file no project lists has nothing to update. + /// + private void UpdateRazorText(string filePath, string newText) + { + _solution = TargetFrameworks.WithAdditionalTextInEveryContext( + _solution!, + filePath, + SourceText.From(newText) + ); + } + + /// + /// A Razor file's diagnostics: its projection's C# diagnostics that map into it. + /// [RAZOR-FEATURES-DIAGNOSTICS] + /// + private async Task> DiagnoseRazorAsync( + string filePath, + CancellationToken ct + ) + { + var projection = await FindRazorProjectionAsync(filePath, ct).ConfigureAwait(false); + var model = projection is null + ? null + : await projection.Generated.GetSemanticModelAsync(ct).ConfigureAwait(false); + return model is null ? [] : MapDiagnostics(filePath, model, ct); + } + + /// + /// A Razor file's semantic tokens, all of them or those that start in + /// . [RAZOR-FEATURES-SEMANTIC-TOKENS] + /// + private async Task> GetRazorSemanticTokensAsync( + string filePath, + LinePositionSpan? range, + CancellationToken ct + ) + { + try + { + var projection = await FindRazorProjectionAsync(filePath, ct).ConfigureAwait(false); + var data = projection is null + ? [] + : await RazorTokensAsync(projection, range, ct).ConfigureAwait(false); + return new Outcome.Result.Ok< + SemanticTokensResult, + string + >(new SemanticTokensResult { Data = data }); + } + catch (Exception ex) + { + return Outcome.Result.Failure(ex.Message); + } + } + + private static async Task RazorTokensAsync( + RazorProjection projection, + LinePositionSpan? range, + CancellationToken ct + ) + { + var text = projection.RazorText; + var window = range is { } requested ? DocumentText.TrySpan(text, requested) : null; + var spans = await RazorSemanticTokens + .CSharpSpansAsync(projection, ct) + .ConfigureAwait(false); + return SemanticTokensResolver.Encode( + spans + .Where(span => window is not { } inside || inside.Contains(span.Span.Start)) + .Where(span => span.Type >= 0) + .DistinctBy(span => span.Span.Start), + text + ); + } + + /// with projection locations mapped to Razor files or dropped. + private Task MapLocationsAsync( + LocationListResult result, + CancellationToken ct + ) + { + return _solution is null + ? Task.FromResult(result) + : RazorLocations.MapAsync(_solution, result, ct); + } + + /// mapped to a Razor file, or null when it is hidden scaffolding. + private async Task MapLocationAsync( + LocationResult? location, + CancellationToken ct + ) + { + return _solution is null + ? location + : await RazorLocations.MapAsync(_solution, location, ct).ConfigureAwait(false); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Rename.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Rename.cs index b2ec115e..e2c31307 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Rename.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Rename.cs @@ -1,6 +1,8 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Razor; using PrepareRenameQueryResult = Outcome.Result< SharpLsp.Sidecar.CSharp.PrepareRenameResult, string @@ -37,9 +39,9 @@ public async Task PrepareRenameAsync( { try { - var target = await FindRenameTargetAsync(filePath, line, character, ct) + var (target, origin) = await FindRenameTargetAsync(filePath, line, character, ct) .ConfigureAwait(false); - return PrepareResult(target); + return PrepareSuccess(RazorResults.MapPrepareRename(PrepareResult(target), origin)); } catch (Exception ex) { @@ -76,7 +78,7 @@ private async Task RenameCoreAsync( CancellationToken ct ) { - var target = await FindRenameTargetAsync(filePath, line, character, ct) + var (target, _) = await FindRenameTargetAsync(filePath, line, character, ct) .ConfigureAwait(false); if (target is null || _solution is null || !CanUseNewName(target.Value, newName)) { @@ -85,20 +87,38 @@ CancellationToken ct var renamed = await RenameSolutionAsync(_solution, target.Value.Symbol, newName, ct) .ConfigureAwait(false); - return await BuildRenameResultAsync(_solution, renamed, newName, ct).ConfigureAwait(false); + var edits = await BuildRenameEditsAsync(_solution, renamed, newName, ct) + .ConfigureAwait(false); + var razor = await RazorRenameEdits + .FindAsync(_solution, target.Value.Symbol, newName, ct) + .ConfigureAwait(false); + return RenameSuccess(new WorkspaceEditResult { DocumentChanges = [.. edits, .. razor] }); } - private async Task FindRenameTargetAsync( + /// + /// The rename target at a position, and the Razor file it was asked from. A Razor position + /// outside every C# fragment has no target; any other file must be in the workspace. + /// [RAZOR-FEATURES-RENAME] + /// + private async Task<(RenameTarget? Target, RazorProjection? Origin)> FindRenameTargetAsync( string filePath, int line, int character, CancellationToken ct ) { - var document = - await FindDocumentAsync(filePath, ct).ConfigureAwait(false) - ?? throw new InvalidOperationException("Document not found"); - return await FindRenameTargetAsync(document, line, character, ct).ConfigureAwait(false); + var resolved = await ResolveQueryTargetAsync(filePath, (line, character), ct) + .ConfigureAwait(false); + if (resolved is not { } at) + { + return NativePaths.IsRazor(filePath) + ? (null, null) + : throw new InvalidOperationException("Document not found"); + } + + var target = await FindRenameTargetAsync(at.Document, at.Line, at.Character, ct) + .ConfigureAwait(false); + return (target, at.Origin); } private static async Task FindRenameTargetAsync( @@ -146,15 +166,15 @@ private static bool IsSourceSymbol(ISymbol symbol) return symbol.Locations.Any(location => location.IsInSource); } - private static PrepareRenameQueryResult PrepareResult(RenameTarget? target) + private static PrepareRenameResult PrepareResult(RenameTarget? target) { if (target is null) { - return PrepareSuccess(new PrepareRenameResult { CanRename = false }); + return new PrepareRenameResult { CanRename = false }; } var lineSpan = target.Value.Text.Lines.GetLinePositionSpan(target.Value.Token.Span); - return PrepareSuccess(CreatePrepareResult(target.Value.Token.Text, lineSpan)); + return CreatePrepareResult(target.Value.Token.Text, lineSpan); } private static PrepareRenameResult CreatePrepareResult( @@ -279,7 +299,7 @@ CancellationToken ct ); } - private static async Task BuildRenameResultAsync( + private static async Task> BuildRenameEditsAsync( Solution original, Solution renamed, string newName, @@ -294,7 +314,7 @@ await AddChangedDocumentsAsync(solutions, projectChange, edits, ct) .ConfigureAwait(false); } - return RenameSuccess(new WorkspaceEditResult { DocumentChanges = edits }); + return edits; } private static async Task AddChangedDocumentsAsync( diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.SignatureHelp.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.SignatureHelp.cs index 7e017f20..d5c2b55a 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.SignatureHelp.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.SignatureHelp.cs @@ -9,7 +9,8 @@ internal sealed partial class WorkspaceManager { /// /// Signature help at a position: when the caret is not - /// inside a call's argument list, or the file is not in the workspace. + /// inside a call's argument list, or the file is not in the workspace. A Razor position + /// answers from its projection ([RAZOR-FEATURES-SIGNATURE-HELP]). /// Implements [SHARPLSP-FEATURES-INTELLIGENCE-SIGNATURE-HELP] (GitHub #174). /// public Task GetSignatureHelpAsync( @@ -19,10 +20,17 @@ public Task GetSignatureHelpAsync( CancellationToken ct = default ) { - return RunDocumentQueryAsync( + return RunPositionQueryAsync( filePath, + (line, character), null, - document => SignatureHelpResolver.ResolveAsync(document, line, character, ct), + target => + SignatureHelpResolver.ResolveAsync( + target.Document, + target.Line, + target.Character, + ct + ), ct ); } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs index 3f8b797d..4f9b4089 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs @@ -138,7 +138,7 @@ public async Task UpdateDocumentTextAsync( { try { - if (_isProjectlessDirectory) + if (_isProjectlessDirectory && !NativePaths.IsRazor(filePath)) { await _projectlessLoadLock.WaitAsync(ct).ConfigureAwait(false); try @@ -171,6 +171,12 @@ public async Task UpdateDocumentTextAsync( return new VoidResult.Ok(Unit.Value); } + if (NativePaths.IsRazor(filePath)) + { + UpdateRazorText(filePath, newText); + return new VoidResult.Ok(Unit.Value); + } + var document = await FindDocumentAsync(filePath, ct).ConfigureAwait(false); if (document is null) { @@ -297,44 +303,39 @@ public async Task GetCompletionsAsync( } /// Get hover information at a position. - public async Task GetHoverAsync( + public Task GetHoverAsync( string filePath, int line, int character, CancellationToken ct = default ) { - try - { - Log.Debug("[Hover] request {File}:{Line}:{Character}", filePath, line, character); - var document = await FindDocumentAsync(filePath, ct).ConfigureAwait(false); - if (document is null) - { - Log.Debug("[Hover] document not found: {File}", filePath); - return new HoverQueryResult.Ok(null); - } - - var text = await document.GetTextAsync(ct).ConfigureAwait(false); - var position = text.Lines.GetPosition(new LinePosition(line, character)); - var model = await document.GetSemanticModelAsync(ct).ConfigureAwait(false); - if (model is null) - { - Log.Debug("[Hover] semantic model is null: {File}", filePath); - return new HoverQueryResult.Ok(null); - } + Log.Debug("[Hover] request {File}:{Line}:{Character}", filePath, line, character); + return RunPositionQueryAsync( + filePath, + (line, character), + null, + target => HoverAtAsync(target, ct), + ct + ); + } - var result = CSharpHoverBuilder.Build(model, position, ct); - var outcome = result is HoverQueryResult.Ok { Value: not null } - ? "content" - : "null"; - Log.Debug("[Hover] result: {Outcome}", outcome); - return result; - } - catch (Exception ex) + private static async Task HoverAtAsync(QueryTarget target, CancellationToken ct) + { + var text = await target.Document.GetTextAsync(ct).ConfigureAwait(false); + var model = await target.Document.GetSemanticModelAsync(ct).ConfigureAwait(false); + if (model is null) { - Log.Debug(ex, "[Hover] request failed"); - return HoverQueryResult.Failure(ex.Message); + return null; } + + var position = text.Lines.GetPosition(new LinePosition(target.Line, target.Character)); + var built = CSharpHoverBuilder.Build(model, position, ct); + // The hover's range is the token's mapped span, already in the Razor file for a + // projection. [RAZOR-FEATURES-HOVER] + return built is HoverQueryResult.Ok { Value: var hover } + ? hover + : throw new InvalidOperationException(!built ?? "Hover failed"); } /// Go to definition at a position (returns all locations for partial types). @@ -345,11 +346,23 @@ public Task GetDefinitionAsync( CancellationToken ct = default ) { - return RunDocumentQueryAsync( + return RunPositionQueryAsync( filePath, + (line, character), new LocationListResult(), - document => - DefinitionResolver.ResolveDefinitionLocationsAsync(document, line, character, ct), + async target => + await MapLocationsAsync( + await DefinitionResolver + .ResolveDefinitionLocationsAsync( + target.Document, + target.Line, + target.Character, + ct + ) + .ConfigureAwait(false), + ct + ) + .ConfigureAwait(false), ct ); } @@ -362,11 +375,23 @@ public Task GetTypeDefinitionAsync( CancellationToken ct = default ) { - return RunDocumentQueryAsync( + return RunPositionQueryAsync( filePath, + (line, character), null, - document => - DefinitionResolver.ResolveTypeDefinitionAsync(document, line, character, ct), + async target => + await MapLocationAsync( + await DefinitionResolver + .ResolveTypeDefinitionAsync( + target.Document, + target.Line, + target.Character, + ct + ) + .ConfigureAwait(false), + ct + ) + .ConfigureAwait(false), ct ); } @@ -379,10 +404,23 @@ public Task GetDeclarationAsync( CancellationToken ct = default ) { - return RunDocumentQueryAsync( + return RunPositionQueryAsync( filePath, + (line, character), null, - document => DefinitionResolver.ResolveDeclarationAsync(document, line, character, ct), + async target => + await MapLocationAsync( + await DefinitionResolver + .ResolveDeclarationAsync( + target.Document, + target.Line, + target.Character, + ct + ) + .ConfigureAwait(false), + ct + ) + .ConfigureAwait(false), ct ); } @@ -395,11 +433,17 @@ public Task GetImplementationsAsync( CancellationToken ct = default ) { - return RunScopedQueryAsync( + return RunScopedLocationQueryAsync( filePath, - new LocationListResult(), - (document, scope) => - ReferenceResolver.ResolveImplementationsAsync(document, scope, line, character, ct), + (line, character), + (target, scope) => + ReferenceResolver.ResolveImplementationsAsync( + target.Document, + scope, + target.Line, + target.Character, + ct + ), ct ); } @@ -412,15 +456,15 @@ public Task GetReferencesAsync( CancellationToken ct = default ) { - return RunScopedQueryAsync( + return RunScopedLocationQueryAsync( filePath, - new LocationListResult(), - (document, scope) => + (line, character), + (target, scope) => ReferenceResolver.ResolveReferencesAsync( - document, + target.Document, scope, - line, - character, + target.Line, + target.Character, includeDeclaration, ct ), @@ -488,13 +532,19 @@ public Task GetDocumentHighlightsAsync( CancellationToken ct = default ) { - return RunDocumentQueryAsync( + return RunPositionQueryAsync( filePath, + (line, character), new DocumentHighlightListResult(), - async document => new DocumentHighlightListResult + async target => new DocumentHighlightListResult { Highlights = await ReferenceResolver - .ResolveDocumentHighlightsAsync(document, line, character, ct) + .ResolveDocumentHighlightsAsync( + target.Document, + (target.Line, target.Character), + target.AnswerPath, + ct + ) .ConfigureAwait(false), }, ct @@ -612,6 +662,12 @@ private void ReplayPendingTextEdits() foreach (var (filePath, newText) in _pendingTextEdits) { + if (NativePaths.IsRazor(filePath)) + { + UpdateRazorText(filePath, newText); + continue; + } + var document = SolutionPaths.FindDocument(_solution, filePath); if (document is not null) { diff --git a/src/sidecars/SharpLsp.Sidecar.Common/NativePaths.Razor.cs b/src/sidecars/SharpLsp.Sidecar.Common/NativePaths.Razor.cs new file mode 100644 index 00000000..12a24b25 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.Common/NativePaths.Razor.cs @@ -0,0 +1,27 @@ +namespace SharpLsp.Sidecar.Common; + +/// +/// How the Razor compiler spells a file: rooted at its project directory, with forward +/// slashes (/Components/Pages/Board.razor). Implements [RAZOR-SCOPE-FILES] and +/// [RAZOR-ARCHITECTURE-ENGINE]. +/// +public static partial class NativePaths +{ + /// Whether is a Razor file: a .razor component or a .cshtml page or view. + public static bool IsRazor(string? path) + { + return path is not null && (HasExtension(path, ".razor") || HasExtension(path, ".cshtml")); + } + + /// as the Razor compiler names it inside . + public static string ToRazorProjectPath(string projectDirectory, string path) + { + return "/" + Slashed(RelativeTo(projectDirectory, path)); + } + + /// The file a Razor project path names inside . + public static string FromRazorProjectPath(string projectDirectory, string razorPath) + { + return Resolve(projectDirectory, razorPath.TrimStart('/')); + } +} From 3bc8285bdf8a74637a34a8def7c3132435db5468 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:21:46 +1100 Subject: [PATCH 02/38] Format RazorCompiler.props, and keep the repository's analyzer severities out of the Razor fixtures The root .editorconfig raises every analyzer diagnostic to an error, and .editorconfig files cascade by folder, so the fixtures' empty Directory.Build.props did not stop CA1716 failing BlazorApp's build. A root .editorconfig in the fixture folder does. Refs #397 --- src/sharplsp/tests/fixtures/razor/.editorconfig | 4 ++++ .../SharpLsp.Sidecar.CSharp/RazorCompiler.props | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 src/sharplsp/tests/fixtures/razor/.editorconfig 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/sidecars/SharpLsp.Sidecar.CSharp/RazorCompiler.props b/src/sidecars/SharpLsp.Sidecar.CSharp/RazorCompiler.props index db17aefb..6f9b45f1 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/RazorCompiler.props +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/RazorCompiler.props @@ -8,8 +8,17 @@ $(MSBuildExtensionsPath)/Sdks/Microsoft.NET.Sdk.Razor/source-generators/ - - - + + + From 084d5ce342407b60ee8060c75656c408834f80a6 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:32:55 +1100 Subject: [PATCH 03/38] Video search and playable social previews for every docs film Every catalogued video gets a watch page in each language, a bare embed player, a 1200x630 social card, a video sitemap entry and complete VideoObject data, so Google can index the films and X, LinkedIn and Slack can play them in link previews. Every docs page gets its own description. [WEB-DESIGN-MEDIA-DISCOVERY] --- docs/specs/DESIGN-SYSTEM.md | 19 ++- src/website/eleventy.config.js | 24 +-- src/website/src/_data/eleventyComputed.js | 7 + src/website/src/_data/i18n.json | 15 ++ src/website/src/_data/site.json | 1 + src/website/src/_data/videoPages.js | 4 + src/website/src/_data/videos.json | 64 +++++--- src/website/src/_includes/layouts/base.njk | 50 ++++-- .../src/_includes/overrides/robots.txt.njk | 64 ++++++++ .../src/_includes/overrides/sitemap.njk | 46 ++++++ src/website/src/assets/css/pages.css | 3 + src/website/src/assets/css/prose.css | 2 + src/website/src/assets/js/custom.js | 12 ++ .../src/assets/videos/architecture-card.jpg | Bin 0 -> 95668 bytes .../assets/videos/code-completions-card.jpg | Bin 0 -> 79526 bytes .../src/assets/videos/configuration-card.jpg | Bin 0 -> 35114 bytes .../src/assets/videos/context-menus-card.jpg | Bin 0 -> 38565 bytes .../src/assets/videos/contributing-card.jpg | Bin 0 -> 27588 bytes .../src/assets/videos/diagnostics-card.jpg | Bin 0 -> 84018 bytes .../src/assets/videos/editor-setup-card.jpg | Bin 0 -> 42575 bytes .../assets/videos/fsharp-language-card.jpg | Bin 0 -> 91295 bytes .../assets/videos/getting-started-card.jpg | Bin 0 -> 90247 bytes src/website/src/assets/videos/hover-card.jpg | Bin 0 -> 46928 bytes .../src/assets/videos/navigation-card.jpg | Bin 0 -> 102461 bytes src/website/src/assets/videos/nuget-card.jpg | Bin 0 -> 67310 bytes .../videos/profiler-cpu-recording-card.jpg | Bin 0 -> 143084 bytes .../assets/videos/profiler-csharp-card.jpg | Bin 0 -> 146268 bytes .../src/assets/videos/profiler-film-card.jpg | Bin 0 -> 66669 bytes .../videos/profiler-fix-memory-leak-card.jpg | Bin 0 -> 51122 bytes .../assets/videos/profiler-fsharp-card.jpg | Bin 0 -> 84276 bytes .../videos/profiler-memory-timelapse-card.jpg | Bin 0 -> 73022 bytes .../src/assets/videos/refactoring-card.jpg | Bin 0 -> 80351 bytes .../src/assets/videos/sharplsp-tour-card.jpg | Bin 0 -> 91553 bytes src/website/src/docs/architecture.md | 1 + src/website/src/docs/completions.md | 1 + src/website/src/docs/configuration.md | 1 + src/website/src/docs/context-menus.md | 1 + src/website/src/docs/contributing.md | 1 + src/website/src/docs/diagnostics.md | 2 +- src/website/src/docs/editors.md | 1 + src/website/src/docs/fsharp-profiling.md | 1 - src/website/src/docs/fsharp.md | 2 +- src/website/src/docs/go-to-definition.md | 2 +- src/website/src/docs/hover.md | 2 +- src/website/src/docs/index.md | 1 + src/website/src/docs/memory-leaks.md | 1 - src/website/src/docs/nuget.md | 1 + src/website/src/docs/profiler.md | 2 +- src/website/src/docs/refactoring.md | 1 + src/website/src/ja/docs/architecture.md | 1 + src/website/src/ja/docs/completions.md | 1 + src/website/src/ja/docs/configuration.md | 1 + src/website/src/ja/docs/context-menus.md | 1 + src/website/src/ja/docs/contributing.md | 1 + src/website/src/ja/docs/diagnostics.md | 2 +- src/website/src/ja/docs/editors.md | 1 + src/website/src/ja/docs/fsharp-profiling.md | 1 - src/website/src/ja/docs/fsharp.md | 2 +- src/website/src/ja/docs/go-to-definition.md | 2 +- src/website/src/ja/docs/hover.md | 2 +- src/website/src/ja/docs/index.md | 1 + src/website/src/ja/docs/memory-leaks.md | 1 - src/website/src/ja/docs/nuget.md | 1 + src/website/src/ja/docs/profiler.md | 2 +- src/website/src/ja/docs/refactoring.md | 1 + src/website/src/videos/embed.njk | 23 +++ src/website/src/videos/videos.11tydata.js | 9 ++ src/website/src/videos/watch.njk | 25 +++ src/website/src/zh/docs/architecture.md | 1 + src/website/src/zh/docs/completions.md | 1 + src/website/src/zh/docs/configuration.md | 1 + src/website/src/zh/docs/context-menus.md | 1 + src/website/src/zh/docs/contributing.md | 1 + src/website/src/zh/docs/diagnostics.md | 2 +- src/website/src/zh/docs/editors.md | 1 + src/website/src/zh/docs/fsharp-profiling.md | 1 - src/website/src/zh/docs/fsharp.md | 2 +- src/website/src/zh/docs/go-to-definition.md | 2 +- src/website/src/zh/docs/hover.md | 2 +- src/website/src/zh/docs/index.md | 1 + src/website/src/zh/docs/memory-leaks.md | 1 - src/website/src/zh/docs/nuget.md | 1 + src/website/src/zh/docs/profiler.md | 2 +- src/website/src/zh/docs/refactoring.md | 1 + src/website/tests/media.js | 9 ++ src/website/tests/video-discovery.spec.js | 84 ++++++++++ src/website/tests/video-watch-pages.spec.js | 148 ++++++++++++++++++ .../tools/profiler-film/kit/encode.mjs | 11 ++ src/website/video-figure.js | 72 ++++++--- src/website/video-pages.js | 80 ++++++++++ 90 files changed, 751 insertions(+), 84 deletions(-) create mode 100644 src/website/src/_data/eleventyComputed.js create mode 100644 src/website/src/_data/videoPages.js create mode 100644 src/website/src/_includes/overrides/robots.txt.njk create mode 100644 src/website/src/_includes/overrides/sitemap.njk create mode 100644 src/website/src/assets/videos/architecture-card.jpg create mode 100644 src/website/src/assets/videos/code-completions-card.jpg create mode 100644 src/website/src/assets/videos/configuration-card.jpg create mode 100644 src/website/src/assets/videos/context-menus-card.jpg create mode 100644 src/website/src/assets/videos/contributing-card.jpg create mode 100644 src/website/src/assets/videos/diagnostics-card.jpg create mode 100644 src/website/src/assets/videos/editor-setup-card.jpg create mode 100644 src/website/src/assets/videos/fsharp-language-card.jpg create mode 100644 src/website/src/assets/videos/getting-started-card.jpg create mode 100644 src/website/src/assets/videos/hover-card.jpg create mode 100644 src/website/src/assets/videos/navigation-card.jpg create mode 100644 src/website/src/assets/videos/nuget-card.jpg create mode 100644 src/website/src/assets/videos/profiler-cpu-recording-card.jpg create mode 100644 src/website/src/assets/videos/profiler-csharp-card.jpg create mode 100644 src/website/src/assets/videos/profiler-film-card.jpg create mode 100644 src/website/src/assets/videos/profiler-fix-memory-leak-card.jpg create mode 100644 src/website/src/assets/videos/profiler-fsharp-card.jpg create mode 100644 src/website/src/assets/videos/profiler-memory-timelapse-card.jpg create mode 100644 src/website/src/assets/videos/refactoring-card.jpg create mode 100644 src/website/src/assets/videos/sharplsp-tour-card.jpg create mode 100644 src/website/src/videos/embed.njk create mode 100644 src/website/src/videos/videos.11tydata.js create mode 100644 src/website/src/videos/watch.njk create mode 100644 src/website/tests/video-discovery.spec.js create mode 100644 src/website/tests/video-watch-pages.spec.js create mode 100644 src/website/video-pages.js diff --git a/docs/specs/DESIGN-SYSTEM.md b/docs/specs/DESIGN-SYSTEM.md index 2cf5196c..552f9962 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 ` - private async Task> RunDocumentQueryAsync( + private Task> RunDocumentQueryAsync( string filePath, TValue emptyValue, Func> resolve, CancellationToken ct ) + { + return RunQueryAsync(() => FindDocumentAsync(filePath, ct), emptyValue, resolve); + } + + /// + /// The skeleton every query shares: find what it runs on, answer + /// when there is nothing, else run . Any exception becomes a + /// failure carrying its message. + /// + private static async Task> RunQueryAsync( + Func> find, + TValue empty, + Func> query + ) + where TSubject : class { try { - var document = await FindDocumentAsync(filePath, ct).ConfigureAwait(false); - var value = document is null - ? emptyValue - : await resolve(document).ConfigureAwait(false); + var subject = await find().ConfigureAwait(false); + var value = subject is null ? empty : await query(subject).ConfigureAwait(false); return new Outcome.Result.Ok(value); } catch (Exception ex) diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Razor.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Razor.cs index 4288c4df..6ac330bc 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Razor.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Razor.cs @@ -9,7 +9,7 @@ namespace SharpLsp.Sidecar.CSharp.Workspace; /// Where a position query runs: a document, the position in it, and the Razor file the /// request named when the document is that file's projection. /// -internal readonly record struct QueryTarget( +internal sealed record QueryTarget( Document Document, int Line, int Character, @@ -67,7 +67,7 @@ CancellationToken ct /// Razor file, its projection at the mapped position. A position outside every C# /// fragment, or a file outside the workspace, answers . /// - private async Task> RunPositionQueryAsync( + private Task> RunPositionQueryAsync( string filePath, (int Line, int Character) at, T empty, @@ -75,16 +75,7 @@ CancellationToken ct CancellationToken ct ) { - try - { - var target = await ResolveQueryTargetAsync(filePath, at, ct).ConfigureAwait(false); - var value = target is { } found ? await query(found).ConfigureAwait(false) : empty; - return new Outcome.Result.Ok(value); - } - catch (Exception ex) - { - return Outcome.Result.Failure(ex.Message); - } + return RunQueryAsync(() => ResolveQueryTargetAsync(filePath, at, ct), empty, query); } private async Task ResolveQueryTargetAsync( @@ -138,27 +129,20 @@ CancellationToken ct /// A Razor file's semantic tokens, all of them or those that start in /// . [RAZOR-FEATURES-SEMANTIC-TOKENS] /// - private async Task> GetRazorSemanticTokensAsync( + private Task> GetRazorSemanticTokensAsync( string filePath, LinePositionSpan? range, CancellationToken ct ) { - try - { - var projection = await FindRazorProjectionAsync(filePath, ct).ConfigureAwait(false); - var data = projection is null - ? [] - : await RazorTokensAsync(projection, range, ct).ConfigureAwait(false); - return new Outcome.Result.Ok< - SemanticTokensResult, - string - >(new SemanticTokensResult { Data = data }); - } - catch (Exception ex) - { - return Outcome.Result.Failure(ex.Message); - } + return RunQueryAsync( + () => FindRazorProjectionAsync(filePath, ct), + new SemanticTokensResult(), + async projection => new SemanticTokensResult + { + Data = await RazorTokensAsync(projection, range, ct).ConfigureAwait(false), + } + ); } private static async Task RazorTokensAsync( diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorStructure.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorStructure.cs index 83e53819..c10f17d8 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorStructure.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorStructure.cs @@ -83,22 +83,13 @@ .. spans.Select(span => } /// Run on a Razor file's markup facts; when it has none. - private async Task> RunMarkupQueryAsync( + private Task> RunMarkupQueryAsync( string filePath, T empty, Func> query, CancellationToken ct ) { - try - { - var markup = await FindRazorMarkupAsync(filePath, ct).ConfigureAwait(false); - var value = markup is null ? empty : await query(markup).ConfigureAwait(false); - return new Outcome.Result.Ok(value); - } - catch (Exception ex) - { - return Outcome.Result.Failure(ex.Message); - } + return RunQueryAsync(() => FindRazorMarkupAsync(filePath, ct), empty, query); } } From 9131ec0a8ae1364856cc861f16bcae495283bd82 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:47:20 +1100 Subject: [PATCH 10/38] Razor spec: declare the compiler types the markup facts model uses, so the typeDiagram generates Refs #397 --- docs/specs/RAZOR-SPEC.md | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/specs/RAZOR-SPEC.md b/docs/specs/RAZOR-SPEC.md index fcece55f..6ab0cdd3 100644 --- a/docs/specs/RAZOR-SPEC.md +++ b/docs/specs/RAZOR-SPEC.md @@ -217,9 +217,35 @@ type MarkupDiagnostic { 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 {} ``` -`TextSpan` is a Roslyn text span, and the descriptor and severity types are the Razor compiler's own. The facts hold no C# fragment spans: C# positions come from the projection's map ([RAZOR-ARCHITECTURE-PROJECTION]). +The facts hold no C# fragment spans: C# positions come from the projection's map ([RAZOR-ARCHITECTURE-PROJECTION]). ### [RAZOR-ARCHITECTURE-HTML] HTML Projection From dfc9d495fd63d9e71c74095266afb07213b2b86f Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:47:51 +1100 Subject: [PATCH 11/38] Razor HTML projection and markup-context tokenizer - The HTML projection blanks every non-HTML character of a Razor file, keeping offsets and newlines, from the compiler's IR; component HTML comments are restored from a second parse with component parsing off - A WHATWG-state tokenizer over the projection finds the markup context at a caret - The sidecar serves sharplsp/razor/htmlProjection, and the structure handlers are registered - End tag names tolerate whitespace before > and match plain elements case-insensitively Refs #397 --- .../HtmlMarkupContextTests.cs | 248 ++++++++++++ .../RazorHtmlProjectionTests.cs | 146 +++++++ .../CSharpSidecar.Razor.cs | 55 +++ .../SharpLsp.Sidecar.CSharp/CSharpSidecar.cs | 4 + .../Razor/HtmlMarkupContext.cs | 377 ++++++++++++++++++ .../Razor/RazorHtmlComments.cs | 117 ++++++ .../Razor/RazorHtmlProjection.cs | 169 ++++++++ .../SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs | 36 +- .../Razor/RazorMarkup.cs | 9 + .../Razor/RazorMarkupCapture.cs | 7 +- .../Razor/RazorMarkupEngine.cs | 6 +- .../Workspace/WorkspaceManager.RazorHtml.cs | 18 + 12 files changed, 1181 insertions(+), 11 deletions(-) create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HtmlMarkupContextTests.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorHtmlProjectionTests.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Razor.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/HtmlMarkupContext.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHtmlComments.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHtmlProjection.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorHtml.cs 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..014c823f --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HtmlMarkupContextTests.cs @@ -0,0 +1,248 @@ +#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.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("")); + Assert.Equal(MarkupContext.Content, Of("a < b|")); + Assert.Equal(MarkupContext.Content, Of("")); + Assert.Equal(MarkupContext.Content, Of("<b|")); + } + + [Fact] + public void Raw_text_matches_its_end_tag_case_insensitively() + { + Assert.Equal(MarkupContext.Content, Of("")); + Assert.Equal(MarkupContext.TagName, Of("|")); + } + + [Fact] + public void Raw_text_end_tag_names_flip_to_tag_name_only_when_appropriate() + { + Assert.Equal(MarkupContext.TagName, Of("x")); + Assert.Equal(MarkupContext.Content, Of("", "Content")] + [InlineData("
", + "

" + )] + [InlineData("", "")] + [InlineData("
ok
", "
ok
")] + [InlineData("
ok
", "
ok
")] + [InlineData("
", "")] + [InlineData(" *@", " ")] + public async Task Projection_query_preserves_html_and_utf16_offsets( + string text, + string expected + ) + { + using var manager = await fixture.OpenManagerAsync(); + var path = fixture.Path("BlazorApp", "Components", "Pages", "Board.razor"); + _ = RazorFixture.Ok(await manager.UpdateDocumentTextAsync(path, text)); + var projection = RazorFixture.Ok(await manager.GetRazorHtmlProjectionAsync(path, default)); + Assert.NotNull(projection); + Assert.Equal(text.Length, projection.Text.Length); + Assert.Equal(expected, projection.Text); + } + + [Theory] + [InlineData("@code { string Hidden = \"\"; }")] + [InlineData("@{ string hidden = \"\"; }")] + public async Task Html_comments_inside_csharp_strings_stay_masked(string text) + { + using var manager = await fixture.OpenManagerAsync(); + var path = fixture.Path("BlazorApp", "Components", "Pages", "Board.razor"); + _ = RazorFixture.Ok(await manager.UpdateDocumentTextAsync(path, text)); + var projection = RazorFixture.Ok(await manager.GetRazorHtmlProjectionAsync(path, default)); + Assert.NotNull(projection); + Assert.Equal(text.Length, projection.Text.Length); + Assert.DoesNotContain("hidden", projection.Text, StringComparison.Ordinal); + Assert.EndsWith("", projection.Text, StringComparison.Ordinal); + } + + [Fact] + public async Task Projection_query_returns_null_for_unlisted_and_non_razor_documents() + { + using var manager = await fixture.OpenManagerAsync(); + foreach ( + var path in new[] + { + fixture.Path("Absent.razor"), + fixture.Path("BlazorApp", "Program.cs"), + } + ) + { + var projection = RazorFixture.Ok( + await manager.GetRazorHtmlProjectionAsync(path, default) + ); + Assert.Null(projection); + Assert.Null(await manager.FindRazorMarkupAsync(path, default)); + } + } + + [Fact] + public async Task Projection_retains_component_tags_and_reacts_to_unsaved_edits() + { + using var manager = await fixture.OpenManagerAsync(); + var path = fixture.Path("BlazorApp", "Components", "Pages", "Board.razor"); + var before = await manager.FindRazorMarkupAsync(path, default); + Assert.NotNull(before); + Assert.Contains("", before.HtmlText, StringComparison.Ordinal); + Assert.DoesNotContain("@code", before.HtmlText, StringComparison.Ordinal); + _ = await manager.UpdateDocumentTextAsync( + path, + "
@DateTime.Now
" + ); + var after = await manager.FindRazorMarkupAsync(path, default); + Assert.NotNull(after); + Assert.Equal("
", after.HtmlText); + Assert.DoesNotContain("Panel", after.HtmlText, StringComparison.Ordinal); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Razor.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Razor.cs new file mode 100644 index 00000000..6cb68bbd --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Razor.cs @@ -0,0 +1,55 @@ +using MessagePack; +using ByteResult = Outcome.Result; + +namespace SharpLsp.Sidecar.CSharp; + +internal sealed partial class CSharpSidecar +{ + // Implements [RAZOR-ARCHITECTURE-ROUTING]. + private Task HandleDocumentSymbolAsync(byte[] payload, CancellationToken ct) + { + return HandleRequestAsync( + payload, + (PositionRequest request) => _workspace.GetDocumentSymbolsAsync(request.FilePath, ct), + ct + ); + } + + private Task HandleFoldingRangeAsync(byte[] payload, CancellationToken ct) + { + return HandleRequestAsync( + payload, + (PositionRequest request) => _workspace.GetFoldingRangesAsync(request.FilePath, ct), + ct + ); + } + + private Task HandleLinkedEditingRangeAsync(byte[] payload, CancellationToken ct) + { + return HandlePositionRequestAsync( + payload, + (file, line, character, token) => + _workspace.GetLinkedEditingRangesAsync(file, (line, character), token), + ct + ); + } + + // Implements [RAZOR-ARCHITECTURE-HTML]. + private Task HandleRazorHtmlProjectionAsync(byte[] payload, CancellationToken ct) + { + return HandleRequestAsync( + payload, + (PositionRequest request) => + _workspace.GetRazorHtmlProjectionAsync(request.FilePath, ct), + ct + ); + } +} + +/// The position-preserving HTML projection, [RAZOR-ARCHITECTURE-HTML]. +[MessagePackObject(AllowPrivate = true)] +internal sealed class HtmlProjectionResult +{ + [Key(0)] + public string Text { get; set; } = ""; +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.cs index 37ae1c6c..64758d9e 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.cs @@ -25,6 +25,7 @@ public CSharpSidecar() Register("textDocument/completion", HandleCompletionAsync); Register("completionItem/resolve", HandleCompletionResolveAsync); Register("textDocument/hover", HandleHoverAsync); + Register("sharplsp/razor/htmlProjection", HandleRazorHtmlProjectionAsync); Register("textDocument/signatureHelp", HandleSignatureHelpAsync); Register("textDocument/definition", HandleDefinitionAsync); Register("textDocument/typeDefinition", HandleTypeDefinitionAsync); @@ -44,6 +45,9 @@ public CSharpSidecar() Register("textDocument/formatting", HandleFormattingAsync); Register("textDocument/rangeFormatting", HandleRangeFormattingAsync); Register("textDocument/onTypeFormatting", HandleOnTypeFormattingAsync); + Register("textDocument/documentSymbol", HandleDocumentSymbolAsync); + Register("textDocument/foldingRange", HandleFoldingRangeAsync); + Register("textDocument/linkedEditingRange", HandleLinkedEditingRangeAsync); Register("textDocument/semanticTokens/full", HandleSemanticTokensFullAsync); Register("textDocument/semanticTokens/range", HandleSemanticTokensRangeAsync); Register("textDocument/inlayHint", HandleInlayHintAsync); diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/HtmlMarkupContext.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/HtmlMarkupContext.cs new file mode 100644 index 00000000..4bd5fbbc --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/HtmlMarkupContext.cs @@ -0,0 +1,377 @@ +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// Where the caret sits in projected HTML. Implements [RAZOR-ARCHITECTURE-HTML]. +/// +internal enum MarkupContext +{ + /// Text between tags, inside comments or declarations, or raw text. + Content, + + /// On a start or end tag's name, or right after <. + TagName, + + /// On an attribute's name, or where a new attribute may start. + AttributeName, + + /// Inside a quoted or unquoted attribute value. + AttributeValue, +} + +/// +/// The HTML markup context at a caret: a WHATWG-style tokenizer folded over the projection's +/// prefix up to the caret. Pure — every step maps an immutable state and an input character to +/// the next state. Implements [RAZOR-ARCHITECTURE-HTML]. +/// +internal static class HtmlMarkupContext +{ + /// The markup context at ; out-of-range offsets clamp. + public static MarkupContext At(string html, int offset) + { + return Context(ScanTo(html, Math.Clamp(offset, 0, html.Length)).State); + } + + /// The tokenizer states the caret context derives from. + private enum Scanner + { + Data, + TagOpen, + EndTagOpen, + TagName, + BeforeAttribute, + Attribute, + AfterAttribute, + BeforeValue, + ValueDoubleQuoted, + ValueSingleQuoted, + ValueUnquoted, + AfterValue, + SelfClosing, + RawText, + ScriptEscaped, + ScriptDoubleEscaped, + } + + /// The tokenizer state. + /// The current start tag's name; the raw-text target while in . + /// Whether the current tag is an end tag. + private readonly record struct Scan(Scanner State, string Tag, bool EndTag); + + private static Scan ScanTo(string html, int end) + { + var scan = Data(); + var i = 0; + while (i < end) + { + (scan, i) = Advance(scan, html, i, end); + } + + return scan; + } + + private static (Scan Next, int Index) Advance(Scan s, string html, int i, int end) + { + return (s.State, html[i]) switch + { + (Scanner.TagOpen, '!') => Declaration(html, i), + (Scanner.TagOpen, '?') => (Data(), Bogus(html, i + 1)), + (Scanner.RawText, _) when s.Tag == "script" => Script(s, html, i), + (Scanner.ScriptEscaped or Scanner.ScriptDoubleEscaped, _) => Script(s, html, i), + (Scanner.RawText, _) => RawText(s, html, i, end), + _ => (Step(s, html[i]), i + 1), + }; + } + + private static Scan Step(Scan s, char c) + { + return s.State switch + { + Scanner.Data => c == '<' ? Data(Scanner.TagOpen) : s, + Scanner.TagOpen => TagOpen(s, c), + Scanner.EndTagOpen => EndTagOpen(s, c), + Scanner.TagName => TagName(s, c), + Scanner.BeforeAttribute => BeforeAttribute(s, c), + Scanner.Attribute or Scanner.AfterAttribute => Attribute(s, c), + Scanner.BeforeValue => BeforeValue(s, c), + Scanner.ValueDoubleQuoted => c == '"' ? Kept(Scanner.AfterValue, s) : s, + Scanner.ValueSingleQuoted => c == '\'' ? Kept(Scanner.AfterValue, s) : s, + Scanner.ValueUnquoted => ValueUnquoted(s, c), + Scanner.AfterValue => AfterValue(s, c), + Scanner.SelfClosing => c == '>' ? Close(s) : Kept(Scanner.BeforeAttribute, s), + Scanner.RawText or Scanner.ScriptEscaped or Scanner.ScriptDoubleEscaped => s, + _ => s, + }; + } + + private static Scan TagOpen(Scan s, char c) + { + return c switch + { + '/' => Data(Scanner.EndTagOpen), + _ => char.IsAsciiLetter(c) ? Named(s, c, false) : Data(), + }; + } + + private static Scan EndTagOpen(Scan s, char c) + { + return c switch + { + '>' => Close(s), + _ => char.IsAsciiLetter(c) ? Named(s, c, true) : Data(), + }; + } + + private static Scan TagName(Scan s, char c) + { + return c switch + { + '>' => Close(s), + '/' => Kept(Scanner.SelfClosing, s), + _ => IsSpace(c) ? Kept(Scanner.BeforeAttribute, s) : Named(s, c, s.EndTag), + }; + } + + private static Scan BeforeAttribute(Scan s, char c) + { + return c switch + { + '>' => Close(s), + '/' => Kept(Scanner.SelfClosing, s), + _ => IsSpace(c) ? s : Kept(Scanner.Attribute, s), + }; + } + + private static Scan Attribute(Scan s, char c) + { + return c switch + { + '>' => Close(s), + '/' => Kept(Scanner.SelfClosing, s), + '=' => Kept(Scanner.BeforeValue, s), + _ => IsSpace(c) ? Kept(Scanner.AfterAttribute, s) : s, + }; + } + + private static Scan BeforeValue(Scan s, char c) + { + return c switch + { + '"' => Kept(Scanner.ValueDoubleQuoted, s), + '\'' => Kept(Scanner.ValueSingleQuoted, s), + '>' => Close(s), + _ => IsSpace(c) ? s : Kept(Scanner.ValueUnquoted, s), + }; + } + + private static Scan ValueUnquoted(Scan s, char c) + { + return c switch + { + '>' => Close(s), + _ => IsSpace(c) ? Kept(Scanner.BeforeAttribute, s) : s, + }; + } + + private static Scan AfterValue(Scan s, char c) + { + return c switch + { + '>' => Close(s), + '/' => Kept(Scanner.SelfClosing, s), + _ => IsSpace(c) ? Kept(Scanner.BeforeAttribute, s) : Kept(Scanner.Attribute, s), + }; + } + + /// A completed start tag of a raw-text element switches to its raw text. + private static Scan Close(Scan s) + { + return !s.EndTag && IsRawText(s.Tag) ? new Scan(Scanner.RawText, s.Tag, false) : Data(); + } + + /// + /// Raw text consumes everything until its appropriate end tag; the </name then + /// re-enters the normal tag states ([RAZOR-ARCHITECTURE-HTML]). + /// + private static (Scan Next, int Index) RawText(Scan s, string html, int i, int end) + { + if (html[i] != '<') + { + return (s, i + 1); + } + + var close = "= 0 && !EndsTag(html, j + close.Length, html.Length)) + { + j = html.IndexOf(close, j + 1, StringComparison.OrdinalIgnoreCase); + } + + return j < 0 ? (s, end) : (Data(), j); + } + + private static (Scan Next, int Index) Script(Scan s, string html, int i) + { + return s switch + { + { State: Scanner.RawText } when IsAt(html, i, "") => (Kept(Scanner.RawText, s), i + 3), + { State: Scanner.ScriptEscaped } when AtTag(html, i, " ( + Kept(Scanner.ScriptDoubleEscaped, s), + i + 7 + ), + _ => ScriptEnd(s, html, i), + }; + } + + private static (Scan Next, int Index) ScriptEnd(Scan s, string html, int i) + { + return !AtTag(html, i, "After <!: comments end at --> or --!>, the rest at >. + private static (Scan, int) Declaration(string html, int bang) + { + return IsAt(html, bang + 1, "--") + ? (Data(), SkipComment(html, bang + 3)) + : ( + Data(), + IsDoctype(html, bang + 1) ? DoctypeEnd(html, bang + 1) : Bogus(html, bang + 1) + ); + } + + private static bool IsDoctype(string html, int i) + { + return html.AsSpan(i).StartsWith("DOCTYPE", StringComparison.OrdinalIgnoreCase); + } + + private static int DoctypeEnd(string html, int start) + { + var quote = '\0'; + for (var i = start; i < html.Length; i++) + { + if (quote == '\0' && html[i] == '>') + { + return i + 1; + } + + quote = + html[i] == quote ? '\0' + : quote == '\0' && html[i] is '\'' or '"' ? html[i] + : quote; + } + return html.Length; + } + + private static int SkipComment(string html, int i) + { + if (Abrupt(html, i) is { } closed) + { + return closed; + } + + for (var j = i; j < html.Length; j++) + { + if (IsAt(html, j, "-->") || IsAt(html, j, "--!>")) + { + return j + (html[j + 2] == '!' ? 4 : 3); + } + } + + return html.Length; + } + + // and close abruptly ([WHATWG]). + private static int? Abrupt(string html, int i) + { + return IsAt(html, i, ">") ? i + 1 + : IsAt(html, i, "->") ? i + 2 + : null; + } + + private static int Bogus(string html, int i) + { + var close = html.IndexOf('>', i); + return close < 0 ? html.Length : close + 1; + } + + private static MarkupContext Context(Scanner state) + { + return state switch + { + Scanner.TagOpen or Scanner.EndTagOpen or Scanner.TagName => MarkupContext.TagName, + Scanner.SelfClosing + or Scanner.BeforeAttribute + or Scanner.Attribute + or Scanner.AfterAttribute + or Scanner.AfterValue => MarkupContext.AttributeName, + Scanner.BeforeValue + or Scanner.ValueDoubleQuoted + or Scanner.ValueSingleQuoted + or Scanner.ValueUnquoted => MarkupContext.AttributeValue, + Scanner.Data + or Scanner.RawText + or Scanner.ScriptEscaped + or Scanner.ScriptDoubleEscaped => MarkupContext.Content, + _ => MarkupContext.Content, + }; + } + + private static Scan Data(Scanner state = Scanner.Data) + { + return new(state, string.Empty, false); + } + + private static Scan Kept(Scanner state, Scan s) + { + return new(state, s.Tag, s.EndTag); + } + + private static Scan Named(Scan s, char c, bool endTag) + { + return new(Scanner.TagName, s.Tag + char.ToLowerInvariant(c), endTag); + } + + private static bool EndsTag(string html, int k, int end) + { + return k >= end || IsSpace(html[k]) || html[k] is '/' or '>'; + } + + private static bool IsSpace(char c) + { + return c is ' ' or '\t' or '\n' or '\f' or '\r'; + } + + private static bool IsAt(string html, int i, string text) + { + return i >= 0 + && i + text.Length <= html.Length + && string.CompareOrdinal(html, i, text, 0, text.Length) == 0; + } + + // Raw-text and RCDATA elements: markup inside them is text ([RAZOR-ARCHITECTURE-HTML]). + private static bool IsRawText(string tag) + { + return tag + is "script" + or "style" + or "textarea" + or "title" + or "xmp" + or "iframe" + or "noembed" + or "noframes"; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHtmlComments.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHtmlComments.cs new file mode 100644 index 00000000..9f3922a4 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHtmlComments.cs @@ -0,0 +1,117 @@ +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Intermediate; +using Microsoft.CodeAnalysis.Razor; +using Microsoft.CodeAnalysis.Text; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// Component lowering discards HTML comments. A markup-only compiler pass retains their +/// token boundaries; only those spans are restored. Implements [RAZOR-ARCHITECTURE-HTML]. +/// +internal static class RazorHtmlComments +{ + public static RazorMarkup Restore( + RazorProjectEngine original, + RazorCodeDocument document, + RazorMarkup markup, + CancellationToken ct + ) + { + if (!document.FileKind.IsComponent()) + { + return markup; + } + + var spans = ReadComments(original, document, ct); + return markup with + { + HtmlText = RestoreSpans(document.Source.Text, markup.HtmlText, spans), + }; + } + + private static List ReadComments( + RazorProjectEngine original, + RazorCodeDocument document, + CancellationToken ct + ) + { + var comments = new CommentPass(); + var engine = CreateEngine(original, comments); + var input = RazorCodeDocument.Create( + document.Source, + document.Imports, + document.ParserOptions.WithFlags(allowComponentFileKind: false), + document.CodeGenerationOptions + ); + _ = engine.Engine.Process(input, ct); + return comments.Spans; + } + + private static string RestoreSpans(SourceText source, string html, IEnumerable spans) + { + var chars = html.ToCharArray(); + foreach (var span in spans) + { + source.CopyTo(span.Start, chars, span.Start, span.Length); + } + return new string(chars); + } + + private static RazorProjectEngine CreateEngine( + RazorProjectEngine original, + CommentPass comments + ) + { + return RazorProjectEngine.Create( + original.Configuration, + original.FileSystem, + builder => + { + CompilerFeatures.Register(builder); + builder.Features.Add(comments); + } + ); + } + + private sealed class CommentPass : IntermediateNodePassBase, IRazorDirectiveClassifierPass + { + public override int Order => int.MinValue; + public List Spans { get; } = []; + + protected override void ExecuteCore( + RazorCodeDocument document, + DocumentIntermediateNode root, + CancellationToken ct + ) + { + int? start = null; + foreach (var token in RazorIr.Descendants(root).OfType()) + { + start = ReadToken(token, start); + } + if (start is { } remaining) + { + Spans.Add(TextSpan.FromBounds(remaining, document.Source.Text.Length)); + } + } + + private int? ReadToken(HtmlIntermediateToken token, int? start) + { + if (token.IsImported || token.Source is not { } source) + { + return start; + } + if (start is null && token.Content == "") + { + Spans.Add(TextSpan.FromBounds(first, source.AbsoluteIndex + source.Length)); + return null; + } + return start; + } + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHtmlProjection.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHtmlProjection.cs new file mode 100644 index 00000000..7c8c88e7 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHtmlProjection.cs @@ -0,0 +1,169 @@ +using Microsoft.AspNetCore.Razor.Language.Intermediate; +using Microsoft.CodeAnalysis.Text; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// Keeps only compiler-owned HTML spans. Everything else starts blank, including Razor +/// comments and C# transitions absent from the IR. Implements [RAZOR-ARCHITECTURE-HTML]. +/// +internal static class RazorHtmlProjection +{ + public static string Create(SourceText text, DocumentIntermediateNode root) + { + var chars = text.ToString().Select(Blank).ToArray(); + Write(root, text, chars); + return new string(chars); + } + + private static void Write(IntermediateNode node, SourceText text, char[] chars) + { + if (node.IsImported) + { + return; + } + + WriteNode(node, text, chars); + foreach (var child in node.Children) + { + Write(child, text, chars); + } + } + + private static void WriteNode(IntermediateNode node, SourceText text, char[] chars) + { + if (RazorIr.Tag(node, text) is { } tag) + { + WriteTag(node, tag, text, chars); + } + else + { + WriteLiteral(node, text, chars); + } + } + + private static void WriteLiteral(IntermediateNode node, SourceText text, char[] chars) + { + if (node.Source is not { } source) + { + return; + } + + var span = new TextSpan(source.AbsoluteIndex, source.Length); + if (node is HtmlIntermediateToken or HtmlAttributeValueIntermediateNode) + { + Copy(text, chars, span); + } + else if (node is HtmlAttributeIntermediateNode attribute) + { + CopyAttribute(text, chars, span, attribute); + } + } + + private static void CopyAttribute( + SourceText text, + char[] chars, + TextSpan span, + HtmlAttributeIntermediateNode attribute + ) + { + var prefixLength = attribute.Prefix?.Length ?? 0; + var suffixLength = attribute.Suffix?.Length ?? 0; + Copy(text, chars, new TextSpan(span.Start, prefixLength)); + Copy(text, chars, new TextSpan(span.End - suffixLength, suffixLength)); + } + + private static void WriteTag( + IntermediateNode node, + MarkupTag tag, + SourceText text, + char[] chars + ) + { + var attributes = Attributes(node); + Copy( + text, + chars, + TextSpan.FromBounds(tag.Element.Start, OpeningEnd(text, tag.Element, attributes)) + ); + foreach (var attribute in attributes) + { + Clear(chars, attribute); + } + + if (tag.EndName is { } end) + { + Copy(text, chars, TextSpan.FromBounds(end.Start - 2, tag.Element.End)); + } + } + + private static TextSpan[] Attributes(IntermediateNode node) + { + return + [ + .. node + .Children.Where(IsAttribute) + .Where(child => child.Source is not null) + .Select(child => new TextSpan( + child.Source!.Value.AbsoluteIndex, + child.Source.Value.Length + )), + ]; + } + + private static bool IsAttribute(IntermediateNode node) + { + return node + is HtmlAttributeIntermediateNode + or TagHelperHtmlAttributeIntermediateNode + or TagHelperPropertyIntermediateNode + or TagHelperDirectiveAttributeIntermediateNode + or TagHelperDirectiveAttributeParameterIntermediateNode; + } + + /// Tokenizes only an IR-owned opening tag; compiler attribute spans are opaque. + private static int OpeningEnd(SourceText text, TextSpan element, TextSpan[] attributes) + { + var quote = '\0'; + foreach ( + var offset in Enumerable + .Range(element.Start, element.Length) + .Where(index => !attributes.Any(span => span.Contains(index))) + ) + { + var character = text[offset]; + if (quote == '\0' && character == '>') + { + return offset + 1; + } + + quote = + character == quote ? '\0' + : quote == '\0' && character is '\'' or '"' ? character + : quote; + } + + return element.End; + } + + private static void Copy(SourceText text, char[] chars, TextSpan span) + { + if (span.End <= text.Length) + { + text.CopyTo(span.Start, chars, span.Start, span.Length); + } + } + + private static void Clear(char[] chars, TextSpan span) + { + for (var offset = span.Start; offset < span.End && offset < chars.Length; offset++) + { + chars[offset] = Blank(chars[offset]); + } + } + + private static char Blank(char character) + { + return character is '\r' or '\n' ? character : ' '; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs index 2ea4f3b8..37a9d5a6 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs @@ -169,7 +169,13 @@ private static MarkupAttribute Html(string name, MarkupTag tag) { var start = new TextSpan(element.Start + 1, name.Length); return Holds(text, start, name) - ? new MarkupTag(name, element, start, EndName(element, name, text), []) + ? new MarkupTag( + name, + element, + start, + EndName(element, name, text, StringComparison.OrdinalIgnoreCase), + [] + ) : null; } @@ -188,14 +194,24 @@ private static MarkupAttribute Html(string name, MarkupTag tag) } /// The name of an element's end tag, </Name> closing . - private static TextSpan? EndName(TextSpan element, string name, SourceText text) + private static TextSpan? EndName( + TextSpan element, + string name, + SourceText text, + StringComparison comparison = StringComparison.Ordinal + ) { - var end = new TextSpan(element.End - 1 - name.Length, name.Length); + var nameEnd = element.End - 1; + while (nameEnd > element.Start && text[nameEnd - 1] is ' ' or '\t' or '\r' or '\n' or '\f') + { + nameEnd--; + } + var end = new TextSpan(nameEnd - name.Length, name.Length); return end.Start >= element.Start + 2 && Holds(text, new TextSpan(end.Start - 2, 2), "") + && Holds(text, end, name, comparison) + && Holds(text, new TextSpan(element.End - 1, 1), ">") ? end : null; } @@ -214,10 +230,14 @@ SourceText text : null; } - private static bool Holds(SourceText text, TextSpan span, string expected) + private static bool Holds( + SourceText text, + TextSpan span, + string expected, + StringComparison comparison = StringComparison.Ordinal + ) { - return span.End <= text.Length - && string.Equals(text.ToString(span), expected, StringComparison.Ordinal); + return span.End <= text.Length && string.Equals(text.ToString(span), expected, comparison); } private static TextSpan Span(SourceSpan span) diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs index 4dfc0275..8ca7e5af 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs @@ -99,6 +99,15 @@ internal sealed record RazorMarkup( TagHelperCollection TagHelpers ) { + /// Position-preserving HTML for [RAZOR-ARCHITECTURE-HTML]. + public string HtmlText { get; init; } = ""; + + /// The caret's HTML context, [RAZOR-ARCHITECTURE-HTML]. + public MarkupContext ContextAt(int offset) + { + return HtmlMarkupContext.At(HtmlText, offset); + } + /// The innermost tag whose start or end name holds . public MarkupTag? TagNamedAt(int offset) { diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCapture.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCapture.cs index e57102d7..1407fffc 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCapture.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCapture.cs @@ -19,6 +19,7 @@ internal sealed class RazorMarkupCapture private readonly List _diagnostics = []; private readonly List _namespaces = []; private RazorCodeDocument? _codeDocument; + private string _htmlText = ""; /// Add the capture passes to an engine. public void Register(RazorProjectEngineBuilder builder) @@ -44,12 +45,16 @@ [.. _namespaces.Distinct(StringComparer.Ordinal)], [.. _diagnostics.Distinct()], document.ParserOptions.Directives, tagHelpers - ); + ) + { + HtmlText = _htmlText, + }; } private void ReadDirectives(RazorCodeDocument document, DocumentIntermediateNode root) { var text = document.Source.Text; + _htmlText = RazorHtmlProjection.Create(text, root); foreach (var node in RazorIr.Descendants(root).Where(node => !node.IsImported)) { if (RazorIr.Directive(node, text) is { } directive) diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupEngine.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupEngine.cs index bb66f992..c0bad4e3 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupEngine.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupEngine.cs @@ -56,8 +56,10 @@ CancellationToken ct var tagHelpers = RazorTagHelpers.Of(compilation); var capture = new RazorMarkupCapture(); var engine = CreateEngine(settings, fileSystem, tagHelpers, capture); - _ = engine.Process(fileSystem.GetItem(settings.RazorPathOf(razor), null), ct); - return capture.ToMarkup(tagHelpers); + var document = engine.Process(fileSystem.GetItem(settings.RazorPathOf(razor), null), ct); + return capture.ToMarkup(tagHelpers) is { } markup + ? RazorHtmlComments.Restore(engine, document, markup, ct) + : null; } private static RazorProjectEngine CreateEngine( diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorHtml.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorHtml.cs new file mode 100644 index 00000000..5fa6a493 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorHtml.cs @@ -0,0 +1,18 @@ +namespace SharpLsp.Sidecar.CSharp.Workspace; + +internal sealed partial class WorkspaceManager +{ + /// Current HTML or null for an unlisted file; [RAZOR-ARCHITECTURE-HTML]. + internal Task> GetRazorHtmlProjectionAsync( + string filePath, + CancellationToken ct + ) + { + return RunMarkupQueryAsync( + filePath, + null, + markup => Task.FromResult(new() { Text = markup.HtmlText }), + ct + ); + } +} From 2d58669adbcffa21586240920b6cc81b54ed30cd Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:48:02 +1100 Subject: [PATCH 12/38] Razor spec: Zed attaches the server to the community Razor language Refs #397 --- docs/specs/RAZOR-SPEC.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/RAZOR-SPEC.md b/docs/specs/RAZOR-SPEC.md index 6ab0cdd3..b85a94cc 100644 --- a/docs/specs/RAZOR-SPEC.md +++ b/docs/specs/RAZOR-SPEC.md @@ -393,7 +393,7 @@ Blazor front ends often sit over F# domain libraries. A Razor project that refer - 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, so Zed support is a plan item. +- **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 From a17e238077829628ded768f390d2d21ef0f90c5c Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:49:26 +1100 Subject: [PATCH 13/38] Razor plan: the foundation and the editors are done Phase 1 closes with the editor verification of the HTML projection, and Phase 5 with VS Code sync, HTML forwarding, the razor end-to-end chunk and Zed's Razor registration, each checked against the test that proves it. --- docs/plans/RAZOR-PLAN.md | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/docs/plans/RAZOR-PLAN.md b/docs/plans/RAZOR-PLAN.md index b9dc0a33..3c626335 100644 --- a/docs/plans/RAZOR-PLAN.md +++ b/docs/plans/RAZOR-PLAN.md @@ -18,10 +18,6 @@ The phases below are in dependency order. Each item is checked only when a test ### Phase 1: Foundation -**In progress:** `SharpLspRazorClaude1` owns host routing and diagnostics; -`SharpLspCodex1` owns the sidecar HTML projection and markup-context tokenizer. -`SharpLspRazorClaude2` owns editor integration (Phase 5). Checkboxes require passing tests. - - [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 @@ -31,11 +27,11 @@ The phases below are in dependency order. Each item is checked only when a test - [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] 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 -- [ ] 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`: 16 passing fixture cases (Blazor and MVC, comments, UTF-16, incomplete tags, unsaved edits) - - [x] HTML markup-context tokenizer — `HtmlMarkupContextTests`: 21 passing cases for tags, attributes, comments, declarations, raw text and caret positions +- [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`: 26 passing cases for tags, attributes, comments, quoted declarations, raw text, script escape states and caret positions - [x] Host: `sharplsp/razor/htmlProjection` answers `{ text }`, or `null` for a file no project lists — `test_full_stack_razor_routing_session` - - [ ] Verify `sharplsp/razor/htmlProjection` through editor integration + - [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 ### Phase 2: C# Inside Razor @@ -65,10 +61,10 @@ The phases below are in dependency order. Each item is checked only when a test ### Phase 5: Editors -- [ ] VS Code: sync `razor` and `aspnetcorerazor` documents, and activate on `onLanguage:razor` ([RAZOR-EDITORS]) -- [ ] VS Code: forward HTML completion and hover over `sharplsp/razor/htmlProjection` ([RAZOR-FEATURES-HTML]) -- [ ] VS Code: add a `razor` end-to-end chunk on both platforms -- [ ] Zed: Razor language registration (Zed has no built-in Razor grammar) +- [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# From 156b84fa065ba208ae8763c92c162f8ac2a70eba Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:50:48 +1100 Subject: [PATCH 14/38] Test the C# symbols behind Razor markup, and tell the generic @bind helper apart by name Component tags resolve to their types across the app and its class library, parameters and @bind-X to their properties, a child content tag to its enclosing component's parameter; @onclick, HTML attributes and the generic @bind helper set none. Refs #397 --- .../RazorMarkupSymbolsTests.cs | 119 ++++++++++++++++++ .../Razor/RazorMarkupSymbols.cs | 2 +- 2 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMarkupSymbolsTests.cs diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMarkupSymbolsTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMarkupSymbolsTests.cs new file mode 100644 index 00000000..8735c7d0 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMarkupSymbolsTests.cs @@ -0,0 +1,119 @@ +using Microsoft.CodeAnalysis; +using SharpLsp.Sidecar.CSharp.Razor; +using SharpLsp.Sidecar.CSharp.Workspace; + +#pragma warning disable CA1515 // Public xUnit discovery type +#pragma warning disable CA2007 // xUnit executes without a synchronization context +#pragma warning disable RS1035 // Real-file reads are intentional in this end-to-end suite + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// +/// The C# symbols behind the fixture's markup: the types its components instantiate and the +/// properties its attributes set. Implements [RAZOR-TESTING] for [RAZOR-FEATURES-HOVER] and +/// [RAZOR-FEATURES-DEFINITION]. +/// +public sealed class RazorMarkupSymbolsTests : IClassFixture +{ + private readonly RazorFixture _fixture; + + public RazorMarkupSymbolsTests(RazorFixture fixture) + { + _fixture = fixture; + } + + private string Board => _fixture.Path("BlazorApp", "Components", "Pages", "Board.razor"); + + [Fact] + public async Task Component_tags_resolve_to_their_types_from_the_app_and_its_library() + { + using var manager = await _fixture.OpenManagerAsync(); + var (markup, compilation) = await ReadAsync(manager, Board); + + var panel = RazorMarkupSymbols.TypeOf(compilation, Tag(markup, "Panel")); + Assert.Equal("Widgets.Panel", panel?.ToDisplayString()); + Assert.Equal("Widgets", panel?.ContainingAssembly.Name); + var tally = RazorMarkupSymbols.TypeOf(compilation, Tag(markup, "Tally")); + Assert.Equal("BlazorApp.Components.Shared.Tally", tally?.ToDisplayString()); + Assert.Equal(1, tally?.Arity); + Assert.Null(RazorMarkupSymbols.TypeOf(compilation, Tag(markup, "input"))); + Assert.Null(RazorMarkupSymbols.TypeOf(compilation, Tag(markup, "button"))); + + var inherited = RazorMarkupSymbols.PropertyOf(panel!, "RendererInfo"); + Assert.Equal("ComponentBase", inherited?.ContainingType.Name); + Assert.Null(RazorMarkupSymbols.PropertyOf(panel!, "Missing")); + } + + [Fact] + public async Task Parameters_and_bind_X_set_properties_and_other_attributes_set_none() + { + using var manager = await _fixture.OpenManagerAsync(); + var (markup, compilation) = await ReadAsync(manager, Board); + + var title = RazorMarkupSymbols.PropertyOf(compilation, Attribute(markup, "Title")); + Assert.Equal("Widgets.Panel.Title", title?.ToDisplayString()); + var items = RazorMarkupSymbols.PropertyOf(compilation, Attribute(markup, "Items")); + Assert.Equal(("Items", "Tally"), (items?.Name, items?.ContainingType.Name)); + + // `@bind-Label` binds through Tally's own bind helper and the generic `@bind` + // helper; only Tally's sets a property, its Label parameter. + var binds = markup.Attributes.Where(a => a.Name == "@bind-Label").ToList(); + Assert.True(binds.Count > 1, "both bind helpers see @bind-Label"); + var bound = binds + .Select(a => RazorMarkupSymbols.PropertyOf(compilation, a)) + .OfType(); + var label = Assert.Single(bound); + Assert.Equal(("Label", "Tally"), (label.Name, label.ContainingType.Name)); + + Assert.Null(RazorMarkupSymbols.PropertyOf(compilation, Attribute(markup, "@onclick"))); + Assert.Null(RazorMarkupSymbols.PropertyOf(compilation, Attribute(markup, "disabled"))); + Assert.All( + markup.Attributes.Where(a => a.Owner.Name == "input"), + attribute => Assert.Null(RazorMarkupSymbols.PropertyOf(compilation, attribute)) + ); + } + + [Fact] + public async Task A_child_content_tag_sets_the_parameter_of_its_enclosing_component() + { + using var manager = await _fixture.OpenManagerAsync(); + var text = await File.ReadAllTextAsync(Board); + var edited = text.Replace( + "Board @Id", + "Board @Id\nKept", + StringComparison.Ordinal + ); + _ = await manager.UpdateDocumentTextAsync(Board, edited); + var (markup, compilation) = await ReadAsync(manager, Board); + + var content = Tag(markup, "ChildContent"); + Assert.NotNull(content.ChildContent); + var property = RazorMarkupSymbols.ChildContentOf(compilation, markup, content); + Assert.Equal("Widgets.Panel.ChildContent", property?.ToDisplayString()); + // Badge's nearest enclosing component is Tally, which has no Badge parameter. + Assert.Null(RazorMarkupSymbols.ChildContentOf(compilation, markup, Tag(markup, "Badge"))); + } + + private static async Task<(RazorMarkup Markup, Compilation Compilation)> ReadAsync( + WorkspaceManager manager, + string path + ) + { + var markup = await manager.FindRazorMarkupAsync(path, default); + var project = manager.FindRazorDocument(path)?.Project; + var compilation = project is null ? null : await project.GetCompilationAsync(); + Assert.NotNull(markup); + Assert.NotNull(compilation); + return (markup, compilation); + } + + private static MarkupTag Tag(RazorMarkup markup, string name) + { + return Assert.Single(markup.Tags, tag => tag.Name == name); + } + + private static MarkupAttribute Attribute(RazorMarkup markup, string name) + { + return Assert.Single(markup.Attributes, attribute => attribute.Name == name); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupSymbols.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupSymbols.cs index 7c6b10f6..705bb7d9 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupSymbols.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupSymbols.cs @@ -90,7 +90,7 @@ candidate.Component is not null || ( attribute.Kind == MarkupAttributeKind.Directive && attribute.TagHelper is { Kind: TagHelperKind.Bind } helper - && helper.TypeName != "Bind" + && helper.Name != "Bind" ); return binds ? attribute.Bound?.PropertyName : null; } From ab742a226311443a261b6c5fdfa8ee94f384a4b4 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:52:34 +1100 Subject: [PATCH 15/38] Test the markup lookups by offset: tag, bound attribute and directive names Refs #397 --- .../RazorMarkupEngineTests.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMarkupEngineTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMarkupEngineTests.cs index a1ab1d06..3e52e450 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMarkupEngineTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMarkupEngineTests.cs @@ -114,6 +114,38 @@ public async Task Directives_and_razor_diagnostics_come_from_the_compiler() Assert.DoesNotContain(edited.Tags, tag => tag.Name == "Badge"); } + [Fact] + public async Task Names_at_an_offset_find_their_tag_attribute_and_directive() + { + using var manager = await _fixture.OpenManagerAsync(); + var markup = await manager.FindRazorMarkupAsync(Board, default); + Assert.NotNull(markup); + + var title = Offset(markup, RazorFixture.Locate(Board, "", "Panel", 1)); + Assert.Equal("Panel", markup.TagNamedAt(end)?.Name); + + // HTML attributes belong to the HTML server, and content names nothing. + var disabled = Offset(markup, RazorFixture.Locate(Board, "Add<", 2)); + Assert.Null(markup.AttributeNamedAt(content)); + Assert.Null(markup.DirectiveNamedAt(content)); + Assert.Null(markup.TagNamedAt(content)); + } + + private static int Offset(RazorMarkup markup, (int Line, int Character) at) + { + return markup.Text.Lines.GetPosition( + new Microsoft.CodeAnalysis.Text.LinePosition(at.Line, at.Character) + ); + } + private static string Slice(RazorMarkup markup, Microsoft.CodeAnalysis.Text.TextSpan span) { return markup.Text.ToString(span); From 70bf531fbb6a457376fc95a49aa0915c5d4a6e91 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:54:58 +1100 Subject: [PATCH 16/38] End the Razor session with a clean shutdown, so the server writes its coverage The session dropped its client, which kills the server before LLVM writes its profile: the Razor routing it exercised measured as uncovered. razor.rs goes from 71% to 99%. Refs #397 --- src/sharplsp/tests/e2e_modules/razor.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/sharplsp/tests/e2e_modules/razor.rs b/src/sharplsp/tests/e2e_modules/razor.rs index 5d67dfbd..90b34ec1 100644 --- a/src/sharplsp/tests/e2e_modules/razor.rs +++ b/src/sharplsp/tests/e2e_modules/razor.rs @@ -41,6 +41,9 @@ fn test_full_stack_razor_routing_session() { 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 From 6caf4eb5458c9897a91b88ea16a92dcdfdf8c962 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:58:02 +1100 Subject: [PATCH 17/38] Test the Razor endpoints over the sidecar's real socket The HTML projection round-trips unsaved text and answers null for an unlisted file; the structure requests keep their wire shapes and Razor ranges; a malformed payload answers an error and leaves the socket healthy. Refs #397 --- .../RazorSidecarTests.cs | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorSidecarTests.cs diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorSidecarTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorSidecarTests.cs new file mode 100644 index 00000000..849bb218 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorSidecarTests.cs @@ -0,0 +1,120 @@ +using MessagePack; +using SharpLsp.Sidecar.CSharp.Razor; + +#pragma warning disable CA1515 // Public xUnit discovery type + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// Real IPC coverage of [RAZOR-ARCHITECTURE-ROUTING], [RAZOR-ARCHITECTURE-HTML] and [RAZOR-TESTING]. +public sealed class RazorSidecarTests(CSharpSidecarFixture socket, RazorFixture files) + : IClassFixture, + IClassFixture +{ + private string Board => files.Path("BlazorApp", "Components", "Pages", "Board.razor"); + + [Fact] + public async Task Projection_round_trips_current_text_and_returns_null_for_unlisted_files() + { + await OpenAsync().ConfigureAwait(true); + var before = await AtAsync("sharplsp/razor/htmlProjection", Board) + .ConfigureAwait(true); + Assert.NotNull(before); + Assert.Contains("("sharplsp/razor/htmlProjection", Board) + .ConfigureAwait(true); + Assert.NotNull(after); + Assert.Equal("

\r\n", after.Text); + Assert.Equal(changed.Length, after.Text.Length); + Assert.Null( + await AtAsync( + "sharplsp/razor/htmlProjection", + files.Path("Missing.razor") + ) + .ConfigureAwait(true) + ); + } + + [Fact] + public async Task Syntax_requests_keep_their_wire_shapes_and_source_ranges() + { + await OpenAsync().ConfigureAwait(true); + var folds = await AtAsync>("textDocument/foldingRange", Board) + .ConfigureAwait(true); + Assert.Equal( + [(6, 9), (7, 8), (14, 24)], + folds.Select(fold => (fold.StartLine, fold.EndLine)) + ); + var symbols = await AtAsync>( + "textDocument/documentSymbol", + Board + ) + .ConfigureAwait(true); + Assert.Equal( + "Tally", + Assert.Single(symbols.Single(symbol => symbol.Name == "Panel").Children).Name + ); + Assert.Contains( + symbols.Single(symbol => symbol.Name == "@code").Children, + child => child.Name == "Add" + ); + var linked = await AtAsync( + "textDocument/linkedEditingRange", + Board, + 6, + 3 + ) + .ConfigureAwait(true); + Assert.Equal( + [(6, 1, 6, 6), (10, 2, 10, 7)], + linked.Locations.Select(at => (at.Line, at.Character, at.EndLine, at.EndCharacter)) + ); + Assert.All(linked.Locations, at => Assert.Equal(Board, at.FilePath)); + } + + [Theory] + [InlineData("sharplsp/razor/htmlProjection")] + [InlineData("textDocument/documentSymbol")] + [InlineData("textDocument/foldingRange")] + [InlineData("textDocument/linkedEditingRange")] + public async Task Malformed_payloads_return_errors_and_keep_the_socket_healthy(string method) + { + var error = await socket.SendAsync(method, [0xC1]).ConfigureAwait(true); + Assert.NotNull(error.Error); + var pong = await socket.SendAndDeserializeAsync("ping", []).ConfigureAwait(true); + Assert.Equal("pong", pong); + } + + private Task OpenAsync() + { + return socket.SendAndAssertOkAsync( + "workspace/open", + MessagePackSerializer.Serialize(files.SolutionPath) + ); + } + + private Task AtAsync(string method, string path, int? line = null, int character = 0) + { + return line is { } at + ? socket.SendAndDeserializeAsync( + method, + new PositionRequest + { + FilePath = path, + Line = at, + Character = character, + } + ) + : socket.SendAndDeserializeAsync( + method, + MessagePackSerializer.Serialize(new[] { path }) + ); + } +} From 5b20c4b8161e4975c13dc6945d4b77daecc8a0d0 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:01:01 +1100 Subject: [PATCH 18/38] Test that a Razor edit made before the workspace loads is replayed into the file The plan's foundation item promised the pending-edit replay for Razor files; nothing exercised it. The edit now waits for the solution, and both the markup facts and the C# projection read the edited text once it loads. Refs #397 --- .../RazorMarkupEngineTests.cs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMarkupEngineTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMarkupEngineTests.cs index 3e52e450..e2be21c7 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMarkupEngineTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMarkupEngineTests.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Razor.Language; using SharpLsp.Sidecar.CSharp.Razor; +using SharpLsp.Sidecar.CSharp.Workspace; #pragma warning disable CA1515 // Public xUnit discovery type #pragma warning disable CA2007 // xUnit executes without a synchronization context @@ -114,6 +115,28 @@ public async Task Directives_and_razor_diagnostics_come_from_the_compiler() Assert.DoesNotContain(edited.Tags, tag => tag.Name == "Badge"); } + [Fact] + public async Task An_edit_that_arrives_before_the_workspace_loads_is_replayed_into_the_razor_file() + { + var text = await File.ReadAllTextAsync(Board); + var edited = text.Replace(" "ok", error => error)); + + var markup = await manager.FindRazorMarkupAsync(Board, default); + Assert.NotNull(markup); + Assert.Equal(edited, markup.Text.ToString()); + Assert.Contains(markup.Diagnostics, d => d.Id == "RZ10012"); + var projection = await manager.FindRazorProjectionAsync(Board, default); + Assert.Equal(edited, projection?.RazorText.ToString()); + } + [Fact] public async Task Names_at_an_offset_find_their_tag_attribute_and_directive() { From a96f3a3da69e93e78b23100ca8e963eda9c1b25c Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:02:36 +1100 Subject: [PATCH 19/38] Test completion's additional edits through real Razor projections An edit inside a C# fragment maps back to it; an added using becomes an @using line after the file's own, keeping static and alias spellings and the file's line break, never twice; an edit that cannot be placed is dropped. Refs #397 --- .../RazorCompletionEditsTests.cs | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCompletionEditsTests.cs diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCompletionEditsTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCompletionEditsTests.cs new file mode 100644 index 00000000..8205b4ce --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCompletionEditsTests.cs @@ -0,0 +1,112 @@ +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.CSharp.Razor; + +#pragma warning disable CA1515 // Public xUnit discovery type + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// Additional edits through real SDK projections; [RAZOR-ARCHITECTURE-PROJECTION], [RAZOR-FEATURES-COMPLETION], [RAZOR-TESTING]. +public sealed class RazorCompletionEditsTests(RazorFixture fixture) : IClassFixture +{ + private string Board => fixture.Path("BlazorApp", "Components", "Pages", "Board.razor"); + + [Fact] + public async Task Additional_csharp_edits_map_back_to_the_original_fragment() + { + using var manager = await fixture.OpenManagerAsync().ConfigureAwait(true); + var projection = await manager + .FindRazorProjectionAsync(Board, default) + .ConfigureAwait(true); + Assert.NotNull(projection); + var declaration = projection + .GeneratedRoot.DescendantNodes() + .OfType() + .Single(node => node.Identifier.ValueText == "caption"); + var edit = Assert.Single( + RazorCompletionEdits.Map( + projection, + [new TextChange(declaration.Identifier.Span, "changed")] + ) + ); + var (expectedLine, expectedCharacter) = RazorFixture.Locate( + Board, + "private string caption", + "caption" + ); + Assert.Equal("changed", edit.NewText); + Assert.Equal( + (expectedLine, expectedCharacter, expectedLine, expectedCharacter + 7), + (edit.StartLine, edit.StartCharacter, edit.EndLine, edit.EndCharacter) + ); + } + + [Fact] + public async Task Imports_keep_spelling_and_crlf_after_own_imports_without_duplicates() + { + using var manager = await fixture.OpenManagerAsync().ConfigureAwait(true); + const string text = + "@using System.Text\r\n@using static System.Math\r\n@using Alias = System.String\r\n

body

\r\n"; + _ = RazorFixture.Ok( + await manager.UpdateDocumentTextAsync(Board, text).ConfigureAwait(true) + ); + var projection = await manager + .FindRazorProjectionAsync(Board, default) + .ConfigureAwait(true); + Assert.NotNull(projection); + const string added = + "using System.Text; using System.Linq; using System.Linq; using static System.Math; using Alias = System.String; using NewAlias = System.Uri; using static System.Console;"; + var edits = RazorCompletionEdits + .Map(projection, [new TextChange(new TextSpan(0, 0), added)]) + .ToArray(); + Assert.Equal( + [ + "@using System.Linq\r\n", + "@using NewAlias = System.Uri\r\n", + "@using static System.Console\r\n", + ], + edits.Select(edit => edit.NewText) + ); + Assert.All( + edits, + edit => + Assert.Equal( + (3, 0, 3, 0), + (edit.StartLine, edit.StartCharacter, edit.EndLine, edit.EndCharacter) + ) + ); + Assert.Equal(3, edits.Length); + } + + [Fact] + public async Task Non_using_generated_edits_are_dropped_and_new_imports_use_lf_fallback() + { + using var manager = await fixture.OpenManagerAsync().ConfigureAwait(true); + _ = RazorFixture.Ok( + await manager.UpdateDocumentTextAsync(Board, "

body

").ConfigureAwait(true) + ); + var projection = await manager + .FindRazorProjectionAsync(Board, default) + .ConfigureAwait(true); + Assert.NotNull(projection); + var invalid = new[] { "class Extra {}", "[assembly: System.Obsolete]", "using ;", "" }; + Assert.All( + invalid, + text => + Assert.Empty( + RazorCompletionEdits.Map(projection, [new TextChange(new TextSpan(0, 0), text)]) + ) + ); + var edit = Assert.Single( + RazorCompletionEdits.Map( + projection, + [new TextChange(new TextSpan(0, 0), "using System.Text;")] + ) + ); + Assert.Equal("@using System.Text\n", edit.NewText); + Assert.Equal( + (0, 0, 0, 0), + (edit.StartLine, edit.StartCharacter, edit.EndLine, edit.EndCharacter) + ); + } +} From 8943a6bd23a4baa74b18f56b4413db55a82dc834 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:03:49 +1100 Subject: [PATCH 20/38] Rename skips the Razor reference search in solutions without Razor files Every C# rename, and every rename that starts in F#, searched the whole solution for references a second time to find Razor uses, even where no project lists a Razor file. Only a solution with Razor files pays for it now. [RAZOR-FEATURES-RENAME] --- .../SharpLsp.Sidecar.CSharp/Razor/RazorRenameEdits.cs | 7 +++++++ .../SharpLsp.Sidecar.CSharp/Workspace/SolutionPaths.cs | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorRenameEdits.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorRenameEdits.cs index d7ed5b92..c56164e9 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorRenameEdits.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorRenameEdits.cs @@ -22,6 +22,13 @@ public static async Task> FindAsync( CancellationToken ct ) { + // Without a Razor file there is no Razor use to edit, so a solution without one + // never pays for a second solution-wide search after the renamer's own. + if (!SolutionPaths.HasRazorDocuments(solution)) + { + return []; + } + var referenced = await SymbolFinder .FindReferencesAsync(symbol, solution, ct) .ConfigureAwait(false); diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SolutionPaths.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SolutionPaths.cs index a71ec531..68811d3a 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SolutionPaths.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SolutionPaths.cs @@ -43,6 +43,14 @@ string filePath .Where(document => NativePaths.AreEqual(document.FilePath, filePath)); } + /// Whether any project lists a Razor file. [RAZOR-ARCHITECTURE-DOCUMENTS] + internal static bool HasRazorDocuments(Solution solution) + { + return solution + .Projects.SelectMany(project => project.AdditionalDocuments) + .Any(document => NativePaths.IsRazor(document.FilePath)); + } + /// Find a loaded project by its project-file path. internal static Project? FindProject(Solution solution, string projectPath) { From f75afd91d47afe32046cac55afe686454f5450d9 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:08:03 +1100 Subject: [PATCH 21/38] Look for Razor projections only in projects that list a Razor file Mapping a location outside the workspace's documents, such as decompiled source, asked every project for its generated documents, so going to a framework definition compiled every project and ran its generators, Razor or not. Only Razor projects are searched now. Refs #397 --- .../RazorLocationsTests.cs | 74 +++++++++++++++++++ .../Razor/RazorLocations.cs | 4 +- .../Workspace/SolutionPaths.cs | 12 ++- 3 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorLocationsTests.cs diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorLocationsTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorLocationsTests.cs new file mode 100644 index 00000000..b0be213a --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorLocationsTests.cs @@ -0,0 +1,74 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Razor; + +#pragma warning disable CA1515 // Public xUnit discovery type +#pragma warning disable CA2007 // xUnit executes without a synchronization context + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// +/// A location outside the workspace's documents, such as decompiled source, is looked up as +/// a Razor projection only in projects that list a Razor file, so navigating from a solution +/// without Razor never compiles a project to run its generators. Implements [RAZOR-TESTING] +/// for [RAZOR-ARCHITECTURE-PROJECTION]. +/// +public sealed class RazorLocationsTests +{ + [Fact] + public async Task A_solution_without_razor_files_compiles_nothing_to_find_a_projection() + { + using var workspace = new AdhocWorkspace(); + // Every SDK project carries source generators, and running them means compiling. + var solution = workspace + .CurrentSolution.AddProject("Plain", "Plain", LanguageNames.CSharp) + .AddAnalyzerReference(new GeneratorReference()) + .AddDocument("Program.cs", SourceText.From("class Program { }")) + .Project.Solution; + + var projection = await RazorLocations.FindProjectionAsync( + solution, + NativePaths.Temp("Console.cs"), + default + ); + + Assert.Null(projection); + var project = Assert.Single(solution.Projects); + Assert.False( + project.TryGetCompilation(out _), + "no project compiles just to look for a Razor projection" + ); + } + + /// A generator that adds nothing, as most of a project's generators do. + private sealed class EmptyGenerator : IIncrementalGenerator + { + public void Initialize(IncrementalGeneratorInitializationContext context) { } + } + + /// An analyzer reference that supplies . + private sealed class GeneratorReference : AnalyzerReference + { + public override string FullPath => ""; + + public override object Id => nameof(GeneratorReference); + + public override ImmutableArray GetAnalyzers(string language) + { + return []; + } + + public override ImmutableArray GetAnalyzersForAllLanguages() + { + return []; + } + + public override ImmutableArray GetGenerators(string language) + { + return [new EmptyGenerator().AsSourceGenerator()]; + } + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorLocations.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorLocations.cs index a4b208d2..3a167f8d 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorLocations.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorLocations.cs @@ -67,6 +67,8 @@ CancellationToken ct /// /// The projection whose generated document lives at ; null for a /// regular document, decompiled source, or a generated document that is not a projection. + /// Only projects that list a Razor file have projections, so no other project runs its + /// generators for the lookup. /// public static async Task FindProjectionAsync( Solution solution, @@ -79,7 +81,7 @@ CancellationToken ct return null; } - foreach (var project in solution.Projects) + foreach (var project in SolutionPaths.RazorProjects(solution)) { var generated = await project .GetSourceGeneratedDocumentsAsync(ct) diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SolutionPaths.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SolutionPaths.cs index 68811d3a..3cf9a411 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SolutionPaths.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SolutionPaths.cs @@ -43,12 +43,18 @@ string filePath .Where(document => NativePaths.AreEqual(document.FilePath, filePath)); } + /// The projects that list a Razor file. [RAZOR-ARCHITECTURE-DOCUMENTS] + internal static IEnumerable RazorProjects(Solution solution) + { + return solution.Projects.Where(project => + project.AdditionalDocuments.Any(document => NativePaths.IsRazor(document.FilePath)) + ); + } + /// Whether any project lists a Razor file. [RAZOR-ARCHITECTURE-DOCUMENTS] internal static bool HasRazorDocuments(Solution solution) { - return solution - .Projects.SelectMany(project => project.AdditionalDocuments) - .Any(document => NativePaths.IsRazor(document.FilePath)); + return RazorProjects(solution).Any(); } /// Find a loaded project by its project-file path. From c351c5d3c72961680b14e3c54fac5641ef41a25e Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:14:23 +1100 Subject: [PATCH 22/38] Split the linked editing answer out of its handler Refs #397 --- src/sharplsp/src/razor.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/sharplsp/src/razor.rs b/src/sharplsp/src/razor.rs index 377ed6fb..87bc7b38 100644 --- a/src/sharplsp/src/razor.rs +++ b/src/sharplsp/src/razor.rs @@ -107,6 +107,11 @@ pub fn handle_linked_editing_range( "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 From b6a346689e5a40f714f1e28f027d913e14fd3712 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:14:24 +1100 Subject: [PATCH 23/38] Razor plan: record the foundation's Release validation Refs #397 --- docs/plans/RAZOR-PLAN.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/plans/RAZOR-PLAN.md b/docs/plans/RAZOR-PLAN.md index 3c626335..b77a9b0c 100644 --- a/docs/plans/RAZOR-PLAN.md +++ b/docs/plans/RAZOR-PLAN.md @@ -33,6 +33,8 @@ The phases below are in dependency order. Each item is checked only when a test - [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; 642 C#, 440 F# and 125 Common tests pass, and all three coverage gates pass. `RazorSidecarTests` exercises the real IPC contract and malformed-request recovery; `RazorCompletionEditsTests` covers mapped edits, import deduplication, aliases, static imports and LF/CRLF insertion. + ### Phase 2: C# Inside Razor - [ ] Diagnostics: C# diagnostics from projections, `RZ` diagnostics from facts, and Razor files in solution-wide diagnostics ([RAZOR-FEATURES-DIAGNOSTICS]) From 1f29cc097d51242e8462fa8ceeab5d8ccbb9c102 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:15:26 +1100 Subject: [PATCH 24/38] Date each watch page by its video's upload, not by the checkout that built it A watch page has no file of its own whose date means anything, so its datePublished and sitemap lastmod took the template's file date, which every fresh checkout moves. They now carry the video's upload date; every other page keeps its own. [WEB-DESIGN-MEDIA-DISCOVERY] --- docs/specs/DESIGN-SYSTEM.md | 2 +- src/website/src/_data/eleventyComputed.js | 6 ++++++ src/website/src/_includes/layouts/base.njk | 4 ++-- src/website/src/_includes/overrides/sitemap.njk | 2 +- src/website/tests/video-watch-pages.spec.js | 8 ++++++++ 5 files changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/specs/DESIGN-SYSTEM.md b/docs/specs/DESIGN-SYSTEM.md index 552f9962..61b5471c 100644 --- a/docs/specs/DESIGN-SYSTEM.md +++ b/docs/specs/DESIGN-SYSTEM.md @@ -144,7 +144,7 @@ Screenshots and video of a SharpLsp feature show the real extension, never a moc 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. Docs and article figures link to their video's watch page. +- **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`). diff --git a/src/website/src/_data/eleventyComputed.js b/src/website/src/_data/eleventyComputed.js index 70a174f2..e941d125 100644 --- a/src/website/src/_data/eleventyComputed.js +++ b/src/website/src/_data/eleventyComputed.js @@ -4,4 +4,10 @@ import { socialVideo } from "../../video-pages.js"; export default { socialVideo: (data) => socialVideo(data.page.url), + // When a page was published: a watch page when its video was uploaded, for it has + // no file of its own whose date means anything, and every other page on its own date. + published: (data) => { + const film = socialVideo(data.page.url); + return film?.watch === data.page.url ? film.uploadDate : data.page.date; + }, }; diff --git a/src/website/src/_includes/layouts/base.njk b/src/website/src/_includes/layouts/base.njk index 4ecab241..93964187 100644 --- a/src/website/src/_includes/layouts/base.njk +++ b/src/website/src/_includes/layouts/base.njk @@ -122,8 +122,8 @@ "name": "{{ title | default(site.title) }}", "description": {{ pageDescription | dump | safe }}, "isPartOf": { "@id": "{{ site.url }}/#website" }, - "inLanguage": "{{ lang | default('en') }}"{% if page.date %}, - "datePublished": "{{ page.date | isoDate }}"{% endif %}{% if author %}, + "inLanguage": "{{ lang | default('en') }}"{% if published %}, + "datePublished": "{{ published | isoDate }}"{% endif %}{% if author %}, "author": { "@type": "Person", "name": "{{ author }}" diff --git a/src/website/src/_includes/overrides/sitemap.njk b/src/website/src/_includes/overrides/sitemap.njk index 1f643274..7127b6f9 100644 --- a/src/website/src/_includes/overrides/sitemap.njk +++ b/src/website/src/_includes/overrides/sitemap.njk @@ -12,7 +12,7 @@ {%- set film = page.data.socialVideo %} {{ site.url }}{{ page.url }} - {{ page.date | isoDate }} + {{ page.data.published | isoDate }} {%- if page.url == "/" or page.url == "/index.html" %} 1.0 weekly diff --git a/src/website/tests/video-watch-pages.spec.js b/src/website/tests/video-watch-pages.spec.js index f5e68f0e..2c7c04ef 100644 --- a/src/website/tests/video-watch-pages.spec.js +++ b/src/website/tests/video-watch-pages.spec.js @@ -41,8 +41,14 @@ async function expectWatchStructuredData(page, id, lang) { expect(described.potentialAction.target).toBe(`${SITE}${watchOf(id, lang)}?t={seek_to_second_number}`); expect(described.potentialAction['startOffset-input']).toBe('required name=seek_to_second_number'); expect(Boolean(described.transcript), 'a narrated film carries its transcript').toBe((VIDEOS[id].captions ?? []).includes(lang)); + const [webPage] = await page.locator('script[type="application/ld+json"]').evaluateAll((scripts) => + scripts.flatMap((script) => JSON.parse(script.textContent)['@graph'] ?? []).filter((node) => node['@type'] === 'WebPage')); + expect(webPage.datePublished, 'the page is published when its video was').toBe(uploadedAt(id)); } +/** When video `id` was uploaded, as the ISO timestamp pages and the sitemap carry. */ +const uploadedAt = (id) => new Date(VIDEOS[id].uploadDate).toISOString(); + /** The narration as text: one paragraph per spoken cue, no sound cues. */ async function expectTranscript(page, id, lang) { const lines = page.locator('main section p'); @@ -108,6 +114,7 @@ const sitemapEntries = (page, xml) => const field = (node, name) => node.getElementsByTagName(`video:${name}`)[0]?.textContent ?? null; return [...doc.getElementsByTagName('url')].map((url) => ({ loc: url.getElementsByTagName('loc')[0].textContent, + lastmod: url.getElementsByTagName('lastmod')[0]?.textContent ?? null, videos: url.getElementsByTagName('video:video').length, ...Object.fromEntries(['title', 'description', 'thumbnail_loc', 'content_loc', 'player_loc', 'duration', 'publication_date'] .map((name) => [name, field(url, name)])), @@ -122,6 +129,7 @@ function expectVideoEntry(entry, id, lang) { expect(entry.player_loc).toBe(`${SITE}${watchOf(id, lang)}embed/`); expect(Number(entry.duration)).toBe(secondsOf(VIDEOS[id].duration)); expect(entry.publication_date).toBe(VIDEOS[id].uploadDate); + expect(entry.lastmod, 'a watch page changes when its video does, not with each build').toBe(uploadedAt(id)); } test('the sitemap lists every docs page and every watch page with its video', async ({ page, request }) => { From 66f8940ca165d3c99dfc2890294cad8a7055ca6b Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:45:02 +1100 Subject: [PATCH 25/38] Razor HTML forwarding lives and dies with the client, and forgets closed documents - The projection provider joins the extension's subscriptions once the client has started, and is disposed when the start fails. - A closed Razor document's projection is dropped, so projections no longer pile up for the whole session. - The projection's address is built in paths.ts (embeddedUri), the extension's one path module [SHARPLSP-ARCHITECTURE-PATHS]. - A failed projection logs a structured event with the URI and no error text. Refs #397 --- src/editors/vscode/src/client.ts | 10 ++++++++-- src/editors/vscode/src/paths.ts | 9 +++++++++ src/editors/vscode/src/razor-html.ts | 29 +++++++++++++++------------- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/src/editors/vscode/src/client.ts b/src/editors/vscode/src/client.ts index 2e2f70f1..ec83bc3e 100644 --- a/src/editors/vscode/src/client.ts +++ b/src/editors/vscode/src/client.ts @@ -109,7 +109,6 @@ export async function start( // 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)); - context.subscriptions.push(razorHtml.provider); const clientOptions: LanguageClientOptions = { documentSelector: DOCUMENT_SELECTOR, @@ -146,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 index 3d3f10db..0128d2fc 100644 --- a/src/editors/vscode/src/razor-html.ts +++ b/src/editors/vscode/src/razor-html.ts @@ -6,18 +6,19 @@ // https://code.visualstudio.com/api/language-extensions/embedded-languages import { CompletionList, - Uri, + Disposable, commands, workspace, type CompletionItem, - type Disposable, 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 { getErrorMessage, isRecord } from './utils.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']; @@ -46,7 +47,9 @@ export function serverProjection(client: () => LanguageClient | undefined): Proj }); return isRecord(answer) && typeof answer.text === 'string' ? answer.text : undefined; } catch (error) { - log.warn(`Razor HTML projection failed for ${uri}: ${getErrorMessage(error)}`); + log.warn( + JSON.stringify({ event: 'razor_html_projection_failed', uri, errorKind: typeof error }), + ); return undefined; } }; @@ -68,10 +71,14 @@ type Completions = CompletionItem[] | CompletionList | null | undefined; export function createRazorHtml(source: ProjectionSource): RazorHtml { // Each Razor document's latest projection, by the projection's address. const projections = new Map(); - const provider = workspace.registerTextDocumentContentProvider(PROJECTION_SCHEME, { - provideTextDocumentContent: (uri) => - [...projections.values()].find((entry) => entry.address === uri.toString())?.text ?? '', - }); + 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; @@ -114,11 +121,7 @@ function isRazor(document: TextDocument): boolean { * Razor URI it projects and the version it projects, so no answer reads stale text. */ function projectionUri(document: TextDocument): Uri { - return Uri.from({ - scheme: PROJECTION_SCHEME, - path: `/${encodeURIComponent(document.uri.toString())}.html`, - query: `v=${document.version.toString()}`, - }); + return embeddedUri(PROJECTION_SCHEME, document.uri, '.html', `v=${document.version.toString()}`); } /** The HTML service's completion items at `position` in the projection. */ From 804456908c7e0134138ed3f16fac1dca9225eaa1 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:55:11 +1100 Subject: [PATCH 26/38] Razor HTML: test every reachable tokenizer and comment path, drop the unreachable ones - The markup-context tokenizer dispatches on its state and the next character. Quoted values close in one place, and the raw-text and fallback arms that Advance never reaches are gone. - An unterminated comment never yields a standalone `") + if ( + token is { IsImported: false, Source: { } end, Content: "-->" } + && start is { } first + ) { - Spans.Add(TextSpan.FromBounds(first, source.AbsoluteIndex + source.Length)); + Spans.Add(TextSpan.FromBounds(first, end.AbsoluteIndex + end.Length)); return null; } return start; diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs index 37a9d5a6..297c2b60 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs @@ -180,7 +180,7 @@ private static MarkupAttribute Html(string name, MarkupTag tag) } /// A directive attribute's name, @ included, from the span of what follows the @. - private static TextSpan? DirectiveName(SourceSpan? original, string name, SourceText text) + public static TextSpan? DirectiveName(SourceSpan? original, string name, SourceText text) { if (original is not { } span) { From 85a34c0bba42a806772b29067bb5c515f2b30bdc Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:02:52 +1100 Subject: [PATCH 27/38] Test Razor markup that has no projection: a stray file, a refused URI and a stopped client keep the server's answers An unlisted Razor file gets no HTML items or hover, its projection address reads as empty HTML, and a projection the server refuses is logged and answers none. The HTML completion command always resolves to a list, so its undefined fallback goes. --- src/editors/vscode/src/razor-html.ts | 11 ++- .../vscode/src/test/suite/razor-kit.ts | 14 +++ .../vscode/src/test/suite/razor-lsp.test.ts | 99 ++++++++++++++++++- 3 files changed, 118 insertions(+), 6 deletions(-) diff --git a/src/editors/vscode/src/razor-html.ts b/src/editors/vscode/src/razor-html.ts index 0128d2fc..14f6d782 100644 --- a/src/editors/vscode/src/razor-html.ts +++ b/src/editors/vscode/src/razor-html.ts @@ -120,23 +120,26 @@ function isRazor(document: TextDocument): boolean { * 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. */ -function projectionUri(document: TextDocument): Uri { +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 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( + const list = await commands.executeCommand( 'vscode.executeCompletionItemProvider', uri, position, trigger, ); - return list?.items ?? []; + return list.items; } /** The HTML service's hover at `position` in the projection, if it has one. */ diff --git a/src/editors/vscode/src/test/suite/razor-kit.ts b/src/editors/vscode/src/test/suite/razor-kit.ts index 40cccfb2..2741e71f 100644 --- a/src/editors/vscode/src/test/suite/razor-kit.ts +++ b/src/editors/vscode/src/test/suite/razor-kit.ts @@ -3,6 +3,7 @@ // to a scratch root, built so the SDK's Razor generator has run, and loaded // through the real extension. 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 { serverProjection } from '../../razor-html.js'; @@ -22,6 +23,9 @@ export const RAZOR_FILES = { 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'; + /** Copy the fixture to `root` and build it. */ async function writeRazorFixture(root: string): Promise { fs.cpSync(SOURCE, root, { @@ -42,6 +46,16 @@ 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 index d29b4945..3daf7a96 100644 --- a/src/editors/vscode/src/test/suite/razor-lsp.test.ts +++ b/src/editors/vscode/src/test/suite/razor-lsp.test.ts @@ -4,19 +4,55 @@ // 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, useRazorFixture } from './razor-kit'; +import { + htmlProjection, + RAZOR_FILES, + STRAY_RAZOR, + useRazorFixture, + writeStrayRazor, +} from './razor-kit'; import { packageJson } from './run-debug-kit'; -import { closeAllEditors, pollProvider, waitForHoverResult } from './test-helpers'; +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, @@ -147,6 +183,65 @@ suite('Razor in VS Code', () => { assert.ok(!fromMdn(csharp), 'no HTML documentation on C#'); }); + test('markup without a projection keeps the server’s answers alone', async function () { + this.timeout(LSP_RESPONSE_MS * 4); + const root = writeStrayRazor('

Stray

\n

No 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); From 11e9721b34f78b5c8826a175cf096ed826f51554 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:05:22 +1100 Subject: [PATCH 28/38] Razor sidecar: test the projection, span map, rename, settings and file system without the fixture - AdhocRazorProject builds an in-memory project the way MSBuild hands one over: Razor files as additional documents, MSBuild properties and file metadata in a global analyzer config, and fixed generator output in place of the SDK's Razor generator. RazorLocationsTests now uses its generator types. - RazorProjectionTests (6 cases) runs hand-written generator output through every kind of line mapping: the checksum lookup; hidden, fileless, foreign and out-of-range mappings, which never map; carets at a fragment's end; and rename, which edits only listed Razor files whose span holds the old name. - RazorEngineSettingsTests (12 cases) covers the settings fallbacks, target-path metadata, the engine's file system and missing files, projects and documents without a path, cancellation, and an unreadable file that answers null without breaking later requests. - Code that no input can reach is gone. TypeOf reads a type name's arity from its rightmost part and has no dead arms. A projection's Razor path is always the one its checksum names. The span map's bound already keeps it inside the Razor text. Hover treats a missing semantic model as a failure. Refs #397 --- docs/plans/RAZOR-PLAN.md | 2 + .../AdhocRazorProject.cs | 144 ++++++++ .../RazorEngineSettingsTests.cs | 209 ++++++++++++ .../RazorLocationsTests.cs | 33 +- .../RazorProjectionTests.cs | 314 ++++++++++++++++++ .../Razor/RazorMarkupSymbols.cs | 15 +- .../Razor/RazorProjection.cs | 4 +- .../Razor/RazorSpanMap.cs | 2 +- .../Workspace/WorkspaceManager.cs | 10 +- 9 files changed, 682 insertions(+), 51 deletions(-) create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/AdhocRazorProject.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorEngineSettingsTests.cs create mode 100644 src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorProjectionTests.cs diff --git a/docs/plans/RAZOR-PLAN.md b/docs/plans/RAZOR-PLAN.md index 02444670..10931f98 100644 --- a/docs/plans/RAZOR-PLAN.md +++ b/docs/plans/RAZOR-PLAN.md @@ -26,7 +26,9 @@ The phases below are in dependency order. Each item is checked only when a test - [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`: 33 passing cases for tags, attributes, comments, quoted declarations, raw text, script escape states, malformed HTML and caret positions; 100% line coverage 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/RazorEngineSettingsTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorEngineSettingsTests.cs new file mode 100644 index 00000000..915b73c0 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorEngineSettingsTests.cs @@ -0,0 +1,209 @@ +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Razor; + +#pragma warning disable CA1515 // Public xUnit discovery type + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// Configuration and failure contracts for [RAZOR-ARCHITECTURE-ENGINE] and [RAZOR-ERRORS]. +public sealed class RazorEngineSettingsTests : IDisposable +{ + private readonly AdhocWorkspace _workspace = new(); + private static string Board => AdhocRazorProject.PathOf("Board.razor"); + + private static (string Path, string Text)[] Files => + [ + (Board, "

current

"), + (AdhocRazorProject.PathOf("_Imports.razor"), "@using System"), + (AdhocRazorProject.PathOf("Other.razor"), "other"), + ]; + + [Fact] + public void Missing_and_invalid_properties_fall_back_to_project_and_parse_settings() + { + var project = Create("build_property.RazorLangVersion = bad") + .WithDefaultNamespace("Fallback") + .WithParseOptions( + new CSharpParseOptions(LanguageVersion.CSharp13).WithPreprocessorSymbols("FEATURE") + ); + var settings = RazorEngineSettings.Read(project); + Assert.Equal("Fallback", settings.RootNamespace); + Assert.Equal(RazorLanguageVersion.Latest, settings.Configuration.LanguageVersion); + Assert.Equal(LanguageVersion.CSharp13, settings.Configuration.CSharpLanguageVersion); + Assert.Contains("FEATURE", settings.Configuration.PreprocessorSymbols); + Assert.False(settings.LocalizedComponentNames); + } + + [Fact] + public void Explicit_build_properties_override_project_defaults() + { + var project = Create( + "build_property.RootNamespace = Configured\nbuild_property.RazorLangVersion = 8.0\nbuild_property.SupportLocalizedComponentNames = TRUE" + ); + var settings = RazorEngineSettings.Read(project); + Assert.Equal("Configured", settings.RootNamespace); + Assert.Equal(RazorLanguageVersion.Version_8_0, settings.Configuration.LanguageVersion); + Assert.True(settings.LocalizedComponentNames); + } + + [Theory] + [InlineData(null, "/Board.razor")] + [InlineData("not-base64!", "/Board.razor")] + [InlineData("TGlua2VkL0JvYXJkLnJhem9y", "/Linked/Board.razor")] + public void Target_path_metadata_decodes_or_falls_back_to_the_document( + string? encoded, + string expected + ) + { + var config = encoded is null ? "" : TargetConfig(encoded); + var project = Create(config); + var document = AdhocRazorProject.Additional(project, Board); + Assert.Equal(expected, RazorEngineSettings.Read(project).RazorPathOf(document)); + Assert.Equal(Board, document.FilePath); + Assert.Equal( + AdhocRazorProject.Directory, + RazorEngineSettings.Read(project).ProjectDirectory + ); + } + + [Fact] + public async Task File_system_reads_current_target_and_imports() + { + var project = AdhocRazorProject.Create(_workspace, Files); + var system = await FileSystemAsync(project).ConfigureAwait(true); + Assert.Equal(2, system.EnumerateItems("/").Count()); + var item = system.GetItem("/Board.razor", null); + Assert.Equal("/", item.BasePath); + Assert.Equal(Board, item.PhysicalPath); + Assert.True(item.Exists); + using var reader = new StreamReader(item.Read()); + Assert.Equal("

current

", await reader.ReadToEndAsync().ConfigureAwait(true)); + } + + [Fact] + public async Task File_system_represents_missing_files_without_inventing_contents() + { + var system = await FileSystemAsync(Create()).ConfigureAwait(true); + var missing = system.GetItem("/Missing.razor", null); + Assert.False(missing.Exists); + Assert.Equal("/Missing.razor", missing.FilePath); + Assert.Equal("/", missing.BasePath); + using var absent = new StreamReader(missing.Read()); + Assert.Equal("", await absent.ReadToEndAsync().ConfigureAwait(true)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Engine_rejects_unlocated_projects_and_documents(bool missingProject) + { + var project = Create(); + var target = Unlocated(project, missingProject); + Assert.Null(await RazorMarkupEngine.ProcessAsync(target, default).ConfigureAwait(true)); + var valid = await ProcessAsync(project).ConfigureAwait(true); + Assert.NotNull(valid); + Assert.Equal("

hello

", valid.HtmlText); + } + + [Fact] + public async Task Engine_propagates_cancellation() + { + var project = Create(); + var target = AdhocRazorProject.Additional(project, Board); + _ = await Assert + .ThrowsAnyAsync(() => + RazorMarkupEngine.ProcessAsync(target, new CancellationToken(canceled: true)) + ) + .ConfigureAwait(true); + Assert.Equal("

hello

", (await target.GetTextAsync().ConfigureAwait(true)).ToString()); + Assert.NotNull(await ProcessAsync(project).ConfigureAwait(true)); + } + + [Fact] + public async Task Unreadable_workspace_text_is_contained_and_does_not_poison_later_requests() + { + var project = Create(); + var document = AdhocRazorProject.Additional(project, Board); + var failed = project + .Solution.WithAdditionalDocumentTextLoader( + document.Id, + new UnavailableTextLoader(), + PreservationMode.PreserveIdentity + ) + .GetProject(project.Id)!; + Assert.Null(await ProcessAsync(failed).ConfigureAwait(true)); + Assert.Null(await ProcessAsync(failed).ConfigureAwait(true)); + var recovered = AdhocRazorProject.Create(_workspace, [(Board, "

recovered

")]); + var markup = await ProcessAsync(recovered).ConfigureAwait(true); + Assert.NotNull(markup); + Assert.Equal("

recovered

", markup.HtmlText); + } + + private static string TargetConfig(string encoded) + { + return $"[{NativePaths.Slashed(Board)}]\nbuild_metadata.AdditionalFiles.TargetPath = {encoded}"; + } + + private static TextDocument Unlocated(Project project, bool missingProject) + { + return missingProject + ? AdhocRazorProject.Additional( + project.Solution.WithProjectFilePath(project.Id, null).GetProject(project.Id)!, + Board + ) + : project.AddAdditionalDocument("Untitled.razor", SourceText.From("

hello

")); + } + + [Fact] + public void Absent_namespace_and_language_options_use_assembly_name_and_latest_language() + { + var settings = RazorEngineSettings.Read(Create()); + Assert.Equal("App", settings.RootNamespace); + Assert.Equal(RazorLanguageVersion.Latest, settings.Configuration.LanguageVersion); + Assert.False(settings.LocalizedComponentNames); + } + + private Project Create(string config = "") + { + return AdhocRazorProject.Create(_workspace, [(Board, "

hello

")], config); + } + + private static Task ProcessAsync(Project project) + { + return RazorMarkupEngine.ProcessAsync( + AdhocRazorProject.Additional(project, Board), + default + ); + } + + private static Task FileSystemAsync(Project project) + { + return WorkspaceRazorFileSystem.CreateAsync( + AdhocRazorProject.Additional(project, Board), + RazorEngineSettings.Read(project), + default + ); + } + + public void Dispose() + { + _workspace.Dispose(); + } + + private sealed class UnavailableTextLoader : TextLoader + { + public override Task LoadTextAndVersionAsync( + LoadTextOptions options, + CancellationToken cancellationToken = default + ) + { + return Task.FromException( + new InvalidOperationException("Input unavailable") + ); + } + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorLocationsTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorLocationsTests.cs index b0be213a..15112f77 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorLocationsTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorLocationsTests.cs @@ -1,6 +1,4 @@ -using System.Collections.Immutable; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Text; using SharpLsp.Sidecar.Common; using SharpLsp.Sidecar.CSharp.Razor; @@ -25,7 +23,7 @@ public async Task A_solution_without_razor_files_compiles_nothing_to_find_a_proj // Every SDK project carries source generators, and running them means compiling. var solution = workspace .CurrentSolution.AddProject("Plain", "Plain", LanguageNames.CSharp) - .AddAnalyzerReference(new GeneratorReference()) + .AddAnalyzerReference(new GeneratorReference(new FixedSources([]))) .AddDocument("Program.cs", SourceText.From("class Program { }")) .Project.Solution; @@ -42,33 +40,4 @@ public async Task A_solution_without_razor_files_compiles_nothing_to_find_a_proj "no project compiles just to look for a Razor projection" ); } - - /// A generator that adds nothing, as most of a project's generators do. - private sealed class EmptyGenerator : IIncrementalGenerator - { - public void Initialize(IncrementalGeneratorInitializationContext context) { } - } - - /// An analyzer reference that supplies . - private sealed class GeneratorReference : AnalyzerReference - { - public override string FullPath => ""; - - public override object Id => nameof(GeneratorReference); - - public override ImmutableArray GetAnalyzers(string language) - { - return []; - } - - public override ImmutableArray GetAnalyzersForAllLanguages() - { - return []; - } - - public override ImmutableArray GetGenerators(string language) - { - return [new EmptyGenerator().AsSourceGenerator()]; - } - } } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorProjectionTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorProjectionTests.cs new file mode 100644 index 00000000..72d09f25 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorProjectionTests.cs @@ -0,0 +1,314 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.FindSymbols; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.CSharp.Razor; + +#pragma warning disable CA1515 // Public xUnit discovery type +#pragma warning disable CA2007 // xUnit executes without a synchronization context + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// +/// A Razor file's C# projection, read from generated sources written the way the SDK's Razor +/// generator writes them: a #pragma checksum names the Razor file, and #line +/// spans map each C# fragment back into it. Hidden lines, line numbers without a file, +/// other files and spans outside the Razor text never map. Implements [RAZOR-TESTING] for +/// [RAZOR-ARCHITECTURE-PROJECTION] and [RAZOR-FEATURES-RENAME]. +/// +public sealed class RazorProjectionTests +{ + // `count` is used on line 0 at 4..9 and declared on line 2 at 15..20. + private const string CounterText = "

@count

\n@code {\n public int count = 1;\n}\n"; + + private static readonly string Counter = AdhocRazorProject.PathOf("Counter.razor"); + private static readonly string Stale = AdhocRazorProject.PathOf("Stale.razor"); + private static readonly string Orphan = AdhocRazorProject.PathOf("Orphan.razor"); + private static readonly string Notes = AdhocRazorProject.PathOf("Notes.txt"); + private static readonly string Ghost = AdhocRazorProject.PathOf("Ghost.razor"); + + [Fact] + public async Task A_razor_file_projects_through_the_generated_document_its_checksum_names() + { + using var workspace = new AdhocWorkspace(); + var razor = AdhocRazorProject.Additional(Create(workspace), Counter); + + var projection = await RazorProjection.ForRazorAsync(razor, default); + + Assert.NotNull(projection); + Assert.Equal(Counter, projection.RazorPath); + Assert.Equal("Counter.g.cs", projection.Generated.HintName); + Assert.Equal(CounterText, projection.RazorText.ToString()); + Assert.Equal(CounterSource(), projection.GeneratedText.ToString()); + // The use, the declaration and the closing brace map, in Razor order. Roslyn puts a + // mapped end one past the fragment, so an entry runs on through a line break both + // texts share, and the brace's end, past the Razor text, stops at the text's end. + Assert.Equal( + [(4, "count"), (26, "public int count = 1;\n"), (48, "}\n")], + projection.Map.Mappings.Select(entry => + (entry.RazorStart, projection.RazorText.ToString(Razor(entry))) + ) + ); + Assert.All( + projection.Map.Mappings, + entry => + Assert.Equal( + projection.RazorText.ToString(Razor(entry)), + projection.GeneratedText.ToString(Generated(entry)) + ) + ); + } + + [Fact] + public async Task Positions_map_inside_and_at_the_end_of_a_fragment_and_nowhere_else() + { + using var workspace = new AdhocWorkspace(); + var razor = AdhocRazorProject.Additional(Create(workspace), Counter); + var projection = await RazorProjection.ForRazorAsync(razor, default); + Assert.NotNull(projection); + var use = UseLine(projection.GeneratedText); + + Assert.Equal(new LinePosition(use, 0), projection.ToGenerated(new LinePosition(0, 4))); + Assert.Equal(new LinePosition(use, 2), projection.ToGenerated(new LinePosition(0, 6))); + // A caret just after `@count` still maps, to the end of the generated fragment. + Assert.Equal(new LinePosition(use, 5), projection.ToGenerated(new LinePosition(0, 9))); + Assert.Null(projection.ToGenerated(new LinePosition(0, 1))); + Assert.Null(projection.ToGenerated(new LinePosition(1, 3))); + Assert.Null(projection.ToGenerated(new LinePosition(9, 0))); + + Assert.Equal(Span(0, 4, 0, 9), projection.ToRazor(Span(use, 0, use, 5))); + Assert.Equal(Span(0, 5, 0, 7), projection.ToRazor(Span(use, 1, use, 3))); + Assert.Null(projection.ToRazor(Span(use, 0, use + 1, 0))); + Assert.Null(projection.ToRazor(Span(0, 0, 0, 3))); + Assert.Null(projection.ToRazor(Span(use, 0, 999, 0))); + } + + [Fact] + public async Task Only_a_listed_razor_file_that_a_checksum_names_has_a_projection() + { + using var workspace = new AdhocWorkspace(); + var project = Create(workspace); + var generated = (await project.GetSourceGeneratedDocumentsAsync()).ToDictionary(document => + document.HintName + ); + + Assert.Null(await RazorProjection.ForRazorAsync(Additional(project, Orphan), default)); + Assert.Equal(Counter, (await ForGenerated(generated["Counter.g.cs"]))?.RazorPath); + Assert.Equal(Stale, (await ForGenerated(generated["Stale.g.cs"]))?.RazorPath); + // Ghost.razor is not in the project, Notes.txt is not Razor, and Plain names no file. + Assert.Null(await ForGenerated(generated["Ghost.g.cs"])); + Assert.Null(await ForGenerated(generated["Notes.g.cs"])); + Assert.Null(await ForGenerated(generated["Plain.g.cs"])); + + Assert.Equal(Notes, RazorProjection.ChecksumPath(await Root(generated["Notes.g.cs"]))); + Assert.Null(RazorProjection.ChecksumPath(await Root(generated["Plain.g.cs"]))); + Assert.Null(RazorProjection.ChecksumPath(null)); + } + + [Fact] + public async Task A_project_whose_generators_add_nothing_projects_no_razor_file() + { + using var workspace = new AdhocWorkspace(); + var project = AdhocRazorProject.Create(workspace, [(Orphan, "

orphan

\n")]); + + var projection = await RazorProjection.ForRazorAsync(Additional(project, Orphan), default); + + Assert.Null(projection); + Assert.Empty(await project.GetSourceGeneratedDocumentsAsync()); + _ = Assert.Single(project.AdditionalDocuments); + } + + [Fact] + public async Task Rename_edits_only_listed_razor_spans_that_hold_the_old_name() + { + using var workspace = new AdhocWorkspace(); + var project = Create(workspace); + var compilation = await project.GetCompilationAsync(); + var field = compilation!.GetTypeByMetadataName("App.Counter")!.GetMembers("count").Single(); + var referenced = await SymbolFinder.FindReferencesAsync(field, project.Solution); + + var edits = await RazorRenameEdits.FromReferencesAsync( + project.Solution, + referenced, + ("count", "total"), + default + ); + + // Ghost.razor is unlisted, Stale.razor's span holds `total`, and the hidden, unmapped, + // foreign and out-of-range uses map to no Razor text. + var edit = Assert.Single(edits); + Assert.Equal(Counter, edit.FilePath); + Assert.Equal( + [(0, 4, 0, 9), (2, 15, 2, 20)], + edit.Edits.Select(e => (e.StartLine, e.StartCharacter, e.EndLine, e.EndCharacter)) + ); + Assert.All(edit.Edits, e => Assert.Equal("total", e.NewText)); + var found = await RazorRenameEdits.FindAsync(project.Solution, field, "total", default); + Assert.Equal( + edit.Edits.Select(e => (e.StartLine, e.StartCharacter)), + Assert.Single(found).Edits.Select(e => (e.StartLine, e.StartCharacter)) + ); + } + + [Fact] + public void Mappings_that_start_outside_either_text_are_dropped() + { + var tree = CSharpSyntaxTree.ParseText(CounterSource()); + var generated = tree.GetText(); + var razor = SourceText.From(CounterText); + var use = UseLine(generated); + // The generated text ends just before the use's line, as a stale snapshot would. + var cut = SourceText.From( + generated.ToString(TextSpan.FromBounds(0, generated.Lines[use - 1].End)) + ); + + Assert.Equal([4, 26, 48], Starts(RazorSpanMap.Build(tree, generated, razor, Counter))); + Assert.Equal([26], Starts(RazorSpanMap.Build(tree, cut, razor, Counter))); + Assert.Equal( + [4], + Starts(RazorSpanMap.Build(tree, generated, SourceText.From("

@count

\n"), Counter)) + ); + Assert.Empty(RazorSpanMap.Build(tree, generated, razor, Stale).Mappings); + } + + private static Project Create(AdhocWorkspace workspace) + { + return AdhocRazorProject.Create( + workspace, + [ + (Counter, CounterText), + (Stale, "

@total

\n"), + (Orphan, "

orphan

\n"), + (Notes, "notes\n"), + ], + generated: + [ + ("Counter.g.cs", CounterSource()), + ("Stale.g.cs", ReaderSource("Stale", Stale)), + ("Ghost.g.cs", ReaderSource("Ghost", Ghost)), + ("Notes.g.cs", Lines(Checksum(Notes), "namespace App { partial class Notes { } }")), + ("Plain.g.cs", "namespace App { partial class Plain { } }"), + ] + ); + } + + /// Counter's projection: every kind of line mapping the generator can write. + private static string CounterSource() + { + return Lines( + Checksum(Counter), + "namespace App", + "{", + " public partial class Counter", + " {", + $"#line (3,5)-(3,26) \"{Counter}\"", + "public int count = 1;", + "#line default", + " public object Render()", + " {", + " return", + $"#line (1,5)-(1,10) \"{Counter}\"", + "count", + "#line default", + " ;", + " }", + "#line hidden", + " public int Hidden => count;", + "#line default", + "#line 7", + " public int Unmapped => count;", + $"#line 9 \"{AdhocRazorProject.PathOf("Other.cs")}\"", + " public int Elsewhere => count;", + $"#line (40,1)-(40,6) \"{Counter}\"", + "public int Far => count;", + $"#line (1,40)-(1,45) \"{Counter}\"", + "public int Wide => count;", + "#line default", + " public void Block()", + " {", + " {", + $"#line (4,1)-(9,1) \"{Counter}\"", + "}", + "#line default", + " }", + " }", + "}" + ); + } + + /// A projection of that reads Counter's field there. + private static string ReaderSource(string name, string razorPath) + { + return Lines( + Checksum(razorPath), + "namespace App", + "{", + $" public partial class {name}", + " {", + " public int Read(Counter counter)", + " {", + " return counter.", + $"#line (1,5)-(1,10) \"{razorPath}\"", + "count", + "#line default", + " ;", + " }", + " }", + "}" + ); + } + + private static string Checksum(string path) + { + return $"#pragma checksum \"{path}\" \"{{8829d00f-11b8-4213-878b-770e8597ac16}}\" \"00\""; + } + + private static string Lines(params string[] lines) + { + return string.Join("\n", lines); + } + + /// The generated line that holds Counter's mapped use of count. + private static int UseLine(SourceText generated) + { + return generated.Lines.Single(line => line.ToString() == "count").LineNumber; + } + + private static TextDocument Additional(Project project, string path) + { + return AdhocRazorProject.Additional(project, path); + } + + private static Task ForGenerated(SourceGeneratedDocument generated) + { + return RazorProjection.ForGeneratedAsync(generated, default); + } + + private static Task Root(SourceGeneratedDocument generated) + { + return generated.GetSyntaxRootAsync(); + } + + private static int[] Starts(RazorSpanMap map) + { + return [.. map.Mappings.Select(entry => entry.RazorStart)]; + } + + private static TextSpan Razor(RazorMapping entry) + { + return new TextSpan(entry.RazorStart, entry.Length); + } + + private static TextSpan Generated(RazorMapping entry) + { + return new TextSpan(entry.GeneratedStart, entry.Length); + } + + private static LinePositionSpan Span(int line, int character, int endLine, int endCharacter) + { + return new LinePositionSpan( + new LinePosition(line, character), + new LinePosition(endLine, endCharacter) + ); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupSymbols.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupSymbols.cs index 705bb7d9..a0c5f372 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupSymbols.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupSymbols.cs @@ -15,20 +15,15 @@ internal static class RazorMarkupSymbols /// The type names in . public static INamedTypeSymbol? TypeOf(Compilation compilation, TagHelperDescriptor descriptor) { - var name = SyntaxFactory.ParseTypeName(descriptor.TypeName) switch - { - QualifiedNameSyntax qualified => qualified.Right, - SimpleNameSyntax simple => simple, - _ => null, - }; - var arity = name is GenericNameSyntax generic ? generic.Arity : 0; + // A generic component's type name ends in its type parameters: `Shared.Tally`. + // A name's arity counts only its own, so a qualified name asks its rightmost part. + var name = SyntaxFactory.ParseName(descriptor.TypeName); + var arity = (name is QualifiedNameSyntax qualified ? qualified.Right : name).Arity; var prefix = string.IsNullOrEmpty(descriptor.TypeNamespace) ? "" : descriptor.TypeNamespace + "."; var suffix = arity > 0 ? "`" + arity : ""; - return name is null - ? null - : compilation.GetTypeByMetadataName(prefix + descriptor.TypeNameIdentifier + suffix); + return compilation.GetTypeByMetadataName(prefix + descriptor.TypeNameIdentifier + suffix); } /// The property named on or a base type. diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorProjection.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorProjection.cs index 2b12f916..b2eafaab 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorProjection.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorProjection.cs @@ -21,8 +21,8 @@ internal sealed record RazorProjection( RazorSpanMap Map ) { - /// The Razor file's path. - public string RazorPath => Razor.FilePath ?? ""; + /// The Razor file's path, which its projection's checksum names, so never null. + public string RazorPath => Razor.FilePath!; /// The projection of , or null when its project generates none. public static async Task ForRazorAsync( diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSpanMap.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSpanMap.cs index 06dba1fb..04118b10 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSpanMap.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSpanMap.cs @@ -129,10 +129,10 @@ private static int CommonLength( int bound ) { + // The bound never runs past the Razor text, so only the generated end needs a check. var length = 0; while ( length < bound - && razorStart + length < razor.Length && generatedStart + length < generated.Length && razor[razorStart + length] == generated[generatedStart + length] ) diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs index 4f9b4089..7cdb041a 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs @@ -323,12 +323,10 @@ public Task GetHoverAsync( private static async Task HoverAtAsync(QueryTarget target, CancellationToken ct) { var text = await target.Document.GetTextAsync(ct).ConfigureAwait(false); - var model = await target.Document.GetSemanticModelAsync(ct).ConfigureAwait(false); - if (model is null) - { - return null; - } - + // Every query target is a C# document, which always has a semantic model. + var model = + await target.Document.GetSemanticModelAsync(ct).ConfigureAwait(false) + ?? throw new InvalidOperationException("Hover found no semantic model"); var position = text.Lines.GetPosition(new LinePosition(target.Line, target.Character)); var built = CSharpHoverBuilder.Build(model, position, ct); // The hover's range is the token's mapped span, already in the Razor file for a From 014ee9fd2675873b1449fe075ebe274180b74e89 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:05:22 +1100 Subject: [PATCH 29/38] Host: test Razor structure requests without a sidecar, and postfix templates in Razor - linkedEditingRange and foldingRange fail and name themselves when the C# sidecar is missing, instead of answering that nothing folds or links. - Postfix templates never expand in a Razor file, where the same text can be markup. [RAZOR-FEATURES-COMPLETION] now says so. Refs #397 --- docs/specs/RAZOR-SPEC.md | 2 +- src/sharplsp/src/postfix_completion.rs | 24 ++++++++++++++++++++++++ src/sharplsp/src/razor.rs | 24 ++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/docs/specs/RAZOR-SPEC.md b/docs/specs/RAZOR-SPEC.md index b85a94cc..d9ca150d 100644 --- a/docs/specs/RAZOR-SPEC.md +++ b/docs/specs/RAZOR-SPEC.md @@ -302,7 +302,7 @@ The facts hold no C# fragment spans: C# positions come from the projection's map | `@` 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 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 diff --git a/src/sharplsp/src/postfix_completion.rs b/src/sharplsp/src/postfix_completion.rs index 4663ba26..24eee8cf 100644 --- a/src/sharplsp/src/postfix_completion.rs +++ b/src/sharplsp/src/postfix_completion.rs @@ -346,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 index 87bc7b38..7be98729 100644 --- a/src/sharplsp/src/razor.rs +++ b/src/sharplsp/src/razor.rs @@ -230,6 +230,30 @@ mod tests { 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() { From 64372afb991a1539809e99a82d3ed84c45399186 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:15:11 +1100 Subject: [PATCH 30/38] Razor HTML tokenizer: reconsume after a stray slash, a doubled `<` and a bad end-tag opening The markup context at a caret now follows the WHATWG tokenizer in three malformed cases: - After `/` in a start tag, any character but `>` is reconsumed as the start of an attribute, so `
` opens a bogus comment that ends at the next `>`. Three regression tests failed before the fix and pass unchanged after it. The tokenizer now has 36 cases and full line coverage. Refs #397 --- docs/plans/RAZOR-PLAN.md | 2 +- .../HtmlMarkupContextTests.cs | 30 +++++++++++++++++++ .../Razor/HtmlMarkupContext.cs | 13 ++++---- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/docs/plans/RAZOR-PLAN.md b/docs/plans/RAZOR-PLAN.md index 10931f98..744f74f8 100644 --- a/docs/plans/RAZOR-PLAN.md +++ b/docs/plans/RAZOR-PLAN.md @@ -31,7 +31,7 @@ The phases below are in dependency order. Each item is checked only when a test - [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`: 33 passing cases for tags, attributes, comments, quoted declarations, raw text, script escape states, malformed HTML and caret positions; 100% line coverage + - [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 diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HtmlMarkupContextTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HtmlMarkupContextTests.cs index 0a4fa1dc..c266559e 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HtmlMarkupContextTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HtmlMarkupContextTests.cs @@ -175,6 +175,36 @@ public void A_self_closed_script_still_reads_raw_text_like_the_tokenizer() Assert.Equal(MarkupContext.Content, Of("