diff --git a/.deslop.toml b/.deslop.toml
index dedd3afc..abf62af6 100644
--- a/.deslop.toml
+++ b/.deslop.toml
@@ -1,6 +1,6 @@
# agent-pmo:a72c926
[threshold]
-max_duplication_percent = 6.65
+max_duplication_percent = 6.48
[defaults]
exclude = [
diff --git a/coverage-thresholds.json b/coverage-thresholds.json
index 89bada40..4f8270b3 100644
--- a/coverage-thresholds.json
+++ b/coverage-thresholds.json
@@ -12,7 +12,7 @@
"language": "dotnet"
},
"sharplsp-sidecar-csharp": {
- "threshold": 95.39,
+ "threshold": 95.54,
"language": "dotnet"
},
"sharplsp-sidecar-fsharp": {
diff --git a/docs/plans/RAZOR-PLAN.md b/docs/plans/RAZOR-PLAN.md
index 66e28729..ce6d727a 100644
--- a/docs/plans/RAZOR-PLAN.md
+++ b/docs/plans/RAZOR-PLAN.md
@@ -31,10 +31,20 @@ 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] The C# in an HTML attribute value (`disabled="@(…)"`) and every directive attribute, including a minimized parameter the compiler gives no span (`@onclick:stopPropagation`) and a binding parameter's value (`@bind:event="oninput"`), is blank in the projection, while the attribute's name, quotes and literal text stay (#407, review R10, Claude2, 694b48c0). Three regressions failed with the fix off, then passed unchanged; the full .NET leg passes and C# coverage ratchets to 95.54%
- [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
+- [x] Sidecar: Razor C# features answer whatever SDK a project resolves (#399). When the project's SDK Razor generator was built against a newer Roslyn than the sidecar's, the bundled Razor compiler loads in its place, and a warning names both Roslyn versions ([RAZOR-ARCHITECTURE-GENERATOR])
+ - [x] Root cause: a newer SDK's Razor compiler has a higher assembly version than the bundled copy. Roslyn's analyzer loader stands the sidecar's own copy in only once that copy is loaded. Otherwise it loads the SDK's copy, finds it references a newer `Microsoft.CodeAnalysis`, and refuses it for the session. xUnit's discovery always loads the bundled copy before any test runs, which is why the sidecar's own Razor tests passed on runners with SDK 10.0.401 while the end-to-end suites failed. Confirmed on SDK 10.0.401 itself: its compiler is version 10.4.0.0 and references `Microsoft.CodeAnalysis` 5.9, against the sidecar's 5.6
+ - [x] Failing tests, failing for the bug: `RazorNewerSdkTests` gives the fixture's design-time builds a newer SDK's Razor compiler (a higher assembly version that references a newer Roslyn). In a fresh sidecar process, hover in `Counter.razor` answers null: the process loads that compiler from the SDK's folder, never its own copy, and Roslyn refuses it. In process, the projects keep the newer SDK's generator files. The fixture copies now keep the repository's SDK pin, as the editor suites' copies do
+ - [x] Fix: at project load, an SDK Razor compiler that references a newer `Microsoft.CodeAnalysis` than the running one is swapped, with its two companion files, for the bundled copies, and a warning names both versions (`RazorGenerator`, Codex1, 029fc3a)
+ - [x] Pass: the two failing tests pass unchanged, and a third proves a compiler that cannot be read leaves the project as it loaded
+ - [x] Full sidecar suite and coverage gates with the fix: C# 693/693, F# 447/447, Common 125/125; C# coverage 96.50% ratchets its threshold to 95.49%
+ - [x] CI proof on a real newer SDK: a Rust end-to-end Razor test whose fixture copy keeps no SDK pin, so runners carrying SDK 10.0.401 load it with that SDK (Codex1, 029fc3a). It passes locally against an isolated real SDK 10.0.401, and in CI: `test_latest_sdk_razor_generator_remains_usable` passes on Rust Shard 2/2 of run 39364686929, whose ubuntu-24.04 image (20261012.334) ships SDK 10.0.401 beside the pinned 10.0.303
+ - [x] Docs and blog: drop the advice to pin SDK 10.0.303, and explain the bundled-compiler fallback (Claude1, 8c418ced)
+
**Validation (2026-10-20):** the Release sidecar solution builds with zero warnings and errors; 671 C#, 440 F# and 125 Common tests pass, and all three coverage gates pass. C# coverage is 96.39%, with all 1,530 new C# executable lines covered. `RazorSidecarTests` exercises the real IPC contract and malformed-request recovery; `RazorCompletionEditsTests` covers mapped edits, import deduplication, aliases, static imports and LF/CRLF insertion.
**Editor validation (2026-10-20):** the final Razor chunk passes all 6 cases, the workspace chunk passes all 446 cases (including server-start lifecycle), the debugger chunk passes all 80 cases, and editor lint and formatting pass. The debugger group-outcome test that previously timed out passes unchanged in the full chunk in 6.5 seconds.
@@ -48,9 +58,14 @@ The phases below are in dependency order. Each item is checked only when a test
- [ ] 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] Completion right after a typed dot offers members: `@scores.` and an unclosed `@(scores.` (#401, review R2). Red tests `RazorCompletionRecoveryTests` (Codex1) failed for the bug, then passed unchanged once completion asks a copy of the file with the expression finished at the caret (Claude2). An unclosed `@{` already completed and is kept as a control
+ - [x] Coverage gates with the #401 fix; the full C# sidecar suite passes with it
+ - [x] Completion inside a call or lambda of an unfinished `@(` offers members (#404, review R8). Red tests from Claude1's report failed for the bug, then passed unchanged once the copy closes every construct Roslyn's parser reads as open, innermost first, and its map keeps the parts of a fragment outside the C# it inserted. Completion now reads the compiler's diagnostics only when the projection's C# does not parse, and forks only the file's active framework
- [x] Signature help ([RAZOR-FEATURES-SIGNATURE-HELP])
+ - [x] Signature help after `@(` no longer lists the generator's scaffolding (`AddContent`, `AddAttribute`) (#400, review R1, Codex1): the regression cases failed for the bug, then passed unchanged
- [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])
+ - [x] Escaped identifiers (`@class`) get their Razor rename edits (review R4, Codex1): the real-fixture regression failed for the bug, then passed unchanged
### Phase 3: Blazor Markup
@@ -71,6 +86,7 @@ The phases below are in dependency order. Each item is checked only when a test
- [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] A C# position offers C# items alone: no word suggestions from the HTML projection, including after a typed dot and inside an unfinished `@(` (#405, review R9, Codex1, fb1fff3). The client sends the caret, and the projection answers null where the compiler-backed completion target is C#, while tag and attribute names stay markup. The host, sidecar and real-editor regressions failed for the bug, then passed unchanged
- [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`
diff --git a/docs/plans/RAZOR-REVIEW.md b/docs/plans/RAZOR-REVIEW.md
new file mode 100644
index 00000000..f2992423
--- /dev/null
+++ b/docs/plans/RAZOR-REVIEW.md
@@ -0,0 +1,115 @@
+# Review since 67eba0793051391209cbfecfe3b39c2fa9c25172
+
+Scope: the complete tracked diff from the requested baseline through current HEAD, plus in-progress working changes. Started 2026-10-20. This is a live review report; unchecked areas are still under review. Findings require concrete behavior and source evidence.
+
+## Findings
+
+### R1 — P1: Razor signature help exposes generated rendering calls
+
+Owner: SharpLspCodex1. Status: fixed; all three regression cases failed for the reported reason, then passed unchanged. Razor and ordinary C# signature-help tests: 11/11 passed. The added guard has 100% line coverage and all 6/6 branch outcomes covered.
+
+`src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SignatureHelpResolver.cs` selects an enclosing generated argument list without checking that its opening parenthesis maps to Razor source. At `disabled="@(|scores.Count > 9)"`, the user receives RenderTreeBuilder.AddAttribute signatures; `
@(|1)
` exposes AddContent. Suppress calls whose opening parenthesis has no Razor mapping, while preserving actual user C# calls. Issue: https://github.com/Nimblesite/SharpLsp/issues/400. [RAZOR-FEATURES-SIGNATURE-HELP]
+
+### R2 — P1: completion fails immediately after a typed dot
+
+Owner: SharpLspRazorClaude2 (fix), SharpLspCodex1 (red tests/review). Status: confirmed by real-fixture tests: implicit trailing dot and unclosed explicit expression fail with empty completions; closed explicit expression is a passing control. Fix implemented by Claude2: original resolver reproduced two failures, then all four cases passed (implicit, unclosed explicit, closed control, and unclosed code block). Full C# suite pending. Issue: https://github.com/Nimblesite/SharpLsp/issues/401
+
+`
@basket.|
` and an unclosed `
@(basket.|` return no C# members. Completion works once a member prefix is present or the explicit expression is closed. The Razor generator ends or omits the mapping of these incomplete expressions. Reproduce against a real component before fixing. [RAZOR-FEATURES-COMPLETION]
+
+### R3 — P1: newer SDK Razor generator cannot load in the sidecar
+
+Owner: SharpLspCodex1 (fix, host proof and spec), SharpLspRazorClaude2 (deterministic regression fixture/tests). Status: both unchanged incompatible-generator tests pass after the fix; compatible SDK references remain unchanged. A third test verifies missing and malformed analyzer files preserve the project and its references. All three pass on the final implementation. Full integration and coverage gates are pending.
+
+Unpinned temporary/new user projects can select SDK 10.0.401, whose Razor generator references a Roslyn version newer than the sidecar runs. The generator produces no usable component projection; Razor hover disappears and Program.cs cannot resolve generated App (CS0246). The existing fixture pin keeps ordinary regression runs deterministic but does not fix user projects. Issue: https://github.com/Nimblesite/SharpLsp/issues/399. [RAZOR-ARCHITECTURE-GENERATOR]
+
+### R4 — P1: escaped identifiers silently receive no Razor rename edits
+
+Owner: SharpLspCodex1. Status: fixed; the unchanged real-fixture regression now passes. Signature-help, real-call roundtrips, escaped rename, existing projection and ordinary C# signature-help tests: 29/29 passed.
+
+`src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorRenameEdits.cs` compared a mapped token's raw source spelling with `ISymbol.Name`. For `@class`, those are `@class` and `class`, so every declaration/use was discarded. A real-fixture test confirmed prepareRename succeeds but rename returns zero edits. The fix parses the entire mapped span as a Roslyn identifier and compares its ValueText, preserving the full replacement span. Assertions cover declaration range, placeholder, three usage/declaration edits, target path, line order, replacement text, and removal of the escape. [RAZOR-FEATURES-RENAME]. Issue: https://github.com/Nimblesite/SharpLsp/issues/402
+
+### R5 — P1: plain C# keywords use the control-flow theme color
+
+Owner: SharpLspCodex1. Status: editor regression reproduced red, then green unchanged.
+
+`src/editors/vscode/package.json` mapped `controlKeyword` but omitted a language-specific plain `keyword` mapping. VS Code's public token classification registry defaults `keyword` to `keyword.control`, making `public`, `class`, `string`, and `int` purple in Dark+/Dark Modern. Add the C#-scoped standard `keyword.other.cs` mapping; retain the control keyword scope and F# grammar colors. The same mapping covers Razor and aspnetcorerazor. No RGB values or user setting overrides. The editor test asserts scope existence, language isolation, plain/control separation, and absence of hard-coded token color customization defaults. [SHARPLSP-FEATURES-HIGHLIGHTING]
+
+### R6 — P1: F# values and functions are mislabeled as methods
+
+Owner: SharpLspCodex1. Status: six real-project cases reproduced the incorrect categories, then passed unchanged. Full F# suite: 447/447 passed; the highlighting implementation has 100% line coverage. Value-type constructor follow-up adds a seventh red case; it now passes unchanged after preserving the struct category.
+
+`src/sidecars/SharpLsp.Sidecar.FSharp/FSharpFeatures.fs::mapFcsSymbolKind` reports every non-property/event function-or-value as method. Module values and local values therefore receive method colors, and functions lose their standard function category. FCS property/qualified ranges can also include receivers. Tests pin exact spans and standard categories for module/local values, functions, properties and methods in both full and range results, and assert keywords remain owned by the grammar. Reuse the existing FCS lexer helper for source token boundaries. [SHARPLSP-FEATURES-HIGHLIGHTING]
+
+### R7 — P2: non-finite video seek parameters throw in the page
+
+Owner: SharpLspRazorClaude1. Status: fixed and pushed as b7d301d8; regression failed with a non-finite currentTime page error, then passed across all three browser/form-factor projects.
+
+`src/website/src/assets/js/custom.js::initSeekToTime` accepted `?t=Infinity` because Infinity is positive, then assigned an invalid media currentTime. Guard with Number.isFinite. The new test also checks overflowing `1e400`, negative and nonnumeric inputs and asserts no page error or seek. [WEB-DESIGN-MEDIA-DISCOVERY]
+
+### R8 — P1: nested unfinished Razor expressions lose completion
+
+Owner: SharpLspRazorClaude2. Status: fixed in f12be294. Claude1 reproduced null completion with a nested unclosed `@(` expression after the first completion recovery fix. The first three reported cases failed because Price was missing, while closed-expression and code-block controls passed. All six cases, including a lambda block, now pass unchanged. The full C# suite passes 692/692. Roslyn supplies the missing closing tokens; the map preserves only the prefix before inserted text, keeping the original caret before synthetic closers. Issue: https://github.com/Nimblesite/SharpLsp/issues/404. [RAZOR-FEATURES-COMPLETION]
+
+### R9 — P1: HTML projection word suggestions leak into Razor C# completion
+
+Owner: SharpLspCodex1. Status: fixed. The real host test failed because a C# binding caret still returned an HTML projection. The fix sends an optional caret and reuses the compiler-backed completion target resolver; compiler markup names take precedence. Nineteen IPC tests pass, including thirteen new cases covering markup names and literal values, bindings, code, whitespace, implicit trailing dots and incomplete nested expressions. The unchanged host regression passes. The real editor regression registers an additional provider on the actual HTML projection: its marker must appear in markup and never in C#. The old unpositioned forwarding failed that assertion; the unchanged corrected regression passes with all six Razor editor cases. An initially overbroad new assertion rejected legitimate C# Text-kind fallback items; it was corrected to observe HTML forwarding specifically, retaining all original assertions and the real C#/HTML services. Issue: https://github.com/Nimblesite/SharpLsp/issues/405. [RAZOR-FEATURES-HTML]
+
+### R10 — P1: directive attributes and embedded C# remain in the HTML projection
+
+Owner: SharpLspRazorClaude2; reviewed by SharpLspCodex1. The compiler sometimes omits the span of a minimized directive parameter or a quoted parameter value. The projection now derives boundaries from compiler attribute structure and descendant spans, and validates span-less names at the preceding compiler-owned attribute boundary. Directive children are never copied back as HTML. Six new exact-output cases cover ordinary C# attribute values, directive values, minimized parameters, quoted binding parameters and preceding literal/minimized HTML attributes. The real Board fixture also asserts that C# expressions, binding names and binding values are absent while its input element remains. Three regression cases were confirmed failing with the fix disabled; all 56 focused Razor cases pass unchanged. The full .NET leg passes with the fix: C# 716/716, F# 447/447, Common 125/125; C# coverage 96.54% ratchets its threshold to 95.54%, and every line of the changed projection and IR files is covered. Issue: https://github.com/Nimblesite/SharpLsp/issues/407. [RAZOR-ARCHITECTURE-HTML]
+
+## Review and validation checklist
+
+- [x] C# workspace/query changes and non-Razor compatibility reviewed
+- [x] Razor compiler/IR/mapping/HTML/rename/structure reviewed
+- [x] Common paths and F# project loading reviewed
+- [x] Rust host routing/syntax/diagnostics/sort changes reviewed
+- [x] VS Code and Zed integration reviewed
+- [x] Website templates/data/media/tooling and documentation reviewed
+- [x] Changed existing tests audited for removed/weakened assertions
+- [x] lspkit submodule changes reviewed: only `.deslop/` added to its `.gitignore`; no runtime code changed.
+- [x] R1 reproduced red, fixed, unchanged tests green
+- [x] R2 reproduced red, fixed, unchanged tests green (Claude2 reports 4/4; integration suite pending)
+- [x] R3 newest-SDK host regression proof green after sidecar fix (real SDK 10.0.401, isolated installation)
+- [x] R4 escaped rename fixed with unchanged real-fixture assertions (29-test focused run green)
+- [x] R5 standard keyword scope regression red/green in the real editor
+- [x] R6 F# category/span/operator regressions red/green (447-test full suite green)
+- [x] R7 non-finite video seek regression red/green across three projects
+- [x] R8 nested unfinished expression regression red/green
+- [x] R9 editor completion leak regression red/green (6/6 real-editor cases); final combined CI remains pending
+- [x] Value-type constructor follow-up green on final F# build (7/7 focused tests)
+- [x] R10 source-less directive attribute regressions red/green; full .NET leg and coverage gate green
+- [ ] Full integrated suite and CI gates green
+
+Pi performed the SDK test reuse inspection, which was reviewed against the source and used. The subsequent read-only website/host/Razor/F# review requests all returned provider timeouts without findings. The primary agent inspected those changes directly; no review item relies on missing delegation output.
+
+## Test weakening audit evidence
+
+- Removed assertion lines in the existing test diff occur in the website media tests: the 2400px/loaded/meaningful-alt/no-filename checks moved unchanged into `tests/media.js::expectStills` and remain called.
+- Existing video catalogue expectations changed to the replacement narrated films and MP4-first encoding; equivalent schema/source/poster checks remain, with all-language walkthrough coverage added.
+- Zed assertions retain the existing CSharp/FSharp values and add Razor; the unavailable-sidecar assertion still requires an error after its handler rename.
+- No existing C#/F# sidecar assertions were removed. C# coverage threshold increased from 95 to 95.39; no threshold was lowered.
+- SDK pinning narrowed the ordinary Razor fixtures' SDK selection. A separate unpinned Rust regression test now restores the newest installed SDK and asserts a real C# field hover, mapped range, and no generated-component CS0246. It compiles and passes locally against SDK 10.0.303 (the newest installed locally); deterministic incompatible-generator sidecar tests still reproduce #399. CI additionally installs newer SDKs.
+
+## Static scope and excluded candidates
+
+The baseline diff initially covered 395 tracked paths: 276 website, 58 sidecar, 41 host, 11 editor, plus documentation/configuration/submodule paths. The continuing working changes and follow-up commits are included. Runtime review traced projection ownership and UTF-16 mapping, generated-location filtering, real-file edits/import insertion, active frameworks, F# references, host dispatch, virtual HTML document lifetime, middleware request ordering, standard theme fallback, template escaping, catalogue-generated pages, captions, video source selection, and filming tool isolation. Media binaries were reviewed through references/metadata and existing browser assertions, not by treating binary bytes as source code.
+
+A possible open-sync composition race was rejected after tracing the actual hooks: open-sync intercepts sendRequest, while Razor wraps provideHover/provideCompletionItem, so their middleware keys do not overwrite each other. No patch or assertion removal was made for that rejected candidate.
+
+Remaining unchecked validation tasks concern executing fixes together and CI; they are not a claim that later Razor backlog phases are complete.
+
+## Current CI-prep evidence
+
+- Rust: the final archive passes all 567 tests (301 + 266 across the two CI partitions) against the final #405/#407 sidecars. Merged coverage is 8938/9399 lines (95.0952%), passing the gate. Rust fmt/clippy and Zed fmt/clippy pass.
+- Editor: all three highlighting cases pass against the staged host/sidecars after restaging the packaged payload; extension lint/typecheck pass.
+- F#: all 447 sidecar tests pass with coverage after the final value-type constructor correction.
+- .NET: the final full suites pass: C# 716/716, F# 447/447 and Common 125/125; C# coverage is 96.54%, raising its ratchet to 95.54%.
+- Tooling: 72 tests pass. Rust/NuGet/npm dependency audits report no vulnerabilities at the configured threshold.
+- Zed: final archive executes all 27 tests successfully; 89.95% line coverage passes the ratchet. Final Rust and Zed lint passes.
+- #399: both deterministic incompatible-generator tests were confirmed red before the fix and now pass unchanged, including a fresh sidecar process and preservation of compatible SDK analyzer references. After the final rebuild, the unpinned host test also passes against real SDK 10.0.401, downloaded from Microsoft's release metadata into an isolated temporary directory and verified against the published SHA512. Only that test's PATH and DOTNET_ROOT use the temporary SDK; the user's installed SDKs remain untouched. The test verifies field hover/type, original Razor range and absence of generated-component CS0246.
+- Earlier full-editor attempts failed because VSIX packaging intentionally removed the staged `bin` directory; after restaging, the unchanged highlighting suite passed. No product/test assertion was weakened to bypass the missing artifact.
+
+## Final PR validation follow-up
+
+PR #406, head d3697e70: both host shards, the .NET sidecar suite, Linux and Windows artifact builds, lint, audit and CodeQL pass remotely. The Windows workspace check that timed out on the earlier head passes unchanged. The Windows profiler chunk fails nine assertions: six F# CPU attribution/heat-map checks, followed by two decoration checks and a setting-default check. A failed soak assertion can leave showInlineHeatMap disabled before its normal reset, explaining the later configuration failures; the F# attribution failures remain under investigation. All existing assertions remain required. Full CI stays unchecked until this failure is resolved. Logs: /tmp/sharplsp-pr406-profiler-failed.txt; reviewed the complete log with a read-only pi session and inspected the assertions and cleanup directly. Pi's suggestion that the declared default was false was rejected: the observed value was mutable test configuration.
diff --git a/docs/specs/DESIGN-SYSTEM.md b/docs/specs/DESIGN-SYSTEM.md
index 61b5471c..0f70f2fe 100644
--- a/docs/specs/DESIGN-SYSTEM.md
+++ b/docs/specs/DESIGN-SYSTEM.md
@@ -88,7 +88,7 @@ The docs sidebar groups pages by what the reader is doing. The groups are declar
| Group | Pages |
|---|---|
| Set up | Getting Started, Editor Setup, Configuration |
-| Write code | F# Language Support, Code Completions, Hover & Quick Info, Go to Definition, Diagnostics, Refactoring & Code Actions |
+| Write code | F# Language Support, Blazor & Razor, Code Completions, Hover & Quick Info, Go to Definition, Diagnostics, Refactoring & Code Actions |
| Project tools | Context Menus, NuGet Package Manager, Profiler |
| For contributors | Architecture, Contributing |
diff --git a/docs/specs/RAZOR-SPEC.md b/docs/specs/RAZOR-SPEC.md
index f8cfed01..ce1ef86e 100644
--- a/docs/specs/RAZOR-SPEC.md
+++ b/docs/specs/RAZOR-SPEC.md
@@ -116,6 +116,14 @@ flowchart LR
- 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-GENERATOR] Generator Compatibility
+
+- The project's Razor source generator must run against the Roslyn version loaded by the sidecar.
+- When the project's Razor compiler references a newer `Microsoft.CodeAnalysis` assembly than the sidecar runs, project loading replaces that compiler and its sibling dependencies with the bundled copies before the SDK compiler can load.
+- The bundled compiler is loaded first: once the newer SDK compiler is loaded, Roslyn cannot replace it for that session.
+- A structured warning identifies the SDK compiler's required Roslyn version and the sidecar's Roslyn version when replacement is necessary.
+- The project's source-generated components remain available to ordinary C# files and Razor projection queries. End-to-end tests restore a real fixture without the repository SDK pin, then verify Razor hover and component references from C# ([RAZOR-TESTING]).
+
### [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.
@@ -251,7 +259,7 @@ The facts hold no C# fragment spans: C# positions come from the projection's map
- **What it is:** the HTML projection is the Razor text with every non-HTML character replaced by a space, newlines kept, so offsets and positions are unchanged. The replaced characters are C# fragments with their transitions and delimiters, directives, and Razor comments.
- **HTML comments:** a component's IR drops HTML comments. The engine therefore parses a component a second time with component parsing off, which keeps each comment's `` tokens, and copies those comment spans back into the projection.
-- **How it is served:** `sharplsp/razor/htmlProjection` (`{ textDocument }` → `{ text }`) returns the projection for the document's current text.
+- **How it is served:** `sharplsp/razor/htmlProjection` (`{ textDocument, position? }` → `{ text } | null`) returns the projection for the document's current text. With a caret, it returns `null` for compiler-backed C# positions, including whitespace and incomplete expressions recovered for completion. Compiler markup name facts take precedence: component tag and attribute names remain markup even when they also have C# mappings. Without a caret, it returns the whole projection.
- **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
@@ -304,6 +312,14 @@ The facts hold no C# fragment spans: C# positions come from the projection's map
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.
+**Unfinished expressions.** The Razor compiler cannot finish an implicit expression that ends in a dot (`@scores.`) or an explicit expression still missing its `)` (`@(basket.MinBy(product => product.`). The caret then has no mapping, or maps into C# that does not parse. Completion asks a copy of the file whose expression is finished at the caret:
+
+- `@scores.` becomes `@(scores.)`.
+- An unclosed `@(` gets each `)`, `]` and `}` that Roslyn's parser finds missing, innermost first, and then its own `)`. The compiler's markup diagnostics are read only when the projection's C# does not parse.
+- Only the file's active framework is forked.
+- The copy's projection is read in the file itself, keeping each fragment's part before the inserted text, so items and edits land at the real file's caret.
+- A copy that still cannot map the caret leaves completion to the file's own projection.
+
### [RAZOR-FEATURES-SIGNATURE-HELP] Signature Help
Signature help in a C# fragment is computed at the mapped position.
@@ -342,7 +358,7 @@ Signature help in a C# fragment is computed at the mapped position.
### [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.
+- **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 client sends the caret with the projection request; a C# position has no HTML forwarding, so projection word suggestions cannot enter C# completion lists. In markup, 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
@@ -400,7 +416,7 @@ Blazor front ends often sit over F# domain libraries. A Razor project that refer
- **Engine unavailable:** if the bundled engine fails to load or to process a file, markup features answer empty and the C# projection features keep working. The failure is logged once per project, with the project name and the exception type.
- **No projection:** a file whose project does not run the generator has no projection, so its C# features answer empty. Its markup facts still answer.
-- **SDK newer than the sidecar's Roslyn:** the generator comes from the SDK that resolves for the project, which is the newest installed SDK when no `global.json` pins one. Roslyn does not load a generator built against a newer Roslyn than the sidecar bundles, so the project's Razor files have no projection, as above. The tests keep the repository's pin in their fixture copies. GitHub issue #399 tracks making the generator load with any SDK.
+- **SDK newer than the sidecar's Roslyn:** project loading replaces an incompatible SDK Razor compiler with the bundled compiler before generation ([RAZOR-ARCHITECTURE-GENERATOR]). Ordinary fixture tests retain the repository SDK pin for reproducibility; a separate unpinned fixture tests the newest installed SDK, and a controlled incompatible compiler tests the replacement rule regardless of installed SDK versions (GitHub #399).
- **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.
diff --git a/docs/specs/SHARPLSP-SPEC.md b/docs/specs/SHARPLSP-SPEC.md
index 9794d097..6729b977 100644
--- a/docs/specs/SHARPLSP-SPEC.md
+++ b/docs/specs/SHARPLSP-SPEC.md
@@ -502,7 +502,7 @@ SharpLsp does **not** provide document formatting. Use dedicated formatters:
| Range semantic tokens | `textDocument/semanticTokens/range` | Classifier (range-scoped) | GetSemanticClassification (range) | P0 |
| Keyword classes | `textDocument/semanticTokens/*` | `ClassificationTypeNames.ControlKeyword` → `controlKeyword` | Keywords left to the grammar | P0 |
-**Keyword classes.** A flow-control keyword (`if`, `for`, `while`, `return`, `throw`, …) is a different token from every other keyword (`public`, `class`, `int`), as Visual Studio's "Keyword - Control" classification is. The legend carries a `controlKeyword` type after the standard ones, and the C# sidecar reports Roslyn's `ControlKeyword` classification as it; Roslyn's plain `Keyword` stays `keyword`. The VS Code extension declares `controlKeyword` with super type `keyword` and maps it to the TextMate scope `keyword.control`, so a theme paints it as its own grammar does (and as `keyword` where a theme has no `keyword.control` rule). FCS classifies symbols, not keywords, so an F# keyword carries no semantic token and the F# grammar's own keyword classes show through.
+**Keyword classes.** A flow-control keyword (`if`, `for`, `while`, `return`, `throw`, …) is a different token from every other keyword (`public`, `class`, `int`), as Visual Studio's "Keyword - Control" classification is. The legend carries a `controlKeyword` type after the standard ones, and the C# sidecar reports Roslyn's `ControlKeyword` classification as it; Roslyn's plain `Keyword` stays `keyword`. The VS Code extension declares `controlKeyword` with super type `keyword` and maps it to the TextMate scope `keyword.control`, so a theme paints it as its own grammar does (and as `keyword` where a theme has no `keyword.control` rule). Plain C# `keyword` tokens map to `keyword.other.cs` through language-scoped semantic mappings for C#, Razor, and aspnetcorerazor: VS Code's generic `keyword` fallback is `keyword.control`, which would otherwise paint declarations and predefined types as control flow. Colors come from the active theme; the extension never assigns RGB values or changes user color settings. FCS classifies symbols, not keywords, so an F# keyword carries no semantic token and the F# grammar's own keyword classes show through. F# module/local values use the standard `variable` category, module functions use `function`, member methods use `method`, property accessors use `property`, constructors use `class`, and operator tokens use `operator`. Symbol spans are recovered through the FCS lexer so a qualified member never recolors its receiver or punctuation.
### [SHARPLSP-FEATURES-CODE-LENS] Code Lens
diff --git a/src/editors/vscode/package.json b/src/editors/vscode/package.json
index dcfa9613..ab031580 100644
--- a/src/editors/vscode/package.json
+++ b/src/editors/vscode/package.json
@@ -1362,6 +1362,30 @@
"keyword.control"
]
}
+ },
+ {
+ "language": "csharp",
+ "scopes": {
+ "keyword": [
+ "keyword.other.cs"
+ ]
+ }
+ },
+ {
+ "language": "razor",
+ "scopes": {
+ "keyword": [
+ "keyword.other.cs"
+ ]
+ }
+ },
+ {
+ "language": "aspnetcorerazor",
+ "scopes": {
+ "keyword": [
+ "keyword.other.cs"
+ ]
+ }
}
],
"breakpoints": [
diff --git a/src/editors/vscode/src/razor-html.ts b/src/editors/vscode/src/razor-html.ts
index 14f6d782..8051010b 100644
--- a/src/editors/vscode/src/razor-html.ts
+++ b/src/editors/vscode/src/razor-html.ts
@@ -30,7 +30,7 @@ const PROJECTION_SCHEME = 'sharplsp-razor-html';
const PROJECTION_REQUEST = 'sharplsp/razor/htmlProjection';
/** The server's HTML projection of the document at `uri`; undefined when it has none. */
-export type ProjectionSource = (uri: string) => Promise;
+export type ProjectionSource = (uri: string, position?: Position) => Promise;
/**
* Projections from the running server. A server that cannot project the document
@@ -38,12 +38,13 @@ export type ProjectionSource = (uri: string) => Promise;
* completion and hover answers still reach the editor.
*/
export function serverProjection(client: () => LanguageClient | undefined): ProjectionSource {
- return async (uri) => {
+ return async (uri, position) => {
const running = client();
if (running?.state !== State.Running) return undefined;
try {
const answer: unknown = await running.sendRequest(PROJECTION_REQUEST, {
textDocument: { uri },
+ ...(position === undefined ? {} : { position }),
});
return isRecord(answer) && typeof answer.text === 'string' ? answer.text : undefined;
} catch (error) {
@@ -79,8 +80,8 @@ export function createRazorHtml(source: ProjectionSource): RazorHtml {
// 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());
+ const project = async (document: TextDocument, position: Position): Promise => {
+ const text = await source(document.uri.toString(), position);
if (text === undefined) return undefined;
const uri = projectionUri(document);
projections.set(document.uri.toString(), { address: uri.toString(), text });
@@ -91,13 +92,13 @@ export function createRazorHtml(source: ProjectionSource): RazorHtml {
/** The middleware that adds the HTML service's answers to the server's. */
function forwardingMiddleware(
- project: (document: TextDocument) => Promise,
+ project: (document: TextDocument, position: Position) => Promise,
): RazorHtml['middleware'] {
return {
provideCompletionItem: async (document, position, context, token, next) => {
const own = await next(document, position, context, token);
if (!isRazor(document)) return own;
- const uri = await project(document);
+ const uri = await project(document, position);
return uri === undefined
? own
: appended(own, await htmlCompletions(uri, position, context.triggerCharacter));
@@ -105,7 +106,7 @@ function forwardingMiddleware(
provideHover: async (document, position, token, next) => {
const own = await next(document, position, token);
if (own !== null && own !== undefined) return own;
- const uri = isRazor(document) ? await project(document) : undefined;
+ const uri = isRazor(document) ? await project(document, position) : undefined;
return uri === undefined ? own : await htmlHover(uri, position);
},
};
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 3daf7a96..f0188dbb 100644
--- a/src/editors/vscode/src/test/suite/razor-lsp.test.ts
+++ b/src/editors/vscode/src/test/suite/razor-lsp.test.ts
@@ -144,22 +144,44 @@ suite('Razor in VS Code', () => {
this.timeout(LSP_RESPONSE_MS * 3);
const { doc, uri } = await openRepoFile(fixture().root, RAZOR_FILES.counter);
- // Interaction 1 — in a tag name, the HTML service's element names arrive through
- // the projection, and no C# member does.
- const tag = completionLabels(
- await completionList(uri, positionOf(doc, '
Counter', 'h1'), 'div'),
+ const projectionWord = 'HtmlProjectionOnlyWord';
+ const provider = vscode.languages.registerCompletionItemProvider(
+ { scheme: projectionUri(doc).scheme, language: 'html' },
+ {
+ provideCompletionItems: () => [
+ new vscode.CompletionItem(projectionWord, vscode.CompletionItemKind.Text),
+ ],
+ },
);
- for (const element of ['div', 'span', 'h1']) assert.ok(tag.has(element), `offers <${element}>`);
- assert.ok(!tag.has('currentCount'), 'markup offers no C# member');
+ try {
+ // Interaction 1 — in a tag name, the HTML service's element names arrive through
+ // the projection, and no C# member does.
+ const tag = completionLabels(
+ await completionList(uri, positionOf(doc, '
Counter', 'h1'), 'div'),
+ );
+ for (const element of ['div', 'span', 'h1'])
+ assert.ok(tag.has(element), `offers <${element}>`);
+ assert.ok(!tag.has('currentCount'), 'markup offers no C# member');
+ assert.ok(
+ tag.has(projectionWord),
+ 'markup really forwards to providers on the HTML projection',
+ );
- // Interaction 2 — in @code, the server's C# members answer, and the HTML
- // service adds no element name: the projection blanks the C#.
- const code = completionLabels(
- await completionList(uri, positionOf(doc, 'currentCount++;', 'currentCount'), 'currentCount'),
- );
- assert.ok(code.has('currentCount'), 'the field');
- assert.ok(code.has('IncrementCount'), 'the method');
- assert.ok(!code.has('div'), 'C# offers no HTML element');
+ // Interaction 2 — in @code, the server's C# members answer, and the HTML
+ // service adds no element name: the projection blanks the C#.
+ const codeItems = await completionList(
+ uri,
+ positionOf(doc, 'currentCount++;', 'currentCount'),
+ 'currentCount',
+ );
+ const code = completionLabels(codeItems);
+ assert.ok(code.has('currentCount'), 'the field');
+ assert.ok(code.has('IncrementCount'), 'the method');
+ assert.ok(!code.has('div'), 'C# offers no HTML element');
+ assert.ok(!code.has(projectionWord), 'C# never forwards to the HTML projection (#405)');
+ } finally {
+ provider.dispose();
+ }
});
test('markup hovers come from the HTML service when the server has none', async function () {
diff --git a/src/editors/vscode/src/test/suite/semantic-highlighting.test.ts b/src/editors/vscode/src/test/suite/semantic-highlighting.test.ts
index a9305f41..f4295882 100644
--- a/src/editors/vscode/src/test/suite/semantic-highlighting.test.ts
+++ b/src/editors/vscode/src/test/suite/semantic-highlighting.test.ts
@@ -204,3 +204,37 @@ suite('Semantic highlighting — keyword classes (#251)', () => {
);
});
});
+
+suite('Standard language keyword scopes', () => {
+ test('C# plain keywords use the standard language scope instead of the control-flow fallback', () => {
+ // VS Code's default `keyword` fallback is `keyword.control`. Without a C#
+ // mapping, declaration/type keywords become control-flow purple in Dark+.
+ // [SHARPLSP-FEATURES-HIGHLIGHTING]
+ const scopes: unknown = contributes().semanticTokenScopes;
+ assert.ok(Array.isArray(scopes), 'semantic scope mappings exist');
+ const entries = scopes as { language?: string; scopes?: Record }[];
+ const csharp = entries.find((entry) => entry.language === 'csharp');
+ assert.ok(csharp, 'plain keyword mapping is scoped to C#, preserving F# grammar colors');
+ assert.deepStrictEqual(csharp.scopes?.['keyword'], ['keyword.other.cs']);
+ for (const language of ['razor', 'aspnetcorerazor']) {
+ assert.deepStrictEqual(
+ entries.find((entry) => entry.language === language)?.scopes?.['keyword'],
+ ['keyword.other.cs'],
+ `C# inside ${language} uses the same standard keyword scope`,
+ );
+ }
+ assert.ok(!csharp.scopes?.['keyword']?.some((scope) => scope.startsWith('keyword.control')));
+ const control = entries.find((entry) => entry.scopes?.['controlKeyword']);
+ assert.deepStrictEqual(control?.scopes?.['controlKeyword'], ['keyword.control']);
+ assert.ok(
+ !entries.some((entry) => entry.language === 'fsharp' && entry.scopes?.['keyword']),
+ 'F# keyword coloring stays with its standard grammar',
+ );
+ const defaults: unknown = contributes().configurationDefaults;
+ assert.ok(
+ defaults === undefined ||
+ !JSON.stringify(defaults).toLowerCase().includes('tokencolorcustomizations'),
+ 'the extension never hard-codes token colors over the selected theme',
+ );
+ });
+});
diff --git a/src/sharplsp/src/razor.rs b/src/sharplsp/src/razor.rs
index 7be98729..8a4099a8 100644
--- a/src/sharplsp/src/razor.rs
+++ b/src/sharplsp/src/razor.rs
@@ -31,6 +31,8 @@ pub const HTML_PROJECTION: &str = "sharplsp/razor/htmlProjection";
struct HtmlProjectionParams {
/// The Razor document to project.
text_document: TextDocumentIdentifier,
+ /// Only project markup at this caret; absent requests the whole projection.
+ position: Option,
}
/// Whether `uri` names a Razor document.
@@ -71,7 +73,11 @@ pub fn handle_html_projection(
sidecar: Option<&Arc>,
) -> Result {
let params: HtmlProjectionParams = serde_json::from_value(req.params)?;
- let request = file_request(¶ms.text_document.uri)?;
+ let file = file_request(¶ms.text_document.uri)?;
+ let request = (
+ file.file_path,
+ params.position.map(|at| (at.line, at.character)),
+ );
let projection: Option<(String,)> = fetch(runtime, sidecar, HTML_PROJECTION, &request)?;
Ok(projection_answer(projection))
}
diff --git a/src/sharplsp/tests/e2e_modules/fixtures_razor.rs b/src/sharplsp/tests/e2e_modules/fixtures_razor.rs
index 36ab569d..3cdd4d0a 100644
--- a/src/sharplsp/tests/e2e_modules/fixtures_razor.rs
+++ b/src/sharplsp/tests/e2e_modules/fixtures_razor.rs
@@ -41,10 +41,22 @@ impl RazorWorkspace {
/// Copy the Razor fixture solution to a temp directory, with the repository's SDK
/// pin, and restore it.
pub fn create_razor_workspace() -> RazorWorkspace {
+ create_razor_workspace_with_pin(true)
+}
+
+/// Restore under the newest installed SDK to catch compiler/sidecar version skew.
+/// [RAZOR-ARCHITECTURE-GENERATOR]
+pub fn create_razor_workspace_with_latest_sdk() -> RazorWorkspace {
+ create_razor_workspace_with_pin(false)
+}
+
+fn create_razor_workspace_with_pin(pin_sdk: bool) -> RazorWorkspace {
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/razor");
let dir = tempfile::tempdir().expect("create Razor temp dir");
copy_fixture_tree(&source, dir.path());
- keep_sdk_pin(dir.path());
+ if pin_sdk {
+ keep_sdk_pin(dir.path());
+ }
restore_project(dir.path());
RazorWorkspace { dir }
}
diff --git a/src/sharplsp/tests/e2e_modules/razor.rs b/src/sharplsp/tests/e2e_modules/razor.rs
index 90b34ec1..ea3fcea5 100644
--- a/src/sharplsp/tests/e2e_modules/razor.rs
+++ b/src/sharplsp/tests/e2e_modules/razor.rs
@@ -39,6 +39,7 @@ fn test_full_stack_razor_routing_session() {
assert_syntax_requests_reach_the_sidecar(&mut client, &board);
assert_html_projection(&mut client, &ws, &board, &board_text);
+ assert_html_projection_only_in_markup(&mut client, &board);
assert_markup_triggers_only_complete_razor(&mut client, &ws);
assert_diagnostics_publish_as_razor(&mut client, &board, &board_text);
@@ -46,6 +47,38 @@ fn test_full_stack_razor_routing_session() {
client.wait_with_timeout();
}
+/// A newer SDK still generates components for Razor and ordinary C# queries.
+/// [RAZOR-ARCHITECTURE-GENERATOR] [RAZOR-TESTING] GitHub #399.
+#[test]
+fn test_latest_sdk_razor_generator_remains_usable() {
+ require_dotnet();
+ let ws = create_razor_workspace_with_latest_sdk();
+ let mut client = LspClient::start_verbose();
+ let init = client.initialize_with_root(json!(path_to_file_uri(ws.root())));
+ assert_rpc_ok(&init, "initialize newest SDK fixture");
+ assert_latest_sdk_razor_hover(&mut client, &ws);
+ let program = ws.uri(&["BlazorApp", "Program.cs"]);
+ client.open_document(&program, &ws.text(&["BlazorApp", "Program.cs"]));
+ let errors = pull_error_diagnostics(&mut client, &program, "newest SDK Program.cs");
+ assert!(
+ errors.iter().all(|d| d["code"] != "CS0246"),
+ "generated App must resolve: {errors:?}"
+ );
+ client.shutdown_and_exit();
+ client.wait_with_timeout();
+}
+
+fn assert_latest_sdk_razor_hover(client: &mut LspClient, ws: &RazorWorkspace) {
+ let file = &["BlazorApp", "Components", "Pages", "Counter.razor"];
+ let counter = ws.uri(file);
+ client.open_document(&counter, &ws.text(file));
+ let hover = poll_hover_until_ready(client, &counter, 7, 35, Duration::from_mins(3));
+ let markdown = hover["contents"]["value"].as_str().unwrap_or_default();
+ assert!(markdown.contains("currentCount"), "field hover: {markdown}");
+ assert!(markdown.contains("int"), "field type: {markdown}");
+ assert_eq!(hover["range"]["start"], json!({"line": 7, "character": 33}));
+}
+
/// documentSymbol, foldingRange and linkedEditingRange come from the sidecar's
/// Razor compiler; selectionRange answers empty. [RAZOR-FEATURES-STRUCTURE]
fn assert_syntax_requests_reach_the_sidecar(client: &mut LspClient, board: &str) {
@@ -154,6 +187,23 @@ fn assert_html_projection(client: &mut LspClient, ws: &RazorWorkspace, board: &s
assert_eq!(none["result"], Value::Null, "a C# file has no projection");
}
+/// A caret-aware projection forwards markup and excludes C# including whitespace.
+/// [RAZOR-FEATURES-HTML] GitHub #405.
+fn assert_html_projection_only_in_markup(client: &mut LspClient, board: &str) {
+ for (at, code) in [
+ ((4, 2), false),
+ ((6, 3), false),
+ ((11, 16), true),
+ ((22, 24), true),
+ ((22, 4), true),
+ ] {
+ let answer = position_request(client, "sharplsp/razor/htmlProjection", board, at);
+ assert_rpc_ok(&answer, "caret-aware HTML projection");
+ assert_eq!(answer["result"].is_null(), code, "caret {at:?}: {answer}");
+ assert_eq!(answer["result"]["text"].as_str().is_some(), !code);
+ }
+}
+
/// `<` and `@` complete in Razor but answer `null` at once in C#.
fn assert_markup_triggers_only_complete_razor(client: &mut LspClient, ws: &RazorWorkspace) {
let program = ws.uri(&["BlazorApp", "Program.cs"]);
diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/NewerSdkRazorFixture.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/NewerSdkRazorFixture.cs
new file mode 100644
index 00000000..c8e116e6
--- /dev/null
+++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/NewerSdkRazorFixture.cs
@@ -0,0 +1,137 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using SharpLsp.Sidecar.Common;
+
+#pragma warning disable CA1515 // Public xUnit fixture type
+#pragma warning disable RS1035 // Real-file writes are intentional: the fixture is built for real
+#pragma warning disable IDE0058 // Directory.CreateDirectory's result is not needed
+
+namespace SharpLsp.Sidecar.CSharp.Tests;
+
+///
+/// The Razor fixture as a user sees it whose newest SDK is newer than the sidecar's Roslyn,
+/// with no global.json (#399): the command-line build works, but the Razor generator
+/// the sidecar's design-time builds hand Roslyn was built against a newer Roslyn, which
+/// Roslyn refuses to load. Implements [RAZOR-TESTING] for [RAZOR-ARCHITECTURE-GENERATOR].
+///
+public sealed class NewerSdkRazorFixture : IDisposable
+{
+ /// Where the copy keeps the newer SDK's Razor generator, as the SDK does.
+ private static readonly string[] Generators = ["newer-sdk", "source-generators"];
+
+ /// The Razor compiler's file in an SDK.
+ internal const string Compiler = "Microsoft.CodeAnalysis.Razor.Compiler.dll";
+
+ /// Every file of an SDK's Razor generator, in ordinal order.
+ internal static readonly string[] GeneratorFiles =
+ [
+ "Microsoft.AspNetCore.Razor.Utilities.Shared.dll",
+ Compiler,
+ "Microsoft.Extensions.ObjectPool.dll",
+ ];
+
+ ///
+ /// The copy's design-time builds replace the SDK's Razor generator with the newer one,
+ /// as an SDK newer than the sidecar's would supply it.
+ ///
+ private const string DesignTimeGenerators = """
+
+
+
+
+
+
+
+
+ """;
+
+ /// The built fixture.
+ public RazorFixture Razor { get; } = new(InstallNewerSdkGenerator);
+
+ public void Dispose()
+ {
+ Razor.Dispose();
+ }
+
+ /// Give the copy at the newer SDK's Razor generator.
+ private static void InstallNewerSdkGenerator(string root)
+ {
+ var generators = NativePaths.Join([root, .. Generators]);
+ Directory.CreateDirectory(generators);
+ File.WriteAllBytes(NativePaths.Join(generators, Compiler), NewerRazorCompiler());
+ foreach (
+ var dependency in GeneratorFiles.Where(file => !NativePaths.SameName(file, Compiler))
+ )
+ {
+ File.Copy(
+ NativePaths.Join(AppContext.BaseDirectory, dependency),
+ NativePaths.Join(generators, dependency)
+ );
+ }
+
+ File.WriteAllText(NativePaths.Join(root, "Directory.Build.targets"), DesignTimeGenerators);
+ }
+
+ ///
+ /// The Razor compiler as Roslyn reads it from a newer SDK: an assembly of that name, at a
+ /// version above the sidecar's own copy, whose generator references a
+ /// Microsoft.CodeAnalysis newer than any running. Its generator has the real one's
+ /// name, so a process that has already loaded its own copy stands that copy in, as it
+ /// would for a real newer SDK.
+ ///
+ private static byte[] NewerRazorCompiler()
+ {
+ var roslyn = Compile(
+ "Microsoft.CodeAnalysis",
+ """
+ [assembly: System.Reflection.AssemblyVersion("99.0.0.0")]
+
+ namespace Microsoft.CodeAnalysis
+ {
+ public sealed class GeneratorAttribute : System.Attribute { }
+ }
+ """,
+ []
+ );
+ return Emit(
+ Compile(
+ "Microsoft.CodeAnalysis.Razor.Compiler",
+ """
+ [assembly: System.Reflection.AssemblyVersion("99.0.0.0")]
+
+ namespace Microsoft.NET.Sdk.Razor.SourceGenerators
+ {
+ [Microsoft.CodeAnalysis.Generator]
+ public sealed class RazorSourceGenerator { }
+ }
+ """,
+ [roslyn.ToMetadataReference()]
+ )
+ );
+ }
+
+ private static CSharpCompilation Compile(
+ string name,
+ string source,
+ IEnumerable references
+ )
+ {
+ return CSharpCompilation.Create(
+ name,
+ [CSharpSyntaxTree.ParseText(source)],
+ [.. Basic.Reference.Assemblies.Net100.References.All, .. references],
+ new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
+ );
+ }
+
+ private static byte[] Emit(CSharpCompilation compilation)
+ {
+ using var image = new MemoryStream();
+ var emitted = compilation.Emit(image);
+ Assert.True(emitted.Success, string.Join('\n', emitted.Diagnostics));
+ return image.ToArray();
+ }
+}
diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCSharpFeatureTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCSharpFeatureTests.cs
index cc2f66c0..383c2718 100644
--- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCSharpFeatureTests.cs
+++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCSharpFeatureTests.cs
@@ -137,6 +137,90 @@ await manager.GetSignatureHelpAsync(Board, argument.Line, argument.Character)
Assert.Equal(0, help.ActiveParameter);
}
+ [Theory]
+ [InlineData("disabled=", "scores.Count")]
+ [InlineData("")]
+ public async Task Signature_help_never_exposes_generated_render_tree_calls(
+ string anchor,
+ string token
+ )
+ {
+ // Explicit Razor parentheses belong to the expression, not a call the user wrote.
+ // [RAZOR-FEATURES-SIGNATURE-HELP]
+ using var manager = await _fixture.OpenManagerAsync();
+ var argument = RazorFixture.Locate(Board, anchor, token);
+ Assert.Null(
+ RazorFixture.Ok(
+ await manager.GetSignatureHelpAsync(Board, argument.Line, argument.Character)
+ )
+ );
+ }
+
+ [Fact]
+ public async Task Signature_help_never_exposes_generated_content_calls()
+ {
+ using var manager = await _fixture.OpenManagerAsync();
+ _ = RazorFixture.Ok(await manager.UpdateDocumentTextAsync(Board, "
First: @(new[] { basket[0].", "")]
+ [InlineData("@{ var cheapest = basket.MinBy(product => product.", " }")]
+ public async Task Completion_inside_a_call_in_an_unfinished_expression_offers_members(
+ string before,
+ string after
+ )
+ {
+ // A call or lambda inside an @( missing its ) completes too (#404), with its edit at
+ // the caret of a file whose fragments before and after the caret still map.
+ // [RAZOR-FEATURES-COMPLETION]
+ using var manager = await fixture.OpenManagerAsync();
+ const string heading = "
@basket.Count
\n";
+ const string code =
+ "\n@code { private readonly List basket = []; "
+ + "private sealed record Product(string Name, decimal Price); }";
+ _ = RazorFixture.Ok(
+ await manager.UpdateDocumentTextAsync(Board, heading + before + after + code)
+ );
+ var items = RazorFixture.Ok(await manager.GetCompletionsAsync(Board, 1, before.Length));
+ var price = Assert.Single(items, item => item.Label == "Price" && item.Kind == "Property");
+ Assert.Contains(items, item => item.Label == "Name" && item.Kind == "Property");
+ Assert.NotNull(price.TextEdit);
+ var member = before.LastIndexOf('.') + 1;
+ Assert.Equal(
+ (1, member, 1, before.Length),
+ (
+ price.TextEdit.StartLine,
+ price.TextEdit.StartCharacter,
+ price.TextEdit.EndLine,
+ price.TextEdit.EndCharacter
+ )
+ );
+ }
+
+ [Fact]
+ public async Task Completion_in_an_unclosed_block_replaces_what_was_typed_before_the_caret()
+ {
+ // The copy closes the expression after the caret, so an edit still spans the member
+ // prefix typed before it. [RAZOR-FEATURES-COMPLETION]
+ using var manager = await fixture.OpenManagerAsync();
+ const string members = "\n@code { private readonly List scores = []; }";
+ _ = RazorFixture.Ok(
+ await manager.UpdateDocumentTextAsync(Board, "
@(scores.Co" + members)
+ );
+ var typed = RazorFixture.Ok(await manager.GetCompletionsAsync(Board, 0, 14));
+ var count = Assert.Single(typed, item => item.Label == "Count" && item.Kind == "Property");
+ Assert.NotNull(count.TextEdit);
+ Assert.Equal(
+ (0, 12, 0, 14),
+ (
+ count.TextEdit.StartLine,
+ count.TextEdit.StartCharacter,
+ count.TextEdit.EndLine,
+ count.TextEdit.EndCharacter
+ )
+ );
+
+ // An @code block that is missing its close keeps member completion too.
+ const string block =
+ "
x
\n@code { private readonly List scores = []; void Score() { scores.";
+ _ = RazorFixture.Ok(await manager.UpdateDocumentTextAsync(Board, block));
+ var caret = block.Split('\n')[1].Length;
+ var inBlock = RazorFixture.Ok(await manager.GetCompletionsAsync(Board, 1, caret));
+ var blockCount = Assert.Single(
+ inBlock,
+ item => item.Label == "Count" && item.Kind == "Property"
+ );
+ Assert.Contains(inBlock, item => item.Label == "Add");
+ Assert.NotNull(blockCount.TextEdit);
+ Assert.Equal(
+ (1, caret, caret),
+ (
+ blockCount.TextEdit.StartLine,
+ blockCount.TextEdit.StartCharacter,
+ blockCount.TextEdit.EndCharacter
+ )
+ );
+ }
+}
diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorFixture.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorFixture.cs
index 8481ae5f..2fba963f 100644
--- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorFixture.cs
+++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorFixture.cs
@@ -11,15 +11,34 @@ 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.
+/// tests can edit it, and built so the F# DLL that BlazorApp references exists. The copy
+/// keeps the SDK the repository pins: with no global.json above it, it would load with
+/// the newest installed SDK, as the editor suites' copies would.
///
public sealed class RazorFixture : IDisposable
{
+ /// The fixture solution's directory, relative to the repository root.
+ private static readonly string[] FixtureDirectory =
+ [
+ "src",
+ "sharplsp",
+ "tests",
+ "fixtures",
+ "razor",
+ ];
+
private readonly string _root = NativePaths.Temp($"sharplsp-razor-{Guid.NewGuid():N}");
public RazorFixture()
+ : this(_ => { }) { }
+
+ /// The fixture, built once has changed the copy at its root.
+ internal RazorFixture(Action prepare)
{
- CopyTree(FindRepositoryFixture(), _root);
+ var repository = FindRepository();
+ CopyTree(NativePaths.Join([repository, .. FixtureDirectory]), _root);
+ File.Copy(NativePaths.Join(repository, "global.json"), Path("global.json"));
+ prepare(_root);
SolutionPath = Path("RazorFixtures.slnx");
var (exitCode, output) = DotnetBuild.Run(SolutionPath);
Assert.True(exitCode == 0, $"Razor fixture build failed ({exitCode}):{output}");
@@ -98,22 +117,23 @@ private static void CopyTree(string source, string destination)
}
}
- private static string FindRepositoryFixture()
+ /// The repository root: the nearest ancestor holding the fixture solution.
+ private static string FindRepository()
{
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")))
+ if (
+ File.Exists(
+ NativePaths.Join([
+ directory.FullName,
+ .. FixtureDirectory,
+ "RazorFixtures.slnx",
+ ])
+ )
+ )
{
- return candidate;
+ return directory.FullName;
}
directory = directory.Parent;
diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorHtmlProjectionTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorHtmlProjectionTests.cs
index 5024ee75..4735a1d5 100644
--- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorHtmlProjectionTests.cs
+++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorHtmlProjectionTests.cs
@@ -88,6 +88,34 @@ private static void AssertOffsets(string original, string html)
[InlineData("", "")]
[InlineData(" *@", " ")]
+ // [RAZOR-ARCHITECTURE-HTML]: a directive attribute and the C# inside an HTML attribute
+ // value are both blank; HTML names, quotes and literal text stay.
+ [InlineData(
+ "",
+ ""
+ )]
+ [InlineData(
+ "",
+ ""
+ )]
+ [InlineData(
+ "",
+ ""
+ )]
+ [InlineData(
+ "",
+ ""
+ )]
+ // The compiler gives a minimized directive parameter no span, nor a minimized HTML
+ // attribute before it; both are read in order, and only the directive is blank.
+ [InlineData(
+ "",
+ ""
+ )]
+ [InlineData(
+ "",
+ ""
+ )]
public async Task Projection_query_preserves_html_and_utf16_offsets(
string text,
string expected
@@ -147,6 +175,11 @@ public async Task Projection_retains_component_tags_and_reacts_to_unsaved_edits(
Assert.Contains("", before.HtmlText, StringComparison.Ordinal);
Assert.DoesNotContain("@code", before.HtmlText, StringComparison.Ordinal);
+ // The fixture's C# attribute value and directive attributes are blank too (#407).
+ Assert.DoesNotContain("scores.Count", before.HtmlText, StringComparison.Ordinal);
+ Assert.DoesNotContain("@bind", before.HtmlText, StringComparison.Ordinal);
+ Assert.DoesNotContain("oninput", before.HtmlText, StringComparison.Ordinal);
+ Assert.Contains("@DateTime.Now"
diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorNewerSdkTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorNewerSdkTests.cs
new file mode 100644
index 00000000..0601b4e9
--- /dev/null
+++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorNewerSdkTests.cs
@@ -0,0 +1,192 @@
+using MessagePack;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using SharpLsp.Sidecar.Common;
+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 IDE0042 // Positions read better as (Line, Character) pairs
+#pragma warning disable RS1035 // Real-file writes are intentional: a compiler that cannot be read
+
+namespace SharpLsp.Sidecar.CSharp.Tests;
+
+///
+/// Razor C# features for a user whose newest SDK is newer than the sidecar's Roslyn, with no
+/// global.json (#399), against the real fixture solution. Implements [RAZOR-TESTING]
+/// for [RAZOR-ARCHITECTURE-GENERATOR].
+///
+public sealed class RazorNewerSdkTests(NewerSdkRazorFixture newer, RazorFixture current)
+ : IClassFixture,
+ IClassFixture
+{
+ private RazorFixture Razor => newer.Razor;
+
+ ///
+ /// The sidecar as the host runs it, in a process of its own. This test process loaded the
+ /// sidecar's Razor compiler before any test ran, so only a fresh process meets the SDK's
+ /// generator before its own copy is loaded.
+ ///
+ [Fact]
+ public async Task Razor_csharp_features_answer_when_the_sdk_generator_needs_a_newer_roslyn()
+ {
+ await using var sidecar = await ExternalSidecar.StartAsync(Razor.Path());
+ Assert.Equal(
+ "ok",
+ await sidecar.SendAndDeserializeAsync(
+ "workspace/open",
+ MessagePackSerializer.Serialize(Razor.SolutionPath)
+ )
+ );
+
+ // A component's C# answers inside the component.
+ var counter = Razor.Path("BlazorApp", "Components", "Pages", "Counter.razor");
+ var use = RazorFixture.Locate(counter, "Current count: @currentCount", "currentCount");
+ var hover = await sidecar.SendAndDeserializeAsync(
+ "textDocument/hover",
+ At(counter, use)
+ );
+ Assert.NotNull(hover);
+ Assert.Contains("currentCount", hover.Contents, StringComparison.Ordinal);
+ Assert.Contains("int", hover.Contents, StringComparison.Ordinal);
+ Assert.Equal((use.Line, use.Character), (hover.StartLine, hover.StartCharacter));
+
+ // C# that uses a generated component finds it.
+ var program = Razor.Path("BlazorApp", "Program.cs");
+ Assert.DoesNotContain(
+ await sidecar.SendAndDeserializeAsync>(
+ "workspace/diagnostics",
+ MessagePackSerializer.Serialize(program)
+ ),
+ diagnostic => diagnostic.Code == "CS0246"
+ );
+ var app = RazorFixture.Locate(program, "MapRazorComponents", "App>");
+ var definition = await sidecar.SendAndDeserializeAsync(
+ "textDocument/definition",
+ At(program, app)
+ );
+ var component = Assert.Single(definition.Locations);
+ Assert.True(
+ NativePaths.AreEqual(
+ Razor.Path("BlazorApp", "Components", "App.razor"),
+ component.FilePath
+ ),
+ component.FilePath
+ );
+
+ // A view's C# answers too.
+ var view = Razor.Path("PagesApp", "Views", "Home", "Index.cshtml");
+ var name = RazorFixture.Locate(view, "
@Model.Name
", "Name");
+ var member = await sidecar.SendAndDeserializeAsync(
+ "textDocument/hover",
+ At(view, name)
+ );
+ Assert.NotNull(member);
+ Assert.Contains("Name", member.Contents, StringComparison.Ordinal);
+ Assert.Contains("string", member.Contents, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task Only_a_generator_built_for_a_newer_roslyn_gives_way_to_the_bundled_compiler()
+ {
+ // Every Razor project of the copy whose generator needs a newer Roslyn: the component
+ // app, the Razor class library and the Pages and MVC app.
+ using var skewed = await Razor.OpenManagerAsync();
+ string[][] razorFiles =
+ [
+ ["BlazorApp", "Components", "Pages", "Counter.razor"],
+ ["Widgets", "Panel.razor"],
+ ["PagesApp", "Views", "Home", "Index.cshtml"],
+ ];
+ foreach (var file in razorFiles)
+ {
+ var swapped = GeneratorPaths(skewed, Razor.Path(file));
+ Assert.Equal(NewerSdkRazorFixture.GeneratorFiles, swapped.Select(NativePaths.NameOf));
+ Assert.All(swapped, path => Assert.True(IsBundled(path), path));
+ Assert.DoesNotContain(swapped, path => NativePaths.HasDirectory(path, "newer-sdk"));
+ }
+
+ // The repository's own copy resolves an SDK whose generator loads: it keeps it.
+ using var matched = await current.OpenManagerAsync();
+ var kept = GeneratorPaths(matched, current.Path(razorFiles[0]));
+ Assert.Equal(NewerSdkRazorFixture.GeneratorFiles, kept.Select(NativePaths.NameOf));
+ Assert.All(kept, path => Assert.False(IsBundled(path), path));
+ Assert.All(kept, path => Assert.True(NativePaths.HasDirectory(path, "Sdks"), path));
+ }
+
+ [Fact]
+ public async Task A_razor_compiler_that_cannot_be_read_leaves_the_project_as_loaded()
+ {
+ // A compiler path that is missing, or holds no assembly, is logged and the project
+ // keeps the references it loaded with. [RAZOR-ARCHITECTURE-GENERATOR]
+ using var manager = await Razor.OpenManagerAsync();
+ var counter = Razor.Path("BlazorApp", "Components", "Pages", "Counter.razor");
+ var loader = manager
+ .FindRazorDocument(counter)
+ ?.Project.AnalyzerReferences.OfType()
+ .First()
+ .AssemblyLoader;
+ Assert.NotNull(loader);
+ var unread = Razor.Path("unread", NewerSdkRazorFixture.Compiler);
+ _ = Directory.CreateDirectory(NativePaths.DirectoryOf(unread));
+ await File.WriteAllTextAsync(unread, "not an assembly");
+ string[] compilers = [Razor.Path("missing", NewerSdkRazorFixture.Compiler), unread];
+ foreach (var compiler in compilers)
+ {
+ var reference = new AnalyzerFileReference(compiler, loader);
+ var project = ProjectInfo.Create(
+ ProjectId.CreateNewId(),
+ VersionStamp.Create(),
+ "Unread",
+ "Unread",
+ LanguageNames.CSharp,
+ analyzerReferences: [reference]
+ );
+ var kept = RazorGenerator.Compatible(project);
+ Assert.Same(project, kept);
+ Assert.Same(reference, Assert.Single(kept.AnalyzerReferences));
+ Assert.Equal(compiler, kept.AnalyzerReferences[0].FullPath);
+ }
+ }
+
+ private static byte[] At(string file, (int Line, int Character) position)
+ {
+ return MessagePackSerializer.Serialize(
+ new PositionRequest
+ {
+ FilePath = file,
+ Line = position.Line,
+ Character = position.Character,
+ }
+ );
+ }
+
+ /// The Razor generator's files that the project listing loads, in ordinal order.
+ private static string[] GeneratorPaths(WorkspaceManager manager, string razorFile)
+ {
+ var project = manager.FindRazorDocument(razorFile)?.Project;
+ Assert.NotNull(project);
+ return
+ [
+ .. project
+ .AnalyzerReferences.Select(reference => reference.FullPath)
+ .OfType()
+ .Where(path =>
+ NewerSdkRazorFixture.GeneratorFiles.Any(file =>
+ NativePaths.SameName(path, file)
+ )
+ )
+ .OrderBy(NativePaths.NameOf, StringComparer.Ordinal),
+ ];
+ }
+
+ /// Whether is the copy the sidecar bundles beside itself.
+ private static bool IsBundled(string path)
+ {
+ return NativePaths.AreEqual(
+ path,
+ NativePaths.Join(AppContext.BaseDirectory, NativePaths.NameOf(path))
+ );
+ }
+}
diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorProjectionTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorProjectionTests.cs
index 72d09f25..9aacb49d 100644
--- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorProjectionTests.cs
+++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorProjectionTests.cs
@@ -171,6 +171,43 @@ public void Mappings_that_start_outside_either_text_are_dropped()
Assert.Empty(RazorSpanMap.Build(tree, generated, razor, Stale).Mappings);
}
+ [Theory]
+ [InlineData(")", false)]
+ [InlineData("))", false)]
+ [InlineData("]})", false)]
+ [InlineData("))", true)]
+ public void Completion_carets_stay_before_inserted_closers_and_later_fragments_still_map(
+ string closing,
+ bool earlierInsertion
+ )
+ {
+ // A temporary completion copy can map a whole fragment containing its synthetic
+ // closers. Only the prefix belongs to the user's expression; a surviving suffix
+ // would claim the caret after the closers. [RAZOR-FEATURES-COMPLETION]
+ var shift = earlierInsertion ? 1 : 0;
+ var close = new TextChange(new TextSpan(8, 0), closing);
+ var copy = new RazorSpanMap([
+ new(4 + shift, 100, 12 + closing.Length),
+ new(8 + shift, 150, closing.Length),
+ new(22 + shift + closing.Length, 200, 5),
+ ]);
+ var map = copy.Without(
+ earlierInsertion ? [new TextChange(new TextSpan(2, 0), "("), close] : [close]
+ );
+ Assert.Equal([new(4, 100, 4), new(22, 200, 5)], map.Mappings);
+ Assert.Equal(100, map.ToGenerated(4));
+ Assert.Equal(104, map.ToGenerated(8));
+ Assert.Null(map.ToGenerated(9));
+ Assert.Null(map.ToGenerated(3));
+ Assert.Equal(new TextSpan(4, 4), map.ToRazor(new TextSpan(100, 4)));
+ Assert.Null(map.ToRazor(new TextSpan(104, 1)));
+ Assert.Null(map.ToRazor(new TextSpan(150, closing.Length)));
+ Assert.Equal(200, map.ToGenerated(22));
+ Assert.Equal(205, map.ToGenerated(27));
+ Assert.Equal(new TextSpan(22, 5), map.ToRazor(new TextSpan(200, 5)));
+ Assert.Equal(map.Mappings, map.Without([]).Mappings);
+ }
+
private static Project Create(AdhocWorkspace workspace)
{
return AdhocRazorProject.Create(
diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorSidecarTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorSidecarTests.cs
index 849bb218..60b30485 100644
--- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorSidecarTests.cs
+++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorSidecarTests.cs
@@ -42,6 +42,69 @@ await socket
);
}
+ [Theory]
+ [InlineData(4, 2, true)]
+ [InlineData(4, 14, true)]
+ [InlineData(4, 19, false)]
+ [InlineData(6, 3, true)]
+ [InlineData(6, 8, true)]
+ [InlineData(6, 16, true)]
+ [InlineData(11, 16, false)]
+ [InlineData(22, 24, false)]
+ [InlineData(22, 4, false)]
+ public async Task Positioned_projection_forwards_markup_but_not_csharp_or_its_whitespace(
+ int line,
+ int character,
+ bool markup
+ )
+ {
+ // The compiler-backed position query decides ownership. [RAZOR-FEATURES-HTML]
+ await OpenAsync().ConfigureAwait(true);
+ var at = await socket
+ .SendAndDeserializeAsync(
+ "sharplsp/razor/htmlProjection",
+ MessagePackSerializer.Serialize((Board, new[] { line, character }))
+ )
+ .ConfigureAwait(true);
+ var whole = await AtAsync("sharplsp/razor/htmlProjection", Board)
+ .ConfigureAwait(true);
+ Assert.Equal(markup, at is not null);
+ Assert.NotNull(whole);
+ Assert.Contains("@scores.", "
'],
+ ]);
+
+/** Line 20: an element named through VS Code's HTML completion, then an HTML hover on `
`. */
+async function html({ page }) {
+ await typeThroughCompletion(page, 19, '\n<', [['stro', '>Free delivery on every order']]);
+ await hoverOn(page, { line: 8, word: 'ul' });
+ await sleep(3500);
+}
+
+/** Line 21: a misspelt call, its error in a hover and in the Problems panel, then the line deleted again. */
+async function mistake({ page }) {
+ await typeAt(page, { line: 20, append: true, text: '\n
@Totl()
' });
+ await page.keyboard.press('Escape');
+ await sleep(3500);
+ await hoverOn(page, { line: 21, word: 'Totl' });
+ await sleep(3500);
+ await page.keyboard.press('Escape');
+ await page.keyboard.press('Meta+Shift+M');
+ await sleep(3500);
+ await hidePanel(page);
+ await goTo(page, 21);
+ await page.keyboard.press('Meta+Shift+K');
+ await sleep(2500);
+}
+
+/** F12 from the button's handler into `@code`, then every reference to the basket. */
+async function definitionAndReferences({ page }) {
+ await navigate(page, { line: 13, word: 'Add' }, 'F12');
+ await sleep(2500);
+ await peekReferences(page, { line: 23, word: 'basket' });
+}
+
+/** The basket field renamed in `@code`; its first use in the markup, hovered by the new name. */
+async function rename({ page }) {
+ await renameTo(page, { line: 23, word: 'basket' }, 'cart');
+ await hoverOn(page, { line: 18, word: 'cart' });
+ await sleep(3000);
+}
+
+/** Program.cs: F12 on the root component lands in App.razor. */
+async function fromCSharp({ page }) {
+ await openFile(page, 'Program.cs');
+ await sleep(800);
+ await navigate(page, { line: 8, word: 'App' }, 'F12');
+ await sleep(3500);
+}
+
+/** `
` renamed to ``, its end tag following, then the `@code` block folded. */
+async function linkedAndFolded({ page }) {
+ await openFile(page, BASKET);
+ await clickOn(page, { line: 8, word: 'ul' });
+ await page.keyboard.press('Meta+D');
+ await sleep(600);
+ await typeText(page, 'ol', 220);
+ await sleep(2500);
+ await clickOn(page, { line: 22, word: 'code' });
+ await page.keyboard.press('Meta+Alt+BracketLeft');
+ await sleep(3000);
+}
+
+export default {
+ name: 'blazor',
+ demo: BLAZOR_STOREFRONT,
+ // VS Code's default bracket closing: Razor's language configuration closes `(`, so an
+ // expression stays balanced as it is typed, and each typed `)` steps over its closer.
+ settings: { ...LANGUAGE, 'editor.linkedEditing': true, 'editor.autoClosingBrackets': 'languageDefined' },
+ narration: 'blazor.narration.json',
+ stillsFolder: 'docs',
+ poster: { scene: 'hover', at: 4 },
+ scenes: [
+ ...ready(AT_BASKET, AT_PROGRAM),
+ { offCamera: ({ page }) => openFile(page, BASKET) },
+ scene('hover', 1, 'C# in markup: Roslyn hovers, with your XML docs', ({ page }) =>
+ hoverEach(page, [{ line: 18, word: 'Total' }, { line: 9, word: 'Products' }], { hold: 3200 }),
+ ),
+ {
+ offCamera: async (shot) => {
+ await hoverOn(shot.page, { line: 18, word: 'Total' });
+ await sleep(1200);
+ await snap(shot, 'blazor', { hovering: true });
+ },
+ },
+ scene('complete', 2, 'Completion inside @ expressions, edits in the .razor file', cheapest),
+ scene('html', 3, "HTML completion and hover from VS Code's HTML service", html),
+ scene('errors', 4, 'C# errors land on the Razor line that caused them', mistake),
+ scene('navigate', 5, 'F12 into @code, and every reference in the markup', definitionAndReferences),
+ scene('rename', 6, 'Rename a @code field: every use in the markup follows', rename),
+ scene('csharp', 7, "From C#, a component's definition is its .razor file", fromCSharp),
+ scene('structure', 8, 'Linked tag editing, and folding for elements and @code', linkedAndFolded),
+ ],
+};
diff --git a/src/website/tools/profiler-film/films/blazor.narration.json b/src/website/tools/profiler-film/films/blazor.narration.json
new file mode 100644
index 00000000..4247b916
--- /dev/null
+++ b/src/website/tools/profiler-film/films/blazor.narration.json
@@ -0,0 +1,66 @@
+{
+ "voice": "af_heart",
+ "say": {
+ "SharpLsp": "Sharp L S P",
+ "Blazor": "Blazer",
+ "C#": "C sharp",
+ "XML": "X M L",
+ "VS Code": "V S Code",
+ "HTML": "H T M L"
+ },
+ "music": {
+ "en": "[Gentle music]",
+ "ja": "[穏やかな音楽]",
+ "zh": "[舒缓的音乐]"
+ },
+ "lines": [
+ {
+ "scene": "hover",
+ "en": "Blazor components get the same C# engine as the rest of your code. Hover any expression in the markup, and Roslyn answers, with your XML docs.",
+ "ja": "Blazor のコンポーネントにも、ほかのコードと同じ C# エンジンが働きます。マークアップのどの式にホバーしても、Roslyn が XML ドキュメント付きで答えます。",
+ "zh": "Blazor 组件和你的其他代码使用同一个 C# 引擎。在标记中悬停任意表达式,Roslyn 就会显示它的信息,并附上你的 XML 文档。"
+ },
+ {
+ "scene": "complete",
+ "en": "Completion works inside Razor expressions too. The members come from Roslyn, and each edit lands in the Razor file, never in generated code.",
+ "ja": "補完は Razor の式の中でも動きます。メンバーは Roslyn から届き、編集はすべて、生成されたコードではなく Razor ファイルに反映されます。",
+ "zh": "补全在 Razor 表达式中同样可用。成员来自 Roslyn,每次编辑都落在 Razor 文件里,绝不会写进生成的代码。"
+ },
+ {
+ "scene": "html",
+ "en": "The markup around it is still HTML. Element names and HTML hovers come from VS Code's own HTML service.",
+ "ja": "その周りのマークアップは HTML のままです。要素名と HTML のホバーは、VS Code 自身の HTML サービスが提供します。",
+ "zh": "周围的标记依然是 HTML。元素名称和 HTML 悬停信息来自 VS Code 自带的 HTML 服务。"
+ },
+ {
+ "scene": "errors",
+ "en": "Make a mistake, and the error shows on the Razor line that caused it, in the editor and in the Problems panel.",
+ "ja": "間違えると、エラーは原因となった Razor の行に表示されます。エディターでも、問題パネルでも同じです。",
+ "zh": "一旦写错,错误就会显示在引起它的那一行 Razor 代码上,编辑器和问题面板里都能看到。"
+ },
+ {
+ "scene": "navigate",
+ "en": "Go to Definition jumps from the markup into the code block. Find All References lists every use in the Razor file, with the generated scaffolding left out.",
+ "ja": "定義へ移動すると、マークアップからコードブロックへジャンプします。「すべての参照を検索」は、Razor ファイル内の使用箇所をすべて一覧にし、生成された足場のコードは含めません。",
+ "zh": "转到定义会从标记跳进代码块。查找所有引用会列出 Razor 文件中的每一处用法,生成的脚手架代码不会混进来。"
+ },
+ {
+ "scene": "rename",
+ "en": "Rename a field in the code block, and every use in the markup follows.",
+ "ja": "コードブロックのフィールド名を変更すると、マークアップ内の使用箇所もすべて追従します。",
+ "zh": "在代码块里重命名一个字段,标记中的每一处用法都会随之更新。"
+ },
+ {
+ "scene": "csharp",
+ "en": "From C#, Go to Definition on a component opens its Razor file, not a generated class.",
+ "ja": "C# からコンポーネントの定義へ移動すると、生成されたクラスではなく、その Razor ファイルが開きます。",
+ "zh": "在 C# 中对组件转到定义,打开的是它的 Razor 文件,而不是生成的类。"
+ },
+ {
+ "scene": "structure",
+ "en": "Rename a tag, and its closing tag changes with it. Elements and code blocks fold, too.",
+ "ja": "タグ名を変えると、閉じタグも一緒に変わります。要素とコードブロックは折りたたむこともできます。",
+ "zh": "修改一个标签的名称,它的结束标签也会一起改变。元素和代码块同样可以折叠。"
+ }
+ ]
+}
diff --git a/src/website/tools/profiler-film/kit/acts/language.mjs b/src/website/tools/profiler-film/kit/acts/language.mjs
index 4601decb..0f76ceda 100644
--- a/src/website/tools/profiler-film/kit/acts/language.mjs
+++ b/src/website/tools/profiler-film/kit/acts/language.mjs
@@ -5,7 +5,7 @@
import { ACTIONS, HOVER, RENAME, SUGGEST, clickOn, hoverOn } from '../code.mjs';
import { runCommand } from '../editor.mjs';
import { caption, ringClick } from '../overlay.mjs';
-import { PARK, clickSharpLspIcon, openFile, sleep } from '../workbench.mjs';
+import { PARK, clickSharpLspIcon, openFile, sleep, typeAt } from '../workbench.mjs';
/**
* Workspace settings for a language film: hover, signature help, CodeLens and
@@ -152,6 +152,17 @@ export async function complete(page, filter) {
await sleep(600);
}
+/** Type `opening` after `line`, then for each `[filter, then]`: accept the completion `filter` narrows to, and type `then`. */
+export async function typeThroughCompletion(page, line, opening, steps) {
+ await typeAt(page, { line, append: true, text: opening });
+ for (const [filter, then] of steps) {
+ await complete(page, filter);
+ await typeText(page, then);
+ }
+ await page.keyboard.press('Escape');
+ await sleep(1500);
+}
+
/** Open the quick fixes at `spot` (Cmd+.) and apply the one titled `title`. */
export async function quickFix(page, cues, spot, title) {
await clickOn(page, spot);
diff --git a/src/website/tools/profiler-film/kit/acts/shop.mjs b/src/website/tools/profiler-film/kit/acts/shop.mjs
index 957b6b7a..0e20e431 100644
--- a/src/website/tools/profiler-film/kit/acts/shop.mjs
+++ b/src/website/tools/profiler-film/kit/acts/shop.mjs
@@ -3,18 +3,7 @@
// match completed by a quick fix, and a rename that reaches another file.
import { hoverOn } from '../code.mjs';
import { openFile, sleep, typeAt } from '../workbench.mjs';
-import { complete, prepareCode, quickFix, renameTo, scrollEditor, typeText, warm } from './language.mjs';
-
-/** Type `opening` after `line`, then for each `[filter, then]`: accept the completion `filter` narrows to, and type `then`. */
-async function typeThroughCompletion(page, line, opening, steps) {
- await typeAt(page, { line, append: true, text: opening });
- for (const [filter, then] of steps) {
- await complete(page, filter);
- await typeText(page, then);
- }
- await page.keyboard.press('Escape');
- await sleep(1500);
-}
+import { prepareCode, quickFix, renameTo, scrollEditor, typeThroughCompletion, warm } from './language.mjs';
/** Checkout.fs: a new function typed through List's functions, then the record's fields. */
export async function fsharpCheapest({ page }) {
diff --git a/src/website/tools/profiler-film/kit/set.mjs b/src/website/tools/profiler-film/kit/set.mjs
index 988d200c..2bde9524 100644
--- a/src/website/tools/profiler-film/kit/set.mjs
+++ b/src/website/tools/profiler-film/kit/set.mjs
@@ -24,6 +24,14 @@ export const FS_STOREFRONT = { folder: 'fs-storefront', project: 'FsStorefront'
*/
export const SHOP = { folder: '.', project: 'Shop', omit: ['Storefront.slnx', 'FsStorefront.slnx'], runs: false };
+/**
+ * The Blazor Web App the Razor film is shot on: a basket page whose markup and
+ * `@code` block share one C# engine. Nothing runs. It keeps the repository's
+ * SDK pin, because the Razor generator comes from the SDK that builds it and
+ * must match the Roslyn the sidecar bundles (#399).
+ */
+export const BLAZOR_STOREFRONT = { folder: 'blazor-storefront', project: 'BlazorStorefront', runs: false, pinSdk: true };
+
/** `git archive` of HEAD in `repo`, unpacked into `into`: the committed tree, nothing local. */
function unpackHead(repo, into) {
fs.mkdirSync(into, { recursive: true });
@@ -70,6 +78,7 @@ export function stageDemo(demo) {
recursive: true,
filter: (source) => !['bin', 'obj', '.sharplsp', ...(demo.omit ?? [])].includes(path.basename(source)),
});
+ if (demo.pinSdk) fs.copyFileSync(path.join(repoRoot, 'global.json'), path.join(workspace, 'global.json'));
buildDemo(workspace);
return { root, workspace };
}