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, "

@(1)

")); + Assert.Null(RazorFixture.Ok(await manager.GetSignatureHelpAsync(Board, 0, 5))); + } + + [Theory] + [InlineData("

@(Math.Max(1, 2))

", 5, 14, 17)] + [InlineData("", 20, 29, 32)] + public async Task Signature_help_tracks_real_calls_and_active_arguments_in_unsaved_razor( + string source, + int expression, + int first, + int second + ) + { + // All three requests use the current unsaved text and the same real projection. + // [RAZOR-FEATURES-SIGNATURE-HELP] [RAZOR-ARCHITECTURE-DOCUMENTS] + using var manager = await _fixture.OpenManagerAsync(); + _ = RazorFixture.Ok(await manager.UpdateDocumentTextAsync(Board, source)); + AssertMax(RazorFixture.Ok(await manager.GetSignatureHelpAsync(Board, 0, first)), 0); + AssertMax(RazorFixture.Ok(await manager.GetSignatureHelpAsync(Board, 0, second)), 1); + Assert.Null(RazorFixture.Ok(await manager.GetSignatureHelpAsync(Board, 0, expression))); + } + + private static void AssertMax(SignatureHelpResult? help, int parameter) + { + Assert.NotNull(help); + Assert.Equal(parameter, help.ActiveParameter); + var signature = help.Signatures[help.ActiveSignature]; + Assert.Equal("int Math.Max(int val1, int val2)", signature.Label); + Assert.Equal(["int val1", "int val2"], signature.Parameters); + Assert.All( + help.Signatures, + item => Assert.Contains("Math.Max(", item.Label, StringComparison.Ordinal) + ); + } + + [Fact] + public async Task Rename_edits_every_escaped_identifier_in_razor_without_leaving_the_escape() + { + // Escapes are C# identifier syntax; the semantic name excludes the leading @. + // [RAZOR-FEATURES-RENAME] [RAZOR-ARCHITECTURE-PROJECTION] + using var manager = await _fixture.OpenManagerAsync(); + const string source = + "

@(@class)

\n\n@code { private string @class = \"Board\"; }"; + _ = RazorFixture.Ok(await manager.UpdateDocumentTextAsync(Board, source)); + var prepared = RazorFixture.Ok(await manager.PrepareRenameAsync(Board, 2, 25)); + Assert.True(prepared.CanRename); + Assert.Equal("@class", prepared.Placeholder); + Assert.Equal( + (2, 23, 29), + (prepared.StartLine, prepared.StartCharacter, prepared.EndCharacter) + ); + var result = RazorFixture.Ok(await manager.RenameAsync(Board, 2, 25, "heading")); + var file = Assert.Single(result.DocumentChanges); + Assert.True(NativePaths.AreEqual(Board, file.FilePath)); + Assert.Equal(3, file.Edits.Count); + Assert.Equal([0, 1, 2], file.Edits.Select(edit => edit.StartLine)); + Assert.All(file.Edits, edit => Assert.Equal("heading", edit.NewText)); + Assert.All(file.Edits, edit => Assert.Equal(6, edit.EndCharacter - edit.StartCharacter)); + } + [Fact] public async Task Import_files_never_report_their_usings_as_unnecessary() { diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCompletionRecoveryTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCompletionRecoveryTests.cs new file mode 100644 index 00000000..3fcbd7cb --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCompletionRecoveryTests.cs @@ -0,0 +1,114 @@ +#pragma warning disable CA1515 // Public xUnit discovery type +#pragma warning disable CA2007 // xUnit executes without a synchronization context + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// Incomplete expressions keep member completion. [RAZOR-FEATURES-COMPLETION] +public sealed class RazorCompletionRecoveryTests(RazorFixture fixture) : IClassFixture +{ + private string Board => fixture.Path("BlazorApp", "Components", "Pages", "Board.razor"); + + [Theory] + [InlineData("

@scores.

", 11)] + [InlineData("

@(scores.", 12)] + [InlineData("

@(scores.)

", 12)] + [InlineData("

@{ var n = scores.", 21)] + public async Task Completion_after_a_dot_offers_collection_members(string markup, int character) + { + // Incomplete expressions still need completion before a member name is typed. + // [RAZOR-FEATURES-COMPLETION] + using var manager = await fixture.OpenManagerAsync(); + const string members = "\n@code { private readonly List scores = []; }"; + _ = RazorFixture.Ok(await manager.UpdateDocumentTextAsync(Board, markup + members)); + var items = RazorFixture.Ok(await manager.GetCompletionsAsync(Board, 0, character)); + var count = Assert.Single(items, item => item.Label == "Count" && item.Kind == "Property"); + Assert.Contains(items, item => item.Label == "Add"); + Assert.NotNull(count.TextEdit); + Assert.Equal((0, character), (count.TextEdit.StartLine, count.TextEdit.StartCharacter)); + Assert.Equal(character, count.TextEdit.EndCharacter); + } + + [Theory] + [InlineData("

Cheapest: @(basket.MinBy(product => product.", "")] + [InlineData("

Cheapest: @(basket.MinBy(product => product.Pr", "")] + [InlineData("

Cheapest: @(basket.MinBy(product => product.", ")

")] + [InlineData("

Cheapest: @(basket.MinBy(product => product.", "))

")] + [InlineData("

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.", "

")] + [InlineData("

@(scores.", "")] + [InlineData("

@(scores.MinBy(score => score.", "")] + [InlineData("

@(scores.MinBy(score => { return score.", "")] + public async Task Incomplete_csharp_keeps_html_forwarding_off(string before, string after) + { + // The generator can call a trailing dot markup; completion recovery still owns it. + // [RAZOR-FEATURES-HTML] [RAZOR-FEATURES-COMPLETION] + await OpenAsync().ConfigureAwait(true); + var text = before + after + "\n@code { private readonly List scores = []; }"; + await socket + .SendAndAssertOkAsync( + "textDocument/didChange", + new DidChangeRequest { FilePath = Board, NewText = text } + ) + .ConfigureAwait(true); + var at = await socket + .SendAndDeserializeAsync( + "sharplsp/razor/htmlProjection", + MessagePackSerializer.Serialize((Board, new[] { 0, before.Length })) + ) + .ConfigureAwait(true); + Assert.Null(at); + var whole = await AtAsync("sharplsp/razor/htmlProjection", Board) + .ConfigureAwait(true); + Assert.NotNull(whole); + Assert.StartsWith("

", whole.Text, StringComparison.Ordinal); + Assert.Equal(text.Length, whole.Text.Length); + } + [Fact] public async Task Syntax_requests_keep_their_wire_shapes_and_source_ranges() { diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Razor.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Razor.cs index 6cb68bbd..c1fdc929 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Razor.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Razor.cs @@ -39,13 +39,28 @@ private Task HandleRazorHtmlProjectionAsync(byte[] payload, Cancella { return HandleRequestAsync( payload, - (PositionRequest request) => - _workspace.GetRazorHtmlProjectionAsync(request.FilePath, ct), + (HtmlProjectionRequest request) => + _workspace.GetRazorHtmlProjectionAsync( + request.FilePath, + ct, + request.Position is [var line, var character] ? (line, character) : null + ), ct ); } } +///

An optional caret restricts HTML forwarding to markup. [RAZOR-FEATURES-HTML] +[MessagePackObject(AllowPrivate = true)] +internal sealed class HtmlProjectionRequest +{ + [Key(0)] + public string FilePath { get; set; } = ""; + + [Key(1)] + public int[]? Position { get; init; } +} + /// The position-preserving HTML projection, [RAZOR-ARCHITECTURE-HTML]. [MessagePackObject(AllowPrivate = true)] internal sealed class HtmlProjectionResult diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/HtmlMarkupContext.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/HtmlMarkupContext.cs index 00a7c86e..a7a20651 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/HtmlMarkupContext.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/HtmlMarkupContext.cs @@ -367,7 +367,8 @@ 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) + /// HTML's whitespace, which separates a tag's name and attributes. + internal static bool IsSpace(char c) { return c is ' ' or '\t' or '\n' or '\f' or '\r'; } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCompletionRecovery.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCompletionRecovery.cs new file mode 100644 index 00000000..488ca44d --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCompletionRecovery.cs @@ -0,0 +1,133 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// Completion in a C# expression the Razor compiler cannot finish yet. The compiler leaves the +/// dot of @scores. in the markup, and an @( that is missing its ) runs on +/// through the markup after it, calls and lambdas inside it included, so neither projection +/// holds the member access being typed. Completion asks a copy of the file whose expression is +/// finished at the caret. +/// Implements [RAZOR-FEATURES-COMPLETION]. +/// +internal static class RazorCompletionRecovery +{ + /// The Razor compiler's diagnostic for a block that is missing its closing character. + private const string MissingClose = "RZ1006"; + + /// The parentheses of an explicit expression. + private static readonly string Open = SyntaxFacts.GetText(SyntaxKind.OpenParenToken); + + private static readonly string Close = SyntaxFacts.GetText(SyntaxKind.CloseParenToken); + + /// + /// The insertions, in text order, that finish the expression at ; + /// none when the projection already holds it. The compiler's + /// are read only when the projection's C# does not parse. + /// + public static async Task> FinishAsync( + RazorProjection projection, + int caret, + Func>> diagnostics + ) + { + return projection.Map.ToGenerated(caret) is null ? TrailingDot(projection, caret) + : projection.GeneratedRoot.ContainsDiagnostics + ? Unclosed(projection, await diagnostics().ConfigureAwait(false), caret) + : []; + } + + /// + /// @scores. as @(scores.): a fragment ends at the dot before the caret, and + /// with that dot it reads as a member access whose name is still missing. + /// + private static ImmutableArray TrailingDot(RazorProjection projection, int caret) + { + var fragment = projection.Map.Mappings.FirstOrDefault(entry => + entry.Length > 0 && entry.RazorEnd == caret - 1 + ); + return + fragment.Length > 0 + && NamesNoMember( + projection.RazorText.ToString(TextSpan.FromBounds(fragment.RazorStart, caret)) + ) + ? [Insert(fragment.RazorStart, Open), Insert(caret, Close)] + : []; + } + + /// Whether is all one member access missing its name. + private static bool NamesNoMember(string expression) + { + return SyntaxFactory.ParseExpression(expression) + is MemberAccessExpressionSyntax { Name.IsMissing: true } access + && access.FullSpan.Length == expression.Length; + } + + /// + /// @(scores. as @(scores.), and @(basket.MinBy(p => p. as + /// @(basket.MinBy(p => p.)): the fragment holding the caret opens just after a + /// ( that the compiler reports missing its close. An unclosed @{ needs no + /// help: the compiler's recovery keeps its code. + /// + private static ImmutableArray Unclosed( + RazorProjection projection, + IEnumerable diagnostics, + int caret + ) + { + var fragment = projection.Map.Mappings.FirstOrDefault(entry => + entry.Length > 0 && entry.RazorStart <= caret && caret <= entry.RazorEnd + ); + return + fragment.Length > 0 + && diagnostics.Any(diagnostic => + diagnostic.Id == MissingClose + && diagnostic.Span.End == fragment.RazorStart + && OpensExpression(projection.RazorText.ToString(diagnostic.Span)) + ) + ? [Insert(caret, Closing(TextSpan.FromBounds(fragment.RazorStart, caret), projection))] + : []; + } + + /// + /// What closes the explicit expression whose C# runs over : each + /// ), ] and } Roslyn's parser supplies for what the C# leaves open, + /// innermost first, then the expression's own ). + /// + private static string Closing(TextSpan typed, RazorProjection projection) + { + return SyntaxFactory + .TokenList( + SyntaxFactory + .ParseExpression(projection.RazorText.ToString(typed)) + .DescendantTokens() + .Where(token => token.IsMissing && Closes(token.Kind())) + .Select(token => SyntaxFactory.Token(token.Kind())) + .Append(SyntaxFactory.Token(SyntaxKind.CloseParenToken)) + ) + .ToFullString(); + } + + private static bool Closes(SyntaxKind kind) + { + return kind + is SyntaxKind.CloseParenToken + or SyntaxKind.CloseBracketToken + or SyntaxKind.CloseBraceToken; + } + + /// Whether is the ( that opens an explicit expression. + private static bool OpensExpression(string text) + { + return SyntaxFactory.ParseToken(text).IsKind(SyntaxKind.OpenParenToken); + } + + private static TextChange Insert(int at, string text) + { + return new TextChange(new TextSpan(at, 0), text); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorGenerator.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorGenerator.cs new file mode 100644 index 00000000..83c90b46 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorGenerator.cs @@ -0,0 +1,91 @@ +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Serilog; +using SharpLsp.Sidecar.Common; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// Keep an SDK generator that can run on this Roslyn; replace a newer one before it loads. +/// Implements [RAZOR-ARCHITECTURE-GENERATOR]. Metadata inspection never loads the SDK assembly. +/// +internal static class RazorGenerator +{ + private const string Compiler = "Microsoft.CodeAnalysis.Razor.Compiler.dll"; + private static readonly string[] Files = + [ + Compiler, + "Microsoft.AspNetCore.Razor.Utilities.Shared.dll", + "Microsoft.Extensions.ObjectPool.dll", + ]; + + public static ProjectInfo Compatible(ProjectInfo project) + { + try + { + return Inspect(project); + } + catch (Exception error) + { + Log.Warning( + "[Razor] Generator inspection failed for {Project}: {ExceptionType}", + project.Name, + error.GetType().Name + ); + return project; + } + } + + private static ProjectInfo Inspect(ProjectInfo project) + { + var compiler = project.AnalyzerReferences.FirstOrDefault(reference => + reference.FullPath is { } path && NativePaths.SameName(path, Compiler) + ); + var required = compiler?.FullPath is { } path ? RequiredVersion(path) : null; + var running = typeof(Compilation).Assembly.GetName().Version; + if (required is null || required <= running) + { + return project; + } + Log.Warning( + "[Razor] Generator requires Roslyn {RequiredVersion}; using bundled compiler for {RuntimeVersion} in {Project}", + required, + running, + project.Name + ); + return project.WithAnalyzerReferences(Bundled(project.AnalyzerReferences)); + } + + private static Version? RequiredVersion(string path) + { + using var stream = File.OpenRead(path); + using var image = new PEReader(stream); + var metadata = image.GetMetadataReader(); + return metadata + .AssemblyReferences.Select(metadata.GetAssemblyReference) + .Where(reference => metadata.GetString(reference.Name) == "Microsoft.CodeAnalysis") + .Select(reference => reference.Version) + .FirstOrDefault(); + } + + private static IEnumerable Bundled( + IReadOnlyList references + ) + { + // Referencing this public type loads our compiler first. Roslyn cannot stand it in + // for a newer SDK assembly once the SDK assembly has already won the load. + var directory = NativePaths.DirectoryOf(typeof(RazorProjectEngine).Assembly.Location); + return references.Select(reference => + reference is AnalyzerFileReference { FullPath: { } path } fileReference + && Files.Any(file => NativePaths.SameName(path, file)) + ? new AnalyzerFileReference( + NativePaths.Join(directory, NativePaths.NameOf(path)), + fileReference.AssemblyLoader + ) + : reference + ); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHtmlProjection.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHtmlProjection.cs index 7c8c88e7..2a961a57 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHtmlProjection.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHtmlProjection.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Language.Intermediate; using Microsoft.CodeAnalysis.Text; @@ -18,7 +19,8 @@ public static string Create(SourceText text, DocumentIntermediateNode root) private static void Write(IntermediateNode node, SourceText text, char[] chars) { - if (node.IsImported) + // A directive attribute's value is never HTML, whatever the compiler holds under it. + if (node.IsImported || IsDirective(node)) { return; } @@ -58,6 +60,14 @@ private static void WriteLiteral(IntermediateNode node, SourceText text, char[] { CopyAttribute(text, chars, span, attribute); } + else if ( + node + is CSharpExpressionAttributeValueIntermediateNode + or CSharpCodeAttributeValueIntermediateNode + ) + { + Clear(chars, span); + } } private static void CopyAttribute( @@ -86,9 +96,11 @@ char[] chars chars, TextSpan.FromBounds(tag.Element.Start, OpeningEnd(text, tag.Element, attributes)) ); - foreach (var attribute in attributes) + // The IR lists attributes in source order, so each one starts after the one before it. + int? after = tag.StartName.End; + foreach (var attribute in node.Children.Where(IsAttribute)) { - Clear(chars, attribute); + after = ClearAttribute(chars, text, attribute, after); } if (tag.EndName is { } end) @@ -97,6 +109,121 @@ char[] chars } } + /// + /// Blanks an attribute's IR span and a directive's whole @name="value", and answers + /// where the attribute ends, or null when the IR does not say. + /// + private static int? ClearAttribute( + char[] chars, + SourceText text, + IntermediateNode node, + int? after + ) + { + var last = LastEnd(node); + if (DirectiveName(node, text, after) is { } name) + { + var end = Closing(Structure(node), last ?? name.End); + Clear(chars, TextSpan.FromBounds(name.Start, end)); + return end; + } + + if (node.Source is { } source) + { + Clear(chars, new TextSpan(source.AbsoluteIndex, source.Length)); + } + + return last is { } found + ? Closing(Structure(node), found) + : MinimizedEnd(node, text, after); + } + + /// A directive attribute's name, its @ included. + private static TextSpan? DirectiveName(IntermediateNode node, SourceText text, int? after) + { + return node switch + { + TagHelperDirectiveAttributeIntermediateNode directive => Located( + directive.OriginalAttributeSpan, + directive.OriginalAttributeName, + directive.AttributeStructure, + text, + after + ), + TagHelperDirectiveAttributeParameterIntermediateNode parameter => Located( + parameter.OriginalAttributeSpan, + parameter.OriginalAttributeName, + parameter.AttributeStructure, + text, + after + ), + _ => null, + }; + } + + /// + /// A directive attribute's name from the compiler's span. The compiler gives a minimized + /// directive parameter (@onclick:stopPropagation) no span, so its name is read where + /// the attribute before it ends, and only when the text there holds it. + /// + private static TextSpan? Located( + SourceSpan? original, + string name, + AttributeStructure structure, + SourceText text, + int? after + ) + { + return original is null && structure == AttributeStructure.Minimized && after is { } start + ? RazorIr.NameAfter(text, start, name) + : RazorIr.DirectiveName(original, name, text); + } + + /// The end of an attribute's last IR span, its own or a value's under it. + private static int? LastEnd(IntermediateNode node) + { + var ends = RazorIr + .Descendants(node) + .Select(child => child.Source) + .OfType() + .Select(source => source.AbsoluteIndex + source.Length) + .ToArray(); + return ends.Length > 0 ? ends.Max() : null; + } + + /// A minimized attribute has no span, so it ends after its name, read where the one before it ends. + private static int? MinimizedEnd(IntermediateNode node, SourceText text, int? after) + { + return + node is TagHelperHtmlAttributeIntermediateNode html + && html.AttributeStructure == AttributeStructure.Minimized + && after is { } start + ? RazorIr.NameAfter(text, start, html.AttributeName)?.End + : null; + } + + /// How the compiler read a bound attribute's value; a plain attribute's span holds its quotes. + private static AttributeStructure Structure(IntermediateNode node) + { + return node switch + { + TagHelperHtmlAttributeIntermediateNode html => html.AttributeStructure, + TagHelperPropertyIntermediateNode property => property.AttributeStructure, + TagHelperDirectiveAttributeIntermediateNode directive => directive.AttributeStructure, + TagHelperDirectiveAttributeParameterIntermediateNode parameter => + parameter.AttributeStructure, + _ => AttributeStructure.Minimized, + }; + } + + /// An attribute's end: its value's end, plus the closing quote the compiler parsed. + private static int Closing(AttributeStructure structure, int valueEnd) + { + return structure is AttributeStructure.DoubleQuotes or AttributeStructure.SingleQuotes + ? valueEnd + 1 + : valueEnd; + } + private static TextSpan[] Attributes(IntermediateNode node) { return @@ -114,10 +241,16 @@ .. node private static bool IsAttribute(IntermediateNode node) { return node - is HtmlAttributeIntermediateNode - or TagHelperHtmlAttributeIntermediateNode - or TagHelperPropertyIntermediateNode - or TagHelperDirectiveAttributeIntermediateNode + is HtmlAttributeIntermediateNode + or TagHelperHtmlAttributeIntermediateNode + or TagHelperPropertyIntermediateNode + || IsDirective(node); + } + + private static bool IsDirective(IntermediateNode node) + { + return node + is TagHelperDirectiveAttributeIntermediateNode or TagHelperDirectiveAttributeParameterIntermediateNode; } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs index 297c2b60..b0e2f13b 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs @@ -193,6 +193,19 @@ private static MarkupAttribute Html(string name, MarkupTag tag) : Span(span); } + /// The span of at the first non-space offset from , when the text holds it there. + public static TextSpan? NameAfter(SourceText text, int start, string name) + { + var at = start; + while (at < text.Length && HtmlMarkupContext.IsSpace(text[at])) + { + at++; + } + + var span = new TextSpan(at, name.Length); + return Holds(text, span, name) ? span : null; + } + /// The name of an element's end tag, </Name> closing . private static TextSpan? EndName( TextSpan element, @@ -202,7 +215,7 @@ private static MarkupAttribute Html(string name, MarkupTag tag) ) { var nameEnd = element.End - 1; - while (nameEnd > element.Start && text[nameEnd - 1] is ' ' or '\t' or '\r' or '\n' or '\f') + while (nameEnd > element.Start && HtmlMarkupContext.IsSpace(text[nameEnd - 1])) { nameEnd--; } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorProjection.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorProjection.cs index b2eafaab..446e0163 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorProjection.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorProjection.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -72,6 +73,20 @@ is PragmaChecksumDirectiveTriviaSyntax pragma : null; } + /// + /// This projection, of a copy of 's Razor file that + /// made, read in the file itself. [RAZOR-FEATURES-COMPLETION] + /// + public RazorProjection ReadIn(RazorProjection file, ImmutableArray insertions) + { + return this with + { + Razor = file.Razor, + RazorText = file.RazorText, + Map = Map.Without(insertions), + }; + } + /// The generated position of a Razor position inside a C# fragment. public LinePosition? ToGenerated(LinePosition razorPosition) { diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorRenameEdits.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorRenameEdits.cs index c56164e9..036d198e 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorRenameEdits.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorRenameEdits.cs @@ -1,4 +1,5 @@ using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.FindSymbols; using Microsoft.CodeAnalysis.Text; using SharpLsp.Sidecar.Common; @@ -90,11 +91,13 @@ CancellationToken ct : new DocumentEditResult { FilePath = file.Key, Edits = edits }; } - /// Whether of is exactly . + /// Whether the span is one C# identifier whose semantic name matches, escapes included. 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); + && SyntaxFactory.ParseToken(text.ToString(found)) + is { RawKind: (int)SyntaxKind.IdentifierToken } token + && token.Span.Length == found.Length + && string.Equals(token.ValueText, name, StringComparison.Ordinal); } } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSpanMap.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSpanMap.cs index 04118b10..28275afe 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSpanMap.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSpanMap.cs @@ -70,6 +70,66 @@ .. tree.GetLineMappings() return null; } + /// + /// This map, built on a copy of the Razor file that made, + /// read in the file itself. An entry keeps only its part before the first insertion it holds: + /// C# the copy finished inside a fragment has nothing in the file to map to, and a part + /// after it would start at the caret and claim it. Each part then moves back past the + /// insertions before it. [RAZOR-FEATURES-COMPLETION] + /// + public RazorSpanMap Without(ImmutableArray insertions) + { + var inserted = InCopy(insertions); + return new([ + .. mappings + .SelectMany(entry => Before(entry, inserted)) + .Select(part => + part with + { + RazorStart = + part.RazorStart + - inserted + .Where(span => span.End <= part.RazorStart) + .Sum(span => span.Length), + } + ), + ]); + } + + /// The part of before the first span of it holds. + private static IEnumerable Before( + RazorMapping entry, + ImmutableArray inserted + ) + { + var end = inserted + .Where(span => span.OverlapsWith(new TextSpan(entry.RazorStart, entry.Length))) + .Select(span => span.Start) + .DefaultIfEmpty(entry.RazorEnd) + .Min(); + return end > entry.RazorStart ? [entry with { Length = end - entry.RazorStart }] : []; + } + + /// Where each of , in text order, lands in the copy they make. + private static ImmutableArray InCopy(ImmutableArray insertions) + { + return + [ + .. insertions.Select( + (insertion, index) => + new TextSpan( + insertion.Span.Start + insertions.Take(index).Sum(Inserted), + Inserted(insertion) + ) + ), + ]; + } + + private static int Inserted(TextChange insertion) + { + return insertion.NewText?.Length ?? 0; + } + private RazorMapping? EndingAt(int razorOffset) { foreach (var entry in mappings) diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/RazorCompiler.props b/src/sidecars/SharpLsp.Sidecar.CSharp/RazorCompiler.props index 6f9b45f1..a1e78b3b 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/RazorCompiler.props +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/RazorCompiler.props @@ -3,7 +3,7 @@ SDK (no current NuGet package carries it), so the sidecar bundles the copy from the SDK global.json pins. Private="true" copies it into the build, publish and tool package output. The project's own SDK generator still produces the C# projection; - this copy only reads markup structure. Imported by the sidecar and its tests. --> + this copy reads markup structure and stands in for an SDK generator that requires a newer Roslyn. Imported by the sidecar and its tests. --> $(MSBuildExtensionsPath)/Sdks/Microsoft.NET.Sdk.Razor/source-generators/ diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/RoslynProjectLoading.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/RoslynProjectLoading.cs index 44b805d8..4bf47eda 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/RoslynProjectLoading.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/RoslynProjectLoading.cs @@ -3,6 +3,7 @@ using Microsoft.CodeAnalysis.MSBuild; using SharpLsp.Sidecar.Common; using SharpLsp.Sidecar.Common.Solutions; +using SharpLsp.Sidecar.CSharp.Razor; namespace SharpLsp.Sidecar.CSharp.Workspace; @@ -171,7 +172,10 @@ private static Solution WithNew(Solution solution, IEnumerable info { return infos .Where(info => solution.GetProject(info.Id) is null) - .Aggregate(solution, (current, info) => current.AddProject(info)); + .Aggregate( + solution, + (current, info) => current.AddProject(RazorGenerator.Compatible(info)) + ); } /// diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SignatureHelpResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SignatureHelpResolver.cs index f128a36e..351e2fd4 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SignatureHelpResolver.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/SignatureHelpResolver.cs @@ -3,6 +3,7 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.CSharp.Razor; namespace SharpLsp.Sidecar.CSharp.Workspace; @@ -37,6 +38,7 @@ internal static class SignatureHelpResolver Document document, int line, int character, + RazorProjection? origin, CancellationToken ct ) { @@ -50,7 +52,14 @@ CancellationToken ct var position = text.Lines.GetPosition(new LinePosition(line, character)); var arguments = EnclosingArgumentList(root, position); - return arguments is null ? null : Build(model, arguments, position); + // A Razor expression sits inside generated render-tree calls. Only a call whose + // opening parenthesis belongs to the user's C# can offer signatures. + // [RAZOR-FEATURES-SIGNATURE-HELP] + return + arguments is null + || (origin is not null && origin.Map.ToRazor(arguments.OpenParenToken.Span) is null) + ? null + : Build(model, arguments, position); } /// diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs index 009f1e02..3dfa647b 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs @@ -80,7 +80,8 @@ CancellationToken ct ) { if ( - await ResolveQueryTargetAsync(filePath, (line, character), ct).ConfigureAwait(false) + await ResolveCompletionTargetAsync(filePath, (line, character), ct) + .ConfigureAwait(false) is not { } target ) { diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Razor.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Razor.cs index 645b4529..f9b37f16 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Razor.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Razor.cs @@ -1,5 +1,7 @@ +using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; +using Serilog; using SharpLsp.Sidecar.Common; using SharpLsp.Sidecar.CSharp.Razor; @@ -91,11 +93,102 @@ CancellationToken ct } var projection = await FindRazorProjectionAsync(filePath, ct).ConfigureAwait(false); - return projection?.ToGenerated(new LinePosition(at.Line, at.Character)) is { } mapped + return projection is null + ? null + : Target(projection, new LinePosition(at.Line, at.Character)); + } + + /// Where a query at in a Razor file runs in its projection. + private static QueryTarget? Target(RazorProjection projection, LinePosition position) + { + return projection.ToGenerated(position) is { } mapped ? new QueryTarget(projection.Generated, mapped.Line, mapped.Character, projection) : null; } + /// + /// Where completion runs: where any position query runs, except in a Razor expression the + /// compiler cannot finish yet, where it runs in a copy that finishes it. [RAZOR-FEATURES-COMPLETION] + /// + private async Task ResolveCompletionTargetAsync( + string filePath, + (int Line, int Character) at, + CancellationToken ct + ) + { + if (!NativePaths.IsRazor(filePath)) + { + return await ResolveQueryTargetAsync(filePath, at, ct).ConfigureAwait(false); + } + + var position = new LinePosition(at.Line, at.Character); + var projection = await FindRazorProjectionAsync(filePath, ct).ConfigureAwait(false); + return projection is null + ? null + : Target(await FinishedAsync(projection, position, ct).ConfigureAwait(false), position); + } + + /// + /// , or, when is in an expression the + /// compiler cannot finish yet, the projection of a copy of the file that finishes it. A copy + /// that does not map the caret leaves the file's own projection to answer. + /// + private static async Task FinishedAsync( + RazorProjection projection, + LinePosition position, + CancellationToken ct + ) + { + var insertions = DocumentText.TryOffset(projection.RazorText, position) is { } caret + ? await RazorCompletionRecovery + .FinishAsync(projection, caret, () => MarkupDiagnosticsAsync(projection, ct)) + .ConfigureAwait(false) + : []; + var finished = insertions.IsEmpty + ? null + : await CopyFinishingAsync(projection, insertions, ct).ConfigureAwait(false); + return finished?.ToGenerated(position) is null ? projection : finished; + } + + /// The Razor compiler's diagnostics for 's file. + private static async Task> MarkupDiagnosticsAsync( + RazorProjection projection, + CancellationToken ct + ) + { + var markup = await RazorMarkupEngine + .ProcessAsync(projection.Razor, ct) + .ConfigureAwait(false); + return markup?.Diagnostics ?? []; + } + + /// + /// The projection of a copy of 's file, in its active framework, + /// with made, read in the file itself; null when the copy + /// projects nothing. + /// + private static async Task CopyFinishingAsync( + RazorProjection projection, + ImmutableArray insertions, + CancellationToken ct + ) + { + var copy = projection.Razor.Project.Solution.WithAdditionalDocumentText( + projection.Razor.Id, + projection.RazorText.WithChanges(insertions) + ); + var finished = copy.GetAdditionalDocument(projection.Razor.Id) is { } razor + ? await RazorProjection.ForRazorAsync(razor, ct).ConfigureAwait(false) + : null; + Log.Debug( + "[Completion] Razor expression finished in a copy {File} {Insertions} {Projected}", + projection.RazorPath, + insertions.Length, + finished is not null + ); + return finished?.ReadIn(projection, insertions); + } + /// /// Replace a Razor file's text in every framework context. Caller holds /// . A file no project lists has nothing to update. diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorHtml.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorHtml.cs index 5fa6a493..01a7edc5 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorHtml.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorHtml.cs @@ -1,3 +1,6 @@ +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.CSharp.Razor; + namespace SharpLsp.Sidecar.CSharp.Workspace; internal sealed partial class WorkspaceManager @@ -5,14 +8,29 @@ internal sealed partial class WorkspaceManager /// Current HTML or null for an unlisted file; [RAZOR-ARCHITECTURE-HTML]. internal Task> GetRazorHtmlProjectionAsync( string filePath, - CancellationToken ct + CancellationToken ct, + (int Line, int Character)? position = null ) { return RunMarkupQueryAsync( filePath, null, - markup => Task.FromResult(new() { Text = markup.HtmlText }), + async markup => + position is { } at + && !MarkupNameAt(markup, at) + && await ResolveCompletionTargetAsync(filePath, at, ct).ConfigureAwait(false) + is not null + ? null + : new HtmlProjectionResult { Text = markup.HtmlText }, ct ); } + + /// Component names and parameters can also have C# mappings; they remain markup. + private static bool MarkupNameAt(RazorMarkup markup, (int Line, int Character) at) + { + var offset = DocumentText.TryOffset(markup.Text, new LinePosition(at.Line, at.Character)); + return offset is { } found + && (markup.TagNamedAt(found) is not null || markup.AttributeNamedAt(found) is not null); + } } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.SignatureHelp.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.SignatureHelp.cs index d5c2b55a..ef1b7f28 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.SignatureHelp.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.SignatureHelp.cs @@ -29,6 +29,7 @@ public Task GetSignatureHelpAsync( target.Document, target.Line, target.Character, + target.Origin, ct ), ct diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpSemanticHighlightingTests.fs b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpSemanticHighlightingTests.fs new file mode 100644 index 00000000..eb489a1b --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpSemanticHighlightingTests.fs @@ -0,0 +1,55 @@ +/// Standard semantic classifications preserve the active theme's F# colors. +/// Implements [SHARPLSP-FEATURES-HIGHLIGHTING]. Every assertion runs against real FCS and a real fsproj. +module SharpLsp.Sidecar.FSharp.Tests.FSharpSemanticHighlightingTests + +open Xunit +open SharpLsp.Sidecar.FSharp +open SharpLsp.Sidecar.FSharp.Tests.FSharpCoverageTests +open SharpLsp.Sidecar.FSharp.Tests.FSharpRenameSemanticTests + +let private code = + source + [ "module Highlighting" + "let number = 42" + "let add (value: int) =" + " let local = value + number" + " local" + "type Box() =" + " member _.Value = number" + " member _.Add(value: int) = add value" + "let box = Box()" + "let result = box.Add number" + "let date = System.DateTime(2026, 1, 1)" ] + +/// Decode the LSP stream so assertions name absolute source spans and standard legend types. +let private decode (data: int array) = + data + |> Array.chunkBySize 5 + |> Array.mapFold (fun (line, character) token -> + let nextLine = line + token[0] + let nextCharacter = if token[0] = 0 then character + token[1] else token[1] + (nextLine, nextCharacter, token[2], token[3]), (nextLine, nextCharacter)) (0, 0) + |> fst + +[] +[] // A module value is a variable, not a method. +[] // A module function is a function, not a method. +[] // A local value is a variable, not a method. +[] // A member property remains a property. +[] // A member method remains a method. +[] // An overloaded operator keeps the standard operator category. +[] // A value-type constructor has its standard struct color. +let ``full and range tokens use standard FSharp symbol categories`` line column length expected = task { + let! state, directory, _, files = loadWorkspace [ "Highlighting.fs", code ] + try + let! full = FSharpFeatures.getSemanticTokens state files[0] + let tokens = decode full + Assert.Contains((line, column, length, expected), tokens) + let! ranged = FSharpFeatures.getSemanticTokensRange state files[0] line line + let selected = decode ranged + Assert.Contains((line, column, length, expected), selected) + Assert.All(selected, fun (actualLine, _, _, _) -> Assert.Equal(line, actualLine)) + Assert.DoesNotContain(tokens, fun (_, _, _, kind) -> kind = 15 || kind = 23) + finally + cleanup directory +} diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/SharpLsp.Sidecar.FSharp.Tests.fsproj b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/SharpLsp.Sidecar.FSharp.Tests.fsproj index 66809513..c1c206c1 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/SharpLsp.Sidecar.FSharp.Tests.fsproj +++ b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/SharpLsp.Sidecar.FSharp.Tests.fsproj @@ -22,6 +22,7 @@ + diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpFeatures.fs b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpFeatures.fs index f5635920..afef2e9a 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpFeatures.fs +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpFeatures.fs @@ -105,6 +105,18 @@ let formatPreview (state: FSharpWorkspace.FSharpWorkspaceState) (filePath: strin // ── Semantic Tokens ────────────────────────────────────────────── +/// Keep values, functions and members in their standard LSP categories. +let private mapFcsMemberKind (mfv: FSharpMemberOrFunctionOrValue) = + if mfv.IsProperty || mfv.IsPropertyGetterMethod || mfv.IsPropertySetterMethod then 9 + elif mfv.IsEvent then 11 + elif mfv.IsConstructor then + match mfv.DeclaringEntity with + | Some entity when entity.IsValueType -> 5 + | _ -> 2 + elif mfv.IsMember then 13 + elif mfv.IsFunction then 12 + else 8 + /// Map FCS symbol to LSP semantic token type index. let private mapFcsSymbolKind (symbol: FSharpSymbol) : int = match symbol with @@ -115,38 +127,47 @@ let private mapFcsSymbolKind (symbol: FSharpSymbol) : int = elif ent.IsValueType then 5 elif ent.IsFSharpModule then 0 else 2 - | :? FSharpMemberOrFunctionOrValue as mfv -> - if mfv.IsProperty then 9 - elif mfv.IsEvent then 11 - else 13 + | :? FSharpMemberOrFunctionOrValue as mfv -> mapFcsMemberKind mfv | :? FSharpField -> 8 | :? FSharpUnionCase -> 10 | :? FSharpGenericParameter -> 6 | _ -> -1 +/// FCS ranges may include a receiver (`box.Value`). Reuse the real lexer to +/// color only the symbol token, never its receiver or punctuation. +/// [SHARPLSP-FEATURES-HIGHLIGHTING] +let private semanticSpans checker source (checkResults: FSharpCheckFileResults) = + let tokenized = FSharpRenameToken.tokenizeSource checker source + checkResults.GetAllUsesOfAllSymbolsInFile() + |> Seq.choose (fun symbolUse -> + let range = symbolUse.Range + FSharpRenameToken.tokenForUse checker tokenized symbolUse + |> Option.map (fun token -> + let kind = + match token.Kind with + | FSharpRenameToken.OperatorToken -> 21 + | FSharpRenameToken.IdentifierToken -> mapFcsSymbolKind symbolUse.Symbol + range.EndLine - 1, token.StartCharacter, + token.EndCharacter - token.StartCharacter, kind)) + |> Seq.distinct + |> Seq.sortBy (fun (line, character, _, _) -> line, character) + |> Seq.toArray + /// Extract semantic tokens from check results using symbol uses. let private extractSemanticTokens + checker (checkResults: FSharpCheckFileResults) - (_source: string) + (source: string) (rangeFilter: (int -> bool) option) : int array = // Callers (getSemanticTokens / getSemanticTokensRange) wrap this in their own // try/with, so no exception needs to be re-caught here. - let uses = checkResults.GetAllUsesOfAllSymbolsInFile() |> Seq.toArray + let sorted = semanticSpans checker source checkResults let mutable prevLine = 0 let mutable prevChar = 0 let data = System.Collections.Generic.List() - let sorted = uses |> Array.sortBy (fun u -> - let r = u.Range - r.StartLine, r.StartColumn) - - for su in sorted do - let r = su.Range - let line = r.StartLine - 1 - let character = r.StartColumn - let length = r.EndColumn - r.StartColumn - let tokenType = mapFcsSymbolKind su.Symbol + for line, character, length, tokenType in sorted do let inRange = match rangeFilter with | Some f -> f line @@ -175,7 +196,7 @@ let getSemanticTokens let! result = FSharpWorkspace.checkFile state filePath match result with | Some(checkResults, source) -> - return extractSemanticTokens checkResults source None + return extractSemanticTokens state.Checker checkResults source None | None -> return [||] with ex -> Log.Debug(ex, "[F# SemanticTokens] failed") @@ -195,7 +216,7 @@ let getSemanticTokensRange match result with | Some(checkResults, source) -> let filter = fun line -> line >= startLine && line <= endLine - return extractSemanticTokens checkResults source (Some filter) + return extractSemanticTokens state.Checker checkResults source (Some filter) | None -> return [||] with ex -> Log.Debug(ex, "[F# SemanticTokensRange] failed") diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj b/src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj index b3f8e9d2..de33a626 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj @@ -58,13 +58,13 @@ + - diff --git a/src/website/src/_data/i18n.json b/src/website/src/_data/i18n.json index 910eec99..526d84d0 100644 --- a/src/website/src/_data/i18n.json +++ b/src/website/src/_data/i18n.json @@ -45,7 +45,9 @@ "diagnostics": "Diagnostics", "lsp": "LSP", "profiling": "Profiling", - "memory-leaks": "Memory leaks" + "memory-leaks": "Memory leaks", + "blazor": "Blazor", + "razor": "Razor" }, "empty": "No blog posts yet. Check back soon!" }, @@ -142,6 +144,7 @@ "title": "Documentation", "gettingStarted": "Getting Started", "fsharp": "F# Language Support", + "blazor": "Blazor & Razor", "architecture": "Architecture", "editorSetup": "Editor Setup", "zed": "Zed", @@ -257,7 +260,9 @@ "diagnostics": "诊断", "lsp": "LSP", "profiling": "性能分析", - "memory-leaks": "内存泄漏" + "memory-leaks": "内存泄漏", + "blazor": "Blazor", + "razor": "Razor" }, "empty": "暂无博客文章,请稍后再来!" }, @@ -342,6 +347,7 @@ "title": "文档", "gettingStarted": "快速入门", "fsharp": "F# 语言支持", + "blazor": "Blazor 与 Razor", "architecture": "架构", "editorSetup": "编辑器配置", "zed": "Zed", @@ -457,7 +463,9 @@ "diagnostics": "診断", "lsp": "LSP", "profiling": "プロファイリング", - "memory-leaks": "メモリリーク" + "memory-leaks": "メモリリーク", + "blazor": "Blazor", + "razor": "Razor" }, "empty": "ブログ記事はまだありません。しばらくしてからご確認ください。" }, @@ -542,6 +550,7 @@ "title": "ドキュメント", "gettingStarted": "はじめに", "fsharp": "F# 言語サポート", + "blazor": "Blazor と Razor", "architecture": "アーキテクチャ", "editorSetup": "エディター設定", "zed": "Zed", diff --git a/src/website/src/_data/navigation.json b/src/website/src/_data/navigation.json index 14e8146a..ce40a3dd 100644 --- a/src/website/src/_data/navigation.json +++ b/src/website/src/_data/navigation.json @@ -10,6 +10,7 @@ "group": "writeCode", "pages": { "fsharp/": "fsharp", + "blazor/": "blazor", "completions/": "codeCompletions", "hover/": "hover", "go-to-definition/": "goToDefinition", diff --git a/src/website/src/_data/videos.json b/src/website/src/_data/videos.json index 782ba46b..c0989096 100644 --- a/src/website/src/_data/videos.json +++ b/src/website/src/_data/videos.json @@ -248,6 +248,29 @@ } } }, + "blazor": { + "page": "/docs/blazor/", + "encodings": ["mp4", "webm"], + "width": 1920, + "height": 1200, + "duration": "PT2M38S", + "uploadDate": "2026-10-20T14:13:00+11:00", + "captions": ["en", "ja", "zh"], + "text": { + "en": { + "label": "Blazor and Razor in VS Code with SharpLsp: C# hover, completion, diagnostics, go to definition, references and rename in a .razor component, and HTML from VS Code's HTML service", + "caption": "Narrated, with original music and captions. Recorded in VS Code on a Blazor Web App: Roslyn hovers in the markup, completion inside a Razor expression, HTML completion and hover, a C# error on its Razor line, F12 into the @code block, references and rename, F12 from Program.cs into App.razor, then linked tag editing and folding." + }, + "zh": { + "label": "在 VS Code 中用 SharpLsp 编辑 Blazor 和 Razor:在 .razor 组件中进行 C# 悬停、补全、诊断、转到定义、查找引用和重命名,HTML 由 VS Code 的 HTML 服务提供", + "caption": "带旁白、原创配乐和字幕。在 VS Code 中基于一个 Blazor Web App 录制:标记中的 Roslyn 悬停、Razor 表达式中的补全、HTML 补全与悬停、显示在对应 Razor 行上的 C# 错误、按 F12 跳进 @code 代码块、查找引用与重命名、从 Program.cs 按 F12 打开 App.razor,最后是链接标签编辑和折叠。" + }, + "ja": { + "label": "SharpLsp による VS Code の Blazor と Razor:.razor コンポーネント内の C# のホバー、補完、診断、定義へ移動、参照、名前の変更と、VS Code の HTML サービスによる HTML", + "caption": "ナレーション、オリジナル音楽、字幕付き。VS Code で Blazor Web App を使って収録:マークアップでの Roslyn のホバー、Razor の式の中での補完、HTML の補完とホバー、原因の Razor の行に表示される C# のエラー、F12 による @code ブロックへの移動、参照と名前の変更、Program.cs から App.razor への F12、そしてタグのリンクされた編集と折りたたみ。" + } + } + }, "code-completions": { "page": "/docs/completions/", "encodings": ["mp4", "webm"], diff --git a/src/website/src/assets/images/blog/blazor-razor-vs-code.png b/src/website/src/assets/images/blog/blazor-razor-vs-code.png new file mode 100644 index 00000000..6e0c8513 Binary files /dev/null and b/src/website/src/assets/images/blog/blazor-razor-vs-code.png differ diff --git a/src/website/src/assets/js/custom.js b/src/website/src/assets/js/custom.js index 387d4b7e..c6208ed3 100644 --- a/src/website/src/assets/js/custom.js +++ b/src/website/src/assets/js/custom.js @@ -14,7 +14,7 @@ document.addEventListener("DOMContentLoaded", () => { function initSeekToTime() { const start = Number(new URLSearchParams(window.location.search).get("t")); const video = document.querySelector(".prose video"); - if (!video || !(start > 0)) return; + if (!video || !(Number.isFinite(start) && start > 0)) return; const seek = () => { video.currentTime = start; }; if (video.readyState >= HTMLMediaElement.HAVE_METADATA) seek(); else video.addEventListener("loadedmetadata", seek, { once: true }); diff --git a/src/website/src/assets/screenshots/docs/blazor.webp b/src/website/src/assets/screenshots/docs/blazor.webp new file mode 100644 index 00000000..64b47a3a Binary files /dev/null and b/src/website/src/assets/screenshots/docs/blazor.webp differ diff --git a/src/website/src/assets/videos/blazor-card.jpg b/src/website/src/assets/videos/blazor-card.jpg new file mode 100644 index 00000000..c862d86a Binary files /dev/null and b/src/website/src/assets/videos/blazor-card.jpg differ diff --git a/src/website/src/assets/videos/blazor-poster.webp b/src/website/src/assets/videos/blazor-poster.webp new file mode 100644 index 00000000..899f3dce Binary files /dev/null and b/src/website/src/assets/videos/blazor-poster.webp differ diff --git a/src/website/src/assets/videos/blazor.en.vtt b/src/website/src/assets/videos/blazor.en.vtt new file mode 100644 index 00000000..1e4af6e3 --- /dev/null +++ b/src/website/src/assets/videos/blazor.en.vtt @@ -0,0 +1,26 @@ +WEBVTT + +00:00.000 --> 00:11.039 +[Gentle music] +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. + +00:16.617 --> 00:25.704 +Completion works inside Razor expressions too. The members come from Roslyn, and each edit lands in the Razor file, never in generated code. + +00:37.150 --> 00:46.068 +The markup around it is still HTML. Element names and HTML hovers come from VS Code's own HTML service. + +01:00.150 --> 01:06.498 +Make a mistake, and the error shows on the Razor line that caused it, in the editor and in the Problems panel. + +01:24.983 --> 01:34.729 +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. + +01:43.350 --> 01:47.468 +Rename a field in the code block, and every use in the markup follows. + +02:00.283 --> 02:06.736 +From C#, Go to Definition on a component opens its Razor file, not a generated class. + +02:19.350 --> 02:25.148 +Rename a tag, and its closing tag changes with it. Elements and code blocks fold, too. diff --git a/src/website/src/assets/videos/blazor.ja.vtt b/src/website/src/assets/videos/blazor.ja.vtt new file mode 100644 index 00000000..9948532a --- /dev/null +++ b/src/website/src/assets/videos/blazor.ja.vtt @@ -0,0 +1,26 @@ +WEBVTT + +00:00.000 --> 00:11.039 +[穏やかな音楽] +Blazor のコンポーネントにも、ほかのコードと同じ C# エンジンが働きます。マークアップのどの式にホバーしても、Roslyn が XML ドキュメント付きで答えます。 + +00:16.617 --> 00:25.704 +補完は Razor の式の中でも動きます。メンバーは Roslyn から届き、編集はすべて、生成されたコードではなく Razor ファイルに反映されます。 + +00:37.150 --> 00:46.068 +その周りのマークアップは HTML のままです。要素名と HTML のホバーは、VS Code 自身の HTML サービスが提供します。 + +01:00.150 --> 01:06.498 +間違えると、エラーは原因となった Razor の行に表示されます。エディターでも、問題パネルでも同じです。 + +01:24.983 --> 01:34.729 +定義へ移動すると、マークアップからコードブロックへジャンプします。「すべての参照を検索」は、Razor ファイル内の使用箇所をすべて一覧にし、生成された足場のコードは含めません。 + +01:43.350 --> 01:47.468 +コードブロックのフィールド名を変更すると、マークアップ内の使用箇所もすべて追従します。 + +02:00.283 --> 02:06.736 +C# からコンポーネントの定義へ移動すると、生成されたクラスではなく、その Razor ファイルが開きます。 + +02:19.350 --> 02:25.148 +タグ名を変えると、閉じタグも一緒に変わります。要素とコードブロックは折りたたむこともできます。 diff --git a/src/website/src/assets/videos/blazor.mp4 b/src/website/src/assets/videos/blazor.mp4 new file mode 100644 index 00000000..a4028c36 --- /dev/null +++ b/src/website/src/assets/videos/blazor.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cf50d70b13336bd58f650de4bc96c6dbc6f1fd5f7c52578874e1c166f2441fcd +size 6187192 diff --git a/src/website/src/assets/videos/blazor.webm b/src/website/src/assets/videos/blazor.webm new file mode 100644 index 00000000..9a86030c --- /dev/null +++ b/src/website/src/assets/videos/blazor.webm @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b145b9bfbf82d5196b4a3d1aab728f79005eb42d4dbe75c069bafa56b68ae45 +size 6844296 diff --git a/src/website/src/assets/videos/blazor.zh.vtt b/src/website/src/assets/videos/blazor.zh.vtt new file mode 100644 index 00000000..6958f844 --- /dev/null +++ b/src/website/src/assets/videos/blazor.zh.vtt @@ -0,0 +1,26 @@ +WEBVTT + +00:00.000 --> 00:11.039 +[舒缓的音乐] +Blazor 组件和你的其他代码使用同一个 C# 引擎。在标记中悬停任意表达式,Roslyn 就会显示它的信息,并附上你的 XML 文档。 + +00:16.617 --> 00:25.704 +补全在 Razor 表达式中同样可用。成员来自 Roslyn,每次编辑都落在 Razor 文件里,绝不会写进生成的代码。 + +00:37.150 --> 00:46.068 +周围的标记依然是 HTML。元素名称和 HTML 悬停信息来自 VS Code 自带的 HTML 服务。 + +01:00.150 --> 01:06.498 +一旦写错,错误就会显示在引起它的那一行 Razor 代码上,编辑器和问题面板里都能看到。 + +01:24.983 --> 01:34.729 +转到定义会从标记跳进代码块。查找所有引用会列出 Razor 文件中的每一处用法,生成的脚手架代码不会混进来。 + +01:43.350 --> 01:47.468 +在代码块里重命名一个字段,标记中的每一处用法都会随之更新。 + +02:00.283 --> 02:06.736 +在 C# 中对组件转到定义,打开的是它的 Razor 文件,而不是生成的类。 + +02:19.350 --> 02:25.148 +修改一个标签的名称,它的结束标签也会一起改变。元素和代码块同样可以折叠。 diff --git a/src/website/src/blog/blazor-razor-vs-code.md b/src/website/src/blog/blazor-razor-vs-code.md new file mode 100644 index 00000000..d928b403 --- /dev/null +++ b/src/website/src/blog/blazor-razor-vs-code.md @@ -0,0 +1,109 @@ +--- +layout: layouts/blog.njk +title: "Blazor and Razor in VS Code: C# Tooling for .razor Files" +description: "SharpLsp now edits Blazor components, Razor Pages and MVC views in VS Code: C# hover, completion, diagnostics, go to definition and rename in .razor and .cshtml files." +date: 2026-10-20 +author: SharpLsp Team +image: /assets/images/blog/blazor-razor-vs-code.png +imageAlt: "A polished metal @ sculpture glowing with amber fire above layered glass, edged in cyan light" +tags: + - posts + - blazor + - razor + - csharp + - dotnet-lsp +category: announcement +excerpt: "Blazor components, Razor Pages and MVC views now get the same Roslyn engine as your C# files: hover, completion, diagnostics, go to definition, references and rename, inside the markup." +--- + +SharpLsp now understands Razor. Blazor components (`.razor` files), and Razor Pages and MVC views (`.cshtml` files), get the same Roslyn engine as the rest of your C#. You get hover, completion, diagnostics, go to definition, references and rename, inside the markup. It ships in SharpLsp 0.30. + +Here it is on a small Blazor Web App: + +{% video "blazor" %} + +## What is Blazor? Blazor vs Razor in one minute + +The two names describe different layers, which is why they are easy to mix up. + +- **Razor** is the syntax. It mixes HTML with C#: an `@` expression, an `@if` or `@foreach` block, a `@code` block. Microsoft's [Razor syntax reference](https://learn.microsoft.com/en-us/aspnet/core/mvc/views/razor?view=aspnetcore-10.0) covers all of it. +- **ASP.NET Core Razor Pages and MVC views** are `.cshtml` files that the server renders for each request. Read [Razor Pages in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/razor-pages/?view=aspnetcore-10.0) and [Views in ASP.NET Core MVC](https://learn.microsoft.com/en-us/aspnet/core/mvc/views/overview?view=aspnetcore-10.0). +- **Blazor** is the component framework. A [Razor component](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/?view=aspnetcore-10.0) is a `.razor` file. A Blazor Web App, the `dotnet new blazor` template since .NET 8, runs components on the server or in the browser on WebAssembly. Each component picks its [render mode](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/render-modes?view=aspnetcore-10.0): static server rendering, `InteractiveServer`, `InteractiveWebAssembly` or `InteractiveAuto`. + +Under all three sits one Razor compiler, which turns every Razor file into C#. That is why one implementation in SharpLsp serves Blazor Server, Blazor WebAssembly, Razor Pages and MVC alike. + +## What you get in a .razor file + +Everything below is shown on the `Basket.razor` page of the demo in the video, and each item is covered by an automated test. + +- **Hover.** Hover `@Total()` and Roslyn shows the signature of `Basket.Total()` with its XML summary. Hover `@Body` in a layout and you see the `LayoutComponentBase.Body` property it renders. +- **Completion.** Roslyn's member completion inside `@` expressions and `@code` blocks. Every edit lands in the `.razor` file, and a `using` that completion adds becomes an `@using` line. +- **Diagnostics.** A C# mistake is reported on the Razor line that caused it, with its compiler code, in the editor and in the Problems panel. The solution-wide scan includes Razor files. +- **Go to definition.** F12 on `Add` in `@onclick="() => Add(product)"` jumps into the `@code` block. F12 on `Catalog.Products` opens `Catalog.cs`. From C#, F12 on `App` in `app.MapRazorComponents()` opens `App.razor`, not a generated class. +- **References and highlights.** Every use in your Razor files, with the compiler's generated scaffolding left out. +- **Rename.** Rename a `@code` field, and every use in the markup follows. +- **Structure.** Multi-line elements and `@code` blocks fold, the outline lists directives and elements, and renaming a start tag renames its end tag. +- **Signature help** inside calls, and **semantic highlighting** for the C# in the file. + +The markup around the C# is still HTML. In VS Code, SharpLsp forwards element and attribute completion and HTML hovers to VS Code's own HTML language service. It sends an HTML view of the file in which every C# fragment is blanked, so the HTML service never mistakes C# for markup. + +## How it works: the Razor compiler is the source of truth + +The tempting way to build Razor tooling is to write a Razor parser. SharpLsp doesn't. Razor's semantics belong to the Razor compiler, and any second parser would drift from it with every release. + +Since .NET 6, [the Razor compiler has been a C# source generator](https://learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-6.0?view=aspnetcore-10.0#razor-compiler-updated-to-use-source-generators). It ships in the .NET SDK, and the C# sidecar's design-time build runs it exactly as `dotnet build` does. The generator turns each Razor file into a C# class. Every C# fragment is copied into that class verbatim, inside an [enhanced `#line` directive](https://github.com/dotnet/csharplang/blob/main/proposals/csharp-10.0/enhanced-line-directives.md) that points back at the Razor text. + +SharpLsp builds a two-way map from those directives: + +1. A request at a Razor position maps to the same character in the generated C#. +2. Roslyn answers there, exactly as it would for a `.cs` file. +3. Every location in the answer maps back to the Razor file. + +The map never trusts a directive blindly. Roslyn reports each mapped span's end one character past the fragment, so SharpLsp measures every fragment by comparing the Razor text with the generated text, character by character. A position in code the generator wrote by itself, such as the `__builder` calls that render the markup, has no Razor text and never reaches you. + +Because the generator is the compiler's, the C# inside every construct it maps gets the same treatment. That includes `@bind` values, event handlers, generic components and `@code` blocks. The markup side, which drives folding, the outline, linked editing and the HTML view, comes from the compiler's public [intermediate representation](https://github.com/dotnet/razor), not from a reimplementation. + +## Found while building it + +Running the real host over a fresh `dotnet new blazor` app found one false report: every `@using` in `_Imports.razor` was faded as unnecessary. Roslyn only sees each file's own generated C#, so it can't see that an import file's usings serve the components beside and below it. A component's own usings had the same problem when only its markup needed them. Razor files no longer report unnecessary usings, and C# files, code-behind included, still do. + +The second finding is about SDK versions. The Razor generator comes from the SDK that builds your project, which is the newest one installed unless a `global.json` pins another. SDK 10.0.401's generator needs a newer Roslyn than the one SharpLsp bundles, so at first its Razor files got no C# features ([issue #399](https://github.com/Nimblesite/SharpLsp/issues/399)). SharpLsp now loads the Razor compiler it bundles in that generator's place, and logs a warning that names both Roslyn versions, so a newer SDK works without a pin. The [Blazor and Razor docs](/docs/blazor/) have the details. + +## Blazor on .NET 10: Server, WebAssembly and Razor class libraries + +SharpLsp reads the projects your solution already has. They all load the same way, through `Microsoft.NET.Sdk.Web`, `Microsoft.NET.Sdk.BlazorWebAssembly` or `Microsoft.NET.Sdk.Razor`: + +- a Blazor Web App, with Blazor Server or Blazor WebAssembly interactivity; +- a standalone Blazor WebAssembly app; +- a Razor Pages or MVC app; +- a [Razor class library](https://learn.microsoft.com/en-us/aspnet/core/razor-pages/ui-class?view=aspnetcore-10.0) of shared components. + +ASP.NET Core 10 on the [.NET 10](https://devblogs.microsoft.com/dotnet/announcing-dotnet-10/) SDK is verified, including a fresh `dotnet new blazor` app. Projects on net8.0 and net9.0 compile Razor through the same source generator, so they should work, but we haven't verified them yet. Projects older than .NET 6 compile Razor without a source generator and aren't supported, and neither are .NET Framework's MVC 5 and Web Forms. + +## What's next + +This release covers the C# in Razor files. The markup itself is next, tracked in [issue #397](https://github.com/Nimblesite/SharpLsp/issues/397): + +- hover, definition, completion and references on component tags, component parameters, `@bind-Value` and directive attributes such as `@onclick`; +- the compiler's own `RZ` diagnostics, such as an unknown component; +- tag helpers in Razor Pages and MVC views, such as `asp-for`; +- F# libraries behind a Blazor front end: hover, completion and definition on F# types from Razor, and F# renames that reach Razor usages; +- formatting, code actions such as extracting a component, and Hot Reload. + +## Try it + +1. Install SharpLsp from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=nimblesite.sharplsp), or from [Open VSX](https://open-vsx.org/extension/nimblesite/sharplsp) in Cursor, Windsurf and VSCodium. Razor support needs version 0.30 or later. +2. Create a Blazor Web App with `dotnet new blazor -o BlazorDemo`, and open the folder. +3. Open `Components/Pages/Counter.razor` and hover `currentCount`. + +The [Blazor and Razor docs](/docs/blazor/) list every feature, the supported projects and what is not supported yet. + +## Further reading + +- [ASP.NET Core Blazor](https://learn.microsoft.com/en-us/aspnet/core/blazor/?view=aspnetcore-10.0) and [Blazor hosting models](https://learn.microsoft.com/en-us/aspnet/core/blazor/hosting-models?view=aspnetcore-10.0) +- [Blazor project structure](https://learn.microsoft.com/en-us/aspnet/core/blazor/project-structure?view=aspnetcore-10.0) and [Blazor render modes](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/render-modes?view=aspnetcore-10.0) +- [Blazor data binding](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/data-binding?view=aspnetcore-10.0) and [event handling](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/event-handling?view=aspnetcore-10.0) +- [What's new in ASP.NET Core in .NET 10](https://learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-10.0?view=aspnetcore-10.0) +- [Tag Helpers in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/mvc/views/tag-helpers/intro?view=aspnetcore-10.0) +- [The dotnet/razor repository](https://github.com/dotnet/razor) and [the dotnet/aspnetcore repository](https://github.com/dotnet/aspnetcore) +- [Embedded Programming Languages](https://code.visualstudio.com/api/language-extensions/embedded-languages), the VS Code guide to request forwarding diff --git a/src/website/src/docs/blazor.md b/src/website/src/docs/blazor.md new file mode 100644 index 00000000..a0d61e8d --- /dev/null +++ b/src/website/src/docs/blazor.md @@ -0,0 +1,113 @@ +--- +layout: layouts/docs.njk +title: Blazor and Razor +description: "Blazor and Razor in VS Code with SharpLsp: C# hover, completion, diagnostics, go to definition, references and rename in .razor components and .cshtml pages." +--- + +A C# hover inside a Blazor component's markup in VS Code: the Total method's signature and XML documentation, on its line in Basket.razor + +# Blazor and Razor + +SharpLsp edits Razor files in VS Code with the same Roslyn engine it uses for C#. That covers [Blazor](https://learn.microsoft.com/en-us/aspnet/core/blazor/?view=aspnetcore-10.0) components (`.razor`), and ASP.NET Core [Razor Pages](https://learn.microsoft.com/en-us/aspnet/core/razor-pages/?view=aspnetcore-10.0) and [MVC views](https://learn.microsoft.com/en-us/aspnet/core/mvc/views/overview?view=aspnetcore-10.0) (`.cshtml`). + +The C# inside the markup gets hover, completion, diagnostics, navigation and rename: `@` expressions, `@code` blocks, event handlers and `@bind` values. The HTML around it gets your editor's HTML support. + +{% video "blazor" %} + +## What Works in a Razor File + +| Feature | What you get | +|---|---| +| Hover | Roslyn's hover for the C# under the cursor, with its signature and XML documentation, ranged on the Razor text | +| Completion | Roslyn completion inside `@` expressions and `@code` blocks. Every edit lands in the Razor file, and a `using` that completion adds becomes an `@using` line | +| Signature help | Parameter help inside a call | +| Diagnostics | C# errors and warnings on the Razor line that caused them, and Razor files in the solution-wide scan | +| Go to definition | From markup to the declaration, such as a method in the `@code` block. From C#, a component's definition is its `.razor` file: `MapRazorComponents()` goes to `App.razor` | +| References and highlights | Every use in your Razor files. The compiler's hidden scaffolding is left out | +| Rename | A field or member of the `@code` block, renamed at every use in the markup. Rename refuses on plain markup | +| Semantic highlighting | Roslyn's classification for the C# in the file. The editor's Razor grammar colors the markup | +| Folding and outline | Multi-line elements and `@code` blocks fold. The outline lists directives and elements, with the members of each `@code` block nested under it | +| Linked editing | In a component, a start tag and its end tag rename together | + +Results never point into generated code. A position in a file's generated C# always maps back to its Razor file. + +Razor files never report a `using` as unnecessary. Markup resolves component tags through usings that the generated C# never needs, and the usings in [`_Imports.razor`](https://learn.microsoft.com/en-us/aspnet/core/blazor/tutorials/movie-database-app/part-1?view=aspnetcore-10.0#components_importsrazor-file) and `_ViewImports.cshtml` serve the files beside and below them. C# files, including a component's `.razor.cs` code-behind, still report them. + +## HTML in the Markup + +- **VS Code:** element and attribute completion, and HTML hovers, come from VS Code's built-in HTML language service. SharpLsp forwards the request with an HTML view of the file, the pattern VS Code's [Embedded Programming Languages](https://code.visualstudio.com/api/language-extensions/embedded-languages) guide describes. Its own items come first. +- **Zed:** Zed has no built-in Razor language. Install a Razor extension from Zed's extension registry, and SharpLsp attaches to the `Razor` language it defines. +- **Other LSP clients:** the language id is `razor`, for `.razor` and `.cshtml` files. Pair SharpLsp with an HTML language server for the markup. + +## How It Works + +SharpLsp never reimplements Razor. Since .NET 6, the [Razor compiler runs as a Roslyn source generator](https://learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-6.0?view=aspnetcore-10.0#razor-compiler-updated-to-use-source-generators), and the C# sidecar's design-time build runs it exactly as `dotnet build` does. + +The generator turns each Razor file into C#. It copies every C# fragment into the generated file verbatim, and wraps it in an [enhanced `#line` directive](https://github.com/dotnet/csharplang/blob/main/proposals/csharp-10.0/enhanced-line-directives.md) that points back at the Razor text. + +SharpLsp builds a two-way map from those directives, checking each fragment's text on both sides: + +1. A request at a Razor position maps to the generated C#. +2. Roslyn answers there, exactly as it would for a `.cs` file. +3. Each answer maps back to the Razor file. + +Positions in the compiler's hidden scaffolding have no Razor text, so they never reach you. + +```mermaid +flowchart LR + razor["Basket.razor"] -- "Razor source generator" --> generated["Generated C#
with #line mappings"] + generated --> roslyn["Roslyn: hover, completion,
references, rename"] + roslyn -- "mapped back" --> razor +``` + +The markup comes from the compiler's public [intermediate representation](https://github.com/dotnet/razor). It drives folding, the outline and linked editing, and it builds the HTML view that VS Code's HTML service reads. + +## Supported Projects + +| File | What it is | +|---|---| +| `*.razor` | A [Razor component](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/?view=aspnetcore-10.0) | +| `_Imports.razor` | Directives for every component in its folder and below | +| `*.cshtml` | A Razor Page (`@page`), MVC view, partial view or layout | +| `_ViewImports.cshtml` | Directives for every page and view in its folder and below | +| `_ViewStart.cshtml` | Code that runs before each view | + +- **Apps:** a Blazor Web App (`dotnet new blazor`) with Blazor Server or Blazor WebAssembly [render modes](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/render-modes?view=aspnetcore-10.0), a standalone Blazor WebAssembly app, a Razor Pages or MVC app, and a [Razor class library](https://learn.microsoft.com/en-us/aspnet/core/razor-pages/ui-class?view=aspnetcore-10.0) of shared components. +- **Project SDKs:** `Microsoft.NET.Sdk.Web`, `Microsoft.NET.Sdk.BlazorWebAssembly` and `Microsoft.NET.Sdk.Razor`. +- **Verified:** ASP.NET Core 10 on the [.NET 10](https://devblogs.microsoft.com/dotnet/announcing-dotnet-10/) SDK, including a fresh `dotnet new blazor` app. +- **net8.0 and net9.0:** these projects compile Razor through the same source generator, so they should work, but they are not verified yet. +- **Not supported:** projects older than .NET 6, which compile Razor without a source generator, and ASP.NET MVC 5, Web Pages and Web Forms on .NET Framework. + +## SDK Versions + +The Razor generator comes from the SDK that builds your project. When no `global.json` pins one, that is the newest SDK you have installed. + +Roslyn only loads a generator built for its own version or an older one, and a newer SDK's generator can need a newer Roslyn than the one SharpLsp bundles. SDK 10.0.401's does. SharpLsp then loads the Razor compiler it bundles in that generator's place, so the project's Razor files keep their C# features, and it logs a warning that names both Roslyn versions ([issue #399](https://github.com/Nimblesite/SharpLsp/issues/399)). Your own builds still use the SDK's generator. + +## Not Yet Supported + +Progress is tracked in [issue #397](https://github.com/Nimblesite/SharpLsp/issues/397). + +- **Component markup:** hover, definition, completion, references and semantic colors on component tags, component parameters, `@bind-X`, and directive attributes such as `@onclick`. +- **Razor diagnostics:** the compiler's own `RZ` diagnostics, such as an unknown component. +- **Tag helpers:** hover and completion for [tag helper](https://learn.microsoft.com/en-us/aspnet/core/mvc/views/tag-helpers/intro?view=aspnetcore-10.0) elements and attributes such as `asp-for`. +- **F#:** Razor's code language is C#, because the Razor compiler generates only C#, but a Blazor app can reference F# libraries. Hover, completion and definition on F# types used from Razor, and F# renames that reach Razor usages, are planned. +- **Editing:** formatting, selection ranges, code actions, inlay hints, code lens, and call and type hierarchy from Razor positions. +- **Collocated files:** awareness of [CSS isolation](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/css-isolation?view=aspnetcore-10.0) (`.razor.css`) and collocated `.razor.js` files. +- **Runtime:** Blazor WebAssembly debugging and Hot Reload. + +## Failure Behavior + +- **No projection:** if a project does not run the Razor generator, its Razor files have no C# features. Their markup features still answer. +- **Engine failure:** if the Razor engine fails to load or to process a file, markup features answer empty and C# features keep working. SharpLsp logs the failure once per project, with the project name and the exception type. +- **Nothing at the position:** a position that is neither C# nor a markup element answers nothing, never an error. +- **Privacy:** SharpLsp never logs Razor or generated text, only paths, spans and lengths. + +## Microsoft Documentation + +- [ASP.NET Core Blazor](https://learn.microsoft.com/en-us/aspnet/core/blazor/?view=aspnetcore-10.0) and [Blazor project structure](https://learn.microsoft.com/en-us/aspnet/core/blazor/project-structure?view=aspnetcore-10.0) +- [Razor syntax reference for ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/mvc/views/razor?view=aspnetcore-10.0) +- [Blazor render modes](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/render-modes?view=aspnetcore-10.0), [data binding](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/data-binding?view=aspnetcore-10.0) and [event handling](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/event-handling?view=aspnetcore-10.0) +- [Generic components](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/generic-type-support?view=aspnetcore-10.0) and [cascading values and parameters](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/cascading-values-and-parameters?view=aspnetcore-10.0) +- [What's new in ASP.NET Core in .NET 10](https://learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-10.0?view=aspnetcore-10.0) +- [The Roslyn APIs and source generators](https://learn.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/#source-generators) diff --git a/src/website/src/ja/blog/blazor-razor-vs-code.md b/src/website/src/ja/blog/blazor-razor-vs-code.md new file mode 100644 index 00000000..c61e878e --- /dev/null +++ b/src/website/src/ja/blog/blazor-razor-vs-code.md @@ -0,0 +1,110 @@ +--- +layout: layouts/blog.njk +title: "VS Code での Blazor と Razor:.razor ファイルのための C# ツール" +description: "SharpLsp は VS Code で Blazor コンポーネント、Razor Pages、MVC ビューを編集できるようになりました:.razor ファイルと .cshtml ファイルでの C# のホバー、補完、診断、定義へ移動、名前の変更。" +lang: ja +date: 2026-10-20 +author: SharpLsp チーム +image: /assets/images/blog/blazor-razor-vs-code.png +imageAlt: "シアンの光に縁取られたガラスの層の上で、琥珀色の炎をまとって輝く金属製の @ の彫刻" +tags: + - posts + - blazor + - razor + - csharp + - dotnet-lsp +category: announcement +excerpt: "Blazor コンポーネント、Razor Pages、MVC ビューが、C# ファイルと同じ Roslyn エンジンを使えるようになりました:マークアップの中で、ホバー、補完、診断、定義へ移動、参照、名前の変更が使えます。" +--- + +SharpLsp は Razor を理解するようになりました。Blazor コンポーネント(`.razor` ファイル)、Razor Pages、MVC ビュー(`.cshtml` ファイル)が、残りの C# と同じ Roslyn エンジンを使えるようになりました。マークアップの中で、ホバー、補完、診断、定義へ移動、参照、名前の変更が使えます。このサポートは SharpLsp 0.30 で提供されます。 + +小さな Blazor Web App で見てみましょう。 + +{% video "blazor" %} + +## Blazor とは?Blazor と Razor を 1 分で + +2 つの名前は別の層を指しているため、混同しやすいのです。 + +- **Razor** は構文です。HTML と C# を混ぜ合わせます。`@` 式、`@if` や `@foreach` のブロック、`@code` ブロックなどです。Microsoft の [Razor 構文リファレンス](https://learn.microsoft.com/ja-jp/aspnet/core/mvc/views/razor?view=aspnetcore-10.0)がそのすべてを説明しています。 +- **ASP.NET Core の Razor Pages と MVC ビュー**は、リクエストごとにサーバーがレンダリングする `.cshtml` ファイルです。[ASP.NET Core の Razor Pages](https://learn.microsoft.com/ja-jp/aspnet/core/razor-pages/?view=aspnetcore-10.0)と [ASP.NET Core MVC のビュー](https://learn.microsoft.com/ja-jp/aspnet/core/mvc/views/overview?view=aspnetcore-10.0)をご覧ください。 +- **Blazor** はコンポーネントフレームワークです。[Razor コンポーネント](https://learn.microsoft.com/ja-jp/aspnet/core/blazor/components/?view=aspnetcore-10.0)は `.razor` ファイルです。Blazor Web App は .NET 8 以降の `dotnet new blazor` テンプレートで、コンポーネントをサーバー上またはブラウザー内の WebAssembly で実行します。各コンポーネントは[レンダーモード](https://learn.microsoft.com/ja-jp/aspnet/core/blazor/components/render-modes?view=aspnetcore-10.0)を選びます。静的なサーバーレンダリング、`InteractiveServer`、`InteractiveWebAssembly`、`InteractiveAuto` です。 + +この 3 つすべての下に、すべての Razor ファイルを C# に変換する 1 つの Razor コンパイラーがあります。だからこそ、SharpLsp の 1 つの実装が Blazor Server、Blazor WebAssembly、Razor Pages、MVC のすべてに対応します。 + +## .razor ファイルで得られるもの + +以下はすべて、動画のデモの `Basket.razor` ページで示したものであり、それぞれが自動テストでカバーされています。 + +- **ホバー。** `@Total()` にホバーすると、Roslyn が `Basket.Total()` のシグネチャーと XML サマリーを表示します。レイアウトの `@Body` にホバーすると、それがレンダリングする `LayoutComponentBase.Body` プロパティが表示されます。 +- **補完。** `@` 式と `@code` ブロックの中で、Roslyn によるメンバーの補完が働きます。編集はすべて `.razor` ファイルに反映され、補完が追加する `using` は `@using` 行になります。 +- **診断。** C# の誤りは、その原因となった Razor の行に、コンパイラーのコードとともに、エディターと問題パネルに報告されます。ソリューション全体のスキャンには Razor ファイルも含まれます。 +- **定義へ移動。** `@onclick="() => Add(product)"` の `Add` で F12 を押すと、`@code` ブロックに移動します。`Catalog.Products` で F12 を押すと `Catalog.cs` が開きます。C# 側から、`app.MapRazorComponents()` の `App` で F12 を押すと、生成されたクラスではなく `App.razor` が開きます。 +- **参照とハイライト。** Razor ファイル内のすべての使用箇所を表示し、コンパイラーが生成した隠れた足場のコードは含みません。 +- **名前の変更。** `@code` のフィールドの名前を変更すると、マークアップ内のすべての使用箇所が追従します。 +- **構造。** 複数行の要素と `@code` ブロックが折りたためるほか、アウトラインがディレクティブと要素を一覧表示し、開始タグの名前を変更すると終了タグも変わります。 +- 呼び出しの中の**シグネチャヘルプ**と、ファイル内の C# に対する**セマンティックハイライト**。 + +C# を囲むマークアップは、やはり HTML です。VS Code では、SharpLsp が要素と属性の補完、および HTML のホバーを VS Code 自身の HTML 言語サービスへ転送します。C# の断片をすべて空白にした HTML ビューを送るため、HTML サービスが C# をマークアップと取り違えることはありません。 + +## 仕組み:Razor コンパイラーこそが信頼できる唯一の情報源 + +Razor ツールを作るとき、つい Razor パーサーを書いてしまいがちです。SharpLsp はそうしません。Razor のセマンティクスは Razor コンパイラーに属しており、2 つ目のパーサーはリリースのたびにそれからずれていきます。 + +.NET 6 以降、[Razor コンパイラーは C# のソースジェネレーターです](https://learn.microsoft.com/ja-jp/aspnet/core/release-notes/aspnetcore-6.0?view=aspnetcore-10.0#razor-compiler-updated-to-use-source-generators)。それは .NET SDK に同梱され、C# サイドカーのデザインタイムビルドが `dotnet build` とまったく同じように実行します。ジェネレーターは各 Razor ファイルを C# クラスに変換します。C# の断片はすべて、Razor のテキストを指し示す[拡張された `#line` ディレクティブ](https://github.com/dotnet/csharplang/blob/main/proposals/csharp-10.0/enhanced-line-directives.md)の中に、そのままそのクラスへコピーされます。 + +SharpLsp はこれらのディレクティブから双方向のマップを作ります。 + +1. Razor の位置へのリクエストは、生成された C# の同じ文字にマップされます。 +2. Roslyn がそこで応答します。`.cs` ファイルの場合とまったく同じです。 +3. 応答内のすべての位置が、Razor ファイルへ逆マップされます。 + +マップはディレクティブを鵜呑みにはしません。Roslyn はマップされた各スパンの終端を、断片の 1 文字先として報告するため、SharpLsp は Razor のテキストと生成されたテキストを 1 文字ずつ比較して、すべての断片を測ります。マークアップをレンダリングする `__builder` 呼び出しのように、ジェネレーター自身が書いたコード内の位置には Razor のテキストがなく、利用者に届くことはありません。 + +ジェネレーターがコンパイラー自身のものであるため、それがマップするあらゆる構文の中の C# が同じ扱いを受けます。`@bind` の値、イベントハンドラー、ジェネリックコンポーネント、`@code` ブロックも含まれます。折りたたみ、アウトライン、リンクされた編集、HTML ビューを駆動するマークアップ側は、再実装ではなく、コンパイラーの公開された[中間表現](https://github.com/dotnet/razor)から来ています。 + +## 作りながら見つけたこと + +新しい `dotnet new blazor` アプリで実際のホストを動かすと、1 つの誤報告が見つかりました。`_Imports.razor` のすべての `@using` が、不要なものとして淡色表示されていたのです。Roslyn は各ファイル自身の生成された C# しか見ないため、インポートファイルの using が、その隣や下にあるコンポーネントに役立っていることを知りません。コンポーネント自身の using も、マークアップだけがそれを必要とする場合には同じ問題を抱えていました。現在、Razor ファイルは不要な using を報告せず、C# ファイルはコードビハインドを含めて引き続き報告します。 + +2 つ目の発見は SDK のバージョンについてです。Razor ジェネレーターはプロジェクトをビルドする SDK に由来し、`global.json` で別のバージョンを固定していない限り、インストールされている最新のものが使われます。SDK 10.0.401 のジェネレーターは SharpLsp が同梱する Roslyn より新しいバージョンを必要とするため、当初はその Razor ファイルで C# の機能が使えませんでした([issue #399](https://github.com/Nimblesite/SharpLsp/issues/399))。現在の SharpLsp は、そのジェネレーターの代わりに同梱の Razor コンパイラーを読み込み、両方の Roslyn バージョンを示す警告をログに記録します。そのため、新しい SDK でもバージョンを固定する必要はありません。詳しくは [Blazor と Razor のドキュメント](/ja/docs/blazor/)をご覧ください。 + +## .NET 10 の Blazor:Server、WebAssembly、Razor クラスライブラリ + +SharpLsp は、ソリューションに既にあるプロジェクトを読み取ります。どれも `Microsoft.NET.Sdk.Web`、`Microsoft.NET.Sdk.BlazorWebAssembly`、`Microsoft.NET.Sdk.Razor` を通じて同じように読み込まれます。 + +- Blazor Server または Blazor WebAssembly の対話性を持つ Blazor Web App +- スタンドアロンの Blazor WebAssembly アプリ +- Razor Pages または MVC のアプリ +- 共有コンポーネントの [Razor クラスライブラリ](https://learn.microsoft.com/ja-jp/aspnet/core/razor-pages/ui-class?view=aspnetcore-10.0) + +[.NET 10](https://devblogs.microsoft.com/dotnet/announcing-dotnet-10/) SDK 上の ASP.NET Core 10 は検証済みで、新しい `dotnet new blazor` アプリも含まれます。net8.0 と net9.0 のプロジェクトも同じソースジェネレーターで Razor をコンパイルするため動作するはずですが、まだ検証していません。.NET 6 より前のプロジェクトはソースジェネレーターなしで Razor をコンパイルするためサポート対象外であり、.NET Framework の MVC 5 と Web Forms も同様です。 + +## 今後の予定 + +このリリースは Razor ファイル内の C# を対象にしています。次はマークアップ自体で、[issue #397](https://github.com/Nimblesite/SharpLsp/issues/397) で追跡しています。 + +- コンポーネントタグ、コンポーネントパラメーター、`@bind-Value`、`@onclick` などのディレクティブ属性に対するホバー、定義へ移動、補完、参照 +- 未知のコンポーネントなど、コンパイラー自身の `RZ` 診断 +- `asp-for` など、Razor Pages と MVC ビューのタグヘルパー +- Blazor フロントエンドの背後にある F# ライブラリ:Razor からの F# の型に対するホバー、補完、定義へ移動、および Razor の使用箇所にまで届く F# の名前の変更 +- フォーマット、コンポーネントの抽出などのコードアクション、ホットリロード + +## 試してみる + +1. [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=nimblesite.sharplsp) から、または Cursor、Windsurf、VSCodium では [Open VSX](https://open-vsx.org/extension/nimblesite/sharplsp) から SharpLsp をインストールします。Razor のサポートにはバージョン 0.30 以降が必要です。 +2. `dotnet new blazor -o BlazorDemo` で Blazor Web App を作成し、そのフォルダーを開きます。 +3. `Components/Pages/Counter.razor` を開き、`currentCount` にホバーします。 + +[Blazor と Razor のドキュメント](/ja/docs/blazor/)には、すべての機能、サポートするプロジェクト、まだサポートしていない項目が掲載されています。 + +## 参考資料 + +- [ASP.NET Core Blazor](https://learn.microsoft.com/ja-jp/aspnet/core/blazor/?view=aspnetcore-10.0)と [Blazor のホスティングモデル](https://learn.microsoft.com/ja-jp/aspnet/core/blazor/hosting-models?view=aspnetcore-10.0) +- [Blazor プロジェクトの構造](https://learn.microsoft.com/ja-jp/aspnet/core/blazor/project-structure?view=aspnetcore-10.0)と [Blazor のレンダーモード](https://learn.microsoft.com/ja-jp/aspnet/core/blazor/components/render-modes?view=aspnetcore-10.0) +- [Blazor のデータバインディング](https://learn.microsoft.com/ja-jp/aspnet/core/blazor/components/data-binding?view=aspnetcore-10.0)と[イベント処理](https://learn.microsoft.com/ja-jp/aspnet/core/blazor/components/event-handling?view=aspnetcore-10.0) +- [.NET 10 の ASP.NET Core の新機能](https://learn.microsoft.com/ja-jp/aspnet/core/release-notes/aspnetcore-10.0?view=aspnetcore-10.0) +- [ASP.NET Core のタグヘルパー](https://learn.microsoft.com/ja-jp/aspnet/core/mvc/views/tag-helpers/intro?view=aspnetcore-10.0) +- [dotnet/razor リポジトリ](https://github.com/dotnet/razor)と [dotnet/aspnetcore リポジトリ](https://github.com/dotnet/aspnetcore) +- [Embedded Programming Languages](https://code.visualstudio.com/api/language-extensions/embedded-languages) — リクエスト転送についての VS Code ガイド diff --git a/src/website/src/ja/docs/blazor.md b/src/website/src/ja/docs/blazor.md new file mode 100644 index 00000000..853f6b8f --- /dev/null +++ b/src/website/src/ja/docs/blazor.md @@ -0,0 +1,114 @@ +--- +layout: layouts/docs.njk +title: Blazor と Razor +description: "VS Code での SharpLsp による Blazor と Razor:.razor コンポーネントと .cshtml ページでの C# のホバー、補完、診断、定義へ移動、参照、名前の変更。" +lang: ja +--- + +VS Code の Blazor コンポーネントのマークアップ内での C# ホバー。Basket.razor のその行に、Total メソッドのシグネチャーと XML ドキュメントを表示 + +# Blazor と Razor + +SharpLsp は、C# に使用するのと同じ Roslyn エンジンで、VS Code の Razor ファイルを編集します。これには [Blazor](https://learn.microsoft.com/ja-jp/aspnet/core/blazor/?view=aspnetcore-10.0) コンポーネント(`.razor`)と、ASP.NET Core の [Razor Pages](https://learn.microsoft.com/ja-jp/aspnet/core/razor-pages/?view=aspnetcore-10.0) および [MVC ビュー](https://learn.microsoft.com/ja-jp/aspnet/core/mvc/views/overview?view=aspnetcore-10.0)(`.cshtml`)が含まれます。 + +マークアップ内の C# では、ホバー、補完、診断、ナビゲーション、名前の変更が使えます。対象は `@` 式、`@code` ブロック、イベントハンドラー、`@bind` の値です。その周囲の HTML では、エディターの HTML サポートが使えます。 + +{% video "blazor" %} + +## Razor ファイルで動作する機能 + +| 機能 | 得られるもの | +|---|---| +| ホバー | カーソル位置の C# に対する Roslyn のホバー。シグネチャーと XML ドキュメントを含み、Razor のテキスト上の範囲に表示されます | +| 補完 | `@` 式と `@code` ブロック内での Roslyn の補完。すべての編集は Razor ファイルに反映され、補完が追加する `using` は `@using` 行になります | +| シグネチャヘルプ | 呼び出し内でのパラメーターのヘルプ | +| 診断 | 原因となった Razor の行に表示される C# のエラーと警告。ソリューション全体のスキャンにも Razor ファイルが含まれます | +| 定義へ移動 | マークアップから宣言へ。たとえば `@code` ブロック内のメソッドへ移動します。C# から見ると、コンポーネントの定義はその `.razor` ファイルです。`MapRazorComponents()` は `App.razor` へ移動します | +| 参照とハイライト | Razor ファイル内のすべての使用箇所。コンパイラーの隠れた足場のコードは除外されます | +| 名前の変更 | `@code` ブロックのフィールドまたはメンバーの名前を変更すると、マークアップ内のすべての使用箇所も変わります。C# を含まないマークアップでは名前の変更を拒否します | +| セマンティックハイライト | ファイル内の C# に対する Roslyn の分類。マークアップはエディターの Razor 文法が色分けします | +| 折りたたみとアウトライン | 複数行の要素と `@code` ブロックが折りたたまれます。アウトラインにはディレクティブと要素が並び、各 `@code` ブロックのメンバーはその下に入れ子になります | +| リンクされた編集 | コンポーネント内で、開始タグと終了タグの名前が一緒に変わります | + +結果が生成されたコードを指すことはありません。ファイルの生成された C# 内の位置は、常にその Razor ファイルへマップし直されます。 + +Razor ファイルは `using` を不要として報告することはありません。マークアップは、生成された C# 自体には不要な using を使ってコンポーネントタグを解決します。また、[`_Imports.razor`](https://learn.microsoft.com/ja-jp/aspnet/core/blazor/tutorials/movie-database-app/part-1?view=aspnetcore-10.0#components_importsrazor-file) と `_ViewImports.cshtml` の using は、その隣と下にあるファイルのためのものです。コンポーネントの `.razor.cs` コードビハインドを含む C# ファイルでは、引き続き報告されます。 + +## マークアップ内の HTML + +- **VS Code:** 要素と属性の補完、および HTML のホバーは、VS Code 内蔵の HTML 言語サービスから提供されます。SharpLsp は、VS Code の [Embedded Programming Languages](https://code.visualstudio.com/api/language-extensions/embedded-languages) ガイドが説明するパターンで、ファイルの HTML ビューとともにリクエストを転送します。SharpLsp 自身の項目が先に表示されます。 +- **Zed:** Zed には Razor 言語は組み込まれていません。Zed の拡張機能レジストリから Razor 拡張機能をインストールすると、SharpLsp はそれが定義する `Razor` 言語にアタッチします。 +- **その他の LSP クライアント:** `.razor` と `.cshtml` ファイルの言語 id は `razor` です。マークアップには、SharpLsp と HTML 言語サーバーを組み合わせてください。 + +## 仕組み + +SharpLsp は Razor を再実装することはありません。.NET 6 以降、[Razor コンパイラーは Roslyn のソースジェネレーターとして動作](https://learn.microsoft.com/ja-jp/aspnet/core/release-notes/aspnetcore-6.0?view=aspnetcore-10.0#razor-compiler-updated-to-use-source-generators)し、C# サイドカーのデザインタイムビルドが `dotnet build` とまったく同じようにそれを実行します。 + +ジェネレーターは各 Razor ファイルを C# に変換します。すべての C# フラグメントをそのまま生成されたファイルへコピーし、Razor のテキストを指し示す[拡張された `#line` ディレクティブ](https://github.com/dotnet/csharplang/blob/main/proposals/csharp-10.0/enhanced-line-directives.md)でそれを包みます。 + +SharpLsp はこれらのディレクティブから双方向のマップを構築し、各フラグメントのテキストを両側で照合します。 + +1. Razor の位置でのリクエストは、生成された C# にマップされます。 +2. Roslyn がそこで応答します。`.cs` ファイルの場合とまったく同じです。 +3. 各応答は Razor ファイルへマップし直されます。 + +コンパイラーの隠れた足場のコード内の位置には Razor のテキストがないため、利用者に届くことはありません。 + +```mermaid +flowchart LR + razor["Basket.razor"] -- "Razor ソースジェネレーター" --> generated["#line マッピング付きの
生成された C#"] + generated --> roslyn["Roslyn:ホバー、補完、
参照、名前の変更"] + roslyn -- "マップし直し" --> razor +``` + +マークアップは、コンパイラーの公開された[中間表現](https://github.com/dotnet/razor)から得られます。それが折りたたみ、アウトライン、リンクされた編集を駆動し、VS Code の HTML サービスが読み取る HTML ビューを構築します。 + +## サポートされるプロジェクト + +| ファイル | 内容 | +|---|---| +| `*.razor` | [Razor コンポーネント](https://learn.microsoft.com/ja-jp/aspnet/core/blazor/components/?view=aspnetcore-10.0) | +| `_Imports.razor` | そのフォルダーとその下のすべてのコンポーネント向けのディレクティブ | +| `*.cshtml` | Razor Page(`@page`)、MVC ビュー、部分ビュー、またはレイアウト | +| `_ViewImports.cshtml` | そのフォルダーとその下のすべてのページとビュー向けのディレクティブ | +| `_ViewStart.cshtml` | 各ビューの前に実行されるコード | + +- **アプリ:** Blazor Server または Blazor WebAssembly の[レンダーモード](https://learn.microsoft.com/ja-jp/aspnet/core/blazor/components/render-modes?view=aspnetcore-10.0)を使う Blazor Web App(`dotnet new blazor`)、スタンドアロンの Blazor WebAssembly アプリ、Razor Pages または MVC アプリ、および共有コンポーネントの [Razor クラスライブラリ](https://learn.microsoft.com/ja-jp/aspnet/core/razor-pages/ui-class?view=aspnetcore-10.0)。 +- **プロジェクト SDK:** `Microsoft.NET.Sdk.Web`、`Microsoft.NET.Sdk.BlazorWebAssembly`、`Microsoft.NET.Sdk.Razor`。 +- **検証済み:** [.NET 10](https://devblogs.microsoft.com/dotnet/announcing-dotnet-10/) SDK 上の ASP.NET Core 10。新規の `dotnet new blazor` アプリを含みます。 +- **net8.0 と net9.0:** これらのプロジェクトは同じソースジェネレーターを通じて Razor をコンパイルするため、動作するはずですが、まだ検証されていません。 +- **非対応:** ソースジェネレーターなしで Razor をコンパイルする .NET 6 より前のプロジェクト、および .NET Framework 上の ASP.NET MVC 5、Web Pages、Web Forms。 + +## SDK バージョン + +Razor ジェネレーターは、プロジェクトをビルドする SDK に由来します。`global.json` で固定されていない場合、それはインストール済みの最新 SDK です。 + +Roslyn は、自身のバージョンまたはそれより古いバージョン向けにビルドされたジェネレーターしか読み込みません。そして新しい SDK のジェネレーターは、SharpLsp が同梱する Roslyn より新しい Roslyn を必要とする場合があります。SDK 10.0.401 のものがそうです。その場合 SharpLsp は、そのジェネレーターの代わりに同梱の Razor コンパイラーを読み込むため、プロジェクトの Razor ファイルでは引き続き C# の機能が使えます。また、両方の Roslyn バージョンを示す警告をログに記録します([issue #399](https://github.com/Nimblesite/SharpLsp/issues/399))。ご自身のビルドでは、引き続き SDK のジェネレーターが使われます。 + +## まだサポートされていない機能 + +進捗は [issue #397](https://github.com/Nimblesite/SharpLsp/issues/397) で追跡しています。 + +- **コンポーネントのマークアップ:** コンポーネントタグ、コンポーネントパラメーター、`@bind-X`、および `@onclick` などのディレクティブ属性に対するホバー、定義、補完、参照、セマンティックカラー。 +- **Razor の診断:** 未知のコンポーネントなど、コンパイラー自身の `RZ` 診断。 +- **タグヘルパー:** `asp-for` などの[タグヘルパー](https://learn.microsoft.com/ja-jp/aspnet/core/mvc/views/tag-helpers/intro?view=aspnetcore-10.0)の要素と属性に対するホバーと補完。 +- **F#:** Razor のコード言語は C# です。Razor コンパイラーが生成するのは C# のみだからです。ただし Blazor アプリは F# ライブラリを参照できます。Razor から使用する F# の型に対するホバー、補完、定義、および Razor の使用箇所にまで届く F# の名前の変更は計画中です。 +- **編集:** フォーマット、選択範囲、コードアクション、インレイヒント、コードレンズ、および Razor の位置からの呼び出し階層と型階層。 +- **併置されたファイル:** [CSS 分離](https://learn.microsoft.com/ja-jp/aspnet/core/blazor/components/css-isolation?view=aspnetcore-10.0)(`.razor.css`)と併置された `.razor.js` ファイルの認識。 +- **ランタイム:** Blazor WebAssembly のデバッグとホットリロード。 + +## 失敗時の動作 + +- **プロジェクションなし:** プロジェクトが Razor ジェネレーターを実行しない場合、その Razor ファイルには C# 機能がありません。マークアップ機能は引き続き応答します。 +- **エンジンの障害:** Razor エンジンの読み込み、またはファイルの処理に失敗した場合、マークアップ機能は空を返し、C# 機能は引き続き動作します。SharpLsp はプロジェクトごとに 1 回、プロジェクト名と例外の種類とともに失敗をログに記録します。 +- **その位置に何もない:** C# でもマークアップ要素でもない位置は、エラーではなく何も返しません。 +- **プライバシー:** SharpLsp は Razor や生成されたテキストをログに記録せず、パス、スパン、長さのみを記録します。 + +## Microsoft のドキュメント + +- [ASP.NET Core Blazor](https://learn.microsoft.com/ja-jp/aspnet/core/blazor/?view=aspnetcore-10.0) と [Blazor プロジェクトの構造](https://learn.microsoft.com/ja-jp/aspnet/core/blazor/project-structure?view=aspnetcore-10.0) +- [ASP.NET Core の Razor 構文リファレンス](https://learn.microsoft.com/ja-jp/aspnet/core/mvc/views/razor?view=aspnetcore-10.0) +- [Blazor のレンダーモード](https://learn.microsoft.com/ja-jp/aspnet/core/blazor/components/render-modes?view=aspnetcore-10.0)、[データ バインディング](https://learn.microsoft.com/ja-jp/aspnet/core/blazor/components/data-binding?view=aspnetcore-10.0)、[イベント処理](https://learn.microsoft.com/ja-jp/aspnet/core/blazor/components/event-handling?view=aspnetcore-10.0) +- [ジェネリック コンポーネント](https://learn.microsoft.com/ja-jp/aspnet/core/blazor/components/generic-type-support?view=aspnetcore-10.0)と[カスケード値とパラメーター](https://learn.microsoft.com/ja-jp/aspnet/core/blazor/components/cascading-values-and-parameters?view=aspnetcore-10.0) +- [.NET 10 の ASP.NET Core の新機能](https://learn.microsoft.com/ja-jp/aspnet/core/release-notes/aspnetcore-10.0?view=aspnetcore-10.0) +- [Roslyn API とソースジェネレーター](https://learn.microsoft.com/ja-jp/dotnet/csharp/roslyn-sdk/#source-generators) diff --git a/src/website/src/zh/blog/blazor-razor-vs-code.md b/src/website/src/zh/blog/blazor-razor-vs-code.md new file mode 100644 index 00000000..631e318a --- /dev/null +++ b/src/website/src/zh/blog/blazor-razor-vs-code.md @@ -0,0 +1,110 @@ +--- +layout: layouts/blog.njk +title: "VS Code 中的 Blazor 与 Razor:为 .razor 文件提供 C# 工具" +description: "SharpLsp 现在可以在 VS Code 中编辑 Blazor 组件、Razor Pages 和 MVC 视图:在 .razor 与 .cshtml 文件中提供 C# 悬停、补全、诊断、转到定义和重命名。" +lang: zh +date: 2026-10-20 +author: SharpLsp 团队 +image: /assets/images/blog/blazor-razor-vs-code.png +imageAlt: "抛光金属 @ 雕塑燃起琥珀色火焰,立于青色光线勾勒的层叠玻璃之上" +tags: + - posts + - blazor + - razor + - csharp + - dotnet-lsp +category: announcement +excerpt: "Blazor 组件、Razor Pages 和 MVC 视图现在与你的 C# 文件使用同一个 Roslyn 引擎:在标记内部提供悬停、补全、诊断、转到定义、引用和重命名。" +--- + +SharpLsp 现在理解 Razor。Blazor 组件(`.razor` 文件),以及 Razor Pages 和 MVC 视图(`.cshtml` 文件),与你的其余 C# 代码使用同一个 Roslyn 引擎。在标记内部,你可以使用悬停、补全、诊断、转到定义、引用和重命名。这项支持随 SharpLsp 0.30 发布。 + +下面是一个小型 Blazor Web App 上的效果: + +{% video "blazor" %} + +## 什么是 Blazor?一分钟了解 Blazor 与 Razor + +这两个名字描述的是不同的层次,所以很容易混淆。 + +- **Razor** 是语法。它把 HTML 与 C# 混合在一起:一个 `@` 表达式、一个 `@if` 或 `@foreach` 块、一个 `@code` 块。Microsoft 的 [Razor 语法参考](https://learn.microsoft.com/zh-cn/aspnet/core/mvc/views/razor?view=aspnetcore-10.0)涵盖了全部内容。 +- **ASP.NET Core Razor Pages 和 MVC 视图**是服务器为每个请求呈现的 `.cshtml` 文件。请阅读 [ASP.NET Core 中的 Razor Pages](https://learn.microsoft.com/zh-cn/aspnet/core/razor-pages/?view=aspnetcore-10.0)和 [ASP.NET Core MVC 中的视图](https://learn.microsoft.com/zh-cn/aspnet/core/mvc/views/overview?view=aspnetcore-10.0)。 +- **Blazor** 是组件框架。[Razor 组件](https://learn.microsoft.com/zh-cn/aspnet/core/blazor/components/?view=aspnetcore-10.0)就是一个 `.razor` 文件。Blazor Web App 是 .NET 8 起的 `dotnet new blazor` 模板,它在服务器上或在浏览器中的 WebAssembly 上运行组件。每个组件选择自己的[呈现模式](https://learn.microsoft.com/zh-cn/aspnet/core/blazor/components/render-modes?view=aspnetcore-10.0):静态服务器呈现、`InteractiveServer`、`InteractiveWebAssembly` 或 `InteractiveAuto`。 + +在这三者之下是一个 Razor 编译器,它把每个 Razor 文件转换成 C#。这就是为什么 SharpLsp 中的一套实现就能同时服务 Blazor Server、Blazor WebAssembly、Razor Pages 和 MVC。 + +## 在 .razor 文件中你能得到什么 + +下面的一切都展示在视频演示的 `Basket.razor` 页面上,每一项都有自动化测试覆盖。 + +- **悬停。** 悬停 `@Total()`,Roslyn 会显示 `Basket.Total()` 的签名及其 XML 摘要。在布局中悬停 `@Body`,你会看到它所呈现的 `LayoutComponentBase.Body` 属性。 +- **补全。** 在 `@` 表达式和 `@code` 块中提供 Roslyn 的成员补全。每次编辑都落在 `.razor` 文件里,补全添加的 `using` 会变成一行 `@using`。 +- **诊断。** C# 的错误会带着编译器代码,报告在导致它的那一行 Razor 上,并显示在编辑器和“问题”面板中。整个解决方案的扫描涵盖 Razor 文件。 +- **转到定义。** 在 `@onclick="() => Add(product)"` 中的 `Add` 上按 F12 会跳到 `@code` 块。在 `Catalog.Products` 上按 F12 会打开 `Catalog.cs`。从 C# 一侧,在 `app.MapRazorComponents()` 的 `App` 上按 F12 会打开 `App.razor`,而不是生成的类。 +- **引用和高亮。** 显示 Razor 文件中的每一处使用,不包含编译器生成的隐藏的脚手架代码。 +- **重命名。** 重命名 `@code` 字段,标记中的每一处使用都会跟着改变。 +- **结构。** 多行元素和 `@code` 块可以折叠,大纲列出指令和元素,重命名开始标签会同时重命名其结束标签。 +- 调用中的**签名帮助**,以及文件中 C# 的**语义高亮**。 + +围绕 C# 的标记仍然是 HTML。在 VS Code 中,SharpLsp 把元素和属性补全以及 HTML 悬停转发给 VS Code 自带的 HTML 语言服务。它发送的是文件中每个 C# 片段都被留空的 HTML 视图,因此 HTML 服务永远不会把 C# 误认为标记。 + +## 工作原理:Razor 编译器是唯一的事实来源 + +构建 Razor 工具时,一个诱人的做法是写一个 Razor 解析器。SharpLsp 没有这样做。Razor 的语义属于 Razor 编译器,任何第二套解析器都会在每个版本中与它偏离。 + +自 .NET 6 起,[Razor 编译器就是一个 C# 源生成器](https://learn.microsoft.com/zh-cn/aspnet/core/release-notes/aspnetcore-6.0?view=aspnetcore-10.0#razor-compiler-updated-to-use-source-generators)。它随 .NET SDK 一起提供,C# sidecar 的设计时构建会像 `dotnet build` 一样运行它。生成器把每个 Razor 文件转换成一个 C# 类。每个 C# 片段都被原样复制进该类,位于一个指回 Razor 文本的[增强 `#line` 指令](https://github.com/dotnet/csharplang/blob/main/proposals/csharp-10.0/enhanced-line-directives.md)之中。 + +SharpLsp 根据这些指令建立双向映射: + +1. Razor 位置上的请求会映射到生成的 C# 中相同的字符。 +2. Roslyn 在那里作答,与处理 `.cs` 文件时完全相同。 +3. 答案中的每个位置都映射回 Razor 文件。 + +映射从不盲目相信指令。Roslyn 会把每个已映射范围的结束位置报告为片段末尾再多一个字符,因此 SharpLsp 通过逐字符比较 Razor 文本和生成的文本,来测量每个片段。生成器自己写出的代码(例如呈现标记的 `__builder` 调用)中的位置没有对应的 Razor 文本,永远不会传到你这里。 + +由于生成器来自编译器本身,它映射的每个构造中的 C# 都得到同样的处理。这包括 `@bind` 值、事件处理程序、泛型组件和 `@code` 块。负责折叠、大纲、链接编辑和 HTML 视图的标记一侧,来自编译器公开的[中间表示](https://github.com/dotnet/razor),而不是重新实现。 + +## 在构建过程中发现的问题 + +在一个全新的 `dotnet new blazor` 应用上运行真实宿主时,发现了一个误报:`_Imports.razor` 中的每个 `@using` 都被淡显为不必要。Roslyn 只能看到每个文件自己生成的 C#,因此它无法看到导入文件的 using 是服务于它旁边和下方的组件的。当组件自身的 using 只被其标记需要时,也存在同样的问题。现在 Razor 文件不再报告不必要的 using,而 C# 文件(包括代码隐藏)仍然会报告。 + +第二个发现与 SDK 版本有关。Razor 生成器来自构建你的项目的 SDK,也就是已安装的最新版本,除非 `global.json` 固定了另一个版本。SDK 10.0.401 的生成器需要比 SharpLsp 捆绑的 Roslyn 更新的版本,因此起初它的 Razor 文件得不到任何 C# 功能([issue #399](https://github.com/Nimblesite/SharpLsp/issues/399))。现在,SharpLsp 会加载自己捆绑的 Razor 编译器来代替该生成器,并记录一条写明两个 Roslyn 版本的警告,因此较新的 SDK 无需固定版本也能正常工作。详细信息请参阅 [Blazor 与 Razor 文档](/zh/docs/blazor/)。 + +## .NET 10 上的 Blazor:Server、WebAssembly 和 Razor 类库 + +SharpLsp 读取你的解决方案中已有的项目。它们都通过 `Microsoft.NET.Sdk.Web`、`Microsoft.NET.Sdk.BlazorWebAssembly` 或 `Microsoft.NET.Sdk.Razor` 以相同的方式加载: + +- 带有 Blazor Server 或 Blazor WebAssembly 交互性的 Blazor Web App; +- 独立的 Blazor WebAssembly 应用; +- Razor Pages 或 MVC 应用; +- 由共享组件组成的 [Razor 类库](https://learn.microsoft.com/zh-cn/aspnet/core/razor-pages/ui-class?view=aspnetcore-10.0)。 + +在 [.NET 10](https://devblogs.microsoft.com/dotnet/announcing-dotnet-10/) SDK 上的 ASP.NET Core 10 已经过验证,包括一个全新的 `dotnet new blazor` 应用。net8.0 和 net9.0 上的项目通过同一个源生成器编译 Razor,因此应该可以工作,但我们还没有验证它们。早于 .NET 6 的项目在不使用源生成器的情况下编译 Razor,因此不受支持,.NET Framework 的 MVC 5 和 Web Forms 同样不受支持。 + +## 接下来 + +这个版本涵盖 Razor 文件中的 C#。接下来是标记本身,在 [issue #397](https://github.com/Nimblesite/SharpLsp/issues/397) 中追踪: + +- 组件标签、组件参数、`@bind-Value` 以及 `@onclick` 等指令属性上的悬停、定义、补全和引用; +- 编译器自带的 `RZ` 诊断,例如未知组件; +- Razor Pages 和 MVC 视图中的标记帮助程序,例如 `asp-for`; +- Blazor 前端背后的 F# 库:从 Razor 对 F# 类型的悬停、补全和定义,以及能触及 Razor 使用处的 F# 重命名; +- 格式化、提取组件等代码操作,以及热重载。 + +## 试一试 + +1. 从 [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=nimblesite.sharplsp) 安装 SharpLsp,或在 Cursor、Windsurf 和 VSCodium 中从 [Open VSX](https://open-vsx.org/extension/nimblesite/sharplsp) 安装。Razor 支持需要 0.30 或更高版本。 +2. 用 `dotnet new blazor -o BlazorDemo` 创建一个 Blazor Web App,然后打开该文件夹。 +3. 打开 `Components/Pages/Counter.razor`,悬停 `currentCount`。 + +[Blazor 与 Razor 文档](/zh/docs/blazor/)列出了每一项功能、支持的项目以及尚不支持的内容。 + +## 进一步阅读 + +- [ASP.NET Core Blazor](https://learn.microsoft.com/zh-cn/aspnet/core/blazor/?view=aspnetcore-10.0)与 [Blazor 托管模型](https://learn.microsoft.com/zh-cn/aspnet/core/blazor/hosting-models?view=aspnetcore-10.0) +- [Blazor 项目结构](https://learn.microsoft.com/zh-cn/aspnet/core/blazor/project-structure?view=aspnetcore-10.0)与 [Blazor 呈现模式](https://learn.microsoft.com/zh-cn/aspnet/core/blazor/components/render-modes?view=aspnetcore-10.0) +- [Blazor 数据绑定](https://learn.microsoft.com/zh-cn/aspnet/core/blazor/components/data-binding?view=aspnetcore-10.0)与[事件处理](https://learn.microsoft.com/zh-cn/aspnet/core/blazor/components/event-handling?view=aspnetcore-10.0) +- [.NET 10 中 ASP.NET Core 的新增功能](https://learn.microsoft.com/zh-cn/aspnet/core/release-notes/aspnetcore-10.0?view=aspnetcore-10.0) +- [ASP.NET Core 中的标记帮助程序](https://learn.microsoft.com/zh-cn/aspnet/core/mvc/views/tag-helpers/intro?view=aspnetcore-10.0) +- [dotnet/razor 仓库](https://github.com/dotnet/razor)与 [dotnet/aspnetcore 仓库](https://github.com/dotnet/aspnetcore) +- [Embedded Programming Languages](https://code.visualstudio.com/api/language-extensions/embedded-languages),VS Code 关于请求转发的指南 diff --git a/src/website/src/zh/docs/blazor.md b/src/website/src/zh/docs/blazor.md new file mode 100644 index 00000000..908dc60c --- /dev/null +++ b/src/website/src/zh/docs/blazor.md @@ -0,0 +1,114 @@ +--- +layout: layouts/docs.njk +title: Blazor 与 Razor +description: "在 VS Code 中用 SharpLsp 处理 Blazor 与 Razor:.razor 组件和 .cshtml 页面中的 C# 悬停、补全、诊断、转到定义、引用和重命名。" +lang: zh +--- + +VS Code 中 Blazor 组件标记内的 C# 悬停:在 Basket.razor 的对应行上显示 Total 方法的签名和 XML 文档 + +# Blazor 与 Razor + +SharpLsp 在 VS Code 中编辑 Razor 文件时,使用的是它处理 C# 时的同一个 Roslyn 引擎。这涵盖 [Blazor](https://learn.microsoft.com/zh-cn/aspnet/core/blazor/?view=aspnetcore-10.0) 组件(`.razor`),以及 ASP.NET Core 的 [Razor Pages](https://learn.microsoft.com/zh-cn/aspnet/core/razor-pages/?view=aspnetcore-10.0) 和 [MVC 视图](https://learn.microsoft.com/zh-cn/aspnet/core/mvc/views/overview?view=aspnetcore-10.0)(`.cshtml`)。 + +标记中的 C# 可以获得悬停、补全、诊断、导航和重命名:`@` 表达式、`@code` 块、事件处理程序和 `@bind` 值。其周围的 HTML 则由编辑器的 HTML 支持处理。 + +{% video "blazor" %} + +## Razor 文件中可用的功能 + +| 功能 | 你可以获得 | +|---|---| +| 悬停 | 光标下 C# 的 Roslyn 悬停信息,包含签名和 XML 文档,并标注在 Razor 文本的对应范围上 | +| 补全 | `@` 表达式和 `@code` 块内的 Roslyn 补全。所有编辑都落在 Razor 文件中,补全添加的 `using` 会变成一行 `@using` | +| 签名帮助 | 调用内部的参数帮助 | +| 诊断 | 显示在引发问题的 Razor 行上的 C# 错误和警告。整个解决方案的扫描也包含 Razor 文件 | +| 转到定义 | 从标记跳到声明,例如跳到 `@code` 块中的方法。在 C# 中,组件的定义就是它的 `.razor` 文件:`MapRazorComponents()` 会转到 `App.razor` | +| 引用与高亮 | Razor 文件中的每一处使用。编译器隐藏的脚手架代码会被排除在外 | +| 重命名 | 重命名 `@code` 块中的字段或成员时,标记中的每一处使用都会被重命名。对纯标记的重命名会被拒绝 | +| 语义高亮 | 文件中 C# 的 Roslyn 分类。标记则由编辑器的 Razor 语法着色 | +| 折叠与大纲 | 多行元素和 `@code` 块可以折叠。大纲列出指令和元素,每个 `@code` 块的成员嵌套在其下 | +| 链接编辑 | 在组件中,开始标签和结束标签一起重命名 | + +结果永远不会指向生成的代码。文件中生成的 C# 里的位置,总会映射回它的 Razor 文件。 + +Razor 文件永远不会把 `using` 报告为多余。标记通过生成的 C# 根本不需要的 using 来解析组件标签,而 [`_Imports.razor`](https://learn.microsoft.com/zh-cn/aspnet/core/blazor/tutorials/movie-database-app/part-1?view=aspnetcore-10.0#components_importsrazor-file) 和 `_ViewImports.cshtml` 中的 using 服务于与它们同级及下级的文件。C# 文件,包括组件的 `.razor.cs` 代码隐藏,仍然会报告它们。 + +## 标记中的 HTML + +- **VS Code**:元素和属性补全以及 HTML 悬停来自 VS Code 内置的 HTML 语言服务。SharpLsp 会带着文件的 HTML 视图转发请求,这正是 VS Code 的 [Embedded Programming Languages](https://code.visualstudio.com/api/language-extensions/embedded-languages) 指南所描述的模式。SharpLsp 自己的条目排在最前面。 +- **Zed**:Zed 没有内置的 Razor 语言。从 Zed 的扩展注册表安装 Razor 扩展后,SharpLsp 会附加到该扩展定义的 `Razor` 语言。 +- **其他 LSP 客户端**:对于 `.razor` 和 `.cshtml` 文件,语言 id 是 `razor`。请将 SharpLsp 与 HTML 语言服务器配合使用来处理标记。 + +## 工作原理 + +SharpLsp 从不重新实现 Razor。从 .NET 6 起,[Razor 编译器作为 Roslyn 源生成器运行](https://learn.microsoft.com/zh-cn/aspnet/core/release-notes/aspnetcore-6.0?view=aspnetcore-10.0#razor-compiler-updated-to-use-source-generators),C# sidecar 的设计时构建会像 `dotnet build` 那样原样运行它。 + +生成器把每个 Razor 文件转换为 C#。它把每一段 C# 片段原样复制到生成的文件中,并用指向 Razor 文本的[增强 `#line` 指令](https://github.com/dotnet/csharplang/blob/main/proposals/csharp-10.0/enhanced-line-directives.md)将其包裹。 + +SharpLsp 依据这些指令构建双向映射,并在两侧核对每个片段的文本: + +1. Razor 位置上的请求会映射到生成的 C#。 +2. Roslyn 在那里作答,与处理 `.cs` 文件时完全一致。 +3. 每个答案都会映射回 Razor 文件。 + +编译器隐藏的脚手架中的位置没有 Razor 文本,因此永远不会到达你这里。 + +```mermaid +flowchart LR + razor["Basket.razor"] -- "Razor 源生成器" --> generated["带 #line 映射的
生成的 C#"] + generated --> roslyn["Roslyn:悬停、补全、
引用、重命名"] + roslyn -- "映射回" --> razor +``` + +标记来自编译器公开的[中间表示](https://github.com/dotnet/razor)。它驱动折叠、大纲和链接编辑,并构建 VS Code 的 HTML 服务所读取的 HTML 视图。 + +## 支持的项目 + +| 文件 | 说明 | +|---|---| +| `*.razor` | 一个 [Razor 组件](https://learn.microsoft.com/zh-cn/aspnet/core/blazor/components/?view=aspnetcore-10.0) | +| `_Imports.razor` | 面向所在文件夹及其下级每个组件的指令 | +| `*.cshtml` | Razor Page(`@page`)、MVC 视图、分部视图或布局 | +| `_ViewImports.cshtml` | 面向所在文件夹及其下级每个页面和视图的指令 | +| `_ViewStart.cshtml` | 在每个视图之前运行的代码 | + +- **应用**:使用 Blazor Server 或 Blazor WebAssembly [呈现模式](https://learn.microsoft.com/zh-cn/aspnet/core/blazor/components/render-modes?view=aspnetcore-10.0)的 Blazor Web App(`dotnet new blazor`)、独立的 Blazor WebAssembly 应用、Razor Pages 或 MVC 应用,以及提供共享组件的 [Razor 类库](https://learn.microsoft.com/zh-cn/aspnet/core/razor-pages/ui-class?view=aspnetcore-10.0)。 +- **项目 SDK**:`Microsoft.NET.Sdk.Web`、`Microsoft.NET.Sdk.BlazorWebAssembly` 和 `Microsoft.NET.Sdk.Razor`。 +- **已验证**:在 [.NET 10](https://devblogs.microsoft.com/dotnet/announcing-dotnet-10/) SDK 上的 ASP.NET Core 10,包括全新的 `dotnet new blazor` 应用。 +- **net8.0 与 net9.0**:这些项目通过同一个源生成器编译 Razor,因此应该可以工作,但尚未验证。 +- **不支持**:早于 .NET 6 的项目(不使用源生成器编译 Razor),以及 .NET Framework 上的 ASP.NET MVC 5、Web Pages 和 Web Forms。 + +## SDK 版本 + +Razor 生成器来自构建你的项目的 SDK。如果没有 `global.json` 固定版本,那它就是你所安装的最新 SDK。 + +Roslyn 只能加载为其自身版本或更早版本构建的生成器,而较新 SDK 的生成器可能需要比 SharpLsp 捆绑的 Roslyn 更新的版本,SDK 10.0.401 的生成器就是如此。这时 SharpLsp 会加载它捆绑的 Razor 编译器来代替该生成器,因此项目的 Razor 文件仍然具备 C# 功能;它还会记录一条警告,写明两个 Roslyn 版本([issue #399](https://github.com/Nimblesite/SharpLsp/issues/399))。你自己的构建仍然使用 SDK 的生成器。 + +## 尚不支持 + +进度在 [issue #397](https://github.com/Nimblesite/SharpLsp/issues/397) 中跟踪。 + +- **组件标记**:组件标签、组件参数、`@bind-X` 以及 `@onclick` 等指令属性上的悬停、定义、补全、引用和语义着色。 +- **Razor 诊断**:编译器自身的 `RZ` 诊断,例如未知组件。 +- **标记帮助程序**:`asp-for` 等[标记帮助程序](https://learn.microsoft.com/zh-cn/aspnet/core/mvc/views/tag-helpers/intro?view=aspnetcore-10.0)元素和属性上的悬停与补全。 +- **F#**:Razor 的代码语言是 C#,因为 Razor 编译器只生成 C#;但 Blazor 应用可以引用 F# 库。针对从 Razor 使用的 F# 类型的悬停、补全和定义,以及让 F# 重命名覆盖 Razor 中的使用,都在计划之中。 +- **编辑**:格式化、选择范围、代码操作、内嵌提示、CodeLens,以及从 Razor 位置发起的调用层次结构和类型层次结构。 +- **并置文件**:对 [CSS 隔离](https://learn.microsoft.com/zh-cn/aspnet/core/blazor/components/css-isolation?view=aspnetcore-10.0)(`.razor.css`)和并置的 `.razor.js` 文件的识别。 +- **运行时**:Blazor WebAssembly 调试和热重载。 + +## 失败行为 + +- **没有投影**:如果项目不运行 Razor 生成器,其 Razor 文件就没有 C# 功能。标记功能仍会作答。 +- **引擎故障**:如果 Razor 引擎加载失败或处理文件失败,标记功能返回空结果,C# 功能继续工作。SharpLsp 会为每个项目记录一次失败,包含项目名称和异常类型。 +- **位置上没有内容**:既不是 C# 也不是标记元素的位置不会返回任何内容,也绝不会返回错误。 +- **隐私**:SharpLsp 从不记录 Razor 或生成的文本,只记录路径、范围和长度。 + +## Microsoft 文档 + +- [ASP.NET Core Blazor](https://learn.microsoft.com/zh-cn/aspnet/core/blazor/?view=aspnetcore-10.0) 和 [Blazor 项目结构](https://learn.microsoft.com/zh-cn/aspnet/core/blazor/project-structure?view=aspnetcore-10.0) +- [ASP.NET Core 的 Razor 语法参考](https://learn.microsoft.com/zh-cn/aspnet/core/mvc/views/razor?view=aspnetcore-10.0) +- [Blazor 呈现模式](https://learn.microsoft.com/zh-cn/aspnet/core/blazor/components/render-modes?view=aspnetcore-10.0)、[数据绑定](https://learn.microsoft.com/zh-cn/aspnet/core/blazor/components/data-binding?view=aspnetcore-10.0)和[事件处理](https://learn.microsoft.com/zh-cn/aspnet/core/blazor/components/event-handling?view=aspnetcore-10.0) +- [泛型组件](https://learn.microsoft.com/zh-cn/aspnet/core/blazor/components/generic-type-support?view=aspnetcore-10.0)和[级联值和参数](https://learn.microsoft.com/zh-cn/aspnet/core/blazor/components/cascading-values-and-parameters?view=aspnetcore-10.0) +- [.NET 10 中 ASP.NET Core 的新增功能](https://learn.microsoft.com/zh-cn/aspnet/core/release-notes/aspnetcore-10.0?view=aspnetcore-10.0) +- [Roslyn API 与源生成器](https://learn.microsoft.com/zh-cn/dotnet/csharp/roslyn-sdk/#source-generators) diff --git a/src/website/tests/docs-nav.spec.js b/src/website/tests/docs-nav.spec.js index 16fd4802..59a60b81 100644 --- a/src/website/tests/docs-nav.spec.js +++ b/src/website/tests/docs-nav.spec.js @@ -6,7 +6,7 @@ import { docsSidebar as sidebar, openDocsMenu as openDrawer } from './docs-menu. const GROUPS = { 'Set up': ['Getting Started', 'Editor Setup', 'Zed', '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', 'Profiling F#', 'Fix a Memory Leak'], }; const CONTRIBUTOR_PAGES = ['Architecture', 'Contributing']; diff --git a/src/website/tests/mobile-nav.spec.js b/src/website/tests/mobile-nav.spec.js index 4a0e6581..8c2fdcfe 100644 --- a/src/website/tests/mobile-nav.spec.js +++ b/src/website/tests/mobile-nav.spec.js @@ -101,6 +101,7 @@ for (const viewport of MOBILE_VIEWPORTS) { '/docs/hover/', '/docs/go-to-definition/', '/docs/configuration/', + '/docs/blazor/', ]; for (const path of docsPaths) { diff --git a/src/website/tests/screenshots.spec.js b/src/website/tests/screenshots.spec.js index e13f8ad9..fed8c4e9 100644 --- a/src/website/tests/screenshots.spec.js +++ b/src/website/tests/screenshots.spec.js @@ -68,6 +68,7 @@ const DOC_STILLS = { 'configuration/': 'configuration', 'completions/': 'completions', 'hover/': 'hover', + 'blazor/': 'blazor', 'go-to-definition/': 'navigation', 'diagnostics/': 'diagnostics', 'refactoring/': 'refactoring', diff --git a/src/website/tests/site-layout.spec.js b/src/website/tests/site-layout.spec.js index de1883a3..798685b0 100644 --- a/src/website/tests/site-layout.spec.js +++ b/src/website/tests/site-layout.spec.js @@ -10,6 +10,7 @@ const LOCALIZED_ROUTES = [ 'blog/', 'blog/tags/', 'blog/categories/', + 'blog/blazor-razor-vs-code/', 'blog/dotnet-framework-windows-vs-code/', 'blog/dotnet-profiler-vs-code-csharp-fsharp/', 'blog/editor-agnostic-dotnet-lsp/', @@ -19,6 +20,7 @@ const LOCALIZED_ROUTES = [ 'blog/why-fsharp-is-first-class-in-sharplsp/', 'docs/', 'docs/architecture/', + 'docs/blazor/', 'docs/completions/', 'docs/configuration/', 'docs/context-menus/', diff --git a/src/website/tests/site-videos.spec.js b/src/website/tests/site-videos.spec.js index 3d68a1f8..955d1e05 100644 --- a/src/website/tests/site-videos.spec.js +++ b/src/website/tests/site-videos.spec.js @@ -19,6 +19,7 @@ const WALKTHROUGHS = [ ...everyLanguage('editors/', ['editor-setup'], { en: 'setup', ja: 'エディター設定', zh: '编辑器设置' }), ...everyLanguage('configuration/', ['configuration'], { en: 'Configuring', ja: '設定', zh: '配置' }), ...everyLanguage('fsharp/', FSHARP_PAGE, { en: 'completion', ja: '補完', zh: '补全' }), + ...everyLanguage('blazor/', ['blazor'], { en: 'Blazor and Razor', ja: 'コンポーネント', zh: '组件' }), ...everyLanguage('completions/', ['code-completions'], { en: 'completion', ja: '補完', zh: '补全' }), ...everyLanguage('hover/', ['hover'], { en: 'Hover', ja: 'ホバー', zh: '悬停' }), ...everyLanguage('go-to-definition/', ['navigation'], { en: 'definition', ja: '定義', zh: '定义' }), @@ -44,6 +45,9 @@ const PAGES = [ { path: '/ja/blog/editor-agnostic-dotnet-lsp/', videos: ['sharplsp-tour'], says: 'ツアー' }, { path: '/zh/blog/editor-agnostic-dotnet-lsp/', videos: ['sharplsp-tour'], says: '导览' }, { path: '/blog/dotnet-profiler-vs-code-csharp-fsharp/', videos: PROFILER_DOC, says: 'profiler' }, + { path: '/blog/blazor-razor-vs-code/', videos: ['blazor'], says: 'Blazor and Razor' }, + { path: '/ja/blog/blazor-razor-vs-code/', videos: ['blazor'], says: 'コンポーネント' }, + { path: '/zh/blog/blazor-razor-vs-code/', videos: ['blazor'], says: '组件' }, { path: '/ja/blog/dotnet-profiler-vs-code-csharp-fsharp/', videos: PROFILER_DOC, says: 'プロファイラー' }, { path: '/zh/blog/dotnet-profiler-vs-code-csharp-fsharp/', videos: PROFILER_DOC, says: '性能分析器' }, ]; diff --git a/src/website/tests/video-discovery.spec.js b/src/website/tests/video-discovery.spec.js index ce4eb816..f8310f39 100644 --- a/src/website/tests/video-discovery.spec.js +++ b/src/website/tests/video-discovery.spec.js @@ -11,7 +11,7 @@ const mp4Of = (id) => `/assets/videos/${id}.mp4`; // Each docs page's preview is the film it leads with. [WEB-DESIGN-MEDIA-DISCOVERY] Social previews const PREVIEWS = { '/docs/': 'getting-started', '/docs/editors/': 'editor-setup', '/docs/configuration/': 'configuration', - '/docs/fsharp/': 'fsharp-language', '/docs/completions/': 'code-completions', '/docs/hover/': 'hover', + '/docs/fsharp/': 'fsharp-language', '/docs/blazor/': 'blazor', '/docs/completions/': 'code-completions', '/docs/hover/': 'hover', '/docs/go-to-definition/': 'navigation', '/docs/diagnostics/': 'diagnostics', '/docs/refactoring/': 'refactoring', '/docs/context-menus/': 'context-menus', '/docs/nuget/': 'nuget', '/docs/profiler/': 'profiler-csharp', '/docs/memory-leaks/': 'profiler-fix-memory-leak', '/docs/fsharp-profiling/': 'profiler-fsharp', diff --git a/src/website/tests/video-playback.spec.js b/src/website/tests/video-playback.spec.js index 84f6e499..d4342484 100644 --- a/src/website/tests/video-playback.spec.js +++ b/src/website/tests/video-playback.spec.js @@ -5,7 +5,7 @@ import { test, expect } from '@playwright/test'; import { expectFramesAdvance } from './media.js'; const FILMS = [ - ['/docs/fsharp/', 0], ['/docs/fsharp-profiling/', 0], + ['/docs/fsharp/', 0], ['/docs/fsharp-profiling/', 0], ['/docs/blazor/', 0], ['/docs/', 0], ['/docs/editors/', 0], ['/docs/configuration/', 0], ['/docs/completions/', 0], ['/docs/hover/', 0], ['/docs/go-to-definition/', 0], ['/docs/diagnostics/', 0], ['/docs/refactoring/', 0], ['/docs/context-menus/', 0], @@ -13,6 +13,7 @@ const FILMS = [ ['/docs/profiler/', 2], ['/docs/memory-leaks/', 0], ['/docs/architecture/', 0], ['/docs/contributing/', 0], ['/blog/introducing-sharplsp/', 0], ['/blog/editor-agnostic-dotnet-lsp/', 0], + ['/blog/blazor-razor-vs-code/', 0], ]; for (const [route, index] of FILMS) { diff --git a/src/website/tests/video-watch-pages.spec.js b/src/website/tests/video-watch-pages.spec.js index 2c7c04ef..0e12d6d7 100644 --- a/src/website/tests/video-watch-pages.spec.js +++ b/src/website/tests/video-watch-pages.spec.js @@ -6,7 +6,7 @@ import { test, expect } from '@playwright/test'; import { seconds as secondsOf } from '../video-pages.js'; import { LANGS, SITE, VIDEOS, embedOf, expectPlayable, local, videoObjects, watchOf } from './media.js'; -const DOC_SLUGS = ['', 'architecture/', 'completions/', 'configuration/', 'context-menus/', 'contributing/', 'diagnostics/', +const DOC_SLUGS = ['', 'architecture/', 'blazor/', 'completions/', 'configuration/', 'context-menus/', 'contributing/', 'diagnostics/', 'editors/', 'fsharp/', 'fsharp-profiling/', 'go-to-definition/', 'hover/', 'memory-leaks/', 'nuget/', 'profiler/', 'refactoring/', 'zed/']; @@ -91,6 +91,23 @@ test('docs figures link to their watch page, which opens at ?t= seconds', async expect(await page.locator('main video').evaluate((v) => v.currentTime)).toBe(0); }); +test('a watch page ignores a ?t= that is not a finite number of seconds, without an error', async ({ page }) => { + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + for (const t of ['Infinity', '1e400', '-5', 'abc']) { + await page.goto(`/videos/hover/?t=${t}`); + const video = page.locator('main video'); + const real = await loadedMetadata(video); + expect(real.duration, `?t=${t} still loads the film`).toBeGreaterThan(0); + expect(await video.evaluate((v) => v.currentTime), `?t=${t} leaves the film at its start`).toBe(0); + await expect(video).toHaveJSProperty('paused', true); + } + expect(errors, 'no seek throws').toEqual([]); + await page.goto('/videos/hover/?t=12'); + await expect.poll(() => page.locator('main video').evaluate((v) => Math.floor(v.currentTime)), { timeout: 15000 }).toBe(12); + expect(errors, 'a finite seek still works without an error').toEqual([]); +}); + for (const lang of LANGS) { test(`every ${lang} embed page is the bare player, kept out of the index`, async ({ page, request }) => { for (const id of Object.keys(VIDEOS)) { diff --git a/src/website/tools/profiler-film/demos/blazor-storefront/BlazorStorefront.csproj b/src/website/tools/profiler-film/demos/blazor-storefront/BlazorStorefront.csproj new file mode 100644 index 00000000..05ceb3f0 --- /dev/null +++ b/src/website/tools/profiler-film/demos/blazor-storefront/BlazorStorefront.csproj @@ -0,0 +1,7 @@ + + + net10.0 + enable + enable + + diff --git a/src/website/tools/profiler-film/demos/blazor-storefront/BlazorStorefront.slnx b/src/website/tools/profiler-film/demos/blazor-storefront/BlazorStorefront.slnx new file mode 100644 index 00000000..22e32a5e --- /dev/null +++ b/src/website/tools/profiler-film/demos/blazor-storefront/BlazorStorefront.slnx @@ -0,0 +1,3 @@ + + + diff --git a/src/website/tools/profiler-film/demos/blazor-storefront/Catalog.cs b/src/website/tools/profiler-film/demos/blazor-storefront/Catalog.cs new file mode 100644 index 00000000..663ecde4 --- /dev/null +++ b/src/website/tools/profiler-film/demos/blazor-storefront/Catalog.cs @@ -0,0 +1,15 @@ +namespace BlazorStorefront; + +/// A product on sale: what it is called, and what it costs. +public sealed record Product(string Name, decimal Price); + +public static class Catalog +{ + /// Every product the store sells, cheapest first. + public static IReadOnlyList Products { get; } = + [ + new("Rustic Lamp", 19.99m), + new("Sleek Kettle", 34.50m), + new("Ergonomic Keyboard", 89.00m), + ]; +} diff --git a/src/website/tools/profiler-film/demos/blazor-storefront/Components/App.razor b/src/website/tools/profiler-film/demos/blazor-storefront/Components/App.razor new file mode 100644 index 00000000..3b7bb3e1 --- /dev/null +++ b/src/website/tools/profiler-film/demos/blazor-storefront/Components/App.razor @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/website/tools/profiler-film/demos/blazor-storefront/Components/Layout/MainLayout.razor b/src/website/tools/profiler-film/demos/blazor-storefront/Components/Layout/MainLayout.razor new file mode 100644 index 00000000..724fc91b --- /dev/null +++ b/src/website/tools/profiler-film/demos/blazor-storefront/Components/Layout/MainLayout.razor @@ -0,0 +1,5 @@ +@inherits LayoutComponentBase + +
+ @Body +
diff --git a/src/website/tools/profiler-film/demos/blazor-storefront/Components/Pages/Basket.razor b/src/website/tools/profiler-film/demos/blazor-storefront/Components/Pages/Basket.razor new file mode 100644 index 00000000..e429ad01 --- /dev/null +++ b/src/website/tools/profiler-film/demos/blazor-storefront/Components/Pages/Basket.razor @@ -0,0 +1,28 @@ +@page "/" +@rendermode InteractiveServer + +Basket + +

Basket

+ +
    + @foreach (var product in Catalog.Products) + { +
  • + @product.Name, @product.Price.ToString("C") + +
  • + } +
+ +

@basket.Count items, @Total().ToString("C") in total

+ +@code { + private readonly List basket = []; + + /// Puts one more in the basket. + private void Add(Product product) => basket.Add(product); + + /// What the basket costs: every product's price, summed. + private decimal Total() => basket.Sum(product => product.Price); +} diff --git a/src/website/tools/profiler-film/demos/blazor-storefront/Components/Routes.razor b/src/website/tools/profiler-film/demos/blazor-storefront/Components/Routes.razor new file mode 100644 index 00000000..678b1ccc --- /dev/null +++ b/src/website/tools/profiler-film/demos/blazor-storefront/Components/Routes.razor @@ -0,0 +1,5 @@ + + + + + diff --git a/src/website/tools/profiler-film/demos/blazor-storefront/Components/_Imports.razor b/src/website/tools/profiler-film/demos/blazor-storefront/Components/_Imports.razor new file mode 100644 index 00000000..716c0f42 --- /dev/null +++ b/src/website/tools/profiler-film/demos/blazor-storefront/Components/_Imports.razor @@ -0,0 +1,5 @@ +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using BlazorStorefront +@using BlazorStorefront.Components diff --git a/src/website/tools/profiler-film/demos/blazor-storefront/Directory.Build.props b/src/website/tools/profiler-film/demos/blazor-storefront/Directory.Build.props new file mode 100644 index 00000000..cdccb2a9 --- /dev/null +++ b/src/website/tools/profiler-film/demos/blazor-storefront/Directory.Build.props @@ -0,0 +1,3 @@ + + + diff --git a/src/website/tools/profiler-film/demos/blazor-storefront/Program.cs b/src/website/tools/profiler-film/demos/blazor-storefront/Program.cs new file mode 100644 index 00000000..4a15f915 --- /dev/null +++ b/src/website/tools/profiler-film/demos/blazor-storefront/Program.cs @@ -0,0 +1,9 @@ +using BlazorStorefront.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/website/tools/profiler-film/films/blazor.mjs b/src/website/tools/profiler-film/films/blazor.mjs new file mode 100644 index 00000000..343011ad --- /dev/null +++ b/src/website/tools/profiler-film/films/blazor.mjs @@ -0,0 +1,114 @@ +// Implements [WEB-DESIGN-MEDIA] and shows [RAZOR-FEATURES]. The "Blazor and +// Razor" walkthrough (`blazor`): C# hover, completion, diagnostics, navigation +// and rename inside a Blazor component's markup and `@code` block, HTML from +// VS Code's own HTML service, a component's definition reached from C#, and +// linked tag editing and folding. +// +// node src/website/tools/profiler-film/film.mjs blazor +import { snap } from '../kit/camera.mjs'; +import { clickOn, hoverOn } from '../kit/code.mjs'; +import { LANGUAGE, hidePanel, hoverEach, navigate, peekReferences, renameTo, scene, typeText, typeThroughCompletion } from '../kit/acts/language.mjs'; +import { ready } from '../kit/acts/shop.mjs'; +import { BLAZOR_STOREFRONT } from '../kit/set.mjs'; +import { goTo, openFile, sleep, typeAt } from '../kit/workbench.mjs'; + +const BASKET = 'Basket.razor'; +const AT_BASKET = [BASKET, { line: 18, word: 'Total' }]; +const AT_PROGRAM = ['Program.cs', { line: 8, word: 'MapRazorComponents' }]; + +/** Line 19: the cheapest product, typed through LINQ's members and then the record's. */ +const cheapest = ({ page }) => + typeThroughCompletion(page, 18, '\n

Cheapest: @(basket.', [ + ['MinB', '(product => product.'], + ['Pri', ')?.Name)

'], + ]); + +/** 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 }; }