diff --git a/Cargo.lock b/Cargo.lock index 700864d0..599c7ad9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1148,9 +1148,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -1946,9 +1946,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] diff --git a/docs/plans/RAZOR-PLAN.md b/docs/plans/RAZOR-PLAN.md index 561a3f3f..5197f243 100644 --- a/docs/plans/RAZOR-PLAN.md +++ b/docs/plans/RAZOR-PLAN.md @@ -20,7 +20,7 @@ The phases below are in dependency order. Each item is checked only when a test - [x] Fixture solution `src/sharplsp/tests/fixtures/razor/` with `BlazorApp`, `Widgets` (RCL), `PagesApp` (Pages + MVC) and `Domain` (F#), isolated by its own `Directory.Build.props` ([RAZOR-TESTING]) - [x] Host: add `LangId::Razor` for `.razor` and `.cshtml`, owned by the C# sidecar ([RAZOR-ARCHITECTURE-ROUTING]) -- [x] Host: route `documentSymbol`, `foldingRange` and `linkedEditingRange` for Razor to the sidecar, and answer `selectionRange` empty +- [x] Host: route `documentSymbol`, `foldingRange` and `linkedEditingRange` for Razor to the sidecar, and answer `selectionRange` empty (the Deferred selection-range item has since routed it to the sidecar too) - [x] Host: add `<` and `@` completion triggers, and short-circuit them for C# and F# documents - [x] Host: publish Razor diagnostics under the source `sharplsp-razor` - [x] Sidecar: bundle the Razor compiler from the build SDK, and list it in `THIRD-PARTY-NOTICES.md` ([RAZOR-ARCHITECTURE-ENGINE]) @@ -76,7 +76,7 @@ The phases below are in dependency order. Each item is checked only when a test - Renaming `Badge.Text` from its code-behind edits `@Text` and Board's `Text=`. - Renaming `Tally.Label` turns `@bind-Label` into `@bind-Title`. - prepareRename refuses on a component's start and end tags. - - The F# half of the rename is Phase 6 + - The F# half of the rename is Phase 5 ### Phase 3: Blazor Markup @@ -110,11 +110,30 @@ The phases below are in dependency order. Each item is checked only when a test ### Phase 4: Razor Pages and MVC -- [ ] `@model`, `@inject`, `@section` and `@functions` through the projection ([RAZOR-MVC]) -- [ ] Tag helper elements and attributes: hover and completion -- [ ] `_ViewImports.cshtml` imports resolved by the engine - -### Phase 5: Editors +- [x] `@model`, `@inject`, `@section` and `@functions` through the projection ([RAZOR-MVC-DIRECTIVES]) + - `RazorMvcTests.Model_inject_and_functions_answer_through_the_projection`: `@model EditModel` and `Model.Title` hover their docs, `@inject` hovers `ILogger<…>`, and `@Shout` goes to its `@functions` declaration. + - `Model_expressions_and_section_code_get_csharp_features`: `asp-for="Title"` hovers and goes to `EditModel.Title`, finds its 4 references in `Edit.cshtml`, and renames them all with no generated-file edit; `@Model.Price` inside `@section` hovers. `Section_blocks_fold_and_outline` folds and outlines `@section`. + - Full stack: `test_full_stack_razor_pages_and_mvc_views` (`razor_mvc.rs`). +- [x] Tag helper elements and attributes: hover and completion ([RAZOR-MVC-TAGHELPERS], [RAZOR-MVC-SCOPE]) + - Hover: `Tag_helper_elements_and_attributes_answer_hover_from_their_descriptors` (`` answers `InputTagHelper`, `asp-for` its `ModelExpression`). + - Completion: `Tag_helper_attributes_complete_in_an_element_start_tag` (`asp-page`, `asp-controller`, `asp-route-`) and `Tag_helper_elements_complete_after_an_open_angle_bracket` (`partial`, `environment`, `a` once, the `th:` prefix, `@removeTagHelper`). That test found components completing after `<` in `.cshtml` files, where no component binds by tag; they no longer do. + - Scope: `Tag_helper_directives_scope_completion_and_binding`. VS Code: `a Razor Page completes tag helper elements once, before the HTML service` (an element that both HTML and a tag helper name is listed once). +- [x] `_ViewImports.cshtml` imports resolved by the engine ([RAZOR-MVC-IMPORTS]) — `ViewImports_imports_reach_the_views_below_them`: `@model Item` resolves through `Views/_ViewImports.cshtml`'s `@using`, the view reports no error, and its `@addTagHelper` binds `` + +### Phase 5: F# + +- [x] Hover, completion and definition from Razor into F# types of a referenced F# project ([RAZOR-FSHARP-METADATA], [RAZOR-FSHARP-DEFINITION]) + - Hover shows the F# XML docs: the design-time build references Domain's `refint` assembly, which has no XML, so the sidecar swaps in the built DLL with its documentation. `RazorFSharpTests.Hover_and_signature_help_read_FSharp_symbols_from_their_assembly` and `Completion_offers_FSharp_members_in_markup_and_code`. + - Definition lands in `Scores.fs`, not decompiled source: the C# sidecar names each F# symbol by assembly and XML doc ID, and the F# sidecar finds its declaration (`workspace/declarationForeign`, [DEFINITION-CROSSLANG-IDENTITY]). `Definition_names_each_FSharp_symbol_for_the_FSharp_sidecar`; F#: `FSharpForeignSymbolsTests`. The C#→F# definition e2e now lands in `Shapes.fs` too. + - References merge both sides by the same identity (`workspace/referencesForeign`, [REFERENCES-CROSS-LANGUAGE]): `Foreign_references_and_rename_reach_Razor_uses_of_an_FSharp_symbol`. + - F# references and highlights placed a dotted use (`player.Score`) over its whole path. They now sit on the name, the same span the foreign path reports (`F# references and foreign uses place each use on its name alike`). +- [x] F#-origin rename that includes Razor usages, mapped back from the projections ([RAZOR-FSHARP-RENAME]) + - From F#: `Foreign_references_and_rename_reach_Razor_uses_of_an_FSharp_symbol` renames `Leaders.razor`'s `@player.Score`. + - From Razor or C#: the rename resolves to the F# symbol and renames it everywhere (`Rename_from_Razor_renames_an_FSharp_symbol_in_CSharp_and_Razor`). + - Full stack: `test_full_stack_fsharp_in_razor_session` (hover, definition into `Scores.fs`, references with and without the declaration, rename from Razor and from F#). VS Code: `F# symbols in Razor navigate to, find and rename in their F# source`. + - That full-stack test only restored the fixture, so C# had no `Domain.dll` to read and every F# symbol stayed unresolved ([DEFINITION-CROSSLANG]: the F# project must be built). Its workspace now builds the F# library (`create_razor_workspace_with_fsharp_built`). + +### Phase 6: Editors - [x] VS Code: sync `razor` and `aspnetcorerazor` documents, and activate on `onLanguage:razor` ([RAZOR-EDITORS]) — `razor-lsp.test.ts`: a Razor file opens as Razor, and its unsaved edits reach the server - [x] VS Code: forward HTML completion and hover over `sharplsp/razor/htmlProjection` ([RAZOR-FEATURES-HTML]) — `razor-lsp.test.ts`: element names and HTML hovers in markup, C# completion and hover in code @@ -122,16 +141,21 @@ The phases below are in dependency order. Each item is checked only when a test - [x] VS Code: add a `razor` end-to-end chunk on both platforms — `test-chunks.json`, in both matrices - [x] Zed: Razor language registration (Zed has no built-in Razor grammar) — `the_server_attaches_to_the_csharp_fsharp_and_razor_languages` -### Phase 6: F# - -- [ ] Hover, completion and definition from Razor into F# types of a referenced F# project ([RAZOR-FSHARP]) -- [ ] F#-origin rename that includes Razor usages, mapped back from the projections - -### Deferred ([RAZOR-FEATURES-DEFERRED]) - -- [ ] Razor formatting -- [ ] Selection ranges -- [ ] Code actions: extract a block to code-behind, extract a component, generate an event handler, add `@using` -- [ ] Inlay hints, code lens, and call and type hierarchy from Razor positions -- [ ] `.razor.css` and `.razor.js` collocation awareness -- [ ] Blazor WebAssembly debugging and Razor Hot Reload +### Deferred + +- [x] Razor formatting — resolved as out of scope: SharpLsp formats no language ([SHARPLSP-FEATURES-FORMATTING]), so the spec now says so for Razor ([RAZOR-SCOPE-FORMATTING]) and nothing is advertised +- [x] Selection ranges ([RAZOR-FEATURES-SELECTION]) — `RazorSelectionRangesTests` (6 cases: C# chains through syntax nodes, tag names through enclosing elements, attribute names and values, implicit expressions, legacy files, unlisted files); `test_full_stack_razor_selection_ranges`; the routing session's `selectionRange` now asserts the exact chain. An attribute's whole span comes from the compiler's spans (a new `MarkupAttribute.End` fact), not a scan of the text +- [x] Code actions: extract a block to code-behind, extract a component, generate an event handler, add `@using` ([RAZOR-CODE-ACTIONS]) — `RazorCodeActionsTests` (5 cases). A new `@using` goes after a Razor Page's `@page` line, never above it (`A_new_import_follows_the_page_directive_a_Razor_Page_opens_with`). New files arrive as LSP `CreateFile` operations (`map_workspace_edit_creates_new_files_before_editing_them`) +- [x] Inlay hints, code lens, and call and type hierarchy from Razor positions ([RAZOR-FEATURES-INLAY-HINTS], [RAZOR-FEATURES-CODE-LENS], [RAZOR-FEATURES-HIERARCHY]) — `RazorHintsAndLensesTests` (2), `RazorHierarchyTests` (3), `test_full_stack_razor_hierarchy_and_hints` + - Outgoing calls from a Razor projection threw: the resolver read the declaration through `Solution.GetDocument`, which is null for a source-generated document. It now reads each declaration through its own tree + - Markup callers are the component, named by its file with kind `Class`, never the generated `BuildRenderTree` + - The C# sidecar named hierarchy kinds in lowercase, while the host maps PascalCase (the F# sidecar's casing). So every C# class, property or interface item reached the client as a method or class. The C# kinds are now PascalCase, and the e2e asserts the LSP kinds +- [x] `.razor.css` and `.razor.js` collocation awareness ([RAZOR-COLLOCATION]) — `RazorCollocationTests` (6), `RazorStylesheetTests` (10), `HtmlMarkupContextTests` (attribute at a caret), `test_full_stack_razor_collocated_styles_and_scripts`, VS Code `a component completes and defines its stylesheet classes, and nests its collocated files` + - Classes come from a CSS Syntax Level 3 tokenizer over rule preludes; a class completes as LSP `Value` and defines to every selector that declares it. A module literal in an `"import"` call defines to its `.razor.js`, from Razor, a code-behind and plain C# + - The attribute at a caret is read by the HTML tokenizer, continued past the caret. Review found three bugs in a text-scanning first version: an attribute after a bare one (``, `
`, ``) is a tag of the file's markup facts, with the tag helpers bound to it. Its attributes that the tag helpers bind (`asp-for`, `asp-page`, `asp-route-*`) are `Property` attributes. +- **Hover:** a bound element answers the tag helper type's hover, and a bound attribute the hover of the property it sets. +- **Completion after `<`:** each element a tag helper in scope targets by name, spelled with the scope's prefix, once, as a `Class` item whose detail names the tag helpers that bind it. A rule that targets every element (`*`) names no element. The client drops its own HTML item with the same label. +- **Completion in a start tag:** the attributes that the tag helpers targeting the element bind, not yet set, and each indexer's prefix (`asp-route-`). +- **Model expressions:** an attribute whose property is a `ModelExpression` (`asp-for="Title"`) holds a C# model expression, so it gets C# hover, definition, references and rename through the projection. Its value is blank in the HTML projection; every other literal value stays. + +### [RAZOR-MVC-SCOPE] Tag Helper Scope + +The tag helpers a legacy file can use are those its imports' and its own `@addTagHelper`, `@removeTagHelper` and `@tagHelperPrefix` directives declare, applied in order, as the compiler applies them: + +- `@addTagHelper TypePattern, Assembly` adds the assembly's tag helpers whose full name the pattern names: all of them for `*`, those whose name starts with what precedes a trailing `*`, or the one so named. A `global::` prefix is ignored. A lookup the compiler rejects adds none. +- `@removeTagHelper` removes the tag helpers its lookup names, wherever in the file it is written. +- `@tagHelperPrefix p:` makes only elements spelled `p:name` tag helper elements. They bind, complete and answer hover under the prefixed name, and unprefixed elements do none of it. +- A component file has no tag helper scope: its MVC tag helpers never bind. A legacy file binds no component by its tag, so components never complete after `<` there. + +### [RAZOR-MVC-IMPORTS] Imports + +- `_ViewImports.cshtml` is an import. Its `@using`, `@addTagHelper` and other directives reach every view and page in its folder and below, read from the workspace's current text ([RAZOR-ARCHITECTURE-ENGINE]). +- `_ViewStart.cshtml`, layouts and partials are ordinary legacy files. ## [RAZOR-FSHARP] F# in Razor Solutions -Blazor front ends often sit over F# domain libraries. A Razor project that references an F# project reads it as its built DLL, like any C# project does ([DEFINITION-CROSSLANG]). +Blazor front ends often sit over F# domain libraries. A Razor project that references an F# project reads it as its built DLL, like any C# project does ([DEFINITION-CROSSLANG]). Every feature below reaches the F# project through the symbol's identity, its assembly and XML doc signature, so F# symbols in Razor files are as navigable and renameable as C# ones ([DEFINITION-CROSSLANG-IDENTITY]). + +### [RAZOR-FSHARP-METADATA] Hover, Completion and Signature Help + +- These work on F# types and members used in Razor, through Domain's metadata: records and their fields, unions and their cases' tests (`IsLeading`), and module functions. +- Hover shows the F# documentation, read from the F# project's XML documentation file. +- A curried F# function is a method of all its parameters to C#, so signature help lists them all. + +### [RAZOR-FSHARP-DEFINITION] Definition + +- Definition, type definition and declaration on an F# symbol from Razor land on its F# declaration in the `.fs` file, ranged on its name. +- A record's or union's constructor (`new Player(…)`) lands on its type. +- Until the F# sidecar has loaded, the answer is the decompiled source, and it is not cached. + +### [RAZOR-FSHARP-REFERENCES] References + +- References from Razor on an F# symbol hold its Razor and C# uses, its F# uses and, when asked for, its F# declaration ([REFERENCES-CROSS-LANGUAGE]). +- References that start in F# hold the symbol's C# and Razor uses too. Each Razor use is mapped back to its Razor file and never reported in generated code. + +### [RAZOR-FSHARP-RENAME] Rename + +- **From F#:** renaming an F# symbol renames its Razor uses too. The Rust host asks the C# sidecar for the C# uses of the symbol ([RENAME-CROSSLANGUAGE]). Those uses include the projections, and each one is mapped back to its Razor file, never edited in generated code. +- **From Razor or C#:** `prepareRename` accepts an F# symbol on its name. The C# sidecar edits its C# and Razor uses, and the F# sidecar renames its declaration and F# uses, in one workspace edit. +- **A C# symbol from Razor:** the identity a rename sends to the F# sidecar resolves through the projection, so the symbol's F# uses are renamed too. + +### [RAZOR-FSHARP-LANGUAGE] No F# Razor -- **Hover, completion and signature help:** these work on F# types and members used in Razor through that metadata. -- **Definition:** definition on an F# symbol from Razor lands where it lands from C#: in decompiled metadata-as-source for the symbol's type ([DEFINITION-CROSSLANG]). -- **Rename from F#:** renaming an F# symbol renames its Razor usages too. The Rust host asks the C# sidecar for the C# usages of the symbol ([RENAME-CROSSLANGUAGE]). Those usages include the projections, and each one is mapped back to its Razor file, never edited in generated code. -- **References from F#:** references that start in F# reach C# and Razor usages only where they reach C# usages today ([DEFINITION-CROSSLANG]). -- **No F# Razor:** F# is not a Razor code language. A Razor `@code` block is C#. +F# is not a Razor code language, because the Razor compiler generates only C#. A Razor `@code` block is C#. ## [RAZOR-EDITORS] Editors diff --git a/docs/specs/REFERENCES-SPEC.md b/docs/specs/REFERENCES-SPEC.md index 68a0c758..8188dc22 100644 --- a/docs/specs/REFERENCES-SPEC.md +++ b/docs/specs/REFERENCES-SPEC.md @@ -131,7 +131,7 @@ F# references MUST search every compile item in the loaded project; a file-local 3. From the `FSharpSymbolUse.Symbol`, call [`GetUsesOfSymbolInFile()`](https://fsharp.github.io/fsharp-compiler-docs/) for document-scoped results. 4. For project-wide results, iterate all project files and call `GetAllUsesOfAllSymbolsInFile()` on each, filtering to the target symbol by `FSharpSymbol` equality. 5. If `context.includeDeclaration` is true, include the symbol's declaration range. -6. Map each `FSharpSymbolUse.Range` to LSP `Location`. +6. Map each use to an LSP `Location` on the symbol's name. FCS ranges a dotted use (`player.Score`) over the whole path, so the name is the identifier token at the range's end that spells the symbol's name; a use no token spells keeps FCS's range. Document highlights use the same name ranges. ### textDocument/documentHighlight `[REFERENCES-FSHARP-HIGHLIGHT]` @@ -158,10 +158,13 @@ When a C# project references an F# project (or vice versa), find-all-references | Scenario | Approach | |---|---| -| C# symbol used in F# code | C# sidecar finds references in C# projects → Rust host also dispatches to F# sidecar for F# projects | -| F# symbol used in C# code | F# sidecar finds references in F# projects → Rust host also dispatches to C# sidecar for C# projects | +| C# symbol used in F# code | C# sidecar finds references in C# projects and Razor files → the Rust host asks it for the symbol's identity, then asks the F# sidecar for the F# uses of that identity | +| F# symbol used in C# or Razor code | F# sidecar finds references in F# projects → the Rust host asks it for the symbol's identity, then asks the C# sidecar for the C# and Razor uses of that identity | +| F# symbol, asked from a C# or Razor use | C# sidecar finds the C# and Razor uses of the metadata symbol → the Rust host asks the F# sidecar for its F# declaration and uses by the same identity | -The Rust host merges results from both sidecars and deduplicates by location. +- **Identity:** the symbol's assembly and XML doc signature, from `textDocument/renameIdentity` ([DEFINITION-CROSSLANG-IDENTITY]). A Razor position resolves through its projection. +- **Request:** `workspace/referencesForeign` (`{ assemblyName, xmlDocSig, includeDeclaration }`) answers a location list. The C# sidecar searches every C# project and maps Razor uses back to their Razor files, dropping hidden scaffolding. The F# sidecar searches only the project that builds the assembly and its in-memory readers, or, for an assembly no F# project builds, the F# projects that reference it. It never walks every file of the workspace. +- **Merge:** the Rust host appends the other side's locations to the primary's, each place once. A missing, loading or failing other sidecar adds nothing, so the primary answer always stands. ## Extended Results `[REFERENCES-EXTENSIONS]` diff --git a/docs/specs/RENAME-SPEC.md b/docs/specs/RENAME-SPEC.md index a678b7c9..c260eba2 100644 --- a/docs/specs/RENAME-SPEC.md +++ b/docs/specs/RENAME-SPEC.md @@ -119,6 +119,8 @@ Public symbols used across C# and F# project boundaries MUST be renamed across b - C# symbol renamed with F# references: C# sidecar computes C# edits; Rust host asks F# sidecar for F# usages and merges edits. - F# symbol renamed with C# references: F# sidecar computes F# edits; Rust host asks C# sidecar for C# usages and merges edits. +- F# symbol renamed from a C# or Razor use: C# reads it from the F# project's DLL. `prepareRename` accepts it on its name, and the C# sidecar computes the C# and Razor edits by the symbol's identity ([DEFINITION-CROSSLANG-IDENTITY]); the Rust host asks the F# sidecar to rename the declaration and its F# uses, and merges the edits. +- A rename asked from a Razor file resolves the symbol's identity through the file's projection, so its other-language uses are renamed too ([RAZOR-FSHARP-RENAME]). - Conflicting or overlapping edits MUST fail the rename with a clear error instead of returning a partial edit. ## [RENAME-ERRORS] Error Handling diff --git a/src/editors/vscode/package.json b/src/editors/vscode/package.json index 7b08a348..e45472dd 100644 --- a/src/editors/vscode/package.json +++ b/src/editors/vscode/package.json @@ -42,7 +42,9 @@ "workspaceContains:**/*.fsproj", "onLanguage:razor", "onDebugResolve:sharplsp-coreclr", - "onDebugDynamicConfigurations:sharplsp-coreclr" + "onDebugDynamicConfigurations:sharplsp-coreclr", + "onDebugResolve:sharplsp-blazorwasm", + "onDebugDynamicConfigurations:sharplsp-blazorwasm" ], "main": "./dist/extension.js", "capabilities": { @@ -365,6 +367,12 @@ } } }, + "configurationDefaults": { + "explorer.fileNesting.patterns": { + "*.razor": "${capture}.razor.cs, ${capture}.razor.css, ${capture}.razor.js", + "*.cshtml": "${capture}.cshtml.cs, ${capture}.cshtml.css, ${capture}.cshtml.js" + } + }, "debuggers": [ { "type": "sharplsp-coreclr", @@ -573,6 +581,67 @@ "stopAtEntry": false } ] + }, + { + "type": "sharplsp-blazorwasm", + "label": "Blazor WebAssembly", + "configurationAttributes": { + "launch": { + "required": [], + "properties": { + "project": { + "type": "string", + "description": "The Blazor WebAssembly .csproj/.fsproj to run. Omit to use the project the active document belongs to." + }, + "launchProfile": { + "type": "string", + "description": "The Properties/launchSettings.json profile to run, by name. Omit to use the project's only Project profile." + }, + "browser": { + "type": "string", + "enum": [ + "chrome", + "edge" + ], + "default": "chrome", + "description": "The browser to debug, through VS Code's built-in JavaScript debugger." + }, + "url": { + "type": "string", + "description": "The page to open. Omit to use the address the DevServer announces." + }, + "webRoot": { + "type": "string", + "description": "The directory served URLs map onto. Omit to use the project's wwwroot." + }, + "timeout": { + "type": "number", + "default": 30000, + "description": "How long the browser debugger waits for the page to load." + } + } + } + }, + "configurationSnippets": [ + { + "label": "Blazor WebAssembly: Launch", + "description": "Run a Blazor WebAssembly project and debug it in the browser", + "body": { + "type": "sharplsp-blazorwasm", + "request": "launch", + "name": "Launch Blazor WebAssembly", + "browser": "chrome" + } + } + ], + "initialConfigurations": [ + { + "type": "sharplsp-blazorwasm", + "request": "launch", + "name": "Launch Blazor WebAssembly", + "browser": "chrome" + } + ] } ], "viewsContainers": { diff --git a/src/editors/vscode/src/blazor-wasm-debug.ts b/src/editors/vscode/src/blazor-wasm-debug.ts new file mode 100644 index 00000000..da38c0a7 --- /dev/null +++ b/src/editors/vscode/src/blazor-wasm-debug.ts @@ -0,0 +1,443 @@ +// Blazor WebAssembly launch resolution: everything a `sharplsp-blazorwasm` launch +// decides that never touches a process. +// +// Implements [DEBUG-FEATURES-LAUNCH-BLAZOR-WASM]. +// +// A Blazor WebAssembly app is served by `Microsoft.AspNetCore.Components.WebAssembly +// .DevServer` (or `app.UseWebAssemblyDebugging()`), which exposes the .NET debug proxy +// at `/_framework/debug/ws-proxy`. VS Code's built-in JavaScript debugger reaches .NET +// breakpoints through that proxy when it is handed an `inspectUri`, so this tier's whole +// job is to turn a launch configuration into the `pwa-chrome`/`pwa-msedge` one js-debug +// understands. Process control lives in blazor-wasm-launch.ts. +import * as fs from 'node:fs'; +import { XMLParser } from 'fast-xml-parser'; +import type * as vscode from 'vscode'; +import { + directoryOf, + extensionOf, + fileNameOf, + isAbsolutePath, + joinPath, + resolvePath, +} from './paths'; +import { resolveLaunchTarget } from './launch-resolver'; +import { projectProfiles, readProfiles, type LaunchProfile } from './launch-profiles'; +import { err, ok, type Result } from './result'; +import { isRecord } from './utils'; + +/** The name a launch that states none is listed under. */ +export const DEFAULT_LAUNCH_NAME = 'Launch Blazor WebAssembly'; + +/** The debug type this extension contributes for Blazor WebAssembly. */ +export const BLAZOR_WASM_DEBUG_TYPE = 'sharplsp-blazorwasm'; + +/** The SDK a standalone Blazor WebAssembly project names on its `Project` element. */ +export const BLAZOR_WASM_SDK = 'Microsoft.NET.Sdk.BlazorWebAssembly'; + +/** The browsers js-debug ships a debug type for. */ +export type BlazorWasmBrowser = 'chrome' | 'edge'; + +/** Chromium: the browser a `blazorwasm` template profile is written for. */ +export const DEFAULT_BLAZOR_WASM_BROWSER: BlazorWasmBrowser = 'chrome'; + +/** + * The `inspectUri` the `blazorwasm` template writes into `launchSettings.json`. + * + * A launch profile that states none still has to be debuggable, so this is the + * fallback. `{browserInspectUri}` is deliberately LEFT in place: js-debug + * substitutes it with the address of the browser tab it opened, which is + * information only it has. + */ +export const DEFAULT_INSPECT_URI = + '{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}'; + +/** How long js-debug waits for the page before it gives up. */ +export const DEFAULT_BROWSER_TIMEOUT_MS = 30_000; + +/** The two `contributes.debuggers` labels the browsers map onto. */ +const BROWSER_DEBUG_TYPES: Readonly> = { + chrome: 'pwa-chrome', + edge: 'pwa-msedge', +}; + +/** The prefix the ASP.NET Core host logs a bound address behind. */ +const LISTENING_PREFIX = 'Now listening on:'; + +/** The tokens a launch profile's `inspectUri` carries. */ +const WS_PROTOCOL_TOKEN = '{wsProtocol}'; +const HOSTNAME_TOKEN = '{url.hostname}'; +const PORT_TOKEN = '{url.port}'; + +/** Attributes are read with an `@_` prefix, the spelling dependencies.ts uses. */ +const ATTRIBUTE_PREFIX = '@_'; + +/** One parser for every project file this module reads. */ +const projectParser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: ATTRIBUTE_PREFIX, +}); + +/** + * A browser name from user input, defaulting to Chromium. + * + * `browser` arrives from a hand-written `launch.json`, so it can be absent or + * misspelled; an unknown value MUST NOT refuse the launch, because the only + * thing a wrong browser name changes is which of two identical debug types is + * started. + */ +export function browserOf(value: unknown): BlazorWasmBrowser { + return typeof value === 'string' && value.trim().toLowerCase() === 'edge' ? 'edge' : 'chrome'; +} + +/** The js-debug type that drives `browser`. */ +export function browserDebugType(browser: BlazorWasmBrowser): string { + return BROWSER_DEBUG_TYPES[browser]; +} + +/** A URL from process output, or undefined when the text is not one. */ +function httpUrlOf(token: string): string | undefined { + try { + const parsed = new URL(token); + return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? token : undefined; + } catch { + return undefined; + } +} + +/** The first space-delimited token of `text`. */ +function firstToken(text: string): string { + const end = text.indexOf(' '); + return end === -1 ? text : text.slice(0, end); +} + +/** + * The URL a `Now listening on: ` line announces. + * + * `dotnet run` interleaves that line with build output, restore warnings and + * the SDK's telemetry notice, so the line is found by its prefix and the URL by + * parsing the token after it — text that merely *contains* a URL is not an + * announcement, which is what keeps `Content root path:` and the dev + * certificate warning out of the answer. + */ +export function listeningUrlOf(line: string): string | undefined { + const trimmed = line.trim(); + const at = trimmed.indexOf(LISTENING_PREFIX); + if (at === -1) return undefined; + return httpUrlOf(firstToken(trimmed.slice(at + LISTENING_PREFIX.length).trim())); +} + +/** Every URL announced in a whole block of output, in order. */ +export function listeningUrlsOf(output: string): string[] { + return output.split('\n').flatMap((line) => { + const url = listeningUrlOf(line); + return url === undefined ? [] : [url]; + }); +} + +/** Output split into whole lines, plus the unfinished tail of the last one. */ +export interface LineBuffer { + /** The tail of a line whose newline has not arrived yet. */ + readonly pending: string; + /** The lines completed by the chunk. */ + readonly lines: readonly string[]; +} + +/** + * Split one output chunk into whole lines. + * + * A child's output arrives in arbitrary chunks, so `Now listening on:` lands + * split across two of them often enough to matter; keeping the tail makes the + * announcement findable wherever the pipe happened to cut it. + */ +export function takeLines(pending: string, chunk: string): LineBuffer { + const parts = `${pending}${chunk}`.split('\n'); + const tail = parts.pop() ?? ''; + return { pending: tail, lines: parts }; +} + +/** + * `pattern` with the host, port and socket protocol of `appUrl` filled in. + * + * `{browserInspectUri}` is left alone: js-debug owns it. An unparseable + * `appUrl` leaves the pattern untouched rather than producing a half-expanded + * URL the browser would refuse to load. + */ +export function expandInspectUri(pattern: string, appUrl: string): string { + const parsed = urlOf(appUrl); + if (parsed === undefined) return pattern; + return pattern + .replaceAll(WS_PROTOCOL_TOKEN, parsed.protocol === 'https:' ? 'wss' : 'ws') + .replaceAll(HOSTNAME_TOKEN, parsed.hostname) + .replaceAll(PORT_TOKEN, parsed.port); +} + +/** `value` as a URL, or undefined when it is not one. */ +function urlOf(value: string): URL | undefined { + try { + return new URL(value); + } catch { + return undefined; + } +} + +/** The `inspectUri` for a launch: the profile's own, or the template's default. */ +export function inspectUriFor(pattern: string | undefined, appUrl: string): string { + const usable = + pattern === undefined || pattern.trim().length === 0 ? DEFAULT_INSPECT_URI : pattern; + return expandInspectUri(usable, appUrl); +} + +/** Everything the browser session needs, once the app is listening. */ +export interface BrowserSessionOptions { + /** The configuration name the session is listed under. */ + readonly name: string; + /** The URL to open. */ + readonly url: string; + /** The .NET debug proxy URL, already expanded. */ + readonly inspectUri: string; + readonly browser: BlazorWasmBrowser; + /** Where js-debug maps served URLs back onto source files. */ + readonly webRoot: string; + /** js-debug's own wait for the page. */ + readonly timeout: number; +} + +/** + * The js-debug launch configuration a Blazor WebAssembly session is handed to. + * + * `inspectUri` is the whole bridge: without it js-debug attaches to the browser + * only, and every .NET breakpoint in a `.razor` file or a C# class silently + * never binds. + */ +export function browserConfiguration(options: BrowserSessionOptions): vscode.DebugConfiguration { + return { + type: browserDebugType(options.browser), + request: 'launch', + name: options.name, + url: options.url, + inspectUri: options.inspectUri, + webRoot: options.webRoot, + timeout: options.timeout, + }; +} + +/** The `wwwroot` directory a Blazor WebAssembly project serves from. */ +export function webRootOf(projectFile: string): string { + return joinPath(directoryOf(projectFile), 'wwwroot'); +} + +/** The `dotnet run` arguments for `projectFile`, without a shell in between. */ +export function runArguments( + projectFile: string, + launchProfile: string | undefined, +): readonly string[] { + const named = launchProfile === undefined ? '' : launchProfile.trim(); + const args = ['run', '--project', projectFile]; + return named.length === 0 ? args : [...args, '--launch-profile', named]; +} + +/** + * The launch profile a launch runs under. + * + * The profile is named when the configuration says so; otherwise the project's + * SOLE `Project` profile applies, exactly as [DEBUG-FEATURES-LAUNCH-PROFILES] + * makes it apply to every other launch. Several profiles and no name is not an + * ambiguity to guess at here: the dev server announces whatever URL it binds, + * so the launch still works, it just carries no `inspectUri` of its own. + */ +export function launchProfileOf( + projectFile: string, + named: string | undefined, +): LaunchProfile | undefined { + const eligible = projectProfiles(readProfiles(projectFile)); + if (named === undefined || named.trim().length === 0) { + return eligible.length === 1 ? eligible[0] : undefined; + } + return eligible.find((profile) => profile.name === named); +} + +/** SDK names an MSBuild `Project` element declares, in order. */ +function sdkNamesOf(element: unknown): string[] { + const sdk = isRecord(element) ? element[`${ATTRIBUTE_PREFIX}Sdk`] : undefined; + return typeof sdk === 'string' ? sdk.split(';') : []; +} + +/** True when `element` is a `Project` carrying the Blazor WebAssembly SDK. */ +function isWebAssemblySdk(element: unknown): boolean { + return sdkNamesOf(element).some( + (name) => name.trim().toLowerCase() === BLAZOR_WASM_SDK.toLowerCase(), + ); +} + +/** + * True when project XML declares `Sdk="Microsoft.NET.Sdk.BlazorWebAssembly"`. + * + * Read through a real XML parser, never by matching text: a commented-out + * `` element, an `` item inside an `ItemGroup` and a + * property whose VALUE happens to name the SDK all look identical to a string + * search and are three different facts. + */ +export function isBlazorWasmProject(xml: string): boolean { + if (xml.trim().length === 0) return false; + try { + const parsed: unknown = projectParser.parse(xml); + const project = isRecord(parsed) ? parsed.Project : undefined; + const elements = Array.isArray(project) ? project : [project]; + return elements.some(isWebAssemblySdk); + } catch { + return false; + } +} + +/** True when the project file on disk is a Blazor WebAssembly project. */ +export function isBlazorWasmProjectFile(file: string): boolean { + try { + return isBlazorWasmProject(fs.readFileSync(file, 'utf-8')); + } catch { + return false; + } +} + +/** The Blazor WebAssembly projects among `files`, keeping their order. */ +export function blazorWasmProjects(files: readonly string[]): string[] { + return files.filter(isBlazorWasmProjectFile); +} + +/** The name a generated configuration carries for `projectFile`. */ +export function configurationName(projectFile: string): string { + return `Blazor WebAssembly: ${fileNameOf(projectFile, extensionOf(projectFile))}`; +} + +/** One generated configuration, carrying nothing but what a user must choose. */ +export function blazorWasmConfiguration(projectFile: string): vscode.DebugConfiguration { + return { + type: BLAZOR_WASM_DEBUG_TYPE, + request: 'launch', + name: configurationName(projectFile), + project: projectFile, + browser: DEFAULT_BLAZOR_WASM_BROWSER, + }; +} + +/** One configuration per Blazor WebAssembly project, without prompting. */ +export function blazorWasmConfigurations(files: readonly string[]): vscode.DebugConfiguration[] { + return blazorWasmProjects(files).map(blazorWasmConfiguration); +} + +/** + * How long the app gets to announce a listening URL. + * + * A cold `dotnet run` restores and builds before it binds, which on a first run + * with an empty package cache takes far longer than the bind itself; the wait is + * generous for that reason and still bounded, so a project that never serves + * reports itself instead of leaving a session open on nothing. + */ +export const STARTUP_TIMEOUT_MS = 180_000; + +/** How a launch's startup ended. */ +export type Startup = + | { readonly kind: 'listening'; readonly url: string } + | { readonly kind: 'exited'; readonly outcome: Result } + | { readonly kind: 'timeout' }; + +/** Everything one launch needs, once the project is known. */ +export interface BlazorWasmRequest { + readonly projectFile: string; + readonly launchProfile: LaunchProfile | undefined; + readonly browser: BlazorWasmBrowser; + readonly url: string | undefined; + readonly webRoot: string; + readonly name: string; + readonly timeout: number; +} + +/** Why a launch has no project to run, in the words of a user. */ +const NO_PROJECT_MESSAGE = + 'Blazor WebAssembly launch found no runnable project for this workspace.'; +const NOT_A_PROJECT_MESSAGE = + 'Blazor WebAssembly launches need a .csproj/.fsproj project, not a script or a single file.'; + +/** A `string` field of the configuration, or undefined when absent or empty. */ +export function stringField(config: vscode.DebugConfiguration, key: string): string | undefined { + const value: unknown = config[key]; + return typeof value === 'string' && value.trim().length > 0 ? value : undefined; +} + +/** A positive `number` field of the configuration, or undefined. */ +export function numberField(config: vscode.DebugConfiguration, key: string): number | undefined { + const value: unknown = config[key]; + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined; +} + +/** + * The project a configuration names, or the one the workspace resolves to. + * + * A stated `project` is the user's decision and is used as given; only a + * MISSING one consults the resolver every other launch surface shares + * ([DEBUG-FEATURES-LAUNCH-TARGET]). + */ +export async function resolveProject( + folder: vscode.WorkspaceFolder | undefined, + stated: unknown, +): Promise> { + const root = folder?.uri.fsPath; + const named = typeof stated === 'string' ? stated.trim() : ''; + if (named.length > 0) return projectAt(root, named); + if (folder === undefined) return err(NO_PROJECT_MESSAGE); + const resolved = await resolveLaunchTarget(undefined, folder); + if (!resolved.ok) + return err(resolved.error.trim().length === 0 ? NO_PROJECT_MESSAGE : resolved.error); + if (resolved.value.kind !== 'project') return err(NOT_A_PROJECT_MESSAGE); + return ok(resolved.value.projectFile); +} + +/** `named` as an existing project path, resolved against `root` when relative. */ +export function projectAt(root: string | undefined, named: string): Result { + if (root === undefined && !isAbsolutePath(named)) { + return err(`Cannot resolve '${named}' without a workspace folder.`); + } + const file = isAbsolutePath(named) || root === undefined ? named : resolvePath(root, named); + return fs.existsSync(file) ? ok(file) : err(`Blazor WebAssembly project not found: ${file}`); +} + +/** The request a configuration describes, or why it cannot be launched. */ +export async function requestOf( + folder: vscode.WorkspaceFolder | undefined, + config: vscode.DebugConfiguration, +): Promise> { + const project = await resolveProject(folder, config.project); + if (!project.ok) return project; + const named = stringField(config, 'launchProfile'); + const profile = launchProfileOf(project.value, named); + if (named !== undefined && profile === undefined) { + return err( + `Launch profile '${named}' is not declared by ${fileNameOf(project.value)}. ` + + 'Add it to Properties/launchSettings.json, or omit launchProfile.', + ); + } + return ok({ + projectFile: project.value, + launchProfile: profile, + browser: browserOf(config.browser), + url: stringField(config, 'url'), + webRoot: stringField(config, 'webRoot') ?? webRootOf(project.value), + name: stringField(config, 'name') ?? DEFAULT_LAUNCH_NAME, + timeout: numberField(config, 'timeout') ?? DEFAULT_BROWSER_TIMEOUT_MS, + }); +} + +/** The sentence a startup that never announced an address earns. */ +export function startupFailure(outcome: Startup, projectFile: string): string { + const named = fileNameOf(projectFile); + if (outcome.kind === 'timeout') { + return ( + `dotnet run for ${named} announced no listening URL within ` + + `${String(STARTUP_TIMEOUT_MS / 1000)}s. Check that the project is a Blazor ` + + 'WebAssembly app and that Properties/launchSettings.json binds a URL.' + ); + } + if (outcome.kind === 'exited' && !outcome.outcome.ok) { + return `dotnet run for ${named} stopped before it served anything: ${outcome.outcome.error}`; + } + return `dotnet run for ${named} exited before it announced a listening URL.`; +} diff --git a/src/editors/vscode/src/blazor-wasm-launch.ts b/src/editors/vscode/src/blazor-wasm-launch.ts new file mode 100644 index 00000000..65b5f1cb --- /dev/null +++ b/src/editors/vscode/src/blazor-wasm-launch.ts @@ -0,0 +1,468 @@ +// Launching a Blazor WebAssembly app and handing it to js-debug. +// +// Implements [DEBUG-FEATURES-LAUNCH-BLAZOR-WASM]. +// +// Three moving parts, in this order: `dotnet run` (which starts the DevServer that +// serves both the app and the .NET debug proxy), the announcement of the address it +// bound, and the browser session that receives the expanded `inspectUri`. Everything +// the launch decides WITHOUT a process lives in blazor-wasm-debug.ts; everything here +// is process control and the handoff. +// +// The process is started through dotnet-process.ts rather than a bare `spawn`, so the +// CLI's UI language is pinned (the announcement is English text) and the stop reaps the +// whole tree: `dotnet run` launches the dev server as a CHILD, so signalling only the +// parent leaves the port bound by a process nothing owns. +import * as vscode from 'vscode'; +import { + BLAZOR_WASM_DEBUG_TYPE, + DEFAULT_LAUNCH_NAME, + STARTUP_TIMEOUT_MS, + blazorWasmConfigurations, + browserConfiguration, + browserDebugType, + browserOf, + inspectUriFor, + listeningUrlOf, + requestOf, + runArguments, + startupFailure, + stringField, + takeLines, + type BlazorWasmRequest, + type Startup, +} from './blazor-wasm-debug'; +import { runDotnet, type DotnetRun } from './dotnet-process'; +import { info, traceInfo, warn } from './log'; +import { directoryOf, fileNameOf } from './paths'; +import { err, ok, type Result } from './result'; + +/** + * A backstop on the whole session, not a policy. + * + * dotnet-process.ts kills an invocation that outlives its own ceiling; a dev + * server legitimately lives as long as the browser session does, so the ceiling + * is set far past any debugging session rather than at the run timeout. + */ +const SESSION_CEILING_MS = 24 * 60 * 60 * 1_000; + +/** A `dotnet run` process a launch owns. */ +export interface BlazorWasmProcess { + /** Resolves once the process, and everything it started, is gone. */ + readonly ended: Promise>; + /** Terminates the process and everything it started. */ + stop(): void; +} + +/** Starts `dotnet run`; `onOutput` sees every chunk as it arrives. */ +export type BlazorWasmProcessStarter = ( + args: readonly string[], + cwd: string, + onOutput: (chunk: string) => void, +) => BlazorWasmProcess; + +/** A started browser debug session, as far as the launch cares. */ +export interface BrowserSession { + /** Resolves when the user's browser session ends, however it ends. */ + readonly ended: Promise; +} + +/** Hands a configuration to js-debug, or reports that it refused. */ +export type BrowserSessionStarter = ( + folder: vscode.WorkspaceFolder | undefined, + configuration: vscode.DebugConfiguration, +) => Promise; + +/** + * What a launch needs from outside itself. + * + * A seam, not a testing afterthought: a browser debug session cannot be started + * in a headless test host, and a real dev server takes minutes to build, so the + * suite that proves the handoff replaces exactly these three and nothing else. + */ +export interface BlazorWasmHost { + readonly startProcess: BlazorWasmProcessStarter; + readonly startBrowserSession: BrowserSessionStarter; + /** Every project file under a workspace folder. */ + readonly listProjects: (root: string) => Promise; +} + +/** A launch that is currently running, and how to end it. */ +export interface RunningLaunch { + readonly projectFile: string; + /** The address the DevServer announced. */ + readonly url: string; + /** The configuration js-debug was handed. */ + readonly configuration: vscode.DebugConfiguration; + /** Resolves once the `dotnet run` process is gone. */ + readonly ended: Promise; + /** Ends the launch; the browser session ends with the app. */ + stop(): void; +} + +/** The launches in flight, and the process each one owns. */ +interface LaunchRegistry { + readonly running: Map; + /** The process each launch owns, so only its OWN exit un-registers it. */ + readonly processes: Map; +} + +declare global { + /** The live launches; global for the reason {@link useBlazorWasmHost} is. */ + var __sharplspBlazorWasmLaunches: LaunchRegistry | undefined; +} + +/** The one registry, wherever this module was loaded from. */ +function registry(): LaunchRegistry { + globalThis.__sharplspBlazorWasmLaunches ??= { running: new Map(), processes: new Map() }; + return globalThis.__sharplspBlazorWasmLaunches; +} + +/** The launch in flight for `projectFile`, if any. */ +export function runningBlazorWasmLaunch(projectFile: string): RunningLaunch | undefined { + return registry().running.get(projectFile); +} + +/** + * `dotnet run`, supervised by the one module that knows how to stop a tree. + * + * `runDotnet` never rejects: a spawn that failed, a non-zero exit and a kill all + * arrive as a {@link DotnetRun} the caller can report. + */ +const dotnetRunProcess: BlazorWasmProcessStarter = (args, cwd, onOutput) => { + const controller = new AbortController(); + const ended = runDotnet(args, cwd, SESSION_CEILING_MS, controller.signal, { onOutput }).then( + exitOf, + ); + return { + ended, + stop: () => { + controller.abort(); + }, + }; +}; + +/** What a finished invocation means to a launch: OK, or the reason it is not. */ +function exitOf(run: DotnetRun): Result { + if (!run.failed && !run.killed) return ok(undefined); + const reason = run.errorMessage?.trim(); + return err(reason !== undefined && reason.length > 0 ? reason : 'dotnet run stopped'); +} + +/** + * The shipped browser handoff: `vscode.debug.startDebugging` onto js-debug. + * + * The session is matched by NAME because `startDebugging` answers whether the + * session started, not which one it was; the name is the one this launch wrote + * into the configuration, so the pair is unambiguous. + */ +async function startBrowserSession( + folder: vscode.WorkspaceFolder | undefined, + configuration: vscode.DebugConfiguration, +): Promise { + const started = await vscode.debug.startDebugging(folder, configuration); + if (!started) return undefined; + return { ended: sessionLifetime(configuration.name) }; +} + +/** Resolves when the session named `name` terminates. */ +async function sessionLifetime(name: string): Promise { + await new Promise((resolve) => { + const starting = vscode.debug.onDidStartDebugSession((session) => { + if (session.name !== name) return; + starting.dispose(); + const ending = vscode.debug.onDidTerminateDebugSession((gone) => { + if (gone.id !== session.id) return; + ending.dispose(); + resolve(); + }); + }); + }); +} + +/** Every project file beneath a folder, minus build output and dependencies. */ +async function listProjectFiles(root: string): Promise { + const found = await vscode.workspace.findFiles( + new vscode.RelativePattern(root, '**/*.{csproj,fsproj}'), + '**/{node_modules,bin,obj,target}/**', + ); + return found.map((uri) => uri.fsPath); +} + +/** The shipped launch environment. */ +const shippedHost: BlazorWasmHost = { + startProcess: dotnetRunProcess, + startBrowserSession, + listProjects: listProjectFiles, +}; + +declare global { + /** Overrides on the launch environment; see {@link useBlazorWasmHost}. */ + var __sharplspBlazorWasmHost: Partial | undefined; +} + +/** + * Overrides live on `globalThis`, not in a module variable. + * + * The suite runs from `out/` while the extension runs from the bundled + * `dist/extension.js`, so those are two module instances that share no module + * state; a module-scoped seam would be replaced in a copy nothing calls, and + * the test would pass or fail for reasons unrelated to the launch. hot-reload.ts + * keeps its flag in the same place for the same reason. + */ +function host(): BlazorWasmHost { + return { ...shippedHost, ...(globalThis.__sharplspBlazorWasmHost ?? {}) }; +} + +/** + * Replace part of the launch environment, returning the undo. + * + * Callers MUST restore: a suite that leaves an override in place changes every + * launch the extension makes afterwards, in this window and any later one. + */ +export function useBlazorWasmHost(overrides: Partial): () => void { + const previous = globalThis.__sharplspBlazorWasmHost; + globalThis.__sharplspBlazorWasmHost = { ...previous, ...overrides }; + return () => { + globalThis.__sharplspBlazorWasmHost = previous; + }; +} + +/** A promise, and the one call that settles it. */ +interface Deferred { + readonly promise: Promise; + settle(value: T): void; +} + +/** A {@link Deferred}, resolved exactly once by whoever gets there first. */ +function deferred(): Deferred { + let settle: (value: T) => void = () => undefined; + const promise = new Promise((resolve) => { + settle = resolve; + }); + return { + promise, + settle: (value) => { + settle(value); + }, + }; +} + +/** The app's address, or the reason there is none. */ +async function waitForAddress(startup: Deferred): Promise { + const timer = setTimeout(() => { + startup.settle({ kind: 'timeout' }); + }, STARTUP_TIMEOUT_MS); + try { + return await startup.promise; + } finally { + clearTimeout(timer); + } +} + +/** Streams `dotnet run` output to the log and reports the first bound address. */ +interface OutputScanner { + readonly push: (chunk: string) => void; +} + +/** A scanner that logs whole lines and reports the first listening URL. */ +function outputScanner(onUrl: (url: string) => void): OutputScanner { + let pending = ''; + const feed = (line: string): void => { + const trimmed = line.trimEnd(); + if (trimmed.length > 0) traceInfo(`dotnet run: ${trimmed}`); + const url = listeningUrlOf(trimmed); + if (url !== undefined) onUrl(url); + }; + return { + push: (chunk) => { + const buffer = takeLines(pending, chunk); + pending = buffer.pending; + buffer.lines.forEach(feed); + }, + }; +} + +/** + * One `dotnet run` process, and what became of it. + * + * `exit` is recorded and queryable because a dev server can die between the + * moment it announces its address and the moment the browser session opens; + * registering a launch whose process is already gone would leave the next + * launch believing a DevServer it does not own is still serving the port. + */ +interface ProcessRun { + readonly process: BlazorWasmProcess; + /** Resolves once the process is gone and this launch is un-registered. */ + readonly finished: Promise; + /** How the process ended, or undefined while it is still alive. */ + readonly exit: () => Result | undefined; +} + +/** + * Forget a launch ONLY when the process that ended is the one it owns. + * + * A stopped app takes a moment to exit, and a newer launch of the same project + * can register in that window; deleting by key alone un-registered the LIVE + * launch and left a second DevServer nobody would ever stop. + */ +function forget(projectFile: string, process: BlazorWasmProcess): void { + const live = registry(); + if (live.processes.get(projectFile) !== process) return; + live.processes.delete(projectFile); + live.running.delete(projectFile); +} + +/** Start the app, wait for its address, and hand it to the browser. */ +async function startSession( + folder: vscode.WorkspaceFolder | undefined, + request: BlazorWasmRequest, +): Promise> { + const startup = deferred(); + const scanner = outputScanner((url) => { + startup.settle({ kind: 'listening', url }); + }); + info(`Starting Blazor WebAssembly launch for ${fileNameOf(request.projectFile)}`); + const process = host().startProcess( + runArguments(request.projectFile, request.launchProfile?.name), + directoryOf(request.projectFile), + scanner.push, + ); + let exit: Result | undefined; + const finished = process.ended.then((outcome) => { + exit = outcome; + forget(request.projectFile, process); + startup.settle({ kind: 'exited', outcome }); + }); + const outcome = await waitForAddress(startup); + if (outcome.kind !== 'listening') { + process.stop(); + return err(startupFailure(outcome, request.projectFile)); + } + const run: ProcessRun = { process, finished, exit: () => exit }; + return await handOff(folder, request, { ...run, url: request.url ?? outcome.url }); +} + +/** A process that has announced its address, ready for a browser session. */ +type ServedApp = ProcessRun & { readonly url: string }; + +/** Hand the served app to js-debug, and tie the app's life to that session's. */ +async function handOff( + folder: vscode.WorkspaceFolder | undefined, + request: BlazorWasmRequest, + app: ServedApp, +): Promise> { + const configuration = browserConfiguration({ + name: request.name, + url: app.url, + inspectUri: inspectUriFor(request.launchProfile?.inspectUri, app.url), + browser: request.browser, + webRoot: request.webRoot, + timeout: request.timeout, + }); + info( + `Blazor WebAssembly app for ${fileNameOf(request.projectFile)} is listening on ${app.url}; ` + + `handing it to ${browserDebugType(request.browser)}`, + ); + const session = await host().startBrowserSession(folder, configuration); + const died = app.exit(); + if (session === undefined) { + app.process.stop(); + return err(`Could not start ${browserDebugType(request.browser)} for ${app.url}.`); + } + if (died !== undefined) { + app.process.stop(); + return err(startupFailure({ kind: 'exited', outcome: died }, request.projectFile)); + } + const viaBrowser = session.ended.then(async () => { + info( + `Blazor WebAssembly browser session for ${fileNameOf(request.projectFile)} ended; ` + + 'stopping dotnet run', + ); + app.process.stop(); + await app.finished; + }); + // The launch's whole lifetime: the browser session ends (which stops the app), + // or the app dies on its own. Both paths have reaped the process by the time it + // settles, so a caller that awaits `ended` may rely on the port being free. + const ended = Promise.race([viaBrowser, app.finished]); + const launch: RunningLaunch = { + projectFile: request.projectFile, + url: app.url, + configuration, + ended, + stop: () => { + app.process.stop(); + }, + }; + const live = registry(); + live.processes.set(request.projectFile, app.process); + live.running.set(request.projectFile, launch); + return ok(launch); +} + +/** + * Launch the Blazor WebAssembly app a configuration describes. + * + * The returned launch owns the `dotnet run` process: when the browser session + * that received its `inspectUri` ends, that process — and only the tree it + * started — is terminated. + */ +export async function launchBlazorWasm( + folder: vscode.WorkspaceFolder | undefined, + config: vscode.DebugConfiguration, +): Promise> { + const request = await requestOf(folder, config); + if (!request.ok) return request; + const previous = registry().running.get(request.value.projectFile); + if (previous !== undefined) { + warn( + `Stopping the Blazor WebAssembly launch already running for ${fileNameOf(request.value.projectFile)}`, + ); + previous.stop(); + } + return await startSession(folder, request.value); +} + +/** + * Fills in, and later launches, a `sharplsp-blazorwasm` session. + * + * Implements [DEBUG-FEATURES-LAUNCH-BLAZOR-WASM] and + * [DEBUG-FEATURES-LAUNCH-DYNAMIC]. + */ +export class BlazorWasmDebugProvider implements vscode.DebugConfigurationProvider { + /** Nothing to resolve before `${...}` are expanded: this tier is not a debugger. */ + public resolveDebugConfiguration( + _folder: vscode.WorkspaceFolder | undefined, + config: vscode.DebugConfiguration, + ): vscode.DebugConfiguration { + config.type = BLAZOR_WASM_DEBUG_TYPE; + config.request = config.request === 'attach' ? 'launch' : config.request; + config.browser = browserOf(config.browser); + config.name = stringField(config, 'name') ?? DEFAULT_LAUNCH_NAME; + return config; + } + + /** + * Start the app and hand it to js-debug. + * + * `undefined` AFTER the browser session has started is the handoff, not a + * refusal: VS Code must start no session for `sharplsp-blazorwasm` itself, + * because the session the user debugs is the `pwa-chrome`/`pwa-msedge` one. + */ + public async resolveDebugConfigurationWithSubstitutedVariables( + folder: vscode.WorkspaceFolder | undefined, + config: vscode.DebugConfiguration, + ): Promise { + const outcome = await launchBlazorWasm(folder, config); + if (!outcome.ok) void vscode.window.showErrorMessage(outcome.error); + return undefined; + } + + /** One configuration per Blazor WebAssembly project, generated without a prompt. */ + public async provideDebugConfigurations( + folder: vscode.WorkspaceFolder | undefined, + ): Promise { + if (folder === undefined) return []; + return blazorWasmConfigurations(await host().listProjects(folder.uri.fsPath)); + } +} diff --git a/src/editors/vscode/src/dap-hot-reload.ts b/src/editors/vscode/src/dap-hot-reload.ts index 9f2a0416..5805eced 100644 --- a/src/editors/vscode/src/dap-hot-reload.ts +++ b/src/editors/vscode/src/dap-hot-reload.ts @@ -11,8 +11,12 @@ import * as vscode from 'vscode'; import type { DapMessage } from './dap-emulate'; import { error, traceInfo } from './log'; import * as state from './state'; +import { RAZOR_LANGUAGES } from './razor-html'; import { getErrorMessage, isRecord, removeDirRecursive } from './utils'; +/** The languages whose saves this pipeline owns: C# and Razor ([RAZOR-HOT-RELOAD]). */ +const OWNED_LANGUAGES: readonly string[] = ['csharp', ...RAZOR_LANGUAGES]; + interface HotReloadHost { request(command: string, args: Record): Promise; /** Per-type debugger metadata caches go stale once new IL is committed. */ @@ -353,7 +357,11 @@ export class DapHotReload implements vscode.Disposable { } private owns(document: vscode.TextDocument): boolean { - if (!this.enabled || document.languageId !== 'csharp' || this.projectPath === undefined) { + if ( + !this.enabled || + !OWNED_LANGUAGES.includes(document.languageId) || + this.projectPath === undefined + ) { return false; } return isLexicallyWithin(directoryOf(this.projectPath), document.uri.fsPath); diff --git a/src/editors/vscode/src/debug.ts b/src/editors/vscode/src/debug.ts index 6e598177..95ab36b1 100644 --- a/src/editors/vscode/src/debug.ts +++ b/src/editors/vscode/src/debug.ts @@ -20,6 +20,8 @@ import { findNetcoredbg, getNetcoredbgCandidates } from './netcoredbg'; import { info, warn } from './log'; import { DapRouter } from './dap-router'; import { resolveAttachTarget } from './attach-target'; +import { BlazorWasmDebugProvider } from './blazor-wasm-launch'; +import { BLAZOR_WASM_DEBUG_TYPE } from './blazor-wasm-debug'; import { NO_TARGET_MESSAGE, folderFor, @@ -509,6 +511,7 @@ export function registerDebugAdapter( ): void { activeFrameworkReader = readActiveFramework; const provider = new SharpLspLaunchProvider(); + const blazorWasm = new BlazorWasmDebugProvider(); context.subscriptions.push( // Both trigger kinds: `Initial` fills a generated launch.json, `Dynamic` // puts SharpLsp in the "Show all automatic debug configurations" dropdown. @@ -526,6 +529,20 @@ export function registerDebugAdapter( DEBUG_TYPE, new SharpLspDebugAdapterFactory(context.extensionPath), ), + // The Blazor WebAssembly debugger has no adapter of its own: it starts `dotnet run` + // and hands the app to js-debug ([DEBUG-FEATURES-LAUNCH-BLAZOR-WASM]), so only its + // configuration providers are registered — both trigger kinds, as + // [DEBUG-FEATURES-LAUNCH-DYNAMIC] requires of every SharpLsp debugger. + vscode.debug.registerDebugConfigurationProvider( + BLAZOR_WASM_DEBUG_TYPE, + blazorWasm, + vscode.DebugConfigurationProviderTriggerKind.Initial, + ), + vscode.debug.registerDebugConfigurationProvider( + BLAZOR_WASM_DEBUG_TYPE, + blazorWasm, + vscode.DebugConfigurationProviderTriggerKind.Dynamic, + ), vscode.commands.registerCommand(CMD_DEBUG_PROGRAM, async (argument?: LaunchArgument) => { await launch(argument, false); }), diff --git a/src/editors/vscode/src/hot-reload.ts b/src/editors/vscode/src/hot-reload.ts index 050a1ba2..3545658b 100644 --- a/src/editors/vscode/src/hot-reload.ts +++ b/src/editors/vscode/src/hot-reload.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { hotReloadOnSave } from './config.js'; import { info } from './log.js'; +import { RAZOR_LANGUAGES } from './razor-html.js'; declare global { var __sharplspHotReloadRunning: boolean | undefined; @@ -106,9 +107,9 @@ function handleDocumentSave(doc: vscode.TextDocument): void { info(`Hot Reload triggered on save: ${doc.uri.fsPath}`); } -/** Check if the language is C# or F#. */ +/** Check if the language is C#, F#, or Razor ([RAZOR-HOT-RELOAD]). */ export function isRelevantLanguage(languageId: string): boolean { - return languageId === 'csharp' || languageId === 'fsharp'; + return languageId === 'csharp' || languageId === 'fsharp' || RAZOR_LANGUAGES.includes(languageId); } /** Stop hot reload when a debug session ends. */ diff --git a/src/editors/vscode/src/launch-profiles.ts b/src/editors/vscode/src/launch-profiles.ts index 9bd20fc4..9344de51 100644 --- a/src/editors/vscode/src/launch-profiles.ts +++ b/src/editors/vscode/src/launch-profiles.ts @@ -19,6 +19,17 @@ export interface LaunchProfile { readonly commandLineArgs?: string; readonly environmentVariables?: Readonly>; readonly applicationUrl?: string; + /** + * The .NET debug proxy URL a Blazor WebAssembly profile carries. + * + * `Microsoft.AspNetCore.Components.WebAssembly.DevServer` writes the template's + * `{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser= + * {browserInspectUri}` here, and that value is the whole bridge to .NET + * breakpoints ([DEBUG-FEATURES-LAUNCH-BLAZOR-WASM]). It is parsed with the rest + * of the profile rather than re-read at the launch site, so one reader owns the + * profile document. + */ + readonly inspectUri?: string; } /** ASP.NET reads its listening URLs from this variable. */ @@ -65,6 +76,7 @@ function toProfile(name: string, raw: unknown): LaunchProfile | undefined { commandName, ...optional('commandLineArgs', stringOf(raw, 'commandLineArgs')), ...optional('applicationUrl', stringOf(raw, 'applicationUrl')), + ...optional('inspectUri', stringOf(raw, 'inspectUri')), ...optional('environmentVariables', envOf(raw, 'environmentVariables')), }; return profile; diff --git a/src/editors/vscode/src/razor-html.ts b/src/editors/vscode/src/razor-html.ts index 8051010b..5cfc4c07 100644 --- a/src/editors/vscode/src/razor-html.ts +++ b/src/editors/vscode/src/razor-html.ts @@ -153,9 +153,19 @@ async function htmlHover(uri: Uri, position: Position): Promise !offered.has(labelOf(item))); + return new CompletionList([...items, ...added], incomplete); +} + +/** A completion item's label text. */ +function labelOf(item: CompletionItem): string { + return typeof item.label === 'string' ? item.label : item.label.label; } diff --git a/src/editors/vscode/src/test/suite/blazor-wasm-debug-resolution.test.ts b/src/editors/vscode/src/test/suite/blazor-wasm-debug-resolution.test.ts new file mode 100644 index 00000000..98bdd1a1 --- /dev/null +++ b/src/editors/vscode/src/test/suite/blazor-wasm-debug-resolution.test.ts @@ -0,0 +1,479 @@ +// Blazor WebAssembly launch RESOLUTION — every part of a `sharplsp-blazorwasm` +// launch that decides an answer without starting a real process. +// Spec: [DEBUG-FEATURES-LAUNCH-BLAZOR-WASM]. +// +// Nothing here mocks the extension's own surface: the pure functions, the real +// `launchSettings.json` of the repository fixture and the shipped provider are +// driven directly. Only the two things a headless host cannot own — a `dotnet +// run` child and a browser debug session — are replaced, through the shipped +// `useBlazorWasmHost` seam. +import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type * as vscode from 'vscode'; +import { + BLAZOR_WASM_DEBUG_TYPE, + DEFAULT_INSPECT_URI, + blazorWasmConfigurations, + browserConfiguration, + browserDebugType, + browserOf, + expandInspectUri, + inspectUriFor, + isBlazorWasmProject, + isBlazorWasmProjectFile, + launchProfileOf, + listeningUrlOf, + listeningUrlsOf, + projectAt, + resolveProject, + runArguments, + startupFailure, + takeLines, +} from '../../blazor-wasm-debug'; +import { + BlazorWasmDebugProvider, + runningBlazorWasmLaunch, + launchBlazorWasm, + useBlazorWasmHost, + type BlazorWasmProcess, +} from '../../blazor-wasm-launch'; +import { err, ok, type Result } from '../../result'; +import { fakeFolder } from './run-debug-kit'; + +/** The Blazor WebAssembly fixture in the repository. */ +const FIXTURE = path.resolve(__dirname, '../../../../../sharplsp/tests/fixtures/blazor-wasm'); +/** The one project it declares, and the one the launch is pointed at. */ +const PROJECT = path.join(FIXTURE, 'BlazorWasmApp.csproj'); +/** A real project of the same repository that is NOT a Blazor WebAssembly app. */ +const OTHER_PROJECT = path.resolve( + __dirname, + '../../../../../sharplsp/tests/fixtures/razor/BlazorApp/BlazorApp.csproj', +); +/** The template's own `inspectUri`, as written into launchSettings.json. */ +const TEMPLATE_INSPECT_URI = + '{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}'; +/** What the DevServer prints on a loopback HTTP bind, in its real shape. */ +const HTTP_ADDRESS = 'http://127.0.0.1:58480'; +/** A profile that binds HTTPS: the socket must be `wss`, not `ws`. */ +const HTTPS_ADDRESS = 'https://localhost:7297'; +/** The expanded forms the two addresses must produce. */ +const HTTP_INSPECT = `ws://127.0.0.1:58480/_framework/debug/ws-proxy?browser={browserInspectUri}`; +const HTTPS_INSPECT = `wss://localhost:7297/_framework/debug/ws-proxy?browser={browserInspectUri}`; + +/** + * `dotnet run` output, captured from a real run of the repository fixture: + * build chatter first, then the host's announcement, then lifecycle noise. + */ +const REAL_OUTPUT = [ + 'Using launch settings from Properties/launchSettings.json...', + 'info: Microsoft.Hosting.Lifetime[14]', + ` Now listening on: ${HTTPS_ADDRESS}`, + 'info: Microsoft.Hosting.Lifetime[14]', + ` Now listening on: ${HTTP_ADDRESS}`, + 'info: Microsoft.Hosting.Lifetime[0]', + ' Application started. Press Ctrl+C to shut down.', + ` Content root path: ${FIXTURE}`, +].join('\n'); + +/** A `dotnet run` child the test drives by hand. */ +interface FakeRun { + readonly process: BlazorWasmProcess; + /** The argv the launch built, in full. */ + readonly args: readonly string[]; + /** The directory the launch ran it in. */ + readonly cwd: string; + /** How many times the launch asked for it to stop. */ + readonly stops: () => number; + /** Emit child output exactly as a pipe would. */ + readonly announce: (lines: readonly string[]) => void; + /** End the child on its own terms, before it announces anything. */ + readonly crash: (reason: string) => void; +} + +/** A fake `dotnet run` that records everything the launch does to it. */ +function fakeRun(args: readonly string[], cwd: string, onOutput: (chunk: string) => void): FakeRun { + let settle: (value: Result) => void = () => undefined; + let stops = 0; + const ended = new Promise>((resolve) => { + settle = resolve; + }); + return { + process: { + ended, + stop: () => { + stops += 1; + settle(ok(undefined)); + }, + }, + args, + cwd, + stops: () => stops, + announce: (lines) => { + onOutput(`${lines.join('\n')}\n`); + }, + crash: (reason) => { + settle(err(reason)); + }, + }; +} + +/** A launch configuration pointing at the fixture project. */ +function launchConfig(overrides: Record = {}): vscode.DebugConfiguration { + return { + type: BLAZOR_WASM_DEBUG_TYPE, + request: 'launch', + name: 'Launch Blazor WebAssembly', + project: PROJECT, + ...overrides, + }; +} + +suite('Blazor WebAssembly launch resolution', () => { + test('expands the inspectUri onto the address the DevServer announced', () => { + // 1. The loopback HTTP address the fixture's own profile binds. + assert.strictEqual(expandInspectUri(DEFAULT_INSPECT_URI, HTTP_ADDRESS), HTTP_INSPECT); + assert.strictEqual(inspectUriFor(undefined, HTTP_ADDRESS), HTTP_INSPECT); + + // 2. An HTTPS profile must produce a SECURE socket: `ws` refuses to speak + // to a TLS listener, and js-debug then never reaches .NET. + assert.strictEqual(expandInspectUri(DEFAULT_INSPECT_URI, HTTPS_ADDRESS), HTTPS_INSPECT); + assert.strictEqual(expandInspectUri(TEMPLATE_INSPECT_URI, HTTPS_ADDRESS), HTTPS_INSPECT); + + // 3. A profile that states no pattern at all falls back to the template's, + // and `{browserInspectUri}` is left for js-debug to substitute. + assert.strictEqual( + inspectUriFor(undefined, 'http://localhost:5297'), + 'ws://localhost:5297/_framework/debug/ws-proxy?browser={browserInspectUri}', + ); + assert.strictEqual(inspectUriFor(' ', HTTP_ADDRESS), HTTP_INSPECT); + assert.ok(inspectUriFor(undefined, HTTP_ADDRESS).endsWith('?browser={browserInspectUri}')); + + // 4. An address that is not a URL leaves the pattern whole rather than + // producing a half-expanded proxy the browser would refuse. + assert.strictEqual( + expandInspectUri(DEFAULT_INSPECT_URI, 'not an address'), + DEFAULT_INSPECT_URI, + ); + }); + + test('reads the bound address out of real dotnet run output', () => { + // 1. A real capture: two announcements, and only they are URLs. + assert.deepStrictEqual(listeningUrlsOf(REAL_OUTPUT), [HTTPS_ADDRESS, HTTP_ADDRESS]); + const twice = `${REAL_OUTPUT}\n Now listening on: ${HTTP_ADDRESS}`; + assert.deepStrictEqual(listeningUrlsOf(twice), [HTTPS_ADDRESS, HTTP_ADDRESS, HTTP_ADDRESS]); + // A bare URL on its own line is output, not an announcement of one. + assert.deepStrictEqual(listeningUrlsOf(HTTP_ADDRESS), []); + + // 2. Noise that merely CONTAINS a URL is not an announcement. + assert.strictEqual(listeningUrlOf(` Content root path: ${FIXTURE}`), undefined); + assert.strictEqual( + listeningUrlOf( + 'warn: The ASP.NET Core developer certificate is not trusted: https://aka.ms/aspnet/https-trust-dev-cert', + ), + undefined, + ); + assert.deepStrictEqual(listeningUrlsOf('info: Microsoft.Hosting.Lifetime[14]'), []); + + // 3. A pipe cuts the line wherever it likes; the tail is kept until the + // newline arrives, so the announcement is still found whole. + const first = takeLines('', ` Now lis`); + assert.strictEqual(first.pending, ' Now lis'); + assert.deepStrictEqual(first.lines, []); + const second = takeLines(first.pending, `tening on: ${HTTP_ADDRESS}\n`); + assert.strictEqual(second.pending, ''); + assert.strictEqual(second.lines.length, 1); + assert.deepStrictEqual(listeningUrlsOf(second.lines.join('\n')), [HTTP_ADDRESS]); + }); + + test('maps a browser onto the debug type VS Code ships for it', () => { + // 1. Both browsers js-debug ships a type for. + assert.strictEqual(browserDebugType('chrome'), 'pwa-chrome'); + assert.strictEqual(browserDebugType('edge'), 'pwa-msedge'); + + // 2. A browser name is hand-written in launch.json, so it arrives in any + // case and can be absent; neither may refuse a launch. + assert.strictEqual(browserDebugType(browserOf('EDGE')), 'pwa-msedge'); + assert.strictEqual(browserDebugType(browserOf('Chrome')), 'pwa-chrome'); + assert.strictEqual(browserDebugType(browserOf(undefined)), 'pwa-chrome'); + assert.strictEqual(browserDebugType(browserOf(42)), 'pwa-chrome'); + + // 3. The configuration js-debug receives names the browser type, and the + // proxy URL is the expanded one. + const configuration = browserConfiguration({ + name: 'Launch Blazor WebAssembly', + url: HTTP_ADDRESS, + inspectUri: HTTP_INSPECT, + browser: 'edge', + webRoot: path.join(FIXTURE, 'wwwroot'), + timeout: 30_000, + }); + assert.strictEqual(configuration.type, 'pwa-msedge'); + assert.strictEqual(configuration.request, 'launch'); + assert.strictEqual(configuration.url, HTTP_ADDRESS); + assert.strictEqual(configuration.inspectUri, HTTP_INSPECT); + assert.strictEqual(configuration.name, 'Launch Blazor WebAssembly'); + }); + + test('generates one configuration per Blazor WebAssembly project, without prompting', async () => { + // 1. Discovery is the shipped XML reader against the real fixture files. + assert.strictEqual(isBlazorWasmProject(fs.readFileSync(PROJECT, 'utf-8')), true); + assert.strictEqual(isBlazorWasmProject(fs.readFileSync(OTHER_PROJECT, 'utf-8')), false); + assert.strictEqual( + isBlazorWasmProject( + '', + ), + false, + ); + assert.strictEqual(isBlazorWasmProject('not xml at all'), false); + + // 2. The provider answers from the project list it is given, checking the + // Sdk attribute of each — the non-WebAssembly project is dropped. + const provider = new BlazorWasmDebugProvider(); + const restore = useBlazorWasmHost({ + listProjects: () => Promise.resolve([PROJECT, OTHER_PROJECT]), + }); + try { + const generated = await provider.provideDebugConfigurations(fakeFolder(FIXTURE)); + assert.deepStrictEqual(generated, [ + { + type: BLAZOR_WASM_DEBUG_TYPE, + request: 'launch', + name: 'Blazor WebAssembly: BlazorWasmApp', + project: PROJECT, + browser: 'chrome', + }, + ]); + const fromFiles = blazorWasmConfigurations([OTHER_PROJECT, PROJECT]); + assert.strictEqual(fromFiles.length, 1); + assert.strictEqual(fromFiles[0]?.project, PROJECT); + + // 3. With no folder there is nothing to enumerate, and nothing is offered. + assert.deepStrictEqual(await provider.provideDebugConfigurations(undefined), []); + } finally { + restore(); + } + + // 4. The shipped listing finds the folder's project files itself, build + // output excluded, and offers the WebAssembly one. + const listed = await provider.provideDebugConfigurations(fakeFolder(FIXTURE)); + assert.deepStrictEqual( + listed.map((configuration) => configuration.project), + [PROJECT], + 'the fixture folder holds one Blazor WebAssembly project', + ); + }); + + test('reads the profile, its inspectUri and the run arguments from the project', () => { + // 1. The fixture's sole `Project` profile applies without being named. + const profile = launchProfileOf(PROJECT, undefined); + assert.strictEqual(profile?.name, 'http'); + assert.strictEqual(profile?.inspectUri, TEMPLATE_INSPECT_URI); + + // 2. A named profile that the project does not declare is NOT invented. + assert.strictEqual(launchProfileOf(PROJECT, 'nope'), undefined); + assert.strictEqual(launchProfileOf(PROJECT, 'http')?.name, 'http'); + + // 3. The argv is a real argument vector — no shell, so no quoting. + assert.deepStrictEqual(runArguments(PROJECT, 'http'), [ + 'run', + '--project', + PROJECT, + '--launch-profile', + 'http', + ]); + assert.deepStrictEqual(runArguments(PROJECT, undefined), ['run', '--project', PROJECT]); + assert.deepStrictEqual(runArguments(PROJECT, ' '), ['run', '--project', PROJECT]); + }); + + test('kills the dotnet run process when the browser session ends', async () => { + const runs: FakeRun[] = []; + const handed: vscode.DebugConfiguration[] = []; + let endBrowser: () => void = () => undefined; + const restore = useBlazorWasmHost({ + startProcess: (args, cwd, onOutput) => { + const run = fakeRun(args, cwd, onOutput); + runs.push(run); + run.announce([` Now listening on: ${HTTP_ADDRESS}`]); + return run.process; + }, + startBrowserSession: (_folder, configuration) => { + handed.push(configuration); + return Promise.resolve({ + ended: new Promise((resolve) => { + endBrowser = resolve; + }), + }); + }, + }); + try { + // 1. The launch runs the project's own profile and hands js-debug the + // expanded proxy — and touches no process while the session is live. + const first = await launchBlazorWasm(fakeFolder(FIXTURE), launchConfig()); + assert.ok(first.ok, `the fake app served an address; ${JSON.stringify(first)}`); + const firstRun = runs[0]; + const firstHanded = handed[0]; + assert.ok(firstRun !== undefined && firstHanded !== undefined); + assert.deepStrictEqual(firstRun.args, [ + 'run', + '--project', + PROJECT, + '--launch-profile', + 'http', + ]); + assert.strictEqual(firstRun.cwd, path.dirname(PROJECT)); + assert.strictEqual(firstHanded.type, 'pwa-chrome'); + assert.strictEqual(firstHanded.url, HTTP_ADDRESS); + assert.strictEqual(firstHanded.inspectUri, HTTP_INSPECT); + assert.strictEqual(firstHanded.webRoot, path.join(FIXTURE, 'wwwroot')); + assert.strictEqual(first.value.url, HTTP_ADDRESS); + assert.strictEqual(firstRun.stops(), 0); + assert.strictEqual(runningBlazorWasmLaunch(PROJECT), first.value); + + // 2. A second launch of the SAME project replaces the first: the old app + // is stopped once, so two DevServers never race for one port. + const second = await launchBlazorWasm(fakeFolder(FIXTURE), launchConfig()); + assert.ok(second.ok); + const secondRun = runs[1]; + assert.ok(secondRun !== undefined); + assert.strictEqual(firstRun.stops(), 1); + assert.strictEqual(handed.length, 2); + assert.strictEqual(runningBlazorWasmLaunch(PROJECT), second.value); + + // 3. The browser session ends, and with it the app it was attached to — + // and only that app, and only once. + endBrowser(); + await second.value.ended; + assert.strictEqual(secondRun.stops(), 1); + assert.strictEqual(firstRun.stops(), 1); + assert.strictEqual(runningBlazorWasmLaunch(PROJECT), undefined); + } finally { + restore(); + } + }); + + test('stops the served app when the browser never starts, or the app dies during the handoff', async () => { + const runs: FakeRun[] = []; + let browser: () => Promise<{ readonly ended: Promise } | undefined> = () => + Promise.resolve(undefined); + const restore = useBlazorWasmHost({ + startProcess: (args, cwd, onOutput) => { + const run = fakeRun(args, cwd, onOutput); + runs.push(run); + run.announce([` Now listening on: ${HTTP_ADDRESS}`]); + return run.process; + }, + startBrowserSession: () => browser(), + }); + try { + // 1. The app served its address, but js-debug never started: the app is + // stopped, and the refusal names the browser and the address. + const refused = await launchBlazorWasm(fakeFolder(FIXTURE), launchConfig()); + assert.strictEqual(refused.ok, false); + assert.strictEqual( + refused.ok ? '' : refused.error, + `Could not start pwa-chrome for ${HTTP_ADDRESS}.`, + ); + assert.strictEqual(runs[0]?.stops(), 1, 'the served app is stopped once'); + assert.strictEqual(runningBlazorWasmLaunch(PROJECT), undefined); + + // 2. The browser started, but the app had died meanwhile: its reason is + // carried into the refusal, and nothing is left running. + browser = async () => { + runs[1]?.crash('the DevServer crashed'); + await new Promise((resolve) => setTimeout(resolve, 0)); + return { ended: new Promise(() => undefined) }; + }; + const died = await launchBlazorWasm(fakeFolder(FIXTURE), launchConfig()); + assert.strictEqual(died.ok, false); + assert.strictEqual( + died.ok ? '' : died.error, + 'dotnet run for BlazorWasmApp.csproj stopped before it served anything: the DevServer crashed', + ); + assert.strictEqual(runs.length, 2); + assert.strictEqual(runningBlazorWasmLaunch(PROJECT), undefined); + } finally { + restore(); + } + }); + + test('names why a launch has no project, or why its startup failed', async () => { + // 1. With no folder, a relative project cannot be resolved, and a missing + // project is never assumed. + assert.deepStrictEqual( + projectAt(undefined, 'App.csproj'), + err("Cannot resolve 'App.csproj' without a workspace folder."), + ); + assert.deepStrictEqual( + await resolveProject(undefined, undefined), + err('Blazor WebAssembly launch found no runnable project for this workspace.'), + ); + + // 2. Without a stated project, the folder's only project is the one launched; + // a folder holding none is refused, never guessed at. + assert.deepStrictEqual(await resolveProject(fakeFolder(FIXTURE), ' '), ok(PROJECT)); + const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'sharplsp-wasm-empty-')); + try { + const none = await resolveProject(fakeFolder(empty), undefined); + assert.strictEqual(none.ok, false, 'an empty folder has no project to launch'); + } finally { + fs.rmSync(empty, { recursive: true, force: true }); + } + + // 3. A project file that cannot be read is not a Blazor WebAssembly app, and + // an address that is not a URL is not announced. + assert.strictEqual(isBlazorWasmProjectFile(path.join(FIXTURE, 'Missing.csproj')), false); + assert.strictEqual(isBlazorWasmProjectFile(PROJECT), true); + assert.strictEqual(listeningUrlOf(' Now listening on: http://[::1'), undefined); + + // 4. A startup that timed out or exited says which, by project name. + assert.match( + startupFailure({ kind: 'timeout' }, PROJECT), + /^dotnet run for BlazorWasmApp\.csproj announced no listening URL within 180s\./u, + ); + assert.strictEqual( + startupFailure({ kind: 'exited', outcome: ok(undefined) }, PROJECT), + 'dotnet run for BlazorWasmApp.csproj exited before it announced a listening URL.', + ); + }); + + test('refuses a project that stops before it serves anything', async () => { + const restore = useBlazorWasmHost({ + startProcess: (args, cwd, onOutput) => { + const run = fakeRun(args, cwd, onOutput); + run.crash('MSB1009: Project file does not exist.'); + return run.process; + }, + startBrowserSession: () => Promise.resolve(undefined), + }); + try { + // 1. dotnet's own reason is carried into the refusal, named by project. + const failed = await launchBlazorWasm(fakeFolder(FIXTURE), launchConfig()); + assert.strictEqual(failed.ok, false); + assert.match( + failed.ok ? '' : failed.error, + /dotnet run for BlazorWasmApp\.csproj stopped before it served anything: MSB1009/u, + ); + assert.strictEqual(runningBlazorWasmLaunch(PROJECT), undefined); + + // 2. A project path that does not exist is refused before a process runs. + const missing = await launchBlazorWasm( + fakeFolder(FIXTURE), + launchConfig({ project: path.join(FIXTURE, 'Nope.csproj') }), + ); + assert.strictEqual(missing.ok, false); + assert.match(missing.ok ? '' : missing.error, /project not found/iu); + + // 3. A profile the project does not declare is refused, not ignored. + const unknownProfile = await launchBlazorWasm( + fakeFolder(FIXTURE), + launchConfig({ launchProfile: 'nope' }), + ); + assert.strictEqual(unknownProfile.ok, false); + assert.match(unknownProfile.ok ? '' : unknownProfile.error, /'nope' is not declared/iu); + } finally { + restore(); + } + }); +}); diff --git a/src/editors/vscode/src/test/suite/blazor-wasm-debug.test.ts b/src/editors/vscode/src/test/suite/blazor-wasm-debug.test.ts new file mode 100644 index 00000000..1060b130 --- /dev/null +++ b/src/editors/vscode/src/test/suite/blazor-wasm-debug.test.ts @@ -0,0 +1,286 @@ +// Blazor WebAssembly debugging, end to end: a REAL `dotnet run` of a REAL +// Blazor WebAssembly app, handed to js-debug, and stopped with the session. +// Spec: [DEBUG-FEATURES-LAUNCH-BLAZOR-WASM]. +// +// The launch is driven exactly as a user drives it — `vscode.debug.startDebugging` +// with a `sharplsp-blazorwasm` configuration, through the provider the extension +// registered at activation. Only the browser session itself is replaced, by the +// shipped `useBlazorWasmHost` seam: a headless host has no js-debug to start, and +// what this suite must prove is the configuration js-debug would have received +// and the app that answers behind it. +import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; +import { + runningBlazorWasmLaunch, + useBlazorWasmHost, + type RunningLaunch, +} from '../../blazor-wasm-launch.js'; +import { copyPinnedFixture, dotnet } from './dotnet-project-kit'; +import { authoredPackageJson } from './run-debug-kit'; +import { + comparablePath, + pollUntilResult, + removeDirRecursive, + requireWorkspaceRoot, +} from './test-helpers'; +import { DEBUG_SESSION_MS, FIXTURE_BUILD_MS, POLL_INTERVAL_MS } from './test-timeouts'; + +/** The Blazor WebAssembly fixture in the repository. */ +const SOURCE = path.resolve(__dirname, '../../../../../sharplsp/tests/fixtures/blazor-wasm'); + +/** The project the launch is pointed at, relative to the fixture root. */ +const PROJECT_NAME = 'BlazorWasmApp.csproj'; + +/** + * The manifest as AUTHORED, which is not what VS Code core hands back: core + * injects its own attributes into every debugger it loads + * ([DEBUG-FEATURES-LAUNCH-OUTPUT] rule 5). + */ +interface AuthoredDebugger { + readonly type?: string; + readonly configurationAttributes?: { readonly launch?: { properties?: object } }; + readonly configurationSnippets?: { readonly body?: { readonly type?: string } }[]; +} + +/** The parts of the authored manifest this suite judges. */ +interface AuthoredManifest { + readonly activationEvents?: string[]; + readonly contributes?: { readonly debuggers?: AuthoredDebugger[] }; +} + +/** The page the DevServer answers the debug proxy on. */ +const DEBUG_PROXY_PATH = '/_framework/debug'; + +/** Copy the fixture to `root` and build it, so the launch only has to run it. */ +async function writeFixture(root: string): Promise { + copyPinnedFixture(SOURCE, root); + await dotnet(['build', '--nologo'], root); +} + +/** An HTTP status for `url`, or undefined when nothing answers. */ +function statusOf(url: string): Promise { + return new Promise((resolve) => { + const request = http.get(url, (response) => { + response.resume(); + resolve(response.statusCode); + }); + request.on('error', () => { + resolve(undefined); + }); + request.setTimeout(5_000, () => { + request.destroy(); + resolve(undefined); + }); + }); +} + +/** + * The scratch copy of the fixture this suite works against. + * + * Inside the OPENED workspace folder, as every debug suite's scratch tree is: a + * session started against a temp directory has no `workspaceFolder`, which is a + * different refusal path from the one under test here. + */ +let root = ''; + +/** Everything the browser session received, in order. */ +const handed: vscode.DebugConfiguration[] = []; + +/** How many browser sessions were started. */ +let sessions = 0; + +/** Ends the browser session the launch is waiting on. */ +let endBrowser: () => void = () => undefined; + +/** The fixture, built, with the browser session replaced by a recorder. */ +suiteSetup(async function () { + this.timeout(FIXTURE_BUILD_MS); + root = fs.mkdtempSync(path.join(requireWorkspaceRoot(), 'blazor-wasm-')); + await writeFixture(root); + useBlazorWasmHost({ + startBrowserSession: (_folder, configuration) => { + handed.push(configuration); + sessions += 1; + return Promise.resolve({ + ended: new Promise((resolve) => { + endBrowser = resolve; + }), + }); + }, + }); +}); + +suiteTeardown(function () { + this.timeout(DEBUG_SESSION_MS); + removeDirRecursive(root); +}); + +/** The project file of the scratch copy. */ +function projectFile(): string { + return path.join(root, PROJECT_NAME); +} + +/** A `sharplsp-blazorwasm` configuration for the scratch project. */ +function launchConfiguration(): vscode.DebugConfiguration { + return { + type: 'sharplsp-blazorwasm', + request: 'launch', + name: 'Launch Blazor WebAssembly', + project: projectFile(), + browser: 'chrome', + }; +} + +/** Start the launch through the provider the extension registered, and wait for it. */ +async function startLaunch(): Promise { + const folder: vscode.WorkspaceFolder = { + uri: vscode.Uri.file(requireWorkspaceRoot()), + name: path.basename(requireWorkspaceRoot()), + index: 0, + }; + const wanted = sessions + 1; + // The provider hands off to js-debug and answers `undefined`, which is how a + // provider starts NO session of its own — so `false` here is success, and the + // seam is what says the launch reached the browser. + await vscode.debug.startDebugging(folder, launchConfiguration()); + const launch = await pollUntilResult( + () => Promise.resolve(runningBlazorWasmLaunch(projectFile())), + (found) => found !== undefined && sessions === wanted, + FIXTURE_BUILD_MS, + POLL_INTERVAL_MS, + 'the Blazor WebAssembly launch to serve and hand off', + ); + assert.ok(launch, 'the launch registers itself while it runs'); + return launch; +} + +suite('Blazor WebAssembly debugging', () => { + test('serves the app, hands js-debug the proxy and dies with the session', async function () { + this.timeout(FIXTURE_BUILD_MS + DEBUG_SESSION_MS); + + // 1. The user presses F5 on a `sharplsp-blazorwasm` launch. `dotnet run` + // builds and serves the app, and js-debug receives the expanded proxy. + const launch = await startLaunch(); + const configuration = handed[0]; + assert.ok(configuration !== undefined, 'js-debug was handed a configuration'); + assert.strictEqual(configuration.type, 'pwa-chrome', 'chrome maps onto pwa-chrome'); + assert.strictEqual(configuration.request, 'launch'); + assert.strictEqual(configuration.name, 'Launch Blazor WebAssembly'); + assert.ok( + /^http:\/\/127\.0\.0\.1:\d+$/u.test(launch.url), + `the DevServer's own address is launched: ${launch.url}`, + ); + assert.strictEqual(configuration.url, launch.url, 'the browser opens the real address'); + const expanded = `ws://${new URL(launch.url).host}${DEBUG_PROXY_PATH}/ws-proxy?browser={browserInspectUri}`; + assert.strictEqual(configuration.inspectUri, expanded, 'the proxy carries host and port'); + assert.ok( + !configuration.inspectUri.includes('{wsProtocol}'), + 'every token but {browserInspectUri} is expanded', + ); + assert.strictEqual( + comparablePath(configuration.webRoot), + comparablePath(path.join(root, 'wwwroot')), + 'webRoot defaults to the project wwwroot', + ); + + // 2. The proxy the configuration names is really served: a GET of the + // DevServer's `/_framework/debug` answers 200, which only the dev server + // package produces. TWICE, so a single lucky answer cannot pass. + const status = await statusOf(`${launch.url}${DEBUG_PROXY_PATH}`); + assert.strictEqual(status, 200, `the .NET debug proxy answers on ${launch.url}`); + const again = await statusOf(`${launch.url}${DEBUG_PROXY_PATH}`); + assert.strictEqual(again, 200, 'and keeps answering'); + const page = await statusOf(`${launch.url}/`); + assert.strictEqual(page, 200, 'the app itself is served too'); + + // 3. The user stops the browser session: the app it was attached to stops + // with it, and the port it held is released. Polled, because the stop is + // a signal and the process takes a moment to go. + endBrowser(); + await launch.ended; + assert.strictEqual( + runningBlazorWasmLaunch(projectFile()), + undefined, + 'a finished launch is not registered', + ); + const closed = await pollUntilResult( + () => statusOf(`${launch.url}${DEBUG_PROXY_PATH}`), + (status) => status !== 200, + DEBUG_SESSION_MS, + POLL_INTERVAL_MS, + 'the DevServer to stop answering once the browser session ended', + ); + assert.notStrictEqual(closed, 200, 'the dotnet run process is gone'); + }); + + test('stops the app it replaced when the same project launches twice', async function () { + this.timeout(FIXTURE_BUILD_MS + DEBUG_SESSION_MS); + + // 1. A launch of the project, serving. + const first = await startLaunch(); + assert.strictEqual(sessions, 2, 'the recorder saw the session'); + assert.strictEqual(await statusOf(`${first.url}${DEBUG_PROXY_PATH}`), 200); + + // 2. A second launch of the SAME project: one DevServer may hold the port, + // so the first is stopped and the second takes over the registry. + const second = await startLaunch(); + assert.notStrictEqual(second.url, first.url, 'the second app bound its own address'); + assert.strictEqual(runningBlazorWasmLaunch(projectFile()), second); + await pollUntilResult( + () => statusOf(`${first.url}${DEBUG_PROXY_PATH}`), + (status) => status !== 200, + DEBUG_SESSION_MS, + POLL_INTERVAL_MS, + 'the replaced DevServer to stop answering', + ); + + // 3. Ending the browser session ends only what it was attached to. + endBrowser(); + await second.ended; + assert.notStrictEqual(await statusOf(`${second.url}${DEBUG_PROXY_PATH}`), 200); + assert.strictEqual(runningBlazorWasmLaunch(projectFile()), undefined); + }); + + test('contributes the debugger, its snippet and its activation events', async function () { + this.timeout(DEBUG_SESSION_MS); + + // 1. The extension activated, so the providers a launch needs are registered. + const extension = vscode.extensions.getExtension('nimblesite.sharplsp'); + assert.ok(extension !== undefined, 'the SharpLsp extension is installed in the host'); + await extension.activate(); + assert.strictEqual(extension.isActive, true); + + // 2. Both debuggers are declared: the C#/F# one, and the Blazor WebAssembly + // one, whose launch properties are what `launch.json` IntelliSense reads. + // Read from the AUTHORED manifest, because the loaded one is merged with + // core's injected attributes and would report properties we never wrote. + const manifest = authoredPackageJson() as AuthoredManifest; + const debuggers = manifest.contributes?.debuggers ?? []; + const wasm = debuggers.find((entry) => entry.type === 'sharplsp-blazorwasm'); + assert.ok( + wasm !== undefined, + `contributed debuggers: ${debuggers.map((d) => d.type).join(', ')}`, + ); + const properties = Object.keys(wasm.configurationAttributes?.launch?.properties ?? {}).sort(); + assert.deepStrictEqual(properties, [ + 'browser', + 'launchProfile', + 'project', + 'timeout', + 'url', + 'webRoot', + ]); + const snippets = wasm.configurationSnippets?.map((snippet) => snippet.body?.type) ?? []; + assert.deepStrictEqual(snippets, ['sharplsp-blazorwasm'], 'one snippet, of this type'); + + // 3. The type makes the extension activate before it is used, and the + // manifest the host loaded agrees with the one in the source tree. + assert.ok(manifest.activationEvents?.includes('onDebugResolve:sharplsp-blazorwasm')); + assert.ok( + manifest.activationEvents?.includes('onDebugDynamicConfigurations:sharplsp-blazorwasm'), + ); + }); +}); diff --git a/src/editors/vscode/src/test/suite/debug-hot-reload-e2e.test.ts b/src/editors/vscode/src/test/suite/debug-hot-reload-e2e.test.ts index ca847f7d..2ad47854 100644 --- a/src/editors/vscode/src/test/suite/debug-hot-reload-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/debug-hot-reload-e2e.test.ts @@ -11,6 +11,8 @@ // their locals, and the state they spent minutes reaching. So the session id is // asserted unchanged across every edit. import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; import * as vscode from 'vscode'; import { MODE, type DebugFixture } from './debug-fixture-programs'; import { CMD_CONTINUE, assertStoppedAt, topFrame } from './debug-drive-kit'; @@ -238,4 +240,62 @@ suite('Debug hot reload — editing a method while the debuggee is paused', () = ); }, ); + + // Implements [RAZOR-HOT-RELOAD]: the DAP save pipeline owns Razor documents + // as well as C#. A saved .razor file inside the debugged project reaches the + // sidecar — which refuses an unlisted Razor file BY NAME — instead of + // vanishing as a foreign language, and the session survives the refusal. + debugTest( + 'a saved Razor document is owned by the pipeline and names itself when unlisted', + debuggee, + async ({ fixture, recorder, stubs }) => { + // Interaction 1 — run the debuggee with hot reload enabled. + const { session } = await runToFirstStop(debuggee(), 'accumulate-entry', { + mode: MODE.plain, + extra: { hotReload: true }, + }); + + // Interaction 2 — save a Razor document inside the debugged project. + // This console debuggee lists no Razor file, so the sidecar must refuse + // the save by name rather than apply anything. + const razorPath = path.join(fixture.dir, 'Owned.razor'); + fs.writeFileSync(razorPath, '

Owned

\n', 'utf8'); + const razorDoc = await vscode.workspace.openTextDocument(vscode.Uri.file(razorPath)); + eq(razorDoc.languageId, 'razor', 'VS Code opens a .razor file as the razor language'); + const razorEditor = await vscode.window.showTextDocument(razorDoc); + await razorEditor.edit((b) => { + b.insert(new vscode.Position(0, 0), '

touched

'); + }); + assert.ok(await razorDoc.save(), 'the Razor document must save — that is the trigger'); + await sleep(RELOAD_SETTLE_MS); + + // Interaction 3 — the save REACHED the pipeline: one refusal, naming the + // file. An unowned language would leave `refusalsOf` empty here. + const reported = refusalsOf(stubs); + eq( + reported.length, + 1, + `exactly one refusal for the unlisted Razor save: ${JSON.stringify(reported)}`, + ); + const message = requireAt(reported, 0, 'the unlisted-Razor refusal'); + assert.ok( + message.includes('Owned.razor'), + `the refusal must name the Razor file; it said: '${message}'`, + ); + + // Interaction 4 — the session SURVIVED the refusal, and the original IL + // still runs: a refused save is a verdict, not a lost debuggee. + eq( + vscode.debug.activeDebugSession?.id, + session.id, + 'an unlisted Razor save must not restart or kill the session', + ); + await vscode.commands.executeCommand(CMD_CONTINUE); + await assertRanToCompletion(recorder, 0, 'a session after an unlisted Razor save'); + assert.ok( + recorder.outputText().includes('total=8'), + 'nothing was applied, so the original result must appear', + ); + }, + ); }); diff --git a/src/editors/vscode/src/test/suite/dotnet-project-kit.ts b/src/editors/vscode/src/test/suite/dotnet-project-kit.ts index 57f4476a..fff2b171 100644 --- a/src/editors/vscode/src/test/suite/dotnet-project-kit.ts +++ b/src/editors/vscode/src/test/suite/dotnet-project-kit.ts @@ -10,10 +10,12 @@ // Solutions are produced by `dotnet new sln` + `dotnet sln add`, never authored: // .NET 10 emits the XML `.slnx` format by default and the extension has to cope // with whichever the CLI chose, so the fixture must not assume an extension. +import * as assert from 'node:assert/strict'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { execFile } from 'node:child_process'; import { XMLBuilder } from 'fast-xml-parser'; +import { findGlobalJson } from '../../global-json.js'; import { FIXTURE_BUILD_MS } from './test-timeouts'; /** A ``. */ @@ -433,3 +435,22 @@ export async function installedFrameworkPair(cwd: string): Promise { } return targets.slice(-2); } + +/** Build output a copied fixture must not bring along: a copied `obj` points back at the source. */ +const BUILD_OUTPUT = new Set(['bin', 'obj']); + +/** + * Copy the repository fixture at `source` to `root`, without its build output, pinned to + * the SDK the repository builds it with. A copy under the OS temp directory has no + * `global.json` above it, so it would build with whatever SDK is newest on the machine, + * whose Razor generator can need a newer Roslyn than the sidecar's. + */ +export function copyPinnedFixture(source: string, root: string): void { + fs.cpSync(source, root, { + recursive: true, + filter: (entry) => !BUILD_OUTPUT.has(path.basename(entry)), + }); + const pin = findGlobalJson(source); + assert.ok(pin, 'the repository pins the fixture SDK in a global.json'); + fs.copyFileSync(pin, path.join(root, 'global.json')); +} diff --git a/src/editors/vscode/src/test/suite/fsi-build-output-e2e.test.ts b/src/editors/vscode/src/test/suite/fsi-build-output-e2e.test.ts index 427f33de..1b764bdb 100644 --- a/src/editors/vscode/src/test/suite/fsi-build-output-e2e.test.ts +++ b/src/editors/vscode/src/test/suite/fsi-build-output-e2e.test.ts @@ -420,11 +420,16 @@ suite('FSI / Build / Output-filter / Hot-reload E2E', () => { // ── hot-reload.ts ─────────────────────────────────────────────── - test('isRelevantLanguage classifies C#/F# vs other languages within a save flow', async function () { + test('isRelevantLanguage classifies C#/F#/Razor vs other languages within a save flow', async function () { this.timeout(COMMAND_MS); assert.ok(isRelevantLanguage('csharp')); assert.ok(isRelevantLanguage('fsharp')); + assert.ok(isRelevantLanguage('razor'), 'a .razor save feeds hot reload ([RAZOR-HOT-RELOAD])'); + assert.ok( + isRelevantLanguage('aspnetcorerazor'), + "the C# extension's Razor id feeds hot reload too ([RAZOR-HOT-RELOAD])", + ); assert.ok(!isRelevantLanguage('json')); assert.ok(!isRelevantLanguage('plaintext')); @@ -473,6 +478,26 @@ suite('FSI / Build / Output-filter / Hot-reload E2E', () => { assert.ok(!isRelevantLanguage(txtDoc.languageId)); assert.ok(isHotReloadRunning()); + // Saving a Razor document must also be harmless: its language is relevant, + // so the onSave path runs instead of early-returning ([RAZOR-HOT-RELOAD]). + const razorPath = path.join(tmpDir, 'Saved.razor'); + fs.writeFileSync(razorPath, '

Saved

\n', 'utf8'); + const razorDoc = await vscode.workspace.openTextDocument(vscode.Uri.file(razorPath)); + assert.strictEqual( + razorDoc.languageId, + 'razor', + 'VS Code opens a .razor file as the razor language', + ); + const razorEditor = await vscode.window.showTextDocument(razorDoc); + await razorEditor.edit((b) => { + b.insert(new vscode.Position(0, 0), '

touched

'); + }); + await assert.doesNotReject(async () => { + await razorDoc.save(); + }, 'Saving a Razor doc during hot reload must not throw'); + assert.ok(isRelevantLanguage(razorDoc.languageId)); + assert.ok(isHotReloadRunning(), 'Hot reload must remain running after a Razor save'); + await vscode.commands.executeCommand('sharplsp.hotReload.stop'); assert.ok(!isHotReloadRunning()); }); diff --git a/src/editors/vscode/src/test/suite/razor-kit.ts b/src/editors/vscode/src/test/suite/razor-kit.ts index d5f39aee..9d7fd63e 100644 --- a/src/editors/vscode/src/test/suite/razor-kit.ts +++ b/src/editors/vscode/src/test/suite/razor-kit.ts @@ -2,50 +2,33 @@ // repository's src/sharplsp/tests/fixtures/razor, copied without its build output // to a scratch root, built so the SDK's Razor generator has run, and loaded // through the real extension. -import * as assert from 'node:assert/strict'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import type * as vscode from 'vscode'; -import { findGlobalJson } from '../../global-json.js'; import { serverProjection } from '../../razor-html.js'; -import { dotnet } from './dotnet-project-kit'; +import { copyPinnedFixture, dotnet } from './dotnet-project-kit'; import { type LoadedFixture, useLoadedFixture } from './netfx-language-kit'; import { lspClient } from './profiler-kit'; /** The fixture in the repository. */ const SOURCE = path.resolve(__dirname, '../../../../../sharplsp/tests/fixtures/razor'); -/** Build output a copy leaves behind: a copied `obj` would point back at the source. */ -const BUILD_OUTPUT = new Set(['bin', 'obj']); - /** The Razor files the suite opens, relative to the fixture root. */ export const RAZOR_FILES = { counter: 'BlazorApp/Components/Pages/Counter.razor', board: 'BlazorApp/Components/Pages/Board.razor', + leaders: 'BlazorApp/Components/Pages/Leaders.razor', + card: 'BlazorApp/Components/Shared/Card.razor', + edit: 'PagesApp/Pages/Edit.cshtml', } as const; /** The Razor file the suite writes where no project lists it. */ export const STRAY_RAZOR = 'Stray.razor'; -/** - * Pin `root` to the SDK the fixture has in the repository. A scratch copy has no - * `global.json` above it, so it would build and load with the newest installed SDK, - * whose Razor generator can need a newer Roslyn than the sidecar's. - */ -function keepSdkPin(root: string): void { - const pin = findGlobalJson(SOURCE); - assert.ok(pin, 'the repository pins the fixture SDK in a global.json'); - fs.copyFileSync(pin, path.join(root, 'global.json')); -} - -/** Copy the fixture to `root`, with its SDK pin, and build it. */ +/** Copy the fixture to `root`, pinned to the repository SDK, and build it. */ async function writeRazorFixture(root: string): Promise { - fs.cpSync(SOURCE, root, { - recursive: true, - filter: (source) => !BUILD_OUTPUT.has(path.basename(source)), - }); - keepSdkPin(root); + copyPinnedFixture(SOURCE, root); const solution = path.join(root, 'RazorFixtures.slnx'); await dotnet(['build', solution], root); return { 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 f0188dbb..0215ac9a 100644 --- a/src/editors/vscode/src/test/suite/razor-lsp.test.ts +++ b/src/editors/vscode/src/test/suite/razor-lsp.test.ts @@ -23,6 +23,7 @@ import { import { packageJson } from './run-debug-kit'; import { closeAllEditors, + comparablePath, pollProvider, pollUntilResult, removeDirRecursive, @@ -300,4 +301,175 @@ suite('Razor in VS Code', () => { 'every usage finds the same references', ); }); + + test('F# symbols in Razor navigate to, find and rename in their F# source', async function () { + this.timeout(LSP_RESPONSE_MS * 3); + const { doc, uri } = await openRepoFile(fixture().root, RAZOR_FILES.leaders); + const inScores = (target: vscode.Uri): boolean => target.path.endsWith('/Domain/Scores.fs'); + + // Interaction 1 — definition on an F# record lands on its F# declaration, not + // decompiled source. [RAZOR-FSHARP-DEFINITION] + const definitions = await pollProvider( + 'vscode.executeDefinitionProvider', + [uri, positionOf(doc, 'private readonly Player[]', 'Player')], + (found) => found.some((target) => inScores(targetUri(target))), + LSP_RESPONSE_MS * 2, + ); + const declared = definitions.find((target) => inScores(targetUri(target))); + assert.ok(declared, 'a definition in Scores.fs'); + const scores = await vscode.workspace.openTextDocument(targetUri(declared)); + assert.equal(scores.getText(targetRange(declared)), 'Player', 'ranged on the name'); + assert.ok( + scores.lineAt(targetRange(declared).start.line).text.startsWith('type Player'), + 'on the type declaration', + ); + + // Interaction 2 — references on an F# field span F# and Razor, each on its name. + // [RAZOR-FSHARP-REFERENCES] + const score = positionOf(doc, '@player.Score', 'Score'); + const references = await pollProvider( + 'vscode.executeReferenceProvider', + [uri, score], + (found) => + found.some((location) => inScores(location.uri)) && + found.some((location) => location.uri.toString() === uri.toString()), + ); + assert.equal(references.filter((location) => inScores(location.uri)).length, 5); + for (const location of references) { + const holder = inScores(location.uri) ? scores : doc; + assert.equal(holder.getText(location.range), 'Score', 'every reference spans the name'); + assert.ok(!location.uri.path.endsWith('.g.cs'), 'never generated code'); + } + + // Interaction 3 — renaming it from Razor renames its F# declaration and uses. + // [RAZOR-FSHARP-RENAME] + const edit = await vscode.commands.executeCommand( + 'vscode.executeDocumentRenameProvider', + uri, + score, + 'Points', + ); + // Keyed as the file system compares paths: on Windows a drive letter may be + // spelled in either case, and one file must never count as two. + const byFile = new Map(); + for (const [target, edits] of edit.entries()) { + const key = comparablePath(target.fsPath); + byFile.set(key, [...(byFile.get(key) ?? []), ...edits]); + } + assert.equal(byFile.get(comparablePath(uri.fsPath))?.length, 1, 'the Razor use'); + assert.equal( + byFile.get(comparablePath(scores.uri.fsPath))?.length, + 5, + 'the F# declaration and four uses', + ); + assert.equal(byFile.size, 2, 'the Razor file and the F# file, and nothing else'); + assert.ok( + [...byFile.values()].flat().every((change) => change.newText === 'Points'), + 'every edit renames to Points', + ); + assert.ok(![...byFile.keys()].some((file) => file.endsWith('.g.cs')), 'no generated file'); + }); + + test('a Razor Page completes tag helper elements once, before the HTML service', async function () { + this.timeout(LSP_RESPONSE_MS * 2); + const { doc, uri, editor } = await openRepoFile(fixture().root, RAZOR_FILES.edit); + const end = doc.lineAt(doc.lineCount - 1).range.end; + await editor.edit((builder) => { + builder.insert(end, '\n<'); + }); + const caret = doc.lineAt(doc.lineCount - 1).range.end; + + // The tag helper's element comes from the server, HTML's own from the HTML + // service, and `a`, which both offer, appears once. [RAZOR-MVC-TAGHELPERS] + const list = await completionList(uri, caret, 'partial'); + const labels = list.items.map((item) => + typeof item.label === 'string' ? item.label : item.label.label, + ); + assert.ok(labels.includes('environment'), 'every tag helper element'); + assert.ok( + HTML_ONLY.every((element) => labels.includes(element)), + "and the HTML service's", + ); + assert.equal(labels.filter((label) => label === 'a').length, 1, '`a` once'); + const anchor = list.items.find((item) => item.label === 'a'); + assert.match( + String(anchor?.detail), + /AnchorTagHelper/, + "the server's item, naming its tag helper", + ); + assert.ok(!labels.includes('ErrorBoundary'), 'no component completes in a Razor Page'); + }); + + test('a component completes and defines its stylesheet classes, and nests its collocated files', async function () { + this.timeout(LSP_RESPONSE_MS * 3); + const { doc, uri, editor } = await openRepoFile(fixture().root, RAZOR_FILES.card); + const inStylesheet = (target: vscode.Uri): boolean => + target.path.endsWith('/Shared/Card.razor.css'); + + // Interaction 1 — a class name defines to every selector that declares it, each on + // the name after its `.`. [RAZOR-COLLOCATION-CSS] + const definitions = await pollProvider( + 'vscode.executeDefinitionProvider', + [uri, positionOf(doc, 'class="card title"', 'title')], + (found) => found.length > 0 && found.every((target) => inStylesheet(targetUri(target))), + LSP_RESPONSE_MS * 2, + ); + assert.equal(definitions.length, 5, 'the five rules whose selectors hold .title'); + const [first] = definitions; + assert.ok(first, 'a definition'); + const stylesheet = await vscode.workspace.openTextDocument(targetUri(first)); + for (const definition of definitions) { + assert.equal( + stylesheet.getText(targetRange(definition)), + 'title', + 'ranged on the class name', + ); + } + + // Interaction 2 — inside a class value, the stylesheet's classes complete as values + // that replace the class name at the caret. [RAZOR-COLLOCATION-CSS] + const end = doc.lineAt(doc.lineCount - 1).range.end; + await editor.edit((builder) => { + builder.insert(end, '\n
{ assert.strictEqual(DEBUG_TYPE, DEBUG_TYPE_ID, 'constants and the kit name one debug type'); const debuggers: unknown = block.debuggers; assert.ok(Array.isArray(debuggers), 'contributes.debuggers must be an array'); - assert.strictEqual(debuggers.length, 1, 'a second debugger would split the F5 auto-pick'); - assert.strictEqual(debuggers[0], contribution, 'the only entry IS the sharplsp-coreclr one'); + // Judged on `languages`, not on the array's length: a debugger that declares + // NONE ([DEBUG-FEATURES-LAUNCH-BLAZOR-WASM]'s `sharplsp-blazorwasm`, reached + // from launch.json and the Select Debugger list) takes no part in the F5 + // auto-pick, so only an entry that DOES declare them can split it. + const autoPicked = debuggers.filter( + (item) => Array.isArray(item?.languages) && item.languages.length > 0, + ); + assert.strictEqual(autoPicked.length, 1, 'a second debugger would split the F5 auto-pick'); + assert.strictEqual(autoPicked[0], contribution, 'the auto-picked entry IS sharplsp-coreclr'); const bodies = snippetBodies(); assert.strictEqual(bodies.length, 2, 'a launch and an attach snippet are both offered'); const types = bodies.map((body) => String(body.type)); diff --git a/src/editors/vscode/test-chunks.json b/src/editors/vscode/test-chunks.json index 3e274eba..ed0ba200 100644 --- a/src/editors/vscode/test-chunks.json +++ b/src/editors/vscode/test-chunks.json @@ -56,11 +56,13 @@ ] }, "debug": { - "description": "Getting a program to START, and nothing about what happens once it has: the F5 / no-launch.json resolve contract, launchSettings.json + .run.json profile parsing, the netcoredbg adapter factory, manifest conformance for the debugger, breakpoint, task-definition, command and menu contributions, the [SCRIPT-CONE] launch-target walk with MSBuild output resolution, and the F5 / Ctrl+F5 / sharplsp.runProgram gestures over real projects, C# file-based apps and .fsx scripts.", + "description": "Getting a program to START, and nothing about what happens once it has: the F5 / no-launch.json resolve contract, launchSettings.json + .run.json profile parsing, the netcoredbg adapter factory, manifest conformance for the debugger, breakpoint, task-definition, command and menu contributions, the [SCRIPT-CONE] launch-target walk with MSBuild output resolution, and the F5 / Ctrl+F5 / sharplsp.runProgram gestures over real projects, C# file-based apps and .fsx scripts. Also the Blazor WebAssembly launch ([DEBUG-FEATURES-LAUNCH-BLAZOR-WASM]), which starts `dotnet run`, hands the DevServer's debug proxy to js-debug and dies with that browser session.", "files": [ "debug-e2e.test.js", "debug-adapter-e2e.test.js", "debug-adapter-startup.test.js", + "blazor-wasm-debug.test.js", + "blazor-wasm-debug-resolution.test.js", "run-debug-profiles.test.js", "run-debug-contributions.test.js", "run-debug-target.test.js", diff --git a/src/sharplsp/src/call_hierarchy.rs b/src/sharplsp/src/call_hierarchy.rs index e5ff5d9c..c92f0bd8 100644 --- a/src/sharplsp/src/call_hierarchy.rs +++ b/src/sharplsp/src/call_hierarchy.rs @@ -75,6 +75,7 @@ fn parse_symbol_kind(kind: &str) -> SymbolKind { "Property" => SymbolKind::PROPERTY, "Field" => SymbolKind::FIELD, "Class" => SymbolKind::CLASS, + "Event" => SymbolKind::EVENT, "Interface" => SymbolKind::INTERFACE, "Struct" => SymbolKind::STRUCT, "Enum" => SymbolKind::ENUM, diff --git a/src/sharplsp/src/code_actions.rs b/src/sharplsp/src/code_actions.rs index 6dea6e3f..a8d12c10 100644 --- a/src/sharplsp/src/code_actions.rs +++ b/src/sharplsp/src/code_actions.rs @@ -8,7 +8,10 @@ use std::sync::Arc; use anyhow::Result; use lsp_server::Request; use lsp_types::{ - CodeAction, CodeActionKind, CodeActionOrCommand, CodeActionParams, TextEdit, Uri, WorkspaceEdit, + CodeAction, CodeActionKind, CodeActionOrCommand, CodeActionParams, CreateFile, + CreateFileOptions, DocumentChangeOperation, DocumentChanges, OneOf, + OptionalVersionedTextDocumentIdentifier, ResourceOp, TextDocumentEdit, TextEdit, Uri, + WorkspaceEdit, }; use tracing::debug; @@ -104,11 +107,59 @@ fn map_action_kind(kind: &str) -> CodeActionKind { } /// Convert a sidecar workspace edit into an LSP `WorkspaceEdit`. +/// The LSP edit for a sidecar edit: a map of text edits by file, or, when it +/// creates a file, document changes that create each new file before editing +/// it, since a text edit cannot reach a file that does not exist yet. +/// [RAZOR-CODE-ACTIONS] +fn map_workspace_edit(edit: &SidecarWorkspaceEdit) -> WorkspaceEdit { + if edit.document_changes.iter().any(|change| change.create) { + WorkspaceEdit { + document_changes: Some(DocumentChanges::Operations( + edit.document_changes + .iter() + .flat_map(document_operations) + .collect(), + )), + ..WorkspaceEdit::default() + } + } else { + map_text_changes(edit) + } +} + +/// A document's operations: its creation when it is new, then its edits. +fn document_operations(change: &SidecarDocumentEdit) -> Vec { + let Ok(uri) = crate::paths::path_to_lsp_uri(&change.file_path) else { + return Vec::new(); + }; + let create = change.create.then(|| { + DocumentChangeOperation::Op(ResourceOp::Create(CreateFile { + uri: uri.clone(), + options: Some(CreateFileOptions { + overwrite: Some(false), + ignore_if_exists: Some(false), + }), + annotation_id: None, + })) + }); + let edits = DocumentChangeOperation::Edit(TextDocumentEdit { + text_document: OptionalVersionedTextDocumentIdentifier { uri, version: None }, + edits: change + .edits + .iter() + .map(map_text_edit) + .map(OneOf::Left) + .collect(), + }); + create.into_iter().chain([edits]).collect() +} + +/// A sidecar edit that creates no file, as per-document text changes. #[expect( clippy::mutable_key_type, reason = "Uri is the key type mandated by lsp-types WorkspaceEdit" )] -fn map_workspace_edit(edit: &SidecarWorkspaceEdit) -> WorkspaceEdit { +fn map_text_changes(edit: &SidecarWorkspaceEdit) -> WorkspaceEdit { let mut changes = std::collections::HashMap::new(); for doc_edit in &edit.document_changes { if let Ok(uri) = crate::paths::path_to_lsp_uri(&doc_edit.file_path) { @@ -166,6 +217,9 @@ struct SidecarDocumentEdit { file_path: String, /// Text edits to apply to this document. edits: Vec, + /// Whether the edit creates the file, whose whole text it then holds. + #[serde(default)] + create: bool, } /// A workspace-wide set of edits returned by the sidecar. @@ -238,6 +292,7 @@ mod tests { use crate::paths::test_paths::{NATIVE_FILE, NATIVE_FILE_URI}; let edit = SidecarWorkspaceEdit { document_changes: vec![SidecarDocumentEdit { + create: false, file_path: NATIVE_FILE.to_string(), edits: vec![ SidecarTextEdit { @@ -271,6 +326,7 @@ mod tests { fn map_workspace_edit_skips_unparseable_paths() { let edit = SidecarWorkspaceEdit { document_changes: vec![SidecarDocumentEdit { + create: false, file_path: "\u{0}".to_string(), edits: vec![], }], @@ -278,4 +334,121 @@ mod tests { let workspace_edit = map_workspace_edit(&edit); assert!(workspace_edit.changes.unwrap().is_empty()); } + + /// An edit that creates a file creates it before writing its text, and + /// edits the other files in the same document changes. [RAZOR-CODE-ACTIONS] + #[test] + fn map_workspace_edit_creates_new_files_before_editing_them() { + use crate::paths::test_paths::{NATIVE_FILE, NATIVE_FILE_URI}; + let text = |new_text: &str| SidecarTextEdit { + start_line: 0, + start_character: 0, + end_line: 0, + end_character: 0, + new_text: new_text.to_string(), + }; + let created = format!("{NATIVE_FILE}.cs"); + let edit = SidecarWorkspaceEdit { + document_changes: vec![ + SidecarDocumentEdit { + create: true, + file_path: created.clone(), + edits: vec![text("public partial class Counter {}")], + }, + SidecarDocumentEdit { + create: false, + file_path: NATIVE_FILE.to_string(), + edits: vec![text("")], + }, + ], + }; + let workspace_edit = map_workspace_edit(&edit); + assert!(workspace_edit.changes.is_none()); + let operations = match workspace_edit.document_changes { + Some(DocumentChanges::Operations(operations)) => operations, + _ => Vec::new(), + }; + let steps: Vec<(&str, &str, Option, usize)> = + operations.iter().map(operation_step).collect(); + let created_uri = crate::paths::path_to_lsp_uri(&created).unwrap(); + assert_eq!( + steps, + [ + ("create", created_uri.as_str(), Some(false), 0), + ("edit", created_uri.as_str(), None, 1), + ("edit", NATIVE_FILE_URI, None, 1), + ], + "the new file is created, then written, then the existing file is edited" + ); + assert!( + operations.iter().all(|operation| !matches!( + operation, + DocumentChangeOperation::Edit(TextDocumentEdit { + text_document: OptionalVersionedTextDocumentIdentifier { + version: Some(_), + .. + }, + .. + }) + )), + "no edit pins a document version" + ); + } + + /// A creating edit drops a document whose path is no URI, and keeps the + /// others: the new file is still created. [RAZOR-CODE-ACTIONS] + #[test] + fn map_workspace_edit_creating_files_skips_unparseable_paths() { + use crate::paths::test_paths::NATIVE_FILE; + let created = format!("{NATIVE_FILE}.cs"); + let edit = SidecarWorkspaceEdit { + document_changes: vec![ + SidecarDocumentEdit { + create: true, + file_path: created.clone(), + edits: vec![], + }, + SidecarDocumentEdit { + create: false, + file_path: "\u{0}".to_string(), + edits: vec![], + }, + ], + }; + let operations = match map_workspace_edit(&edit).document_changes { + Some(DocumentChanges::Operations(operations)) => operations, + _ => Vec::new(), + }; + let created_uri = crate::paths::path_to_lsp_uri(&created).unwrap(); + let steps: Vec<_> = operations.iter().map(operation_step).collect(); + assert_eq!( + steps, + [ + ("create", created_uri.as_str(), Some(false), 0), + ("edit", created_uri.as_str(), None, 0), + ] + ); + } + + /// An operation as `(kind, uri, overwrite, edit count)`. + fn operation_step(operation: &DocumentChangeOperation) -> (&str, &str, Option, usize) { + match operation { + DocumentChangeOperation::Op(ResourceOp::Create(create)) => ( + "create", + create.uri.as_str(), + create + .options + .as_ref() + .and_then(|options| options.overwrite), + 0, + ), + DocumentChangeOperation::Edit(edit) => ( + "edit", + edit.text_document.uri.as_str(), + None, + edit.edits.len(), + ), + DocumentChangeOperation::Op(_) => ("other", "", None, 0), + } + } } diff --git a/src/sharplsp/src/cross_language.rs b/src/sharplsp/src/cross_language.rs new file mode 100644 index 00000000..1915b660 --- /dev/null +++ b/src/sharplsp/src/cross_language.rs @@ -0,0 +1,305 @@ +//! Symbols that cross the C# ↔ F# boundary, found in the other sidecar by +//! their identity: the assembly that declares them and their XML doc +//! signature, the only name both compilers give a symbol. +//! +//! C# sees an F# project only as its built DLL, so definition from C# or a +//! Razor file onto an F# symbol lands in decompiled source. That location +//! carries the symbol's identity, and the F# sidecar answers its F# +//! declaration instead. References merge each side's uses the same way. +//! Implements [DEFINITION-CROSSLANG-IDENTITY], [REFERENCES-CROSS-LANGUAGE] +//! and [RAZOR-FSHARP]. + +use std::sync::Arc; + +use anyhow::Result; +use lsp_types::Location; +use tracing::debug; + +use crate::semantic::{ + request_symbol_identity, sidecar_location_to_lsp, sidecar_workspace_loaded, + SidecarLocationListResult, SidecarLocationResult, SidecarRenameIdentityResult, +}; +use crate::sidecar::manager::SidecarManager; +use crate::utils::{request_sidecar, SidecarPositionReq}; + +/// The request naming a symbol by identity, matching the sidecars' +/// `ForeignSymbolRequest` key layout. +#[derive(serde::Serialize)] +struct ForeignSymbolReq<'a> { + /// Simple name of the assembly that declares the symbol. + assembly_name: &'a str, + /// The symbol's XML doc signature. + xml_doc_sig: &'a str, + /// Whether references include the declaration. + include_declaration: bool, +} + +/// `result` with each decompiled location of an F# project's symbol replaced +/// by that symbol's F# declarations, where the F# sidecar finds them, and +/// whether the answer is settled. A location without an identity stays; one +/// the F# sidecar cannot place yet stays too, and leaves the answer unsettled, +/// since the F# sidecar may still be loading. With no F# sidecar, nothing can +/// place it, so the decompiled answer is final. +pub fn in_fsharp_source( + runtime: &tokio::runtime::Runtime, + fsharp: Option<&Arc>, + result: SidecarLocationListResult, +) -> (SidecarLocationListResult, bool) { + let Some(fsharp) = fsharp else { + return (result, true); + }; + let (placed, settled): (Vec<_>, Vec<_>) = result + .locations + .into_iter() + .map(|location| declarations_of(runtime, fsharp, location)) + .unzip(); + let locations = placed.into_iter().flatten().collect(); + ( + SidecarLocationListResult { locations }, + settled.into_iter().all(|done| done), + ) +} + +/// The F# declarations of the symbol `location` shows, else `location`, and +/// whether that is final: a location naming no F# symbol always is. +fn declarations_of( + runtime: &tokio::runtime::Runtime, + fsharp: &Arc, + location: SidecarLocationResult, +) -> (Vec, bool) { + let Some(symbol) = location.symbol.as_ref().filter(|symbol| symbol.found) else { + return (vec![location], true); + }; + by_identity( + runtime, + fsharp, + "workspace/declarationForeign", + symbol, + true, + ) + .inspect_err(|err| debug!(error = %format_args!("{err:#}"), "F# declaration lookup failed")) + .ok() + .filter(|declared| !declared.is_empty()) + .map_or_else(|| (vec![location], false), |declared| (declared, true)) +} + +/// The other language's uses of the symbol at `at`, found by its identity: +/// none when the other sidecar has no workspace, the symbol has no identity, +/// or either sidecar fails, so the primary answer always stands. +pub fn foreign_references( + runtime: &tokio::runtime::Runtime, + primary: &SidecarManager, + other: Option<&Arc>, + at: &SidecarPositionReq, + include_declaration: bool, +) -> Vec { + let Some(other) = other else { + return Vec::new(); + }; + references_by_identity(runtime, primary, other, at, include_declaration).unwrap_or_else(|err| { + debug!(error = %format_args!("{err:#}"), "cross-language references failed"); + Vec::new() + }) +} + +/// The other sidecar's references to the symbol at `at`, found by the identity +/// the primary sidecar names it with; none when it names no foreign symbol. +fn references_by_identity( + runtime: &tokio::runtime::Runtime, + primary: &SidecarManager, + other: &Arc, + at: &SidecarPositionReq, + include_declaration: bool, +) -> Result> { + if !sidecar_workspace_loaded(runtime, other)? { + return Ok(Vec::new()); + } + let identity = request_symbol_identity(runtime, primary, at)?; + if !identity.found { + return Ok(Vec::new()); + } + let method = "workspace/referencesForeign"; + let found = by_identity(runtime, other, method, &identity, include_declaration)?; + Ok(found.iter().filter_map(sidecar_location_to_lsp).collect()) +} + +/// Ask `sidecar` for the locations `method` finds for the symbol `identity`. +fn by_identity( + runtime: &tokio::runtime::Runtime, + sidecar: &Arc, + method: &str, + identity: &SidecarRenameIdentityResult, + include_declaration: bool, +) -> Result> { + let request = ForeignSymbolReq { + assembly_name: &identity.assembly_name, + xml_doc_sig: &identity.xml_doc_sig, + include_declaration, + }; + let found: Option = + request_sidecar(runtime, sidecar, method, &request)?; + Ok(found.map_or_else(Vec::new, |result| result.locations)) +} + +/// `primary` followed by each of `foreign` it does not already hold. +pub fn merged(primary: Vec, foreign: Vec) -> Vec { + foreign.into_iter().fold(primary, |mut all, location| { + if !all.contains(&location) { + all.push(location); + } + all + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use lsp_types::{Position, Range, Uri}; + use std::str::FromStr; + + /// A sidecar whose process cannot start, so every request to it fails. + fn unreachable_sidecar() -> Arc { + Arc::new(SidecarManager::new( + "unreachable", + "sharplsp-no-such-sidecar", + Vec::new(), + "sharplsp-no-such-sidecar.sock", + )) + } + + /// A sidecar location as the wire carries it: `symbol` names an F# + /// project's symbol as `(found, assembly, xml doc signature)`. + fn location(line: u32, symbol: Option<(bool, &str, &str)>) -> Option { + let wire = ("/a/Domain.dll.cs", line, 4, line, 10, symbol); + rmp_serde::from_slice(&rmp_serde::to_vec(&wire).ok()?).ok() + } + + fn lines(result: &SidecarLocationListResult) -> Vec { + result.locations.iter().map(|found| found.line).collect() + } + + /// With no F# sidecar nothing can place a decompiled location, so the + /// answer is final as it stands. [DEFINITION-CROSSLANG-IDENTITY] + #[test] + fn without_an_fsharp_sidecar_decompiled_locations_are_final() { + let runtime = tokio::runtime::Runtime::new().ok(); + let result = SidecarLocationListResult { + locations: [ + location(3, Some((true, "Domain", "T:Domain.Player"))), + location(7, None), + ] + .into_iter() + .flatten() + .collect(), + }; + let placed = runtime.map(|runtime| in_fsharp_source(&runtime, None, result)); + let (result, settled) = placed.unzip(); + assert_eq!(settled, Some(true), "nothing could ever place it"); + assert_eq!( + result.as_ref().map(lines), + Some(vec![3, 7]), + "every location stays" + ); + } + + /// A location the F# sidecar cannot place stays decompiled and leaves the + /// answer unsettled, so it is asked again; a location naming no F# symbol, + /// or one whose identity was not found, never asks. [DEFINITION-CROSSLANG-IDENTITY] + #[test] + fn an_unplaced_fsharp_symbol_stays_decompiled_and_unsettled() { + let runtime = tokio::runtime::Runtime::new().ok(); + let fsharp = unreachable_sidecar(); + let answer = |locations: Vec>| { + let result = SidecarLocationListResult { + locations: locations.into_iter().flatten().collect(), + }; + runtime + .as_ref() + .map(|runtime| in_fsharp_source(runtime, Some(&fsharp), result)) + .map(|(result, settled)| (lines(&result), settled)) + }; + assert_eq!( + answer(vec![ + location(3, Some((true, "Domain", "T:Domain.Player"))), + location(7, None) + ]), + Some((vec![3, 7], false)), + "the F# sidecar failed, so the decompiled answer stands for now" + ); + assert_eq!( + answer(vec![location(5, Some((false, "", ""))), location(7, None)]), + Some((vec![5, 7], true)), + "no F# symbol to place: the answer is final" + ); + } + + /// No other sidecar, or one that fails, adds no references: the primary + /// answer always stands. [REFERENCES-CROSS-LANGUAGE] + #[test] + fn foreign_references_are_none_without_a_working_other_sidecar() { + let runtime = tokio::runtime::Runtime::new().ok(); + let primary = unreachable_sidecar(); + let other = unreachable_sidecar(); + let at = SidecarPositionReq { + file_path: "/a/Board.razor".to_string(), + line: 3, + character: 9, + }; + let found = |other: Option<&Arc>| { + runtime + .as_ref() + .map(|runtime| foreign_references(runtime, &primary, other, &at, true)) + }; + assert_eq!(found(None), Some(Vec::new()), "no other sidecar"); + assert_eq!( + found(Some(&other)), + Some(Vec::new()), + "the other sidecar fails" + ); + } + + fn at(uri: &str, line: u32) -> Option { + Some(Location { + uri: Uri::from_str(uri).ok()?, + range: Range::new(Position::new(line, 0), Position::new(line, 4)), + }) + } + + /// Each side's answers once, the primary's first. [REFERENCES-CROSS-LANGUAGE] + #[test] + fn merged_keeps_the_primary_order_and_drops_duplicates() { + let primary: Vec = [ + at("file:///a/Leaders.razor", 6), + at("file:///a/Scores.fs", 3), + ] + .into_iter() + .flatten() + .collect(); + let foreign: Vec = [at("file:///a/Scores.fs", 3), at("file:///a/Scores.fs", 9)] + .into_iter() + .flatten() + .collect(); + let all = merged(primary.clone(), foreign.clone()); + assert_eq!(all.len(), 3); + assert_eq!(all.first(), primary.first()); + assert_eq!(all.last(), foreign.last()); + assert_eq!(merged(Vec::new(), foreign.clone()), foreign); + } + + /// The wire request is positional, in the sidecars' key order. + #[test] + fn foreign_symbol_request_is_positional() { + let request = ForeignSymbolReq { + assembly_name: "Domain", + xml_doc_sig: "T:Domain.Player", + include_declaration: true, + }; + let decoded: Option<(String, String, bool)> = rmp_serde::to_vec(&request) + .ok() + .and_then(|bytes| rmp_serde::from_slice(&bytes).ok()); + assert_eq!( + decoded, + Some(("Domain".into(), "T:Domain.Player".into(), true)) + ); + } +} diff --git a/src/sharplsp/src/main.rs b/src/sharplsp/src/main.rs index 99ba9ab8..0a710ab9 100644 --- a/src/sharplsp/src/main.rs +++ b/src/sharplsp/src/main.rs @@ -8,6 +8,7 @@ mod code_lens; mod config; mod config_debug; mod configuration; +mod cross_language; mod diagnostics; mod document_symbols; mod folding; @@ -810,8 +811,9 @@ fn handle_request( // than a syntax tree can give). The other syntax features — // foldingRange, selectionRange, linkedEditingRange — run on the F# // tree-sitter grammar for both languages. Razor has no grammar, so the - // C# sidecar answers its syntax requests, and selection ranges are not - // served yet. [SHARPLSP-FEATURES-NAVIGATION] [RAZOR-ARCHITECTURE-ROUTING] + // C# sidecar answers every one of its syntax requests, selection + // ranges chained out to the whole document. + // [SHARPLSP-FEATURES-NAVIGATION] [RAZOR-ARCHITECTURE-ROUTING] DocumentSymbolRequest::METHOD => match request_lang(&req) { Some(LangId::FSharp | LangId::Razor) => { let sidecar = pick_sidecar(&req, csharp_sidecar, fsharp_sidecar); @@ -823,7 +825,7 @@ fn handle_request( razor::handle_folding_ranges(req, runtime, csharp_sidecar) } SelectionRangeRequest::METHOD if request_lang(&req) == Some(LangId::Razor) => { - Ok(serde_json::json!([])) + razor::handle_selection_ranges(req, runtime, csharp_sidecar) } LinkedEditingRange::METHOD if request_lang(&req) == Some(LangId::Razor) => { razor::handle_linked_editing_range(req, runtime, csharp_sidecar) diff --git a/src/sharplsp/src/razor.rs b/src/sharplsp/src/razor.rs index 8a4099a8..46bc5107 100644 --- a/src/sharplsp/src/razor.rs +++ b/src/sharplsp/src/razor.rs @@ -1,7 +1,8 @@ //! Razor documents (`.razor`, `.cshtml`) in the host. //! //! The host has no Razor grammar, so the syntax requests it answers from -//! tree-sitter for C# go to the C# sidecar for Razor, whose Razor compiler is +//! tree-sitter for C# — documentSymbol, foldingRange, selectionRange and +//! linkedEditingRange — go to the C# sidecar for Razor, whose Razor compiler is //! the parser. The two Razor completion triggers are useless in C# and F# //! documents, so those answer at once. Implements [RAZOR-ARCHITECTURE-ROUTING]. @@ -11,7 +12,7 @@ use anyhow::Result; use lsp_server::Request; use lsp_types::{ CompletionParams, FoldingRange, FoldingRangeParams, LinkedEditingRangeParams, - LinkedEditingRanges, Position, Range, TextDocumentIdentifier, Uri, + LinkedEditingRanges, Position, Range, SelectionRangeParams, TextDocumentIdentifier, Uri, }; use crate::semantic::SidecarLocationListResult; @@ -129,6 +130,70 @@ fn linked_ranges(found: &SidecarLocationListResult) -> Result }) } +/// Handle `textDocument/selectionRange` for a Razor document via the C# sidecar: +/// each position's chain of Razor ranges, innermost first, nested into LSP +/// parents. Implements [RAZOR-FEATURES-SELECTION]. +pub fn handle_selection_ranges( + req: Request, + runtime: &tokio::runtime::Runtime, + sidecar: Option<&Arc>, +) -> Result { + let params: SelectionRangeParams = serde_json::from_value(req.params)?; + let request = SidecarSelectionRangesReq { + file_path: crate::paths::uri_to_path(params.text_document.uri.as_str())?, + positions: params + .positions + .iter() + .map(|at| (at.line, at.character)) + .collect(), + }; + let chains: Vec = + fetch(runtime, sidecar, "textDocument/selectionRange", &request)?; + Ok(serde_json::to_value(selection_ranges(&chains))?) +} + +/// The host's request for a Razor file's selection ranges: the file and its +/// carets, matching the sidecar's `SelectionRangeRequest` Key layout. +#[derive(serde::Serialize)] +struct SidecarSelectionRangesReq { + /// Absolute path of the Razor file. + file_path: String, + /// The carets, in request order. + positions: Vec<(u32, u32)>, +} + +/// One position's answer: its Razor ranges, innermost first. +#[derive(serde::Deserialize)] +struct SidecarRangeList { + /// The chain, innermost first, as `(start_line, start_character, end_line, + /// end_character)`. + ranges: Vec<(u32, u32, u32, u32)>, +} + +/// The sidecar's chains as LSP selection ranges, one per position. LSP 3.17 +/// types the answer `SelectionRange[] | null`, so a position with no chain — a +/// file no project lists — makes the whole answer none. +fn selection_ranges(chains: &[SidecarRangeList]) -> Option> { + chains + .iter() + .map(|chain| nest(chain.ranges.as_slice())) + .collect() +} + +/// Ranges innermost first as one [`lsp_types::SelectionRange`], each range its +/// successor's `parent`; none when the chain is empty. +fn nest(ranges: &[(u32, u32, u32, u32)]) -> Option { + ranges.iter().rev().fold(None, |parent, range| { + Some(lsp_types::SelectionRange { + range: Range::new( + Position::new(range.0, range.1), + Position::new(range.2, range.3), + ), + parent: parent.map(Box::new), + }) + }) +} + /// Send one request to the sidecar that owns Razor and decode its answer. fn fetch( runtime: &tokio::runtime::Runtime, @@ -268,4 +333,38 @@ mod tests { assert_eq!((fold.start_character, fold.end_character), (None, None)); assert!(fold.kind.is_none()); } + + /// A sidecar chain nests into LSP parents, innermost first; an empty chain + /// answers none. [RAZOR-FEATURES-SELECTION] + #[test] + fn selection_range_chains_nest_innermost_first() { + let nested = selection_ranges(&[SidecarRangeList { + ranges: vec![(1, 2, 1, 8), (1, 0, 3, 4)], + }]); + let inner = nested + .as_ref() + .and_then(|chains| chains.first()) + .expect("the innermost range"); + assert_eq!( + inner.range, + Range::new(Position::new(1, 2), Position::new(1, 8)) + ); + let outer = inner.parent.as_ref().expect("the outer range"); + assert_eq!( + outer.range, + Range::new(Position::new(1, 0), Position::new(3, 4)) + ); + assert!(outer.parent.is_none(), "the document has no parent"); + + let empty = selection_ranges(&[ + SidecarRangeList { + ranges: vec![(0, 0, 0, 1)], + }, + SidecarRangeList { ranges: vec![] }, + ]); + assert_eq!( + empty, None, + "a position with no chain makes the whole answer none" + ); + } } diff --git a/src/sharplsp/src/semantic.rs b/src/sharplsp/src/semantic.rs index 5e015293..8fc265b3 100644 --- a/src/sharplsp/src/semantic.rs +++ b/src/sharplsp/src/semantic.rs @@ -297,9 +297,9 @@ pub fn handle_implementation( fallback: Option<&Arc>, ) -> Result { let method = "textDocument/implementation"; - let value = handle_multi_location_nav(req.clone(), runtime, sidecar, method)?; + let (value, _) = handle_multi_location_nav(req.clone(), runtime, (sidecar, fallback), method)?; Ok(or_fallback(value, fallback, method, |fb| { - handle_multi_location_nav(req, runtime, Some(fb), method) + handle_multi_location_nav(req, runtime, (Some(fb), None), method).map(|(value, _)| value) })) } @@ -319,12 +319,12 @@ pub fn handle_references( "textDocument/references:nodecl" }; cached_at_position(req, vfs, nav_cache, cache_method, |req| { - let value = handle_references_nav(req.clone(), runtime, sidecar)?; + let value = handle_references_nav(req.clone(), runtime, (sidecar, fallback))?; Ok(or_fallback( value, fallback, "textDocument/references", - |fb| handle_references_nav(req, runtime, Some(fb)), + |fb| handle_references_nav(req, runtime, (Some(fb), None)), )) }) } @@ -393,10 +393,13 @@ fn to_document_highlight(h: &SidecarDocumentHighlightResult) -> DocumentHighligh } /// Inner handler for references (serializes `ReferencesRequest` with `include_declaration`). +/// References from the sidecar that owns the document, merged with the other +/// language's uses of the same symbol, found by its identity. +/// [REFERENCES-CROSS-LANGUAGE] fn handle_references_nav( req: Request, runtime: &tokio::runtime::Runtime, - sidecar: Option<&Arc>, + (sidecar, other): NavRoute<'_>, ) -> Result { with_sidecar(req, sidecar, |sidecar, params: ReferenceParams| { let at = SidecarPositionReq::at( @@ -412,7 +415,7 @@ fn handle_references_nav( "References request dispatching to sidecar" ); let request = SidecarReferencesReq { - file_path: at.file_path, + file_path: at.file_path.clone(), line: at.line, character: at.character, include_declaration, @@ -423,12 +426,21 @@ fn handle_references_nav( else { return Ok(serde_json::Value::Null); }; - let locations: Vec = result + let own: Vec = result .locations .iter() .filter_map(sidecar_location_to_lsp) .collect(); - Ok(serde_json::to_value(locations)?) + let foreign = crate::cross_language::foreign_references( + runtime, + sidecar, + other, + &at, + include_declaration, + ); + Ok(serde_json::to_value(crate::cross_language::merged( + own, foreign, + ))?) }) } @@ -440,9 +452,9 @@ fn handle_references_nav( fn dispatch_position_request( req: Request, runtime: &tokio::runtime::Runtime, - sidecar: Option<&Arc>, + (sidecar, foreign): NavRoute<'_>, method: &str, -) -> Result> { +) -> Result> { let Some(sidecar) = sidecar else { debug!("{method}: no sidecar available"); return Ok(None); @@ -457,19 +469,28 @@ fn dispatch_position_request( character = request.character, "request dispatching to sidecar" ); - request_sidecar(runtime, sidecar, method, &request) + let found: Option = + request_sidecar(runtime, sidecar, method, &request)?; + Ok(found.map(|result| crate::cross_language::in_fsharp_source(runtime, foreign, result))) } +/// The sidecar a navigation request goes to, and the F# sidecar that places +/// the F# symbols its decompiled answers show. [DEFINITION-CROSSLANG-IDENTITY] +type NavRoute<'a> = ( + Option<&'a Arc>, + Option<&'a Arc>, +); + /// Shared handler for multi-location navigation requests /// (definition, implementation). fn handle_multi_location_nav( req: Request, runtime: &tokio::runtime::Runtime, - sidecar: Option<&Arc>, + route: NavRoute<'_>, method: &str, -) -> Result { - let Some(result) = dispatch_position_request(req, runtime, sidecar, method)? else { - return Ok(serde_json::Value::Null); +) -> Result<(serde_json::Value, bool)> { + let Some((result, settled)) = dispatch_position_request(req, runtime, route, method)? else { + return Ok((serde_json::Value::Null, true)); }; let locations: Vec = result .locations @@ -478,7 +499,7 @@ fn handle_multi_location_nav( .collect(); let response = (!locations.is_empty()).then(|| GotoDefinitionResponse::Array(locations)); - Ok(serde_json::to_value(response)?) + Ok((serde_json::to_value(response)?, settled)) } /// Cached navigation with cross-language fallback. @@ -501,9 +522,18 @@ fn handle_cached_nav_with_fallback( method: &str, multi: bool, ) -> Result { - let value = handle_cached_nav(req.clone(), vfs, nav_cache, runtime, sidecar, method, multi)?; + let route = (sidecar, fallback); + let value = handle_cached_nav(req.clone(), vfs, nav_cache, runtime, route, method, multi)?; Ok(or_fallback(value, fallback, method, |fb| { - handle_cached_nav(req, vfs, nav_cache, runtime, Some(fb), method, multi) + handle_cached_nav( + req, + vfs, + nav_cache, + runtime, + (Some(fb), None), + method, + multi, + ) })) } @@ -542,6 +572,22 @@ fn cached_at_position( nav_cache: &mut crate::nav_cache::NavCache, method: &str, dispatch: impl FnOnce(Request) -> Result, +) -> Result { + cached_at_position_when_settled(req, vfs, nav_cache, method, |req| { + dispatch(req).map(|value| (value, true)) + }) +} + +/// [`cached_at_position`] for an answer that may not be settled yet: one that +/// still shows an F# symbol in decompiled source, because the F# sidecar could +/// not place it yet, is answered but not remembered, so the next request asks +/// again. [DEFINITION-CROSSLANG-IDENTITY] +fn cached_at_position_when_settled( + req: Request, + vfs: &crate::vfs::Vfs, + nav_cache: &mut crate::nav_cache::NavCache, + method: &str, + dispatch: impl FnOnce(Request) -> Result<(serde_json::Value, bool)>, ) -> Result { let at: TextDocumentPositionParams = serde_json::from_value(req.params.clone())?; let uri = at.text_document.uri.as_str(); @@ -550,8 +596,10 @@ fn cached_at_position( if let Some(cached) = cached_nav_hit(nav_cache, uri, version, line, character, method, method) { return Ok(cached); } - let value = dispatch(req)?; - nav_cache.insert(uri, version, line, character, method, value.clone()); + let (value, settled) = dispatch(req)?; + if settled { + nav_cache.insert(uri, version, line, character, method, value.clone()); + } Ok(value) } @@ -581,15 +629,15 @@ fn handle_cached_nav( vfs: &crate::vfs::Vfs, nav_cache: &mut crate::nav_cache::NavCache, runtime: &tokio::runtime::Runtime, - sidecar: Option<&Arc>, + route: NavRoute<'_>, method: &str, multi: bool, ) -> Result { - cached_at_position(req, vfs, nav_cache, method, |req| { + cached_at_position_when_settled(req, vfs, nav_cache, method, |req| { if multi { - handle_multi_location_nav(req, runtime, sidecar, method) + handle_multi_location_nav(req, runtime, route, method) } else { - handle_single_location_nav(req, runtime, sidecar, method) + handle_single_location_nav(req, runtime, route, method) } }) } @@ -599,22 +647,22 @@ fn handle_cached_nav( fn handle_single_location_nav( req: Request, runtime: &tokio::runtime::Runtime, - sidecar: Option<&Arc>, + route: NavRoute<'_>, method: &str, -) -> Result { - let Some(result) = dispatch_position_request(req, runtime, sidecar, method)? else { - return Ok(serde_json::Value::Null); +) -> Result<(serde_json::Value, bool)> { + let Some((result, settled)) = dispatch_position_request(req, runtime, route, method)? else { + return Ok((serde_json::Value::Null, true)); }; let response = result.locations.first().and_then(|loc| { let location = sidecar_location_to_lsp(loc)?; Some(GotoDefinitionResponse::Scalar(location)) }); - Ok(serde_json::to_value(response)?) + Ok((serde_json::to_value(response)?, settled)) } /// Convert a sidecar `LocationResult` to an LSP `Location`. -fn sidecar_location_to_lsp(loc: &SidecarLocationResult) -> Option { +pub(crate) fn sidecar_location_to_lsp(loc: &SidecarLocationResult) -> Option { let uri: Uri = path_to_lsp_uri(&loc.file_path).ok()?; Some(Location { uri, @@ -658,6 +706,7 @@ fn map_completion_kind(tag: &str) -> CompletionItemKind { "Constant" => CompletionItemKind::CONSTANT, "Delegate" => CompletionItemKind::FUNCTION, "TypeParameter" => CompletionItemKind::TYPE_PARAMETER, + "Value" => CompletionItemKind::VALUE, _ => CompletionItemKind::TEXT, } } @@ -776,6 +825,10 @@ pub(crate) struct SidecarLocationResult { pub(crate) end_line: u32, /// End character of the target range. pub(crate) end_character: u32, + /// For decompiled source of an F# project's symbol, the symbol's identity, + /// which the F# sidecar places. [DEFINITION-CROSSLANG-IDENTITY] + #[serde(default)] + pub(crate) symbol: Option, } /// Sidecar response containing a list of locations. @@ -956,7 +1009,7 @@ fn foreign_rename_edits( } /// Check whether the other-language sidecar has a project that can contain references. -fn sidecar_workspace_loaded( +pub(crate) fn sidecar_workspace_loaded( runtime: &tokio::runtime::Runtime, sidecar: &Arc, ) -> Result { @@ -997,7 +1050,18 @@ fn request_rename_identity( line: rename.line, character: rename.character, }; - let payload = rmp_serde::to_vec(&request)?; + request_symbol_identity(runtime, sidecar, &request) +} + +/// Ask the sidecar that owns `at`'s document for the identity of the symbol +/// there: a C# source symbol, an F# one, or an F# project's symbol that C# or +/// Razor reads from its DLL. [DEFINITION-CROSSLANG-IDENTITY] +pub(crate) fn request_symbol_identity( + runtime: &tokio::runtime::Runtime, + sidecar: &SidecarManager, + at: &SidecarPositionReq, +) -> Result { + let payload = rmp_serde::to_vec(at)?; let bytes = runtime.block_on(sidecar.request("textDocument/renameIdentity", payload))?; Ok(rmp_serde::from_slice(&bytes)?) } @@ -1211,13 +1275,13 @@ struct SidecarRenameRequest { /// Portable metadata identity returned by the symbol's owning sidecar. #[derive(serde::Deserialize)] -struct SidecarRenameIdentityResult { +pub(crate) struct SidecarRenameIdentityResult { /// Whether the source position resolved to a cross-language-visible symbol. - found: bool, + pub(crate) found: bool, /// Simple name of the assembly that owns the symbol. - assembly_name: String, + pub(crate) assembly_name: String, /// Standard .NET XML documentation signature for the symbol. - xml_doc_sig: String, + pub(crate) xml_doc_sig: String, } /// Request for references to a metadata symbol owned by the other sidecar. @@ -1281,6 +1345,10 @@ struct SidecarDocumentEditResult { file_path: String, /// Text edits to apply. edits: Vec, + /// Whether the edit creates the file: decoded to keep the positional wire + /// shape, since a rename never creates one. + #[serde(default)] + _create: bool, } /// A workspace-wide set of edits from the sidecar rename operation. @@ -1408,6 +1476,7 @@ mod rename_merge_tests { SidecarDocumentEditResult { file_path: path.to_string(), edits, + _create: false, } } diff --git a/src/sharplsp/tests/e2e_modules/definition_cross_language.rs b/src/sharplsp/tests/e2e_modules/definition_cross_language.rs index f58e1902..63e0fd5f 100644 --- a/src/sharplsp/tests/e2e_modules/definition_cross_language.rs +++ b/src/sharplsp/tests/e2e_modules/definition_cross_language.rs @@ -1,11 +1,11 @@ //! Full-stack cross-language go-to-definition: C# ↔ F#. //! //! A C# project and an F# project reference each other across the language -//! boundary. Each sidecar sees the *other* language only as a compiled -//! assembly, so navigating from a use site into the other-language definition -//! resolves through metadata-as-source (the referenced type is decompiled to a -//! navigable location) — the same behavior mature IDEs give for a compiled -//! cross-language reference. Implements [DEFINITION-CROSSLANG]. +//! boundary. Navigating from a use site into the other-language definition +//! lands in its source: F# compiles the C# project in memory, and C# names the +//! F# symbol it reads from the DLL by its identity, which the F# sidecar +//! places in the `.fs` file. Implements [DEFINITION-CROSSLANG] and +//! [DEFINITION-CROSSLANG-IDENTITY]. use super::*; @@ -51,8 +51,8 @@ fn poll_cross_language_definition( // ── C# → F# ────────────────────────────────────────────────────────── -// FULL-STACK: go-to-definition from C# onto an F#-defined type resolves into -// the F# assembly (decompiled metadata-as-source), not the C# consumer file. +// FULL-STACK: go-to-definition from C# onto an F#-defined type lands on its +// declaration in the F# source, not in decompiled source or the C# consumer. #[test] fn test_cross_language_definition_csharp_to_fsharp() { require_dotnet(); @@ -81,6 +81,54 @@ fn test_cross_language_definition_csharp_to_fsharp() { "C#→F# definition must land on the F#-defined `Shape` type, got: {uri}" ); + // Once the F# sidecar has loaded, the answer is the F# declaration: the + // decompiled answer before it is never cached. [DEFINITION-CROSSLANG-IDENTITY] + let source = poll_cross_language_definition( + &mut client, + &file_uri, + 8, + 22, + ".cs", + Duration::from_mins(3), + ); + let uri = source["uri"].as_str().unwrap(); + assert!( + uri.ends_with("/Shapes.fs"), + "C#→F# lands in the F# source, got: {uri}" + ); + // `type Shape(name: string, sides: int) =` (line 3) + // 0123456789 + assert_eq!( + source["range"]["start"], + json!({ "line": 3, "character": 5 }) + ); + assert_eq!( + source["range"]["end"], + json!({ "line": 3, "character": 10 }) + ); + + // With the F# sidecar loaded, a C# local has no cross-language identity, so + // its references are C#'s alone: the declaration and its one use. + // [REFERENCES-CROSS-LANGUAGE] + let local = references(&mut client, &file_uri, 8, 12, true); + let starts: Vec<(Value, Value)> = local["result"] + .as_array() + .map(|found| { + found + .iter() + .map(|at| (at["uri"].clone(), at["range"]["start"].clone())) + .collect() + }) + .unwrap_or_default(); + assert_eq!( + starts, + vec![ + (json!(file_uri), json!({ "line": 8, "character": 12 })), + (json!(file_uri), json!({ "line": 9, "character": 15 })), + ], + "the local's references stay in C#: {local}" + ); + client.shutdown_and_exit(); client.wait_with_timeout(); } diff --git a/src/sharplsp/tests/e2e_modules/fixtures_cross_language.rs b/src/sharplsp/tests/e2e_modules/fixtures_cross_language.rs index b3ad6a7c..5911ccec 100644 --- a/src/sharplsp/tests/e2e_modules/fixtures_cross_language.rs +++ b/src/sharplsp/tests/e2e_modules/fixtures_cross_language.rs @@ -18,7 +18,7 @@ use super::*; use std::path::Path; /// Run a `dotnet` subcommand in `cwd`, asserting success. -fn dotnet(args: &[&str], cwd: &Path) { +pub fn dotnet(args: &[&str], cwd: &Path) { let status = std::process::Command::new("dotnet") .args(args) .current_dir(cwd) diff --git a/src/sharplsp/tests/e2e_modules/fixtures_razor.rs b/src/sharplsp/tests/e2e_modules/fixtures_razor.rs index 3cdd4d0a..636cc169 100644 --- a/src/sharplsp/tests/e2e_modules/fixtures_razor.rs +++ b/src/sharplsp/tests/e2e_modules/fixtures_razor.rs @@ -44,6 +44,24 @@ pub fn create_razor_workspace() -> RazorWorkspace { create_razor_workspace_with_pin(true) } +/// [`create_razor_workspace`] with the F# `Domain` library built: C# reads an F# +/// project only as its built DLL, so a C# or Razor use of an F# symbol resolves +/// only once that DLL exists. [DEFINITION-CROSSLANG] [RAZOR-FSHARP] +pub fn create_razor_workspace_with_fsharp_built() -> RazorWorkspace { + let ws = create_razor_workspace(); + super::fixtures_cross_language::dotnet( + &[ + "build", + "Domain/Domain.fsproj", + "--verbosity", + "quiet", + "--nologo", + ], + ws.root(), + ); + ws +} + /// Restore under the newest installed SDK to catch compiler/sidecar version skew. /// [RAZOR-ARCHITECTURE-GENERATOR] pub fn create_razor_workspace_with_latest_sdk() -> RazorWorkspace { diff --git a/src/sharplsp/tests/e2e_modules/mod.rs b/src/sharplsp/tests/e2e_modules/mod.rs index 40451186..83dad1d7 100644 --- a/src/sharplsp/tests/e2e_modules/mod.rs +++ b/src/sharplsp/tests/e2e_modules/mod.rs @@ -68,6 +68,11 @@ pub mod nuget_unused_full_stack; pub mod profiler_shared; pub mod pull_diagnostics; pub mod razor; +pub mod razor_collocation; +pub mod razor_fsharp; +pub mod razor_hierarchy; +pub mod razor_mvc; +pub mod razor_selection; pub mod references; pub mod selection; pub mod semantic_coverage; diff --git a/src/sharplsp/tests/e2e_modules/razor.rs b/src/sharplsp/tests/e2e_modules/razor.rs index 9d7df02f..93187e35 100644 --- a/src/sharplsp/tests/e2e_modules/razor.rs +++ b/src/sharplsp/tests/e2e_modules/razor.rs @@ -81,8 +81,8 @@ fn assert_latest_sdk_razor_hover(client: &mut LspClient, ws: &RazorWorkspace) { 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] +/// documentSymbol, foldingRange, linkedEditingRange and selectionRange come from +/// the sidecar's Razor compiler. [RAZOR-FEATURES-STRUCTURE] [RAZOR-FEATURES-SELECTION] fn assert_syntax_requests_reach_the_sidecar(client: &mut LspClient, board: &str) { let symbols = document_request(client, "textDocument/documentSymbol", board); assert_rpc_ok(&symbols, "documentSymbol(Board.razor)"); @@ -160,7 +160,19 @@ fn assert_syntax_requests_reach_the_sidecar(client: &mut LspClient, board: &str) json!({ "textDocument": { "uri": board }, "positions": [{ "line": 6, "character": 3 }] }), ); assert_rpc_ok(&selection, "selectionRange(Board.razor)"); - assert_eq!(selection["result"], json!([])); + assert_eq!( + selection["result"], + json!([{ + "range": { "start": { "line": 6, "character": 1 }, "end": { "line": 6, "character": 6 } }, + "parent": { + "range": { "start": { "line": 6, "character": 0 }, "end": { "line": 10, "character": 8 } }, + "parent": { + "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 26, "character": 0 } } + } + } + }]), + "the tag name, then its element, then the document [RAZOR-FEATURES-SELECTION]" + ); } /// The HTML projection keeps markup at its own positions and blanks Razor; a @@ -372,8 +384,13 @@ fn assert_markup_completion_reaches_the_client(client: &mut LspClient, board: &s client.change_document(board, 8, text); } -/// The completion items at the end of `typed`, once Board holds it. -fn complete_after(client: &mut LspClient, board: &str, version: i32, typed: &str) -> Vec { +/// The completion items at the end of `typed`, once the Razor file holds it. +pub(super) fn complete_after( + client: &mut LspClient, + board: &str, + version: i32, + typed: &str, +) -> Vec { client.change_document(board, version, typed); let line = typed.lines().count() - 1; let character = typed @@ -389,7 +406,7 @@ fn complete_after(client: &mut LspClient, board: &str, version: i32, typed: &str "position": { "line": line, "character": character }, }), ); - assert_rpc_ok(&response, "completion(Board.razor, markup)"); + assert_rpc_ok(&response, "completion(Razor markup)"); let items = response["result"].as_array().cloned().unwrap_or_default(); if !items.is_empty() || std::time::Instant::now() > deadline { return items; diff --git a/src/sharplsp/tests/e2e_modules/razor_collocation.rs b/src/sharplsp/tests/e2e_modules/razor_collocation.rs new file mode 100644 index 00000000..a7ab340b --- /dev/null +++ b/src/sharplsp/tests/e2e_modules/razor_collocation.rs @@ -0,0 +1,175 @@ +//! Full-stack collocated files over the real fixture's `Card.razor`: its +//! `Card.razor.css` classes complete as LSP values inside a `class` value and +//! define to every selector that declares them, and an `"import"` call defines +//! to the start of the `Card.razor.js` module it names, from Razor and from C#. +//! [RAZOR-COLLOCATION] [RAZOR-COLLOCATION-CSS] [RAZOR-COLLOCATION-JS] + +use super::razor::complete_after; +use super::*; + +/// The component with a collocated stylesheet and module. +const CARD: &[&str] = &["BlazorApp", "Components", "Shared", "Card.razor"]; + +/// The component's scoped stylesheet. +const STYLESHEET: &[&str] = &["BlazorApp", "Components", "Shared", "Card.razor.css"]; + +/// Plain C# that imports the component's module. +const INTEROP: &[&str] = &["BlazorApp", "Components", "Shared", "CardInterop.cs"]; + +/// LSP `CompletionItemKind.Value`. +const VALUE: u64 = 12; + +#[test] +fn test_full_stack_razor_collocated_styles_and_scripts() { + require_dotnet(); + let ws = create_razor_workspace(); + let mut client = LspClient::start_verbose(); + let init = client.initialize_with_root(json!(path_to_file_uri(ws.root()))); + assert_rpc_ok(&init, "initialize the Razor fixture"); + + let card = ws.uri(CARD); + let text = ws.text(CARD); + client.open_document(&card, &text); + + assert_classes_define_to_their_selectors(&mut client, &ws, &card); + assert_modules_define_to_their_start(&mut client, &ws, &card); + assert_classes_complete_as_values(&mut client, &card, &text); + + client.shutdown_and_exit(); + client.wait_with_timeout(); +} + +/// `title` in `
` lands on the five selectors of `.title`, +/// each ranged on the name after its `.`. +fn assert_classes_define_to_their_selectors( + client: &mut LspClient, + ws: &RazorWorkspace, + card: &str, +) { + let first = poll_definition_until_ready(client, card, 4, 19, Duration::from_mins(3)); + assert!( + first["uri"] + .as_str() + .is_some_and(|uri| uri.ends_with("/Card.razor.css")), + "a class defines in the component's stylesheet: {first}" + ); + let all = definition(client, card, 4, 19); + assert_nav_ok(&all); + let locations = all["result"].as_array().cloned().unwrap_or_default(); + assert_eq!( + locations.len(), + 5, + "every selector of `.title`: {locations:?}" + ); + let stylesheet = ws.text(STYLESHEET); + let lines: Vec<&str> = stylesheet.lines().collect(); + for location in &locations { + assert!( + location["uri"] + .as_str() + .is_some_and(|uri| uri.ends_with("/Card.razor.css")), + "{location}" + ); + assert_eq!( + ranged_text(&lines, location), + "title", + "on the name: {location}" + ); + } +} + +/// The module literal in `@code` and in plain C# both define to the module's start. +fn assert_modules_define_to_their_start(client: &mut LspClient, ws: &RazorWorkspace, card: &str) { + let razor = definition(client, card, 22, 20); + assert_nav_ok(&razor); + assert_module_start(&razor["result"], "from the component's @code block"); + + let interop = ws.uri(INTEROP); + let source = ws.text(INTEROP); + client.open_document(&interop, &source); + let (line, character) = source + .lines() + .enumerate() + .find_map(|(line, text)| text.find("\"./Components").map(|at| (line, at + 4))) + .expect("CardInterop.cs imports the module"); + let csharp = definition( + client, + &interop, + u32::try_from(line).unwrap_or_default(), + u32::try_from(character).unwrap_or_default(), + ); + assert_nav_ok(&csharp); + assert_module_start(&csharp["result"], "from plain C#"); +} + +/// One location, at the very start of `Card.razor.js`. +fn assert_module_start(result: &Value, label: &str) { + let locations = result.as_array().cloned().unwrap_or_default(); + assert_eq!(locations.len(), 1, "{label}: one module: {result}"); + assert!( + locations[0]["uri"] + .as_str() + .is_some_and(|uri| uri.ends_with("/Card.razor.js")), + "{label}: the collocated module: {result}" + ); + assert_eq!( + locations[0]["range"], + json!({ "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 0 } }), + "{label}: at its start" + ); +} + +/// After `
= items + .iter() + .filter(|item| item["label"] == "title") + .collect(); + assert_eq!(titles.len(), 1, "each class once: {items:?}"); + let title = titles[0]; + assert_eq!( + title["kind"], VALUE, + "a stylesheet class is a value: {title}" + ); + assert_eq!( + title["detail"], "Card.razor.css", + "naming its stylesheet: {title}" + ); + let line = typed.lines().count() - 1; + let end = typed + .lines() + .last() + .map_or(0, |last| last.encode_utf16().count()); + assert_eq!( + title["textEdit"]["range"], + json!({ + "start": { "line": line, "character": end - 2 }, + "end": { "line": line, "character": end } + }), + "replacing the class name at the caret: {title}" + ); + assert!( + items + .iter() + .any(|item| item["label"] == "card--grid" && item["kind"] == VALUE), + "a class inside nested at-rules completes too: {items:?}" + ); + client.change_document(card, 3, text); +} + +/// The text a single-line location covers in `lines`. +fn ranged_text<'a>(lines: &[&'a str], location: &Value) -> &'a str { + let at = |key: &str, field: &str| { + location["range"][key][field] + .as_u64() + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or_default() + }; + lines + .get(at("start", "line")) + .and_then(|line| line.get(at("start", "character")..at("end", "character"))) + .unwrap_or_default() +} diff --git a/src/sharplsp/tests/e2e_modules/razor_fsharp.rs b/src/sharplsp/tests/e2e_modules/razor_fsharp.rs new file mode 100644 index 00000000..bf5feb79 --- /dev/null +++ b/src/sharplsp/tests/e2e_modules/razor_fsharp.rs @@ -0,0 +1,341 @@ +//! Full-stack F# in a Razor solution over the real fixture: `Leaders.razor` +//! uses the F# `Domain` library's record, union and module functions. The C# +//! sidecar reads Domain as its built DLL; the host finds each F# symbol in the +//! F# sidecar by its identity, so definition lands in `Scores.fs`, and +//! references and rename span F#, C# and Razor, from either side. +//! [RAZOR-FSHARP] [DEFINITION-CROSSLANG-IDENTITY] [REFERENCES-CROSS-LANGUAGE] + +use super::*; + +/// The page that uses the F# library. +const LEADERS: &[&str] = &["BlazorApp", "Components", "Pages", "Leaders.razor"]; + +/// The F# library's only file. +const SCORES: &[&str] = &["Domain", "Scores.fs"]; + +/// The zero-based position of `token` on the first line of `text` holding +/// `anchor`. The fixture is ASCII, so bytes are UTF-16 code units. +fn locate(text: &str, anchor: &str, token: &str) -> (u32, u32) { + let (line, content) = text + .lines() + .enumerate() + .find(|(_, content)| content.contains(anchor)) + .unwrap_or_else(|| panic!("missing anchor {anchor}")); + let character = content + .find(token) + .unwrap_or_else(|| panic!("missing token {token}")); + ( + u32::try_from(line).expect("line fits"), + u32::try_from(character).expect("character fits"), + ) +} + +/// The name position of every `.name` member use in `text`. +fn member_uses(text: &str, name: &str) -> Vec<(u32, u32)> { + let dotted = format!(".{name}"); + text.lines() + .enumerate() + .flat_map(|(line, content)| { + content + .match_indices(dotted.as_str()) + .map(move |(at, _)| (line, at + 1)) + .collect::>() + }) + .map(|(line, at)| { + ( + u32::try_from(line).expect("line fits"), + u32::try_from(at).expect("character fits"), + ) + }) + .collect() +} + +/// A location's file name and start. +fn placed(location: &Value) -> (String, u64, u64) { + let uri = location["uri"].as_str().unwrap_or_default(); + let name = uri.rsplit('/').next().unwrap_or_default().to_string(); + let start = &location["range"]["start"]; + ( + name, + start["line"].as_u64().unwrap_or(u64::MAX), + start["character"].as_u64().unwrap_or(u64::MAX), + ) +} + +/// A position in a named file, as [`placed`] reports it. +fn at(file: &str, (line, character): (u32, u32)) -> (String, u64, u64) { + (file.to_string(), u64::from(line), u64::from(character)) +} + +/// Poll definition at `position` until it lands in `Scores.fs`: until the F# +/// sidecar has loaded Domain, the C# sidecar's decompiled answer stands. +fn poll_fsharp_definition( + client: &mut LspClient, + uri: &str, + (line, character): (u32, u32), +) -> Value { + let deadline = std::time::Instant::now() + Duration::from_mins(3); + loop { + let response = definition(client, uri, line, character); + assert_nav_ok(&response); + let location = first_location(&response["result"]); + if location["uri"] + .as_str() + .is_some_and(|found| found.ends_with("Scores.fs")) + { + return location; + } + assert!( + std::time::Instant::now() < deadline, + "definition at {line}:{character} never reached Scores.fs: {response}" + ); + std::thread::sleep(Duration::from_secs(2)); + } +} + +#[test] +fn test_full_stack_fsharp_in_razor_session() { + require_dotnet(); + let ws = create_razor_workspace_with_fsharp_built(); + let mut client = LspClient::start_verbose(); + let init = client.initialize_with_root(json!(path_to_file_uri(ws.root()))); + assert_rpc_ok(&init, "initialize Razor fixture"); + let leaders = ws.uri(LEADERS); + let page = ws.text(LEADERS); + let scores = ws.text(SCORES); + client.open_document(&leaders, &page); + + // ── Hover: the F# record, with its F# documentation, from Domain's DLL ── + let (line, character) = locate(&page, "private readonly Player[]", "Player"); + let hover = poll_hover_until_ready( + &mut client, + &leaders, + line, + character, + Duration::from_mins(3), + ); + let markdown = hover["contents"]["value"].as_str().unwrap_or_default(); + assert!( + markdown.contains("A player on the leader board."), + "Player hover: {markdown}" + ); + assert_eq!( + hover["range"]["start"], + json!({ "line": line, "character": character }) + ); + + assert_definition_lands_in_fsharp_source(&mut client, &leaders, &page, &scores); + assert_references_span_fsharp_and_razor(&mut client, &leaders, &page, &scores); + assert_rename_from_razor_renames_fsharp(&mut client, &leaders, &page, &scores); + assert_rename_from_fsharp_renames_razor(&mut client, &ws, &page, &scores); + + client.shutdown_and_exit(); + client.wait_with_timeout(); +} + +/// Definition, type definition and declaration from Razor onto F# symbols land +/// on their F# declarations, never decompiled source. [DEFINITION-CROSSLANG-IDENTITY] +fn assert_definition_lands_in_fsharp_source( + client: &mut LspClient, + leaders: &str, + page: &str, + scores: &str, +) { + let player = poll_fsharp_definition( + client, + leaders, + locate(page, "private readonly Player[]", "Player"), + ); + assert_eq!( + placed(&player), + at("Scores.fs", locate(scores, "type Player", "Player")) + ); + assert_eq!( + player["range"]["end"]["character"].as_u64(), + Some(u64::from(locate(scores, "type Player", "Player").1) + 6), + "the range is the type's name" + ); + let score = poll_fsharp_definition(client, leaders, locate(page, "@player.Score", "Score")); + assert_eq!( + placed(&score), + at("Scores.fs", locate(scores, "Score: int", "Score")) + ); + let total = poll_fsharp_definition(client, leaders, locate(page, "@Scoring.total", "total")); + assert_eq!( + placed(&total), + at("Scores.fs", locate(scores, "let total", "total")) + ); + let constructed = + poll_fsharp_definition(client, leaders, locate(page, "[new Player(", "Player")); + assert_eq!( + placed(&constructed), + placed(&player), + "a record's constructor is its type" + ); + + let (line, character) = locate(page, "var player in players", "player"); + let type_definition = type_definition(client, leaders, line, character); + assert_nav_ok(&type_definition); + assert_eq!( + placed(&first_location(&type_definition["result"])), + placed(&player) + ); +} + +/// References from Razor on an F# record field hold its F# declaration and +/// every F# and Razor use; without the declaration, only the uses. +/// [REFERENCES-CROSS-LANGUAGE] [RAZOR-FSHARP-REFERENCES] +fn assert_references_span_fsharp_and_razor( + client: &mut LspClient, + leaders: &str, + page: &str, + scores: &str, +) { + let (line, character) = locate(page, "@player.Score", "Score"); + let mut expected: Vec<(String, u64, u64)> = member_uses(scores, "Score") + .into_iter() + .map(|position| at("Scores.fs", position)) + .chain([at("Leaders.razor", (line, character))]) + .collect(); + expected.sort(); + assert_eq!(expected.len(), 5, "four F# uses and one Razor use"); + + let uses = references(client, leaders, line, character, false); + assert_rpc_ok(&uses, "references(Score) without declaration"); + let mut found: Vec<_> = uses["result"] + .as_array() + .expect("locations") + .iter() + .map(placed) + .collect(); + found.sort(); + assert_eq!(found, expected); + + let all = references(client, leaders, line, character, true); + assert_rpc_ok(&all, "references(Score) with declaration"); + let mut with_declaration: Vec<_> = all["result"] + .as_array() + .expect("locations") + .iter() + .map(placed) + .collect(); + with_declaration.sort(); + expected.push(at("Scores.fs", locate(scores, "Score: int", "Score"))); + expected.sort(); + assert_eq!(with_declaration, expected); + assert!( + all["result"] + .as_array() + .expect("locations") + .iter() + .all(|location| { + location["uri"] + .as_str() + .is_some_and(|uri| !uri.ends_with(".g.cs")) + }), + "no generated file: {all}" + ); +} + +/// Renaming an F# record field from Razor renames its F# declaration and uses +/// and its Razor use, in one edit. [RAZOR-FSHARP-RENAME] +fn assert_rename_from_razor_renames_fsharp( + client: &mut LspClient, + leaders: &str, + page: &str, + scores: &str, +) { + let (line, character) = locate(page, "@player.Score", "Score"); + let prepared = client.request( + "textDocument/prepareRename", + json!({ "textDocument": { "uri": leaders }, "position": { "line": line, "character": character } }), + ); + assert_rpc_ok(&prepared, "prepareRename(Score) in Razor"); + assert_eq!(prepared["result"]["placeholder"], "Score"); + assert_eq!( + prepared["result"]["range"]["start"], + json!({ "line": line, "character": character }) + ); + + let renamed = rename_request(client, leaders, line, character, "Points"); + assert_rpc_ok(&renamed, "rename(Score) from Razor"); + let changes = renamed["result"]["documentChanges"] + .as_array() + .expect("documentChanges") + .clone(); + let razor = edits_for(&changes, "Leaders.razor"); + assert_eq!(razor.len(), 1, "the Razor use: {renamed}"); + assert_eq!( + razor[0]["range"]["start"], + json!({ "line": line, "character": character }) + ); + let fsharp = edits_for(&changes, "Scores.fs"); + assert_eq!( + fsharp.len(), + 5, + "the F# declaration and four uses: {renamed}" + ); + let applied = apply_text_edits(scores, &fsharp); + assert!( + applied.contains("Points: int }"), + "the field is renamed: {applied}" + ); + assert!( + !applied.contains(".Score"), + "every F# use is renamed: {applied}" + ); + assert!( + edits_for(&changes, ".g.cs").is_empty(), + "no generated file: {renamed}" + ); + assert!( + fsharp + .iter() + .chain(razor.iter()) + .all(|edit| edit["newText"] == "Points"), + "every edit renames to Points" + ); +} + +/// Renaming the F# field from its F# declaration renames the Razor use too. +/// [RAZOR-FSHARP-RENAME] [RENAME-CROSSLANGUAGE] +fn assert_rename_from_fsharp_renames_razor( + client: &mut LspClient, + ws: &RazorWorkspace, + page: &str, + scores: &str, +) { + let uri = ws.uri(SCORES); + client.open_document(&uri, scores); + let (line, character) = locate(scores, "Score: int", "Score"); + let deadline = std::time::Instant::now() + Duration::from_mins(3); + let changes = loop { + let renamed = rename_request(client, &uri, line, character, "Points"); + assert_rpc_ok(&renamed, "rename(Score) from F#"); + let changes = renamed["result"]["documentChanges"] + .as_array() + .cloned() + .unwrap_or_default(); + if !edits_for(&changes, "Leaders.razor").is_empty() { + break changes; + } + assert!( + std::time::Instant::now() < deadline, + "F# rename never reached Razor: {renamed}" + ); + std::thread::sleep(Duration::from_secs(2)); + }; + assert_eq!(edits_for(&changes, "Scores.fs").len(), 5); + let razor = edits_for(&changes, "Leaders.razor"); + assert_eq!(razor.len(), 1); + let (use_line, use_character) = locate(page, "@player.Score", "Score"); + assert_eq!( + razor[0]["range"]["start"], + json!({ "line": use_line, "character": use_character }) + ); + assert_eq!( + edits_for(&changes, ".g.cs"), + Vec::::new(), + "no edit touches a generated file" + ); +} diff --git a/src/sharplsp/tests/e2e_modules/razor_hierarchy.rs b/src/sharplsp/tests/e2e_modules/razor_hierarchy.rs new file mode 100644 index 00000000..68b572a3 --- /dev/null +++ b/src/sharplsp/tests/e2e_modules/razor_hierarchy.rs @@ -0,0 +1,191 @@ +//! Full-stack Razor inlay hints, code lenses, and call and type hierarchy over the real +//! fixture. Each runs on the Razor file's C# projection and answers in the file itself: +//! positions, items and call sites are Razor ranges, a component class is its own file. +//! [RAZOR-FEATURES-INLAY-HINTS] [RAZOR-FEATURES-HIERARCHY] [RAZOR-ARCHITECTURE-PROJECTION] + +use super::*; + +/// `Ticker.razor`: a component that inherits another, calls a plain C# method, and renders +/// markup that clicks its own handler. +const TICKER: &[&str] = &["BlazorApp", "Components", "Shared", "Ticker.razor"]; + +/// `Panel.razor`, the component `Ticker` inherits. +const PANEL: &[&str] = &["Widgets", "Panel.razor"]; + +#[test] +fn test_full_stack_razor_hierarchy_and_hints() { + require_dotnet(); + let ws = create_razor_workspace(); + let mut client = LspClient::start_verbose(); + let init = client.initialize_with_root(json!(path_to_file_uri(ws.root()))); + assert_rpc_ok(&init, "initialize the Razor fixture"); + + let ticker = ws.uri(TICKER); + client.open_document(&ticker, &ws.text(TICKER)); + + // ── Readiness: a hover inside the file's C# arrives through the projection ── + let hover = poll_hover_until_ready(&mut client, &ticker, 2, 24, Duration::from_mins(3)); + let markdown = hover["contents"]["value"].as_str().unwrap_or_default(); + assert!( + markdown.contains("Describe"), + "hover on `Describe` must show the method: {markdown}" + ); + + assert_call_hierarchy(&mut client, &ticker); + assert_type_hierarchy(&mut client, &ws, &ticker); + assert_inlay_hints(&mut client, &ticker); + + client.shutdown_and_exit(); + client.wait_with_timeout(); +} + +/// `prepareCallHierarchy` and `incomingCalls` from a Razor position: the item is the Razor +/// declaration, and the markup that clicks it is a caller in the same file. +fn assert_call_hierarchy(client: &mut LspClient, ticker: &str) { + let hit = prepared(client, "textDocument/prepareCallHierarchy", ticker, 8, 17); + assert_eq!(hit["name"], "Hit", "{hit}"); + assert_eq!(hit["kind"], 6, "a method: {hit}"); + assert_eq!(hit["uri"], json!(ticker), "{hit}"); + assert_eq!( + hit["range"], + json!({ + "start": { "line": 8, "character": 17 }, + "end": { "line": 8, "character": 20 }, + }), + "{hit}" + ); + + let incoming = client.request("callHierarchy/incomingCalls", json!({ "item": hit })); + assert_rpc_ok(&incoming, "incomingCalls(Hit)"); + let calls = incoming["result"].as_array().expect("incoming calls"); + assert_eq!(calls.len(), 1, "{incoming}"); + let call = &calls[0]; + assert_eq!( + call["from"]["name"], "Ticker.razor", + "the component's own markup calls it: {call}" + ); + assert_eq!(call["from"]["kind"], 5, "the component is a class: {call}"); + assert_eq!(call["from"]["uri"], json!(ticker), "{call}"); + assert_eq!( + call["from"]["selectionRange"]["start"], + json!({ "line": 0, "character": 0 }), + "a component is its file: {call}" + ); + assert_eq!( + call["fromRanges"], + json!([{ + "start": { "line": 3, "character": 18 }, + "end": { "line": 3, "character": 21 }, + }]), + "the click the markup writes: {call}" + ); +} + +/// `prepareTypeHierarchy` from `@inherits`, and its subtypes: the base component is its own +/// file, and the subtype is this one's. +fn assert_type_hierarchy(client: &mut LspClient, ws: &RazorWorkspace, ticker: &str) { + let panel = prepared(client, "textDocument/prepareTypeHierarchy", ticker, 0, 10); + assert_eq!(panel["name"], "Panel", "{panel}"); + assert_eq!(panel["kind"], 5, "a component is a class: {panel}"); + assert_eq!(panel["uri"], json!(ws.uri(PANEL)), "{panel}"); + assert_eq!( + panel["selectionRange"], + json!({ + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 0 }, + }), + "a component class is the start of its file: {panel}" + ); + + let subtypes = client.request("typeHierarchy/subtypes", json!({ "item": panel })); + assert_rpc_ok(&subtypes, "subtypes(Panel)"); + let items = subtypes["result"].as_array().expect("subtypes"); + assert_eq!(items.len(), 1, "{subtypes}"); + assert_eq!(items[0]["name"], "Ticker", "{subtypes}"); + assert_eq!(items[0]["uri"], json!(ticker), "{subtypes}"); +} + +/// `textDocument/inlayHint` over the file: the hints of its C# fragments sit at their Razor +/// positions, the generated builder's own arguments are dropped, and a range outside every +/// fragment answers none. +fn assert_inlay_hints(client: &mut LspClient, ticker: &str) { + let hints = inlay_hints(client, ticker, (0, 0), (18, 0)); + assert!( + hints.iter().any( + |hint| hint["position"] == json!({ "line": 10, "character": 11 }) + && hint["label"] == ": int" + && hint["kind"] == 1 + ), + "the type of a `var` in `@code`: {hints:?}" + ); + assert!( + hints.iter().any( + |hint| hint["position"] == json!({ "line": 10, "character": 33 }) + && hint["label"] == "by:" + && hint["kind"] == 2 + ), + "the name of the argument `Bump` takes: {hints:?}" + ); + assert!( + hints.iter().all(|hint| hint["position"]["line"] + .as_u64() + .is_some_and(|line| line <= 18)), + "every hint is in the file, not in its projection: {hints:?}" + ); + + assert_eq!( + inlay_hints(client, ticker, (4, 0), (4, 0)), + Vec::::new(), + "a blank line outside every C# fragment has no hints" + ); +} + +/// The hints the server answers over `range`, asserted well-formed. +fn inlay_hints( + client: &mut LspClient, + uri: &str, + start: (u64, u64), + end: (u64, u64), +) -> Vec { + let response = client.request( + "textDocument/inlayHint", + json!({ + "textDocument": { "uri": uri }, + "range": { + "start": { "line": start.0, "character": start.1 }, + "end": { "line": end.0, "character": end.1 }, + }, + }), + ); + assert_rpc_ok(&response, "inlayHint"); + let hints = response["result"] + .as_array() + .expect("an array of hints") + .clone(); + for hint in &hints { + assert!(hint["position"].is_object(), "a position: {hint}"); + assert!( + hint["label"] + .as_str() + .is_some_and(|label| !label.is_empty()), + "a label: {hint}" + ); + } + hints +} + +/// The single item `method` prepares at a position. +fn prepared(client: &mut LspClient, method: &str, uri: &str, line: u64, character: u64) -> Value { + let response = client.request( + method, + json!({ + "textDocument": { "uri": uri }, + "position": { "line": line, "character": character }, + }), + ); + assert_rpc_ok(&response, method); + let none = Vec::new(); + let items = response["result"].as_array().unwrap_or(&none); + assert_eq!(items.len(), 1, "{method} prepares one item: {response}"); + items[0].clone() +} diff --git a/src/sharplsp/tests/e2e_modules/razor_mvc.rs b/src/sharplsp/tests/e2e_modules/razor_mvc.rs new file mode 100644 index 00000000..35a0a14d --- /dev/null +++ b/src/sharplsp/tests/e2e_modules/razor_mvc.rs @@ -0,0 +1,143 @@ +//! Full-stack Razor Pages and MVC views over the real fixture's `PagesApp`: +//! `@model`, `@inject` and `@functions` answer through the projection, tag +//! helper elements and attributes answer from their descriptors and complete +//! in scope, and a view resolves what its `_ViewImports.cshtml` imports. +//! [RAZOR-MVC] [RAZOR-MVC-DIRECTIVES] [RAZOR-MVC-TAGHELPERS] [RAZOR-MVC-IMPORTS] + +use super::razor::complete_after; +use super::*; + +/// The Razor Page with a model, an injection, tag helpers and `@functions`. +const EDIT: &[&str] = &["PagesApp", "Pages", "Edit.cshtml"]; + +/// The MVC view whose `@model` only `Views/_ViewImports.cshtml` resolves. +const INDEX: &[&str] = &["PagesApp", "Views", "Home", "Index.cshtml"]; + +#[test] +fn test_full_stack_razor_pages_and_mvc_views() { + require_dotnet(); + let ws = create_razor_workspace(); + let mut client = LspClient::start_verbose(); + let init = client.initialize_with_root(json!(path_to_file_uri(ws.root()))); + assert_rpc_ok(&init, "initialize the Razor fixture"); + + let edit = ws.uri(EDIT); + let text = ws.text(EDIT); + client.open_document(&edit, &text); + + assert_directives_answer_through_the_projection(&mut client, &edit); + assert_tag_helpers_answer_from_their_descriptors(&mut client, &edit, &text); + assert_view_imports_reach_the_view(&mut client, &ws); + + client.shutdown_and_exit(); + client.wait_with_timeout(); +} + +/// `@model`'s type, `@inject`'s property and a `@functions` method are C#. +fn assert_directives_answer_through_the_projection(client: &mut LspClient, edit: &str) { + let model = poll_hover_until_ready(client, edit, 1, 8, Duration::from_mins(3)); + let markdown = model["contents"]["value"].as_str().unwrap_or_default(); + assert!( + markdown.contains("Edits one item."), + "`@model EditModel` hovers the page model's summary: {markdown}" + ); + assert_eq!( + model["range"], + json!({ "start": { "line": 1, "character": 7 }, "end": { "line": 1, "character": 16 } }), + "ranged on the type name" + ); + + let log = hover(client, edit, 2, 28); + assert_hover_ok(&log); + let markdown = log["result"]["contents"]["value"] + .as_str() + .unwrap_or_default(); + assert!( + markdown.contains("ILogger<"), + "`@inject` declares a property of its type: {markdown}" + ); + + let shout = definition(client, edit, 10, 5); + assert_nav_ok(&shout); + let target = first_location(&shout["result"]); + assert_eq!( + target["uri"], + json!(edit), + "the method is the page's: {shout}" + ); + assert_location_line(&target, 13, "`@Shout` goes to its `@functions` declaration"); +} + +/// An element a tag helper binds, and the attribute it sets, hover from their +/// descriptors; tag helper elements and attributes complete in scope. +fn assert_tag_helpers_answer_from_their_descriptors( + client: &mut LspClient, + edit: &str, + text: &str, +) { + let input = hover(client, edit, 6, 6); + assert_hover_ok(&input); + let markdown = input["result"]["contents"]["value"] + .as_str() + .unwrap_or_default(); + assert!( + markdown.contains("InputTagHelper"), + "`` hovers its tag helper: {markdown}" + ); + + let title = definition(client, edit, 6, 20); + assert_nav_ok(&title); + let entries = location_entries(&title["result"]); + assert_has_location_in( + &entries, + "Edit.cshtml.cs", + "`asp-for=\"Title\"` names the model's property", + ); + + let elements = complete_after(client, edit, 2, &format!("{text}\n<")); + let labels: Vec<&str> = elements + .iter() + .filter_map(|item| item["label"].as_str()) + .collect(); + for expected in ["partial", "environment"] { + assert!( + labels.contains(&expected), + "`{expected}` completes after `<`: {labels:?}" + ); + } + assert_eq!( + labels.iter().filter(|label| **label == "a").count(), + 1, + "an element HTML also names completes once: {labels:?}" + ); + + let attributes = complete_after(client, edit, 3, &format!("{text}\n::new(), "the view compiles: {errors:?}"); +} diff --git a/src/sharplsp/tests/e2e_modules/razor_selection.rs b/src/sharplsp/tests/e2e_modules/razor_selection.rs new file mode 100644 index 00000000..a5eff9b8 --- /dev/null +++ b/src/sharplsp/tests/e2e_modules/razor_selection.rs @@ -0,0 +1,179 @@ +//! Full-stack Razor selection ranges over the real fixture: the host routes +//! `textDocument/selectionRange` to the C# sidecar, which answers each position's +//! chain from its innermost span out to the whole document, nested as LSP parents. +//! [RAZOR-FEATURES-SELECTION] + +use super::*; + +/// `Board.razor`, the fixture's richest component. +const BOARD: &[&str] = &["BlazorApp", "Components", "Pages", "Board.razor"]; + +/// The whole file's span: the fixture's files end with a line break. +fn document() -> Value { + json!({ + "start": {"line": 0, "character": 0}, + "end": {"line": 26, "character": 0} + }) +} + +#[test] +fn test_full_stack_razor_selection_ranges() { + require_dotnet(); + let ws = create_razor_workspace(); + let mut client = LspClient::start_verbose(); + let _init = client.initialize_with_root(json!(path_to_file_uri(ws.root()))); + + let board = ws.uri(BOARD); + client.open_document(&board, &ws.text(BOARD)); + + // ── A caret on the Tally tag name: tag → element → Panel → document ── + let chains = poll_selection_until_ready(&mut client, &board, 7, 5); + let tally = chains + .as_array() + .and_then(|all| all.first()) + .expect("one chain per position"); + let levels = selection_levels(tally); + assert!( + levels.len() >= 3, + "a tag name chains at least three levels: {levels:?}" + ); + assert_eq!( + levels[0], + json!({"start": {"line": 7, "character": 5}, "end": {"line": 7, "character": 10}}), + "the innermost range is the tag name: {levels:?}" + ); + assert_eq!( + levels[1], + json!({"start": {"line": 7, "character": 4}, "end": {"line": 9, "character": 12}}), + "then the element: {levels:?}" + ); + assert_eq!( + levels[2], + json!({"start": {"line": 6, "character": 0}, "end": {"line": 10, "character": 8}}), + "then the enclosing Panel: {levels:?}" + ); + assert_eq!( + levels.last(), + Some(&document()), + "the document closes the chain" + ); + assert!( + selection_levels(tally) + .last() + .and_then(|level| level.get("parent")) + .is_none(), + "the document has no parent: {tally}" + ); + assert_parents_hold(tally); + + // ── A caret inside a C# identifier in markup chains through the attribute ── + let answer = client.request( + "textDocument/selectionRange", + json!({ + "textDocument": { "uri": board }, + "positions": [ + { "line": 8, "character": 25 }, + { "line": 19, "character": 20 } + ] + }), + ); + assert_rpc_ok(&answer, "selectionRange(Board.razor, two carets)"); + let chains = answer["result"].as_array().expect("one chain per position"); + assert_eq!(chains.len(), 2, "one chain per position: {answer}"); + + let attribute = selection_levels(&chains[0]); + assert_eq!( + attribute[0], + json!({"start": {"line": 8, "character": 22}, "end": {"line": 8, "character": 29}}), + "the innermost range is the C# identifier in the value: {attribute:?}" + ); + assert!( + attribute.len() >= 5, + "the identifier chains through the attribute and its elements: {attribute:?}" + ); + assert_eq!( + attribute.last(), + Some(&document()), + "the document closes the chain" + ); + assert_parents_hold(&chains[0]); + + let field = selection_levels(&chains[1]); + assert_eq!( + field[0], + json!({"start": {"line": 19, "character": 19}, "end": {"line": 19, "character": 26}}), + "the innermost range is the field's identifier: {field:?}" + ); + assert!(field.len() >= 4, "the identifier chains outward: {field:?}"); + assert_eq!( + field.last(), + Some(&document()), + "the document closes the chain" + ); + assert_parents_hold(&chains[1]); + + client.shutdown_and_exit(); + client.wait_with_timeout(); +} + +/// Ask for `textDocument/selectionRange` until the sidecar has loaded the +/// workspace and answers a chain with a parent, then return the chains. +fn poll_selection_until_ready( + client: &mut LspClient, + uri: &str, + line: u32, + character: u32, +) -> Value { + let deadline = std::time::Instant::now() + Duration::from_mins(3); + loop { + let response = client.request( + "textDocument/selectionRange", + json!({ + "textDocument": { "uri": uri }, + "positions": [{ "line": line, "character": character }] + }), + ); + assert_rpc_ok(&response, "selectionRange(Board.razor)"); + let chains = response["result"].as_array().cloned().unwrap_or_default(); + if chains + .first() + .is_some_and(|chain| chain["parent"].is_object()) + { + return Value::Array(chains); + } + assert!( + std::time::Instant::now() < deadline, + "selection ranges never nested within the deadline: {response}" + ); + std::thread::sleep(Duration::from_secs(2)); + } +} + +/// The chain as its ranges, innermost first. +fn selection_levels(chain: &Value) -> Vec { + let mut levels = Vec::new(); + let mut at = Some(chain); + while let Some(item) = at { + levels.push(item["range"].clone()); + at = item.get("parent").filter(|parent| parent.is_object()); + } + levels +} + +/// A `line`/`character` object as its numbers, so positions compare. +fn point(value: &Value) -> (u64, u64) { + ( + value["line"].as_u64().unwrap_or_default(), + value["character"].as_u64().unwrap_or_default(), + ) +} + +/// Every level holds the one before it, so an editor can grow the selection. +fn assert_parents_hold(chain: &Value) { + let levels = selection_levels(chain); + for (inner, outer) in levels.iter().zip(levels.iter().skip(1)) { + let (start, end) = (point(&inner["start"]), point(&inner["end"])); + let holds = point(&outer["start"]) <= start && point(&outer["end"]) >= end; + assert!(holds, "each parent must hold its child: {inner} in {outer}"); + } +} diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/App.razor b/src/sharplsp/tests/fixtures/blazor-wasm/App.razor new file mode 100644 index 00000000..e6585e93 --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/App.razor @@ -0,0 +1,9 @@ +@* The root component the host mounts into `#app`. [DEBUG-FEATURES-LAUNCH-BLAZOR-WASM] *@ + + + + + +

Sorry, there is nothing at this address.

+
+
diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/BlazorWasmApp.csproj b/src/sharplsp/tests/fixtures/blazor-wasm/BlazorWasmApp.csproj new file mode 100644 index 00000000..a90cdc56 --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/BlazorWasmApp.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + BlazorWasmApp + enable + enable + + + + + + + + diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/Directory.Build.props b/src/sharplsp/tests/fixtures/blazor-wasm/Directory.Build.props new file mode 100644 index 00000000..d320e322 --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/Directory.Build.props @@ -0,0 +1,6 @@ + + + diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/Directory.Build.targets b/src/sharplsp/tests/fixtures/blazor-wasm/Directory.Build.targets new file mode 100644 index 00000000..df1b2315 --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/Directory.Build.targets @@ -0,0 +1,4 @@ + + + diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/Pages/Counter.razor b/src/sharplsp/tests/fixtures/blazor-wasm/Pages/Counter.razor new file mode 100644 index 00000000..f342a792 --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/Pages/Counter.razor @@ -0,0 +1,14 @@ +@page "/counter" + +@* The one page: enough that a breakpoint in a Razor file has something to hit. *@ +

Counter

+ +

Current count: @currentCount

+ + + +@code { + private int currentCount; + + private void Increment() => currentCount++; +} diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/Program.cs b/src/sharplsp/tests/fixtures/blazor-wasm/Program.cs new file mode 100644 index 00000000..d6cf22a5 --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/Program.cs @@ -0,0 +1,11 @@ +using BlazorWasmApp; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; + +// The template's entry point: build the WebAssembly host in the browser, then let it +// take over the page. [DEBUG-FEATURES-LAUNCH-BLAZOR-WASM] +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +await builder.Build().RunAsync(); diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/Properties/launchSettings.json b/src/sharplsp/tests/fixtures/blazor-wasm/Properties/launchSettings.json new file mode 100644 index 00000000..ba7323af --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/Properties/launchSettings.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "launchBrowser": false, + "applicationUrl": "http://127.0.0.1:0", + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}" + } + } +} diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/_Imports.razor b/src/sharplsp/tests/fixtures/blazor-wasm/_Imports.razor new file mode 100644 index 00000000..5f14b71b --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/_Imports.razor @@ -0,0 +1,5 @@ +@using System.Net.Http +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using BlazorWasmApp +@using BlazorWasmApp.Pages diff --git a/src/sharplsp/tests/fixtures/blazor-wasm/wwwroot/index.html b/src/sharplsp/tests/fixtures/blazor-wasm/wwwroot/index.html new file mode 100644 index 00000000..069ab110 --- /dev/null +++ b/src/sharplsp/tests/fixtures/blazor-wasm/wwwroot/index.html @@ -0,0 +1,23 @@ + + + + + + + + BlazorWasmApp + + + +
+

Loading…

+
+ +
+ An unhandled error has occurred. +
+ + + + + diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Pages/Leaders.razor b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Pages/Leaders.razor index 46d61fd9..cb4e91c8 100644 --- a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Pages/Leaders.razor +++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Pages/Leaders.razor @@ -4,11 +4,13 @@
    @foreach (var player in players) { -
  • @player.Name: @player.Score
  • +
  • @player.Name: @player.Score (@Describe(Scoring.standing(players, player)))
  • }

Total: @Scoring.total(players)

@code { private readonly Player[] players = [new Player("Ada", 3), new Player("Grace", 5)]; + + private static string Describe(Standing standing) => standing.IsLeading ? "leading" : "chasing"; } diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor new file mode 100644 index 00000000..fb14970c --- /dev/null +++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor @@ -0,0 +1,36 @@ +@using Microsoft.JSInterop +@inject IJSRuntime JS +@implements IAsyncDisposable + +
+ @Title +
+ +@code { + private IJSObjectReference? module; + + /// The card's title. + [Parameter] + public string Title { get; set; } = ""; + + /// Loads the card's collocated JavaScript module. + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + module = await JS.InvokeAsync( + "import", + "./Components/Shared/Card.razor.js" + ); + } + } + + /// Releases the collocated module. + public async ValueTask DisposeAsync() + { + if (module is not null) + { + await module.DisposeAsync(); + } + } +} diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.cs b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.cs new file mode 100644 index 00000000..a4d06b5f --- /dev/null +++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.cs @@ -0,0 +1,28 @@ +using Microsoft.JSInterop; + +namespace BlazorApp.Components.Shared; + +/// +/// The code-behind half of Card.razor. Implements [RAZOR-COLLOCATION-JS] for the +/// sidecar's tests: a collocated module import is answered from a code-behind file too. +/// +public partial class Card +{ + /// Loads the component's collocated module from a code-behind method. + public static ValueTask LoadAsync(IJSRuntime js) + { + return js.InvokeAsync("import", "./Components/Shared/Card.razor.js"); + } + + /// Loads whatever module the caller names, which no definition can resolve. + public static ValueTask LoadAnyAsync(IJSRuntime js, string path) + { + return js.InvokeAsync("import", path); + } + + /// Runs a JavaScript function; the first argument is not an import. + public static ValueTask InvokeAsync(IJSRuntime js, string identifier) + { + return js.InvokeAsync(identifier, "./Components/Shared/Card.razor.js"); + } +} diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.css b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.css new file mode 100644 index 00000000..f2ed2e8a --- /dev/null +++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.css @@ -0,0 +1,52 @@ +/* + * Card.razor.css — the collocated stylesheet of Card.razor. + * A comment holds braces and class names: } .commented { .alsoCommented { } + */ + +.card { + color: #1a1a1a; + margin: .5rem; + padding: 0.25rem 0.5rem; + background: url(card.bg.png); +} + +.card .title { + font-weight: 600; +} + +.card::deep .card__title { + text-transform: uppercase; + grid-area: zone; +} + +.title:is(.accent, .muted) { + letter-spacing: 0.05em; +} + +a[href$=".pdf"] .title { + text-decoration: underline; +} + +.panel .title { + font-size: 1.25rem; +} + +.p-1\.5 { + padding: 0.375rem; +} + +.labeled { + content: "a.b"; +} + +@media (min-width: 641px) { + .title { + font-size: 2rem; + } + + @supports (display: grid) { + .card--grid .meta { + display: grid; + } + } +} diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.js b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.js new file mode 100644 index 00000000..efc042ff --- /dev/null +++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Card.razor.js @@ -0,0 +1,10 @@ +/** + * The collocated JavaScript module of Card.razor, imported by + * `JS.InvokeAsync("import", "./Components/Shared/Card.razor.js")`. + */ +export function render(elementId) { + const card = document.getElementById(elementId); + if (card) { + card.dataset.enhanced = "true"; + } +} diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/CardInterop.cs b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/CardInterop.cs new file mode 100644 index 00000000..12e58eba --- /dev/null +++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/CardInterop.cs @@ -0,0 +1,17 @@ +using Microsoft.JSInterop; + +namespace BlazorApp.Components.Shared; + +/// +/// Loads a component's collocated JavaScript module from plain C#. Implements +/// [RAZOR-COLLOCATION-JS]: the definition of the module a "import" call names is +/// answered from a .cs file as well as from Razor. +/// +public static class CardInterop +{ + /// Loads Card's collocated module. + public static ValueTask LoadCardAsync(IJSRuntime js) + { + return js.InvokeAsync("import", "./Components/Shared/Card.razor.js"); + } +} diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Counting.cs b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Counting.cs new file mode 100644 index 00000000..85f4a5d1 --- /dev/null +++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Counting.cs @@ -0,0 +1,14 @@ +namespace BlazorApp.Components.Shared; + +/// +/// A plain C# type the fixture's Razor components call into, so that a call from Razor code and +/// markup reaches a method declared outside the projection. [RAZOR-TESTING] +/// +internal static class Counting +{ + /// Doubles a count. + internal static int Bump(int by) + { + return by * 2; + } +} diff --git a/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Ticker.razor b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Ticker.razor new file mode 100644 index 00000000..5518e7f3 --- /dev/null +++ b/src/sharplsp/tests/fixtures/razor/BlazorApp/Components/Shared/Ticker.razor @@ -0,0 +1,19 @@ +@inherits Panel + +@Describe(count) + + +@code { + private int count; + + private void Hit() + { + var next = Counting.Bump(count); + count = next; + } + + private static string Describe(int value) + { + return $"Count: {value}"; + } +} diff --git a/src/sharplsp/tests/fixtures/razor/Domain/Scores.fs b/src/sharplsp/tests/fixtures/razor/Domain/Scores.fs index 11578364..05c2a042 100644 --- a/src/sharplsp/tests/fixtures/razor/Domain/Scores.fs +++ b/src/sharplsp/tests/fixtures/razor/Domain/Scores.fs @@ -1,9 +1,25 @@ namespace Domain /// A player on the leader board. -type Player = { Name: string; Score: int } +type Player = + { /// The player's display name. + Name: string + /// The points the player has scored. + Score: int } + +/// Where a player stands against the field. +type Standing = + /// No player has scored more. + | Leading + /// Behind the leader by some points. + | Chasing of gap: int /// Scoring rules for the leader board. module Scoring = /// The sum of every player's score. let total (players: seq) = players |> Seq.sumBy (fun player -> player.Score) + + /// Where `player` stands against `field`. + let standing (field: seq) (player: Player) = + let best = field |> Seq.map (fun other -> other.Score) |> Seq.max + if player.Score = best then Leading else Chasing(best - player.Score) diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/CallHierarchyKindTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/CallHierarchyKindTests.cs new file mode 100644 index 00000000..374abba2 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/CallHierarchyKindTests.cs @@ -0,0 +1,49 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using SharpLsp.Sidecar.CSharp.Workspace; + +#pragma warning disable CA1515 // Public xUnit discovery type + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// +/// The kinds call hierarchy names its items with, in the PascalCase the host maps to LSP +/// SymbolKind, for the symbols a hierarchy request can land on that no workspace test +/// reaches. Implements [RAZOR-TESTING] for [RAZOR-FEATURES-HIERARCHY]. +/// +public sealed class CallHierarchyKindTests +{ + private const string Source = """ + namespace Kinds + { + public class Box + { + public object Items() => from item in new[] { 1 } select item; + } + } + """; + + [Fact] + public void Namespaces_type_parameters_and_kinds_lsp_lacks_are_named_for_the_host() + { + var tree = CSharpSyntaxTree.ParseText(Source); + var compilation = CSharpCompilation.Create("Kinds", [tree]); + var model = compilation.GetSemanticModel(tree); + var nodes = tree.GetRoot().DescendantNodes().ToList(); + + var space = model.GetDeclaredSymbol(nodes.OfType().Single()); + var item = model.GetDeclaredSymbol(nodes.OfType().Single()); + var range = model.GetDeclaredSymbol(nodes.OfType().Single()); + Assert.Equal( + ("Namespace", "TypeParameter", "Function", "Function"), + ( + CallHierarchyResolver.MapSymbolKind(space!), + CallHierarchyResolver.MapSymbolKind(item!), + CallHierarchyResolver.MapSymbolKind(range!), + CallHierarchyResolver.MapSymbolKind(compilation.Assembly) + ) + ); + Assert.Equal(SymbolKind.RangeVariable, range!.Kind); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HeadlessOverrideGenerationTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HeadlessOverrideGenerationTests.cs index e5ef6399..5043e01a 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HeadlessOverrideGenerationTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HeadlessOverrideGenerationTests.cs @@ -1,8 +1,8 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Text; using SharpLsp.Sidecar.Common; using SharpLsp.Sidecar.CSharp.Workspace; +using static SharpLsp.Sidecar.CSharp.Tests.TextEditing; #pragma warning disable CA1307 // StringComparison overloads add no value to xUnit assertions #pragma warning disable CA1515 // Public xUnit discovery type @@ -411,18 +411,4 @@ private static T Unwrap(Outcome.Result result) Assert.False(result.IsError, result.Match(_ => "ok", error => error)); return +result; } - - private static string ApplyEdits(string source, IEnumerable edits) - { - var text = SourceText.From(source); - var changes = edits.Select(edit => new TextChange(EditSpan(text, edit), edit.NewText)); - return text.WithChanges(changes).ToString(); - } - - private static TextSpan EditSpan(SourceText text, TextEditResult edit) - { - var start = text.Lines.GetPosition(new LinePosition(edit.StartLine, edit.StartCharacter)); - var end = text.Lines.GetPosition(new LinePosition(edit.EndLine, edit.EndCharacter)); - return TextSpan.FromBounds(start, end); - } } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HotReloadSessionRegistryTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HotReloadSessionRegistryTests.cs index 6d30a4f8..756ad8b0 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HotReloadSessionRegistryTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HotReloadSessionRegistryTests.cs @@ -372,6 +372,33 @@ public async Task Two_phase_updates_commit_discard_and_span_documents() .ConfigureAwait(true); Assert.Equal("committed", committed.Status); + // A re-save of the committed text changes nothing at runtime: Roslyn has nothing to + // apply, so it is applied with no deltas and leaves nothing to commit. + var quiet = await SendAsync( + registry, + new HotReloadRequest + { + Action = "update", + SessionId = started.SessionId, + FilePath = sourcePath, + NewText = UpdatedSource, + } + ) + .ConfigureAwait(true); + Assert.Equal( + ("applied", 0, 0), + (quiet.Status, quiet.Updates.Count, quiet.Diagnostics.Count) + ); + _ = await Assert + .ThrowsAsync(async () => + _ = await SendAsync( + registry, + new HotReloadRequest { Action = "commit", SessionId = started.SessionId } + ) + .ConfigureAwait(true) + ) + .ConfigureAwait(true); + // A cross-file-consistent batch: the call in Calculator.cs needs the // method added in Helper.cs — only a single candidate spanning both // documents can emit it. diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HtmlMarkupContextTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HtmlMarkupContextTests.cs index c266559e..b8859d3b 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HtmlMarkupContextTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/HtmlMarkupContextTests.cs @@ -255,6 +255,72 @@ string expected Assert.Equal(expected, Of(html).ToString()); } + [Fact] + public void The_attribute_at_a_caret_is_the_one_the_tokenizer_reads_there() + { + // In a value: the attribute's name, and its value without the quotes. + Assert.Equal(("class", "class", "a b"), AttributeOf("")); + Assert.Equal(("class", "class", "a"), AttributeOf("")); + Assert.Equal(("data-x", "data-x", "1"), AttributeOf("

")); + Assert.Equal(("x", "x", "ab"), AttributeOf("

")); + Assert.Equal(("class", "class", ""), AttributeOf("

")); + + // On a name: the whole name, and the value the tokenizer reads after the caret. + Assert.Equal(("class", "class", "x"), AttributeOf("

")); + Assert.Equal(("b", "b", "x"), AttributeOf("")); + Assert.Equal(("hidden", "hidden", null), AttributeOf("")); + Assert.Equal(("x", "x", null), AttributeOf("

"); + var action = Assert.Single( + await ActionsAsync(manager, Counter, (section.Line, 0), (close.Line, 0)), + a => a.Title == "Extract element to new component 'Component'" + ); + var edit = await ResolveAsync(manager, action); + Assert.Equal( + "
\n

Nested

\n
\n", + Assert.Single(edit.DocumentChanges[0].Edits).NewText + ); + Assert.Contains( + "
\n \n
\n", + ApplyEdits(text, edit.DocumentChanges[1].Edits), + StringComparison.Ordinal + ); + + // A local declared outside the selection is out of the new component's reach. + var span = Find(text, "@item"); + Assert.DoesNotContain( + await ActionsAsync( + manager, + Counter, + span, + (span.Line, span.Character + "@item".Length) + ), + a => a.Title.StartsWith("Extract element", StringComparison.Ordinal) + ); + + // One declared inside it, a lambda's parameter, moves with it. + const string emphasis = "@(new[] { 1 }.Select(n => n + 1).First())"; + var em = Find(text, emphasis); + _ = Assert.Single( + await ActionsAsync(manager, Counter, em, (em.Line, em.Character + emphasis.Length)), + a => a.Title == "Extract element to new component 'Component'" + ); + } + + private static (int Line, int Character) Find(string text, string token) + { + var lines = text.Split('\n'); + var line = Array.FindIndex(lines, l => l.Contains(token, StringComparison.Ordinal)); + Assert.True(line >= 0, $"missing {token}"); + return (line, lines[line].IndexOf(token, StringComparison.Ordinal)); + } + + private static async Task> ActionsAsync( + WorkspaceManager manager, + string file, + (int Line, int Character) start, + (int Line, int Character)? end = null + ) + { + var (endLine, endCharacter) = end ?? start; + return RazorFixture.Ok( + await manager.GetCodeActionsAsync( + file, + start.Line, + start.Character, + endLine, + endCharacter + ) + ); + } + + private static async Task ResolveAsync( + WorkspaceManager manager, + CodeActionItem action + ) + { + Assert.True(action.Id < 0, "a Razor action has its own id"); + return RazorFixture.Ok(await manager.ResolveCodeActionAsync(action.Id)); + } + + private static async Task> ErrorsAsync(WorkspaceManager manager, string file) + { + return + [ + .. RazorFixture + .Ok(await manager.GetDiagnosticsAsync(file)) + .Where(diagnostic => diagnostic.Severity == "Error") + .Select(diagnostic => diagnostic.Code), + ]; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCollocationTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCollocationTests.cs new file mode 100644 index 00000000..51faf35a --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCollocationTests.cs @@ -0,0 +1,301 @@ +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Razor; + +#pragma warning disable CA1515 // Public xUnit discovery type +#pragma warning disable CA2007 // xUnit executes without a synchronization context +#pragma warning disable RS1035 // Real-file reads are intentional in this end-to-end suite +#pragma warning disable IDE0042 // Positions read better as (Line, Character) pairs + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// +/// Collocated files of a Razor file, against the real fixture solution: the class selectors of +/// Card.razor.css complete inside a class value and define to every selector that +/// declares them, and a InvokeAsync<IJSObjectReference>("import", "…") call defines +/// to the start of the Card.razor.js it names, from Razor, a code-behind and plain C#. +/// Implements [RAZOR-TESTING] for [RAZOR-COLLOCATION]. +/// +public sealed class RazorCollocationTests : IClassFixture +{ + private readonly RazorFixture _fixture; + + public RazorCollocationTests(RazorFixture fixture) + { + _fixture = fixture; + } + + private string Card => _fixture.Path("BlazorApp", "Components", "Shared", "Card.razor"); + + private string Stylesheet => + _fixture.Path("BlazorApp", "Components", "Shared", "Card.razor.css"); + + private string Module => _fixture.Path("BlazorApp", "Components", "Shared", "Card.razor.js"); + + private string CodeBehind => + _fixture.Path("BlazorApp", "Components", "Shared", "Card.razor.cs"); + + private string Interop => _fixture.Path("BlazorApp", "Components", "Shared", "CardInterop.cs"); + + private string Board => _fixture.Path("BlazorApp", "Components", "Pages", "Board.razor"); + + /// Text of the stylesheet that is not a class selector. + private static readonly string[] NotClasses = + [ + "commented", + "alsoCommented", + "zone", + "rem", + "pdf", + "png", + "a", + "b", + ]; + + [Fact] + public async Task Class_completion_offers_each_stylesheet_class_once() + { + using var manager = await _fixture.OpenManagerAsync(); + var (items, at) = await CompleteAfterAsync(manager, Card, "\n
item.Label == "title"); + Assert.Equal("Value", title.Kind); + Assert.Equal("Card.razor.css", title.Detail); + Assert.NotNull(title.TextEdit); + Assert.Equal(at.Line, title.TextEdit.StartLine); + Assert.Equal(at.Character - 2, title.TextEdit.StartCharacter); + Assert.Equal(at.Character, title.TextEdit.EndCharacter); + Assert.Equal("title", title.TextEdit.NewText); + + // Declared inside at-rules, `:is()` and `::deep`, they all complete. + Assert.Contains(items, item => item.Label == "card__title"); + Assert.Contains(items, item => item.Label == "accent"); + Assert.Contains(items, item => item.Label == "muted"); + Assert.Contains(items, item => item.Label == "card--grid"); + Assert.Contains(items, item => item.Label == "meta"); + + // Never text that is not a class selector: a comment, a declaration value, a number, + // a string or a url. + Assert.DoesNotContain( + items, + item => item.Kind == "Value" && NotClasses.Contains(item.Label, StringComparer.Ordinal) + ); + + // A class after a bare attribute, single-quoted, still completes the class at the caret. + var (after, end) = await CompleteAfterAsync(manager, Card, "\n
item.Kind == "Value"); + + // In content, neither completes nor defines. + var (content, _) = await CompleteAfterAsync(manager, Card, "\n
title
"); + Assert.DoesNotContain(content, item => item.Kind == "Value"); + var name = RazorFixture.Locate(Card, "class=\"card title\"", "class"); + Assert.Empty( + RazorFixture + .Ok(await manager.GetDefinitionAsync(Card, name.Line, name.Character + 2)) + .Locations + ); + } + + [Fact] + public async Task A_component_without_a_stylesheet_offers_and_defines_nothing() + { + using var manager = await _fixture.OpenManagerAsync(); + + // Board.razor has no Board.razor.css, so its class values offer nothing. + var (items, _) = await CompleteAfterAsync(manager, Board, "\n item.Kind == "Value"); + + // Nor does anything define. + var text = await File.ReadAllTextAsync(Board) + "\n"; + _ = RazorFixture.Ok(await manager.UpdateDocumentTextAsync(Board, text)); + var caret = Position(text, text.Length - 3); + Assert.Empty( + RazorFixture + .Ok(await manager.GetDefinitionAsync(Board, caret.Line, caret.Character)) + .Locations + ); + + // A Razor file no project lists has no stylesheet to define into, and no error. + var missing = _fixture.Path("BlazorApp", "Components", "Pages", "Missing.razor"); + Assert.Empty(RazorFixture.Ok(await manager.GetDefinitionAsync(missing, 0, 1)).Locations); + } + + [Fact] + public void Collocated_files_are_named_after_the_razor_file() + { + Assert.Equal("/app/Card.razor.css", NativePaths.StylesheetOf("/app/Card.razor")); + Assert.Equal("/app/Index.cshtml.css", NativePaths.StylesheetOf("/app/Index.cshtml")); + + // A module is spelled relative to the app's base path, which is the project directory, + // and only a collocated module is one. + Assert.Equal( + "/app/Components/Shared/Card.razor.js", + RazorCollocation.ModuleFile("/app", "./Components/Shared/Card.razor.js") + ); + Assert.Equal( + "/app/Pages/Index.cshtml.js", + RazorCollocation.ModuleFile("/app", "/Pages/Index.cshtml.js") + ); + Assert.Null(RazorCollocation.ModuleFile("/app", "./js/site.js")); + Assert.Null(RazorCollocation.ModuleFile("/app", "./Components/Shared/Card.razor.css")); + Assert.Null(RazorCollocation.ModuleFile("/app", "import")); + } + + /// Completion at the end of , appended to 's text. + private static async Task<( + List Items, + (int Line, int Character) At + )> CompleteAfterAsync( + SharpLsp.Sidecar.CSharp.Workspace.WorkspaceManager manager, + string file, + string suffix + ) + { + var text = await File.ReadAllTextAsync(file) + suffix; + _ = RazorFixture.Ok(await manager.UpdateDocumentTextAsync(file, text)); + var at = Position(text, text.Length); + return ( + RazorFixture.Ok(await manager.GetCompletionsAsync(file, at.Line, at.Character)), + at + ); + } + + /// The position of in . + private static (int Line, int Character) Position(string text, int offset) + { + var before = text.AsSpan(0, offset); + return (before.Count('\n'), offset - (text.LastIndexOf('\n', Math.Max(0, offset - 1)) + 1)); + } + + private static string TextAt(string[] lines, LocationResult location) + { + return lines[location.Line][location.Character..location.EndCharacter]; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCompletionEditsTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCompletionEditsTests.cs index 8205b4ce..48bae6b1 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCompletionEditsTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorCompletionEditsTests.cs @@ -26,7 +26,8 @@ public async Task Additional_csharp_edits_map_back_to_the_original_fragment() var edit = Assert.Single( RazorCompletionEdits.Map( projection, - [new TextChange(declaration.Identifier.Span, "changed")] + [new TextChange(declaration.Identifier.Span, "changed")], + null ) ); var (expectedLine, expectedCharacter) = RazorFixture.Locate( @@ -57,7 +58,7 @@ await manager.UpdateDocumentTextAsync(Board, text).ConfigureAwait(true) const string added = "using System.Text; using System.Linq; using System.Linq; using static System.Math; using Alias = System.String; using NewAlias = System.Uri; using static System.Console;"; var edits = RazorCompletionEdits - .Map(projection, [new TextChange(new TextSpan(0, 0), added)]) + .Map(projection, [new TextChange(new TextSpan(0, 0), added)], null) .ToArray(); Assert.Equal( [ @@ -94,13 +95,18 @@ await manager.UpdateDocumentTextAsync(Board, "

body

").ConfigureAwait(true) invalid, text => Assert.Empty( - RazorCompletionEdits.Map(projection, [new TextChange(new TextSpan(0, 0), text)]) + RazorCompletionEdits.Map( + projection, + [new TextChange(new TextSpan(0, 0), text)], + null + ) ) ); var edit = Assert.Single( RazorCompletionEdits.Map( projection, - [new TextChange(new TextSpan(0, 0), "using System.Text;")] + [new TextChange(new TextSpan(0, 0), "using System.Text;")], + null ) ); Assert.Equal("@using System.Text\n", edit.NewText); @@ -109,4 +115,38 @@ await manager.UpdateDocumentTextAsync(Board, "

body

").ConfigureAwait(true) (edit.StartLine, edit.StartCharacter, edit.EndLine, edit.EndCharacter) ); } + + [Fact] + public async Task A_new_import_follows_the_page_directive_a_Razor_Page_opens_with() + { + using var manager = await fixture.OpenManagerAsync().ConfigureAwait(true); + var edit = fixture.Path("PagesApp", "Pages", "Edit.cshtml"); + var projection = await manager.FindRazorProjectionAsync(edit, default).ConfigureAwait(true); + var markup = await manager.FindRazorMarkupAsync(edit, default).ConfigureAwait(true); + Assert.NotNull(projection); + Assert.NotNull(markup); + + // A Razor Page must open with `@page`, so an import goes on the line after it, + // not at the start the file would otherwise get. [RAZOR-CODE-ACTIONS-USING] + var added = Assert.Single( + RazorCompletionEdits.Map( + projection, + [new TextChange(new TextSpan(0, 0), "using System.Text;")], + markup + ) + ); + Assert.Equal("@using System.Text\n", added.NewText); + Assert.Equal( + (1, 0, 1, 0), + (added.StartLine, added.StartCharacter, added.EndLine, added.EndCharacter) + ); + var unplaced = Assert.Single( + RazorCompletionEdits.Map( + projection, + [new TextChange(new TextSpan(0, 0), "using System.Text;")], + null + ) + ); + Assert.Equal(0, unplaced.StartLine); + } } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorFSharpTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorFSharpTests.cs new file mode 100644 index 00000000..6045c25a --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorFSharpTests.cs @@ -0,0 +1,310 @@ +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Workspace; + +#pragma warning disable CA1515 // Public xUnit discovery type +#pragma warning disable CA2007 // xUnit executes without a synchronization context +#pragma warning disable RS1035 // Real-file reads are intentional in this end-to-end suite +#pragma warning disable IDE0042 // Positions read better as (Line, Character) pairs + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// +/// F# in a Razor solution, against the real fixture: BlazorApp's Leaders.razor uses +/// the F# Domain library's record, union and module functions. The C# sidecar reads +/// them from Domain's built DLL, answers hover, completion and signature help from that +/// metadata, and names each F# symbol by its identity for the F# sidecar: on definition, +/// on rename and for the other language's references. Implements [RAZOR-TESTING] for +/// [RAZOR-FSHARP] and [DEFINITION-CROSSLANG-IDENTITY]. +/// +public sealed class RazorFSharpTests : IClassFixture +{ + private readonly RazorFixture _fixture; + + public RazorFSharpTests(RazorFixture fixture) + { + _fixture = fixture; + } + + private string Leaders => _fixture.Path("BlazorApp", "Components", "Pages", "Leaders.razor"); + + [Fact] + public async Task Hover_and_signature_help_read_FSharp_symbols_from_their_assembly() + { + using var manager = await _fixture.OpenManagerAsync(); + + // A record, a record field and a module function, with their F# documentation. + var player = await HoverAsync(manager, At("private readonly Player[]", "Player")); + Assert.Contains("Player", player, StringComparison.Ordinal); + Assert.Contains("A player on the leader board.", player, StringComparison.Ordinal); + var score = await HoverAsync(manager, At("@player.Score", "Score")); + Assert.Contains("int", score, StringComparison.Ordinal); + Assert.Contains("The points the player has scored.", score, StringComparison.Ordinal); + var total = await HoverAsync(manager, At("@Scoring.total", "total")); + Assert.Contains("The sum of every player", total, StringComparison.Ordinal); + Assert.Contains("public static int Domain.Scoring.total(", total, StringComparison.Ordinal); + Assert.Contains("IEnumerable<", total, StringComparison.Ordinal); + Assert.Contains("Player> players)", total, StringComparison.Ordinal); + + // A curried F# function is a method of every parameter to C#. + var (line, character) = At("Scoring.standing(players", "players"); + var help = RazorFixture.Ok(await manager.GetSignatureHelpAsync(Leaders, line, character)); + Assert.NotNull(help); + var signature = Assert.Single(help.Signatures); + Assert.Contains("standing", signature.Label, StringComparison.Ordinal); + Assert.Equal(2, signature.Parameters.Count); + Assert.Contains( + signature.Parameters, + parameter => parameter.Contains("field", StringComparison.Ordinal) + ); + Assert.Equal(0, help.ActiveParameter); + } + + [Fact] + public async Task Completion_offers_FSharp_members_in_markup_and_code() + { + using var manager = await _fixture.OpenManagerAsync(); + var original = await File.ReadAllTextAsync(Leaders); + + // After `@player.` in markup: the record's fields, documented. + var markup = await CompletedAtAsync(manager, original, "@player.Name", "Name"); + var name = Assert.Single(markup, item => item.Label == "Name"); + Assert.Contains(markup, item => item.Label == "Score"); + Assert.Equal("Property", name.Kind); + + // After `Scoring.` in `@code`: the module's functions; after a union value, its cases' tests. + var code = original.Replace( + "private static string Describe", + "private int Probe() => Scoring.\n private static string Describe", + StringComparison.Ordinal + ); + var functions = await CompletedAfterAsync(manager, code, "Scoring.\n"); + Assert.Contains(functions, item => item.Label == "total"); + Assert.Contains(functions, item => item.Label == "standing"); + var cases = await CompletedAfterAsync( + manager, + original.Replace("standing.IsLeading", "standing.", StringComparison.Ordinal), + "=> standing." + ); + Assert.Contains(cases, item => item.Label == "IsLeading"); + Assert.Contains(cases, item => item.Label == "IsChasing"); + } + + [Fact] + public async Task Definition_names_each_FSharp_symbol_for_the_FSharp_sidecar() + { + using var manager = await _fixture.OpenManagerAsync(); + + // A type, a record field and a function each land in Domain's decompiled source, + // named by their assembly and XML doc signature. [DEFINITION-CROSSLANG-IDENTITY] + Assert.Equal( + "T:Domain.Player", + await DefinedAsync(manager, At("private readonly Player[]", "Player")) + ); + Assert.Equal( + "P:Domain.Player.Score", + await DefinedAsync(manager, At("@player.Score", "Score")) + ); + Assert.Equal( + "M:Domain.Scoring.total(System.Collections.Generic.IEnumerable{Domain.Player})", + await DefinedAsync(manager, At("@Scoring.total", "total")) + ); + + // A record's constructor is named by its type: F# declares it there. + Assert.Equal("T:Domain.Player", await DefinedAsync(manager, At("[new Player(", "Player"))); + + // Type definition and declaration name the symbol the same way. + var (line, character) = At("var player in players", "player"); + var type = RazorFixture.Ok(await manager.GetTypeDefinitionAsync(Leaders, line, character)); + Assert.NotNull(type); + Assert.Equal("T:Domain.Player", type.Symbol?.XmlDocSig); + Assert.Equal("Domain", type.Symbol?.AssemblyName); + + // A framework symbol has no F# declaration, so its location names none. + var (stringLine, stringCharacter) = At("private static string Describe", "string"); + var framework = Assert.Single( + RazorFixture + .Ok(await manager.GetDefinitionAsync(Leaders, stringLine, stringCharacter)) + .Locations + ); + Assert.Null(framework.Symbol); + } + + [Fact] + public async Task Rename_from_Razor_renames_an_FSharp_symbol_in_CSharp_and_Razor() + { + using var manager = await _fixture.OpenManagerAsync(); + var (line, character) = At("@player.Score", "Score"); + + // An F# record field is renameable from Razor, on its name. [RAZOR-FSHARP-RENAME] + var prepared = RazorFixture.Ok(await manager.PrepareRenameAsync(Leaders, line, character)); + Assert.True(prepared.CanRename); + Assert.Equal((line, character), (prepared.StartLine, prepared.StartCharacter)); + Assert.Equal(character + "Score".Length, prepared.EndCharacter); + Assert.Equal("Score", prepared.Placeholder); + + // The C# sidecar edits its Razor and C# uses; F#'s come from the F# sidecar. + var edit = RazorFixture.Ok(await manager.RenameAsync(Leaders, line, character, "Points")); + var razor = Assert.Single( + edit.DocumentChanges, + d => NativePaths.AreEqual(Leaders, d.FilePath) + ); + var renamed = Assert.Single(razor.Edits); + Assert.Equal((line, character), (renamed.StartLine, renamed.StartCharacter)); + Assert.Equal("Points", renamed.NewText); + Assert.DoesNotContain( + edit.DocumentChanges, + d => NativePaths.HasExtension(d.FilePath, ".g.cs") + ); + Assert.DoesNotContain( + edit.DocumentChanges, + d => NativePaths.HasExtension(d.FilePath, ".fs") + ); + + // The identity the host asks F# with comes through the projection too. + var identity = RazorFixture.Ok( + await manager.GetRenameIdentityAsync(Leaders, line, character) + ); + Assert.True(identity.Found); + Assert.Equal( + ("Domain", "P:Domain.Player.Score"), + (identity.AssemblyName, identity.XmlDocSig) + ); + var (describeLine, describeCharacter) = At("@Describe(", "Describe"); + var own = RazorFixture.Ok( + await manager.GetRenameIdentityAsync(Leaders, describeLine, describeCharacter) + ); + Assert.True(own.Found); + Assert.Equal("BlazorApp", own.AssemblyName); + Assert.StartsWith("M:", own.XmlDocSig, StringComparison.Ordinal); + Assert.EndsWith(".Describe(Domain.Standing)", own.XmlDocSig, StringComparison.Ordinal); + } + + [Fact] + public async Task Foreign_references_and_rename_reach_Razor_uses_of_an_FSharp_symbol() + { + using var manager = await _fixture.OpenManagerAsync(); + + // The F# sidecar names `Score`; C# answers its Razor uses, mapped to the Razor file. + var score = RazorFixture.Ok( + await manager.GetForeignReferencesAsync( + new ForeignSymbolQuery("Domain", "P:Domain.Player.Score", true) + ) + ); + var use = Assert.Single(score.Locations); + Assert.True(NativePaths.AreEqual(Leaders, use.FilePath), use.FilePath); + Assert.Equal(At("@player.Score", "Score"), (use.Line, use.Character)); + + // `Player` is used three times: the field's type and both constructions. + var player = RazorFixture.Ok( + await manager.GetForeignReferencesAsync( + new ForeignSymbolQuery("Domain", "T:Domain.Player", false) + ) + ); + Assert.Equal(3, player.Locations.Count); + Assert.All( + player.Locations, + location => Assert.True(NativePaths.AreEqual(Leaders, location.FilePath)) + ); + Assert.Contains( + At("private readonly Player[]", "Player"), + player.Locations.Select(l => (l.Line, l.Character)) + ); + + // An F#-origin rename edits the Razor use, never the generated file. + var rename = RazorFixture.Ok( + await manager.RenameForeignAsync("Domain", "P:Domain.Player.Score", "Points") + ); + var edits = Assert.Single(rename.DocumentChanges); + Assert.True(NativePaths.AreEqual(Leaders, edits.FilePath), edits.FilePath); + Assert.Equal("Points", Assert.Single(edits.Edits).NewText); + + // A symbol no project reads has no uses. + var none = RazorFixture.Ok( + await manager.GetForeignReferencesAsync( + new ForeignSymbolQuery("Domain", "T:Domain.Missing", true) + ) + ); + Assert.Empty(none.Locations); + + // An identity naming no assembly or signature finds nothing, and neither does a + // workspace with no solution loaded. + var unnamed = RazorFixture.Ok( + await manager.GetForeignReferencesAsync( + new ForeignSymbolQuery("", "T:Domain.Player", true) + ) + ); + Assert.Empty(unnamed.Locations); + using var unloaded = new WorkspaceManager(); + Assert.Empty( + RazorFixture + .Ok( + await unloaded.GetForeignReferencesAsync( + new ForeignSymbolQuery("Domain", "T:Domain.Player", true) + ) + ) + .Locations + ); + + // A cancelled request is a failure, never a partial list. + var cancelled = await manager.GetForeignReferencesAsync( + new ForeignSymbolQuery("Domain", "T:Domain.Player", true), + new CancellationToken(true) + ); + Assert.True(cancelled.IsError, "a cancelled request fails"); + } + + private (int Line, int Character) At(string anchor, string token) + { + return RazorFixture.Locate(Leaders, anchor, token); + } + + private async Task HoverAsync(WorkspaceManager manager, (int Line, int Character) at) + { + var hover = RazorFixture.Ok(await manager.GetHoverAsync(Leaders, at.Line, at.Character)); + Assert.NotNull(hover); + return hover.Contents; + } + + /// The XML doc signature the single definition location at names, in Domain. + private async Task DefinedAsync(WorkspaceManager manager, (int Line, int Character) at) + { + var location = Assert.Single( + RazorFixture + .Ok(await manager.GetDefinitionAsync(Leaders, at.Line, at.Character)) + .Locations + ); + Assert.False(NativePaths.AreEqual(Leaders, location.FilePath), location.FilePath); + Assert.NotNull(location.Symbol); + Assert.True(location.Symbol.Found); + Assert.Equal("Domain", location.Symbol.AssemblyName); + return location.Symbol.XmlDocSig; + } + + /// The completion at on 's line of . + private async Task> CompletedAtAsync( + WorkspaceManager manager, + string text, + string anchor, + string token + ) + { + _ = RazorFixture.Ok(await manager.UpdateDocumentTextAsync(Leaders, text)); + var (line, character) = At(anchor, token); + return RazorFixture.Ok(await manager.GetCompletionsAsync(Leaders, line, character)); + } + + /// The completion right after in . + private async Task> CompletedAfterAsync( + WorkspaceManager manager, + string text, + string marker + ) + { + _ = RazorFixture.Ok(await manager.UpdateDocumentTextAsync(Leaders, text)); + var offset = text.IndexOf(marker, StringComparison.Ordinal) + marker.TrimEnd('\n').Length; + var before = text[..offset].Split('\n'); + return RazorFixture.Ok( + await manager.GetCompletionsAsync(Leaders, before.Length - 1, before[^1].Length) + ); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorHierarchyTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorHierarchyTests.cs new file mode 100644 index 00000000..2cf9c4fa --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorHierarchyTests.cs @@ -0,0 +1,191 @@ +using SharpLsp.Sidecar.Common; + +#pragma warning disable CA1515 // Public xUnit discovery type +#pragma warning disable CA2007 // xUnit executes without a synchronization context +#pragma warning disable RS1035 // Real-file reads are intentional in this end-to-end suite +#pragma warning disable IDE0042 // Positions read better as (Line, Character) pairs + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// +/// Call and type hierarchy from Razor positions, against the real fixture solution: each runs on +/// the file's C# projection and places its items and call sites in the Razor text, a component +/// class at the start of the file it is. Implements [RAZOR-TESTING] for [RAZOR-FEATURES-HIERARCHY] +/// and [RAZOR-ARCHITECTURE-PROJECTION]. +/// +public sealed class RazorHierarchyTests : IClassFixture +{ + private readonly RazorFixture _fixture; + + public RazorHierarchyTests(RazorFixture fixture) + { + _fixture = fixture; + } + + private string Ticker => _fixture.Path("BlazorApp", "Components", "Shared", "Ticker.razor"); + + private string Counting => _fixture.Path("BlazorApp", "Components", "Shared", "Counting.cs"); + + private string Edit => _fixture.Path("PagesApp", "Pages", "Edit.cshtml"); + + private string Board => _fixture.Path("BlazorApp", "Components", "Pages", "Board.razor"); + + private string Panel => _fixture.Path("Widgets", "Panel.razor"); + + private string Tally => _fixture.Path("BlazorApp", "Components", "Shared", "Tally.razor"); + + [Fact] + public async Task Call_hierarchy_answers_razor_items_and_razor_callers() + { + using var manager = await _fixture.OpenManagerAsync(); + var at = RazorFixture.Locate(Ticker, "private void Hit", "Hit"); + + // Prepare answers the method where the Razor file declares it. + var prepared = RazorFixture.Ok( + await manager.PrepareCallHierarchyAsync(Ticker, at.Line, at.Character) + ); + Assert.NotNull(prepared); + Assert.Equal(("Hit", "Method"), (prepared.Name, prepared.Kind)); + Assert.True(NativePaths.AreEqual(Ticker, prepared.FilePath), prepared.FilePath); + Assert.Equal(at, (prepared.Line, prepared.Character)); + + // Its markup click names a caller, which is the component the file renders. + var calls = Assert.Single( + RazorFixture.Ok(await manager.GetIncomingCallsAsync(Ticker, at.Line, at.Character)) + ); + Assert.Equal(("Ticker.razor", "Class"), (calls.Name, calls.Kind)); + Assert.True(NativePaths.AreEqual(Ticker, calls.FilePath), calls.FilePath); + Assert.Equal((0, 0), (calls.Line, calls.Character)); + var clicked = Assert.Single(calls.FromRanges); + Assert.Equal( + RazorFixture.Locate(Ticker, "@onclick=\"Hit\"", "Hit", 3), + (clicked.Line, clicked.EndCharacter) + ); + Assert.Equal(clicked.Line, clicked.EndLine); + + // The call the @code block makes reaches the C# method it names, outside every projection. + var outgoing = Assert.Single( + RazorFixture.Ok(await manager.GetOutgoingCallsAsync(Ticker, at.Line, at.Character)) + ); + Assert.Equal("Bump", outgoing.Name); + Assert.True(NativePaths.AreEqual(Counting, outgoing.FilePath), outgoing.FilePath); + Assert.Equal( + RazorFixture.Locate(Counting, "internal static int Bump", "Bump"), + (outgoing.Line, outgoing.Character) + ); + var called = Assert.Single(outgoing.FromRanges); + Assert.Equal( + RazorFixture.Locate(Ticker, "Counting.Bump(count)", "Counting.Bump(count)"), + (called.Line, called.Character) + ); + Assert.Equal(called.Character + "Counting.Bump(count)".Length, called.EndCharacter); + } + + [Fact] + public async Task Calls_from_razor_markup_list_the_razor_file_as_their_caller() + { + using var manager = await _fixture.OpenManagerAsync(); + + // A .cs method Razor code calls answers the Razor member that calls it, in its own file. + var bump = RazorFixture.Locate(Counting, "internal static int Bump", "Bump"); + var callers = Assert.Single( + RazorFixture.Ok( + await manager.GetIncomingCallsAsync(Counting, bump.Line, bump.Character) + ) + ); + var hit = RazorFixture.Locate(Ticker, "private void Hit", "Hit"); + Assert.Equal(("Hit", "Method"), (callers.Name, callers.Kind)); + Assert.True(NativePaths.AreEqual(Ticker, callers.FilePath), callers.FilePath); + Assert.Equal(hit, (callers.Line, callers.Character)); + var site = Assert.Single(callers.FromRanges); + Assert.Equal( + RazorFixture.Locate(Ticker, "Counting.Bump(count)", "Bump"), + (site.Line, site.Character) + ); + Assert.Equal(site.Character + "Bump".Length, site.EndCharacter); + + // A call written in markup answers the page the markup is, at the call's own range. + var shout = RazorFixture.Locate(Edit, "string Shout(string text)", "Shout"); + var prepared = RazorFixture.Ok( + await manager.PrepareCallHierarchyAsync(Edit, shout.Line, shout.Character) + ); + Assert.NotNull(prepared); + Assert.Equal("Shout", prepared.Name); + Assert.True(NativePaths.AreEqual(Edit, prepared.FilePath), prepared.FilePath); + Assert.Equal(shout, (prepared.Line, prepared.Character)); + + var markup = Assert.Single( + RazorFixture.Ok(await manager.GetIncomingCallsAsync(Edit, shout.Line, shout.Character)) + ); + Assert.Equal(("Edit.cshtml", "Class"), (markup.Name, markup.Kind)); + Assert.True(NativePaths.AreEqual(Edit, markup.FilePath), markup.FilePath); + Assert.Equal((0, 0), (markup.Line, markup.Character)); + var write = Assert.Single(markup.FromRanges); + Assert.Equal( + RazorFixture.Locate(Edit, "@Shout(Model.Title)", "Shout", "Shout".Length), + (write.Line, write.EndCharacter) + ); + + // No answer names a generated file. + Assert.False(NativePaths.HasExtension(markup.FilePath, ".g.cs"), markup.FilePath); + Assert.False(NativePaths.HasExtension(prepared.FilePath, ".g.cs"), prepared.FilePath); + } + + [Fact] + public async Task Type_hierarchy_lands_component_classes_in_their_razor_files() + { + using var manager = await _fixture.OpenManagerAsync(); + + // A component's @inherits names the base component, which is its own file. + var inherited = RazorFixture.Locate(Ticker, "@inherits Panel", "Panel"); + var panel = RazorFixture.Ok( + await manager.PrepareTypeHierarchyAsync(Ticker, inherited.Line, inherited.Character) + ); + Assert.NotNull(panel); + Assert.Equal(("Panel", "Class"), (panel.Name, panel.Kind)); + Assert.True(NativePaths.AreEqual(Panel, panel.FilePath), panel.FilePath); + Assert.Equal((0, 0), (panel.Line, panel.Character)); + + // The follow-up a client makes from that item answers its subtypes, each in its own file. + var subtypes = Assert.Single(RazorFixture.Ok(await manager.GetSubtypesAsync(Panel, 0, 0))); + Assert.Equal(("Ticker", "Class"), (subtypes.Name, subtypes.Kind)); + Assert.True(NativePaths.AreEqual(Ticker, subtypes.FilePath), subtypes.FilePath); + Assert.Equal((0, 0), (subtypes.Line, subtypes.Character)); + + // Its supertype is a framework component, which has no source row to show. + Assert.Empty(RazorFixture.Ok(await manager.GetSupertypesAsync(Panel, 0, 0))); + + // A component's type named in @code lands in the component's file too. + var tally = RazorFixture.Locate(Board, "private Tally?", "Tally"); + var prepared = RazorFixture.Ok( + await manager.PrepareTypeHierarchyAsync(Board, tally.Line, tally.Character) + ); + Assert.NotNull(prepared); + Assert.Equal("Tally", prepared.Name); + Assert.True(NativePaths.AreEqual(Tally, prepared.FilePath), prepared.FilePath); + Assert.Equal((0, 0), (prepared.Line, prepared.Character)); + + // A type authored in C# stays in its own file, not in the Razor file that names it. + var model = RazorFixture.Locate(Edit, "@model EditModel", "EditModel"); + var declared = RazorFixture.Ok( + await manager.PrepareTypeHierarchyAsync(Edit, model.Line, model.Character) + ); + Assert.NotNull(declared); + Assert.Equal("EditModel", declared.Name); + Assert.True( + NativePaths.AreEqual( + _fixture.Path("PagesApp", "Pages", "Edit.cshtml.cs"), + declared.FilePath + ), + declared.FilePath + ); + Assert.Equal( + RazorFixture.Locate( + _fixture.Path("PagesApp", "Pages", "Edit.cshtml.cs"), + "public class EditModel", + "EditModel" + ), + (declared.Line, declared.Character) + ); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorHintsAndLensesTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorHintsAndLensesTests.cs new file mode 100644 index 00000000..83bcf67c --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorHintsAndLensesTests.cs @@ -0,0 +1,136 @@ +#pragma warning disable CA1515 // Public xUnit discovery type +#pragma warning disable CA2007 // xUnit executes without a synchronization context +#pragma warning disable RS1035 // Real-file reads are intentional in this end-to-end suite +#pragma warning disable IDE0042 // Positions read better as (Line, Character) pairs + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// +/// Inlay hints and code lenses of Razor files, against the real fixture solution: the hints of +/// the C# fragments the requested Razor lines hold, placed in the file, and the reference counts +/// of the members the file declares in @code and @functions. Implements +/// [RAZOR-TESTING] for [RAZOR-FEATURES-INLAY-HINTS] and [RAZOR-FEATURES-CODE-LENS]. +/// +public sealed class RazorHintsAndLensesTests : IClassFixture +{ + private readonly RazorFixture _fixture; + + public RazorHintsAndLensesTests(RazorFixture fixture) + { + _fixture = fixture; + } + + private string Board => _fixture.Path("BlazorApp", "Components", "Pages", "Board.razor"); + + private string Counter => _fixture.Path("BlazorApp", "Components", "Pages", "Counter.razor"); + + private string Ticker => _fixture.Path("BlazorApp", "Components", "Shared", "Ticker.razor"); + + private string Edit => _fixture.Path("PagesApp", "Pages", "Edit.cshtml"); + + [Fact] + public async Task Inlay_hints_land_at_their_razor_positions() + { + using var manager = await _fixture.OpenManagerAsync(); + + // Board's markup holds a lambda and its @code a call: both keep their parameter names. + var hints = RazorFixture.Ok(await manager.GetInlayHintsAsync(Board, 0, 25)); + var typed = Assert.Single(hints, hint => hint.Label == ": int"); + Assert.Equal( + RazorFixture.Locate(Board, "Format=\"@(score", "score =>", "score".Length), + (typed.Line, typed.Character) + ); + Assert.Equal(1, typed.Kind); + Assert.StartsWith(" =>", TextAfter(Board, typed), StringComparison.Ordinal); + + var scored = Assert.Single(hints, hint => hint.Label == "score:"); + Assert.Equal( + RazorFixture.Locate(Board, "@onclick=\"() => Add(1)\"", "1)"), + (scored.Line, scored.Character) + ); + Assert.Equal(2, scored.Kind); + Assert.StartsWith("1)", TextAfter(Board, scored), StringComparison.Ordinal); + + var listed = Assert.Single(hints, hint => hint.Label == "item:"); + Assert.Equal( + RazorFixture.Locate(Board, "private void Add", "score + Id"), + (listed.Line, listed.Character) + ); + Assert.StartsWith("score + Id", TextAfter(Board, listed), StringComparison.Ordinal); + + // The generated builder's own arguments are hidden scaffolding, never hints of the file. + Assert.DoesNotContain(hints, hint => hint.Label == "sequence:"); + Assert.DoesNotContain(hints, hint => hint.Label == "markupContent:"); + + // A single @code line answers that line alone, and a line outside every fragment none. + var code = RazorFixture.Ok(await manager.GetInlayHintsAsync(Ticker, 10, 10)); + (int Line, int Character, string Label)[] expected = [(10, 33, "by:"), (10, 11, ": int")]; + Assert.Equal(expected, code.Select(hint => (hint.Line, hint.Character, hint.Label))); + Assert.Empty(RazorFixture.Ok(await manager.GetInlayHintsAsync(Ticker, 4, 4))); + Assert.Empty(RazorFixture.Ok(await manager.GetInlayHintsAsync(Board, 3, 3))); + } + + [Fact] + public async Task Code_lenses_count_the_razor_uses_of_the_files_members() + { + using var manager = await _fixture.OpenManagerAsync(); + + // Counter declares one member in @code and its markup clicks it: one reference. + var counter = Assert.Single(RazorFixture.Ok(await manager.GetCodeLensesAsync(Counter))); + Assert.Equal( + RazorFixture.Locate(Counter, "private void IncrementCount", "private"), + (counter.Line, counter.Character) + ); + Assert.Equal("1 reference", counter.Title); + + // Board's @code holds three members: the parameter its markup reads, the method its + // button calls, and the interface's method nothing calls by name. + var board = RazorFixture.Ok(await manager.GetCodeLensesAsync(Board)); + ((int, int), string)[] expected = + [ + ( + RazorFixture.Locate(Board, "[Parameter] public int Id", "[Parameter]"), + "2 references" + ), + (RazorFixture.Locate(Board, "private void Add", "private"), "1 reference"), + (RazorFixture.Locate(Board, "public void Dispose", "public"), "0 references"), + ]; + Assert.Equal(expected, board.Select(lens => ((lens.Line, lens.Character), lens.Title))); + + // A Razor Page's @functions block is lensed the same way, and nothing else in the file is. + var functions = Assert.Single(RazorFixture.Ok(await manager.GetCodeLensesAsync(Edit))); + Assert.Equal( + RazorFixture.Locate(Edit, "string Shout(string text)", "string"), + (functions.Line, functions.Character) + ); + Assert.Equal("1 reference", functions.Title); + + // A component with no @code has no lens at all: its class is not a declaration in the file. + Assert.Empty( + RazorFixture.Ok( + await manager.GetCodeLensesAsync( + _fixture.Path("BlazorApp", "Components", "Shared", "Badge.razor") + ) + ) + ); + + // A code-behind file keeps the lenses C# gives it. + string[] counts = ["2 references", "2 references"]; + Assert.Equal( + counts, + RazorFixture + .Ok( + await manager.GetCodeLensesAsync( + _fixture.Path("BlazorApp", "Components", "Shared", "Badge.razor.cs") + ) + ) + .Select(lens => lens.Title) + ); + } + + /// The file's text from to the end of its line. + private static string TextAfter(string file, InlayHintResult hint) + { + return File.ReadAllLines(file)[hint.Line][hint.Character..]; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorHotReloadTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorHotReloadTests.cs new file mode 100644 index 00000000..c512a97e --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorHotReloadTests.cs @@ -0,0 +1,366 @@ +using SharpLsp.Sidecar.CSharp.Debugging; + +#pragma warning disable CA1515 // Public xUnit fixture type +#pragma warning disable CA2007 // xUnit executes without a synchronization context +#pragma warning disable RS1035 // The fixture is built and hot-reloaded for real + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// +/// Razor Hot Reload against the real fixture: a saved Razor file replaces the additional +/// document's text in every framework copy, the SDK Razor generator re-runs, and the +/// Edit-and-Continue emit produces deltas for the changed generated C#. The signature +/// guard judges a Razor save through its generated documents, never by parsing Razor +/// text. Implements [RAZOR-HOT-RELOAD] for [DEBUG-FEATURES-HOT-RELOAD] and +/// [RAZOR-ARCHITECTURE-DOCUMENTS]. +/// +public sealed class RazorHotReloadTests : IClassFixture +{ + /// Counter.razor with only markup text changed: the generated body moves. + private const string MarkupSave = """ + @page "/counter" + @rendermode InteractiveServer + + Counter + +

Tally

+ +

Current count: @currentCount

+ + + + @code { + private int currentCount = 0; + + private void IncrementCount() + { + currentCount++; + } + } + """; + + /// Counter.razor with the markup changed again, past . + private const string MarkupSaveAgain = """ + @page "/counter" + @rendermode InteractiveServer + + Counter + +

Tally!

+ +

Current count: @currentCount

+ + + + @code { + private int currentCount = 0; + + private void IncrementCount() + { + currentCount++; + } + } + """; + + /// Counter.razor with an @code method body edit: the supported core edit. + private const string BodySave = """ + @page "/counter" + @rendermode InteractiveServer + + Counter + +

Counter

+ +

Current count: @currentCount

+ + + + @code { + private int currentCount = 0; + + private void IncrementCount() + { + currentCount += 2; + } + } + """; + + /// Counter.razor whose @code changes a method's signature: a rude edit. + private const string SignatureSave = """ + @page "/counter" + @rendermode InteractiveServer + + Counter + +

Counter

+ +

Current count: @currentCount

+ + + + @code { + private int currentCount = 0; + + private void IncrementCount(int by) + { + currentCount += by; + } + } + """; + + /// Counting.cs with a method body edit, for the mixed batch. + private const string CountingSave = """ + namespace BlazorApp.Components.Shared; + + /// + /// A plain C# type the fixture's Razor components call into, so that a call from Razor code and + /// markup reaches a method declared outside the projection. [RAZOR-TESTING] + /// + internal static class Counting + { + /// Triples a count. + internal static int Bump(int by) + { + return by * 3; + } + } + """; + + private readonly RazorFixture _fixture; + + public RazorHotReloadTests(RazorFixture fixture) + { + _fixture = fixture; + } + + private string Counter => _fixture.Path("BlazorApp", "Components", "Pages", "Counter.razor"); + + private string Counting => _fixture.Path("BlazorApp", "Components", "Shared", "Counting.cs"); + + private string ProjectPath => _fixture.Path("BlazorApp", "BlazorApp.csproj"); + + // Implements [RAZOR-HOT-RELOAD]: a markup-only save re-runs the SDK Razor generator + // and the EnC emit carries the changed generated body to the runtime. + [Fact] + public async Task A_markup_only_razor_save_emits_deltas_and_commits() + { + var (registry, sessionId) = await StartedAsync().ConfigureAwait(true); + + var applied = await SavedAsync(registry, sessionId, Counter, MarkupSave) + .ConfigureAwait(true); + AssertApplied(applied); + AssertRealDeltas(applied); + Assert.Empty(applied.Diagnostics); + await ConfirmedAsync(registry, sessionId).ConfigureAwait(true); + + // The committed emit is the baseline: a further markup save still emits. + var again = await SavedAsync(registry, sessionId, Counter, MarkupSaveAgain) + .ConfigureAwait(true); + AssertApplied(again); + AssertRealDeltas(again); + await EndedAsync(registry, sessionId).ConfigureAwait(true); + } + + // Implements [RAZOR-HOT-RELOAD]: an @code method body edit is the supported core + // edit, and a re-save of committed text is quiet — the baseline moved with it. + [Fact] + public async Task An_atcode_body_edit_emits_and_a_resave_of_it_is_quiet() + { + var (registry, sessionId) = await StartedAsync().ConfigureAwait(true); + + var applied = await SavedAsync(registry, sessionId, Counter, BodySave).ConfigureAwait(true); + AssertApplied(applied); + AssertRealDeltas(applied); + Assert.Empty(applied.Diagnostics); + await ConfirmedAsync(registry, sessionId).ConfigureAwait(true); + + var quiesced = await SavedAsync(registry, sessionId, Counter, BodySave) + .ConfigureAwait(true); + AssertApplied(quiesced); + Assert.Empty(quiesced.Updates); + await EndedAsync(registry, sessionId).ConfigureAwait(true); + } + + // Implements [RAZOR-HOT-RELOAD]: the signature guard judges the GENERATED documents + // whose text the save changed, so a signature change inside @code refuses exactly + // like a .cs rude edit, naming the method — and never poisons the session. + [Fact] + public async Task A_razor_signature_change_demands_a_restart_naming_the_method() + { + var (registry, sessionId) = await StartedAsync().ConfigureAwait(true); + + var refused = await SavedAsync(registry, sessionId, Counter, SignatureSave) + .ConfigureAwait(true); + Assert.Equal("restartRequired", refused.Status); + Assert.Empty(refused.Updates); + var reason = Assert.Single(refused.Diagnostics); + Assert.StartsWith("ENC0110:", reason, StringComparison.Ordinal); + Assert.Contains("IncrementCount", reason, StringComparison.Ordinal); + + // A refusal is a verdict, not a poison: the next markup save still emits. + var recovered = await SavedAsync(registry, sessionId, Counter, MarkupSave) + .ConfigureAwait(true); + AssertApplied(recovered); + AssertRealDeltas(recovered); + await EndedAsync(registry, sessionId).ConfigureAwait(true); + } + + // Implements [RAZOR-HOT-RELOAD]: one batch may span a .cs save and a .razor save; + // both apply atomically as one candidate, and the batch becomes the baseline. + [Fact] + public async Task A_batch_mixing_a_csharp_and_a_razor_save_applies_as_one() + { + var (registry, sessionId) = await StartedAsync().ConfigureAwait(true); + + var spanning = await SendAsync( + registry, + new HotReloadRequest + { + Action = "update", + SessionId = sessionId, + Documents = + [ + new HotReloadDocument { FilePath = Counting, NewText = CountingSave }, + new HotReloadDocument { FilePath = Counter, NewText = MarkupSave }, + ], + } + ) + .ConfigureAwait(true); + AssertApplied(spanning); + AssertRealDeltas(spanning); + Assert.Empty(spanning.Diagnostics); + await ConfirmedAsync(registry, sessionId).ConfigureAwait(true); + + // Both sides of the batch are the baseline now: the same Razor save is quiet, + // while a further markup tweak emits against the batch. + var quiesced = await SavedAsync(registry, sessionId, Counter, MarkupSave) + .ConfigureAwait(true); + AssertApplied(quiesced); + Assert.Empty(quiesced.Updates); + + var further = await SavedAsync(registry, sessionId, Counter, MarkupSaveAgain) + .ConfigureAwait(true); + AssertApplied(further); + AssertRealDeltas(further); + await EndedAsync(registry, sessionId).ConfigureAwait(true); + } + + // Implements [RAZOR-ARCHITECTURE-DOCUMENTS] "unlisted files" through [RAZOR-HOT-RELOAD]: + // a Razor file no project lists has no semantics — the save fails clearly, by name, + // and the session stays usable. + [Fact] + public async Task An_unlisted_razor_save_fails_clearly_and_the_session_survives() + { + var (registry, sessionId) = await StartedAsync().ConfigureAwait(true); + var ghost = _fixture.Path("BlazorApp", "Components", "Pages", "Ghost.razor"); + + var refusal = await Assert + .ThrowsAsync(async () => + _ = await SavedAsync(registry, sessionId, ghost, MarkupSave).ConfigureAwait(true) + ) + .ConfigureAwait(true); + // The refusal is clear because it names the file it cannot reload. + Assert.Contains(ghost, refusal.Message, StringComparison.Ordinal); + + // The failed save never touched the session: a real save still applies. + var applied = await SavedAsync(registry, sessionId, Counter, MarkupSave) + .ConfigureAwait(true); + AssertApplied(applied); + AssertRealDeltas(applied); + await EndedAsync(registry, sessionId).ConfigureAwait(true); + } + + /// A session over the debugged project, started through the registry. + private async Task<(HotReloadSessionRegistry Registry, string SessionId)> StartedAsync() + { + var registry = new HotReloadSessionRegistry(); + var started = await SendAsync( + registry, + new HotReloadRequest + { + Action = "start", + ProjectPath = ProjectPath, + Capabilities = + [ + "Baseline", + "AddMethodToExistingType", + "AddStaticFieldToExistingType", + "AddDefinitionToExistingType", + "NewTypeDefinition", + ], + } + ) + .ConfigureAwait(true); + Assert.Equal("started", started.Status); + Assert.Equal("BlazorApp", started.AssemblyName); + return (registry, started.SessionId); + } + + /// One saved document, sent as a single-document batch. + private static Task SavedAsync( + HotReloadSessionRegistry registry, + string sessionId, + string filePath, + string newText + ) + { + return SendAsync( + registry, + new HotReloadRequest + { + Action = "update", + SessionId = sessionId, + Documents = [new HotReloadDocument { FilePath = filePath, NewText = newText }], + } + ); + } + + private static async Task SendAsync( + HotReloadSessionRegistry registry, + HotReloadRequest request + ) + { + return await registry.HandleAsync(request, CancellationToken.None).ConfigureAwait(true); + } + + /// One update: a real module delta with all three delta streams. + private static void AssertRealDeltas(HotReloadResponse applied) + { + var update = Assert.Single(applied.Updates); + Assert.NotEqual(Guid.Empty, Guid.Parse(update.ModuleId)); + Assert.NotEmpty(Convert.FromBase64String(update.MetadataDelta)); + Assert.NotEmpty(Convert.FromBase64String(update.IlDelta)); + Assert.NotEmpty(Convert.FromBase64String(update.PdbDelta)); + } + + /// An applied verdict, naming every diagnostic a failure would hide. + private static void AssertApplied(HotReloadResponse response) + { + Assert.True( + response.Status == "applied", + $"{response.Status}: {string.Join(" | ", response.Diagnostics)}" + ); + } + + /// The debuggee confirmed the deltas; the sidecar must accept the commit. + private static async Task ConfirmedAsync(HotReloadSessionRegistry registry, string sessionId) + { + var committed = await SendAsync( + registry, + new HotReloadRequest { Action = "commit", SessionId = sessionId } + ) + .ConfigureAwait(true); + Assert.Equal("committed", committed.Status); + } + + /// The session ends cleanly, releasing its workspace. + private static async Task EndedAsync(HotReloadSessionRegistry registry, string sessionId) + { + var ended = await SendAsync( + registry, + new HotReloadRequest { Action = "end", SessionId = sessionId } + ) + .ConfigureAwait(true); + Assert.Equal("ended", ended.Status); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMvcTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMvcTests.cs new file mode 100644 index 00000000..b5b18d04 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorMvcTests.cs @@ -0,0 +1,376 @@ +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 RS1035 // Real-file reads are intentional in this end-to-end suite +#pragma warning disable IDE0042 // Positions read better as (Line, Character) pairs + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// +/// Razor Pages and MVC views against the real fixture's PagesApp: @model, +/// @inject, @functions and @section through the projection, tag helper +/// elements and attributes from their descriptors, and _ViewImports.cshtml as an +/// import. Implements [RAZOR-TESTING] for [RAZOR-MVC]. +/// +public sealed class RazorMvcTests : IClassFixture +{ + private readonly RazorFixture _fixture; + + public RazorMvcTests(RazorFixture fixture) + { + _fixture = fixture; + } + + private string Edit => _fixture.Path("PagesApp", "Pages", "Edit.cshtml"); + + private string Index => _fixture.Path("PagesApp", "Views", "Home", "Index.cshtml"); + + [Fact] + public async Task Model_inject_and_functions_answer_through_the_projection() + { + using var manager = await _fixture.OpenManagerAsync(); + + // `@model`'s type is C#, and so is `Model.X`. + var model = await HoverAsync( + manager, + Edit, + RazorFixture.Locate(Edit, "@model EditModel", "EditModel") + ); + Assert.Contains("Edits one item.", model, StringComparison.Ordinal); + var title = await HoverAsync( + manager, + Edit, + RazorFixture.Locate(Edit, "@Shout(Model.Title)", "Title") + ); + Assert.Contains("The item's title.", title, StringComparison.Ordinal); + + // `@inject` declares a property of its type, and `@functions` members are C#. + var log = await HoverAsync( + manager, + Edit, + RazorFixture.Locate(Edit, "@inject ILogger", "Log") + ); + Assert.Contains("ILogger<", log, StringComparison.Ordinal); + var call = RazorFixture.Locate(Edit, "@Shout(Model.Title)", "Shout"); + var shout = Assert.Single( + RazorFixture + .Ok(await manager.GetDefinitionAsync(Edit, call.Line, call.Character)) + .Locations + ); + Assert.True(NativePaths.AreEqual(Edit, shout.FilePath), shout.FilePath); + Assert.Equal( + RazorFixture.Locate(Edit, "string Shout", "Shout"), + (shout.Line, shout.Character) + ); + } + + [Fact] + public async Task ViewImports_imports_reach_the_views_below_them() + { + using var manager = await _fixture.OpenManagerAsync(); + + // Views/_ViewImports.cshtml's `@using PagesApp.Models` resolves `@model Item`... + var item = await HoverAsync( + manager, + Index, + RazorFixture.Locate(Index, "@model Item", "Item") + ); + Assert.Contains("An item the home page shows.", item, StringComparison.Ordinal); + Assert.DoesNotContain( + RazorFixture.Ok(await manager.GetDiagnosticsAsync(Index)), + diagnostic => diagnostic.Severity == "Error" + ); + + // ...and its `@addTagHelper` binds the anchor tag helper. + var markup = await manager.FindRazorMarkupAsync(Index, default); + Assert.NotNull(markup); + var anchor = Assert.Single(markup.Tags, tag => tag.Name == "a"); + Assert.Contains( + anchor.MvcTagHelpers, + helper => helper.Name.EndsWith("AnchorTagHelper", StringComparison.Ordinal) + ); + } + + [Fact] + public async Task Tag_helper_elements_and_attributes_answer_hover_from_their_descriptors() + { + using var manager = await _fixture.OpenManagerAsync(); + + // An element a tag helper binds answers the tag helper's type. + var input = await HoverAsync( + manager, + Edit, + RazorFixture.Locate(Edit, " item.Label == "asp-page"); + Assert.Contains(items, item => item.Label == "asp-controller"); + Assert.Contains(items, item => item.Label == "asp-route-"); + var page = Assert.Single(items, item => item.Label == "asp-page"); + Assert.Equal("Property", page.Kind); + } + + [Fact] + public async Task Tag_helper_elements_complete_after_an_open_angle_bracket() + { + using var manager = await _fixture.OpenManagerAsync(); + var original = await File.ReadAllTextAsync(Edit); + + // After `<`, every element a tag helper in scope targets by name completes, with the + // tag helper that binds it, replacing the name typed so far. [RAZOR-MVC-TAGHELPERS] + var items = await CompletedAsync(manager, original + "\n item.Label == "partial"); + Assert.Equal("Class", partial.Kind); + Assert.Contains("PartialTagHelper", partial.Detail, StringComparison.Ordinal); + Assert.Equal("pa", ReplacedText(original + "\n item.Label == "environment"); + var anchor = Assert.Single(items, item => item.Label == "a"); + Assert.Contains("AnchorTagHelper", anchor.Detail, StringComparison.Ordinal); + Assert.DoesNotContain(items, item => item.Label == "*"); + + // `@tagHelperPrefix` spells each element with its prefix... + var prefixed = await CompletedAsync(manager, "@tagHelperPrefix th:\n" + original + "\n<"); + Assert.Contains(prefixed, item => item.Label == "th:partial"); + Assert.DoesNotContain(prefixed, item => item.Label == "partial"); + + // ...and `@removeTagHelper *` takes the assembly's elements away, leaving those of the + // default imports' URL resolution tag helper, which still binds `a`. + const string none = "@removeTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers"; + var removed = await CompletedAsync(manager, original + "\n" + none + "\n<"); + Assert.DoesNotContain(removed, item => item.Label is "partial" or "environment"); + var resolved = Assert.Single(removed, item => item.Label == "a"); + Assert.Contains("UrlResolutionTagHelper", resolved.Detail, StringComparison.Ordinal); + Assert.DoesNotContain("AnchorTagHelper", resolved.Detail, StringComparison.Ordinal); + + // A legacy file binds no component by its tag, so none completes. [RAZOR-MVC-SCOPE] + Assert.DoesNotContain(items, item => item.Label is "ErrorBoundary" or "Virtualize"); + Assert.DoesNotContain( + items, + item => + item.Detail?.StartsWith("Microsoft.AspNetCore.Components", StringComparison.Ordinal) + == true + ); + } + + [Fact] + public async Task Model_expressions_and_section_code_get_csharp_features() + { + using var manager = await _fixture.OpenManagerAsync(); + + // An `asp-for` value is a model expression: C# hover and definition. [RAZOR-MVC] + var at = RazorFixture.Locate(Edit, " NativePaths.AreEqual(Edit, location.FilePath)) + ); + var rename = RazorFixture.Ok( + await manager.RenameAsync(code, title.Line, title.Character, "Heading") + ); + var view = Assert.Single( + rename.DocumentChanges, + d => NativePaths.AreEqual(Edit, d.FilePath) + ); + Assert.Equal(4, view.Edits.Count); + Assert.Contains(view.Edits, e => (e.StartLine, e.StartCharacter) == at); + Assert.All(view.Edits, e => Assert.Equal("Heading", e.NewText)); + Assert.DoesNotContain( + rename.DocumentChanges, + d => NativePaths.HasExtension(d.FilePath, ".g.cs") + ); + + // The C# inside a `@section` block answers through the projection too. + var text = await File.ReadAllTextAsync(Index); + const string section = "\n@section Scripts {\n

@Model.Price

\n}\n"; + _ = RazorFixture.Ok(await manager.UpdateDocumentTextAsync(Index, text + section)); + var line = text.Split('\n').Length + 1; + var price = RazorFixture.Ok(await manager.GetHoverAsync(Index, line, 17)); + Assert.NotNull(price); + Assert.Contains("decimal", price.Contents, StringComparison.Ordinal); + Assert.Contains("Price", price.Contents, StringComparison.Ordinal); + } + + [Fact] + public async Task Section_blocks_fold_and_outline() + { + using var manager = await _fixture.OpenManagerAsync(); + var text = await File.ReadAllTextAsync(Index); + const string section = "\n@section Scripts {\n \n}\n"; + _ = RazorFixture.Ok(await manager.UpdateDocumentTextAsync(Index, text + section)); + var opened = text.Split('\n').Length; + + var folds = RazorFixture.Ok(await manager.GetFoldingRangesAsync(Index, default)); + Assert.Contains((opened, opened + 1), folds.Select(fold => (fold.StartLine, fold.EndLine))); + var symbols = RazorFixture.Ok(await manager.GetDocumentSymbolsAsync(Index, default)); + Assert.Contains(symbols, symbol => symbol.Name == "@section" && symbol.Kind == "Key"); + } + + [Fact] + public async Task Tag_helper_elements_keep_their_markup_and_blank_their_csharp_values() + { + using var manager = await _fixture.OpenManagerAsync(); + var markup = await manager.FindRazorMarkupAsync(Edit, default); + Assert.NotNull(markup); + + // The elements are HTML, so the HTML projection keeps their tags. [RAZOR-ARCHITECTURE-HTML] + var html = markup.HtmlText; + Assert.Contains("", html, StringComparison.Ordinal); + Assert.Contains("", html, StringComparison.Ordinal); + Assert.Contains("class=\"form-control\" />", html, StringComparison.Ordinal); + Assert.Contains(">Reload
", html, StringComparison.Ordinal); + Assert.Contains("", + html, + StringComparison.Ordinal + ); + Assert.Contains( + "Reload", + html, + StringComparison.Ordinal + ); + Assert.DoesNotContain("Title", html, StringComparison.Ordinal); + Assert.DoesNotContain("Model", html, StringComparison.Ordinal); + + // Each element a tag helper binds is a tag of the file, with its tag helpers. + string[] bound = ["form", "label", "input", "span", "a"]; + Assert.All( + bound, + name => Assert.Contains(markup.Tags, tag => tag.Name == name && tag.MvcTagHelpers.Any()) + ); + } + + [Fact] + public async Task Tag_helper_directives_scope_completion_and_binding() + { + using var manager = await _fixture.OpenManagerAsync(); + var original = await File.ReadAllTextAsync(Edit); + + // `@removeTagHelper` takes the anchor tag helper out of scope, wherever it is written: + // anchors stop binding and completing, and the form tag helper stays. [RAZOR-MVC-SCOPE] + const string anchor = + "@removeTagHelper Microsoft.AspNetCore.Mvc.TagHelpers.AnchorTagHelper, " + + "Microsoft.AspNetCore.Mvc.TagHelpers"; + var removed = await EditedAsync(manager, original + "\n" + anchor + "\n item.Label.StartsWith("asp-", StringComparison.Ordinal) + ); + Assert.DoesNotContain(removed.Markup.Tags, tag => tag.Name == "a"); + var form = Assert.Single(removed.Markup.Tags, tag => tag.Name == "form"); + Assert.Contains( + form.MvcTagHelpers, + helper => helper.Name.EndsWith(".FormTagHelper", StringComparison.Ordinal) + ); + + // `@tagHelperPrefix` makes only prefixed elements tag helpers: they bind, complete and + // answer hover under their prefixed name, and unprefixed ones do none of it. + var prefixed = await EditedAsync( + manager, + "@tagHelperPrefix th:\n" + original + "\nGo\n item.Label == "asp-route-"); + Assert.DoesNotContain(prefixed.Markup.Tags, tag => tag.Name == "form"); + var go = Assert.Single(prefixed.Markup.Tags, tag => tag.Name == "a"); + Assert.Equal("th:a", prefixed.Markup.Text.ToString(go.StartName)); + Assert.Equal("th:a", prefixed.Markup.Text.ToString(go.EndName!.Value)); + var at = prefixed.Markup.Text.Lines.GetLinePosition(go.StartName.Start + 3); + var hover = RazorFixture.Ok(await manager.GetHoverAsync(Edit, at.Line, at.Character)); + Assert.NotNull(hover); + Assert.Contains("AnchorTagHelper", hover.Contents, StringComparison.Ordinal); + var unprefixed = await EditedAsync( + manager, + "@tagHelperPrefix th:\n" + original + "\n item.Label.StartsWith("asp-", StringComparison.Ordinal) + ); + } + + /// The markup facts and the completion at the end of , once it is Edit.cshtml's. + private async Task<(RazorMarkup Markup, List Items)> EditedAsync( + WorkspaceManager manager, + string text + ) + { + _ = RazorFixture.Ok(await manager.UpdateDocumentTextAsync(Edit, text)); + var lines = text.Split('\n'); + var items = RazorFixture.Ok( + await manager.GetCompletionsAsync(Edit, lines.Length - 1, lines[^1].Length) + ); + var markup = await manager.FindRazorMarkupAsync(Edit, default); + Assert.NotNull(markup); + return (markup, items); + } + + /// The completion at the end of , once it is Edit.cshtml's. + private async Task> CompletedAsync(WorkspaceManager manager, string text) + { + return (await EditedAsync(manager, text)).Items; + } + + /// The text of that 's edit replaces. + private static string ReplacedText(string text, CompletionItem item) + { + Assert.NotNull(item.TextEdit); + var lines = text.Split('\n'); + Assert.Equal(item.TextEdit.StartLine, item.TextEdit.EndLine); + return lines[item.TextEdit.StartLine][ + item.TextEdit.StartCharacter..item.TextEdit.EndCharacter + ]; + } + + private static async Task HoverAsync( + WorkspaceManager manager, + string file, + (int Line, int Character) at, + int offset = 0 + ) + { + var hover = RazorFixture.Ok( + await manager.GetHoverAsync(file, at.Line, at.Character + offset) + ); + Assert.NotNull(hover); + return hover.Contents; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorSelectionRangesTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorSelectionRangesTests.cs new file mode 100644 index 00000000..6719dde2 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorSelectionRangesTests.cs @@ -0,0 +1,288 @@ +using SharpLsp.Sidecar.CSharp.Workspace; + +#pragma warning disable CA1515 // Public xUnit discovery type +#pragma warning disable CA2007 // xUnit executes without a synchronization context +#pragma warning disable RS1035 // Real-file reads are intentional in this end-to-end suite + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// +/// Selection ranges of the fixture's real Razor files: every position answers the chain from +/// its innermost C# fragment or markup span out to the whole document, each range strictly +/// holding the one before, all in the Razor file. Implements [RAZOR-TESTING] for +/// [RAZOR-FEATURES-SELECTION]. +/// +public sealed class RazorSelectionRangesTests : IClassFixture +{ + private readonly RazorFixture _fixture; + + public RazorSelectionRangesTests(RazorFixture fixture) + { + _fixture = fixture; + } + + private string Counter => _fixture.Path("BlazorApp", "Components", "Pages", "Counter.razor"); + + private string Board => _fixture.Path("BlazorApp", "Components", "Pages", "Board.razor"); + + private string Edit => _fixture.Path("PagesApp", "Pages", "Edit.cshtml"); + + [Fact] + public async Task Csharp_in_a_code_block_chains_from_the_identifier_to_the_document() + { + using var manager = await _fixture.OpenManagerAsync(); + var statement = RazorFixture.Locate(Counter, "currentCount++;", "currentCount"); + + // The identifier, its increment, the statement, the method's body and + // header, the @code directive, and the whole document. + var at = await RangesAsync(manager, Counter, statement); + Assert.Equal( + [ + Span(Counter, "currentCount++;", "currentCount"), + (16, 8, 16, 22), + Span(Counter, "currentCount++;", "currentCount++;"), + (15, 4, 17, 5), + (14, 4, 17, 5), + (11, 0, 18, 1), + Document(Counter), + ], + at + ); + AssertAllContain(at, statement); + + // The method's name selects the method itself, not its body. + var name = RazorFixture.Locate(Counter, "IncrementCount()", "IncrementCount"); + var header = await RangesAsync(manager, Counter, name); + Assert.Equal( + [ + Span(Counter, "IncrementCount()", "IncrementCount"), + (14, 4, 17, 5), + (11, 0, 18, 1), + Document(Counter), + ], + header + ); + AssertAllContain(header, name); + + // The directive's own name selects the whole @code block. + var directive = RazorFixture.Locate(Counter, "@code {", "@code"); + var block = await RangesAsync(manager, Counter, directive); + Assert.Equal([(11, 0, 18, 1), Document(Counter)], block); + AssertAllContain(block, directive); + } + + [Fact] + public async Task Component_tags_chain_through_every_enclosing_element() + { + using var manager = await _fixture.OpenManagerAsync(); + var tally = RazorFixture.Locate(Board, "", "Title"); + var value = RazorFixture.Locate(Board, "", "Scores"); + + var atName = await RangesAsync(manager, Board, name); + Assert.Equal( + [ + Span(Board, "", "Title"), + Span(Board, "", "Title=\"Scores\""), + (6, 0, 10, 8), + Document(Board), + ], + atName + ); + AssertAllContain(atName, name); + + // The value selects the whole attribute, which the facts derive from + // the name's span, then the element and the document. + var atValue = await RangesAsync(manager, Board, value); + Assert.Equal( + [ + Span(Board, "", "Title=\"Scores\""), + (6, 0, 10, 8), + Document(Board), + ], + atValue + ); + AssertAllContain(atValue, value); + } + + [Fact] + public async Task Implicit_expressions_chain_from_csharp_to_their_element() + { + using var manager = await _fixture.OpenManagerAsync(); + var expression = RazorFixture.Locate( + Counter, + "Current count: @currentCount", + "currentCount" + ); + + var at = await RangesAsync(manager, Counter, expression); + Assert.Equal([(7, 33, 7, 45), (7, 0, 7, 49), Document(Counter)], at); + AssertAllContain(at, expression); + } + + [Fact] + public async Task Plain_html_of_a_legacy_file_selects_its_elements_or_only_the_document() + { + using var manager = await _fixture.OpenManagerAsync(); + var form = RazorFixture.Locate(Edit, "
", "form"); + var page = RazorFixture.Locate(Edit, "@page \"{id:int}\"", "@page"); + + // One request for both carets: the form element answers its name and + // its element, and the @page directive answers itself, then the file. + var both = RazorFixture.Ok( + await manager.GetSelectionRangesAsync(Edit, [form, page], default) + ); + Assert.Equal(2, both.Count); + Assert.Equal( + [Span(Edit, "", "form"), (4, 0, 9, 7), Document(Edit)], + Chains(both[0]) + ); + AssertAllContain(Chains(both[0]), form); + + var atPage = Chains(both[1]); + Assert.Equal(2, atPage.Count); + Assert.Equal((0, 0, 1, 0), atPage[0]); + Assert.Equal(Document(Edit), atPage[1]); + AssertAllContain(atPage, page); + + // A position no markup fact holds — the blank line between the + // directives and the form — selects only the document. + var blank = await RangesAsync(manager, Edit, (3, 0)); + Assert.Equal([Document(Edit)], blank); + } + + [Fact] + public async Task A_file_no_project_lists_answers_empty() + { + using var manager = await _fixture.OpenManagerAsync(); + var missing = _fixture.Path("BlazorApp", "Components", "Pages", "Missing.razor"); + + var answer = RazorFixture.Ok( + await manager.GetSelectionRangesAsync(missing, [(0, 1), (18, 0)], default) + ); + Assert.Equal(2, answer.Count); + Assert.Empty(answer[0].Ranges); + Assert.Empty(answer[1].Ranges); + + // A plain C# file is not Razor; the sidecar answers empty for it too. + var program = _fixture.Path("BlazorApp", "Program.cs"); + var csharp = RazorFixture.Ok( + await manager.GetSelectionRangesAsync(program, [(0, 1)], default) + ); + Assert.Empty(csharp[0].Ranges); + + // A cancelled request is a failure the host reports, never a partial answer. + var cancelled = await manager.GetSelectionRangesAsync( + _fixture.Path("BlazorApp", "Components", "Pages", "Board.razor"), + [(0, 1)], + new CancellationToken(true) + ); + Assert.True(cancelled.IsError, "a cancelled request fails"); + } + + /// The selection ranges at one position, as (line, character) tuples. + private static async Task< + List<(int StartLine, int StartCharacter, int EndLine, int EndCharacter)> + > RangesAsync(WorkspaceManager manager, string file, (int Line, int Character) at) + { + var answer = RazorFixture.Ok(await manager.GetSelectionRangesAsync(file, [at], default)); + return Chains(Assert.Single(answer)); + } + + /// One position's ranges, innermost first, as (line, character) tuples. + private static List<(int, int, int, int)> Chains(SelectionRangeResult ranges) + { + return + [ + .. ranges.Ranges.Select(range => + (range.StartLine, range.StartCharacter, range.EndLine, range.EndCharacter) + ), + ]; + } + + /// The (line, character) span of on the line holding . + private static (int StartLine, int StartCharacter, int EndLine, int EndCharacter) Span( + string file, + string anchor, + string token + ) + { + var (line, character) = RazorFixture.Locate(file, anchor, token); + return (line, character, line, character + token.Length); + } + + /// The whole file's span: from the first character to past the last line break. + private static (int, int, int, int) Document(string file) + { + var text = System.IO.File.ReadAllText(file); + var lines = text.Split('\n'); + return text.EndsWith('\n') + ? (0, 0, lines.Length - 1, 0) + : (0, 0, lines.Length - 1, lines[^1].Length); + } + + /// Every range of the chain holds the caret and holds every range before it. + private static void AssertAllContain( + List<(int StartLine, int StartCharacter, int EndLine, int EndCharacter)> chain, + (int Line, int Character) at + ) + { + Assert.NotEmpty(chain); + Assert.True( + chain[0].StartLine < at.Line + || (chain[0].StartLine == at.Line && chain[0].StartCharacter <= at.Character), + $"the innermost range starts after the caret: {chain[0]}" + ); + for (var index = 1; index < chain.Count; index++) + { + Assert.True( + Holds(chain[index - 1], chain[index]), + $"range {index - 1} must sit inside range {index}: {chain[index - 1]} vs {chain[index]}" + ); + } + } + + /// Whether holds . + private static bool Holds( + (int StartLine, int StartCharacter, int EndLine, int EndCharacter) inner, + (int StartLine, int StartCharacter, int EndLine, int EndCharacter) outer + ) + { + var startsInside = + outer.StartLine < inner.StartLine + || (outer.StartLine == inner.StartLine && outer.StartCharacter <= inner.StartCharacter); + var endsInside = + outer.EndLine > inner.EndLine + || (outer.EndLine == inner.EndLine && outer.EndCharacter >= inner.EndCharacter); + return startsInside && endsInside; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorSidecarTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorSidecarTests.cs index 60b30485..8bc3abea 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorSidecarTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorSidecarTests.cs @@ -1,4 +1,5 @@ using MessagePack; +using SharpLsp.Sidecar.Common; using SharpLsp.Sidecar.CSharp.Razor; #pragma warning disable CA1515 // Public xUnit discovery type @@ -142,11 +143,101 @@ public async Task Syntax_requests_keep_their_wire_shapes_and_source_ranges() Assert.All(linked.Locations, at => Assert.Equal(Board, at.FilePath)); } + [Fact] + public async Task Selection_ranges_chain_each_caret_out_to_the_document_over_the_socket() + { + await OpenAsync().ConfigureAwait(true); + + // One chain per caret, in request order, innermost first. [RAZOR-FEATURES-SELECTION] + var chains = await socket + .SendAndDeserializeAsync>( + "textDocument/selectionRange", + new SelectionRangeRequest + { + FilePath = Board, + Positions = + [ + new SelectionRangePosition { Line = 7, Character = 5 }, + new SelectionRangePosition { Line = 0, Character = 0 }, + ], + } + ) + .ConfigureAwait(true); + Assert.Equal(2, chains.Count); + var tally = chains[0].Ranges; + Assert.Equal((7, 5, 7, 10), Span(tally[0])); + Assert.Equal((7, 4, 9, 12), Span(tally[1])); + Assert.Equal((6, 0, 10, 8), Span(tally[2])); + Assert.All(chains, chain => Assert.Equal((0, 0, 26, 0), Span(chain.Ranges[^1]))); + + // A file no project lists has no chain at all, never an error. + var unlisted = await socket + .SendAndDeserializeAsync>( + "textDocument/selectionRange", + new SelectionRangeRequest + { + FilePath = files.Path("Missing.razor"), + Positions = [new SelectionRangePosition { Line = 0, Character = 0 }], + } + ) + .ConfigureAwait(true); + Assert.Empty(Assert.Single(unlisted).Ranges); + } + + [Fact] + public async Task Foreign_references_name_the_razor_uses_of_an_fsharp_symbol_over_the_socket() + { + await OpenAsync().ConfigureAwait(true); + var leaders = files.Path("BlazorApp", "Components", "Pages", "Leaders.razor"); + + // The F# sidecar names `Player` by identity; C# answers its three Razor uses. + // [REFERENCES-CROSS-LANGUAGE] + var player = await socket + .SendAndDeserializeAsync( + "workspace/referencesForeign", + new ForeignSymbolRequest + { + AssemblyName = "Domain", + XmlDocSig = "T:Domain.Player", + IncludeDeclaration = false, + } + ) + .ConfigureAwait(true); + Assert.Equal(3, player.Locations.Count); + Assert.All( + player.Locations, + location => + { + Assert.True(NativePaths.AreEqual(leaders, location.FilePath), location.FilePath); + Assert.Equal("Player".Length, location.EndCharacter - location.Character); + } + ); + Assert.Contains( + RazorFixture.Locate(leaders, "private readonly Player[]", "Player"), + player.Locations.Select(location => (location.Line, location.Character)) + ); + + // Another assembly's symbol of that name has no C# use. + var elsewhere = await socket + .SendAndDeserializeAsync( + "workspace/referencesForeign", + new ForeignSymbolRequest + { + AssemblyName = "Elsewhere", + XmlDocSig = "T:Domain.Player", + } + ) + .ConfigureAwait(true); + Assert.Empty(elsewhere.Locations); + } + [Theory] [InlineData("sharplsp/razor/htmlProjection")] [InlineData("textDocument/documentSymbol")] [InlineData("textDocument/foldingRange")] [InlineData("textDocument/linkedEditingRange")] + [InlineData("textDocument/selectionRange")] + [InlineData("workspace/referencesForeign")] public async Task Malformed_payloads_return_errors_and_keep_the_socket_healthy(string method) { var error = await socket.SendAsync(method, [0xC1]).ConfigureAwait(true); @@ -155,6 +246,11 @@ public async Task Malformed_payloads_return_errors_and_keep_the_socket_healthy(s Assert.Equal("pong", pong); } + private static (int, int, int, int) Span(SelectionSpanResult span) + { + return (span.StartLine, span.StartCharacter, span.EndLine, span.EndCharacter); + } + private Task OpenAsync() { return socket.SendAndAssertOkAsync( diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorStylesheetTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorStylesheetTests.cs new file mode 100644 index 00000000..a66f7d12 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/RazorStylesheetTests.cs @@ -0,0 +1,196 @@ +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.CSharp.Razor; + +#pragma warning disable CA1515 // Public xUnit discovery type + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// +/// The collocated stylesheet reader and its CSS Syntax Module Level 3 tokenizer: class +/// selectors come from rule preludes only, so a comment, a string, a declaration value or a +/// url never yields one, while at-rules, ::deep, :is() and escaped names all do. +/// Implements [RAZOR-TESTING] for [RAZOR-COLLOCATION-CSS]. +/// +public sealed class RazorStylesheetTests +{ + [Fact] + public void Classes_come_from_rule_preludes_only() + { + Assert.Equal( + ["card", "panel", "title"], + Names(".card { color: red; } .panel .title { font-weight: 600; }") + ); + // Compound and comma-separated selectors each name their classes once. + Assert.Equal(["a", "b", "c"], Names(".a.b, .c { color: red; }")); + // A `.` with no ident after it is no class. + Assert.Empty(Names("a. { color: red; }")); + } + + [Fact] + public void A_comment_contributes_no_class_and_hides_its_braces() + { + Assert.Equal(["real"], Names("/* } .fake { .alsoFake { } */ .real { color: red; }")); + // An unterminated comment swallows the rest of the stylesheet. + Assert.Equal(["real"], Names(".real { } /* unterminated .fake { ")); + // A comment inside a prelude hides nothing that follows it. + Assert.Equal(["before", "after"], Names(".before /* .fake */ .after { }")); + } + + [Fact] + public void A_string_never_contributes_a_class() + { + // An attribute selector's string holds a dot, and the selector names no class. + Assert.Empty(Names("a[href$=\".pdf\"] { color: red; }")); + // A declaration's string holds both a dot and a brace. + Assert.Equal(["labeled"], Names(".labeled { content: \"a.b}\"; }")); + // An escaped quote does not end the string early. + Assert.Equal(["kept"], Names(".kept { content: \"\\\" .fake {\"; }")); + } + + [Fact] + public void Declaration_values_and_urls_never_contribute_classes() + { + const string Css = + ".spaced { margin: .5rem; background: url(a.b.png); grid-area: zone; } .after { }"; + Assert.Equal(["spaced", "after"], Names(Css)); + // A quoted url is still no selector, and its string holds no class either. + Assert.Equal(["backed"], Names(".backed { background: url(\"x.y.png\"); }")); + // A number's unit is part of its number. + Assert.Equal(["sized"], Names(".sized { width: 1.5rem; height: 0.25em; }")); + } + + [Fact] + public void At_rules_never_name_a_class_but_the_rules_inside_them_do() + { + const string Css = + "@media (min-width: 641px) { .wide { color: red; } " + + "@supports (display: grid) { .grid .cell { display: grid; } } }"; + Assert.Equal(["wide", "grid", "cell"], Names(Css)); + // An at-rule's own prelude holds a class only in a function's argument. + Assert.Empty(Names("@supports selector(.fake) { }")); + // A statement at-rule ends at its semicolon. + Assert.Equal(["imported"], Names("@import url(a.css); .imported { }")); + } + + [Fact] + public void Deep_combinators_and_pseudo_classes_read_as_selectors() + { + const string Css = + ".card::deep .card__title { color: red; } .title:is(.accent, .muted) { color: blue; }"; + Assert.Equal(["card", "card__title", "title", "accent", "muted"], Names(Css)); + // Nested rules of CSS nesting read the same way. + Assert.Equal(["outer", "inner"], Names(".outer { & .inner, &:hover { } }")); + } + + [Fact] + public void Escapes_in_idents_are_decoded() + { + // `\.` is part of the name, and `\31 ` is a `1`. + Assert.Equal(["p-1.5", "w-10"], Names(".p-1\\.5 { } .w-\\31 0 { }")); + // The span stays on the raw text, escapes and all. + const string Css = ".p-1\\.5 { }"; + var spaced = Assert.Single(Read(Css)); + Assert.Equal("p-1\\.5", Css[spaced.Span.Start..spaced.Span.End]); + Assert.Equal("p-1.5", spaced.Name); + } + + [Fact] + public void Every_occurrence_is_reported_on_the_name_after_its_dot() + { + const string Css = ".card .title { } .title { } .title:is(.muted) { }"; + var classes = Read(Css); + Assert.Equal(5, classes.Count); + Assert.All( + classes, + cssClass => Assert.Equal(cssClass.Name, Css[cssClass.Span.Start..cssClass.Span.End]) + ); + Assert.Equal(1, classes[0].Span.Start); + Assert.Equal(7, classes[1].Span.Start); + // The distinct names keep their first appearance's order. + Assert.Equal( + ["card", "title", "muted"], + RazorStylesheet.Parse("/app/x.razor.css", SourceText.From(Css)).Names + ); + } + + [Fact] + public void Unterminated_blocks_strings_and_comments_are_tolerated() + { + // A string that never closes keeps the braces after it out of the stylesheet. + Assert.Equal(["real"], Names(".real { content: \"unterminated; } .fake { }")); + Assert.Empty(Names("/* no end .fake { ")); + Assert.Equal(["open"], Names(".open { color: red; ")); + } + + [Fact] + public void The_tokenizer_follows_the_css_syntax_tokenization() + { + Assert.Equal( + [ + (CssTokenKind.Ident, "a"), + (CssTokenKind.Delim, "."), + (CssTokenKind.Ident, "b"), + (CssTokenKind.Whitespace, " "), + (CssTokenKind.BraceOpen, "{"), + (CssTokenKind.Whitespace, " "), + (CssTokenKind.Ident, "margin"), + (CssTokenKind.Colon, ":"), + (CssTokenKind.Whitespace, " "), + (CssTokenKind.Number, ".5rem"), + (CssTokenKind.Semicolon, ";"), + (CssTokenKind.Whitespace, " "), + (CssTokenKind.BraceClose, "}"), + ], + Tokens("a.b { margin: .5rem; }") + ); + + // An unquoted url is one token; a quoted one is a function and a string. + Assert.Equal([(CssTokenKind.Url, "url(a.b.png)")], Tokens("url(a.b.png)")); + Assert.Equal( + [ + (CssTokenKind.Function, "url("), + (CssTokenKind.String, "\"a.b.png\""), + (CssTokenKind.ParenClose, ")"), + ], + Tokens("url(\"a.b.png\")") + ); + + // At-keywords, hashes, functions and the HTML comment markers have their own kinds. + Assert.Equal( + [ + (CssTokenKind.AtKeyword, "@media"), + (CssTokenKind.Whitespace, " "), + (CssTokenKind.Colon, ":"), + (CssTokenKind.Function, "is("), + (CssTokenKind.Hash, "#id"), + (CssTokenKind.Cdo, ""), + ], + Tokens("@media :is(#id") + ); + } + + /// The class selectors of , in source order. + private static IReadOnlyList Read(string css) + { + return RazorStylesheet.Parse("/app/Razor.razor.css", SourceText.From(css)).Classes; + } + + /// The names of the class selectors of . + private static IReadOnlyList Names(string css) + { + return [.. Read(css).Select(cssClass => cssClass.Name)]; + } + + /// Each token of with its text. + private static List<(CssTokenKind Kind, string Text)> Tokens(string css) + { + return + [ + .. CssTokenizer + .Tokenize(css) + .Select(token => (token.Kind, css[token.Span.Start..token.Span.End])), + ]; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/TextEditing.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/TextEditing.cs new file mode 100644 index 00000000..cceb5c79 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/TextEditing.cs @@ -0,0 +1,23 @@ +using Microsoft.CodeAnalysis.Text; + +namespace SharpLsp.Sidecar.CSharp.Tests; + +/// Applies a sidecar's text edits the way an editor does, for tests to read the result. +internal static class TextEditing +{ + /// with every one of applied at once. + internal static string ApplyEdits(string source, IEnumerable edits) + { + var text = SourceText.From(source); + var changes = edits.Select(edit => new TextChange(EditSpan(text, edit), edit.NewText)); + return text.WithChanges(changes).ToString(); + } + + /// The span of that replaces. + internal static TextSpan EditSpan(SourceText text, TextEditResult edit) + { + var start = text.Lines.GetPosition(new LinePosition(edit.StartLine, edit.StartCharacter)); + var end = text.Lines.GetPosition(new LinePosition(edit.EndLine, edit.EndCharacter)); + return TextSpan.FromBounds(start, end); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerExtraCoverageTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerExtraCoverageTests.cs index ffe709e7..159933bc 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerExtraCoverageTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerExtraCoverageTests.cs @@ -310,7 +310,7 @@ public async Task PrepareCallHierarchy_on_class_reports_class_kind() var item = Unwrap(await manager.PrepareCallHierarchyAsync(_sourcePath, line, character)); Assert.NotNull(item); - Assert.Equal("class", item!.Kind); + Assert.Equal("Class", item!.Kind); } [Fact] @@ -322,7 +322,7 @@ public async Task PrepareCallHierarchy_on_property_reports_property_kind() var item = Unwrap(await manager.PrepareCallHierarchyAsync(_sourcePath, line, character)); Assert.NotNull(item); - Assert.Equal("property", item!.Kind); + Assert.Equal("Property", item!.Kind); } [Fact] @@ -334,7 +334,7 @@ public async Task PrepareCallHierarchy_on_field_reports_field_kind() var item = Unwrap(await manager.PrepareCallHierarchyAsync(_sourcePath, line, character)); Assert.NotNull(item); - Assert.Equal("field", item!.Kind); + Assert.Equal("Field", item!.Kind); } [Fact] @@ -346,7 +346,7 @@ public async Task PrepareCallHierarchy_on_event_reports_event_kind() var item = Unwrap(await manager.PrepareCallHierarchyAsync(_sourcePath, line, character)); Assert.NotNull(item); - Assert.Equal("event", item!.Kind); + Assert.Equal("Event", item!.Kind); } [Fact] @@ -358,7 +358,7 @@ public async Task PrepareCallHierarchy_on_local_reports_variable_kind() var item = Unwrap(await manager.PrepareCallHierarchyAsync(_sourcePath, line, character)); Assert.NotNull(item); - Assert.Equal("variable", item!.Kind); + Assert.Equal("Variable", item!.Kind); } [Fact] @@ -370,7 +370,7 @@ public async Task PrepareCallHierarchy_on_parameter_reports_parameter_kind() var item = Unwrap(await manager.PrepareCallHierarchyAsync(_sourcePath, line, character)); Assert.NotNull(item); - Assert.Equal("parameter", item!.Kind); + Assert.Equal("Parameter", item!.Kind); } [Fact] @@ -382,7 +382,7 @@ public async Task TypeHierarchy_on_interface_reference_resolves_named_type() var item = Unwrap(await manager.PrepareTypeHierarchyAsync(_sourcePath, line, character)); Assert.NotNull(item); - Assert.Equal("interface", item!.Kind); + Assert.Equal("Interface", item!.Kind); } [Fact] diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerFeatureCoverageTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerFeatureCoverageTests.cs index 95df6766..9dcfb2eb 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerFeatureCoverageTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerFeatureCoverageTests.cs @@ -1,6 +1,7 @@ using Microsoft.CodeAnalysis.Text; using SharpLsp.Sidecar.Common; using SharpLsp.Sidecar.CSharp.Workspace; +using static SharpLsp.Sidecar.CSharp.Tests.TextEditing; #pragma warning disable CA1307 // StringComparison for Assert.Contains #pragma warning disable CA1515 // Types can be internal @@ -106,20 +107,6 @@ private static T Unwrap(Outcome.Result result) return result.Match(value => value, _ => throw new InvalidOperationException("error")); } - private static string ApplyEdits(string source, IEnumerable edits) - { - var text = SourceText.From(source); - var changes = edits.Select(edit => new TextChange(EditSpan(text, edit), edit.NewText)); - return text.WithChanges(changes).ToString(); - } - - private static TextSpan EditSpan(SourceText text, TextEditResult edit) - { - var start = text.Lines.GetPosition(new LinePosition(edit.StartLine, edit.StartCharacter)); - var end = text.Lines.GetPosition(new LinePosition(edit.EndLine, edit.EndCharacter)); - return TextSpan.FromBounds(start, end); - } - private static string ReplacedText(string source, TextEditResult edit) { var text = SourceText.From(source); @@ -476,7 +463,7 @@ public async Task PrepareCallHierarchy_on_method_returns_method_item() var item = Unwrap(result); Assert.NotNull(item); - Assert.Equal("method", item!.Kind); + Assert.Equal("Method", item!.Kind); Assert.Contains("Run", item.Name); } @@ -539,7 +526,7 @@ public async Task PrepareTypeHierarchy_on_class_returns_class_item() var item = Unwrap(result); Assert.NotNull(item); - Assert.Equal("class", item!.Kind); + Assert.Equal("Class", item!.Kind); Assert.Contains("Calculator", item.Name); } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerForeignRenameTests.cs b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerForeignRenameTests.cs index 180a2ef1..43db5069 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerForeignRenameTests.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp.Tests/WorkspaceManagerForeignRenameTests.cs @@ -1,6 +1,7 @@ using Microsoft.CodeAnalysis.Text; using SharpLsp.Sidecar.Common; using SharpLsp.Sidecar.CSharp.Workspace; +using static SharpLsp.Sidecar.CSharp.Tests.TextEditing; #pragma warning disable CA1307 // StringComparison overloads add no value to xUnit assertions #pragma warning disable CA1515 // Public xUnit discovery type @@ -197,17 +198,6 @@ int count Assert.Equal(count, edits.Select(EditKey).Distinct().Count()); } - private static string ApplyEdits(string source, IEnumerable edits) - { - var sourceText = SourceText.From(source); - foreach (var edit in edits.OrderByDescending(item => EditSpan(sourceText, item).Start)) - { - var span = EditSpan(sourceText, edit); - source = source.Remove(span.Start, span.Length).Insert(span.Start, edit.NewText); - } - return source; - } - private static string TextAtEdit(string source, TextEditResult edit) { var sourceText = SourceText.From(source); @@ -219,13 +209,6 @@ private static int CountOccurrences(string source, string value) return source.Split(value, StringSplitOptions.None).Length - 1; } - private static TextSpan EditSpan(SourceText source, TextEditResult edit) - { - var start = source.Lines.GetPosition(new LinePosition(edit.StartLine, edit.StartCharacter)); - var end = source.Lines.GetPosition(new LinePosition(edit.EndLine, edit.EndCharacter)); - return TextSpan.FromBounds(start, end); - } - private static string EditKey(TextEditResult edit) { return $"{edit.StartLine}:{edit.StartCharacter}-{edit.EndLine}:{edit.EndCharacter}"; diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.ForeignRename.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.ForeignRename.cs index 2405739d..227a36eb 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.ForeignRename.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.ForeignRename.cs @@ -1,3 +1,4 @@ +using SharpLsp.Sidecar.CSharp.Workspace; using ByteResult = Outcome.Result; namespace SharpLsp.Sidecar.CSharp; @@ -23,4 +24,22 @@ private Task HandleRenameForeignAsync(byte[] payload, CancellationTo ct ); } + + // Implements [REFERENCES-CROSS-LANGUAGE] and [RAZOR-FSHARP-REFERENCES]. + private Task HandleReferencesForeignAsync(byte[] payload, CancellationToken ct) + { + return HandleRequestAsync( + payload, + (ForeignSymbolRequest request) => + _workspace.GetForeignReferencesAsync( + new ForeignSymbolQuery( + request.AssemblyName, + request.XmlDocSig, + request.IncludeDeclaration + ), + ct + ), + ct + ); + } } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Razor.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Razor.cs index c1fdc929..1857b2ff 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Razor.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.Razor.cs @@ -34,6 +34,21 @@ private Task HandleLinkedEditingRangeAsync(byte[] payload, Cancellat ); } + // Implements [RAZOR-FEATURES-SELECTION]. + private Task HandleSelectionRangeAsync(byte[] payload, CancellationToken ct) + { + return HandleRequestAsync( + payload, + (SelectionRangeRequest request) => + _workspace.GetSelectionRangesAsync( + request.FilePath, + [.. request.Positions.Select(position => (position.Line, position.Character))], + ct + ), + ct + ); + } + // Implements [RAZOR-ARCHITECTURE-HTML]. private Task HandleRazorHtmlProjectionAsync(byte[] payload, CancellationToken ct) { @@ -68,3 +83,50 @@ internal sealed class HtmlProjectionResult [Key(0)] public string Text { get; set; } = ""; } + +/// The Razor file and the carets whose selection ranges to chain. [RAZOR-FEATURES-SELECTION] +[MessagePackObject(AllowPrivate = true)] +internal sealed class SelectionRangeRequest +{ + [Key(0)] + public string FilePath { get; set; } = ""; + + [Key(1)] + public List Positions { get; set; } = []; +} + +/// One caret of a . +[MessagePackObject(AllowPrivate = true)] +internal sealed class SelectionRangePosition +{ + [Key(0)] + public int Line { get; init; } + + [Key(1)] + public int Character { get; init; } +} + +/// One position's selection ranges, innermost first. [RAZOR-FEATURES-SELECTION] +[MessagePackObject(AllowPrivate = true)] +internal sealed class SelectionRangeResult +{ + [Key(0)] + public List Ranges { get; set; } = []; +} + +/// A range of the Razor file. +[MessagePackObject(AllowPrivate = true)] +internal sealed class SelectionSpanResult +{ + [Key(0)] + public int StartLine { get; init; } + + [Key(1)] + public int StartCharacter { get; init; } + + [Key(2)] + public int EndLine { get; init; } + + [Key(3)] + public int EndCharacter { get; init; } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.cs index 64758d9e..ff771db1 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/CSharpSidecar.cs @@ -48,6 +48,7 @@ public CSharpSidecar() Register("textDocument/documentSymbol", HandleDocumentSymbolAsync); Register("textDocument/foldingRange", HandleFoldingRangeAsync); Register("textDocument/linkedEditingRange", HandleLinkedEditingRangeAsync); + Register("textDocument/selectionRange", HandleSelectionRangeAsync); Register("textDocument/semanticTokens/full", HandleSemanticTokensFullAsync); Register("textDocument/semanticTokens/range", HandleSemanticTokensRangeAsync); Register("textDocument/inlayHint", HandleInlayHintAsync); @@ -55,6 +56,7 @@ public CSharpSidecar() Register("textDocument/rename", HandleRenameAsync); Register("textDocument/renameIdentity", HandleRenameIdentityAsync); Register("workspace/renameForeign", HandleRenameForeignAsync); + Register("workspace/referencesForeign", HandleReferencesForeignAsync); Register("project/unusedPackages", HandleUnusedPackagesAsync); Register("project/addPackage", HandleAddPackageAsync); Register("project/removePackage", HandleRemovePackageAsync); diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Debugging/HotReloadSession.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Debugging/HotReloadSession.cs index 51a7adfa..0c6621d2 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Debugging/HotReloadSession.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Debugging/HotReloadSession.cs @@ -5,6 +5,8 @@ using Microsoft.CodeAnalysis.Text; using Serilog; using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Razor; +using SharpLsp.Sidecar.CSharp.Workspace; namespace SharpLsp.Sidecar.CSharp.Debugging; @@ -135,21 +137,47 @@ private void DiscardStalePending() _pending = null; } + /// + /// The saved batch as one candidate solution. A Razor file is an ADDITIONAL document, + /// so its text swaps in every framework copy (`FindDocument` cannot see it), and the + /// SDK's Razor generator reruns when the emit reads the candidate's compilations. + /// Implements [RAZOR-HOT-RELOAD]. + /// private Solution BuildCandidate(IReadOnlyList documents) { var candidate = _solution; foreach (var document in documents) { - var target = FindDocument(candidate, document.FilePath); - candidate = candidate.WithDocumentText( - target.Id, - SourceText.From(document.NewText, Encoding.UTF8) - ); + var text = SourceText.From(document.NewText, Encoding.UTF8); + candidate = NativePaths.IsRazor(document.FilePath) + ? WithRazorText(candidate, document.FilePath, text) + : candidate.WithDocumentText(FindDocument(candidate, document.FilePath).Id, text); } return candidate; } + /// + /// The saved Razor file's text in EVERY framework copy of the additional document, the + /// same swap a live edit makes. A file no project lists has no semantics and fails + /// loudly. Implements [RAZOR-HOT-RELOAD] and [RAZOR-ARCHITECTURE-DOCUMENTS]. + /// + private static Solution WithRazorText(Solution solution, string filePath, SourceText text) + { + var path = NativePaths.WithLinksResolved(filePath); + _ = ListedRazor(solution, path); + return TargetFrameworks.WithAdditionalTextInEveryContext(solution, path, text); + } + + /// The additional document at ; null only when no project lists it. + private static TextDocument ListedRazor(Solution solution, string path) + { + return SolutionPaths.AdditionalDocumentsAt(solution, path).FirstOrDefault() + ?? throw new InvalidOperationException( + $"Hot reload Razor file is not listed by any project: {path}" + ); + } + private async Task ChangedSignatureAsync( Solution candidate, IReadOnlyList documents, @@ -158,10 +186,11 @@ CancellationToken ct { foreach (var document in documents) { - var baseline = FindDocument(_solution, document.FilePath); - var changed = await HotReloadSignatureGuard - .ChangedMethodSignatureAsync(baseline, candidate.GetDocument(baseline.Id)!, ct) - .ConfigureAwait(false); + var changed = NativePaths.IsRazor(document.FilePath) + ? await ChangedRazorSignatureAsync(candidate, document.FilePath, ct) + .ConfigureAwait(false) + : await ChangedDocumentSignatureAsync(candidate, document.FilePath, ct) + .ConfigureAwait(false); if (changed is not null) { return changed; @@ -171,12 +200,54 @@ CancellationToken ct return null; } + private Task ChangedDocumentSignatureAsync( + Solution candidate, + string filePath, + CancellationToken ct + ) + { + var baseline = FindDocument(_solution, filePath); + return HotReloadSignatureGuard.ChangedMethodSignatureAsync( + baseline, + candidate.GetDocument(baseline.Id)!, + ct + ); + } + + /// + /// The signature guard judges a Razor save through the GENERATED documents whose text + /// changed — the baseline versus candidate projection (`RazorProjection.ForRazorAsync`) + /// — never by parsing Razor text. A project that generates no projection has no C# a + /// Razor save could reshape, so the guard abstains and Roslyn's own analysis decides. + /// Implements [RAZOR-HOT-RELOAD] and [RAZOR-ARCHITECTURE-PROJECTION]. + /// + private async Task ChangedRazorSignatureAsync( + Solution candidate, + string filePath, + CancellationToken ct + ) + { + var path = NativePaths.WithLinksResolved(filePath); + var before = await RazorProjection + .ForRazorAsync(ListedRazor(_solution, path), ct) + .ConfigureAwait(false); + var after = await RazorProjection + .ForRazorAsync(ListedRazor(candidate, path), ct) + .ConfigureAwait(false); + return before is null || after is null + ? null + : await HotReloadSignatureGuard + .ChangedMethodSignatureAsync(before.Generated, after.Generated, ct) + .ConfigureAwait(false); + } + /// /// Judge one emit. Errors mean nothing can be applied: rude edits (ENC0xxx) /// require a restart, while compiler errors and Roslyn's operational /// ENC1xxx diagnostics resolve on a later save. A ready emit with deltas /// stays pending for the client's commit or discard; a ready emit with - /// nothing to deliver commits immediately. + /// nothing to deliver, and an emit that names nothing to apply, commit + /// immediately. /// private HotReloadResponse Verdict(EncEmitResult emitted, Solution candidate) { @@ -190,13 +261,25 @@ private HotReloadResponse Verdict(EncEmitResult emitted, Solution candidate) Log.Warning("Hot reload {Status}: {Diagnostics}", status, rendered); return Response(status, [], [.. errors.Select(HotReloadEncService.Render)]); } - if (!emitted.Ready) - { - Log.Warning("Hot reload emit was not ready and named no diagnostic"); - return Response("notCompilable", [], ["The change could not be applied; save again."]); - } + return !emitted.Ready + ? QuietlyApplied(candidate, rendered) + : Emitted(emitted, candidate, rendered); + } - return Emitted(emitted, candidate, rendered); + /// + /// An emit Roslyn answered with Status None, which a not-ready emit with no error is: its + /// solution diff found no changed, added or removed document — regular, additional or + /// source-generated — so the candidate changes nothing at runtime (a re-save of committed + /// text). Roslyn holds no pending update to commit or discard, and its deltas are empty + /// unless ready, so the baseline advances and the save answers applied; a save-again + /// error would send the client into a pointless retry loop. Implements [RAZOR-HOT-RELOAD] + /// and [DEBUG-FEATURES-HOT-RELOAD]. + /// + private HotReloadResponse QuietlyApplied(Solution candidate, List warnings) + { + _solution = candidate; + Log.Information("Hot reload emit had nothing to apply; committed quietly"); + return Response("applied", [], warnings); } /// A ready emit: hold deltas pending, or self-commit an empty one. diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Debugging/HotReloadSignatureGuard.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Debugging/HotReloadSignatureGuard.cs index 77a5f121..e219aaa5 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Debugging/HotReloadSignatureGuard.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Debugging/HotReloadSignatureGuard.cs @@ -81,11 +81,11 @@ private static MethodShape ShapeOf(MethodDeclarationSyntax method) } /// - /// Strip everything that is legal to edit under hot reload from the + /// Strip what is legal to edit under hot reload from the /// declaration: the body, attributes, parameter names, parameter /// defaults, and nullable annotations (reference-type annotations are /// metadata-only; value-type ones that DO change the signature still fall - /// through to Roslyn's real analysis). + /// through to Roslyn's real analysis). [RAZOR-HOT-RELOAD] /// private static MethodDeclarationSyntax Normalize(MethodDeclarationSyntax method) { @@ -98,7 +98,7 @@ private static MethodDeclarationSyntax Normalize(MethodDeclarationSyntax method) stripped = stripped.WithParameterList( stripped.ParameterList.WithParameters(SyntaxFactory.SeparatedList(parameters)) ); - return (MethodDeclarationSyntax)new NullableAnnotationStripper().Visit(stripped); + return (MethodDeclarationSyntax)new SignatureIgnorer().Visit(stripped); } private static ParameterSyntax NormalizeParameter(ParameterSyntax parameter) @@ -128,12 +128,23 @@ private static string ContainerOf(MethodDeclarationSyntax method) ); } - /// Replace every `T?` with `T`, recursively. - private sealed class NullableAnnotationStripper : CSharpSyntaxRewriter + /// + /// Strip what cannot distinguish two versions of one method: every `T?` + /// becomes `T`, recursively, and every trivia disappears — a doc comment + /// or comment edit, and the `#line` and checksum directives a Razor + /// projection's generated document carries, which a markup edit shifts for + /// every method, never touch a signature. [RAZOR-HOT-RELOAD] + /// + private sealed class SignatureIgnorer : CSharpSyntaxRewriter { public override SyntaxNode? VisitNullableType(NullableTypeSyntax node) { return Visit(node.ElementType); } + + public override SyntaxTrivia VisitTrivia(SyntaxTrivia trivia) + { + return default; + } } } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/ForeignRenameMessages.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/ForeignRenameMessages.cs index c68d5905..c090a1c9 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/ForeignRenameMessages.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/ForeignRenameMessages.cs @@ -28,3 +28,17 @@ internal sealed class RenameForeignRequest [Key(2)] public string NewName { get; set; } = ""; } + +/// A symbol another sidecar names by identity. [REFERENCES-CROSS-LANGUAGE] +[MessagePackObject(AllowPrivate = true)] +internal sealed class ForeignSymbolRequest +{ + [Key(0)] + public string AssemblyName { get; set; } = ""; + + [Key(1)] + public string XmlDocSig { get; set; } = ""; + + [Key(2)] + public bool IncludeDeclaration { get; init; } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Messages.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Messages.cs index 311f7870..25703b64 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Messages.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Messages.cs @@ -116,6 +116,13 @@ internal sealed class LocationResult [Key(4)] public int EndCharacter { get; init; } + + /// + /// For decompiled source of a symbol an F# project declares, the symbol's identity, so the + /// host can ask the F# sidecar for its F# declaration. [DEFINITION-CROSSLANG-IDENTITY] + /// + [Key(5)] + public RenameIdentityResultWire? Symbol { get; init; } } [MessagePackObject(AllowPrivate = true)] @@ -288,6 +295,10 @@ internal sealed class DocumentEditResult [Key(1)] public List Edits { get; set; } = []; + + /// Whether the edit creates the file: it holds the new file's whole text. [RAZOR-CODE-ACTIONS] + [Key(2)] + public bool Create { get; init; } } [MessagePackObject(AllowPrivate = true)] diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/CssTokenizer.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/CssTokenizer.cs new file mode 100644 index 00000000..3437a10c --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/CssTokenizer.cs @@ -0,0 +1,377 @@ +using System.Text; +using Microsoft.CodeAnalysis.Text; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// The kinds of token the CSS Syntax Module Level 3 algorithm produces. +internal enum CssTokenKind +{ + /// A name: an element, a class after its ., a property, a unit. + Ident, + + /// A name directly followed by (: :is(, var(. + Function, + + /// An at-rule's name, its @ included. + AtKeyword, + + /// A # and the name after it. + Hash, + + /// A quoted string, its quotes included. + String, + + /// An unquoted url(…), its parentheses included. + Url, + + /// A number, dimension or percentage. + Number, + + /// Any other single character: ., &, >, *. + Delim, + + /// Spaces, tabs and line breaks. + Whitespace, + + /// :. + Colon, + + /// ;. + Semicolon, + + /// ,. + Comma, + + /// (. + ParenOpen, + + /// ). + ParenClose, + + /// [. + BracketOpen, + + /// ]. + BracketClose, + + /// {. + BraceOpen, + + /// }. + BraceClose, + + /// <!--. + Cdo, + + /// -->. + Cdc, +} + +/// A CSS token: its kind and its span in the stylesheet. [RAZOR-COLLOCATION-CSS] +internal readonly record struct CssToken(CssTokenKind Kind, TextSpan Span); + +/// +/// The CSS Syntax Module Level 3 tokenizer (https://www.w3.org/TR/css-syntax-3/#tokenization): +/// comments, strings and idents with escapes, numbers and dimensions, unquoted urls, +/// at-keywords, hashes and every bracket. Comments are dropped, as the specification has a +/// parser do, and a . inside a string, a comment, a url, a number or a declaration value +/// is never a selector's dot. [RAZOR-COLLOCATION-CSS] +/// +internal static class CssTokenizer +{ + /// The tokens of , comments excluded. + public static List Tokenize(string css) + { + var tokens = new List(); + var at = 0; + while (at < css.Length) + { + if (Starts(css, at, "/*")) + { + at = CommentEnd(css, at); + continue; + } + + var (kind, end) = Token(css, at); + tokens.Add(new CssToken(kind, TextSpan.FromBounds(at, end))); + at = Math.Max(end, at + 1); + } + + return tokens; + } + + /// An ident token's value, its escapes decoded. + public static string Value(SourceText css, CssToken token) + { + return token.Kind == CssTokenKind.Ident ? Unescape(css, token.Span) : ""; + } + + /// Whether holds at . + internal static bool Starts(string css, int at, string text) + { + return at >= 0 + && at + text.Length <= css.Length + && string.CompareOrdinal(css, at, text, 0, text.Length) == 0; + } + + /// Whether a name (an ident, a hash or an at-keyword) starts at . + internal static bool StartsName(string css, int at) + { + return at < css.Length + && (IsNameStart(css[at]) || StartsEscape(css, at) || StartsDashName(css, at)); + } + + /// Where the name starting at ends, its escapes included. + internal static int NameEnd(string css, int at) + { + var end = at; + while (end < css.Length) + { + if (IsName(css[end])) + { + end++; + continue; + } + + if (!StartsEscape(css, end)) + { + break; + } + + end = EscapeEnd(css, end); + } + + return end; + } + + private static bool StartsDashName(string css, int at) + { + return css[at] == '-' + && at + 1 < css.Length + && (IsNameStart(css[at + 1]) || css[at + 1] == '-' || StartsEscape(css, at + 1)); + } + + private static (CssTokenKind Kind, int End) Token(string css, int at) + { + var c = css[at]; + return c switch + { + _ when IsSpace(c) => (CssTokenKind.Whitespace, Run(css, at, IsSpace)), + '"' or '\'' => (CssTokenKind.String, StringEnd(css, at)), + '#' => Named(css, at, CssTokenKind.Hash), + '@' => Named(css, at, CssTokenKind.AtKeyword), + '<' when Starts(css, at, "") => (CssTokenKind.Cdc, at + 3), + _ when StartsNumber(css, at) => (CssTokenKind.Number, NumberEnd(css, at)), + _ when StartsName(css, at) => IdentLike(css, at), + ':' => (CssTokenKind.Colon, at + 1), + ';' => (CssTokenKind.Semicolon, at + 1), + ',' => (CssTokenKind.Comma, at + 1), + '(' => (CssTokenKind.ParenOpen, at + 1), + ')' => (CssTokenKind.ParenClose, at + 1), + '[' => (CssTokenKind.BracketOpen, at + 1), + ']' => (CssTokenKind.BracketClose, at + 1), + '{' => (CssTokenKind.BraceOpen, at + 1), + '}' => (CssTokenKind.BraceClose, at + 1), + _ => (CssTokenKind.Delim, at + 1), + }; + } + + /// A hash or an at-keyword: its sigil and the name after it. + private static (CssTokenKind Kind, int End) Named(string css, int at, CssTokenKind kind) + { + return (kind, StartsName(css, at + 1) ? NameEnd(css, at + 1) : at + 1); + } + + /// An ident, a function, or an unquoted url, which the name alone decides. + private static (CssTokenKind Kind, int End) IdentLike(string css, int at) + { + var end = NameEnd(css, at); + return end < css.Length && css[end] == '(' + ? Opener(css, at, end) + : (CssTokenKind.Ident, end); + } + + private static (CssTokenKind Kind, int End) Opener(string css, int at, int paren) + { + return css[at..paren].Equals("url", StringComparison.OrdinalIgnoreCase) + ? Url(css, paren) + : (CssTokenKind.Function, paren + 1); + } + + /// + /// url( with a quoted argument is a function whose string follows it; an unquoted one + /// is a single url token, so a dot inside it is never a selector's. + /// + private static (CssTokenKind Kind, int End) Url(string css, int paren) + { + var at = Run(css, paren + 1, IsSpace); + return at < css.Length && css[at] is '"' or '\'' + ? (CssTokenKind.Function, paren + 1) + : (CssTokenKind.Url, UrlEnd(css, at)); + } + + private static int UrlEnd(string css, int at) + { + while (at < css.Length && css[at] != ')') + { + at += css[at] == '\\' ? 2 : 1; + } + + return Math.Min(at + 1, css.Length); + } + + /// A number, a dimension or a percentage; a dimension's unit is part of it. + private static int NumberEnd(string css, int at) + { + at += css[at] is '+' or '-' ? 1 : 0; + at = Fraction(css, Run(css, at, char.IsAsciiDigit)); + at = Exponent(css, at); + return StartsName(css, at) + ? NameEnd(css, at) + : at + (at < css.Length && css[at] == '%' ? 1 : 0); + } + + private static int Fraction(string css, int at) + { + return at + 1 < css.Length && css[at] == '.' && char.IsAsciiDigit(css[at + 1]) + ? Run(css, at + 1, char.IsAsciiDigit) + : at; + } + + private static int Exponent(string css, int at) + { + var sign = at + 1 < css.Length && css[at + 1] is '+' or '-' ? at + 2 : at + 1; + return + at < css.Length + && css[at] is 'e' or 'E' + && sign < css.Length + && char.IsAsciiDigit(css[sign]) + ? Run(css, sign, char.IsAsciiDigit) + : at; + } + + /// A string's end, the first unescaped matching quote or the file's end. + private static int StringEnd(string css, int at) + { + var quote = css[at]; + at++; + while (at < css.Length && css[at] != quote) + { + at += css[at] == '\\' ? 2 : 1; + } + + return Math.Min(at + 1, css.Length); + } + + private static int CommentEnd(string css, int at) + { + var close = css.IndexOf("*/", at + 2, StringComparison.Ordinal); + return close < 0 ? css.Length : close + 2; + } + + /// An ident's value, its escapes decoded as CSS Syntax §3.3 has them. + private static string Unescape(SourceText css, TextSpan span) + { + var builder = new StringBuilder(span.Length); + var at = span.Start; + while (at < span.End) + { + var (text, next) = css[at] == '\\' ? Escape(css, at) : (css[at].ToString(), at + 1); + _ = builder.Append(text); + at = next; + } + + return builder.ToString(); + } + + /// The character the escape at denotes, and the index after it. + private static (string Text, int Next) Escape(SourceText css, int slash) + { + var at = slash + 1; + return at >= css.Length ? ("", at) + : char.IsAsciiHexDigit(css[at]) ? HexEscape(css, at) + : css[at] is '\n' or '\r' or '\f' ? ("", at + 1) + : (css[at].ToString(), at + 1); + } + + private static (string Text, int Next) HexEscape(SourceText css, int at) + { + var end = at; + while (end < css.Length && end - at < 6 && char.IsAsciiHexDigit(css[end])) + { + end++; + } + + var code = Convert.ToInt32(css.ToString(TextSpan.FromBounds(at, end)), 16); + var next = end < css.Length && IsSpace(css[end]) ? end + 1 : end; + return ( + code is 0 or > 0x10FFFF or (>= 0xD800 and <= 0xDFFF) + ? "\uFFFD" + : char.ConvertFromUtf32(code), + next + ); + } + + /// Where the escape at ends, its terminator included. + private static int EscapeEnd(string css, int slash) + { + var at = slash + 1; + if (at >= css.Length || !char.IsAsciiHexDigit(css[at])) + { + return at + 1; + } + + while (at < css.Length && at - slash <= 6 && char.IsAsciiHexDigit(css[at])) + { + at++; + } + + return at < css.Length && IsSpace(css[at]) ? at + 1 : at; + } + + private static bool StartsNumber(string css, int at) + { + var c = css[at]; + var next = at + 1 < css.Length ? css[at + 1] : '\0'; + return char.IsAsciiDigit(c) + || (c is '+' or '-' or '.' && char.IsAsciiDigit(next)) + || ( + c is '+' or '-' + && next == '.' + && at + 2 < css.Length + && char.IsAsciiDigit(css[at + 2]) + ); + } + + private static int Run(string css, int at, Func keep) + { + var end = at; + while (end < css.Length && keep(css[end])) + { + end++; + } + + return end; + } + + private static bool IsName(char c) + { + return IsNameStart(c) || char.IsAsciiDigit(c) || c == '-'; + } + + private static bool IsNameStart(char c) + { + return char.IsAsciiLetter(c) || c == '_' || c > 0x7F; + } + + private static bool StartsEscape(string css, int at) + { + return at + 1 < css.Length && css[at] == '\\' && css[at + 1] is not ('\n' or '\r' or '\f'); + } + + private 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/HtmlMarkupContext.Attributes.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/HtmlMarkupContext.Attributes.cs new file mode 100644 index 00000000..a8a5fddc --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/HtmlMarkupContext.Attributes.cs @@ -0,0 +1,108 @@ +using Microsoft.CodeAnalysis.Text; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// The attribute at a caret, read by the same tokenizer as the caret's context: the scan up to +/// the caret, continued until the attribute it reads ends. Implements [RAZOR-ARCHITECTURE-HTML] +/// for [RAZOR-COLLOCATION-CSS]. +/// +internal static partial class HtmlMarkupContext +{ + /// + /// The attribute the tokenizer reads at , from its name to its + /// value's end: its name, its name's span and its value's span, the quotes excluded. Null + /// outside a start tag's attribute, and once the tokenizer has left one. [RAZOR-COLLOCATION-CSS] + /// + public static (string Name, TextSpan NameSpan, TextSpan? Value)? AttributeAt( + string html, + int offset + ) + { + var at = Math.Clamp(offset, 0, html.Length); + var caret = ScanTo(html, at); + var scan = Finish(caret, html, at); + return caret.AttributeStart < 0 || caret.EndTag || !IsReadingAttribute(caret.State) + ? null + : ( + html[scan.AttributeStart..scan.NameEnd], + TextSpan.FromBounds(scan.AttributeStart, scan.NameEnd), + scan.ValueStart < 0 ? null : TextSpan.FromBounds(scan.ValueStart, scan.ValueEnd) + ); + } + + /// Records where the attribute name or value a transition enters or leaves begins or ends. + private static Scan Track(Scan before, Scan after, int at) + { + return (before.State, after.State) switch + { + (not Scanner.Attribute, Scanner.Attribute) => after with + { + AttributeStart = at, + NameEnd = -1, + ValueStart = -1, + ValueEnd = -1, + }, + (Scanner.Attribute, not Scanner.Attribute) => after with { NameEnd = at }, + (Scanner.BeforeValue, Scanner.ValueUnquoted) => after with { ValueStart = at }, + (Scanner.BeforeValue, Scanner.ValueDoubleQuoted or Scanner.ValueSingleQuoted) => + after with + { + ValueStart = at + 1, + }, + ( + Scanner.ValueDoubleQuoted + or Scanner.ValueSingleQuoted + or Scanner.ValueUnquoted, + Scanner.AfterValue + or Scanner.BeforeAttribute + ) => after with + { + ValueEnd = at, + }, + _ => after, + }; + } + + /// + /// The scan carried past until the attribute it reads ends: its tag + /// closes, the next attribute starts, or the projection ends. + /// + private static Scan Finish(Scan scan, string html, int i) + { + while (i < html.Length && IsReadingAttribute(scan.State)) + { + var (next, j) = Advance(scan, html, i, html.Length); + if (next.AttributeStart != scan.AttributeStart) + { + break; + } + + (scan, i) = (next, j); + } + + return Ended(scan, i); + } + + /// The scan with the name or value it still reads ending at . + private static Scan Ended(Scan scan, int at) + { + return scan with + { + NameEnd = scan.NameEnd < 0 ? at : scan.NameEnd, + ValueEnd = scan.ValueStart >= 0 && scan.ValueEnd < 0 ? at : scan.ValueEnd, + }; + } + + /// Whether the tokenizer is inside an attribute, from its name to its value's end. + private static bool IsReadingAttribute(Scanner state) + { + return state + is Scanner.Attribute + or Scanner.AfterAttribute + or Scanner.BeforeValue + or Scanner.ValueDoubleQuoted + or Scanner.ValueSingleQuoted + or Scanner.ValueUnquoted; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/HtmlMarkupContext.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/HtmlMarkupContext.cs index a7b9633f..0015108d 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/HtmlMarkupContext.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/HtmlMarkupContext.cs @@ -25,7 +25,7 @@ internal enum MarkupContext /// prefix up to the caret. Pure — every step maps an immutable state and an input character to /// the next state. Implements [RAZOR-ARCHITECTURE-HTML]. /// -internal static class HtmlMarkupContext +internal static partial class HtmlMarkupContext { /// The markup context at ; out-of-range offsets clamp. public static MarkupContext At(string html, int offset) @@ -33,6 +33,16 @@ public static MarkupContext At(string html, int offset) return Context(ScanTo(html, Math.Clamp(offset, 0, html.Length))); } + /// + /// The name of the start tag whose attributes hold , lowercased as + /// the tokenizer reads it; null outside a start tag's attributes. [RAZOR-FEATURES-COMPLETION] + /// + public static string? StartTagAt(string html, int offset) + { + var scan = ScanTo(html, Math.Clamp(offset, 0, html.Length)); + return Context(scan) == MarkupContext.AttributeName && !scan.EndTag ? scan.Tag : null; + } + /// The tokenizer states the caret context derives from. private enum Scanner { @@ -57,7 +67,19 @@ private enum Scanner /// The tokenizer state. /// The current start tag's name; the raw-text target while in . /// Whether the current tag is an end tag. - private readonly record struct Scan(Scanner State, string Tag, bool EndTag); + /// Where the current attribute's name begins; -1 when no start tag has one. + /// Where the current attribute's name ends; -1 while the tokenizer reads it. + /// Where the current attribute's value begins, its quote excluded; -1 before it. + /// Where the current attribute's value ends, its quote excluded; -1 while it is read. + private readonly record struct Scan( + Scanner State, + string Tag, + bool EndTag, + int AttributeStart = -1, + int NameEnd = -1, + int ValueStart = -1, + int ValueEnd = -1 + ); private static Scan ScanTo(string html, int end) { @@ -73,7 +95,7 @@ private static Scan ScanTo(string html, int end) private static (Scan Next, int Index) Advance(Scan s, string html, int i, int end) { - return (s.State, html[i]) switch + var (next, index) = (s.State, html[i]) switch { (Scanner.TagOpen, '!') => Declaration(html, i), (Scanner.TagOpen, '?') => (Data(), Bogus(html, i + 1)), @@ -86,6 +108,7 @@ private static (Scan Next, int Index) Advance(Scan s, string html, int i, int en (Scanner.RawText, _) => RawText(s, html, i, end), _ => (Step(s, html[i]), i + 1), }; + return (Track(s, next, i), index); } private static Scan Step(Scan s, char c) @@ -164,7 +187,7 @@ private static Scan Attribute(Scan s, char c) '>' => Close(s), '/' => Kept(Scanner.SelfClosing, s), '=' => Kept(Scanner.BeforeValue, s), - _ => IsSpace(c) ? Kept(Scanner.AfterAttribute, s) : s, + _ => IsSpace(c) ? Kept(Scanner.AfterAttribute, s) : Kept(Scanner.Attribute, s), }; } @@ -356,7 +379,7 @@ private static Scan Data(Scanner state = Scanner.Data) private static Scan Kept(Scanner state, Scan s) { - return new(state, s.Tag, s.EndTag); + return s with { State = state }; } private static Scan Named(Scan s, char c, bool endTag) diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCollocation.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCollocation.cs new file mode 100644 index 00000000..4260de14 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCollocation.cs @@ -0,0 +1,240 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Workspace; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// A Razor file's collocated files: the stylesheet that scopes a component's CSS +/// (Card.razor.css) and the JavaScript module it imports (Card.razor.js). The +/// stylesheet's classes complete and define inside a class attribute value, and an +/// InvokeAsync<IJSObjectReference>("import", "…") call defines to its module. +/// Implements [RAZOR-COLLOCATION]. +/// +internal static class RazorCollocation +{ + /// The types whose InvokeAsync imports a JavaScript module. + private static readonly string[] JruntimeTypes = + [ + "Microsoft.JSInterop.IJSRuntime", + "Microsoft.JSInterop.IJSObjectReference", + ]; + + /// + /// The class attribute value holding , with the class name + /// the caret is in: the whole token between whitespace, not the whole value. Null wherever the + /// attribute is not a plain HTML class, a directive attribute and a component's own + /// parameter never counting. [RAZOR-COLLOCATION-CSS] + /// + public static (TextSpan Value, TextSpan Class)? ClassValueAt(RazorMarkup markup, int offset) + { + return + HtmlMarkupContext.AttributeAt(markup.HtmlText, offset) is { } attribute + && IsHtmlClass(markup, attribute) + && attribute.Value is { } value + && value.Start <= offset + && offset <= value.End + ? (value, TokenAt(markup.HtmlText, value, offset)) + : null; + } + + /// + /// The items for the classes declares, each once, replacing the + /// class name at the caret and naming the stylesheet. [RAZOR-COLLOCATION-CSS] + /// + public static List ClassCompletions( + RazorMarkup markup, + RazorStylesheet stylesheet, + (TextSpan Value, TextSpan Class) at + ) + { + return + [ + .. stylesheet.Names.Select(name => new CompletionItem + { + Label = name, + Kind = "Value", + Detail = NativePaths.NameOf(stylesheet.FilePath), + SortText = name, + Index = -1, + TextEdit = DocumentText.ToTextEdit(markup.Text, new TextChange(at.Class, name)), + }), + ]; + } + + /// + /// The definitions of the class name at the caret: every selector occurrence of that class in + /// the stylesheet, each ranged on the name after its .. [RAZOR-COLLOCATION-CSS] + /// + public static LocationListResult ClassDefinitions( + RazorMarkup markup, + RazorStylesheet stylesheet, + (TextSpan Value, TextSpan Class) at + ) + { + var name = markup.Text.ToString(at.Class); + return new LocationListResult + { + Locations = + [ + .. stylesheet + .Classes.Where(cssClass => cssClass.Name == name) + .Select(cssClass => ToLocation(stylesheet, cssClass)), + ], + }; + } + + /// The name a module import at passes to InvokeAsync; null elsewhere. + public static LiteralExpressionSyntax? ModuleLiteral(SyntaxNode root, int offset) + { + var token = root.FindToken(Math.Clamp(offset, 0, root.FullSpan.End)); + return + token.Parent is LiteralExpressionSyntax literal + && literal.IsKind(SyntaxKind.StringLiteralExpression) + && literal.Span.Contains(offset) + ? literal + : null; + } + + /// + /// The module path names, when the call it is part of passes the + /// constant "import" to an IJSRuntime or IJSObjectReference + /// InvokeAsync. Resolved with the semantic model, never by reading text. + /// [RAZOR-COLLOCATION-JS] + /// + public static string? ImportedModule( + SemanticModel model, + LiteralExpressionSyntax literal, + CancellationToken ct + ) + { + return + ImportCall(literal) is { } call + && ImportsModule(model, call, ct) + && model.GetConstantValue(literal, ct) is { HasValue: true, Value: string path } + ? path + : null; + } + + /// + /// The file a module path names: the leading ./ and the app's base path both resolve + /// against the project directory, and only a .razor.js or .cshtml.js is a module. + /// [RAZOR-COLLOCATION-JS] + /// + public static string? ModuleFile(string projectDirectory, string path) + { + return + NativePaths.FromModulePath(projectDirectory, path) is { } resolved + && ( + NativePaths.HasExtension(resolved, ".razor.js") + || NativePaths.HasExtension(resolved, ".cshtml.js") + ) + ? resolved + : null; + } + + /// Whether the attribute names the plain HTML class, unbound by any tag helper. + private static bool IsHtmlClass( + RazorMarkup markup, + (string Name, TextSpan NameSpan, TextSpan? Value) attribute + ) + { + return string.Equals(attribute.Name, "class", StringComparison.OrdinalIgnoreCase) + && !markup.Attributes.Any(fact => + fact.Kind != MarkupAttributeKind.Html + && fact.NameSpan is { } name + && name.IntersectsWith(attribute.NameSpan) + ); + } + + /// The whole class name holding , kept inside the value. + private static TextSpan TokenAt(string html, TextSpan value, int offset) + { + var start = Math.Clamp(offset, value.Start, value.End); + var end = start; + while (start > value.Start && !HtmlMarkupContext.IsSpace(html[start - 1])) + { + start--; + } + + while (end < value.End && !HtmlMarkupContext.IsSpace(html[end])) + { + end++; + } + + return TextSpan.FromBounds(start, end); + } + + private static LocationResult ToLocation(RazorStylesheet stylesheet, CssClass cssClass) + { + var start = stylesheet.Text.Lines.GetLinePosition(cssClass.Span.Start); + var end = stylesheet.Text.Lines.GetLinePosition(cssClass.Span.End); + return new LocationResult + { + FilePath = stylesheet.FilePath, + Line = start.Line, + Character = start.Character, + EndLine = end.Line, + EndCharacter = end.Character, + }; + } + + /// The call the literal is the second argument of; null when it is not one. + private static InvocationExpressionSyntax? ImportCall(LiteralExpressionSyntax literal) + { + return + literal.Ancestors().OfType().FirstOrDefault() + is { Parent: ArgumentListSyntax list } argument + && list.Arguments.IndexOf(argument) == 1 + && list.Parent is InvocationExpressionSyntax call + ? call + : null; + } + + /// + /// Whether the call is an InvokeAsync of a JavaScript runtime whose first argument is the + /// constant "import". + /// + private static bool ImportsModule( + SemanticModel model, + InvocationExpressionSyntax call, + CancellationToken ct + ) + { + return model.GetConstantValue(call.ArgumentList.Arguments[0].Expression, ct) + is { HasValue: true, Value: "import" } + && model.GetSymbolInfo(call, ct).Symbol is IMethodSymbol method + && method.Name == "InvokeAsync" + && IsRuntime(model.Compilation, method); + } + + /// + /// Whether the call runs on a JavaScript runtime: its declaring type, or its receiver, is + /// IJSRuntime or IJSObjectReference. The params object?[] overload is an + /// extension method called on the runtime, so its receiver is the runtime; called as a static + /// method it passes the path third, which no import names. + /// + private static bool IsRuntime(Compilation compilation, IMethodSymbol method) + { + var runtimes = JruntimeTypes + .Select(compilation.GetTypeByMetadataName) + .OfType(); + return new ITypeSymbol?[] { method.ContainingType, method.ReceiverType }.Any(receiver => + runtimes.Any(runtime => IsOrImplements(receiver, runtime)) + ); + } + + private static bool IsOrImplements(ITypeSymbol? type, INamedTypeSymbol runtime) + { + return type is not null + && ( + SymbolEqualityComparer.Default.Equals(type, runtime) + || type.AllInterfaces.Any(iface => + SymbolEqualityComparer.Default.Equals(iface, runtime) + ) + ); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCompletionEdits.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCompletionEdits.cs index eccd1d7f..1e835447 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCompletionEdits.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorCompletionEdits.cs @@ -9,7 +9,8 @@ namespace SharpLsp.Sidecar.CSharp.Razor; /// /// A completion's additional edits, moved from a projection into its Razor file: an edit /// inside one C# fragment maps across, an added C# using becomes an @using line -/// after the file's own, and anything else cannot be placed and is dropped. +/// after the file's own (else after its @page, which a Razor Page must open with), and +/// anything else cannot be placed and is dropped. /// Implements [RAZOR-ARCHITECTURE-PROJECTION] and [RAZOR-FEATURES-COMPLETION]. /// internal static class RazorCompletionEdits @@ -17,7 +18,8 @@ internal static class RazorCompletionEdits /// The Razor edits for computed on 's projection. public static IEnumerable Map( RazorProjection origin, - IEnumerable changes + IEnumerable changes, + RazorMarkup? markup ) { var existing = OwnUsings(origin).Select(Spelling).ToHashSet(StringComparer.Ordinal); @@ -34,7 +36,7 @@ IEnumerable changes foreach (var added in AddedUsings(change).Where(existing.Add)) { - yield return UsingLine(origin, added); + yield return UsingLine(origin, added, markup); } } } @@ -72,25 +74,32 @@ private static string Spelling(UsingDirectiveSyntax directive) return isStatic + alias + directive.NamespaceOrType.ToString(); } - /// An @using line inserted after the file's last own using, else at its start. - private static TextEditResult UsingLine(RazorProjection origin, string spelling) + /// + /// An @using line inserted after the file's last own using, else after its + /// @page directive, else at its start. [RAZOR-CODE-ACTIONS-USING] + /// + /// The line holding the last character of , which may end in its line break. + private static int LastLineOf(SourceText text, TextSpan span) { - var after = OwnUsings(origin) + return text.Lines.GetLineFromPosition(Math.Max(span.Start, span.End - 1)).LineNumber; + } + + private static TextEditResult UsingLine( + RazorProjection origin, + string spelling, + RazorMarkup? markup + ) + { + var usings = OwnUsings(origin) .Select(directive => directive.NamespaceOrType.GetLocation().GetMappedLineSpan()) - .Select(span => span.EndLinePosition.Line + 1) - .DefaultIfEmpty(0) - .Max(); + .Select(span => span.EndLinePosition.Line + 1); + var page = (markup?.Directives ?? []) + .Where(directive => directive.Name == "page") + .Select(directive => LastLineOf(origin.RazorText, directive.Span) + 1); + var after = usings.Concat(page).DefaultIfEmpty(0).Max(); var line = Math.Min(after, origin.RazorText.Lines.Count - 1); var at = origin.RazorText.Lines[line].Start; - var text = "@using " + spelling + LineBreak(origin.RazorText); + var text = "@using " + spelling + RazorHandlerText.LineBreak(origin.RazorText); return DocumentText.ToTextEdit(origin.RazorText, new TextChange(new TextSpan(at, 0), text)); } - - private static string LineBreak(SourceText text) - { - var first = text.Lines[0]; - return first.SpanIncludingLineBreak.Length > first.Span.Length - ? text.ToString(TextSpan.FromBounds(first.End, first.EndIncludingLineBreak)) - : "\n"; - } } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorExtractActions.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorExtractActions.cs new file mode 100644 index 00000000..7561dbda --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorExtractActions.cs @@ -0,0 +1,330 @@ +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Workspace; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// Razor refactorings that move markup or code into a new file: a component's @code +/// block into a code-behind partial class, and a run of sibling elements into a new +/// component. Implements [RAZOR-CODE-ACTIONS-CODE-BEHIND] and [RAZOR-CODE-ACTIONS-COMPONENT]. +/// +internal static class RazorExtractActions +{ + /// + /// Move the @code block whose name holds the caret into Name.razor.cs, when the + /// component has no code-behind yet. The partial class carries the file's imported + /// namespaces that the project does not import globally. + /// + public static RazorCodeAction? CodeBehind( + RazorProjection projection, + RazorMarkup markup, + Compilation compilation, + int caret + ) + { + // The caret may sit on the directive's `@` as well as on its name. + var block = markup.Directives.FirstOrDefault(directive => + directive.Span.Start <= caret && caret <= directive.NameSpan.End + ); + var path = NativePaths.CodeBehindOf(projection.RazorPath); + var body = block is { Name: "code" } ? BodyOf(projection, block.Span) : null; + if (markup.FileKind != RazorFileKind.Component || body is null || File.Exists(path)) + { + return null; + } + + var name = NativePaths.StemOf(projection.RazorPath); + var newline = RazorHandlerText.LineBreak(markup.Text); + var text = PartialClass( + markup, + compilation, + name, + markup.Text.ToString(body.Value), + newline + ); + var removed = WithLineBreak(markup.Text, block!.Span); + return new RazorCodeAction( + "Extract block to code behind", + "refactor.extract", + false, + Edits(markup.Text, projection.RazorPath, new TextChange(removed, ""), path, text) + ); + } + + /// + /// Move the sibling elements the selection covers into a new component beside the file, + /// and put its tag in their place. Offered only when their C# names nothing the + /// component or an enclosing block declares, which the new component could not see. + /// + public static RazorCodeAction? Component( + RazorProjection projection, + RazorMarkup markup, + SemanticModel model, + TextSpan range + ) + { + var selected = Trimmed(markup.Text, range); + if ( + markup.FileKind != RazorFileKind.Component + || !CoversSiblings(markup, selected) + || !StandsAlone(projection, model, selected) + ) + { + return null; + } + + var folder = NativePaths.DirectoryOf(projection.RazorPath); + var name = FreeName(folder); + var newline = RazorHandlerText.LineBreak(markup.Text); + var text = ComponentText(markup, selected, newline); + return new RazorCodeAction( + $"Extract element to new component '{name}'", + "refactor.extract", + false, + Edits( + markup.Text, + projection.RazorPath, + new TextChange(selected, $"<{name} />"), + NativePaths.ComponentIn(folder, name), + text + ) + ); + } + + /// The C# of a block: from its first fragment's start to its last fragment's end. + private static TextSpan? BodyOf(RazorProjection projection, TextSpan block) + { + var inside = projection + .Map.Mappings.Where(mapping => + block.Start <= mapping.RazorStart && mapping.RazorEnd <= block.End + ) + .ToList(); + return inside.Count == 0 + ? null + : TextSpan.FromBounds(inside.Min(m => m.RazorStart), inside.Max(m => m.RazorEnd)); + } + + private static string PartialClass( + RazorMarkup markup, + Compilation compilation, + string name, + string body, + string newline + ) + { + var global = GlobalUsings(compilation); + var usings = markup + .ImportedNamespaces.Where(space => !global.Contains(space)) + .Distinct() + .Order(StringComparer.Ordinal) + .Select(space => $"using {space};{newline}"); + var space = markup.Namespace is { Length: > 0 } declared + ? $"namespace {declared};{newline}{newline}" + : ""; + var members = body.Trim('\r', '\n').TrimEnd(); + return string.Concat(usings) + + (global.Count < markup.ImportedNamespaces.Length ? newline : "") + + space + + $"public partial class {name}{newline}{{{newline}{members}{newline}}}{newline}"; + } + + /// The namespaces the compilation imports with global using, as their symbols name them. + private static HashSet GlobalUsings(Compilation compilation) + { + return compilation + .SyntaxTrees.SelectMany(tree => + tree.GetCompilationUnitRoot() + .Usings.Where(directive => + directive.GlobalKeyword.Span.Length > 0 && directive.Alias is null + ) + .Select(directive => + compilation + .GetSemanticModel(tree) + .GetSymbolInfo(directive.NamespaceOrType) + .Symbol + ) + ) + .OfType() + .Select(space => space.ToDisplayString()) + .ToHashSet(StringComparer.Ordinal); + } + + /// with the line break that follows it, so no blank line stays. + private static TextSpan WithLineBreak(SourceText text, TextSpan span) + { + var line = text.Lines.GetLineFromPosition(span.End); + return line.End == span.End + ? TextSpan.FromBounds(span.Start, line.EndIncludingLineBreak) + : span; + } + + /// without the whitespace at either end. + private static TextSpan Trimmed(SourceText text, TextSpan range) + { + var start = range.Start; + var end = range.End; + while (start < end && char.IsWhiteSpace(text[start])) + { + start++; + } + + while (end > start && char.IsWhiteSpace(text[end - 1])) + { + end--; + } + + return TextSpan.FromBounds(start, end); + } + + /// Whether starts at one element and ends at a sibling's end. + private static bool CoversSiblings(RazorMarkup markup, TextSpan selected) + { + var first = markup + .Tags.Where(tag => tag.Element.Start == selected.Start) + .MaxBy(tag => tag.Element.Length); + var last = markup + .Tags.Where(tag => tag.Element.End == selected.End) + .MaxBy(tag => tag.Element.Length); + return selected.Length > 0 + && first is not null + && last is not null + && (first == last || first.Element.End <= last.Element.Start) + && Parent(markup, first) == Parent(markup, last); + } + + private static MarkupTag? Parent(RazorMarkup markup, MarkupTag tag) + { + return markup + .Tags.Where(other => other != tag && other.Element.Contains(tag.Element)) + .MinBy(other => other.Element.Length); + } + + /// + /// Whether the selection's C# binds only to what it declares itself or what any component + /// can name: never a member of this component, nor a local or parameter declared outside it. + /// + private static bool StandsAlone( + RazorProjection projection, + SemanticModel model, + TextSpan selected + ) + { + var generated = projection + .Map.Mappings.Where(mapping => + selected.Start <= mapping.RazorStart && mapping.RazorEnd <= selected.End + ) + .Select(mapping => new TextSpan(mapping.GeneratedStart, mapping.Length)) + .ToList(); + var component = model.GetDeclaredSymbol( + projection.GeneratedRoot.DescendantNodes().OfType().First() + ); + return generated + .SelectMany(span => + projection.GeneratedRoot.DescendantNodes(span).OfType() + ) + .Where(name => generated.Any(span => span.Contains(name.Span))) + .Select(name => model.GetSymbolInfo(name).Symbol) + .OfType() + .All(symbol => !Captures(symbol, component, generated)); + } + + private static bool Captures( + ISymbol symbol, + INamedTypeSymbol? component, + List selected + ) + { + var declaredInside = symbol.Locations.Any(location => + location.IsInSource && selected.Any(span => span.Contains(location.SourceSpan)) + ); + return symbol switch + { + ILocalSymbol or IParameterSymbol or IRangeVariableSymbol => !declaredInside, + _ => SymbolEqualityComparer.Default.Equals(symbol.ContainingType, component), + }; + } + + /// Component, else the first ComponentN no file in the folder takes. + private static string FreeName(string folder) + { + return Enumerable + .Range(1, int.MaxValue - 1) + .Select(n => n == 1 ? "Component" : $"Component{n}") + .First(name => !File.Exists(NativePaths.ComponentIn(folder, name))); + } + + /// The new component: the file's own @using lines, then the elements, dedented. + private static string ComponentText(RazorMarkup markup, TextSpan selected, string newline) + { + var usings = markup + .Directives.Where(directive => directive.Name == "using") + .Select(directive => markup.Text.ToString(directive.Span).TrimEnd() + newline); + var text = markup.Text; + var first = text.Lines.GetLineFromPosition(selected.Start); + var indent = selected.Start - first.Start; + var lines = Enumerable + .Range( + first.LineNumber, + text.Lines.GetLineFromPosition(selected.End).LineNumber - first.LineNumber + 1 + ) + .Select(number => text.Lines[number]) + .Select(line => + text.ToString( + TextSpan.FromBounds( + Math.Max( + line.Start, + line == first ? selected.Start : Dedented(text, line, indent) + ), + Math.Min(line.End, selected.End) + ) + ) + ); + var head = string.Concat(usings); + return head + (head.Length > 0 ? newline : "") + string.Join(newline, lines) + newline; + } + + /// Where 's text starts once up to of its leading whitespace is dropped. + private static int Dedented(SourceText text, TextLine line, int indent) + { + var start = line.Start; + while (start < line.End && start - line.Start < indent && char.IsWhiteSpace(text[start])) + { + start++; + } + + return start; + } + + /// The edit to the Razor file and the new file's whole text, the new file first. + private static WorkspaceEditResult Edits( + SourceText razorText, + string razorPath, + TextChange change, + string newPath, + string newText + ) + { + return new WorkspaceEditResult + { + DocumentChanges = + [ + new DocumentEditResult + { + FilePath = newPath, + Edits = [new TextEditResult { NewText = newText }], + Create = true, + }, + new DocumentEditResult + { + FilePath = razorPath, + Edits = [DocumentText.ToTextEdit(razorText, change)], + }, + ], + }; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHandlerText.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHandlerText.cs new file mode 100644 index 00000000..efaad5f9 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHandlerText.cs @@ -0,0 +1,64 @@ +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.CSharp.Workspace; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// The text of a generated event handler and where it goes: before the closing brace of the +/// file's last @code or @functions block, else in a new @code block at the +/// end of the file. Implements [RAZOR-CODE-ACTIONS-HANDLER]. +/// +internal static class RazorHandlerText +{ + private const string Indent = " "; + + /// A private method named , taking the event's argument when there is one. + public static IReadOnlyList Method(string name, string? argument) + { + var parameters = argument is null ? "" : $"{argument} args"; + return [$"private void {name}({parameters})", "{", "}"]; + } + + /// The edit that inserts into 's file. + public static TextEditResult Insert(RazorMarkup markup, IReadOnlyList method) + { + var text = markup.Text; + var newline = LineBreak(text); + var block = markup + .Directives.Where(directive => directive.Name is "code" or "functions") + .Select(directive => directive.Span) + .Where(span => span.Length > 0 && text[span.End - 1] == '}') + .OrderBy(span => span.End) + .LastOrDefault(); + var body = string.Concat(method.Select(line => Indent + line + newline)); + var change = + block.Length > 0 + ? new TextChange(new TextSpan(block.End - 1, 0), newline + body) + : new TextChange( + new TextSpan(text.Length, 0), + (EndsWithLineBreak(text) ? "" : newline) + + newline + + "@code {" + + newline + + body + + "}" + + newline + ); + return DocumentText.ToTextEdit(text, change); + } + + /// The file's own line break, from its first line; \n for a one-line file. + internal static string LineBreak(SourceText text) + { + var first = text.Lines[0]; + return first.EndIncludingLineBreak > first.End + ? text.ToString(TextSpan.FromBounds(first.End, first.EndIncludingLineBreak)) + : "\n"; + } + + private static bool EndsWithLineBreak(SourceText text) + { + var last = text.Lines[^1]; + return text.Length == 0 || last.Span.Length == 0; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHints.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHints.cs new file mode 100644 index 00000000..9748fa5a --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHints.cs @@ -0,0 +1,94 @@ +using Microsoft.CodeAnalysis.Text; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// Inlay hints of a projection read in the Razor file it belongs to: hints computed on the C# +/// the Razor lines map to, each placed at its Razor position. A hint outside every C# fragment +/// cannot be shown in the file, so it is dropped. Implements [RAZOR-ARCHITECTURE-PROJECTION] +/// and [RAZOR-FEATURES-INLAY-HINTS]. +/// +internal static class RazorHints +{ + /// + /// The generated lines the Razor .. + /// map to, the last one included so that a caller reading to the end of it sees its content; + /// null when they cover no fragment, where the file has no C# to hint. + /// + public static (int Start, int End)? Lines( + RazorProjection projection, + int startLine, + int endLine + ) + { + var lines = projection.RazorText.Lines; + var window = TextSpan.FromBounds( + lines[Clamped(lines, startLine)].Start, + lines[Clamped(lines, endLine)].End + ); + var covering = projection + .Map.Mappings.Where(entry => + entry.RazorStart < window.End && window.Start < entry.RazorEnd + ) + .ToList(); + return covering.Count == 0 + ? null + : ( + LineOf(projection, covering.Min(entry => entry.GeneratedStart)), + LineOf(projection, covering.Max(entry => entry.GeneratedEnd)) + ); + } + + /// + /// , whose positions are in the projection, placed in + /// 's Razor file when they lie within its lines + /// ..; the rest are dropped. + /// + public static List Map( + List hints, + RazorProjection projection, + int startLine, + int endLine + ) + { + return + [ + .. hints + .Select(hint => Map(hint, projection, startLine, endLine)) + .OfType(), + ]; + } + + private static InlayHintResult? Map( + InlayHintResult hint, + RazorProjection projection, + int startLine, + int endLine + ) + { + var at = new LinePosition(hint.Line, hint.Character); + return + projection.ToRazor(new LinePositionSpan(at, at)) is { } razor + && razor.Start.Line >= startLine + && razor.Start.Line <= endLine + ? new InlayHintResult + { + Line = razor.Start.Line, + Character = razor.Start.Character, + Label = hint.Label, + Kind = hint.Kind, + } + : null; + } + + /// The generated line is on. + private static int LineOf(RazorProjection projection, int generatedOffset) + { + return projection.GeneratedText.Lines.GetLinePosition(generatedOffset).Line; + } + + private static int Clamped(TextLineCollection lines, int line) + { + return Math.Clamp(line, 0, lines.Count - 1); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHtmlProjection.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHtmlProjection.cs index 2a961a57..fe0e3dd3 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHtmlProjection.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorHtmlProjection.cs @@ -1,4 +1,3 @@ -using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Language.Intermediate; using Microsoft.CodeAnalysis.Text; @@ -10,31 +9,37 @@ namespace SharpLsp.Sidecar.CSharp.Razor; /// internal static class RazorHtmlProjection { - public static string Create(SourceText text, DocumentIntermediateNode root) + /// The projection of , whose tag helper elements carry . + public static string Create(SourceText text, DocumentIntermediateNode root, string prefix) { var chars = text.ToString().Select(Blank).ToArray(); - Write(root, text, chars); + Write(root, text, chars, prefix); return new string(chars); } - private static void Write(IntermediateNode node, SourceText text, char[] chars) + private static void Write(IntermediateNode node, SourceText text, char[] chars, string prefix) { // A directive attribute's value is never HTML, whatever the compiler holds under it. - if (node.IsImported || IsDirective(node)) + if (node.IsImported || RazorIr.IsDirective(node)) { return; } - WriteNode(node, text, chars); + WriteNode(node, text, chars, prefix); foreach (var child in node.Children) { - Write(child, text, chars); + Write(child, text, chars, prefix); } } - private static void WriteNode(IntermediateNode node, SourceText text, char[] chars) + private static void WriteNode( + IntermediateNode node, + SourceText text, + char[] chars, + string prefix + ) { - if (RazorIr.Tag(node, text) is { } tag) + if (RazorIr.Tag(node, text, prefix) is { } tag) { WriteTag(node, tag, text, chars); } @@ -96,11 +101,12 @@ char[] chars chars, TextSpan.FromBounds(tag.Element.Start, OpeningEnd(text, tag.Element, 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)) + foreach (var attribute in RazorIr.NamedAttributes(node, tag, text)) { - after = ClearAttribute(chars, text, attribute, after); + if (Cleared(attribute) is { } span) + { + Clear(chars, span); + } } if (tag.EndName is { } end) @@ -110,126 +116,25 @@ 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. + /// What an attribute blanks: a directive's whole @name="value", else its IR span, under + /// which HTML is copied back as written. A tag helper attribute's name is markup, so a legacy + /// file's (asp-for, whose span is its value's) stays. [RAZOR-ARCHITECTURE-HTML] /// - 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) + private static TextSpan? Cleared(NamedAttribute attribute) { - 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 + return RazorIr.IsDirective(attribute.Node) && attribute is { Name: { } name, End: { } end } + ? TextSpan.FromBounds(name.Start, end) + : attribute.Node.Source is { } source + ? new TextSpan(source.AbsoluteIndex, source.Length) : 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 [ .. node - .Children.Where(IsAttribute) + .Children.Where(RazorIr.IsAttribute) .Where(child => child.Source is not null) .Select(child => new TextSpan( child.Source!.Value.AbsoluteIndex, @@ -238,22 +143,6 @@ .. node ]; } - private static bool IsAttribute(IntermediateNode node) - { - return node - is HtmlAttributeIntermediateNode - or TagHelperHtmlAttributeIntermediateNode - or TagHelperPropertyIntermediateNode - || IsDirective(node); - } - - private static bool IsDirective(IntermediateNode node) - { - return node - is TagHelperDirectiveAttributeIntermediateNode - or TagHelperDirectiveAttributeParameterIntermediateNode; - } - /// Tokenizes only an IR-owned opening tag; compiler attribute spans are opaque. private static int OpeningEnd(SourceText text, TextSpan element, TextSpan[] attributes) { diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.Attributes.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.Attributes.cs new file mode 100644 index 00000000..a195a0c8 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.Attributes.cs @@ -0,0 +1,231 @@ +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Intermediate; +using Microsoft.CodeAnalysis.Text; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// A tag's attribute node, with its name's span and its end where the compiler's spans and the text say. +internal readonly record struct NamedAttribute(IntermediateNode Node, TextSpan? Name, int? End); + +/// +/// Attribute facts read from a tag's IR nodes, in source order. Implements +/// [RAZOR-ARCHITECTURE-ENGINE]. +/// +internal static partial class RazorIr +{ + /// The attributes of a tag node, each with what binds it. + public static IEnumerable Attributes( + IntermediateNode node, + MarkupTag tag, + SourceText text + ) + { + return NamedAttributes(node, tag, text) + .Select(named => + Bound(named.Node) is { } bound + ? new MarkupAttribute( + bound.Kind, + bound.Name, + named.Name, + named.End, + tag, + bound.TagHelper, + bound.Attribute, + bound.Parameter + ) + : null + ) + .OfType(); + } + + /// + /// A tag node's attributes, each with its name's span and its end. A name the compiler gives + /// no span (a legacy file's attribute, a minimized directive parameter) is read where the + /// attribute before it ends, and only where the text holds it. The nodes that several tag + /// helpers bind for one attribute share its value, and so its name. + /// + public static IEnumerable NamedAttributes( + IntermediateNode node, + MarkupTag tag, + SourceText text + ) + { + int? after = tag.StartName.End; + var from = after; + IntermediateNode? previous = null; + foreach (var child in node.Children.Where(IsAttribute)) + { + from = previous is not null && SameAttribute(previous, child) ? from : after; + var name = NameOf(child, text, from); + var end = EndOf(child, name); + yield return new NamedAttribute(child, name, end); + (after, previous) = (end, child); + } + } + + /// Whether a node is one of a tag's attributes. + public static bool IsAttribute(IntermediateNode node) + { + return node + is HtmlAttributeIntermediateNode + or TagHelperHtmlAttributeIntermediateNode + or TagHelperPropertyIntermediateNode + || IsDirective(node); + } + + /// Whether a node is a directive attribute (@onclick) or one of its parameters. + public static bool IsDirective(IntermediateNode node) + { + return node + is TagHelperDirectiveAttributeIntermediateNode + or TagHelperDirectiveAttributeParameterIntermediateNode; + } + + /// What an attribute node is to the compiler, by the name it was written with. + private static ( + MarkupAttributeKind Kind, + string Name, + TagHelperDescriptor? TagHelper, + BoundAttributeDescriptor? Attribute, + BoundAttributeParameterDescriptor? Parameter + )? Bound(IntermediateNode node) + { + return node switch + { + TagHelperPropertyIntermediateNode property => ( + MarkupAttributeKind.Property, + property.AttributeName, + property.TagHelper, + property.BoundAttribute, + null + ), + TagHelperDirectiveAttributeIntermediateNode directive => ( + MarkupAttributeKind.Directive, + directive.OriginalAttributeName, + directive.TagHelper, + directive.BoundAttribute, + null + ), + TagHelperDirectiveAttributeParameterIntermediateNode parameter => ( + MarkupAttributeKind.DirectiveParameter, + parameter.OriginalAttributeName, + parameter.TagHelper, + parameter.BoundAttribute, + parameter.BoundAttributeParameter + ), + TagHelperHtmlAttributeIntermediateNode html => Html(html.AttributeName), + HtmlAttributeIntermediateNode html => Html(html.AttributeName), + _ => null, + }; + } + + private static ( + MarkupAttributeKind, + string, + TagHelperDescriptor?, + BoundAttributeDescriptor?, + BoundAttributeParameterDescriptor? + ) Html(string name) + { + return (MarkupAttributeKind.Html, name, null, null, null); + } + + /// An attribute's name: the compiler's span, else read where the attribute starts. + private static TextSpan? NameOf(IntermediateNode node, SourceText text, int? from) + { + return node switch + { + TagHelperDirectiveAttributeIntermediateNode directive => Located( + directive.OriginalAttributeSpan, + directive.OriginalAttributeName, + directive.AttributeStructure, + text, + from + ), + TagHelperDirectiveAttributeParameterIntermediateNode parameter => Located( + parameter.OriginalAttributeSpan, + parameter.OriginalAttributeName, + parameter.AttributeStructure, + text, + from + ), + TagHelperPropertyIntermediateNode { OriginalAttributeSpan: { } span } => Span(span), + _ => Bound(node) is { } bound && from is { } start + ? NameAfter(text, start, bound.Name) + : 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. + /// + private static TextSpan? Located( + SourceSpan? original, + string name, + AttributeStructure structure, + SourceText text, + int? from + ) + { + return original is null && structure == AttributeStructure.Minimized && from is { } start + ? NameAfter(text, start, name) + : DirectiveName(original, name, text); + } + + /// + /// Where an attribute ends: its value's end and closing quote, else, for a directive or a + /// minimized attribute, its name's end; null when neither the IR nor the text says. + /// + private static int? EndOf(IntermediateNode node, TextSpan? name) + { + var structure = Structure(node); + var value = + LastEnd(node) + ?? (IsDirective(node) || structure == AttributeStructure.Minimized ? name?.End : null); + return value is { } found ? Closing(structure, found) : null; + } + + /// Whether two property nodes are one attribute that two tag helpers bind. + private static bool SameAttribute(IntermediateNode first, IntermediateNode second) + { + return first is TagHelperPropertyIntermediateNode { Source: { } value } one + && second is TagHelperPropertyIntermediateNode { Source: { } other } two + && one.AttributeName == two.AttributeName + && value.Equals(other); + } + + /// 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 = Descendants(node) + .Select(child => child.Source) + .OfType() + .Select(source => source.AbsoluteIndex + source.Length) + .ToArray(); + return ends.Length > 0 ? ends.Max() : 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; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs index cd428a40..2d94cae7 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorIr.cs @@ -14,7 +14,7 @@ namespace SharpLsp.Sidecar.CSharp.Razor; /// verified against the text, so a span the compiler did not shape that way is never /// reported. Implements [RAZOR-ARCHITECTURE-ENGINE]. /// -internal static class RazorIr +internal static partial class RazorIr { /// Every node under , parents before children. public static IEnumerable Descendants(IntermediateNode root) @@ -79,6 +79,22 @@ public static IEnumerable CodeBlocks(IntermediateNode root) }; } + /// The tag helper directives under , its imports' first, in order. + public static IEnumerable TagHelperDirectives(IntermediateNode root) + { + return Descendants(root) + .OfType() + .Where(directive => RazorTagHelperScope.Declares(directive.DirectiveName)) + .Select(directive => new TagHelperDirective( + directive.DirectiveName, + directive + .Children.OfType() + .FirstOrDefault() + ?.Content + ?? "" + )); + } + /// The namespace an @using imports, without a global:: qualifier; null for an alias or static using. public static string? Namespace(UsingDirectiveIntermediateNode use) { @@ -94,21 +110,20 @@ public static IEnumerable CodeBlocks(IntermediateNode root) return (global is null ? name : name.ReplaceNode(global, global.Name)).ToString(); } - /// The tag a node is: an element bound to tag helpers, or a plain component-file element. - public static MarkupTag? Tag(IntermediateNode node, SourceText text) + /// + /// The tag a node is: an element bound to tag helpers, or a plain component-file element. + /// A legacy file's tag helper element is spelled with the file's @tagHelperPrefix. + /// + public static MarkupTag? Tag(IntermediateNode node, SourceText text, string prefix) { return node switch { - TagHelperIntermediateNode { Source: { } source, StartTagSpan: { } start } bound => - new MarkupTag( - bound.TagName, - Span(source), - Span(start), - bound.TagMode == TagMode.StartTagAndEndTag - ? EndName(Span(source), bound.TagName, text) - : null, - [.. bound.TagHelpers] - ), + TagHelperIntermediateNode { Source: { } source } bound => BoundTag( + bound, + Span(source), + text, + prefix + ), MarkupElementIntermediateNode { Source: { } source } element => PlainTag( element.TagName, Span(source), @@ -118,16 +133,6 @@ [.. bound.TagHelpers] }; } - /// The attributes of a tag node, each with what binds it. - public static IEnumerable Attributes( - IntermediateNode node, - MarkupTag tag, - SourceText text - ) - { - return node.Children.Select(child => Attribute(child, tag, text)).OfType(); - } - /// Whether a diagnostic belongs to the processed file, not to one of its imports. public static bool IsIn(RazorDiagnostic diagnostic, string? path, SourceText text) { @@ -137,58 +142,35 @@ public static bool IsIn(RazorDiagnostic diagnostic, string? path, SourceText tex && (string.IsNullOrEmpty(span.FilePath) || NativePaths.AreEqual(span.FilePath, path)); } - private static MarkupAttribute? Attribute( - IntermediateNode child, - MarkupTag tag, - SourceText text + /// + /// An element tag helpers bind. A component's IR carries its start tag name's span; a + /// legacy file's does not, so its name, prefix first, is read just after the element's + /// <, as HTML spells it in any case, and only where the text holds it. + /// + private static MarkupTag? BoundTag( + TagHelperIntermediateNode bound, + TextSpan element, + SourceText text, + string prefix ) { - return child switch - { - TagHelperPropertyIntermediateNode property => new MarkupAttribute( - MarkupAttributeKind.Property, - property.AttributeName, - property.OriginalAttributeSpan is { } span ? Span(span) : null, - tag, - property.TagHelper, - property.BoundAttribute, - null - ), - TagHelperDirectiveAttributeIntermediateNode directive => new MarkupAttribute( - MarkupAttributeKind.Directive, - directive.OriginalAttributeName, - DirectiveName( - directive.OriginalAttributeSpan, - directive.OriginalAttributeName, - text - ), - tag, - directive.TagHelper, - directive.BoundAttribute, - null - ), - TagHelperDirectiveAttributeParameterIntermediateNode parameter => new MarkupAttribute( - MarkupAttributeKind.DirectiveParameter, - parameter.OriginalAttributeName, - DirectiveName( - parameter.OriginalAttributeSpan, - parameter.OriginalAttributeName, - text - ), - tag, - parameter.TagHelper, - parameter.BoundAttribute, - parameter.BoundAttributeParameter - ), - TagHelperHtmlAttributeIntermediateNode html => Html(html.AttributeName, tag), - HtmlAttributeIntermediateNode html => Html(html.AttributeName, tag), - _ => null, - }; - } - - private static MarkupAttribute Html(string name, MarkupTag tag) - { - return new MarkupAttribute(MarkupAttributeKind.Html, name, null, tag, null, null, null); + var legacy = bound.StartTagSpan is null; + var comparison = legacy ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + var spelled = legacy ? prefix + bound.TagName : bound.TagName; + var start = bound.StartTagSpan is { } span + ? Span(span) + : new TextSpan(element.Start + 1, spelled.Length); + return Holds(text, start, spelled, comparison) + ? new MarkupTag( + bound.TagName, + element, + start, + bound.TagMode == TagMode.StartTagAndEndTag + ? EndName(element, spelled, text, comparison) + : null, + [.. bound.TagHelpers] + ) + : null; } private static MarkupTag? PlainTag(string name, TextSpan element, SourceText text) diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs index f6232e69..87b86868 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkup.cs @@ -39,9 +39,7 @@ ImmutableArray TagHelpers /// The MVC tag helpers bound to this tag. public IEnumerable MvcTagHelpers => - TagHelpers.Where(helper => - helper.Kind is TagHelperKind.ITagHelper or TagHelperKind.ViewComponent - ); + TagHelpers.Where(RazorTagHelperScope.IsMvc); /// Whether lies on the start or end tag's name. public bool NameContains(int offset) @@ -66,11 +64,16 @@ private static bool Touches(TextSpan span, int offset) } } -/// An attribute of a tag, with what binds it. +/// +/// An attribute of a tag, with what binds it. is where the attribute +/// ends — its value and closing quote, or a minimized attribute's name — as the compiler's +/// spans say; null when they do not. +/// internal sealed record MarkupAttribute( MarkupAttributeKind Kind, string Name, TextSpan? NameSpan, + int? End, MarkupTag Owner, TagHelperDescriptor? TagHelper, BoundAttributeDescriptor? Bound, @@ -116,6 +119,9 @@ TagHelperCollection TagHelpers /// The C# code blocks (@{ … }) of the file, which fold. [RAZOR-FEATURES-STRUCTURE] public ImmutableArray CodeBlocks { get; init; } = []; + /// The MVC tag helpers the file's directives bring into scope. [RAZOR-MVC-SCOPE] + public RazorTagHelperScope Scope { get; init; } = RazorTagHelperScope.Empty; + /// The caret's HTML context, [RAZOR-ARCHITECTURE-HTML]. public MarkupContext ContextAt(int offset) { diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCapture.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCapture.cs index 5a443dbb..3072ef54 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCapture.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCapture.cs @@ -6,7 +6,8 @@ namespace SharpLsp.Sidecar.CSharp.Razor; /// /// Reads markup facts out of the compiler's IR while it is built. Directives are read first -/// in the directive-classifier phase, before @code becomes plain C#; tags and +/// in the directive-classifier phase, before @code becomes plain C#, and with them the +/// tag helper directives that scope a legacy file's tag helpers ([RAZOR-MVC-SCOPE]); tags and /// attributes first in the optimization phase, before components are lowered and attribute /// names lose their spans; diagnostics last. The IR is mutated after each read, so each /// pass copies what it needs at once. Implements [RAZOR-ARCHITECTURE-ENGINE]. @@ -21,6 +22,8 @@ internal sealed class RazorMarkupCapture private RazorCodeDocument? _codeDocument; private string _htmlText = ""; private TextSpan[] _codeBlocks = []; + private TagHelperDirective[] _tagHelperDirectives = []; + private string _prefix = ""; /// Add the capture passes to an engine. public void Register(RazorProjectEngineBuilder builder) @@ -50,13 +53,16 @@ [.. _diagnostics.Distinct()], { HtmlText = _htmlText, CodeBlocks = [.. _codeBlocks], + Scope = RazorTagHelperScope.Of(_tagHelperDirectives, tagHelpers), }; } private void ReadDirectives(RazorCodeDocument document, DocumentIntermediateNode root) { var text = document.Source.Text; - _htmlText = RazorHtmlProjection.Create(text, root); + _tagHelperDirectives = [.. RazorIr.TagHelperDirectives(root)]; + _prefix = RazorTagHelperScope.Of(_tagHelperDirectives, []).Prefix; + _htmlText = RazorHtmlProjection.Create(text, root, _prefix); _codeBlocks = [.. RazorIr.CodeBlocks(root)]; foreach (var node in RazorIr.Descendants(root).Where(node => !node.IsImported)) { @@ -77,7 +83,7 @@ private void ReadTags(RazorCodeDocument document, DocumentIntermediateNode root) { _namespaces.Add(ns); } - else if (!node.IsImported && RazorIr.Tag(node, text) is { } tag) + else if (!node.IsImported && RazorIr.Tag(node, text, _prefix) is { } tag) { _tags.Add(tag); _attributes.AddRange(RazorIr.Attributes(node, tag, text)); diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCompletion.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCompletion.cs index 1bb56518..9eac8b7a 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCompletion.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorMarkupCompletion.cs @@ -6,12 +6,12 @@ namespace SharpLsp.Sidecar.CSharp.Razor; /// -/// Completion in Blazor markup, from the compiler's tag helpers. After <: components, +/// Completion in Razor markup, from the compiler's tag helpers. After <: components, /// by their short name where their namespace is in scope and their full name otherwise, and /// inside a component its child-content parameters. In a start tag: the parameters not yet -/// set, [EditorRequired] ones first and marked, then @bind-X and the directive -/// attributes with their parameters. Each item replaces the name typed at the caret. -/// Implements [RAZOR-FEATURES-COMPLETION]. +/// set, [EditorRequired] ones first and marked, the attributes of the MVC tag helpers in +/// scope, then @bind-X and the directive attributes with their parameters. Each item +/// replaces the name typed at the caret. Implements [RAZOR-FEATURES-COMPLETION] and [RAZOR-MVC]. /// internal static class RazorMarkupCompletion { @@ -47,8 +47,8 @@ int Group var context = markup.ContextAt(offset); var entries = context == MarkupContext.TagName ? TagEntries(markup, offset) - : context == MarkupContext.AttributeName && OwnerAt(markup, offset) is { } tag - ? AttributeEntries(markup, compilation, tag) + : HtmlMarkupContext.StartTagAt(markup.HtmlText, offset) is { } spelled + ? AttributeEntries(markup, compilation, OwnerAt(markup, offset, spelled), spelled) : null; var replace = TextSpan.FromBounds(HtmlMarkupContext.NameStart(markup.Text, offset), offset); return entries is null @@ -94,14 +94,42 @@ tag.Component is not null ) .MinBy(tag => tag.Element.Length); var children = parent is null ? [] : ChildContents(markup, parent); - return children.Concat(Components(markup)); + return children.Concat(Components(markup)).Concat(TagHelperElements(markup.Scope)); } - /// Each component once: its short name where its namespace is in scope, else its full one. + /// + /// Each element an MVC tag helper in scope targets by name (partial, environment, + /// a), spelled with the scope's prefix, once, with the tag helpers that bind it. A rule + /// for every element (*) names none. [RAZOR-MVC-TAGHELPERS] + /// + private static IEnumerable TagHelperElements(RazorTagHelperScope scope) + { + return scope + .TagHelpers.SelectMany(helper => + helper + .TagMatchingRules.Where(rule => rule.TagName is not (null or "*")) + .Select(rule => (Label: scope.Prefix + rule.TagName, Helper: helper)) + ) + .GroupBy(element => element.Label, StringComparer.OrdinalIgnoreCase) + .Select(element => new Entry( + element.Key, + "Class", + string.Join(", ", element.Select(bound => bound.Helper.Name).Distinct()), + element.First().Helper.Documentation, + 1 + )); + } + + /// + /// Each component once: its short name where its namespace is in scope, else its full one. + /// A legacy file binds no component by its tag, so it completes none. [RAZOR-MVC-SCOPE] + /// private static IEnumerable Components(RazorMarkup markup) { return markup - .TagHelpers.Where(helper => helper.Kind == TagHelperKind.Component) + .TagHelpers.Where(helper => + helper.Kind == TagHelperKind.Component && markup.FileKind != RazorFileKind.Legacy + ) .GroupBy(helper => helper.TypeName, StringComparer.Ordinal) .Select(group => group.FirstOrDefault(helper => @@ -134,28 +162,60 @@ private static IEnumerable ChildContents(RazorMarkup markup, MarkupTag pa )); } + /// + /// The attributes not yet set on the start tag spelled : a + /// component's parameters, the attributes its MVC tag helpers in scope bind, and in a + /// component file the directive attributes. + /// private static IEnumerable AttributeEntries( RazorMarkup markup, Compilation compilation, - MarkupTag tag + MarkupTag? tag, + string spelled ) { var set = markup .Attributes.Where(attribute => attribute.Owner == tag) .Select(attribute => attribute.Name) .ToHashSet(StringComparer.Ordinal); - var parameters = tag.Component is { } component ? Parameters(compilation, component) : []; - var directives = markup - .TagHelpers.Where(helper => - DirectiveKinds.Contains(helper.Kind) && Applies(helper, tag) - ) - .SelectMany(DirectiveEntries); + var parameters = tag?.Component is { } component ? Parameters(compilation, component) : []; + var helpers = markup.Scope.Targeting(spelled).SelectMany(TagHelperEntries); + var directives = + markup.FileKind == RazorFileKind.Component + ? markup + .TagHelpers.Where(helper => + DirectiveKinds.Contains(helper.Kind) + && Applies(helper, tag?.Name ?? spelled) + ) + .SelectMany(DirectiveEntries) + : []; return parameters + .Concat(helpers) .Concat(directives) .Where(entry => !set.Contains(entry.Label)) .DistinctBy(entry => entry.Label); } + /// An MVC tag helper's attributes, and each indexer's prefix (asp-route-). + private static IEnumerable TagHelperEntries(TagHelperDescriptor helper) + { + return helper.BoundAttributes.SelectMany(attribute => + new[] + { + (Label: attribute.Name, Type: attribute.TypeName), + (Label: attribute.IndexerNamePrefix, Type: attribute.IndexerTypeName), + } + .Where(named => !string.IsNullOrEmpty(named.Label)) + .Select(named => new Entry( + named.Label!, + "Property", + named.Type, + attribute.Documentation, + 1 + )) + ); + } + /// A component's parameters; one its property marks [EditorRequired] sorts first. private static IEnumerable Parameters( Compilation compilation, @@ -210,24 +270,30 @@ private static IEnumerable DirectiveEntries(TagHelperDescriptor helper) ); } - /// Whether a directive attribute's tag helper matches 's name. - private static bool Applies(TagHelperDescriptor helper, MarkupTag tag) + /// Whether a directive attribute's tag helper targets a tag named . + private static bool Applies(TagHelperDescriptor helper, string name) { - return helper.TagMatchingRules.Any(rule => - rule.TagName == "*" - || string.Equals( - rule.TagName, - tag.Name, - rule.CaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase - ) - ); + return helper.TagMatchingRules.Any(rule => RazorTagHelperScope.Targets(rule, name)); } - /// The tag whose start tag holds : the nearest one before it. - private static MarkupTag? OwnerAt(RazorMarkup markup, int offset) + /// + /// The tag whose start tag holds : the innermost one holding the + /// caret whose name is spelled as the tokenizer read it. A legacy file's plain HTML element + /// is no tag, so none owns its start tag. + /// + private static MarkupTag? OwnerAt(RazorMarkup markup, int offset, string spelled) { return markup - .Tags.Where(tag => tag.StartName.End <= offset && tag.Element.Start < offset) + .Tags.Where(tag => + tag.StartName.End <= offset + && tag.Element.Start < offset + && offset <= tag.Element.End + && string.Equals( + markup.Text.ToString(tag.StartName), + spelled, + StringComparison.OrdinalIgnoreCase + ) + ) .MaxBy(tag => tag.StartName.Start); } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorQuickFixes.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorQuickFixes.cs new file mode 100644 index 00000000..10a87331 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorQuickFixes.cs @@ -0,0 +1,244 @@ +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.FindSymbols; +using Microsoft.CodeAnalysis.Text; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// A Razor code action, its edit computed when it is offered. +internal sealed record RazorCodeAction( + string Title, + string Kind, + bool IsPreferred, + WorkspaceEditResult Edit +); + +/// +/// Razor quick fixes for C# names the projection cannot bind: @using for a type +/// another namespace declares, and a handler method for an event attribute or +/// EventCallback parameter that names one not yet written. Implements +/// [RAZOR-CODE-ACTIONS-USING] and [RAZOR-CODE-ACTIONS-HANDLER]. +/// +internal static class RazorQuickFixes +{ + /// The C# diagnostics that name something the projection cannot find. + private static readonly string[] Unbound = ["CS0103", "CS0246"]; + + /// The fixes for the projection's unbound names that lie in . + public static async Task> ForAsync( + RazorProjection projection, + RazorMarkup markup, + TextSpan range, + CancellationToken ct + ) + { + var model = + await projection.Generated.GetSemanticModelAsync(ct).ConfigureAwait(false) + ?? throw new InvalidOperationException("The projection has no semantic model"); + var names = model + .GetDiagnostics(cancellationToken: ct) + .Where(diagnostic => Unbound.Contains(diagnostic.Id)) + .Select(diagnostic => UnboundName(projection, diagnostic, range)) + .OfType() + .DistinctBy(name => name.SpanStart); + var fixes = new List(); + foreach (var name in names) + { + fixes.AddRange( + await UsingsAsync(projection, markup, model, name, ct).ConfigureAwait(false) + ); + fixes.AddRange(Handlers(projection, markup, model, name)); + } + + return fixes + .Concat(ComponentUsings(projection, markup, range)) + .DistinctBy(fix => fix.Title); + } + + /// The name a diagnostic reports, when its Razor span overlaps . + private static SimpleNameSyntax? UnboundName( + RazorProjection projection, + Diagnostic diagnostic, + TextSpan range + ) + { + var span = diagnostic.Location.SourceSpan; + var razor = projection.Map.ToRazor(span); + return razor is { } mapped && (mapped.OverlapsWith(range) || mapped.Contains(range.Start)) + ? projection + .GeneratedRoot.FindNode(span, getInnermostNodeForTie: true) + .DescendantNodesAndSelf() + .OfType() + .FirstOrDefault() + : null; + } + + /// + /// @using for each namespace that declares an accessible type of the unbound name + /// and arity, which the file does not import yet; one choice is preferred. + /// + private static async Task> UsingsAsync( + RazorProjection projection, + RazorMarkup markup, + SemanticModel model, + SimpleNameSyntax name, + CancellationToken ct + ) + { + var arity = name is GenericNameSyntax generic + ? generic.TypeArgumentList.Arguments.Count + : 0; + var declared = await SymbolFinder + .FindDeclarationsAsync( + projection.Generated.Project, + name.Identifier.ValueText, + ignoreCase: false, + SymbolFilter.Type, + ct + ) + .ConfigureAwait(false); + var namespaces = declared + .OfType() + .Where(type => + type.Arity == arity + && type.ContainingType is null + && model.IsAccessible(name.SpanStart, type) + && !type.ContainingNamespace.IsGlobalNamespace + ) + .Select(type => type.ContainingNamespace.ToDisplayString()) + .Where(space => !markup.ImportedNamespaces.Contains(space)) + .Distinct() + .Order(StringComparer.Ordinal) + .ToList(); + return namespaces.Select(space => + UsingFix(projection, markup, space, namespaces.Count == 1) + ); + } + + /// + /// @using for each namespace holding a component that an unknown element names + /// (RZ10012): the compiler binds a component tag only where its namespace is in scope. + /// + private static IEnumerable ComponentUsings( + RazorProjection projection, + RazorMarkup markup, + TextSpan range + ) + { + var spans = markup + .Diagnostics.Where(diagnostic => + diagnostic.Id == "RZ10012" + && (diagnostic.Span.OverlapsWith(range) || diagnostic.Span.Contains(range.Start)) + ) + .Select(diagnostic => diagnostic.Span) + .ToList(); + var unknown = markup + .Tags.Where(tag => spans.Any(span => span.Contains(tag.StartName.Start))) + .Select(tag => tag.Name) + .ToHashSet(StringComparer.Ordinal); + return markup + .TagHelpers.Where(helper => + helper.Kind == TagHelperKind.Component + && unknown.Contains(helper.TypeNameIdentifier ?? "") + && helper.TypeNamespace is { Length: > 0 } space + && !markup.ImportedNamespaces.Contains(space) + ) + .Select(helper => helper.TypeNamespace!) + .Distinct() + .Select(space => UsingFix(projection, markup, space, true)); + } + + private static RazorCodeAction UsingFix( + RazorProjection projection, + RazorMarkup markup, + string space, + bool preferred + ) + { + var line = RazorCompletionEdits.Map( + projection, + [new TextChange(new TextSpan(0, 0), $"using {space};")], + markup + ); + return new RazorCodeAction( + $"@using {space}", + "quickfix", + preferred, + Edit(projection.RazorPath, line) + ); + } + + /// + /// A handler for an unbound name that the projection passes to + /// EventCallback.Factory.Create: an event attribute (@onclick="Missing") or an + /// EventCallback parameter. It takes the event's argument when the callback has one. + /// + private static IEnumerable Handlers( + RazorProjection projection, + RazorMarkup markup, + SemanticModel model, + SimpleNameSyntax name + ) + { + var create = name.Ancestors() + .OfType() + .FirstOrDefault(invocation => CreatesCallback(model, invocation)); + if (create is null || name is GenericNameSyntax) + { + return []; + } + + var argument = CallbackArgument(model, create, name.SpanStart); + var method = RazorHandlerText.Method(name.Identifier.ValueText, argument); + return + [ + new RazorCodeAction( + $"Generate event handler '{name.Identifier.ValueText}'", + "quickfix", + true, + Edit(projection.RazorPath, [RazorHandlerText.Insert(markup, method)]) + ), + ]; + } + + /// Whether calls EventCallbackFactory.Create. + private static bool CreatesCallback(SemanticModel model, InvocationExpressionSyntax invocation) + { + var info = model.GetSymbolInfo(invocation); + return info + .CandidateSymbols.Append(info.Symbol) + .OfType() + .Any(method => + method.Name == "Create" + && method.ContainingType.ToDisplayString() + == "Microsoft.AspNetCore.Components.EventCallbackFactory" + ); + } + + /// The callback's argument type, as the file spells it, from the call's type argument; none for a plain EventCallback. + private static string? CallbackArgument( + SemanticModel model, + InvocationExpressionSyntax create, + int position + ) + { + var argument = create.Expression switch + { + MemberAccessExpressionSyntax { Name: GenericNameSyntax generic } => + generic.TypeArgumentList.Arguments.FirstOrDefault(), + _ => null, + }; + return argument is null + ? null + : model.GetTypeInfo(argument).Type?.ToMinimalDisplayString(model, position); + } + + private static WorkspaceEditResult Edit(string path, IEnumerable edits) + { + return new WorkspaceEditResult + { + DocumentChanges = [new DocumentEditResult { FilePath = path, Edits = [.. edits] }], + }; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSelectionRanges.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSelectionRanges.cs new file mode 100644 index 00000000..71b74354 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorSelectionRanges.cs @@ -0,0 +1,134 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// The selection ranges of a Razor file: each position answers the chain from its innermost +/// span — the C# fragment's token and enclosing syntax nodes, then the markup facts' attribute, +/// tag name, elements, directives and code blocks — out to the whole document. A candidate +/// that cannot map back to the Razor file as a whole, that does not hold the chain so far, or +/// that duplicates it, is dropped, never approximated. Implements [RAZOR-FEATURES-SELECTION]. +/// +internal static class RazorSelectionRanges +{ + /// The spans holding , innermost first, in Razor offsets. + public static IEnumerable At( + RazorProjection? projection, + RazorMarkup? markup, + int offset + ) + { + return Nested( + Fragment(projection, offset) + .Concat(Markup(markup, offset)) + .Append(Document(projection, markup)), + offset + ); + } + + /// + /// The C# fragment at : the Roslyn token there, then each + /// enclosing node of the projection's syntax tree, keeping only the spans that map back + /// to the Razor file whole. [RAZOR-ARCHITECTURE-PROJECTION] + /// + private static IEnumerable Fragment(RazorProjection? projection, int offset) + { + return projection is not { } found || found.Map.ToGenerated(offset) is not { } generated + ? [] + : NodeSpans(found.GeneratedRoot.FindToken(generated), generated) + .Select(found.Map.ToRazor) + .OfType(); + } + + /// The token's span when it holds the position, then every enclosing node's span. + private static IEnumerable NodeSpans(SyntaxToken token, int generated) + { + var nodes = token.Parent?.AncestorsAndSelf().Select(node => node.Span) ?? []; + return token.Span.Contains(generated) ? new[] { token.Span }.Concat(nodes) : nodes; + } + + /// + /// The markup facts' spans holding : an attribute, a tag name, + /// every enclosing element, directives and C# code blocks. [RAZOR-ARCHITECTURE-ENGINE-FACTS] + /// + private static IEnumerable Markup(RazorMarkup? markup, int offset) + { + return markup is not { } facts + ? [] + : Attribute(facts, offset) + .Concat(TagName(facts, offset)) + .Concat( + facts + .Tags.Where(tag => tag.Element.Contains(offset)) + .OrderBy(tag => tag.Element.Length) + .Select(tag => tag.Element) + ) + .Concat( + facts + .Directives.Where(directive => directive.Span.Contains(offset)) + .Select(directive => directive.Span) + ) + .Concat(facts.CodeBlocks.Where(block => block.Contains(offset))); + } + + /// + /// The attribute whose name or value holds : its name's span, + /// then the whole attribute when it reaches past the name. + /// + private static IEnumerable Attribute(RazorMarkup facts, int offset) + { + var found = facts + .Attributes.Select(attribute => (attribute.NameSpan, Whole: Whole(attribute))) + .FirstOrDefault(pair => pair.Whole is { } whole && whole.Contains(offset)); + return found.NameSpan is not { } name ? [] + : found.Whole is { } whole && whole != name ? [name, whole] + : [name]; + } + + /// + /// The attribute's whole span, from its name's start to the end the compiler's spans give + /// it; an attribute they give no end is its name. + /// + private static TextSpan? Whole(MarkupAttribute attribute) + { + return attribute.NameSpan is not { } name ? null + : attribute.End is { } end && end > name.End ? TextSpan.FromBounds(name.Start, end) + : name; + } + + /// The start or end tag name holding , when one does. + private static IEnumerable TagName(RazorMarkup facts, int offset) + { + return facts.TagNamedAt(offset) is { } tag ? [tag.NameAt(offset)] : []; + } + + /// The whole file's span, from the facts' or the projection's text. + private static TextSpan Document(RazorProjection? projection, RazorMarkup? markup) + { + return new TextSpan(0, (markup?.Text ?? projection?.RazorText)?.Length ?? 0); + } + + /// + /// The candidates that hold and hold every span kept before + /// them, innermost first: one that does not contain the chain so far, or that duplicates + /// it, drops out. + /// + private static ImmutableList Nested(IEnumerable candidates, int offset) + { + return candidates.Aggregate( + ImmutableList.Empty, + (chain, candidate) => + chain.Count == 0 && !candidate.Contains(offset) ? chain + : chain.Count > 0 && !StrictlyHolds(chain[^1], candidate) ? chain + : chain.Add(candidate) + ); + } + + /// Whether contains with room to spare. + private static bool StrictlyHolds(TextSpan inner, TextSpan outer) + { + return outer.Contains(inner) && outer != inner; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorStylesheet.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorStylesheet.cs new file mode 100644 index 00000000..59e43804 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorStylesheet.cs @@ -0,0 +1,111 @@ +using Microsoft.CodeAnalysis.Text; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// +/// A class selector a stylesheet declares: its name, with its escapes decoded, and the span of +/// the class name after its .. [RAZOR-COLLOCATION-CSS] +/// +internal readonly record struct CssClass(string Name, TextSpan Span); + +/// +/// A collocated stylesheet: the classes its rule preludes declare, in the order they appear. +/// Only a rule's prelude names selectors, so a class written in a declaration value, a string, +/// a comment or a url is never one. Implements [RAZOR-COLLOCATION-CSS]. +/// +internal sealed record RazorStylesheet( + string FilePath, + SourceText Text, + IReadOnlyList Classes +) +{ + /// The distinct class names the stylesheet declares, in the order they first appear. + public IReadOnlyList Names { get; } = + [.. Classes.Select(cssClass => cssClass.Name).Distinct(StringComparer.Ordinal)]; + + /// The stylesheet holds, read from its text. + public static RazorStylesheet Parse(string filePath, SourceText text) + { + return new RazorStylesheet(filePath, text, RazorStylesheetReader.ClassesOf(text)); + } +} + +/// +/// Reads the class selectors of a stylesheet from the tokens of the CSS Syntax Module Level 3 +/// tokenizer. Every { ends a prelude: an at-rule's preludes are skipped, and a rule's +/// own yields each . immediately followed by an ident. Blocks nest, so at-rules inside +/// at-rules and nested rules read the same way. Implements [RAZOR-COLLOCATION-CSS]. +/// +internal static class RazorStylesheetReader +{ + /// Every class selector occurrence of , in source order. + public static IReadOnlyList ClassesOf(SourceText css) + { + var tokens = CssTokenizer.Tokenize(css.ToString()); + var classes = new List(); + var prelude = 0; + for (var at = 0; at < tokens.Count; at++) + { + if (tokens[at].Kind == CssTokenKind.BraceOpen && IsSelectorPrelude(tokens, prelude, at)) + { + classes.AddRange(ClassesIn(css, tokens, prelude, at)); + } + + prelude = StartsPrelude(tokens[at]) ? at + 1 : prelude; + } + + return classes; + } + + /// Whether the block a { opens starts a prelude of its own after it. + private static bool StartsPrelude(CssToken token) + { + return token.Kind + is CssTokenKind.BraceOpen + or CssTokenKind.BraceClose + or CssTokenKind.Semicolon; + } + + /// Whether the tokens from name a rule's selectors, not an at-rule. + private static bool IsSelectorPrelude(List tokens, int start, int end) + { + // A default token's empty span stands for a prelude holding nothing but whitespace. + var first = tokens.Skip(start).Take(end - start).FirstOrDefault(NotSpace); + return first.Span.Length > 0 + && first.Kind is not (CssTokenKind.AtKeyword or CssTokenKind.Cdo or CssTokenKind.Cdc); + } + + private static bool NotSpace(CssToken token) + { + return token.Kind != CssTokenKind.Whitespace; + } + + /// The classes of a prelude: each . and the ident right after it. + private static IEnumerable ClassesIn( + SourceText css, + List tokens, + int start, + int end + ) + { + return Enumerable + .Range(start, end - start) + .Where(at => IsClassDot(css, tokens, at)) + .Select(at => tokens[at + 1]) + .Select(ident => new CssClass(CssTokenizer.Value(css, ident), ident.Span)); + } + + /// Whether the token is a . directly followed by the ident naming a class. + private static bool IsClassDot(SourceText css, List tokens, int at) + { + return tokens[at].Kind == CssTokenKind.Delim + && IsDot(css, tokens[at]) + && tokens[at + 1].Kind == CssTokenKind.Ident + && tokens[at + 1].Span.Start == tokens[at].Span.End; + } + + private static bool IsDot(SourceText css, CssToken token) + { + return token.Span.Length == 1 && css[token.Span.Start] == '.'; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorTagHelperScope.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorTagHelperScope.cs new file mode 100644 index 00000000..8cb5de17 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Razor/RazorTagHelperScope.cs @@ -0,0 +1,121 @@ +using System.Collections.Immutable; +using Microsoft.AspNetCore.Razor.Language; + +namespace SharpLsp.Sidecar.CSharp.Razor; + +/// A tag helper directive of a legacy file or of an import: its name and its text. +internal sealed record TagHelperDirective(string Name, string Text); + +/// +/// The MVC tag helpers a legacy file can use and the prefix their tags carry: what its +/// @addTagHelper, @removeTagHelper and @tagHelperPrefix directives +/// declare, its imports' first, read as the compiler reads them. Implements [RAZOR-MVC-SCOPE]. +/// +internal sealed record RazorTagHelperScope( + string Prefix, + ImmutableArray TagHelpers +) +{ + private const string Add = "addTagHelper"; + private const string Remove = "removeTagHelper"; + private const string PrefixDirective = "tagHelperPrefix"; + private const string Global = "global::"; + + /// No tag helpers and no prefix: a component file's scope. + public static RazorTagHelperScope Empty { get; } = new("", []); + + /// The scope declare, in order, over . + public static RazorTagHelperScope Of( + IEnumerable directives, + IEnumerable all + ) + { + var mvc = all.Where(IsMvc).ToArray(); + return directives.Aggregate(Empty, (scope, directive) => scope.Apply(directive, mvc)); + } + + /// Whether a directive named declares the scope. + public static bool Declares(string name) + { + return name is Add or Remove or PrefixDirective; + } + + /// Whether a tag helper is an MVC one, which the directives scope, not a component document's. + public static bool IsMvc(TagHelperDescriptor helper) + { + return helper.Kind is TagHelperKind.ITagHelper or TagHelperKind.ViewComponent; + } + + /// Whether targets elements named . + public static bool Targets(TagMatchingRuleDescriptor rule, string tagName) + { + return rule.TagName is null or "*" + || string.Equals( + rule.TagName, + tagName, + rule.CaseSensitive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase + ); + } + + /// The tag helpers in scope that target a tag spelled , prefix included. + public IEnumerable Targeting(string tagName) + { + return tagName.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase) + ? TagHelpers.Where(helper => + helper.TagMatchingRules.Any(rule => Targets(rule, tagName[Prefix.Length..])) + ) + : []; + } + + private RazorTagHelperScope Apply(TagHelperDirective directive, TagHelperDescriptor[] mvc) + { + var text = Unquoted(directive.Text.Trim()); + return directive.Name switch + { + Add => this with { TagHelpers = [.. TagHelpers.Union(Matching(text, mvc))] }, + Remove => this with { TagHelpers = [.. TagHelpers.Except(Matching(text, mvc))] }, + PrefixDirective when text.Length > 0 => this with { Prefix = text }, + _ => this, + }; + } + + /// A directive's text without the quotes it may be written in. + private static string Unquoted(string text) + { + return text.Length >= 2 && text[0] == '"' && text[^1] == '"' ? text[1..^1] : text; + } + + /// + /// The tag helpers a TypePattern, AssemblyName lookup names: all of the assembly's + /// for *, those whose full name starts with what precedes a trailing *, else + /// the one so named. A lookup the compiler rejects names none. + /// + private static IEnumerable Matching( + string lookup, + TagHelperDescriptor[] mvc + ) + { + var parts = lookup.Split(',', StringSplitOptions.TrimEntries); + var valid = + parts is [{ Length: > 0 }, { Length: > 0 }] + && !lookup.StartsWith('\'') + && !lookup.EndsWith('\''); + var pattern = + valid && parts[0].StartsWith(Global, StringComparison.Ordinal) + ? parts[0][Global.Length..] + : parts[0]; + return valid + ? mvc.Where(helper => helper.AssemblyName == parts[1] && Matches(pattern, helper.Name)) + : []; + } + + private static bool Matches(string pattern, string name) + { + return pattern == "*" + || ( + pattern.Length > 1 && pattern[^1] == '*' + ? name.StartsWith(pattern[..^1], StringComparison.Ordinal) + : name == pattern + ); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CallHierarchyResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CallHierarchyResolver.cs index 4241e430..d0b16935 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CallHierarchyResolver.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CallHierarchyResolver.cs @@ -67,27 +67,40 @@ CancellationToken ct { foreach (var location in symbol.Locations.Where(l => l.IsInSource)) { - var tree = location.SourceTree; - if (tree is null) - { - continue; - } - - var model = await document - .Project.Solution.GetDocument(document.Id)! - .GetSemanticModelAsync(ct) + var (model, root) = await DeclarationAsync(document, location, ct) .ConfigureAwait(false); - if (model is null) + if (model is not null && root is not null) { - continue; + CollectOutgoingCalls(root.FindNode(location.SourceSpan), model, results, ct); } - - var root = await tree.GetRootAsync(ct).ConfigureAwait(false); - var node = root.FindNode(location.SourceSpan); - CollectOutgoingCalls(node, model, results, ct); } } + /// + /// The semantic model and syntax root of the tree is declared in: + /// the queried document's own — a Razor projection's included, which has no document of its + /// own in the solution — or, for a declaration in another regular document, that document's. + /// + private static async Task<(SemanticModel? Model, SyntaxNode? Root)> DeclarationAsync( + Document document, + Location location, + CancellationToken ct + ) + { + var tree = location.SourceTree; + var own = await document.GetSyntaxTreeAsync(ct).ConfigureAwait(false); + var owner = + tree is null ? null + : tree == own ? document + : document.Project.Solution.GetDocument(tree); + return owner is null + ? (null, null) + : ( + await owner.GetSemanticModelAsync(ct).ConfigureAwait(false), + await owner.GetSyntaxRootAsync(ct).ConfigureAwait(false) + ); + } + private static void CollectOutgoingCalls( SyntaxNode node, SemanticModel model, @@ -203,19 +216,23 @@ private static CallSiteResult ToCallSite(Location location) }; } - private static string MapSymbolKind(ISymbol symbol) + /// + /// The PascalCase kind the host maps to an LSP SymbolKind; a symbol LSP has no kind for + /// is a Function. [RAZOR-FEATURES-HIERARCHY] + /// + internal static string MapSymbolKind(ISymbol symbol) { return symbol.Kind switch { - SymbolKind.Method => "method", - SymbolKind.Property => "property", - SymbolKind.Field => "field", - SymbolKind.NamedType => "class", - SymbolKind.Event => "event", - SymbolKind.Namespace => "namespace", - SymbolKind.Local => "variable", - SymbolKind.Parameter => "parameter", - SymbolKind.TypeParameter => "typeParameter", + SymbolKind.Method => "Method", + SymbolKind.Property => "Property", + SymbolKind.Field => "Field", + SymbolKind.NamedType => "Class", + SymbolKind.Event => "Event", + SymbolKind.Namespace => "Namespace", + SymbolKind.Local => "Variable", + SymbolKind.Parameter => "Parameter", + SymbolKind.TypeParameter => "TypeParameter", SymbolKind.ArrayType or SymbolKind.PointerType or SymbolKind.FunctionPointerType @@ -227,8 +244,8 @@ or SymbolKind.Alias or SymbolKind.RangeVariable or SymbolKind.Assembly or SymbolKind.NetModule - or SymbolKind.Discard => "function", - _ => "function", + or SymbolKind.Discard => "Function", + _ => "Function", }; } } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeActionResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeActionResolver.cs index 46b12a36..4bf170d0 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeActionResolver.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeActionResolver.cs @@ -564,6 +564,7 @@ private static DocumentEditResult CreateAddedDocumentEdit(string filePath, strin { FilePath = filePath, Edits = [CreateWholeDocumentEdit(text)], + Create = true, }; } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeLensResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeLensResolver.cs index ab04ff74..5cc46bf0 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeLensResolver.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/CodeLensResolver.cs @@ -1,6 +1,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; namespace SharpLsp.Sidecar.CSharp.Workspace; @@ -10,11 +11,16 @@ namespace SharpLsp.Sidecar.CSharp.Workspace; /// internal static class CodeLensResolver { - /// Get code lenses for a document. + /// + /// Get code lenses for a document, each on its declaration. A declaration + /// moves is lensed where it puts it, and one it maps nowhere has no lens: that is how a Razor + /// file's lenses land on the declarations in its C# fragments ([RAZOR-FEATURES-CODE-LENS]). + /// public static async Task> GetLensesAsync( Document document, SearchScope scope, - CancellationToken ct + CancellationToken ct, + Func? place = null ) { var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false); @@ -25,7 +31,7 @@ CancellationToken ct } var lenses = new List(); - await CollectLensesAsync(root, model, scope, lenses, ct).ConfigureAwait(false); + await CollectLensesAsync(root, model, scope, lenses, place, ct).ConfigureAwait(false); return lenses; } @@ -34,6 +40,7 @@ private static async Task CollectLensesAsync( SemanticModel model, SearchScope scope, List lenses, + Func? place, CancellationToken ct ) { @@ -54,13 +61,7 @@ or ConstructorDeclarationSyntax } var symbol = model.GetDeclaredSymbol(node, ct); - if (symbol is null) - { - continue; - } - - var span = node.GetLocation().GetMappedLineSpan(); - if (!span.IsValid) + if (symbol is null || Declared(node, place) is not { } span) { continue; } @@ -81,6 +82,28 @@ symbol is INamedTypeSymbol implSymbol } } + /// + /// Where the lens for sits: its declaration's own mapped range, or, + /// when maps it, that range in the file it belongs to; null when the + /// declaration maps nowhere and its lens is dropped. + /// + private static FileLinePositionSpan? Declared( + SyntaxNode node, + Func? place + ) + { + var location = node.GetLocation(); + if (place is null) + { + var mapped = location.GetMappedLineSpan(); + return mapped.IsValid ? mapped : null; + } + + return place(location.GetLineSpan().Span) is { } placed + ? new FileLinePositionSpan(location.SourceTree?.FilePath ?? "", placed) + : null; + } + /// A lens titled at the start of . private static CodeLensResult LensAt(FileLinePositionSpan span, string title) { diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DefinitionResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DefinitionResolver.cs index e8465f6a..988c5017 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DefinitionResolver.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/DefinitionResolver.cs @@ -78,13 +78,31 @@ CancellationToken ct return new LocationListResult(); } - var metadataLocation = MetadataNavigator.ResolveMetadataSymbol(symbol, compilation); - + var metadataLocation = NamedMetadataLocation(project, symbol, compilation); return metadataLocation is null ? new LocationListResult() : new LocationListResult { Locations = [metadataLocation] }; } + /// + /// The decompiled source of , carrying its identity when an F# + /// project declares it, so the host can land in its F# source instead. [DEFINITION-CROSSLANG-IDENTITY] + /// + private static LocationResult? NamedMetadataLocation( + Project project, + ISymbol symbol, + Compilation compilation + ) + { + var decompiled = MetadataNavigator.ResolveMetadataSymbol(symbol, compilation); + return decompiled is null + ? null + : FSharpProjectSymbols.Naming( + decompiled, + FSharpProjectSymbols.IdentityOf(project, symbol) + ); + } + /// /// Single-location metadata fallback for type-definition and declaration. /// @@ -95,10 +113,9 @@ CancellationToken ct ) { var compilation = await document.Project.GetCompilationAsync(ct).ConfigureAwait(false); - return compilation is null ? null - : MetadataNavigator.ResolveMetadataSymbol(symbol, compilation); + : NamedMetadataLocation(document.Project, symbol, compilation); } /// Find the type definition of the symbol at a position. diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/FSharpProjectSymbols.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/FSharpProjectSymbols.cs new file mode 100644 index 00000000..b10df527 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/FSharpProjectSymbols.cs @@ -0,0 +1,133 @@ +using Microsoft.CodeAnalysis; +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.Common.Solutions; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +/// +/// Symbols a C# project reads from an F# project of the solution. C# sees an F# project only +/// as its built DLL ([DEFINITION-CROSSLANG]), so such a symbol is a metadata symbol whose +/// assembly is that DLL. Its identity, its assembly and XML doc signature, is the name the +/// F# sidecar finds it by, to navigate to its F# declaration, to find its F# uses and to +/// rename it there. Implements [DEFINITION-CROSSLANG-IDENTITY]. +/// +internal static class FSharpProjectSymbols +{ + /// + /// Whether , used in , is declared by an + /// F# project that references: it has no source, and its + /// assembly is that F# project's. Assembly names, not paths: a design-time build and + /// can spell one DLL's path apart. + /// + public static bool Declares(Project project, ISymbol symbol) + { + return !symbol.Locations.Any(location => location.IsInSource) + && symbol.ContainingAssembly?.Name is { } assembly + && FSharpAssembliesOf(project).Contains(assembly); + } + + /// + /// A metadata reference to , with the XML documentation the build + /// writes beside it, so hover shows an F# symbol's /// comments. [RAZOR-FSHARP-METADATA] + /// + public static MetadataReference Reference(string dll) + { + var xml = NativePaths.WithExtension(dll, ".xml"); + return MetadataReference.CreateFromFile( + dll, + documentation: File.Exists(xml) ? XmlDocumentationProvider.CreateFromFile(xml) : null + ); + } + + /// + /// The identity the F# sidecar finds by, when an F# project + /// declares it; null otherwise. A constructor is named by its type: F# declares a record's + /// or union's constructor nowhere but in the type itself. + /// + public static RenameIdentityResultWire? IdentityOf(Project project, ISymbol symbol) + { + var named = symbol is IMethodSymbol { MethodKind: MethodKind.Constructor } constructor + ? constructor.ContainingType + : symbol; + return Declares(project, named) ? Identity(named) : null; + } + + /// + /// reading the F# project's build output , + /// documented, in place of any reference of the same assembly: a design-time build hands C# + /// the F# project's reference assembly (obj/…/refint), which has no XML documentation + /// beside it. [RAZOR-FSHARP-METADATA] + /// + public static Solution WithDocumentedOutput(Solution solution, ProjectId projectId, string dll) + { + var stem = NativePaths.StemOf(dll); + var stale = solution + .GetProject(projectId)! + .MetadataReferences.OfType() + .Where(reference => + reference.FilePath is { } path + && NativePaths.SameName(NativePaths.StemOf(path), stem) + ) + .ToList(); + return stale + .Aggregate( + solution, + (current, reference) => current.RemoveMetadataReference(projectId, reference) + ) + .AddMetadataReference(projectId, Reference(dll)); + } + + /// + /// The assembly and XML doc signature both compilers give ; none + /// without either. FCS writes a method's signature without the ~ReturnType Roslyn + /// appends, which only a conversion operator needs to be told apart. [DEFINITION-CROSSLANG-IDENTITY] + /// + public static RenameIdentityResultWire Identity(ISymbol? symbol) + { + var assemblyName = symbol?.ContainingAssembly?.Name; + var declared = symbol is null ? null : DocumentationCommentId.CreateDeclarationId(symbol); + var xmlDocSig = + symbol is IMethodSymbol { MethodKind: not MethodKind.Conversion } + && declared?.IndexOf('~', StringComparison.Ordinal) is int at and >= 0 + ? declared[..at] + : declared; + return string.IsNullOrEmpty(assemblyName) || string.IsNullOrEmpty(xmlDocSig) + ? new RenameIdentityResultWire() + : new RenameIdentityResultWire + { + Found = true, + AssemblyName = assemblyName, + XmlDocSig = xmlDocSig, + }; + } + + /// , carrying the identity of the F# symbol it shows, if any. + public static LocationResult Naming(LocationResult location, RenameIdentityResultWire? symbol) + { + return symbol is null + ? location + : new LocationResult + { + FilePath = location.FilePath, + Line = location.Line, + Character = location.Character, + EndLine = location.EndLine, + EndCharacter = location.EndCharacter, + Symbol = symbol, + }; + } + + /// The assembly name of each F# project references. + private static HashSet FSharpAssembliesOf(Project project) + { + return project.FilePath is null + ? [] + : + [ + .. ProjectReferences + .ReadReferencedProjects(project.FilePath) + .Where(referenced => NativePaths.HasExtension(referenced, ".fsproj")) + .Select(ProjectReferences.AssemblyNameOf), + ]; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/InlayHintResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/InlayHintResolver.cs index 6d1179ad..9ad46873 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/InlayHintResolver.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/InlayHintResolver.cs @@ -33,7 +33,7 @@ CancellationToken ct var span = TextSpan.FromBounds(start, Math.Max(start, endPos)); var hints = new List(); - CollectParameterHints(root, model, span, hints, ct); + CollectParameterHints(root, model, text, span, hints, ct); CollectTypeHints(root, model, text, span, hints, ct); CollectLambdaTypeHints(root, model, text, span, hints, ct); return hints; @@ -42,6 +42,7 @@ CancellationToken ct private static void CollectParameterHints( SyntaxNode root, SemanticModel model, + SourceText text, TextSpan span, List hints, CancellationToken ct @@ -50,7 +51,7 @@ CancellationToken ct foreach (var invocation in root.DescendantNodes(span).OfType()) { ct.ThrowIfCancellationRequested(); - AddParameterHintsForArgs(invocation.ArgumentList, model, hints, ct); + AddParameterHintsForArgs(invocation.ArgumentList, model, text, hints, ct); } foreach ( @@ -60,7 +61,7 @@ var creation in root.DescendantNodes(span).OfType hints, CancellationToken ct ) @@ -85,17 +87,15 @@ CancellationToken ct continue; } - var pos = arg.Expression.GetLocation().GetMappedLineSpan(); - if (!pos.IsValid) - { - continue; - } - + // The position is in this document's own text, like every other hint's, so a caller + // reading it in another file — a Razor projection read in its Razor file — can place + // it, and a generated argument it cannot place is dropped rather than misplaced. + var pos = text.Lines.GetLinePosition(arg.Expression.SpanStart); hints.Add( new InlayHintResult { - Line = pos.StartLinePosition.Line, - Character = pos.StartLinePosition.Character, + Line = pos.Line, + Character = pos.Character, Label = $"{paramSymbol.Name}:", Kind = 2, // Parameter } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/TypeHierarchyResolver.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/TypeHierarchyResolver.cs index b838ac0a..5fcb8961 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/TypeHierarchyResolver.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/TypeHierarchyResolver.cs @@ -133,10 +133,10 @@ CancellationToken ct private static string MapKind(INamedTypeSymbol symbol) { - return symbol.TypeKind == TypeKind.Interface ? "interface" - : symbol.TypeKind == TypeKind.Struct ? "struct" - : symbol.TypeKind == TypeKind.Enum ? "enum" - : symbol.TypeKind == TypeKind.Delegate ? "delegate" - : "class"; + return symbol.TypeKind == TypeKind.Interface ? "Interface" + : symbol.TypeKind == TypeKind.Struct ? "Struct" + : symbol.TypeKind == TypeKind.Enum ? "Enum" + : symbol.TypeKind == TypeKind.Delegate ? "Delegate" + : "Class"; } } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Features.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Features.cs index 1c3feb9e..4f4f4e9a 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Features.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Features.cs @@ -24,18 +24,23 @@ namespace SharpLsp.Sidecar.CSharp.Workspace; /// internal sealed partial class WorkspaceManager { - /// Get code lenses for a document. + /// + /// Get code lenses for a document, or, for a Razor file, those of the members its C# + /// fragments declare, each on its Razor declaration. [RAZOR-FEATURES-CODE-LENS] + /// public Task GetCodeLensesAsync( string filePath, CancellationToken ct = default ) { - return RunScopedQueryAsync>( - filePath, - [], - (document, scope) => CodeLensResolver.GetLensesAsync(document, scope, ct), - ct - ); + return NativePaths.IsRazor(filePath) + ? GetRazorCodeLensesAsync(filePath, ct) + : RunScopedQueryAsync>( + filePath, + [], + (document, scope) => CodeLensResolver.GetLensesAsync(document, scope, ct), + ct + ); } /// Format an entire document. SEQUESTERED — not called by the LSP server. @@ -150,7 +155,10 @@ public Task GetSemanticTokensRangeAsync( ); } - /// Get inlay hints for a range. + /// + /// Get inlay hints for a range, or, for a Razor file, those of the C# its lines hold, each + /// placed back in the file. [RAZOR-FEATURES-INLAY-HINTS] + /// public Task GetInlayHintsAsync( string filePath, int startLine, @@ -158,11 +166,13 @@ public Task GetInlayHintsAsync( CancellationToken ct = default ) { - return RunDocumentQueryAsync>( - filePath, - [], - document => InlayHintResolver.GetHintsAsync(document, startLine, endLine, ct), - ct - ); + return NativePaths.IsRazor(filePath) + ? GetRazorInlayHintsAsync(filePath, startLine, endLine, ct) + : RunDocumentQueryAsync>( + filePath, + [], + document => InlayHintResolver.GetHintsAsync(document, startLine, endLine, ct), + ct + ); } } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignReferences.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignReferences.cs new file mode 100644 index 00000000..dc6780e3 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignReferences.cs @@ -0,0 +1,67 @@ +using ReferencesResult = Outcome.Result; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +/// A symbol the other sidecar names by identity, and whether its declaration counts. +internal readonly record struct ForeignSymbolQuery( + string AssemblyName, + string XmlDocSig, + bool IncludeDeclaration +); + +internal sealed partial class WorkspaceManager +{ + /// + /// Every C# and Razor use of the symbol the other sidecar names by identity, and its C# + /// declaration when C# declares it. Razor uses land in their Razor files, and hidden + /// scaffolding is dropped. Implements [REFERENCES-CROSS-LANGUAGE] and [RAZOR-FSHARP-REFERENCES]. + /// + public async Task GetForeignReferencesAsync( + ForeignSymbolQuery query, + CancellationToken ct = default + ) + { + try + { + return new ReferencesResult.Ok( + await ForeignReferencesAsync(query, ct).ConfigureAwait(false) + ); + } + catch (Exception ex) + { + return ReferencesResult.Failure(ex.Message); + } + } + + private async Task ForeignReferencesAsync( + ForeignSymbolQuery query, + CancellationToken ct + ) + { + var solution = _solution; + if ( + solution is null + || string.IsNullOrWhiteSpace(query.AssemblyName) + || string.IsNullOrWhiteSpace(query.XmlDocSig) + ) + { + return new LocationListResult(); + } + + var scope = new SearchScope(solution, null, null); + var identity = new ForeignRenameKey(query.AssemblyName, query.XmlDocSig); + var symbols = await ResolveForeignSymbolsAsync(solution, identity, ct) + .ConfigureAwait(false); + var found = await Task.WhenAll( + symbols.Select(symbol => + ReferenceResolver.ReferencesOfAsync(symbol, scope, query.IncludeDeclaration, ct) + ) + ) + .ConfigureAwait(false); + return await MapLocationsAsync( + ReferenceResolver.Distinct(found.SelectMany(result => result.Locations)), + ct + ) + .ConfigureAwait(false); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignRename.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignRename.cs index 3b2c4ca2..32315e00 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignRename.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.ForeignRename.cs @@ -48,6 +48,11 @@ public async Task GetRenameIdentityAsync( } } + /// + /// The identity of the symbol a rename at the position targets: a C# source symbol, or an + /// F# project's symbol C# uses. A Razor position resolves through its projection, so a + /// rename asked from a Razor file reaches the other language too. [RAZOR-FSHARP-RENAME] + /// private async Task ResolveRenameIdentityAsync( string filePath, int line, @@ -55,29 +60,13 @@ private async Task ResolveRenameIdentityAsync( CancellationToken ct ) { - var document = await FindDocumentAsync(filePath, ct).ConfigureAwait(false); - if (document is null) - { - return new RenameIdentityResultWire(); - } - - var target = await FindRenameTargetAsync(document, line, character, ct) + var at = await ResolveQueryTargetAsync(filePath, (line, character), ct) .ConfigureAwait(false); - return CreateRenameIdentity(target?.Symbol); - } - - private static RenameIdentityResultWire CreateRenameIdentity(ISymbol? symbol) - { - var assemblyName = symbol?.ContainingAssembly?.Name; - var xmlDocSig = symbol is null ? null : DocumentationCommentId.CreateDeclarationId(symbol); - return string.IsNullOrEmpty(assemblyName) || string.IsNullOrEmpty(xmlDocSig) - ? new RenameIdentityResultWire() - : new RenameIdentityResultWire - { - Found = true, - AssemblyName = assemblyName, - XmlDocSig = xmlDocSig, - }; + var target = at is null + ? null + : await FindRenameTargetAsync(at.Document, at.Line, at.Character, ct) + .ConfigureAwait(false); + return FSharpProjectSymbols.Identity(target?.Symbol); } public async Task RenameForeignAsync( diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs index d79a0c95..002dec09 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Helpers.cs @@ -211,7 +211,11 @@ _lastCompletionList is null ); result.AdditionalEdits.AddRange( _lastCompletionOrigin is { } origin - ? RazorCompletionEdits.Map(origin, additional) + ? RazorCompletionEdits.Map( + origin, + additional, + await RazorMarkupEngine.ProcessAsync(origin.Razor, ct).ConfigureAwait(false) + ) : additional.Select(textChange => DocumentText.ToTextEdit(text, textChange)) ); return result; diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Hierarchy.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Hierarchy.cs index 8a8e571f..f5858380 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Hierarchy.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Hierarchy.cs @@ -12,10 +12,13 @@ namespace SharpLsp.Sidecar.CSharp.Workspace; /// -/// Call hierarchy and type hierarchy workspace methods. +/// Call hierarchy and type hierarchy workspace methods: each runs where the position lands — a +/// C# document, or a Razor file's projection — and places what it finds in the file it belongs +/// to, dropping hidden scaffolding ([RAZOR-FEATURES-HIERARCHY]). /// internal sealed partial class WorkspaceManager { + /// Prepare a call hierarchy item where the position lands. public Task PrepareCallHierarchyAsync( string filePath, int line, @@ -23,14 +26,10 @@ public Task PrepareCallHierarchyAsync( CancellationToken ct = default ) { - return RunDocumentQueryAsync( - filePath, - null, - document => CallHierarchyResolver.PrepareAsync(document, line, character, ct), - ct - ); + return RunItemAtAsync(filePath, (line, character), CallHierarchyResolver.PrepareAsync, ct); } + /// The calls of the symbol the position lands on, from each project's active framework. public Task GetIncomingCallsAsync( string filePath, int line, @@ -38,7 +37,7 @@ public Task GetIncomingCallsAsync( CancellationToken ct = default ) { - return RunScopedAtAsync( + return RunScopedRowsAtAsync( filePath, (line, character), CallHierarchyResolver.GetIncomingAsync, @@ -46,6 +45,7 @@ public Task GetIncomingCallsAsync( ); } + /// The calls the symbol the position lands on makes. public Task GetOutgoingCallsAsync( string filePath, int line, @@ -53,14 +53,15 @@ public Task GetOutgoingCallsAsync( CancellationToken ct = default ) { - return RunDocumentQueryAsync( + return RunRowsAtAsync( filePath, - new List(), - document => CallHierarchyResolver.GetOutgoingAsync(document, line, character, ct), + (line, character), + CallHierarchyResolver.GetOutgoingAsync, ct ); } + /// Prepare a type hierarchy item where the position lands. public Task PrepareTypeHierarchyAsync( string filePath, int line, @@ -68,14 +69,10 @@ public Task PrepareTypeHierarchyAsync( CancellationToken ct = default ) { - return RunDocumentQueryAsync( - filePath, - null, - document => TypeHierarchyResolver.PrepareAsync(document, line, character, ct), - ct - ); + return RunItemAtAsync(filePath, (line, character), TypeHierarchyResolver.PrepareAsync, ct); } + /// The supertypes of the type the position lands on. public Task GetSupertypesAsync( string filePath, int line, @@ -83,14 +80,18 @@ public Task GetSupertypesAsync( CancellationToken ct = default ) { - return RunDocumentQueryAsync( + return RunRowsAtAsync( filePath, - new List(), - document => TypeHierarchyResolver.GetSupertypesAsync(document, line, character, ct), + (line, character), + TypeHierarchyResolver.GetSupertypesAsync, ct ); } + /// + /// The subtypes of the type the position lands on, from each project's active framework + /// ([NETFX-PROJECTS-CSHARP]). + /// public Task GetSubtypesAsync( string filePath, int line, @@ -98,7 +99,7 @@ public Task GetSubtypesAsync( CancellationToken ct = default ) { - return RunScopedAtAsync( + return RunScopedRowsAtAsync( filePath, (line, character), TypeHierarchyResolver.GetSubtypesAsync, @@ -106,21 +107,93 @@ public Task GetSubtypesAsync( ); } + /// The hierarchy item finds, placed in the file it belongs to. + private Task RunItemAtAsync( + string filePath, + (int Line, int Character) at, + Func> prepare, + CancellationToken ct + ) + { + return RunPlacedAtAsync( + filePath, + at, + null, + prepare, + (item, token) => PlaceAsync(item, token), + ct + ); + } + + /// The hierarchy rows finds, each placed in its file. + private Task, string>> RunRowsAtAsync( + string filePath, + (int Line, int Character) at, + Func>> query, + CancellationToken ct + ) + where T : HierarchyItem + { + return RunPlacedAtAsync( + filePath, + at, + [], + query, + (rows, token) => PlaceAsync(rows, token), + ct + ); + } + + /// + /// What finds where the position lands — a C# document, or a Razor + /// file's projection — placed by in the file it belongs to. + /// + private Task> RunPlacedAtAsync( + string filePath, + (int Line, int Character) at, + TValue empty, + Func> query, + Func> place, + CancellationToken ct + ) + { + return RunQueryAsync( + () => ResolveHierarchyTargetAsync(filePath, at, ct), + empty, + async target => + await place( + await query(target.Document, target.Line, target.Character, ct) + .ConfigureAwait(false), + ct + ) + .ConfigureAwait(false) + ); + } + /// - /// A hierarchy list at a position, searched in each project's active framework - /// ([NETFX-PROJECTS-CSHARP]); empty when the document is not in the solution. + /// for a query that searches beyond the document: + /// also gets each project's active framework to search. /// - private Task, string>> RunScopedAtAsync( + private Task, string>> RunScopedRowsAtAsync( string filePath, (int Line, int Character) at, Func>> query, CancellationToken ct ) + where T : HierarchyItem { - return RunScopedQueryAsync( + return RunRowsAtAsync( filePath, - new List(), - (document, scope) => query(document, scope, at.Line, at.Character, ct), + at, + async (document, line, character, token) => + await query( + document, + await ScopeAsync(document, token).ConfigureAwait(false), + line, + character, + token + ) + .ConfigureAwait(false), ct ); } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Navigation.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Navigation.cs index 7f60fe70..53861ecc 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Navigation.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Navigation.cs @@ -74,32 +74,71 @@ public async Task GetDefinitionAsync( filePath, (line, character), new LocationListResult(), - async target => - await MapLocationsAsync( - await DefinitionResolver - .ResolveDefinitionLocationsAsync( - target.Document, - target.Line, - target.Character, - ct - ) - .ConfigureAwait(false), - ct - ) - .ConfigureAwait(false), + target => DefinitionAtAsync(target, ct), ct ) .ConfigureAwait(false); - // Where no C# fragment answers in a Razor file, the markup name there does. - // [RAZOR-FEATURES-DEFINITION] - return - csharp - is ImplementationsResult.Ok { Value.Locations.Count: 0 } - && NativePaths.IsRazor(filePath) - ? await MarkupDefinitionAsync(filePath, line, character, ct).ConfigureAwait(false) + return IsEmpty(csharp) && NativePaths.IsRazor(filePath) + ? await RazorMarkupDefinitionAsync(filePath, line, character, ct).ConfigureAwait(false) : csharp; } + /// + /// The definition at a target: the collocated module an import call names, ahead of any + /// enclosing declaration the position would fall back to ([RAZOR-COLLOCATION-JS]); else the + /// declarations of the symbol there. + /// + private async Task DefinitionAtAsync( + QueryTarget target, + CancellationToken ct + ) + { + var module = await ModuleStartAsync(target, ct).ConfigureAwait(false); + return module.Locations.Count > 0 + ? module + : await MapLocationsAsync( + await DefinitionResolver + .ResolveDefinitionLocationsAsync( + target.Document, + target.Line, + target.Character, + ct + ) + .ConfigureAwait(false), + ct + ) + .ConfigureAwait(false); + } + + /// + /// Where no C# fragment answers in a Razor file: the markup name there + /// ([RAZOR-FEATURES-DEFINITION]), else the collocated stylesheet's selectors of the class at + /// the caret ([RAZOR-COLLOCATION-CSS]). + /// + private async Task RazorMarkupDefinitionAsync( + string filePath, + int line, + int character, + CancellationToken ct + ) + { + var markup = await MarkupDefinitionAsync(filePath, line, character, ct) + .ConfigureAwait(false); + return IsEmpty(markup) + ? new ImplementationsResult.Ok( + await CollocatedClassDefinitionAsync(filePath, line, character, ct) + .ConfigureAwait(false) + ) + : markup; + } + + /// Whether a definition answered, and found nothing. + private static bool IsEmpty(ImplementationsResult result) + { + return result + is ImplementationsResult.Ok { Value.Locations.Count: 0 }; + } + /// Go to type definition at a position. public Task GetTypeDefinitionAsync( string filePath, diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorCodeActions.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorCodeActions.cs new file mode 100644 index 00000000..45d7fcf0 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorCodeActions.cs @@ -0,0 +1,113 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Razor; +using CodeActionsResult = Outcome.Result< + System.Collections.Generic.List, + string +>; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +/// +/// Razor code actions: quick fixes for C# names the projection cannot bind, and refactorings +/// that move a block or elements into a new file. Each is offered with its edit computed, under +/// a negative id so it never meets a Roslyn action's; a new request replaces the last offers. +/// Implements [RAZOR-CODE-ACTIONS]. +/// +internal sealed partial class WorkspaceManager +{ + private ImmutableDictionary _razorActions = []; + + private int _lastRazorAction; + + /// The Razor file's code actions at ; none for a file no project lists. + private Task GetRazorCodeActionsAsync( + string filePath, + LinePositionSpan range, + CancellationToken ct + ) + { + return RunQueryAsync( + async () => + { + var projection = await FindRazorProjectionAsync(filePath, ct).ConfigureAwait(false); + var markup = await FindRazorMarkupAsync(filePath, ct).ConfigureAwait(false); + return projection is null || markup is null + ? null + : new RazorFile(projection, markup); + }, + new List(), + async found => + Offer( + await RazorActionsAsync(found.Projection, found.Markup, range, ct) + .ConfigureAwait(false) + ) + ); + } + + /// A Razor file's projection and markup facts, which its code actions read. + private sealed record RazorFile(RazorProjection Projection, RazorMarkup Markup); + + private static async Task> RazorActionsAsync( + RazorProjection projection, + RazorMarkup markup, + LinePositionSpan range, + CancellationToken ct + ) + { + var span = markup.Text.Lines.GetTextSpan(range); + var model = + await projection.Generated.GetSemanticModelAsync(ct).ConfigureAwait(false) + ?? throw new InvalidOperationException("The projection has no semantic model"); + var fixes = await RazorQuickFixes + .ForAsync(projection, markup, span, ct) + .ConfigureAwait(false); + RazorCodeAction?[] refactorings = + [ + RazorExtractActions.CodeBehind(projection, markup, model.Compilation, span.Start), + RazorExtractActions.Component(projection, markup, model, span), + ]; + return fixes.Concat(refactorings.OfType()); + } + + /// Offer in place of the last ones offered. + private List Offer(IEnumerable actions) + { + var offered = actions + .Select(action => (Id: Interlocked.Decrement(ref _lastRazorAction), Action: action)) + .ToList(); + _razorActions = offered.ToImmutableDictionary( + entry => entry.Id, + entry => entry.Action.Edit + ); + return + [ + .. offered.Select(entry => new CodeActionItem + { + Id = entry.Id, + Title = entry.Action.Title, + Kind = entry.Action.Kind, + IsPreferred = entry.Action.IsPreferred, + }), + ]; + } + + /// The edit of a Razor action offered last; null for any other id. + private WorkspaceEditResult? ResolveRazorAction(int actionId) + { + return _razorActions.GetValueOrDefault(actionId); + } + + /// Whether names a Razor action: Roslyn's ids count up from one. + private static bool IsRazorAction(int actionId) + { + return actionId < 0; + } + + /// Whether a code action request is for a Razor file. + private static bool IsRazorRequest(string filePath) + { + return NativePaths.IsRazor(filePath); + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorCollocation.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorCollocation.cs new file mode 100644 index 00000000..a2ceddaf --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorCollocation.cs @@ -0,0 +1,157 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using Serilog; +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Razor; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +/// +/// The collocated files of a Razor file: the stylesheet its CSS isolation declares +/// (Card.razor.css) and the JavaScript module it imports (Card.razor.js). +/// Implements [RAZOR-COLLOCATION]. +/// +internal sealed partial class WorkspaceManager +{ + /// + /// The items the collocated stylesheet adds at a caret inside a class value; none + /// elsewhere or when the file has no stylesheet. [RAZOR-COLLOCATION-CSS] + /// + private static async Task> CollocatedClassCompletionsAsync( + TextDocument? razor, + RazorMarkup markup, + int offset, + CancellationToken ct + ) + { + return + razor is not null + && RazorCollocation.ClassValueAt(markup, offset) is { } at + && await CollocatedStylesheetAsync(razor, ct).ConfigureAwait(false) is { } stylesheet + ? RazorCollocation.ClassCompletions(markup, stylesheet, at) + : []; + } + + /// The start of the collocated module an import call at the target's position names. + private static async Task ModuleStartAsync( + QueryTarget target, + CancellationToken ct + ) + { + var module = await ImportedModuleAsync(target, ct).ConfigureAwait(false); + return module is null + ? new LocationListResult() + : new LocationListResult { Locations = [StartOf(module)] }; + } + + /// The selectors of the class named in a class value's collocated stylesheet. [RAZOR-COLLOCATION-CSS] + private async Task CollocatedClassDefinitionAsync( + string filePath, + int line, + int character, + CancellationToken ct + ) + { + var razor = FindRazorDocument(filePath); + if (razor is null) + { + return new LocationListResult(); + } + + var markup = await RazorMarkupEngine.ProcessAsync(razor, ct).ConfigureAwait(false); + return + markup is not null + && DocumentText.TryOffset(markup.Text, new LinePosition(line, character)) is { } offset + && RazorCollocation.ClassValueAt(markup, offset) is { } at + && await CollocatedStylesheetAsync(razor, ct).ConfigureAwait(false) is { } stylesheet + ? RazorCollocation.ClassDefinitions(markup, stylesheet, at) + : new LocationListResult(); + } + + /// + /// The stylesheet beside , from its project's own copy of the file + /// when it lists one, else read from disk: an editor never sends a stylesheet as a document + /// ([RAZOR-COLLOCATION-CSS]). + /// + private static async Task CollocatedStylesheetAsync( + TextDocument razor, + CancellationToken ct + ) + { + var path = NativePaths.StylesheetOf(razor.FilePath ?? ""); + var buffered = razor.Project.AdditionalDocuments.FirstOrDefault(document => + NativePaths.AreEqual(document.FilePath, path) + ); + var text = buffered is null + ? await ReadCollocatedTextAsync(path, ct).ConfigureAwait(false) + : await buffered.GetTextAsync(ct).ConfigureAwait(false); + return text is null ? null : RazorStylesheet.Parse(path, text); + } + + private static async Task ReadCollocatedTextAsync( + string path, + CancellationToken ct + ) + { + if (!File.Exists(path)) + { + return null; + } + + try + { + return SourceText.From(await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Log.Warning("[Razor] collocated file unreadable {File} {Failure}", path, ex.GetType()); + return null; + } + } + + /// The collocated module file an import call at a position names in the project. + private static async Task ImportedModuleAsync(QueryTarget target, CancellationToken ct) + { + return + await ModuleLiteralAsync(target, ct).ConfigureAwait(false) is { } literal + && await target.Document.GetSemanticModelAsync(ct).ConfigureAwait(false) is { } model + && RazorCollocation.ImportedModule(model, literal, ct) is { } path + ? ModuleTarget(target.Document.Project, path) + : null; + } + + /// The string literal at the target's position, when the caret sits in one. + private static async Task ModuleLiteralAsync( + QueryTarget target, + CancellationToken ct + ) + { + var root = await target.Document.GetSyntaxRootAsync(ct).ConfigureAwait(false); + var text = await target.Document.GetTextAsync(ct).ConfigureAwait(false); + return + root is not null + && DocumentText.TryOffset(text, new LinePosition(target.Line, target.Character)) + is { } offset + ? RazorCollocation.ModuleLiteral(root, offset) + : null; + } + + /// The module file names in the project, when it exists. + private static string? ModuleTarget(Project project, string path) + { + return + project.FilePath is not null + && RazorCollocation.ModuleFile(NativePaths.DirectoryOf(project.FilePath), path) + is { } module + && File.Exists(module) + ? module + : null; + } + + /// A definition location at the very start of . + private static LocationResult StartOf(string filePath) + { + return new LocationResult { FilePath = filePath }; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorHierarchy.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorHierarchy.cs new file mode 100644 index 00000000..d5287cc3 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorHierarchy.cs @@ -0,0 +1,240 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.Common; +using SharpLsp.Sidecar.CSharp.Razor; +using CodeLensesResult = Outcome.Result< + System.Collections.Generic.List, + string +>; +using InlayHintsResult = Outcome.Result< + System.Collections.Generic.List, + string +>; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +/// +/// Inlay hints, code lenses and hierarchies answered from Razor positions: each runs on the +/// file's C# projection and puts what it finds back in the Razor text, dropping whatever lies +/// in the projection's hidden scaffolding. Implements [RAZOR-ARCHITECTURE-PROJECTION], +/// [RAZOR-FEATURES-INLAY-HINTS], [RAZOR-FEATURES-CODE-LENS] and [RAZOR-FEATURES-HIERARCHY]. +/// +internal sealed partial class WorkspaceManager +{ + /// + /// A Razor file's inlay hints for its lines ..: + /// those of the generated lines they map to, each placed back in the file. + /// [RAZOR-FEATURES-INLAY-HINTS] + /// + private Task GetRazorInlayHintsAsync( + string filePath, + int startLine, + int endLine, + CancellationToken ct + ) + { + return RunQueryAsync>( + () => FindRazorProjectionAsync(filePath, ct), + [], + async projection => + await HintsAsync(projection, startLine, endLine, ct).ConfigureAwait(false) + ); + } + + private static async Task> HintsAsync( + RazorProjection projection, + int startLine, + int endLine, + CancellationToken ct + ) + { + // A reader asking to the end of a line means to see it, but the resolver's range stops + // at the start of the line it ends on, so the window reaches one line further. + var hints = RazorHints.Lines(projection, startLine, endLine) is not { } lines + ? [] + : await InlayHintResolver + .GetHintsAsync(projection.Generated, lines.Start, lines.End + 1, ct) + .ConfigureAwait(false); + return RazorHints.Map(hints, projection, startLine, endLine); + } + + /// + /// A Razor file's code lenses: those of the members its projection declares in C# fragments, + /// each on its Razor declaration, so its class and generated members are not lensed. + /// [RAZOR-FEATURES-CODE-LENS] + /// + private Task GetRazorCodeLensesAsync(string filePath, CancellationToken ct) + { + return RunQueryAsync>( + () => FindRazorProjectionAsync(filePath, ct), + [], + async projection => + await CodeLensResolver + .GetLensesAsync( + projection.Generated, + await ScopeAsync(projection.Generated, ct).ConfigureAwait(false), + ct, + projection.ToRazor + ) + .ConfigureAwait(false) + ); + } + + /// The locations hierarchy answers search, in each project's active framework. + private Task ScopeAsync(Document document, CancellationToken ct) + { + return SearchScope.OfAsync(document, _activeFrameworks, ct); + } + + /// + /// Where a hierarchy request at a Razor position runs: the mapped C# position, or, at the + /// very start of the file, the component class its projection generates. The second is the + /// inverse of the rule that lands that class at the file's start, so the follow-up a client + /// makes from such an item — subtypes of a component, and its calls — answers from the + /// component's own file. [RAZOR-ARCHITECTURE-PROJECTION] + /// + private async Task ResolveHierarchyTargetAsync( + string filePath, + (int Line, int Character) at, + CancellationToken ct + ) + { + return await ResolveQueryTargetAsync(filePath, at, ct).ConfigureAwait(false) + ?? ( + NativePaths.IsRazor(filePath) && at is (0, 0) + ? await ComponentTargetAsync(filePath, ct).ConfigureAwait(false) + : null + ); + } + + /// The component class generates, as a query target. + private async Task ComponentTargetAsync(string razorPath, CancellationToken ct) + { + var projection = await FindRazorProjectionAsync(razorPath, ct).ConfigureAwait(false); + var declared = projection is null ? null : FileClass(projection); + if (projection is null || declared is null) + { + return null; + } + + var at = projection.GeneratedText.Lines.GetLinePosition(declared.Identifier.SpanStart); + return new QueryTarget(projection.Generated, at.Line, at.Character, projection); + } + + /// The class a projection generates for its Razor file: the file's own type. + private static ClassDeclarationSyntax? FileClass(RazorProjection projection) + { + return projection + .GeneratedRoot.DescendantNodes() + .OfType() + .FirstOrDefault(); + } + + /// + /// placed in the file it belongs to, or null when its declaration + /// cannot be placed. A call row's sites are inside its declaration, so they are placed with + /// it: within a C# fragment they already carry their Razor range, and a row that cannot be + /// placed takes its sites with it. [RAZOR-FEATURES-HIERARCHY] + /// + private async Task PlaceAsync(T? item, CancellationToken ct) + where T : HierarchyItem + { + if (item is null) + { + return null; + } + + var declaration = DeclarationOf(item); + if (await MapLocationAsync(declaration, ct).ConfigureAwait(false) is { } mapped) + { + return Place(item, mapped); + } + + var file = await FileMemberAsync(declaration, ct).ConfigureAwait(false); + return file is null + ? null + : Place(AsFile(item, file), new LocationResult { FilePath = file }); + } + + /// + /// The Razor file whose projection declares in its own + /// generated class, its own members included; null for a location in no projection, or in + /// the scaffolding of one outside that class, which has nothing in the file to point at. A + /// component is its file, so the class and its members are the file's C#, and a call made from + /// them names the file as its caller ([RAZOR-ARCHITECTURE-PROJECTION]). + /// + private async Task FileMemberAsync(LocationResult declaration, CancellationToken ct) + { + var projection = _solution is null + ? null + : await RazorLocations + .FindProjectionAsync(_solution, declaration.FilePath, ct) + .ConfigureAwait(false); + return projection is not null && Owns(projection, declaration) + ? projection.RazorPath + : null; + } + + /// + /// A generated member's item as the component it belongs to: named by its Razor file and a + /// class, because a follow-up at the file's start runs on the component class + /// ([RAZOR-FEATURES-HIERARCHY]), never on a member the file does not show. + /// + private static T AsFile(T item, string razorPath) + where T : HierarchyItem + { + item.Name = NativePaths.NameOf(razorPath); + item.Kind = "Class"; + return item; + } + + /// Whether the file's own generated class holds . + private static bool Owns(RazorProjection projection, LocationResult declaration) + { + var at = new LinePosition(declaration.Line, declaration.Character); + return DocumentText.TryOffset(projection.GeneratedText, at) is { } offset + && FileClass(projection) is { } own + && projection.GeneratedRoot.FindToken(offset).Parent?.AncestorsAndSelf().Contains(own) + == true; + } + + /// placed in their files, hidden scaffolding dropped. + private async Task> PlaceAsync(List items, CancellationToken ct) + where T : HierarchyItem + { + var placed = new List(); + foreach (var item in items) + { + if (await PlaceAsync(item, ct).ConfigureAwait(false) is { } found) + { + placed.Add(found); + } + } + + return placed; + } + + private static LocationResult DeclarationOf(HierarchyItem item) + { + return new LocationResult + { + FilePath = item.FilePath, + Line = item.Line, + Character = item.Character, + EndLine = item.EndLine, + EndCharacter = item.EndCharacter, + }; + } + + private static T Place(T item, LocationResult place) + where T : HierarchyItem + { + item.FilePath = place.FilePath; + item.Line = place.Line; + item.Character = place.Character; + item.EndLine = place.EndLine; + item.EndCharacter = place.EndCharacter; + return item; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorMarkup.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorMarkup.cs index ddcfc578..2591b38c 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorMarkup.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorMarkup.cs @@ -85,7 +85,16 @@ CancellationToken ct var markup = csharpAnswered ? null : RazorMarkupCompletion.At(found.Markup, found.Compilation, found.Offset); - return [.. RazorMarkupCompletion.Directives(found.Markup, found.Offset), .. markup ?? []]; + // The collocated stylesheet's classes complete inside a `class` value. + // [RAZOR-COLLOCATION-CSS] + var classes = await CollocatedClassCompletionsAsync(razor, found.Markup, found.Offset, ct) + .ConfigureAwait(false); + return + [ + .. RazorMarkupCompletion.Directives(found.Markup, found.Offset), + .. markup ?? [], + .. classes, + ]; } /// What a markup query at a position works on: the facts, the compilation, the offset. diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorSelection.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorSelection.cs new file mode 100644 index 00000000..bf7d2d65 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.RazorSelection.cs @@ -0,0 +1,76 @@ +using Microsoft.CodeAnalysis.Text; +using SharpLsp.Sidecar.CSharp.Razor; + +namespace SharpLsp.Sidecar.CSharp.Workspace; + +/// +/// Selection ranges of Razor files: each position chains from its innermost C# fragment or +/// markup span out to the whole document, so an editor can grow the selection outward. A file +/// no project lists, or a non-Razor path, answers empty. Implements +/// [RAZOR-FEATURES-SELECTION]. +/// +internal sealed partial class WorkspaceManager +{ + /// One answer per position, in order; empty when the file has no workspace presence. + public async Task, string>> GetSelectionRangesAsync( + string filePath, + List<(int Line, int Character)> positions, + CancellationToken ct + ) + { + try + { + var projection = await FindRazorProjectionAsync(filePath, ct).ConfigureAwait(false); + var markup = await FindRazorMarkupAsync(filePath, ct).ConfigureAwait(false); + var text = markup?.Text ?? projection?.RazorText; + return new Outcome.Result, string>.Ok< + List, + string + >( + text is null + ? [.. positions.Select(_ => new SelectionRangeResult())] + : [.. positions.Select(position => At(projection, markup, text, position))] + ); + } + catch (Exception ex) + { + return Outcome.Result, string>.Failure(ex.Message); + } + } + + /// One position's chain, mapped onto the file's lines. + private static SelectionRangeResult At( + RazorProjection? projection, + RazorMarkup? markup, + SourceText text, + (int Line, int Character) position + ) + { + var offset = DocumentText.TryOffset( + text, + new LinePosition(position.Line, position.Character) + ); + return new SelectionRangeResult + { + Ranges = + [ + .. ( + offset is { } found ? RazorSelectionRanges.At(projection, markup, found) : [] + ).Select(span => SpanOf(text, span)), + ], + }; + } + + /// A Razor offset span as its (line, character) bounds. + private static SelectionSpanResult SpanOf(SourceText text, TextSpan span) + { + var at = text.Lines.GetLinePositionSpan(span); + return new SelectionSpanResult + { + StartLine = at.Start.Line, + StartCharacter = at.Start.Character, + EndLine = at.End.Line, + EndCharacter = at.End.Character, + }; + } +} diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Rename.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Rename.cs index e2c31307..09e0c428 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Rename.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.Rename.cs @@ -13,11 +13,16 @@ namespace SharpLsp.Sidecar.CSharp.Workspace; internal sealed partial class WorkspaceManager { + /// + /// What a rename at a position renames. A foreign target is a symbol an F# project + /// declares, which C# reads from its DLL: F# renames its declaration. [RAZOR-FSHARP-RENAME] + /// private readonly record struct RenameTarget( Document Document, SourceText Text, ISymbol Symbol, - SyntaxToken Token + SyntaxToken Token, + bool Foreign ); private readonly record struct RenameSolutions( @@ -80,21 +85,48 @@ CancellationToken ct { var (target, _) = await FindRenameTargetAsync(filePath, line, character, ct) .ConfigureAwait(false); - if (target is null || _solution is null || !CanUseNewName(target.Value, newName)) - { - return EmptyRenameResult(); - } + return target is null || _solution is null || !CanUseNewName(target.Value, newName) + ? EmptyRenameResult() + : target.Value.Foreign + ? await RenameFSharpSymbolAsync(target.Value.Symbol, newName, ct) + .ConfigureAwait(false) + : await RenameSourceSymbolAsync(_solution, target.Value.Symbol, newName, ct) + .ConfigureAwait(false); + } - var renamed = await RenameSolutionAsync(_solution, target.Value.Symbol, newName, ct) + /// A C# source symbol's rename: Roslyn's edits, and its Razor uses'. + private static async Task RenameSourceSymbolAsync( + Solution solution, + ISymbol symbol, + string newName, + CancellationToken ct + ) + { + var renamed = await RenameSolutionAsync(solution, symbol, newName, ct) .ConfigureAwait(false); - var edits = await BuildRenameEditsAsync(_solution, renamed, newName, ct) + var edits = await BuildRenameEditsAsync(solution, renamed, newName, ct) .ConfigureAwait(false); var razor = await RazorRenameEdits - .FindAsync(_solution, target.Value.Symbol, newName, ct) + .FindAsync(solution, symbol, newName, ct) .ConfigureAwait(false); return RenameSuccess(new WorkspaceEditResult { DocumentChanges = [.. edits, .. razor] }); } + /// + /// An F# project's symbol, renamed from C# or Razor: its C# and Razor uses here. The host + /// adds F#'s edits, its declaration included, through the symbol's identity. + /// [RAZOR-FSHARP-RENAME] [RENAME-CROSSLANGUAGE] + /// + private Task RenameFSharpSymbolAsync( + ISymbol symbol, + string newName, + CancellationToken ct + ) + { + var identity = FSharpProjectSymbols.Identity(symbol); + return RenameForeignCoreAsync(identity.AssemblyName, identity.XmlDocSig, newName, ct); + } + /// /// The rename target at a position, and the Razor file it was asked from. A Razor position /// outside every C# fragment has no target; any other file must be in the workspace. @@ -139,8 +171,11 @@ CancellationToken ct var symbol = await FindSymbolAsync(document, position, ct).ConfigureAwait(false); var targetSymbol = symbol is null ? null : RenameConflictTarget(symbol); - return targetSymbol is not null && IsSourceSymbol(targetSymbol) - ? new RenameTarget(document, text, targetSymbol, token!.Value) + var foreign = + targetSymbol is not null + && FSharpProjectSymbols.Declares(document.Project, targetSymbol); + return targetSymbol is not null && (foreign || IsSourceSymbol(targetSymbol)) + ? new RenameTarget(document, text, targetSymbol, token!.Value, foreign) : null; } diff --git a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs index f8f1a0fb..13f523ff 100644 --- a/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs +++ b/src/sidecars/SharpLsp.Sidecar.CSharp/Workspace/WorkspaceManager.cs @@ -317,6 +317,15 @@ public Task GetCodeActionsAsync( CancellationToken ct = default ) { + if (IsRazorRequest(filePath)) + { + var range = new LinePositionSpan( + new LinePosition(startLine, startCharacter), + new LinePosition(endLine, endCharacter) + ); + return GetRazorCodeActionsAsync(filePath, range, ct); + } + return RunDocumentQueryAsync>( filePath, [], @@ -347,9 +356,11 @@ public async Task ResolveCodeActionAsync( return ResolveResult.Failure("No solution loaded"); } - var result = await _codeActionResolver - .ResolveAsync(actionId, _solution, ct) - .ConfigureAwait(false); + var result = IsRazorAction(actionId) + ? ResolveRazorAction(actionId) + : await _codeActionResolver + .ResolveAsync(actionId, _solution, ct) + .ConfigureAwait(false); return result is null ? ResolveResult.Failure($"Code action {actionId} not found") : new ResolveResult.Ok(result); @@ -596,6 +607,7 @@ var stubRef in project solution = solution.RemoveProjectReference(projectId, stubRef); } + solution = FSharpProjectSymbols.WithDocumentedOutput(solution, projectId, dll); project = solution.GetProject(projectId)!; // Add the referenced project's output DLL *and its sibling assemblies*. @@ -616,7 +628,7 @@ var stubRef in project { solution = solution.AddMetadataReference( projectId, - MetadataReference.CreateFromFile(sibling) + FSharpProjectSymbols.Reference(sibling) ); } } diff --git a/src/sidecars/SharpLsp.Sidecar.Common/NativePaths.Razor.cs b/src/sidecars/SharpLsp.Sidecar.Common/NativePaths.Razor.cs index 0c5fac11..a747a1a3 100644 --- a/src/sidecars/SharpLsp.Sidecar.Common/NativePaths.Razor.cs +++ b/src/sidecars/SharpLsp.Sidecar.Common/NativePaths.Razor.cs @@ -40,4 +40,47 @@ public static string FromRazorProjectPath(string projectDirectory, string razorP { return Resolve(projectDirectory, razorPath.TrimStart('/')); } + + /// + /// The code-behind partial class file of a component: Counter.razor.cs beside + /// Counter.razor. [RAZOR-CODE-ACTIONS-CODE-BEHIND] + /// + public static string CodeBehindOf(string componentPath) + { + return componentPath + ".cs"; + } + + /// + /// The stylesheet that scopes a Razor file's CSS: Counter.razor.css beside + /// Counter.razor. [RAZOR-COLLOCATION-CSS] + /// + public static string StylesheetOf(string razorPath) + { + return razorPath + ".css"; + } + + /// + /// The file a JavaScript module path names, the app's base path being the project directory: + /// ./Components/Card.razor.js and /Components/Card.razor.js both name + /// Components/Card.razor.js in it. Null for a path that names no file. [RAZOR-COLLOCATION-JS] + /// + public static string? FromModulePath(string projectDirectory, string modulePath) + { + var relative = AppRelative(Slashed(modulePath)); + return relative.Length > 0 ? Resolve(projectDirectory, relative) : null; + } + + /// A module path with the app's base path removed: ./a/b.js is a/b.js. + private static string AppRelative(string path) + { + return path.StartsWith("./", StringComparison.Ordinal) ? AppRelative(path[2..]) + : path.StartsWith('/') ? AppRelative(path[1..]) + : path; + } + + /// The component file named in : Name.razor. + public static string ComponentIn(string folder, string name) + { + return Join(folder, name + ".razor"); + } } diff --git a/src/sidecars/SharpLsp.Sidecar.Common/Solutions/ProjectReferences.cs b/src/sidecars/SharpLsp.Sidecar.Common/Solutions/ProjectReferences.cs index 193261f4..7986af22 100644 --- a/src/sidecars/SharpLsp.Sidecar.Common/Solutions/ProjectReferences.cs +++ b/src/sidecars/SharpLsp.Sidecar.Common/Solutions/ProjectReferences.cs @@ -56,7 +56,7 @@ .. doc.Descendants() return null; } - var dllName = AssemblyName(projectFilePath) + ".dll"; + var dllName = AssemblyNameOf(projectFilePath) + ".dll"; return Directory .EnumerateFiles(binDir, dllName, SearchOption.AllDirectories) .OrderByDescending(File.GetLastWriteTimeUtc) @@ -69,7 +69,7 @@ .. doc.Descendants() } /// Simple assembly name: <AssemblyName> or the file stem. - private static string AssemblyName(string projectFilePath) + public static string AssemblyNameOf(string projectFilePath) { try { diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpForeignSymbolsTests.fs b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpForeignSymbolsTests.fs new file mode 100644 index 00000000..b71495ed --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpForeignSymbolsTests.fs @@ -0,0 +1,157 @@ +/// The F# side of a symbol C# or Razor names by identity, its assembly and XML doc +/// signature: where F# declares it and every F# use, searched only in the project that +/// builds its assembly and the projects reading that one. Implements [RAZOR-TESTING] for +/// [DEFINITION-CROSSLANG-IDENTITY] and [REFERENCES-CROSS-LANGUAGE]. +module SharpLsp.Sidecar.FSharp.Tests.FSharpForeignSymbolsTests + +open Xunit +open FSharp.Compiler.Symbols +open SharpLsp.Sidecar.FSharp +open SharpLsp.Sidecar.FSharp.Tests.FSharpCoverageTests +open SharpLsp.Sidecar.FSharp.Tests.FSharpRenameSemanticTests +open SharpLsp.Sidecar.Common + +let private DOMAIN = + source + [ "namespace Domain" + "" + "type Player = { Name: string; Score: int }" + "" + "module Scoring =" + " let total (players: seq) = players |> Seq.sumBy (fun player -> player.Score)" + " let best (players: seq) = players |> Seq.maxBy (fun player -> player.Score)" ] + +let private BOARD = + source + [ "module Board" + "open Domain" + "let ada = { Name = \"Ada\"; Score = 3 }" + "let points = ada.Score + Scoring.total [ ada ]" ] + +/// A symbol of the test project's assembly, as C# names it. +let private named xmlDocSig : FSharpForeignSymbols.SymbolIdentity = + { AssemblyName = "TestProject"; XmlDocSig = xmlDocSig } + +let private placed (locations: FSharpWorkspace.DefinitionLocation list) = + locations + |> List.map (fun location -> string (NativePaths.NameOf location.FilePath), location.Line, location.Character) + |> List.sort + +let private domain () = loadWorkspace [ "Domain.fs", DOMAIN; "Board.fs", BOARD ] + +[] +let ``declarations place a type, a record field and a function where F# declares them`` () = task { + let! (state, dir, _, _) = domain () + try + let! player = FSharpForeignSymbols.declarations state (named "T:Domain.Player") true + Assert.Equal<(string * int * int) list>([ "Domain.fs", 2, 5 ], placed player) + let! score = FSharpForeignSymbols.declarations state (named "P:Domain.Player.Score") true + Assert.Equal<(string * int * int) list>([ "Domain.fs", 2, 30 ], placed score) + let total = "M:Domain.Scoring.total(System.Collections.Generic.IEnumerable{Domain.Player})" + let! declared = FSharpForeignSymbols.declarations state (named total) false + Assert.Equal<(string * int * int) list>([ "Domain.fs", 5, 8 ], placed declared) + let range = List.exactlyOne declared + Assert.Equal((5, 13), (range.EndLine, range.EndCharacter)) + + // Another assembly's symbol, an unknown one and an empty identity have no F# declaration. + let elsewhere = { named "T:Domain.Player" with AssemblyName = "Elsewhere" } + let! foreign = FSharpForeignSymbols.declarations state elsewhere true + Assert.Empty(foreign) + let! missing = FSharpForeignSymbols.declarations state (named "T:Domain.Missing") true + Assert.Empty(missing) + let! empty = FSharpForeignSymbols.declarations state (named "") true + Assert.Empty(empty) + finally + cleanup dir +} + +[] +let ``uses list every F# use, with the declaration only when asked`` () = task { + let! (state, dir, _, _) = domain () + try + let uses = [ "Domain.fs", 5, 82; "Domain.fs", 6, 81; "Board.fs", 2, 26; "Board.fs", 3, 17 ] + let! withDeclaration = FSharpForeignSymbols.uses state (named "P:Domain.Player.Score") true + Assert.Equal<(string * int * int) list>(List.sort (("Domain.fs", 2, 30) :: uses), placed withDeclaration) + let! withoutDeclaration = FSharpForeignSymbols.uses state (named "P:Domain.Player.Score") false + Assert.Equal<(string * int * int) list>(List.sort uses, placed withoutDeclaration) + + // The type's uses: each annotation; the function's: its one call. + let! player = FSharpForeignSymbols.uses state (named "T:Domain.Player") false + Assert.Equal<(string * int * int) list>([ "Domain.fs", 5, 28; "Domain.fs", 6, 27 ], placed player) + let total = "M:Domain.Scoring.total(System.Collections.Generic.IEnumerable{Domain.Player})" + let! called = FSharpForeignSymbols.uses state (named total) false + Assert.Equal<(string * int * int) list>([ "Board.fs", 3, 33 ], placed called) + finally + cleanup dir +} + +[] +let ``an unloaded workspace has no declarations or uses`` () = task { + let state = FSharpWorkspace.create () + let! declared = FSharpForeignSymbols.declarations state (named "T:Domain.Player") true + Assert.Empty(declared) + let! used = FSharpForeignSymbols.uses state (named "T:Domain.Player") true + Assert.Empty(used) + Assert.Empty(FSharpForeignSymbols.scopeOf state "TestProject") +} + +/// F#'s own references and a foreign request agree: each use is placed on its name, never +/// on the whole dotted path FCS ranges it over (`ada.Score`). [REFERENCES-FSHARP-FIND] +[] +let ``F# references and foreign uses place each use on its name alike`` () = task { + let! (state, dir, _, paths) = domain () + try + let! own = FSharpReferences.getReferences state paths[0] 2 30 true + let! foreign = FSharpForeignSymbols.uses state (named "P:Domain.Player.Score") true + Assert.Equal<(string * int * int) list>(placed foreign, placed own) + let ends = own |> List.map (fun location -> location.EndCharacter - location.Character) |> List.distinct + Assert.Equal([ "Score".Length ], ends) + + // From a use, too: the dotted `ada.Score` in Board.fs. + let! fromUse = FSharpReferences.getReferences state paths[1] 3 18 false + Assert.Equal<(string * int * int) list>( + [ "Board.fs", 2, 26; "Board.fs", 3, 17; "Domain.fs", 5, 82; "Domain.fs", 6, 81 ], + placed fromUse + ) + finally + cleanup dir +} + +let private MARKS = + source + [ "module Marks" + "" + "type MarkAttribute() =" + " inherit System.Attribute()" + "" + "[]" + "let marked (value: 'T) = value" ] + +/// An attribute used by its short name (`[]` for `MarkAttribute`) has no token that +/// spells its symbol, so its use keeps FCS's range; a type parameter has no XML doc signature, +/// so C# can never name it. [REFERENCES-FSHARP-FIND] [DEFINITION-CROSSLANG-IDENTITY] +[] +let ``a use no token spells keeps its range, and a symbol without a signature has no identity`` () = task { + let! (state, dir, _, paths) = loadWorkspace [ "Marks.fs", MARKS ] + try + let! fileCheck = FSharpWorkspace.checkFile state paths[0] + let checkResults, _ = FSharpForeignSymbols.requireCheckedFile paths[0] fileCheck + let uses = checkResults.GetAllUsesOfAllSymbolsInFile() |> Seq.toArray + let tokenized = FSharpRenameToken.tokenizeSource state.Checker (FSharpWorkspace.readSource state paths[0]) + + let mark = + uses + |> Array.find (fun symbolUse -> + let range = symbolUse.Range + range.StartLine = 6 && range.StartColumn = 2) + Assert.Equal((5, 2, 5, 6), FSharpReferences.nameSpan state.Checker tokenized mark) + + let typeParameter = uses |> Array.find (fun symbolUse -> symbolUse.Symbol :? FSharpGenericParameter) + Assert.True((FSharpForeignSymbols.identityOf typeParameter.Symbol).IsNone, "a type parameter") + let declared = + uses |> Array.find (fun symbolUse -> symbolUse.Symbol :? FSharpEntity && symbolUse.IsFromDefinition) + let identity = FSharpForeignSymbols.identityOf declared.Symbol + Assert.Equal(Some "T:Marks.MarkAttribute", identity |> Option.map (fun named -> named.XmlDocSig)) + finally + cleanup dir +} diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpForeignSymbolsWireTests.fs b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpForeignSymbolsWireTests.fs new file mode 100644 index 00000000..ecc50dd3 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.FSharp.Tests/FSharpForeignSymbolsWireTests.fs @@ -0,0 +1,76 @@ +/// The foreign-symbol requests over the sidecar's real IPC socket: the C# sidecar names an F# +/// symbol by its assembly and XML doc signature, and `workspace/declarationForeign` and +/// `workspace/referencesForeign` answer where F# declares and uses it. Implements +/// [RAZOR-TESTING] for [DEFINITION-CROSSLANG-IDENTITY] and [REFERENCES-CROSS-LANGUAGE]. +module SharpLsp.Sidecar.FSharp.Tests.FSharpForeignSymbolsWireTests + +open Xunit +open MessagePack +open SharpLsp.Sidecar.FSharp +open SharpLsp.Sidecar.Common +open SharpLsp.Sidecar.FSharp.Tests.SidecarEndToEndTests + +/// The `workspace/*Foreign` request for the fixture project's symbol `xmlDocSig`. +let private request assemblyName xmlDocSig includeDeclaration = + MessagePackSerializer.Serialize( + { ForeignSymbolRequest.AssemblyName = assemblyName + XmlDocSig = xmlDocSig + IncludeDeclaration = includeDeclaration } + ) + +/// Each location as (file name, line, character, end character). +let private placed (result: LocationListResult) = + result.Locations + |> Array.map (fun location -> + string (NativePaths.NameOf location.FilePath), location.Line, location.Character, location.EndCharacter) + |> Array.sort + |> List.ofArray + +let private ADD = "M:TestProject.Library.add(System.Int32,System.Int32)" + +type FSharpForeignSymbolsWireTests(fixture: SidecarFixture) = + interface IClassFixture + + [] + member _.``a foreign declaration request lands on the F# declaration``() = task { + let! person = fixture.Send("workspace/declarationForeign", request "TestProject" "T:TestProject.Library.Person" true) + Assert.Null(person.Error) + Assert.Equal<(string * int * int * int) list>( + [ "Library.fs", 9, 5, 11 ], + placed (deserialize person.Payload) + ) + + // A function is named with its parameter types, and lands on its name. + let! add = fixture.Send("workspace/declarationForeign", request "TestProject" ADD false) + Assert.Null(add.Error) + Assert.Equal<(string * int * int * int) list>( + [ "Library.fs", 6, 4, 7 ], + placed (deserialize add.Payload) + ) + + // Another assembly's symbol of the same name has no F# declaration here. + let! elsewhere = fixture.Send("workspace/declarationForeign", request "Elsewhere" "T:TestProject.Library.Person" true) + Assert.Null(elsewhere.Error) + Assert.Empty((deserialize elsewhere.Payload).Locations) + } + + [] + member _.``a foreign references request lists F# uses, the declaration only when asked``() = task { + let! withDeclaration = fixture.Send("workspace/referencesForeign", request "TestProject" ADD true) + Assert.Null(withDeclaration.Error) + let all = placed (deserialize withDeclaration.Payload) + Assert.Contains(("Library.fs", 6, 4, 7), all) + Assert.Contains(("Library.fs", 15, 13, 16), all) + + let! withoutDeclaration = fixture.Send("workspace/referencesForeign", request "TestProject" ADD false) + Assert.Null(withoutDeclaration.Error) + let uses = placed (deserialize withoutDeclaration.Payload) + Assert.DoesNotContain(("Library.fs", 6, 4, 7), uses) + Assert.Equal<(string * int * int * int) list>(List.except [ "Library.fs", 6, 4, 7 ] all, uses) + Assert.All(uses, fun (_, _, character, endCharacter) -> Assert.Equal("add".Length, endCharacter - character)) + + // A symbol F# does not declare has no uses. + let! missing = fixture.Send("workspace/referencesForeign", request "TestProject" "T:TestProject.Library.Missing" true) + Assert.Null(missing.Error) + Assert.Empty((deserialize missing.Payload).Locations) + } 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 ed6abf86..b73ff18d 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 @@ -35,6 +35,8 @@ + + diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpForeignSymbols.fs b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpForeignSymbols.fs new file mode 100644 index 00000000..38b220f4 --- /dev/null +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpForeignSymbols.fs @@ -0,0 +1,124 @@ +/// Symbols the other language's sidecar names by identity: the assembly that declares +/// them and their XML doc signature, the only names both compilers give a symbol. The +/// C# sidecar names an F# symbol this way when C# or Razor uses it, and these are its +/// F# declarations and uses. Implements [DEFINITION-CROSSLANG-IDENTITY], +/// [REFERENCES-CROSS-LANGUAGE] and [RENAME-CROSSLANGUAGE]. +module SharpLsp.Sidecar.FSharp.FSharpForeignSymbols + +open System +open FSharp.Compiler.CodeAnalysis +open FSharp.Compiler.Symbols +open SharpLsp.Sidecar.Common + +/// A symbol as both compilers name it. +[] +type SymbolIdentity = + { AssemblyName: string + XmlDocSig: string } + +/// The XML doc signature FCS gives `symbol`; empty for a symbol that has none. +let internal xmlDocSignature (symbol: FSharpSymbol) = + match symbol with + | :? FSharpEntity as entity -> entity.XmlDocSig + | :? FSharpMemberOrFunctionOrValue as memberValue -> memberValue.XmlDocSig + | :? FSharpField as field -> field.XmlDocSig + | :? FSharpUnionCase as unionCase -> unionCase.XmlDocSig + | _ -> "" + +/// The simple name of the assembly that declares `symbol`; empty when FCS cannot say. +let internal assemblyNameOf (symbol: FSharpSymbol) = + try symbol.Assembly.SimpleName |> string + with _ -> "" + +/// `symbol`'s identity, when it has both an assembly and a signature. +let internal identityOf (symbol: FSharpSymbol) = + let assemblyName = assemblyNameOf symbol + let xmlDocSig = xmlDocSignature symbol + if String.IsNullOrWhiteSpace(assemblyName) || String.IsNullOrWhiteSpace(xmlDocSig) then None + else Some { AssemblyName = assemblyName; XmlDocSig = xmlDocSig } + +/// Whether `symbolUse` is a use of the symbol `assemblyName` and `xmlDocSig` name. +let internal matches assemblyName xmlDocSig (symbolUse: FSharpSymbolUse) = + String.Equals(assemblyNameOf symbolUse.Symbol, assemblyName, StringComparison.Ordinal) + && String.Equals(xmlDocSignature symbolUse.Symbol, xmlDocSig, StringComparison.Ordinal) + +/// The check results of `filePath`; FCS failing to check a project file is an error. +let internal requireCheckedFile filePath checkedFile = + checkedFile + |> Option.defaultWith (fun () -> raise (InvalidOperationException($"FCS could not check {filePath}"))) + +/// Every use in `filePath` of the symbol `assemblyName` and `xmlDocSig` name. +let internal usesInFile state assemblyName xmlDocSig filePath = + task { + let! fileCheck = FSharpWorkspace.checkFile state filePath + let checkResults, _ = requireCheckedFile filePath fileCheck + return + checkResults.GetAllUsesOfAllSymbolsInFile() + |> Seq.filter (matches assemblyName xmlDocSig) + |> Seq.toArray + } + +/// Every use in `files` of the symbol `assemblyName` and `xmlDocSig` name, file by file. +let internal usesIn state assemblyName xmlDocSig (files: string array) = + task { + let collected = ResizeArray() + for filePath in files do + let! uses = usesInFile state assemblyName xmlDocSig filePath + collected.AddRange(uses) + return collected.ToArray() + } + +/// Whether `entry` builds the assembly named `assemblyName`. +let private builds assemblyName (entry: FSharpDesignTime.FSharpProjectEntry) = + String.Equals(FSharpWorkspace.parseFsprojAssemblyName entry.Path, assemblyName, StringComparison.Ordinal) + +/// Whether `entry` references an assembly named `assemblyName` in any framework. +let private references assemblyName (entry: FSharpDesignTime.FSharpProjectEntry) = + entry.Resolved.Values + |> Seq.concat + |> Seq.exists (fun reference -> + String.Equals(NativePaths.StemOf reference.Assembly, assemblyName, StringComparison.Ordinal)) + +/// The files of `entries`' query scopes ([SHARPLSP-ARCHITECTURE-PROJECTS-FSHARP-REFERENCES]). +let private scopesOf state (entries: FSharpDesignTime.FSharpProjectEntry seq) = + entries + |> Seq.choose (fun entry -> entry.Options.SourceFiles |> Array.tryHead) + |> Seq.collect (FSharpWorkspace.scopeSourceFiles state) + |> Seq.distinctBy NativePaths.NormalizeFullPath + |> Array.ofSeq + +/// The files that can hold a use of a symbol of `assemblyName`: the F# project that builds +/// that assembly and every project reading it in memory; for an assembly no F# project +/// builds, the F# projects that reference it. Never every file of the workspace. +let internal scopeOf (state: FSharpWorkspace.FSharpWorkspaceState) assemblyName = + let owners = state.Projects.Values |> Seq.filter (builds assemblyName) |> List.ofSeq + if List.isEmpty owners then + state.Projects.Values |> Seq.filter (references assemblyName) |> scopesOf state + else + scopesOf state owners + +/// The names of the uses `query` keeps, of those in scope of the symbol `identity` names; an +/// unloaded workspace or an empty identity has none. +let private inScope (state: FSharpWorkspace.FSharpWorkspaceState) (identity: SymbolIdentity) query = + task { + if not state.IsLoaded + || String.IsNullOrWhiteSpace identity.AssemblyName + || String.IsNullOrWhiteSpace identity.XmlDocSig then + return [] + else + let files = scopeOf state identity.AssemblyName + let! uses = usesIn state identity.AssemblyName identity.XmlDocSig files + return FSharpReferences.nameLocations state (query uses) + } + +let private isDefinition (symbolUse: FSharpSymbolUse) = symbolUse.IsFromDefinition + +/// Where F# declares the symbol `identity` names: the name of each defining use. A symbol +/// no loaded F# project declares has none. [DEFINITION-CROSSLANG-IDENTITY] +let declarations state identity (_: bool) = + inScope state identity (Seq.filter isDefinition) + +/// Every F# use of the symbol `identity` names, and its F# declaration when +/// `includeDeclaration` asks for it. [REFERENCES-CROSS-LANGUAGE] +let uses state identity includeDeclaration = + inScope state identity (Seq.filter (fun symbolUse -> includeDeclaration || not (isDefinition symbolUse))) diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpReferences.fs b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpReferences.fs index 349f1439..e2ecabf3 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpReferences.fs +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpReferences.fs @@ -16,6 +16,39 @@ type HighlightLocation = EndCharacter: int Kind: int } +/// Where `symbolUse` names its symbol, as zero-based (startLine, start, endLine, end). FCS +/// ranges a dotted use (`player.Score`) over the whole path, so the name is the token at the +/// range's end that spells the symbol's name; a use no token spells keeps FCS's range. +let internal nameSpan checker tokenized (symbolUse: FSharpSymbolUse) = + let range = symbolUse.Range + match FSharpRenameToken.tokenForUse checker tokenized symbolUse with + | Some token -> range.EndLine - 1, token.StartCharacter, range.EndLine - 1, token.EndCharacter + | None -> range.StartLine - 1, range.StartColumn, range.EndLine - 1, range.EndColumn + +/// Each use's name as a location, each file read and tokenized once, each place once. +let internal nameLocations (state: FSharpWorkspace.FSharpWorkspaceState) (uses: FSharpSymbolUse seq) = + uses + |> Seq.filter (fun symbolUse -> + let range = symbolUse.Range + range.FileName <> "") + |> Seq.groupBy (fun symbolUse -> + let range = symbolUse.Range + range.FileName) + |> Seq.collect (fun (filePath, inFile) -> + let tokenized = FSharpRenameToken.tokenizeSource state.Checker (FSharpWorkspace.readSource state filePath) + inFile + |> Seq.map (fun symbolUse -> + let line, start, endLine, finish = nameSpan state.Checker tokenized symbolUse + let location: FSharpWorkspace.DefinitionLocation = + { FilePath = filePath + Line = line + Character = start + EndLine = endLine + EndCharacter = finish } + location)) + |> Seq.distinctBy (fun location -> NativePaths.NormalizeFullPath location.FilePath, location.Line, location.Character) + |> List.ofSeq + /// Check whether an FSharpSymbolUse represents a write (definition or pattern). let private isWriteUse (su: FSharpSymbolUse) = su.IsFromDefinition || su.IsFromPattern @@ -144,10 +177,8 @@ let getReferences let! uses = getProjectUsages state filePath line character return uses - |> Array.choose (fun (su: FSharpSymbolUse) -> - if not includeDeclaration && su.IsFromDefinition then None - else FSharpWorkspace.rangeToLocation su.Range) - |> Array.toList + |> Array.filter (fun (su: FSharpSymbolUse) -> includeDeclaration || not su.IsFromDefinition) + |> nameLocations state } /// Find document highlights for the symbol at a position (current file only). @@ -168,20 +199,21 @@ let getDocumentHighlights | Some symbolUse -> let usesInFile = checkResults.GetUsesOfSymbolInFile(symbolUse.Symbol) + let tokenized = FSharpRenameToken.tokenizeSource state.Checker source return usesInFile |> Array.choose (fun (su: FSharpSymbolUse) -> let r = su.Range if r.FileName = "" then None else - let kind = if isWriteUse su then 3 else 2 + let line, start, endLine, finish = nameSpan state.Checker tokenized su Some { FilePath = r.FileName - StartLine = r.StartLine - 1 - StartCharacter = r.StartColumn - EndLine = r.EndLine - 1 - EndCharacter = r.EndColumn - Kind = kind }) + StartLine = line + StartCharacter = start + EndLine = endLine + EndCharacter = finish + Kind = if isWriteUse su then 3 else 2 }) |> Array.toList with ex -> Log.Debug(ex, "[F# DocumentHighlight] failed") diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpRename.fs b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpRename.fs index e6a9571a..cc3d262d 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpRename.fs +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpRename.fs @@ -21,10 +21,7 @@ type PrepareRename = Placeholder: string } /// Stable compiler identity shared with the other language sidecar. -[] -type RenameIdentity = - { AssemblyName: string - XmlDocSig: string } +type RenameIdentity = FSharpForeignSymbols.SymbolIdentity [] type private ResolvedRename = @@ -53,27 +50,6 @@ type private ProjectedCandidate = StartCharacter: int EndCharacter: int } -let private xmlDocSignature (symbol: FSharpSymbol) = - match symbol with - | :? FSharpEntity as entity -> entity.XmlDocSig - | :? FSharpMemberOrFunctionOrValue as memberValue -> memberValue.XmlDocSig - | :? FSharpField as field -> field.XmlDocSig - | :? FSharpUnionCase as unionCase -> unionCase.XmlDocSig - | _ -> "" - -let private symbolAssemblyName (symbol: FSharpSymbol) = - try symbol.Assembly.SimpleName |> string - with _ -> "" - -let private identityForSymbol symbol = - let assemblyName = symbolAssemblyName symbol - let xmlDocSig = xmlDocSignature symbol - if String.IsNullOrWhiteSpace(assemblyName) || String.IsNullOrWhiteSpace(xmlDocSig) then None - else Some { AssemblyName = assemblyName; XmlDocSig = xmlDocSig } - -let private requireCheckedFile filePath checkedFile = - checkedFile |> Option.defaultWith (fun () -> raise (InvalidOperationException($"FCS could not check {filePath}"))) - /// Pure prepare-rename computation over an already-checked file. Kept separate /// from the `task` so the async wrapper has a single bind + single return — the /// shape FCS can compile to a static state machine (avoids FS3511). @@ -217,7 +193,7 @@ let prepareRename let private computeRenameIdentity state parseResults checkResults source line character = resolveRenameUse state checkResults parseResults source line character - |> Option.bind (fun resolved -> identityForSymbol (normalizeSymbol resolved.SymbolUse.Symbol)) + |> Option.bind (fun resolved -> FSharpForeignSymbols.identityOf (normalizeSymbol resolved.SymbolUse.Symbol)) /// Return the compiler-stable identity for the renameable symbol at a position. let getRenameIdentity (state: FSharpWorkspace.FSharpWorkspaceState) filePath line character = @@ -227,7 +203,7 @@ let getRenameIdentity (state: FSharpWorkspace.FSharpWorkspaceState) filePath lin return raise (InvalidOperationException("F# workspace is not loaded")) else let! fileCheck = FSharpWorkspace.checkFileWithParse state filePath - let parseResults, checkResults, source = requireCheckedFile filePath fileCheck + let parseResults, checkResults, source = FSharpForeignSymbols.requireCheckedFile filePath fileCheck return computeRenameIdentity state parseResults checkResults source line character with ex -> Log.Debug(ex, "[F# RenameIdentity] failed") @@ -306,32 +282,10 @@ let private renameChecked state parseResults checkResults source line character | Some resolved -> return! standardRename state newName resolved } -let private matchesIdentity assemblyName xmlDocSig (symbolUse: FSharpSymbolUse) = - String.Equals(symbolAssemblyName symbolUse.Symbol, assemblyName, StringComparison.Ordinal) - && String.Equals(xmlDocSignature symbolUse.Symbol, xmlDocSig, StringComparison.Ordinal) - -let private matchingUsesInFile state assemblyName xmlDocSig filePath = - task { - let! fileCheck = FSharpWorkspace.checkFile state filePath - let checkResults, _ = requireCheckedFile filePath fileCheck - return - checkResults.GetAllUsesOfAllSymbolsInFile() - |> Seq.filter (matchesIdentity assemblyName xmlDocSig) - |> Seq.toArray - } - -let private collectMatchingUses - (state: FSharpWorkspace.FSharpWorkspaceState) - assemblyName - xmlDocSig = - task { - let collected = ResizeArray() - let files = FSharpWorkspace.allSourceFiles state - for filePath in files do - let! uses = matchingUsesInFile state assemblyName xmlDocSig filePath - collected.AddRange(uses) - return collected.ToArray() - } +/// Every use of the symbol `assemblyName` and `xmlDocSig` name. A rename by identity, from +/// the other language with no F# file to anchor on, walks every loaded project. +let private collectMatchingUses (state: FSharpWorkspace.FSharpWorkspaceState) assemblyName xmlDocSig = + FSharpForeignSymbols.usesIn state assemblyName xmlDocSig (FSharpWorkspace.allSourceFiles state) let private signatureHeaderEnd (xmlDocSig: string) = [| xmlDocSig.IndexOf('('); xmlDocSig.IndexOf('~') |] @@ -432,7 +386,7 @@ let private matchingProjectedEdits FSharpWorkspace.getSymbolUse checkResults source candidate.Original.Line candidate.StartCharacter |> Option.filter (matchesProjectedRange candidate) - |> Option.filter (matchesIdentity assemblyName staleXml) + |> Option.filter (FSharpForeignSymbols.matches assemblyName staleXml) |> Option.map (fun _ -> candidateEdit newName candidate.Original)) let private transientFileEdits @@ -446,7 +400,7 @@ let private transientFileEdits let projectedCandidates = projectCandidatePositions newName candidates let projectedSource = projectSource source newName candidates let! checkedFile = FSharpWorkspace.checkFileWithSource state filePath projectedSource - let _, checkResults, checkedSource = requireCheckedFile filePath checkedFile + let _, checkResults, checkedSource = FSharpForeignSymbols.requireCheckedFile filePath checkedFile return matchingProjectedEdits assemblyName staleXml newName checkResults checkedSource projectedCandidates } diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpSidecar.fs b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpSidecar.fs index c80b3e80..0406fe6c 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpSidecar.fs +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpSidecar.fs @@ -293,6 +293,11 @@ type FSharpSidecar() = base.Register("textDocument/renameIdentity", Helpers.renameIdentityHandler workspace) base.Register("workspace/renameForeign", Helpers.renameForeignHandler workspace) + // A symbol C# or Razor uses, named by identity: where F# declares it and every F# + // use. [DEFINITION-CROSSLANG-IDENTITY] [REFERENCES-CROSS-LANGUAGE] + base.Register("workspace/declarationForeign", Helpers.foreignLocationsHandler (FSharpForeignSymbols.declarations workspace)) + base.Register("workspace/referencesForeign", Helpers.foreignLocationsHandler (FSharpForeignSymbols.uses workspace)) + // Call hierarchy prepare [SHARPLSP-FEATURES-NAVIGATION] base.Register("textDocument/prepareCallHierarchy", Helpers.atPosition (FSharpHierarchy.prepareCall workspace) (Helpers.optionOf Helpers.toHierItem)) diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWire.fs b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWire.fs index 37b02a1d..d0981807 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWire.fs +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/FSharpWire.fs @@ -294,6 +294,16 @@ type RenameIdentityResultWire = [] AssemblyName: string [] XmlDocSig: string } +/// A symbol another sidecar names by identity, for its F# declarations or uses. +/// [DEFINITION-CROSSLANG-IDENTITY] [REFERENCES-CROSS-LANGUAGE] +[] +[] +type ForeignSymbolRequest = + { [] AssemblyName: string + [] XmlDocSig: string + /// Whether references include the declaration; a declaration request ignores it. + [] IncludeDeclaration: bool } + [] [] type RenameForeignRequest = @@ -383,6 +393,18 @@ module internal Helpers = atPosition (getLocations workspace) (fun locations ct -> serializeOk { Locations = locations |> List.map toLocationResult |> Array.ofList } ct) + /// Answer a request naming a symbol by identity with the locations `query` finds for it. + let foreignLocationsHandler + (query: FSharpForeignSymbols.SymbolIdentity -> bool -> Task) + : Func> = + handle + (fun (request: ForeignSymbolRequest) -> + query + { AssemblyName = request.AssemblyName; XmlDocSig = request.XmlDocSig } + request.IncludeDeclaration) + (fun locations ct -> + serializeOk { Locations = locations |> List.map toLocationResult |> Array.ofList } ct) + /// Map a hierarchy domain item to its wire shape (call + type hierarchy). let toHierItem (item: FSharpHierarchy.HierItem) : HierarchyItemResult = { Name = item.Name diff --git a/src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj b/src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj index 9a1e005e..a794faa9 100644 --- a/src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj +++ b/src/sidecars/SharpLsp.Sidecar.FSharp/SharpLsp.Sidecar.FSharp.fsproj @@ -58,8 +58,9 @@ - + + diff --git a/src/website/package-lock.json b/src/website/package-lock.json index 6d2a4bd5..54e4dfe4 100644 --- a/src/website/package-lock.json +++ b/src/website/package-lock.json @@ -230,9 +230,9 @@ } }, "node_modules/@11ty/eleventy/node_modules/markdown-it": { - "version": "14.3.2", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz", - "integrity": "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==", + "version": "14.3.3", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz", + "integrity": "sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==", "funding": [ { "type": "github", @@ -1018,9 +1018,9 @@ } }, "node_modules/eleventy-plugin-techdoc/node_modules/markdown-it": { - "version": "14.3.2", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz", - "integrity": "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==", + "version": "14.3.3", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz", + "integrity": "sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==", "funding": [ { "type": "github", @@ -1492,9 +1492,9 @@ } }, "node_modules/markdown-it": { - "version": "15.0.2", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.2.tgz", - "integrity": "sha512-q4IGxMv56jCqT4OCRCADBoDP3LO4MhmTXjFbphHPXs4g3j9Xg5RDnxqN8IF/3vIWEU+VCnUq+7JUg/cfy2E6Qw==", + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.3.tgz", + "integrity": "sha512-+Az7Ojz3iRGu8KYMvJ6xVhvrxP3eE+vEtJX75Ow88XBpcQNV/0lGX5ZynIIBftREo4DC2jsveAMDaeltmm8QvQ==", "funding": [ { "type": "github", @@ -1507,9 +1507,9 @@ ], "license": "MIT", "dependencies": { - "argparse": "^3.0.0", - "entities": "^8.0.0", - "linkify-it": "^6.0.0", + "argparse": "^3.0.3", + "entities": "^8.1.0", + "linkify-it": "^6.1.0", "mdurl": "^2.1.0", "punycode.js": "^2.3.1", "uc.micro": "^3.0.0"