diff --git a/.github/skills/vs-local-development/SKILL.md b/.github/skills/vs-local-development/SKILL.md
new file mode 100644
index 00000000000..bf2090aba38
--- /dev/null
+++ b/.github/skills/vs-local-development/SKILL.md
@@ -0,0 +1,127 @@
+---
+name: vs-local-development
+description: Guide for local Visual Studio extension development, building, deploying, and testing the VS extension (VisualFSharp) in the experimental instance.
+license: MIT
+---
+
+# Local Visual Studio Extension Development
+
+This skill provides guidance on building, deploying, and iterating on the Visual Studio extension locally using the experimental instance of Visual Studio.
+
+## Prerequisites
+
+- Visual Studio 2026 installed with the **Visual Studio extension development** workload
+- The `vs-mcp-testing-tools` skill must be loaded to understand how to use the MCP tools for testing after deployment
+
+## Rebuild & Deploy Workflow
+
+After making code changes to the VS extension, follow these steps **in order** to rebuild and deploy:
+
+### 1. Close All Experimental VS Instances
+
+Run the helper script to close any running experimental instances:
+
+```powershell
+.github/skills/vs-local-development/scripts/close-exp-instances.ps1
+```
+
+This kills all `devenv.exe` processes launched with `/rootsuffix`.
+
+### 2. Rebuild the Solution
+
+Run the build script:
+
+```powershell
+./Build.cmd -c Debug
+```
+
+### 3. Deploy the VSIX
+
+Use the deploy script to install the VSIX, wait for the installer to finish, and clear caches in one step:
+
+```powershell
+.github/skills/vs-local-development/scripts/deploy-to-vs.ps1 "artifacts\VSSetup\Debug\VisualFSharpDebug.vsix"
+```
+
+This script:
+- Installs the VSIX into the experimental hive (`/rootSuffix:exp`)
+
+**Shell requirement:** `VSIXInstaller.exe` and `devenv.exe` must be on `PATH`. Run from a **Visual Studio Developer PowerShell** / **Developer Command Prompt**, or use the discovery snippet below.
+
+### Finding devenv.exe
+
+VS installation folders use **numeric version identifiers** (e.g., `18`), not marketing years (e.g., `2026`), and may be under `Program Files` or `Program Files (x86)`. Editions vary (`IntPreview`, `Enterprise`, `Preview`, `Community`, etc.).
+
+Use this snippet to find `devenv.exe` dynamically:
+
+```powershell
+$devenv = Get-ChildItem "${env:ProgramFiles}\Microsoft Visual Studio","${env:ProgramFiles(x86)}\Microsoft Visual Studio" `
+ -Filter "devenv.exe" -Recurse -ErrorAction SilentlyContinue |
+ Where-Object { $_.FullName -match 'Common7\\IDE\\devenv\.exe$' } |
+ Select-Object -First 1 -ExpandProperty FullName
+
+if (-not $devenv) { Write-Error "devenv.exe not found"; exit 1 }
+Write-Host "Using: $devenv"
+```
+
+Then source the Developer environment and deploy:
+
+```powershell
+$vsDir = Split-Path (Split-Path (Split-Path $devenv)) # …\Common7\IDE → VS root
+& "$vsDir\Common7\Tools\VsDevCmd.bat"
+.github/skills/vs-local-development/scripts/deploy-to-vs.ps1 "artifacts\VSSetup\Debug\VisualFSharpDebug.vsix"
+```
+
+### 4. Reopen VS
+
+The extension is now installed. Open the experimental instance normally:
+
+```powershell
+devenv /rootsuffix exp
+```
+
+## Quick Reference (Copy-Paste)
+
+Full sequence for a rebuild cycle:
+
+```powershell
+# 1. Stop experimental instances
+.github/skills/vs-local-development/scripts/close-exp-instances.ps1
+
+# 2. Rebuild
+./Build.cmd -c Debug
+
+# 3. Deploy VSIX (install, wait, clear caches)
+.github/skills/vs-local-development/scripts/deploy-to-vs.ps1 "artifacts\VSSetup\Debug\VisualFSharpDebug.vsix"
+
+# 4. Reopen VS with the updated extension
+devenv /rootsuffix exp
+```
+
+## Key Concepts
+
+### Experimental Instance
+
+Visual Studio supports **experimental instances** (`/rootsuffix exp`) — isolated environments with separate extension registrations and settings. This allows developing and testing extensions without affecting your main VS installation.
+
+## Troubleshooting
+
+| Problem | Solution |
+|---|---|
+| VSIX install fails | Ensure all experimental VS instances are closed (`.github/skills/vs-local-development/scripts/close-exp-instances.ps1`) |
+| Extension not appearing in VS | Re-run `deploy-to-vs.ps1` or manually run `devenv /rootsuffix exp /clearcache` and `/updateconfiguration` |
+| Old version still loaded | Delete `artifacts/` and rebuild |
+| `devenv.exe` not found | VS folders use numeric versions (e.g., `18`), not years. Use the discovery snippet in the Deploy section above |
+
+## When to Use This Skill
+
+Use this skill when:
+
+- Making changes to the VS extension (`VisualFSharpFull` or `VisualFSharpDebug`)
+- Needing to test extension changes locally in Visual Studio
+- Troubleshooting VSIX deployment to the experimental instance
+- Setting up a local VS extension development loop
+
+## Related
+
+- [vs-mcp-testing-tools](../vs-mcp-testing-tools/SKILL.md) — for automating VS via MCP tools after the extension is deployed
diff --git a/.github/skills/vs-local-development/scripts/close-exp-instances.ps1 b/.github/skills/vs-local-development/scripts/close-exp-instances.ps1
new file mode 100644
index 00000000000..063d6a50f19
--- /dev/null
+++ b/.github/skills/vs-local-development/scripts/close-exp-instances.ps1
@@ -0,0 +1,7 @@
+# Closes all devenv.exe processes launched with /rootsuffix (experimental instances).
+Get-CimInstance Win32_Process -Filter "Name = 'devenv.exe'" |
+ Where-Object { $_.CommandLine -match '/rootsuffix\s+\S+' } |
+ ForEach-Object {
+ Write-Host "Stopping devenv.exe (PID $($_.ProcessId)): $($_.CommandLine)"
+ Stop-Process -Id $_.ProcessId -Force
+ }
diff --git a/.github/skills/vs-local-development/scripts/deploy-to-vs.ps1 b/.github/skills/vs-local-development/scripts/deploy-to-vs.ps1
new file mode 100644
index 00000000000..53926c4cb22
--- /dev/null
+++ b/.github/skills/vs-local-development/scripts/deploy-to-vs.ps1
@@ -0,0 +1,57 @@
+<#
+.SYNOPSIS
+ Deploys a VSIX to the Visual Studio experimental instance.
+.DESCRIPTION
+ Installs a VSIX into the experimental hive, waits for the installer to
+ finish, then clears caches and updates the configuration so VS picks up
+ the new extension on next launch.
+.PARAMETER Path
+ Path to the .vsix file to install.
+#>
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true, Position = 0)]
+ [string]$Path
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+if (-not (Test-Path $Path)) {
+ Write-Error "VSIX not found: $Path"
+ exit 1
+}
+
+# --- 1. Install the VSIX ---------------------------------------------------
+Write-Host "Installing VSIX: $Path" -ForegroundColor Cyan
+VSIXInstaller.exe /rootSuffix:exp $Path /q
+Write-Host "VSIXInstaller.exe launched (exit code $LASTEXITCODE)."
+
+# --- 2. Wait for the background installer to finish ------------------------
+# VSIXInstaller.exe returns immediately while a background process
+# completes the actual installation. Poll until it exits.
+Write-Host "Waiting for VSIXInstaller.exe to finish..." -ForegroundColor Cyan
+while (Get-Process -Name VSIXInstaller -ErrorAction SilentlyContinue) {
+ Start-Sleep -Seconds 5
+}
+Write-Host "VSIX installation complete."
+
+# --- 3. Clear caches and update configuration ------------------------------
+# Find devenv.exe dynamically — VS folders use numeric versions (e.g. 18),
+# not marketing years, and editions vary (IntPreview, Enterprise, etc.).
+$devenv = Get-Command devenv.exe -ErrorAction SilentlyContinue |
+ Select-Object -ExpandProperty Source
+if (-not $devenv) {
+ $devenv = Get-ChildItem "$env:ProgramFiles\Microsoft Visual Studio","${env:ProgramFiles(x86)}\Microsoft Visual Studio" `
+ -Filter "devenv.exe" -Recurse -ErrorAction SilentlyContinue |
+ Where-Object { $_.FullName -match 'Common7\\IDE\\devenv\.exe$' } |
+ Select-Object -First 1 -ExpandProperty FullName
+}
+if (-not $devenv) {
+ Write-Error "devenv.exe not found. Run from a Developer PowerShell or install Visual Studio."
+ exit 1
+}
+Write-Host "Clearing VS experimental instance caches..." -ForegroundColor Cyan
+& $devenv /rootsuffix exp /clearcache
+& $devenv /rootsuffix exp /updateconfiguration
+Write-Host "Caches cleared and configuration updated." -ForegroundColor Green
diff --git a/.github/skills/vs-mcp-testing-tools/REFERENCE.md b/.github/skills/vs-mcp-testing-tools/REFERENCE.md
new file mode 100644
index 00000000000..af1a105f4ab
--- /dev/null
+++ b/.github/skills/vs-mcp-testing-tools/REFERENCE.md
@@ -0,0 +1,176 @@
+# MCP Tools Reference
+
+Complete reference for all Visual Studio MCP server tools provided by `Microsoft.Copilot.Testing.MCP.VisualStudio`.
+
+## Solution Management
+
+### `vs-open-solution`
+
+Opens a solution file and waits for full loading.
+
+```
+vs-open-solution path="C:\path\to\MySolution.sln"
+```
+
+- Accepts `.sln` and `.slnx` files.
+- Blocks until the solution is fully loaded in Visual Studio.
+
+### `vs-open-file`
+
+Opens a file in the Visual Studio editor.
+
+```
+vs-open-file filePath="C:\path\to\MyFile.cs"
+```
+
+### `vs-close`
+
+Closes Visual Studio.
+
+```
+vs-close
+```
+
+## Chat Window
+
+### `chat-submit-prompt`
+
+Submits a prompt to the VS Copilot Chat window.
+
+```
+chat-submit-prompt "@test #file:'C:\path\to\Calculator.cs'"
+```
+
+- Annotations require **full absolute paths** enclosed in single quotes for file-based references.
+- This call is asynchronous — always follow with `chat-wait-complete`.
+
+### `chat-wait-complete`
+
+Waits for the chat response to complete.
+
+```
+chat-wait-complete
+chat-wait-complete timeoutSeconds=600
+```
+
+- Default timeout: 60 seconds.
+- Increase `timeoutSeconds` for large projects or solution-scoped generation (10+ minutes).
+
+### `get-last-message`
+
+Retrieves the last response from the chat session.
+
+```
+get-last-message
+```
+
+- Look for the `**Pass/Fail Summary**` line (`✅` or `❌`) in the response.
+
+## Output Window
+
+### `output-window-get-content`
+
+Gets content from a specific output pane by name.
+
+```
+output-window-get-content paneName="GitHub Copilot Testing"
+output-window-get-content paneName="Build"
+```
+
+Key panes:
+
+| Pane | Content |
+|---|---|
+| `GitHub Copilot Testing` | Agent execution logs, LLM traces, errors |
+| `Build` | MSBuild compilation output |
+| `Package Manager` | NuGet restore and package issues |
+
+### `output-window-get-test-content`
+
+Gets content from the Test output pane for detailed test results.
+
+```
+output-window-get-test-content
+```
+
+## Test Explorer
+
+### `test-explorer-run-all-tests`
+
+Executes all tests in the solution. This call is asynchronous — always follow with `test-explorer-wait-complete`.
+
+```
+test-explorer-run-all-tests
+```
+
+### `test-explorer-wait-complete`
+
+Waits for the test run to finish.
+
+```
+test-explorer-wait-complete
+```
+
+### `test-explorer-verify-all-tests-passed`
+
+Checks if all tests in the solution passed. Returns a quick pass/fail status.
+
+```
+test-explorer-verify-all-tests-passed
+```
+
+## Error List
+
+### `vs-get-error-list`
+
+Gets diagnostics from the Visual Studio Error List window.
+
+```
+vs-get-error-list
+vs-get-error-list severity=error
+```
+
+- **`severity`** (optional): Filter results by severity. Accepted values: `error`, `warning`, `message`, `all` (default).
+- Returns each diagnostic in the format `[severity] file(line,column): description`.
+- Returns a summary header with match count and active filter.
+
+## VS Editor
+
+### `vs-inspect-lightbulb`
+
+Inspects whether a light bulb is present at a file/line and returns available code action titles.
+
+```
+vs-inspect-lightbulb filePath="C:\path\to\File.cs" lineNumber=42
+```
+
+- Uses 1-based line numbers.
+- Returns whether the light bulb is present and the list of offered actions.
+
+## MCP Server Setup
+
+### Prerequisites
+
+1. Check if `vs-open-solution` tool is available. If it is, the server is already set up.
+2. Run `./cibuild.cmd` to build the solution.
+3. Set up the VS MCP server following `src/Microsoft.Copilot.Testing.MCP.VisualStudio/README.md`.
+4. Configure your MCP client (e.g., `mcp.json`) to point to `bin\Microsoft.Copilot.Testing.MCP.VisualStudio\Release\net472\dotnet-codetesting-mcp-vs.exe`.
+5. If testing against an experimental VS hive, pass `--hive Exp` in the MCP server args.
+
+### Configuration Options
+
+- **Hive Selection:** Use `--hive` parameter for experimental or specific VS installations.
+- **Debugging:** Set `ATTACH_DEBUGGER=1` environment variable to debug the MCP server. Diagnostic output is written to stderr.
+
+### System Requirements
+
+- Visual Studio 2022 with GitHub Copilot extension
+- .NET 8 SDK or later
+- GitHub Copilot Testing extension enabled
+- Valid GitHub Copilot subscription
+
+### VS Settings
+
+- **Enable the agent:** Tools → Options → GitHub → Copilot → Testing → "Enable GitHub Copilot testing"
+- **Enable logging:** Tools → Options → GitHub → Copilot → Testing → "Enable detailed logging"
+- **Check authentication:** Ensure GitHub account is properly connected
diff --git a/.github/skills/vs-mcp-testing-tools/SKILL.md b/.github/skills/vs-mcp-testing-tools/SKILL.md
new file mode 100644
index 00000000000..f0448c42038
--- /dev/null
+++ b/.github/skills/vs-mcp-testing-tools/SKILL.md
@@ -0,0 +1,35 @@
+---
+name: vs-mcp-testing-tools
+description: Guide for using Visual Studio MCP tools for Visual Studio automation, troubleshooting, and debugging test generation workflows. Use this when users want to open their solution in Visual Studio and build, run tests or use the Copilot Chat window.
+license: MIT
+---
+
+# Visual Studio MCP Tools
+
+This skill provides guidance on using the Visual Studio MCP (Model Context Protocol) server tools for automating Visual Studio workflows, test generation, and troubleshooting.
+
+## Quick Start
+
+1. Verify `vs-open-solution` tool is available (MCP server is set up).
+2. Open a solution: `vs-open-solution path="C:\path\to\MySolution.sln"`
+4. Open a source file with `vs-open-file`
+3. Wait 10 seconds for the errors to populate, then use `vs-get-error-list` tool to get the list of errors.
+
+## Features
+
+| Document | Contents |
+|---|---|
+| [REFERENCE.md](REFERENCE.md) | Complete MCP tools API reference — all tool names, parameters, setup, and configuration |
+
+## When to Use This Skill
+
+Use this skill when users need help with:
+
+- Setting up or using VS MCP testing tools
+- Integration testing VS F# features
+- Debugging F# LSP server at the VS UI level
+
+## Key Rules
+
+- Use **full absolute paths in single quotes** for file-based annotations via MCP (e.g., `#file:'C:\path\to\File.cs'`).
+- Don't mix different annotation scope types in a single request.
diff --git a/VSFSharpExtension.slnx b/VSFSharpExtension.slnx
index 740a2f7c8b3..a11f2038605 100644
--- a/VSFSharpExtension.slnx
+++ b/VSFSharpExtension.slnx
@@ -12,5 +12,10 @@
+
+
+
+
+
diff --git a/VisualFSharp.slnx b/VisualFSharp.slnx
index caa059a34ee..554fbff4c68 100644
--- a/VisualFSharp.slnx
+++ b/VisualFSharp.slnx
@@ -46,6 +46,9 @@
+
+
+
diff --git a/docs/lsp.md b/docs/lsp.md
index 9025068db68..4c33f09702c 100644
--- a/docs/lsp.md
+++ b/docs/lsp.md
@@ -128,6 +128,203 @@ Will be packaged with FsLSP and available as a dotnet tool (?)
It should always target the latest .NET runtime.
+#### Visual Studio provider selection
+
+`fsharp.semanticClassificationProvider` selects `old` (the in-process classifier)
+or `lsp`. The default, `unset`, resolves to `old` at read time without writing
+that fallback to user settings, preserving the distinction between an explicit
+choice and the default for telemetry and controlled rollouts.
+
+Restart Visual Studio after changing the provider. Unless the effective provider
+is `lsp`, the language server stays disabled: no LSP workspace, project
+subscriptions, document synchronization, or active-project-context forwarding is
+started. Exposure telemetry still records both providers, including `unset`.
+
+With `lsp`, legacy classification remains enabled for closed documents and documents
+in workspaces other than Host and Miscellaneous Files (including code-fix and
+rename previews). Find All References result lines for documents that are open
+in Host or Miscellaneous Files remain without semantic coloring; LSP startup
+failure or a server crash does not enable legacy classification for those documents.
+
+#### Transport and serialization
+
+The standalone server uses header-delimited JSON-RPC on standard input/output; logs
+are written to standard error. The Visual Studio host uses the same protocol over
+an in-memory duplex stream. Both use `SystemTextJsonFormatter`, and CLaSP receives
+the formatter's `JsonSerializerOptions` so request deserialization uses the same
+converters as the transport.
+
+The protocol packages supply System.Text.Json attributes and converters for URIs,
+sum types, and other LSP values. Local converters preserve the five Visual Studio
+extension mappings (diagnostics, locations, server capabilities, symbol information,
+and text-document identifiers), including diagnostic project information and
+`_vs_projectContext`. LSP requests and notifications carry a single parameter object,
+not a positional argument array.
+
+Visual Studio's existing F# navigation bar changes Roslyn's active document
+context rather than the LSP client's navigation-bar context. The in-process
+editor forwards those changes to `textDocument/fsharp/setActiveProjectContext`,
+with the document URI, project file and output file. Semantic-token requests
+without `_vs_projectContext` use that per-document selection; explicit request
+contexts still take precedence. Changed selections request
+`workspace/semanticTokens/refresh` when supported by the client, and closing the
+document clears the selection. Inactive conditional-compilation regions are
+returned as `excluded code` tokens.
+Mutating notifications and context changes cancel and drain earlier read
+requests so a completed request cannot reapply classifications from the previous
+context. A failed refresh is logged and leaves the selection unchanged for retry.
+
+Semantic-token overlap resolution checks accepted tokens on the same line only.
+Range requests normalize semantic classifications only on requested lines and
+scan the lexer from the start to preserve multiline string/comment state, stopping
+at the range's last line. Token processing observes request cancellation per line.
+
+CLaSP is centrally managed through `eng/Packages.props` and tracked as a Roslyn
+dependency in `eng/Version.Details.xml` and `eng/Version.Details.props`, with the same
+version and source commit as the other Roslyn packages.
+
+#### Highlighting telemetry
+
+The F# editor posts `dotnet/fsharp/semantichighlighting/exposure` once per
+eligible devenv lifetime when an F# document view opens, or telemetry is enabled
+while one is open. This includes scripts,
+legacy/default selections, and LSP selections whose server fails to initialize.
+It does not wait for or activate the LSP server.
+
+Properties use the `dotnet.fsharp.` prefix:
+
+| Property | Meaning |
+|---|---|
+| `schema_version` | `1` for this contract |
+| `rollout_session_id` | Random, process-lifetime correlation ID; never persisted |
+| `setting_value` | `old`, `lsp`, `unset`, `unrecognized`, or `unavailable` |
+| `resolved_provider` | Selection after applying the existing fallback: `old` or `lsp` |
+
+`dotnet/fsharp/semantichighlighting/providerstate` separately records actual
+legacy `enabled`/`fallback_only` decisions and LSP `ready`/`disabled`/`unavailable`
+states, with the same correlation ID. These state records are deduplicated
+per provider/state, not counted as new sessions. Selection is not evidence
+that a server started, returned semantic data, or applied colors in the editor.
+Construction-time settings can disagree after a setting changes; these
+records do not implement live provider switching.
+`fallback_only` means legacy classification is restricted to closed documents
+and workspaces other than Host and Miscellaneous Files under the `lsp` selection;
+legacy detail events are emitted only for requests that enter that classifier.
+
+All telemetry added by this implementation, including exposure, provider state,
+lifecycle summaries, measurements, and faults, requires the F# additional-telemetry
+checkbox to be checked **or** the VS telemetry session to identify a
+Microsoft-internal user. Transparent Compiler use alone does not enable it.
+VS-wide consent, sampling, and deduplication still apply. Other legacy features
+and client-owned telemetry retain their existing policy. Suppressed exposure and
+state records do not consume their one-time guards; opting in with an open F#
+view can produce the session's exposure.
+
+The in-process host sends `fsharp/telemetry/configure` through the existing
+general broker to already-initialized servers, without activating a server.
+Parameters are `RolloutSessionId`,
+`Generation`, and `Enabled`; the reply contains the accepted `Generation`
+and actual `SemanticTokensEnabled` capability. `Enabled` carries the combined
+checkbox/Microsoft-internal policy. Policy generations reject stale updates.
+Non-internal LSP users require a known enabled policy; internal users bypass
+that wait. The latest policy is checked at publication, including scopes already
+in flight. LSP-selected views
+use a bounded, cancellable startup retry because client loading can precede
+initialization. Consent updates are forwarded independently of the current
+provider selection; same-host reconnects retain the accepted policy, while
+new clients and view focus reapply it. Standalone servers retain the no-op
+sink. This request is independent of project-context selection and does not
+require an output path.
+
+Consent forwarding and client-load observation belong to a shared editor service,
+not a document view. Closing the last F# view removes only view-owned exposure,
+focus, and project-context handlers; checkbox updates still reach an already
+running server. Disposing the service unsubscribes its settings observer and
+cancels/drains forwarding work without retaining closed views.
+
+Existing detail event names are retained:
+
+| Event | Property prefix | Existing duration |
+|---|---|---|
+| `dotnet/fsharp/addsemanticclassifications` | `dotnet.fsharp.` | `vs_event_duration_ms`: work after the editor-cache lookup |
+| `vs/fsharp/lsp/getsemantictokens` | `vs.fsharp.` | `duration_ms`: semantic-token handler work |
+
+Both add `schema_version`, `rollout_session_id`, `provider`,
+`provider_duration_ms`, `request_kind`, `outcome`, `isOpenDocument`, and
+`document_length` when source is available. The new provider duration includes
+source/cache retrieval through result production. Neither duration includes
+upstream scheduling/transport or downstream tag application/rendering.
+Outcomes distinguish `completed`, `cancelled`, `faulted`, and LSP `no_source`.
+
+Legacy retains document/project IDs, `textSpanLength`, and its real editor
+`cacheHit`. LSP records `context_source` (`explicit`, `active`, `default`),
+`semantic_data_available`, and `token_count`. A completed lexical-only response
+is not semantic-data success; an empty file is not a failure. Full requests and
+single-line ranges have `textSpanLength`; multi-line ranges instead carry their
+zero-based UTF-16 start/end line/character positions, avoiding extra source
+scans or allocations merely for telemetry. Do not equate an LSP compiler-cache
+hit with the legacy editor-cache property.
+
+Detail samples are bounded to one per five seconds per provider/request-kind
+stream. Use the VS client's `VS/LanguageServerClient/Request/AggregateStatistics`
+for method-level succeeded/failed/cancelled totals and successful-request
+average/max durations. Its measurements cover RPC invocation/response, not
+earlier client queueing or later tag application. Aggregates flush on lifecycle
+and feedback paths, so they are not a population signal. Weight averages by
+successful request counts across flushes.
+
+Client tag-application/transport faults already use
+`VS/LanguageServerClient/Fault`, with `VS.LanguageServerClient.Operation`
+identifying the operation such as `SemanticTokensFailed`. F# additionally
+records fixed active-context outcomes and gated, bounded/deduplicated
+server-side refresh faults. Cancellation is not a fault.
+
+The VS host's basic initialization summaries are `vs/fsharp/lsp/initialized`
+and `vs/fsharp/lsp/initializationfailed`; detailed initialization exceptions
+remain in the client-owned fault stream rather than a duplicate F# fault.
+For non-internal users, initialization summaries wait for the first policy
+decision and are discarded if telemetry is disabled. A failure that prevents
+policy negotiation cannot be sent by this adapter; eligible in-process
+exposure/unavailable records and the existing client fault stream retain that
+coverage. Internal-user summaries can precede configuration and therefore lack
+the rollout-session ID. Do not invent a server-side ID or infer legacy fallback
+from an absent initialization/ready record. Generic client wrapper types are
+not unique F# identifiers.
+
+For rollout analysis, first project the actual telemetry backend into
+`EventName`, `EventTime`, `SchemaVersion`, `RolloutSessionId`, `Provider`, `State`,
+and existing SDK session/user metadata. `Provider` comes from `resolved_provider`
+on exposure events and `provider` on state/detail events; the other custom
+fields use the event's `dotnet.fsharp.` or `vs.fsharp.` prefix.
+The following query assumes that explicit projection
+as `HighlightingEvents`; it is not a claim about a production table name:
+
+```kusto
+let exposures = HighlightingEvents
+ | where EventName == "dotnet/fsharp/semantichighlighting/exposure"
+ | where SchemaVersion == 1
+ | summarize arg_min(EventTime, *) by RolloutSessionId;
+exposures
+| summarize Sessions = count(), LspSelected = countif(Provider == "lsp")
+| extend LspSelectionPercent = 100.0 * LspSelected / Sessions
+```
+
+Keep default/unavailable-setting categories and selected-but-not-ready sessions
+visible. Report readiness and observed semantic-data availability separately.
+User-level counts require the existing SDK identity and must classify users
+seen in both cohorts as mixed. Never substitute a new persistent identifier.
+The denominator includes only telemetry-eligible sessions: opted-in sessions and
+Microsoft-internal users, subject to VS-wide consent. Opted-out external sessions
+and users cannot be inferred from these records. Use existing SDK internal-user
+metadata when reporting internal and external rollout rates separately.
+
+Validate backend field mapping, F# content types, SDK cross-process session
+association, and deployed VS/client versions using actual captured events
+before deploying queries. The process correlation ID joins configured F#
+host events without assuming that out-of-process SDK session IDs match.
+The existing in-process telemetry test channel alone does not establish
+extension-host upload or backend ingestion.
+
#### Workspace State Management
This part of the library is responsible for keeping track of the current workspace state. The internal workspace model is used to create requests for FCS. It needs to make sure the internal state doesn't get out of sync with what's in the editor and on disk.
@@ -224,6 +421,12 @@ We should be able to apply configuration changes that require re-creating the FC
#### Extensibility
+Code actions are disabled by default, both in Visual Studio and in the standalone
+server. Hosts can opt in by setting `EnabledFeatures.CodeActions = true` in
+`FSharpLanguageServerConfig`. The experimental fixes use FCS only; failures are
+logged and reported as `lsp/fault` events with the fix name and exception type,
+while cancellation propagates to the caller.
+
The FsLSP library should be extensible by adding custom endpoints or overriding existing ones.
It should also allow to hook into background processes or events to customize behavior.
diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md
index 49d9c199e26..dd61a6485d9 100644
--- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md
+++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md
@@ -2,6 +2,7 @@
* Add a read-only repository review agent for performance-improvement PRs to assess measurement quality, behavior preservation, and implementation risk using source-attributed review precedents. ([PR #20619](https://github.com/dotnet/fsharp/pull/20619))
* Completion: respect EditorBrowsableAttribute ([PR #20729](https://github.com/dotnet/fsharp/pull/20729))
+* `FSharpWorkspaceQuery` members gain an optional project `context` parameter, and `GetProjectSnapshotsForFile` returns all project snapshots containing a file. ([PR #20965](https://github.com/dotnet/fsharp/pull/20965))
* Runtime-async sequences allow awaits inside `try/with` bodies, filters and handlers, and reject overlapping `MoveNextAsync` calls. ([PR #20655](https://github.com/dotnet/fsharp/pull/20655))
* F# Interactive gains a JSON-RPC server mode, `--fsi-server-jsonrpc:`, in which a host submits interactions over a named pipe and receives structured results — diagnostics with positions, escaping exceptions, the values each interaction bound, and the session's own process id — instead of recovering them by looking for a `SERVER-PROMPT>` marker in the output text. Program output continues to flow through the redirected console streams. The pipe admits only the user running the session; `--fsi-server-client-pid:` names the host process whose exit ends the session. `FsiEvaluationSession` exposes both options as `JsonRpcServerPipeName` and `JsonRpcClientProcessId`. The mode is part of the .NET fsi only. ([PR #20396](https://github.com/dotnet/fsharp/pull/20396))
* Include individual active-pattern cases from unopened modules in pattern completion. ([PR #20719](https://github.com/dotnet/fsharp/pull/20719))
@@ -29,6 +30,7 @@
* Restore NativeAOT execution of printf-formatted interpolated strings such as zero-padded timestamps, while preserving reflection-free interpolation fast paths and avoiding spurious function-hole warnings or repeated hole typechecking. Reject incorrectly typed printf thunks at compile time, including when the interpolated string is used as an object or interface. ([Issue #20757](https://github.com/dotnet/fsharp/issues/20757), [PR #20778](https://github.com/dotnet/fsharp/pull/20778))
* `GetProjectSnapshotFromScript` uses the document source of the checker when none is passed, also for the TransparentCompiler's `GetProjectOptionsFromScript`. A file from a `DocumentSource.Custom` keeps its snapshot version until its text changes, and a file for which the source returns `None` is read from disk. `FSharpFileSnapshot.CreateFromDocumentSource` now returns `Async`. `SourceTextNew.ofISourceText` returns an `ISourceTextNew` unchanged, and otherwise computes the checksum from the text instead of from `ToString()`. ([Issue #20750](https://github.com/dotnet/fsharp/issues/20750), [PR #20751](https://github.com/dotnet/fsharp/pull/20751))
* Build parser diagnostic messages without runtime printf specialization or reflective function construction, so Native AOT hosts report indentation and invalid-character diagnostics instead of failing during formatting. Preserve `#r` reference tooltips while script project options remain alive, including after garbage collection or cache eviction. ([PR #20658](https://github.com/dotnet/fsharp/pull/20658))
+* Classify F# `[]` declarations and references as events instead of allowing synthesized add/remove methods to determine their highlighting.
* Preserve tupled lambda argument groups when reducing state-machine applications, avoiding invalid argument substitutions and runtime failures with inline formatters. ([Issue #20675](https://github.com/dotnet/fsharp/issues/20675), [PR #20700](https://github.com/dotnet/fsharp/pull/20700))
* Transparent compiler: `ParseFile` no longer imports the references of the project, which also type checked the projects it references. Parsing only needs the compiler options of the project. ([PR #20704](https://github.com/dotnet/fsharp/pull/20704))
* Preserve SRTP extension scopes across source files and competing witnesses through imported inline calls. Compile generic extension forwarding, including quotations, in unoptimized builds. Preserve applicable built-in operator priority in imported inline quotations. Do not reuse SRTP specializations across different selected witnesses. ([Issue #20683](https://github.com/dotnet/fsharp/issues/20683), [Issue #20684](https://github.com/dotnet/fsharp/issues/20684), [Issue #20685](https://github.com/dotnet/fsharp/issues/20685), [PR #20717](https://github.com/dotnet/fsharp/pull/20717))
diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md
index 776ec02f5b0..3af4c171d0f 100644
--- a/docs/release-notes/.VisualStudio/18.vNext.md
+++ b/docs/release-notes/.VisualStudio/18.vNext.md
@@ -2,6 +2,7 @@
* Suggest active-pattern cases from unopened modules and offer valid Add Open and qualification edits. ([PR #20719](https://github.com/dotnet/fsharp/pull/20719))
* Add maintainer-requested F# Apex Visual Studio integration tests on DartLab-1ES CloudTest, using PR pipeline definitions pinned to an explicitly requested commit SHA. ([PR #20625](https://github.com/dotnet/fsharp/pull/20625))
+* Measure F# highlighting rollout selection independently of LSP startup and correlate legacy and LSP processing telemetry. All newly added telemetry requires the F# additional-telemetry checkbox or Microsoft-internal user status. Retry transient startup failures and keep consent forwarding active after the last F# view closes.
* Code-fixes for FS3888 (compiler-semantic attribute on the `.fs` but not the `.fsi`): copy the attribute into the `.fsi`, or remove it from the `.fs`. ([Issue #19560](https://github.com/dotnet/fsharp/issues/19560), [PR #19880](https://github.com/dotnet/fsharp/pull/19880))
* Expand `` in IDE tooltips, completion, and signature help, inheriting XML documentation from base classes, interfaces, overridden members, and constructors. ([Issue #19175](https://github.com/dotnet/fsharp/issues/19175), [PR #19188](https://github.com/dotnet/fsharp/pull/19188))
* Move F# editor and Interactive options to 43 native Unified Settings with independent roaming and a one-time import of existing settings. Preserve the legacy groupings and Interactive setting order, with restart requirements displayed by Unified Settings. Quick Info's preferred description width is a checkbox with a dependent integer control. Remove the eight legacy options pages; the user-wide compiler setting remains external. Typing and deletion completion triggers are independent. ([PR #20877](https://github.com/dotnet/fsharp/pull/20877))
@@ -10,6 +11,7 @@
### Fixed
+* Preserve semantic coloring in closed-file search results and code-fix and rename previews when LSP highlighting is selected. ([PR #20965](https://github.com/dotnet/fsharp/pull/20965))
* Go To Definition no longer blocks the UI thread with a bare `Task.Wait`: the synchronous `IFSharpGoToDefinitionService` call now waits through the cancellable threaded-wait dialog, and the editor's `TaskCompletionSource` bridges run their continuations on the thread pool instead of inline on whichever thread finished the check, so repeated F12 on a large solution no longer starves semantic classification and other main-thread work. ([PR #20482](https://github.com/dotnet/fsharp/pull/20482))
* Peek Definition on an F# symbol whose definition lives in metadata no longer deadlocks Visual Studio. Peek holds the main thread in `JoinableTaskFactory.Run` without pumping messages while it asks the language service for the definition, and generating the metadata document needs that same thread; Peek now stops at definitions that already have a document, and Go To Definition, which owns the wait it makes, still opens the generated one. ([PR #20503](https://github.com/dotnet/fsharp/pull/20503))
* Improve Find All References performance by throttling parallel typechecks. ([PR #20128](https://github.com/dotnet/fsharp/pull/20128), [PR #20695](https://github.com/dotnet/fsharp/pull/20695))
@@ -21,8 +23,10 @@
* Find All References for external DLL symbols now only searches projects that reference the specific assembly. ([Issue #10227](https://github.com/dotnet/fsharp/issues/10227), [PR #19252](https://github.com/dotnet/fsharp/pull/19252))
* Improve static compilation of state machines. ([PR #19297](https://github.com/dotnet/fsharp/pull/19297))
* Make Alt+F1 (momentary toggle) work for inlay hints. ([PR #19421](https://github.com/dotnet/fsharp/pull/19421))
+* Avoid starting the F# language server when the in-process highlighting provider is selected, and preserve unset provider settings. ([PR #20965](https://github.com/dotnet/fsharp/pull/20965))
* Fix doubled F# diagnostics in tooltips. ([Issue #16360](https://github.com/dotnet/fsharp/issues/16360))
* Fix `NotSupportedException` in the memory-mapped-file optimization when copying `ReadOnlyMemory` into `MemoryMappedFileViewStream`. ([Issue #20263](https://github.com/dotnet/fsharp/issues/20263))
+* Refresh F# LSP semantic classifications when the Visual Studio navigation bar switches target frameworks, using the selected framework's compiler output and conditional defines for active and disabled code.
* Reduce allocations in the VS project options reactor: the command-line options and project options caches and the mailbox reply payloads now hold struct tuples, and `IProjectSite.CompilationBinOutputPath` returns `string voption` picked with a new `Array.tryPickV`. ([PR #20413](https://github.com/dotnet/fsharp/pull/20413))
* Build a single-file project's `OtherOptions` reference flags with one array comprehension instead of two `Array.ofSeq` calls and an `Array.append`. ([PR #20499](https://github.com/dotnet/fsharp/pull/20499))
* Keep concurrent completion requests from mixing their suggestion lists. ([PR #20719](https://github.com/dotnet/fsharp/pull/20719))
@@ -34,8 +38,11 @@
* `FSharpProjectSnapshot`: source files and referenced projects are arrays instead of lists. ([PR #20875](https://github.com/dotnet/fsharp/pull/20875))
* Rename "inline hints" to "inlay hints" in VS options for consistency with industry terminology. ([PR #19318](https://github.com/dotnet/fsharp/pull/19318))
+* Update the F# LSP extension to Visual Studio Extensibility 18.14.65 and .NET 10 (requiring Visual Studio 18.14 or later), track CLaSP with Roslyn dependency updates, and use System.Text.Json while preserving Visual Studio project-context and diagnostic metadata.
* Unused analyzers: disable in VS when file has errors ([PR #19892](https://github.com/dotnet/fsharp/pull/19892))
* Reuse current-project symbol catalogues for completion and Add Open requests that share a file-check result. ([PR #20719](https://github.com/dotnet/fsharp/pull/20719))
+* Align the F# LSP extension and its tests with centrally managed dependencies and the unified Roslyn external-access package.
+* Update the F# editor's LSP client and protocol dependencies to match Roslyn's required assembly versions.
* Move to Roslyn's unified ExternalAccess library ([PR #20099](https://github.com/dotnet/fsharp/pull/20099))
* Remove trailing whitespace from source files. No functional change: whitespace inside string literals and inactive `#if` regions is preserved. ([PR #20355](https://github.com/dotnet/fsharp/pull/20355))
* The VS project options reactor keeps single-file and script project options in a named record instead of a five-place tuple, and its text-view subscription plumbing uses `voption` instead of `option`. ([PR #20455](https://github.com/dotnet/fsharp/pull/20455))
diff --git a/eng/Build.ps1 b/eng/Build.ps1
index a82d0a9a972..5acc71f0af3 100644
--- a/eng/Build.ps1
+++ b/eng/Build.ps1
@@ -501,9 +501,9 @@ function Collect-ApexDiagnostics([string] $screenshotPath, [string] $destination
}
# ===== END Apex CI diagnostics helpers (Phase 2) =====
-function TestApexUsingVSTest([string] $targetFramework) {
- $projectName = "FSharp.Editor.Apex.IntegrationTests"
- $apexProject = Join-Path $RepoRoot "vsintegration\tests\$projectName\$projectName.csproj"
+function TestApexUsingVSTest([string] $targetFramework, [string] $testProject) {
+ $apexProject = Join-Path $RepoRoot $testProject
+ $projectName = [System.IO.Path]::GetFileNameWithoutExtension($apexProject)
# Create the results directory up-front so the CI 'publish test logs' step never fails on a missing
# path, even when the run is skipped below (a marker file records the skip reason).
@@ -622,6 +622,11 @@ function TestApexUsingVSTest([string] $targetFramework) {
"/ResultsDirectory:$testResultsDir"
)
+ $runSettings = Join-Path (Split-Path -Parent $apexProject) ".runsettings"
+ if (Test-Path $runSettings) {
+ $vstestArgs += "/Settings:$runSettings"
+ }
+
Write-Host "$vstestConsole $($vstestArgs -join ' ')"
# ===== BEGIN Apex CI diagnostics (Phase 2, TEMPORARY — revert once the CI hang is understood) =====
@@ -950,6 +955,7 @@ try {
if ($testEditor -and -not $noVisualStudio) {
TestUsingMSBuild -testProject "$RepoRoot\vsintegration\tests\FSharp.Editor.Tests\FSharp.Editor.Tests.fsproj" -targetFramework $script:desktopTargetFramework
+ TestUsingMSBuild -testProject "$RepoRoot\tests\FSharp.VisualStudio.Extension.Tests\FSharp.VisualStudio.Extension.Tests.csproj" -targetFramework "$($script:coreclrTargetFramework)-windows8.0"
}
if ($testVs -and -not $noVisualStudio) {
@@ -961,7 +967,8 @@ try {
}
if ($testApex) {
- TestApexUsingVSTest -targetFramework $script:desktopTargetFramework
+ TestApexUsingVSTest -targetFramework $script:desktopTargetFramework -testProject "vsintegration\tests\FSharp.Editor.Apex.IntegrationTests\FSharp.Editor.Apex.IntegrationTests.csproj"
+ TestApexUsingVSTest -targetFramework $script:desktopTargetFramework -testProject "tests\FSharp.Compiler.LanguageServer.IntegrationTests\FSharp.Compiler.LanguageServer.IntegrationTests.csproj"
}
if ($testAOT) {
diff --git a/eng/Packages.props b/eng/Packages.props
index 6a1d0ce9261..ba7276be914 100644
--- a/eng/Packages.props
+++ b/eng/Packages.props
@@ -3,20 +3,6 @@
eng/Versions.props / eng/Version.Details.props. Manually-pinned, single-use packages carry
their literal version here directly (no indirection through eng/Versions.props). -->
-
-
- 10.0.9
- $(SystemSecurityCryptographyXmlVersion)
- $(SystemCollectionsImmutableVersion)
- $(SystemReflectionMetadataVersion)
- $(SystemRuntimeCentralFloorVersion)
- $(SystemRuntimeCentralFloorVersion)
- $(SystemRuntimeCentralFloorVersion)
-
-
@@ -30,7 +16,7 @@
-
+
@@ -41,19 +27,18 @@
-
+
-
-
+
-
-
+
+
@@ -72,16 +57,16 @@
-
-
+
+
-
-
-
+
+
+
+
@@ -92,7 +77,7 @@
-
+
@@ -105,7 +90,7 @@
-
+
@@ -125,10 +110,10 @@
+
-
+ Shared with Roslyn and the Extensibility SDK to avoid downgrade conflicts. -->
+
diff --git a/eng/Version.Details.props b/eng/Version.Details.props
index 1da73807b44..fc115c47a0b 100644
--- a/eng/Version.Details.props
+++ b/eng/Version.Details.props
@@ -13,6 +13,7 @@ This file should be imported by eng/Versions.props
10.0.15
10.0.15
10.0.15
+ 10.0.15
18.13.0-1.26523.4
18.13.0-1.26523.4
@@ -25,6 +26,7 @@ This file should be imported by eng/Versions.props
1.0.0-prerelease.26527.2
1.0.0-prerelease.26527.2
+ 5.12.0-1.26428.6
5.14.0-1.26557.1
5.14.0-1.26557.1
5.14.0-1.26557.1
@@ -44,6 +46,7 @@ This file should be imported by eng/Versions.props
$(SystemDiagnosticsDiagnosticSourcePackageVersion)
$(SystemReflectionMetadataPackageVersion)
$(SystemSecurityCryptographyXmlPackageVersion)
+ $(SystemTextJsonPackageVersion)
$(MicrosoftBuildPackageVersion)
$(MicrosoftBuildFrameworkPackageVersion)
@@ -56,6 +59,7 @@ This file should be imported by eng/Versions.props
$(optimizationwindows_ntx64MIBCRuntimePackageVersion)
$(optimizationwindows_ntx86MIBCRuntimePackageVersion)
+ $(MicrosoftCommonLanguageServerProtocolFrameworkPackageVersion)
$(MicrosoftCodeAnalysisPackageVersion)
$(MicrosoftCodeAnalysisCompilersPackageVersion)
$(MicrosoftCodeAnalysisCSharpPackageVersion)
diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml
index a181c872f28..d203be6270c 100644
--- a/eng/Version.Details.xml
+++ b/eng/Version.Details.xml
@@ -18,6 +18,10 @@
https://github.com/dotnet/msbuild
ad616f42ced6d62a1926a19641bd0c4353d185c3
+
+ https://github.com/dotnet/roslyn
+ 92a36efa9ae1a26a14872f87798526a1c3dd050b
+
https://github.com/dotnet/roslyn
04b3076a13a7fb376613cb17003059000db1beaa
@@ -75,6 +79,11 @@
https://github.com/dotnet/dotnet
295ff616832ba1f8b192f8178c4188a133affe93
+
+
+ https://github.com/dotnet/dotnet
+ 295ff616832ba1f8b192f8178c4188a133affe93
+
diff --git a/eng/Versions.props b/eng/Versions.props
index 1a2c3909716..28fe12ebd95 100644
--- a/eng/Versions.props
+++ b/eng/Versions.props
@@ -106,7 +106,7 @@
18.7.0-preview-1-11715-043
- 3.6.4
+ 3.8.3
diff --git a/eng/tests/TestSplit.Tests.fsx b/eng/tests/TestSplit.Tests.fsx
index df407a48b7a..d4bf7d09ad8 100644
--- a/eng/tests/TestSplit.Tests.fsx
+++ b/eng/tests/TestSplit.Tests.fsx
@@ -29,12 +29,15 @@ let core = project "FSharp.Core.UnitTests"
let service = project "FSharp.Compiler.Service.Tests"
let scripting = project "FSharp.Compiler.Private.Scripting.UnitTests"
let server = project "FSharp.Compiler.Interactive.Server.Tests"
+let languageServer = project "FSharp.Compiler.LanguageServer.Tests"
let legacy = "tests/fsharp/FSharpSuite.Tests.fsproj"
for platform in [ "coreclr"; "desktop" ] do
let aggregate = commands [ "--all"; platform ]
let expected =
- [ componentTests; build; core; service; scripting ]
+ [ componentTests; build ]
+ @ (if platform = "coreclr" then [ languageServer ] else [])
+ @ [ core; service; scripting ]
@ (if platform = "coreclr" then [ server ] else [])
@ [ legacy ]
if aggregate <> (expected |> List.map (fun p -> p, "")) then
@@ -42,7 +45,7 @@ for platform in [ "coreclr"; "desktop" ] do
let batches = [ for batch in 1..3 -> commands [ string batch; platform ] ]
let expectedBatches =
- [ [ componentTests; build ]
+ [ [ componentTests; build ] @ (if platform = "coreclr" then [ languageServer ] else [])
[ componentTests; core; service; scripting ] @ (if platform = "coreclr" then [ server ] else [])
if platform = "desktop" then [ legacy ] else [] ]
if (batches |> List.map (List.map fst)) <> expectedBatches then
diff --git a/eng/tests/TestSplit.fsx b/eng/tests/TestSplit.fsx
index 190b5770849..726d5086b62 100644
--- a/eng/tests/TestSplit.fsx
+++ b/eng/tests/TestSplit.fsx
@@ -47,6 +47,7 @@ let componentTestsAtoms =
let otherProjects =
[// project path batch batched platform, unbatched platform
"tests/FSharp.Build.UnitTests/FSharp.Build.UnitTests.fsproj", 1, "all", "all"
+ "tests/FSharp.Compiler.LanguageServer.Tests/FSharp.Compiler.LanguageServer.Tests.fsproj", 1, "coreclr", "coreclr"
"tests/FSharp.Core.UnitTests/FSharp.Core.UnitTests.fsproj", 2, "all", "all"
"tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj", 2, "all", "all"
"tests/FSharp.Compiler.Private.Scripting.UnitTests/FSharp.Compiler.Private.Scripting.UnitTests.fsproj", 2, "all", "all"
@@ -74,7 +75,6 @@ let excludedPrefixes =
// Individual projects excluded by exact path.
let excludedProjects =
[ "tests/FSharp.Test.Utilities/FSharp.Test.Utilities.fsproj" // shared test utility library, not a test project
- "tests/FSharp.Compiler.LanguageServer.Tests/FSharp.Compiler.LanguageServer.Tests.fsproj" // LSP tests, run in a dedicated CI job
"tests/FSharp.Build.UnitTests/resources/EmbeddedText/Runtime.fsproj" // executable fixture built by ResourceTests
]
diff --git a/src/Compiler/Service/FSharpWorkspaceQuery.fs b/src/Compiler/Service/FSharpWorkspaceQuery.fs
index 39c87094928..fb3af19bd67 100644
--- a/src/Compiler/Service/FSharpWorkspaceQuery.fs
+++ b/src/Compiler/Service/FSharpWorkspaceQuery.fs
@@ -46,25 +46,40 @@ type FSharpWorkspaceQuery internal (depGraph: IThreadSafeDependencyGraph<_, _>,
with :? KeyNotFoundException ->
None
- member _.GetProjectSnapshotForFile(file: Uri) =
+ /// Returns the project snapshot for a file. If `context` is provided, returns the snapshot whose
+ /// identifier matches. Otherwise (or when no match is found among the projects containing the
+ /// file) falls back to the first snapshot, matching the prior behavior. This fallback keeps
+ /// behavior reasonable for clients that don't send a VS project context (e.g. non-VS clients
+ /// or before the project-context dropdown selection arrives).
+ member _.GetProjectSnapshotForFile(file: Uri, ?context: ProjectSnapshot.FSharpProjectIdentifier) =
use _ =
Activity.start "GetProjectSnapshotForFile" [ Activity.Tags.fileName, file.LocalPath ]
- depGraph.GetProjectsContaining file.LocalPath
+ let candidates = depGraph.GetProjectsContaining file.LocalPath
- // TODO: eventually we need to deal with choosing the appropriate project here
- // Hopefully we will be able to do it through receiving project context from LSP
- // Otherwise we have to keep track of which project/configuration is active
- |> Seq.tryHead // For now just get the first one
+ match context with
+ | Some ctx ->
+ let matching = candidates |> Seq.tryFind (fun s -> s.Identifier = ctx)
- member this.GetParseAndCheckResultsForFile(file: Uri) =
+ match matching with
+ | Some _ -> matching
+ | None -> candidates |> Seq.tryHead
+ | None -> candidates |> Seq.tryHead
+
+ member _.GetProjectSnapshotsForFile(file: Uri) =
+ use _ =
+ Activity.start "GetProjectSnapshotsForFile" [ Activity.Tags.fileName, file.LocalPath ]
+
+ depGraph.GetProjectsContaining file.LocalPath |> Seq.toArray
+
+ member this.GetParseAndCheckResultsForFile(file: Uri, ?context: ProjectSnapshot.FSharpProjectIdentifier) =
async {
use _ =
Activity.start "GetParseAndCheckResultsForFile" [ Activity.Tags.fileName, file.LocalPath ]
return!
- this.GetProjectSnapshotForFile file
+ this.GetProjectSnapshotForFile(file, ?context = context)
|> Option.map (fun snapshot ->
async {
let! parseResult, checkFileAnswer = checker.ParseAndCheckFileInProject(file.LocalPath, snapshot)
@@ -78,15 +93,15 @@ type FSharpWorkspaceQuery internal (depGraph: IThreadSafeDependencyGraph<_, _>,
}
- member this.GetCheckResultsForFile(file) =
- this.GetParseAndCheckResultsForFile file |> Async.map snd
+ member this.GetCheckResultsForFile(file, ?context: ProjectSnapshot.FSharpProjectIdentifier) =
+ this.GetParseAndCheckResultsForFile(file, ?context = context) |> Async.map snd
// TODO: split to parse and check diagnostics
- member this.GetDiagnosticsForFile(file: Uri) =
+ member this.GetDiagnosticsForFile(file: Uri, ?context: ProjectSnapshot.FSharpProjectIdentifier) =
use _ =
Activity.start "GetDiagnosticsForFile" [ Activity.Tags.fileName, file.LocalPath ]
- this.GetParseAndCheckResultsForFile file
+ this.GetParseAndCheckResultsForFile(file, ?context = context)
|> Async.map (fun results ->
let diagnostics =
match results with
@@ -96,11 +111,11 @@ type FSharpWorkspaceQuery internal (depGraph: IThreadSafeDependencyGraph<_, _>,
FSharpDiagnosticReport(diagnostics, getDiagnosticResultId ()))
- member this.GetSemanticClassification(file: Uri) =
+ member this.GetSemanticClassification(file: Uri, ?context: ProjectSnapshot.FSharpProjectIdentifier) =
use _ =
Activity.start "GetSemanticClassification" [ Activity.Tags.fileName, file.LocalPath ]
- this.GetProjectSnapshotForFile file
+ this.GetProjectSnapshotForFile(file, ?context = context)
|> Option.map (fun snapshot ->
checker.GetBackgroundSemanticClassificationForFile(file.LocalPath, snapshot, "LSP Get semantic classification"))
|> Option.defaultValue (async.Return None)
diff --git a/src/Compiler/Service/SemanticClassification.fs b/src/Compiler/Service/SemanticClassification.fs
index 1bf5a752e18..2c7a0462d2e 100644
--- a/src/Compiler/Service/SemanticClassification.fs
+++ b/src/Compiler/Service/SemanticClassification.fs
@@ -182,9 +182,9 @@ module TcResolutionsExtensions =
| _ -> None
| _ -> None
- // Custom builders like 'async { }' are both Item.Value and Item.CustomBuilder.
- // We should prefer the latter, otherwise they would not get classified as CEs.
- let takeCustomBuilder (cnrs: CapturedNameResolution[]) =
+ // CLI event declarations share their range with synthesized add/remove methods;
+ // custom builders share theirs with an ordinary value resolution.
+ let takePreferredResolution (cnrs: CapturedNameResolution[]) =
assert (cnrs.Length > 0)
if cnrs.Length = 1 then
@@ -201,12 +201,22 @@ module TcResolutionsExtensions =
| Item.Value _ -> true
| _ -> false
- let ceCnrs = cnrs |> Array.filter isCE
+ let eventCnrs =
+ cnrs
+ |> Array.filter (fun cnr ->
+ match cnr.Item with
+ | Item.Value vref when vref.TryDeref.IsValueSome -> vref.IsFSharpEventProperty g
+ | _ -> false)
- if ceCnrs.Length > 0 && cnrs |> Array.forall (fun c -> isCE c || isPlainValue c) then
- ceCnrs
+ if eventCnrs.Length > 0 then
+ eventCnrs
else
- cnrs
+ let ceCnrs = cnrs |> Array.filter isCE
+
+ if ceCnrs.Length > 0 && cnrs |> Array.forall (fun c -> isCE c || isPlainValue c) then
+ ceCnrs
+ else
+ cnrs
let resolutions =
let baseCnrs =
@@ -218,7 +228,7 @@ module TcResolutionsExtensions =
baseCnrs
|> Array.groupBy (fun cnr -> cnr.Range)
- |> Array.collect (fun (_, cnrs) -> takeCustomBuilder cnrs)
+ |> Array.collect (fun (_, cnrs) -> takePreferredResolution cnrs)
let duplicates = HashSet(comparer)
@@ -239,6 +249,8 @@ module TcResolutionsExtensions =
| (Item.CustomBuilder _ | Item.CustomOperation _), ItemOccurrence.Use, m ->
add m SemanticClassificationType.ComputationExpression
+ | Item.Value vref, _, m when vref.IsFSharpEventProperty g -> add m SemanticClassificationType.Event
+
| Item.Value vref, _, m when isValRefMutable g vref -> add m SemanticClassificationType.MutableVar
| Item.Value KeywordIntrinsicValue, ItemOccurrence.Use, m -> add m SemanticClassificationType.IntrinsicFunction
@@ -299,7 +311,9 @@ module TcResolutionsExtensions =
add m SemanticClassificationType.RecordField
| Item.Property(info = pinfo :: _), _, m ->
- if not pinfo.IsIndexer then
+ if pinfo.IsFSharpEventProperty then
+ add m SemanticClassificationType.Event
+ elif not pinfo.IsIndexer then
add m SemanticClassificationType.Property
| Item.CtorGroup(_, minfos), _, m ->
diff --git a/src/FSharp.Compiler.LanguageServer/Common/CapabilitiesManager.fs b/src/FSharp.Compiler.LanguageServer/Common/CapabilitiesManager.fs
index 556a1d96edc..ba254ebd18b 100644
--- a/src/FSharp.Compiler.LanguageServer/Common/CapabilitiesManager.fs
+++ b/src/FSharp.Compiler.LanguageServer/Common/CapabilitiesManager.fs
@@ -32,20 +32,25 @@ type CapabilitiesManager(config: FSharpLanguageServerConfig, scOverrides: IServe
Identifier = "potato",
WorkspaceDiagnostics = true
)),
- //CompletionProvider = CompletionOptions(TriggerCharacters = [| "."; " " |], ResolveProvider = true, WorkDoneProgress = true),
- //HoverProvider = SumType(HoverOptions(WorkDoneProgress = true))
+ CodeActionProvider =
+ (if config.EnabledFeatures.CodeActions then
+ System.Nullable(
+ SumType(
+ CodeActionOptions(CodeActionKinds = [| CodeActionKind.QuickFix |], ResolveProvider = false)
+ )
+ )
+ else
+ System.Nullable()),
SemanticTokensOptions =
addIf
- config.EnabledFeatures.SemanticHighlighting
-
+ config.EnabledFeatures.SemanticTokens
(SemanticTokensOptions(
- Legend =
- SemanticTokensLegend(
- TokenTypes = (SemanticTokenTypes.AllTypes |> Seq.toArray), // XXX should be extended
- TokenModifiers = (SemanticTokenModifiers.AllModifiers |> Seq.toArray)
- ),
- Range = false
+ Legend = LspSemanticTokens.legend (),
+ Full = System.Nullable(SumType(true)),
+ Range = System.Nullable(SumType(true))
))
+ //CompletionProvider = CompletionOptions(TriggerCharacters = [| "."; " " |], ResolveProvider = true, WorkDoneProgress = true),
+ //HoverProvider = SumType(HoverOptions(WorkDoneProgress = true))
)
interface IInitializeManager with
diff --git a/src/FSharp.Compiler.LanguageServer/Common/FSharpRequestContext.fs b/src/FSharp.Compiler.LanguageServer/Common/FSharpRequestContext.fs
index fd096d3e582..0d7e883a7fa 100644
--- a/src/FSharp.Compiler.LanguageServer/Common/FSharpRequestContext.fs
+++ b/src/FSharp.Compiler.LanguageServer/Common/FSharpRequestContext.fs
@@ -1,165 +1,48 @@
namespace FSharp.Compiler.LanguageServer.Common
open Microsoft.CommonLanguageServerProtocol.Framework
+open System
+open System.Collections.Concurrent
open System.Threading
open System.Threading.Tasks
-open System
-open System.Collections.Generic
-open Microsoft.VisualStudio.LanguageServer.Protocol
-
-open FSharp.Compiler.CodeAnalysis
-open FSharp.Compiler.EditorServices
-open FSharp.Compiler.Tokenization
-
-open System.Threading
open FSharp.Compiler.CodeAnalysis.Workspace
+open FSharp.Compiler.CodeAnalysis.ProjectSnapshot
#nowarn "57"
-module TokenTypes =
-
- []
- let (|LexicalClassification|_|) (tok: FSharpToken) =
- if tok.IsKeyword then
- ValueSome SemanticTokenTypes.Keyword
- elif tok.IsNumericLiteral then
- ValueSome SemanticTokenTypes.Number
- elif tok.IsCommentTrivia then
- ValueSome SemanticTokenTypes.Comment
- elif tok.IsStringLiteral then
- ValueSome SemanticTokenTypes.String
+type FSharpRequestContext(lspServices: ILspServices, logger: ILspLogger, workspace: FSharpWorkspace) =
+ let pathComparer =
+ if OperatingSystem.IsWindows() then
+ StringComparer.OrdinalIgnoreCase
else
- ValueNone
-
- // Tokenizes the source code and returns a list of token ranges and their SemanticTokenTypes
- let GetSyntacticTokenTypes (source: FSharp.Compiler.Text.ISourceText) (fileName: string) =
- let mutable tokRangesAndTypes = []
-
- let tokenCallback =
- fun (tok: FSharpToken) ->
- match tok with
- | LexicalClassification tokType -> tokRangesAndTypes <- (tok.Range, tokType) :: tokRangesAndTypes
- | _ -> ()
-
- FSharpLexer.Tokenize(
- source,
- tokenCallback,
- flags =
- (FSharpLexerFlags.Default
- &&& ~~~FSharpLexerFlags.Compiling
- &&& ~~~FSharpLexerFlags.UseLexFilter),
- filePath = fileName
- )
-
- tokRangesAndTypes
-
- let FSharpTokenTypeToLSP (fst: SemanticClassificationType) =
- // XXX kinda arbitrary mapping
- match fst with
- | SemanticClassificationType.ReferenceType -> SemanticTokenTypes.Class
- | SemanticClassificationType.ValueType -> SemanticTokenTypes.Struct
- | SemanticClassificationType.UnionCase -> SemanticTokenTypes.Enum
- | SemanticClassificationType.UnionCaseField -> SemanticTokenTypes.EnumMember
- | SemanticClassificationType.Function -> SemanticTokenTypes.Function
- | SemanticClassificationType.Property -> SemanticTokenTypes.Property
- | SemanticClassificationType.Module -> SemanticTokenTypes.Type
- | SemanticClassificationType.Namespace -> SemanticTokenTypes.Namespace
- | SemanticClassificationType.Interface -> SemanticTokenTypes.Interface
- | SemanticClassificationType.TypeArgument -> SemanticTokenTypes.TypeParameter
- | SemanticClassificationType.Operator -> SemanticTokenTypes.Operator
- | SemanticClassificationType.Method -> SemanticTokenTypes.Method
- | SemanticClassificationType.ExtensionMethod -> SemanticTokenTypes.Method
- | SemanticClassificationType.Field -> SemanticTokenTypes.Property
- | SemanticClassificationType.Event -> SemanticTokenTypes.Event
- | SemanticClassificationType.Delegate -> SemanticTokenTypes.Function
- | SemanticClassificationType.NamedArgument -> SemanticTokenTypes.Parameter
- | SemanticClassificationType.LocalValue -> SemanticTokenTypes.Variable
- | SemanticClassificationType.Plaintext -> SemanticTokenTypes.String
- | SemanticClassificationType.Type -> SemanticTokenTypes.Type
- | SemanticClassificationType.Printf -> SemanticTokenTypes.Keyword
- | _ -> SemanticTokenTypes.Comment
-
- let toIndex (x: string) =
- SemanticTokenTypes.AllTypes |> Seq.findIndex (fun y -> y = x)
+ StringComparer.Ordinal
+
+ let activeProjectContexts =
+ ConcurrentDictionary(pathComparer)
+
+ let openDocuments = ConcurrentDictionary(pathComparer)
+
+ member internal _.IsDocumentOpen(uri: Uri) = openDocuments.ContainsKey uri.LocalPath
+ member internal _.OpenDocument(uri: Uri) = openDocuments[uri.LocalPath] <- 0uy
+
+ member internal _.CloseDocument(uri: Uri) =
+ openDocuments.TryRemove uri.LocalPath |> ignore
-type FSharpRequestContext(lspServices: ILspServices, logger: ILspLogger, workspace: FSharpWorkspace) =
member _.LspServices = lspServices
member _.Logger = logger
member _.Workspace = workspace
- member this.GetSemanticTokensForFile(file) =
- task {
- let! scv = this.Workspace.Query.GetSemanticClassification file
- let! source = this.Workspace.Query.GetSource file
-
- match scv, source with
- | Some view, Some source ->
-
- let tokens = ResizeArray()
-
- view.ForEach(fun item ->
- let range = item.Range
- let tokenType = item.Type |> TokenTypes.FSharpTokenTypeToLSP |> TokenTypes.toIndex
- tokens.Add(range, tokenType))
-
- let syntacticClassifications =
- TokenTypes.GetSyntacticTokenTypes source file.LocalPath
- |> Seq.map (fun (r, t) -> (r, TokenTypes.toIndex t))
-
- let allTokens = Seq.append tokens syntacticClassifications
-
- let lspFormatTokens =
- allTokens
- |> Seq.map (fun (r, tokType) ->
- let length = r.EndColumn - r.StartColumn // XXX Does not deal with multiline tokens?
-
- {|
- startLine = r.StartLine - 1
- startCol = r.StartColumn
- length = length
- tokType = tokType
- tokMods = 0
- |})
- //(startLine, startCol, length, tokType, tokMods))
- |> Seq.sortWith (fun x1 x2 ->
- let c = x1.startLine.CompareTo(x2.startLine)
- if c <> 0 then c else x1.startCol.CompareTo(x2.startCol))
-
- let tokensRelative =
- lspFormatTokens
- |> Seq.append
- [|
- {|
- startLine = 0
- startCol = 0
- length = 0
- tokType = 0
- tokMods = 0
- |}
- |]
- |> Seq.pairwise
- |> Seq.map (fun (prev, this) ->
- {|
- startLine = this.startLine - prev.startLine
- startCol =
- (if prev.startLine = this.startLine then
- this.startCol - prev.startCol
- else
- this.startCol)
- length = this.length
- tokType = this.tokType
- tokMods = this.tokMods
- |})
-
- return
- tokensRelative
- |> Seq.map (fun tok -> [| tok.startLine; tok.startCol; tok.length; tok.tokType; tok.tokMods |])
- |> Seq.concat
- |> Seq.toArray
-
- | _ -> return [||]
- }
+ member _.TryGetActiveProjectContext(uri: Uri) =
+ match activeProjectContexts.TryGetValue uri.LocalPath with
+ | true, identifier -> Some identifier
+ | _ -> None
+
+ member _.SetActiveProjectContext(uri: Uri, identifier) =
+ activeProjectContexts[uri.LocalPath] <- identifier
+
+ member _.ClearActiveProjectContext(uri: Uri) =
+ activeProjectContexts.TryRemove uri.LocalPath |> ignore
type ContextHolder(workspace, lspServices: ILspServices) =
@@ -177,7 +60,7 @@ type FShapRequestContextFactory(lspServices: ILspServices) =
override _.CreateRequestContextAsync<'TRequestParam>
(
- _queueItem: IQueueItem,
+ _queueItem: QueueItem,
_methodHandler: IMethodHandler,
_requestParam: 'TRequestParam,
_cancellationToken: CancellationToken
diff --git a/src/FSharp.Compiler.LanguageServer/Common/LifecycleManager.fs b/src/FSharp.Compiler.LanguageServer/Common/LifecycleManager.fs
index 1919512375e..1e305f9626b 100644
--- a/src/FSharp.Compiler.LanguageServer/Common/LifecycleManager.fs
+++ b/src/FSharp.Compiler.LanguageServer/Common/LifecycleManager.fs
@@ -1,24 +1,17 @@
namespace FSharp.Compiler.LanguageServer.Common
open Microsoft.CommonLanguageServerProtocol.Framework
-open System
-open StreamJsonRpc
open System.Threading.Tasks
open Microsoft.Extensions.DependencyInjection
#nowarn "3261"
-type LspServiceLifeCycleManager() =
-
- interface ILifeCycleManager with
- member _.ShutdownAsync(_message: string) =
- task {
- try
- printfn "Shutting down"
- with
- | :? ObjectDisposedException
- | :? ConnectionLostException -> ()
- }
+type LspServiceLifeCycleManager(logger: ILspLogger) =
+
+ interface IOnServerShutdown with
+ member _.ShutdownAsync() =
+ logger.LogInformation("Shutting down")
+ Task.CompletedTask
member _.ExitAsync() = Task.CompletedTask
diff --git a/src/FSharp.Compiler.LanguageServer/Executable.fs b/src/FSharp.Compiler.LanguageServer/Executable.fs
index acc8e869fdc..4df033f2cd6 100644
--- a/src/FSharp.Compiler.LanguageServer/Executable.fs
+++ b/src/FSharp.Compiler.LanguageServer/Executable.fs
@@ -6,16 +6,18 @@ open StreamJsonRpc
[]
let main _argv =
- let jsonRpc = new JsonRpc(Console.OpenStandardOutput(), Console.OpenStandardInput())
+ let formatter = createConfiguredFormatter ()
- let _s = new FSharpLanguageServer(jsonRpc, (LspLogger Console.Out.Write))
+ let messageHandler =
+ new HeaderDelimitedMessageHandler(Console.OpenStandardOutput(), Console.OpenStandardInput(), formatter)
+
+ let jsonRpc = new JsonRpc(messageHandler)
+
+ let server =
+ new FSharpLanguageServer(jsonRpc, formatter.JsonSerializerOptions, (LspLogger Console.Error.Write))
jsonRpc.StartListening()
- async {
- while true do
- do! Async.Sleep 1000
- }
- |> Async.RunSynchronously
+ server.WaitForExitAsync().GetAwaiter().GetResult()
0
diff --git a/src/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.fsproj b/src/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.fsproj
index 6fed6f5a940..4d7ded14be6 100644
--- a/src/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.fsproj
+++ b/src/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.fsproj
@@ -15,6 +15,9 @@
+
+
+
@@ -36,12 +39,17 @@
+
+
+
+
+
diff --git a/src/FSharp.Compiler.LanguageServer/FSharpLanguageServer.fs b/src/FSharp.Compiler.LanguageServer/FSharpLanguageServer.fs
index 3aadb29b2b7..d4a8368f4d0 100644
--- a/src/FSharp.Compiler.LanguageServer/FSharpLanguageServer.fs
+++ b/src/FSharp.Compiler.LanguageServer/FSharpLanguageServer.fs
@@ -1,10 +1,14 @@
namespace FSharp.Compiler.LanguageServer
+open System
+open System.Diagnostics
open System.Runtime.CompilerServices
+open System.Text.Json
+open System.Text.Json.Serialization
+
open FSharp.Compiler.LanguageServer.Common
open FSharp.Compiler.LanguageServer.Handlers
-open System
open Microsoft.CommonLanguageServerProtocol.Framework.Handlers
open Microsoft.CommonLanguageServerProtocol.Framework
open Microsoft.Extensions.DependencyInjection
@@ -12,7 +16,6 @@ open Microsoft.VisualStudio.LanguageServer.Protocol
open StreamJsonRpc
open Nerdbank.Streams
-open System.Diagnostics
open FSharp.Compiler.CodeAnalysis.Workspace
#nowarn "57"
@@ -22,6 +25,37 @@ module Stuff =
[]
let FSharpLanguageName = "F#"
+ // Protocol.Extensions exposes its System.Text.Json converter registration only internally.
+ type private VSExtensionConverter<'TBase when 'TBase: not struct and 'TBase: not null>(extensionType: Type) =
+ inherit JsonConverter<'TBase>()
+
+ do
+ if not (typeof<'TBase>.IsAssignableFrom(extensionType)) then
+ invalidArg (nameof extensionType) "The extension type must derive from the protocol type."
+
+ override _.Read(reader: byref, _typeToConvert: Type, options: JsonSerializerOptions) : 'TBase | null =
+ JsonSerializer.Deserialize(&reader, extensionType, options) :?> ('TBase | null)
+
+ override this.Write(writer: Utf8JsonWriter, value: 'TBase, options: JsonSerializerOptions) =
+ let runtimeType = value.GetType()
+
+ if runtimeType = typeof<'TBase> then
+ let baseOptions = JsonSerializerOptions(options)
+ baseOptions.Converters.Remove(this) |> ignore
+ JsonSerializer.Serialize(writer, value, baseOptions)
+ else
+ JsonSerializer.Serialize(writer, value, runtimeType, options)
+
+ let createConfiguredFormatter () =
+ let formatter = new SystemTextJsonFormatter()
+ let options = formatter.JsonSerializerOptions
+ options.Converters.Add(VSExtensionConverter(typeof))
+ options.Converters.Add(VSExtensionConverter(typeof))
+ options.Converters.Add(VSExtensionConverter(typeof))
+ options.Converters.Add(VSExtensionConverter(typeof))
+ options.Converters.Add(VSExtensionConverter(typeof))
+ formatter
+
[]
type Extensions =
@@ -32,14 +66,14 @@ type Extensions =
type FSharpLanguageServer
(
jsonRpc: JsonRpc,
+ serializerOptions: JsonSerializerOptions,
logger: ILspLogger,
?initialWorkspace: FSharpWorkspace,
?addExtraHandlers: Action,
?config: FSharpLanguageServerConfig
) =
- // TODO: Switch to SystemTextJsonLanguageServer
- inherit NewtonsoftLanguageServer(jsonRpc, Newtonsoft.Json.JsonSerializer.CreateDefault(), logger)
+ inherit CancellableSystemTextJsonLanguageServer(jsonRpc, serializerOptions)
let config = defaultArg config FSharpLanguageServerConfig.Default
let initialWorkspace = defaultArg initialWorkspace (FSharpWorkspace())
@@ -56,17 +90,23 @@ type FSharpLanguageServer
let _ =
serviceCollection
.AddSingleton(initialWorkspace)
+ .AddSingleton(jsonRpc)
.AddSingleton()
.AddSingleton(config)
.AddSingleton>()
.AddSingleton>()
.AddSingleton()
.AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
.AddSingleton(logger)
+ .AddSingleton(NullTelemetry.Instance)
.AddSingleton, FShapRequestContextFactory>()
.AddSingleton, CapabilitiesManager>()
.AddSingleton(this)
- .AddSingleton(new LspServiceLifeCycleManager())
+ .AddSingleton(new LspServiceLifeCycleManager(logger))
match addExtraHandlers with
| Some handler -> handler.Invoke(serviceCollection)
@@ -95,21 +135,27 @@ type FSharpLanguageServer
let struct (clientStream, serverStream) = FullDuplexStream.CreatePair()
// TODO: handle disposal of these
- let formatter = new JsonMessageFormatter()
+ let formatter = createConfiguredFormatter ()
let messageHandler =
new HeaderDelimitedMessageHandler(serverStream, serverStream, formatter)
let jsonRpc = new JsonRpc(messageHandler)
- let listener = new TextWriterTraceListener(Console.Out)
-
- jsonRpc.TraceSource.Listeners.Add(listener) |> ignore
+ let consoleListener = new TextWriterTraceListener(Console.Out)
+ jsonRpc.TraceSource.Listeners.Add(consoleListener) |> ignore
jsonRpc.TraceSource.Switch.Level <- SourceLevels.All
let server =
- new FSharpLanguageServer(jsonRpc, logger, initialWorkspace, ?addExtraHandlers = addExtraHandlers, ?config = config)
+ new FSharpLanguageServer(
+ jsonRpc,
+ formatter.JsonSerializerOptions,
+ logger,
+ initialWorkspace,
+ ?addExtraHandlers = addExtraHandlers,
+ ?config = config
+ )
jsonRpc.StartListening()
diff --git a/src/FSharp.Compiler.LanguageServer/FSharpLanguageServerConfig.fs b/src/FSharp.Compiler.LanguageServer/FSharpLanguageServerConfig.fs
index a1acd2b88da..45891f838fc 100644
--- a/src/FSharp.Compiler.LanguageServer/FSharpLanguageServerConfig.fs
+++ b/src/FSharp.Compiler.LanguageServer/FSharpLanguageServerConfig.fs
@@ -3,13 +3,15 @@ namespace FSharp.Compiler.LanguageServer
type FSharpLanguageServerFeatures =
{
Diagnostics: bool
- SemanticHighlighting: bool
+ CodeActions: bool
+ SemanticTokens: bool
}
static member Default =
{
Diagnostics = true
- SemanticHighlighting = true
+ CodeActions = false
+ SemanticTokens = true
}
type FSharpLanguageServerConfig =
diff --git a/src/FSharp.Compiler.LanguageServer/Handlers/CodeActionHandler.fs b/src/FSharp.Compiler.LanguageServer/Handlers/CodeActionHandler.fs
new file mode 100644
index 00000000000..2191c8e2c30
--- /dev/null
+++ b/src/FSharp.Compiler.LanguageServer/Handlers/CodeActionHandler.fs
@@ -0,0 +1,923 @@
+namespace FSharp.Compiler.LanguageServer.Handlers
+
+open System
+open System.Collections.Generic
+open System.Text.RegularExpressions
+open Microsoft.CommonLanguageServerProtocol.Framework
+open Microsoft.VisualStudio.LanguageServer.Protocol
+open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks
+open FSharp.Compiler.LanguageServer.Common
+open FSharp.Compiler.LanguageServer
+open FSharp.Compiler.CodeAnalysis
+open FSharp.Compiler.Syntax
+open FSharp.Compiler.Text
+open FSharp.Compiler.Diagnostics
+open FSharp.Compiler.Diagnostics.ExtendedData
+open FSharp.Compiler.EditorServices
+open FSharp.Compiler.Symbols
+open FSharp.Compiler.Tokenization
+open System.Threading
+
+#nowarn "57"
+
+// Protocol Position is shadowed by FSharp.Compiler.Text.Position
+type private LspPosition = Microsoft.VisualStudio.LanguageServer.Protocol.Position
+
+// Context passed to each code fix function.
+type CodeFixContext =
+ {
+ SourceText: ISourceText
+ ParseResults: FSharpParseFileResults
+ CheckResults: FSharpCheckFileResults option
+ Diagnostic: FSharpDiagnostic
+ DiagnosticRange: range
+ FilePath: string
+ DocumentUri: Uri
+ }
+
+module private LspCodeFixes =
+
+ // ---------------------------------------------------------------
+ // Utility functions
+ // ---------------------------------------------------------------
+
+ let fcsRangeToLspRange (range: range) =
+ LspRange(
+ Start = LspPosition(Line = range.StartLine - 1, Character = range.StartColumn),
+ End = LspPosition(Line = range.EndLine - 1, Character = range.EndColumn)
+ )
+
+ let rangesOverlap (lspRange: LspRange) (fcsRange: range) =
+ lspRange.Start.Line <= fcsRange.EndLine - 1
+ && fcsRange.StartLine - 1 <= lspRange.End.Line
+
+ let makeCodeAction (title: string) (documentUri: Uri) (editRange: range) (newText: string) =
+ let changes = Dictionary()
+
+ changes.[documentUri.AbsoluteUri] <- [| TextEdit(Range = fcsRangeToLspRange editRange, NewText = newText) |]
+
+ [|
+ CodeAction(Title = title, Kind = CodeActionKind.QuickFix, Edit = WorkspaceEdit(Changes = changes))
+ |]
+
+ let makeMultiEditCodeAction (title: string) (documentUri: Uri) (edits: (range * string) array) =
+ let changes = Dictionary()
+
+ changes.[documentUri.AbsoluteUri] <-
+ edits
+ |> Array.map (fun (r, newText) -> TextEdit(Range = fcsRangeToLspRange r, NewText = newText))
+
+ [|
+ CodeAction(Title = title, Kind = CodeActionKind.QuickFix, Edit = WorkspaceEdit(Changes = changes))
+ |]
+
+ let textAtRange (sourceText: ISourceText) (range: range) = sourceText.GetSubTextFromRange(range)
+
+ let symbolAtPosition (ctx: CodeFixContext) (checkResults: FSharpCheckFileResults) (position: pos) =
+ let line = ctx.SourceText.GetLineString(position.Line - 1)
+
+ let rec identifierAt column =
+ match QuickParse.GetCompleteIdentifierIsland true line column with
+ | Some(_, endColumn, _) when endColumn > column && endColumn < line.Length && line[endColumn] = '.' ->
+ identifierAt (endColumn + 1)
+ | identifier -> identifier
+
+ match identifierAt position.Column with
+ | Some(_, endColumn, _) ->
+ let name = QuickParse.GetPartialLongNameEx(line, endColumn - 1)
+ let island = [ yield! name.QualifyingIdents; name.PartialIdent ]
+ checkResults.GetSymbolUseAtLocation(position.Line, endColumn, line, island)
+ | None -> None
+
+ // ---------------------------------------------------------------
+ // FS0760: Add 'new' keyword
+ // ---------------------------------------------------------------
+
+ let addNewKeywordFix (ctx: CodeFixContext) =
+ let sourceText = ctx.SourceText
+ let parseResults = ctx.ParseResults
+ let diagnosticRange = ctx.DiagnosticRange
+ let filePath = ctx.FilePath
+ let documentUri = ctx.DocumentUri
+
+ let getSourceLineStr line = sourceText.GetLineString(line - 1)
+
+ let matchingApp path node =
+ let (|TargetTy|_|) expr =
+ match expr with
+ | SynExpr.Ident id -> Some(SynType.LongIdent(SynLongIdent([ id ], [], [])))
+ | SynExpr.LongIdent(longDotId = longDotId) -> Some(SynType.LongIdent longDotId)
+ | SynExpr.TypeApp(SynExpr.Ident id, lessRange, typeArgs, commaRanges, greaterRange, _, range) ->
+ Some(
+ SynType.App(
+ SynType.LongIdent(SynLongIdent([ id ], [], [])),
+ Some lessRange,
+ typeArgs,
+ commaRanges,
+ greaterRange,
+ false,
+ range
+ )
+ )
+ | SynExpr.TypeApp(SynExpr.LongIdent(longDotId = longDotId), lessRange, typeArgs, commaRanges, greaterRange, _, range) ->
+ Some(SynType.App(SynType.LongIdent longDotId, Some lessRange, typeArgs, commaRanges, greaterRange, false, range))
+ | _ -> None
+
+ match node with
+ | SyntaxNode.SynExpr(SynExpr.App(funcExpr = TargetTy targetTy; argExpr = argExpr; range = m)) when
+ Range.equals m diagnosticRange
+ ->
+ Some(targetTy, argExpr, path)
+ | _ -> None
+
+ match
+ (diagnosticRange.Start, parseResults.ParseTree)
+ ||> ParsedInput.tryPick matchingApp
+ with
+ | None -> [||]
+ | Some(targetTy, argExpr, path) ->
+ let needsParens =
+ let newExpr = SynExpr.New(false, targetTy, argExpr, diagnosticRange)
+
+ argExpr
+ |> SynExpr.shouldBeParenthesizedInContext getSourceLineStr (SyntaxNode.SynExpr newExpr :: path)
+
+ let targetTyText = sourceText.GetSubTextFromRange(targetTy.Range)
+
+ let textBetween =
+ let betweenRange = Range.mkRange filePath targetTy.Range.End argExpr.Range.Start
+
+ if needsParens && betweenRange.StartLine = betweenRange.EndLine then
+ ""
+ else
+ sourceText.GetSubTextFromRange(betweenRange)
+
+ let argExprText =
+ let originalArgText = sourceText.GetSubTextFromRange(argExpr.Range)
+
+ if needsParens then
+ $"(%s{originalArgText})"
+ else
+ originalArgText
+
+ let newText = $"new %s{targetTyText}%s{textBetween}%s{argExprText}"
+
+ makeCodeAction "Add 'new' keyword" documentUri diagnosticRange newText
+
+ // FS0043: Replace '==' with '='
+ let convertToSingleEquals (ctx: CodeFixContext) =
+ let text = textAtRange ctx.SourceText ctx.DiagnosticRange
+
+ if text = "==" then
+ makeCodeAction "Use '=' for equality" ctx.DocumentUri ctx.DiagnosticRange "="
+ else
+ [||]
+
+ // FS0043: Replace '!=' with '<>'
+ let convertToNotEquals (ctx: CodeFixContext) =
+ let text = textAtRange ctx.SourceText ctx.DiagnosticRange
+
+ if text = "!=" then
+ makeCodeAction "Use '<>' for inequality" ctx.DocumentUri ctx.DiagnosticRange "<>"
+ else
+ [||]
+
+ // FS3198: Replace ':?>' with ':>' or 'downcast' with 'upcast'
+ let changeToUpcast (ctx: CodeFixContext) =
+ let text = textAtRange ctx.SourceText ctx.DiagnosticRange
+ let hasDowncastOp = text.Contains(":?>")
+ let hasDowncastKw = text.Contains("downcast")
+
+ if hasDowncastOp && not hasDowncastKw then
+ let newText = text.Replace(":?>", ":>")
+ makeCodeAction "Use upcast operator" ctx.DocumentUri ctx.DiagnosticRange newText
+ elif hasDowncastKw && not hasDowncastOp then
+ let newText = text.Replace("downcast", "upcast")
+ makeCodeAction "Use 'upcast'" ctx.DocumentUri ctx.DiagnosticRange newText
+ else
+ [||]
+
+ // FS3366: Remove '.' before indexer bracket
+ let fixIndexerAccess (ctx: CodeFixContext) =
+ makeCodeAction "Remove indexer dot" ctx.DocumentUri ctx.DiagnosticRange ""
+
+ // FS0597: Wrap expression in parentheses
+ let wrapExpressionInParentheses (ctx: CodeFixContext) =
+ let text = textAtRange ctx.SourceText ctx.DiagnosticRange
+ makeCodeAction "Wrap expression in parentheses" ctx.DocumentUri ctx.DiagnosticRange $"(%s{text})"
+
+ // FS0003: Change prefix negation to infix subtraction by adding space after '-'
+ let changePrefixNegationToInfixSubtraction (ctx: CodeFixContext) =
+ // The diagnostic range covers the identifier; the '-' is before it.
+ // We need to find the '-' after the squiggly span and insert a space.
+ let line = ctx.SourceText.GetLineString(ctx.DiagnosticRange.EndLine - 1)
+ let col = ctx.DiagnosticRange.EndColumn
+
+ let rec findDash i =
+ if i >= line.Length then None
+ elif line.[i] = '-' then Some i
+ elif Char.IsWhiteSpace(line.[i]) then findDash (i + 1)
+ else None
+
+ match findDash col with
+ | Some dashCol ->
+ // Insert a space after the dash
+ let insertPos = Position.mkPos ctx.DiagnosticRange.EndLine (dashCol + 1)
+
+ let insertRange = Range.mkRange ctx.FilePath insertPos insertPos
+ makeCodeAction "Use subtraction instead of negation" ctx.DocumentUri insertRange " "
+ | None -> [||]
+
+ // FS0039/FS0201: Replace 'using' with 'open' and remove ';'
+ let convertCSharpUsingToFSharpOpen (ctx: CodeFixContext) =
+ let msg = ctx.Diagnostic.Message
+ let text = textAtRange ctx.SourceText ctx.DiagnosticRange
+
+ let isUsingInError = msg.Contains("using ") || text.Contains("using ")
+
+ if not isUsingInError then
+ [||]
+ else
+ let lineIdx = ctx.DiagnosticRange.StartLine - 1
+ let line = ctx.SourceText.GetLineString(lineIdx)
+
+ if line.Contains("using") then
+ let newLine = line.Replace("using", "open").Replace(";", "")
+
+ let lineRange =
+ Range.mkRange
+ ctx.FilePath
+ (Position.mkPos ctx.DiagnosticRange.StartLine 0)
+ (Position.mkPos ctx.DiagnosticRange.StartLine line.Length)
+
+ makeCodeAction "Replace 'using' with 'open'" ctx.DocumentUri lineRange newLine
+ else
+ [||]
+
+ // FS0673: Add missing instance member parameter (e.g., 'x.')
+ let addInstanceMemberParameter (ctx: CodeFixContext) =
+ let insertRange =
+ Range.mkRange ctx.FilePath ctx.DiagnosticRange.Start ctx.DiagnosticRange.Start
+
+ makeCodeAction "Add missing instance member parameter" ctx.DocumentUri insertRange "x."
+
+ // FS0010: Add '=' to type definition (when message mentions '=')
+ let addMissingEqualsToTypeDefinition (ctx: CodeFixContext) =
+ if
+ not (ctx.Diagnostic.Message.Contains("="))
+ || not (ctx.ParseResults.IsPositionWithinTypeDefinition(ctx.DiagnosticRange.Start))
+ then
+ [||]
+ else
+ let insertRange =
+ Range.mkRange ctx.FilePath ctx.DiagnosticRange.Start ctx.DiagnosticRange.Start
+
+ makeCodeAction "Add '=' to type definition" ctx.DocumentUri insertRange "= "
+
+ // FS0010: Add 'fun' keyword (when message mentions '->')
+ let addMissingFunKeyword (ctx: CodeFixContext) =
+ let text = textAtRange ctx.SourceText ctx.DiagnosticRange
+
+ if text <> "->" then
+ [||]
+ else
+ let mutable lineNumber = ctx.DiagnosticRange.StartLine
+ let mutable line = ctx.SourceText.GetLineString(lineNumber - 1)
+ let mutable last = ctx.DiagnosticRange.StartColumn - 1
+
+ while (last >= 0 && Char.IsWhiteSpace line[last]) || (last < 0 && lineNumber > 1) do
+ if last < 0 then
+ lineNumber <- lineNumber - 1
+ line <- ctx.SourceText.GetLineString(lineNumber - 1)
+ last <- line.Length - 1
+ else
+ last <- last - 1
+
+ let argumentStart =
+ if last >= 0 && (line[last] = ')' || line[last] = ']' || line[last] = '}') then
+ let mutable depth = 0
+ let mutable first = last
+
+ while first >= 0 && (first = last || depth > 0) do
+ match line[first] with
+ | ')'
+ | ']'
+ | '}' -> depth <- depth + 1
+ | '('
+ | '['
+ | '{' -> depth <- depth - 1
+ | _ -> ()
+
+ first <- first - 1
+
+ if depth = 0 then ValueSome(first + 1) else ValueNone
+ else
+ match QuickParse.GetCompleteIdentifierIsland false line last with
+ | Some(identifier, endColumn, _) -> ValueSome(endColumn - identifier.Length)
+ | None -> ValueNone
+
+ match argumentStart with
+ | ValueSome column ->
+ let position = Position.mkPos lineNumber column
+ let insertRange = Range.mkRange ctx.FilePath position position
+ makeCodeAction "Add 'fun' keyword" ctx.DocumentUri insertRange "fun "
+ | ValueNone -> [||]
+
+ // FS0010: Replace '=' with ':' in record field type definition
+ let changeEqualsInFieldTypeToColon (ctx: CodeFixContext) =
+ let text = textAtRange ctx.SourceText ctx.DiagnosticRange
+
+ if
+ text <> "="
+ || not (ctx.ParseResults.IsPositionWithinRecordDefinition(ctx.DiagnosticRange.Start))
+ then
+ [||]
+ else
+ makeCodeAction "Replace '=' with ':'" ctx.DocumentUri ctx.DiagnosticRange ":"
+
+ // FS0576: Add 'rec' to mutually recursive functions
+ let addMissingRecToMutuallyRecFunctions (ctx: CodeFixContext) =
+ // Find the 'let' keyword on the same line and insert 'rec' after it.
+ let lineIdx = ctx.DiagnosticRange.StartLine - 1
+ let line = ctx.SourceText.GetLineString(lineIdx)
+
+ let letIdx = line.IndexOf("let ")
+
+ if letIdx < 0 then
+ [||]
+ else
+ let insertCol = letIdx + 3 // after "let"
+
+ let insertPos = Position.mkPos ctx.DiagnosticRange.StartLine insertCol
+
+ let insertRange = Range.mkRange ctx.FilePath insertPos insertPos
+ makeCodeAction "Make declaration 'rec'" ctx.DocumentUri insertRange " rec"
+
+ // FS0039: Make outer binding recursive
+ let makeOuterBindingRecursive (ctx: CodeFixContext) =
+ if not (ctx.ParseResults.IsPosContainedInApplication(ctx.DiagnosticRange.Start)) then
+ [||]
+ else
+ match ctx.ParseResults.TryRangeOfNameOfNearestOuterBindingContainingPos(ctx.DiagnosticRange.Start) with
+ | Some bindingNameRange ->
+ let bindingName = textAtRange ctx.SourceText bindingNameRange
+ let errorText = textAtRange ctx.SourceText ctx.DiagnosticRange
+
+ if bindingName <> errorText then
+ [||]
+ else
+ // Find 'let' before the binding name and insert 'rec' after it
+ let lineIdx = bindingNameRange.StartLine - 1
+ let line = ctx.SourceText.GetLineString(lineIdx)
+ let prefix = line.Substring(0, min bindingNameRange.StartColumn line.Length)
+ let letIdx = prefix.LastIndexOf("let ")
+
+ if letIdx < 0 then
+ [||]
+ else
+ let insertCol = letIdx + 3
+
+ let insertPos = Position.mkPos bindingNameRange.StartLine insertCol
+
+ let insertRange = Range.mkRange ctx.FilePath insertPos insertPos
+
+ makeCodeAction $"Make '%s{bindingName}' recursive" ctx.DocumentUri insertRange " rec"
+ | None -> [||]
+
+ // FS0039: Convert C# lambda (=>) to F# lambda (->)
+ let convertCSharpLambdaToFSharpLambda (ctx: CodeFixContext) =
+ match ctx.ParseResults.TryRangeOfParenEnclosingOpEqualsGreaterUsage(ctx.DiagnosticRange.Start) with
+ | Some(fullParenRange, lambdaArgRange, lambdaBodyRange) ->
+ let argText = textAtRange ctx.SourceText lambdaArgRange
+ let bodyText = textAtRange ctx.SourceText lambdaBodyRange
+ let newText = $"fun %s{argText} -> %s{bodyText}"
+ makeCodeAction "Use F# lambda syntax" ctx.DocumentUri fullParenRange newText
+ | None -> [||]
+
+ // FS0747/FS0748: Remove unnecessary 'return' or 'yield'
+ let removeReturnOrYield (ctx: CodeFixContext) =
+ match ctx.ParseResults.TryRangeOfExprInYieldOrReturn(ctx.DiagnosticRange.Start) with
+ | Some exprRange ->
+ // Remove from diagnostic start to expression start (the keyword + whitespace)
+ let removeRange =
+ Range.mkRange ctx.FilePath ctx.DiagnosticRange.Start exprRange.Start
+
+ let keyword = textAtRange ctx.SourceText removeRange |> fun s -> s.Trim()
+
+ let title =
+ match keyword with
+ | "return!" -> "Remove 'return!'"
+ | "return" -> "Remove 'return'"
+ | "yield!" -> "Remove 'yield!'"
+ | _ -> "Remove 'yield'"
+
+ makeCodeAction title ctx.DocumentUri removeRange ""
+ | None -> [||]
+
+ // FS0020: Replace '=' with '<-' for mutable value assignment
+ let useMutationWhenValueIsMutable (ctx: CodeFixContext) =
+ match ctx.CheckResults with
+ | None -> [||]
+ | Some checkResults ->
+ match symbolAtPosition ctx checkResults ctx.DiagnosticRange.Start with
+ | Some symbolUse when
+ match symbolUse.Symbol with
+ | :? FSharpMemberOrFunctionOrValue as value -> value.IsMutable || value.HasSetterMethod
+ | _ -> false
+ ->
+ let line = ctx.SourceText.GetLineString(symbolUse.Range.EndLine - 1)
+ let mutable eqCol = symbolUse.Range.EndColumn
+
+ while eqCol < line.Length && Char.IsWhiteSpace line[eqCol] do
+ eqCol <- eqCol + 1
+
+ let equalsPosition = Position.mkPos symbolUse.Range.EndLine eqCol
+
+ let eqRange =
+ Range.mkRange ctx.FilePath equalsPosition (Position.mkPos equalsPosition.Line (eqCol + 1))
+
+ if
+ eqCol < line.Length
+ && line[eqCol] = '='
+ && (eqCol + 1 = line.Length || line[eqCol + 1] <> '=')
+ && Range.rangeContainsRange ctx.DiagnosticRange eqRange
+ then
+ makeCodeAction "Use '<-' for mutation" ctx.DocumentUri eqRange "<-"
+ else
+ [||]
+ | _ -> [||]
+
+ // FS3373: Use triple-quoted string interpolation
+ let useTripleQuotedInterpolation (ctx: CodeFixContext) =
+ match ctx.ParseResults.TryRangeOfStringInterpolationContainingPos(ctx.DiagnosticRange.Start) with
+ | Some stringRange ->
+ let text = textAtRange ctx.SourceText stringRange
+
+ if text.StartsWith("$\"") && not (text.StartsWith("$\"\"\"")) then
+ // $"..." -> $"""..."""
+ let inner = text.Substring(2, text.Length - 3) // remove $" and trailing "
+ let newText = "$\"\"\"" + inner + "\"\"\""
+ makeCodeAction "Use triple-quoted interpolation" ctx.DocumentUri stringRange newText
+ else
+ [||]
+ | None -> [||]
+
+ // FS0001: Replace '!' (ref cell deref) with 'not' for boolean negation
+ let changeRefCellDerefToNotExpression (ctx: CodeFixContext) =
+ match ctx.ParseResults.TryRangeOfRefCellDereferenceContainingPos(ctx.DiagnosticRange.Start) with
+ | Some derefRange ->
+ // Replace the '!' with 'not '
+ let bangRange =
+ Range.mkRange ctx.FilePath derefRange.Start (Position.mkPos derefRange.StartLine (derefRange.StartColumn + 1))
+
+ makeCodeAction "Use 'not' for boolean negation" ctx.DocumentUri bangRange "not "
+ | None -> [||]
+
+ // FS0039/FS0495/FS1129: Replace with compiler-suggested name
+ let replaceWithSuggestion (ctx: CodeFixContext) =
+ let msg = ctx.Diagnostic.Message
+ // Diagnostic messages contain suggestions like: Did you mean 'xyz'?
+ let m =
+ Regex.Match(msg, @"Maybe you want one of the following:\s*(.*?)$", RegexOptions.Singleline)
+
+ let suggestions =
+ if m.Success then
+ m.Groups.[1].Value.Split([| '\r'; '\n' |], StringSplitOptions.RemoveEmptyEntries)
+ |> Array.map (fun s -> s.Trim())
+ |> Array.filter (fun s -> s.Length > 0)
+ else
+ // Try single suggestion: "... Did you mean 'X'?"
+ let m2 = Regex.Match(msg, @"Did you mean '([^']+)'\?")
+
+ if m2.Success then [| m2.Groups.[1].Value |] else [||]
+
+ suggestions
+ |> Array.map (fun suggestion ->
+ let title = $"Replace with '%s{suggestion}'"
+
+ (makeCodeAction title ctx.DocumentUri ctx.DiagnosticRange suggestion)
+ |> Array.head)
+
+ // FS1182: Prefix unused value with '_'
+ let prefixUnusedValue (ctx: CodeFixContext) =
+ let text = textAtRange ctx.SourceText ctx.DiagnosticRange
+
+ if text.StartsWith("_") || text = "()" then
+ [||]
+ else
+ let insertRange =
+ Range.mkRange ctx.FilePath ctx.DiagnosticRange.Start ctx.DiagnosticRange.Start
+
+ makeCodeAction $"Prefix '%s{text}' with underscore" ctx.DocumentUri insertRange "_"
+
+ // FS1182: Rename unused value to '_'
+ let discardUnusedValue (ctx: CodeFixContext) =
+ let text = textAtRange ctx.SourceText ctx.DiagnosticRange
+
+ if text = "_" || text = "()" then
+ [||]
+ else
+ makeCodeAction $"Rename '%s{text}' to '_'" ctx.DocumentUri ctx.DiagnosticRange "_"
+
+ // FS0027: Make declaration mutable
+ let makeDeclarationMutable (ctx: CodeFixContext) =
+ match ctx.CheckResults with
+ | None -> [||]
+ | Some checkResults ->
+ if ctx.ParseResults.IsPositionContainedInACurriedParameter(ctx.DiagnosticRange.Start) then
+ [||]
+ else
+ // The diagnostic points to the assignment site (e.g. "x <- 2").
+ // Extract the identifier name at the start of the diagnostic range.
+ let lineText = ctx.SourceText.GetLineString(ctx.DiagnosticRange.StartLine - 1)
+ // Find end of identifier starting at StartColumn
+ let startCol = ctx.DiagnosticRange.StartColumn
+ let mutable endCol = startCol
+
+ while endCol < lineText.Length
+ && (System.Char.IsLetterOrDigit(lineText.[endCol])
+ || lineText.[endCol] = '_'
+ || lineText.[endCol] = '\'') do
+ endCol <- endCol + 1
+
+ if endCol <= startCol then
+ [||]
+ else
+ let identName = lineText.Substring(startCol, endCol - startCol)
+
+ let symbolOpt =
+ checkResults.GetSymbolUseAtLocation(ctx.DiagnosticRange.StartLine, endCol, lineText, [ identName ])
+
+ match symbolOpt with
+ | Some symbolUse ->
+ let declRange = symbolUse.Symbol.DeclarationLocation
+
+ match declRange with
+ | Some declRange when declRange.FileName = ctx.FilePath ->
+ // Find 'let' before the declaration and insert 'mutable' after the identifier pattern
+ let declLineIdx = declRange.StartLine - 1
+ let declLine = ctx.SourceText.GetLineString(declLineIdx)
+ let prefix = declLine.Substring(0, min declRange.StartColumn declLine.Length)
+
+ if prefix.TrimEnd().EndsWith("let") || prefix.TrimEnd().EndsWith("and") then
+ let insertRange = Range.mkRange ctx.FilePath declRange.Start declRange.Start
+
+ makeCodeAction "Make declaration 'mutable'" ctx.DocumentUri insertRange "mutable "
+ else
+ [||]
+ | _ -> [||]
+ | None -> [||]
+
+ // FS0072/FS3245: Add type annotation to object of indeterminate type
+ let addTypeAnnotationToObjectOfIndeterminateType (ctx: CodeFixContext) =
+ match ctx.CheckResults with
+ | None -> [||]
+ | Some checkResults ->
+ let lineText = ctx.SourceText.GetLineString(ctx.DiagnosticRange.StartLine - 1)
+
+ let symbolOpt =
+ checkResults.GetSymbolUseAtLocation(
+ ctx.DiagnosticRange.StartLine,
+ ctx.DiagnosticRange.EndColumn,
+ lineText,
+ [ textAtRange ctx.SourceText ctx.DiagnosticRange ]
+ )
+
+ match symbolOpt with
+ | Some symbolUse ->
+ match symbolUse.Symbol with
+ | :? FSharpMemberOrFunctionOrValue as mfv when not mfv.FullType.IsGenericParameter ->
+ let typeStr = mfv.FullType.Format(symbolUse.DisplayContext)
+ let annotation = $": %s{typeStr}"
+ // Insert after the identifier
+ let insertRange =
+ Range.mkRange ctx.FilePath ctx.DiagnosticRange.End ctx.DiagnosticRange.End
+
+ makeCodeAction "Add type annotation" ctx.DocumentUri insertRange $" %s{annotation}"
+ | _ -> [||]
+ | None -> [||]
+
+ // FS0039/FS3578: Convert record to anonymous record
+ let convertToAnonymousRecord (ctx: CodeFixContext) =
+ match ctx.ParseResults.TryRangeOfRecordExpressionContainingPos(ctx.DiagnosticRange.Start) with
+ | Some recordRange ->
+ let text = textAtRange ctx.SourceText recordRange
+ // { ... } -> {| ... |}
+ if text.StartsWith("{") && text.EndsWith("}") && not (text.StartsWith("{|")) then
+ let inner = text.Substring(1, text.Length - 2)
+ let newText = $"{{|%s{inner}|}}"
+ makeCodeAction "Convert to anonymous record" ctx.DocumentUri recordRange newText
+ else
+ [||]
+ | None -> [||]
+
+ // FS3873/FS0740: Add missing 'seq' to computation expression
+ let addMissingSeq (ctx: CodeFixContext) =
+ let text = textAtRange ctx.SourceText ctx.DiagnosticRange
+ let newText = $"seq %s{text}"
+ makeCodeAction "Add missing 'seq'" ctx.DocumentUri ctx.DiagnosticRange newText
+
+ // FS0725/FS3548: Remove superfluous capture in union case pattern
+ let removeSuperfluousCaptureForUnionCaseWithNoData (ctx: CodeFixContext) =
+ match ctx.CheckResults with
+ | None -> [||]
+ | Some checkResults ->
+ let items = checkResults.GetSemanticClassification(Some ctx.DiagnosticRange)
+
+ let unionCaseItem =
+ items
+ |> Array.tryFind (fun item -> item.Type = FSharp.Compiler.EditorServices.SemanticClassificationType.UnionCase)
+
+ match unionCaseItem with
+ | Some item ->
+ // Keep just the union case name, remove the capture
+ let caseRange = item.Range
+ let caseText = textAtRange ctx.SourceText caseRange
+ makeCodeAction "Remove superfluous capture" ctx.DocumentUri ctx.DiagnosticRange caseText
+ | None -> [||]
+
+ // FS3218: Rename parameter to match signature file
+ let renameParamToMatchSignature (ctx: CodeFixContext) =
+ let suggestion =
+ match ctx.Diagnostic.ExtendedData with
+ | Some(:? ArgumentsInSigAndImplMismatchExtendedData as data) -> ValueSome data.SignatureName
+ | _ ->
+ let parts = Regex.Match(ctx.Diagnostic.Message, ".+'(.+)'.+'(.+)'.+")
+
+ if parts.Success then
+ ValueSome parts.Groups[1].Value
+ else
+ ValueNone
+
+ match suggestion, ctx.CheckResults with
+ | ValueSome name, Some checkResults ->
+ match symbolAtPosition ctx checkResults ctx.DiagnosticRange.Start with
+ | Some symbolUse ->
+ let replacement = FSharpKeywords.NormalizeIdentifierBackticks name
+
+ let edits =
+ checkResults.GetUsesOfSymbolInFile(symbolUse.Symbol)
+ |> Array.map (fun useOfParameter -> useOfParameter.Range, replacement)
+
+ if Array.isEmpty edits then
+ [||]
+ else
+ makeMultiEditCodeAction $"Replace with '{name}'" ctx.DocumentUri edits
+ | None -> [||]
+ | _ -> [||]
+
+ // FS0366: Implement interface members
+ let implementInterface (ctx: CodeFixContext) =
+ async {
+ match ctx.CheckResults with
+ | None -> return [||]
+ | Some checkResults ->
+ let interfaceDataOpt =
+ InterfaceStubGenerator.TryFindInterfaceDeclaration ctx.DiagnosticRange.Start ctx.ParseResults.ParseTree
+
+ match interfaceDataOpt with
+ | None -> return [||]
+ | Some interfaceData ->
+ let symbolUseOpt = symbolAtPosition ctx checkResults interfaceData.Range.Start
+
+ match symbolUseOpt with
+ | Some symbolUse ->
+ match symbolUse.Symbol with
+ | :? FSharpEntity as entity when
+ InterfaceStubGenerator.IsInterface entity
+ && not (InterfaceStubGenerator.HasNoInterfaceMember entity)
+ ->
+ let membersAndRanges = InterfaceStubGenerator.GetMemberNameAndRanges interfaceData
+
+ let indentation lineNumber =
+ let line = ctx.SourceText.GetLineString(lineNumber - 1)
+ line.Length - line.TrimStart().Length
+
+ let startColumn =
+ match membersAndRanges with
+ | (_, memberRange) :: _ -> indentation memberRange.StartLine
+ | [] -> indentation interfaceData.Range.StartLine + 4
+
+ let typeInstances = interfaceData.TypeParameters
+ let methodBody = "raise (System.NotImplementedException())"
+ let displayContext = symbolUse.DisplayContext
+
+ let getMemberByLocation (name, memberRange: range) =
+ let line = ctx.SourceText.GetLineString(memberRange.EndLine - 1)
+ checkResults.GetSymbolUseAtLocation(memberRange.EndLine, memberRange.EndColumn, line, [ name ])
+
+ let! implementedMembers =
+ InterfaceStubGenerator.GetImplementedMemberSignatures getMemberByLocation displayContext interfaceData
+
+ let stub =
+ InterfaceStubGenerator.FormatInterface
+ startColumn
+ 4
+ typeInstances
+ "this"
+ methodBody
+ displayContext
+ implementedMembers
+ entity
+ true
+
+ if System.String.IsNullOrWhiteSpace(stub) then
+ return [||]
+ else
+ let withKeyword =
+ (interfaceData.Range.Start, ctx.ParseResults.ParseTree)
+ ||> ParsedInput.tryPick (fun _ node ->
+ match node with
+ | SyntaxNode.SynMemberDefn(SynMemberDefn.Interface(interfaceType = ty; withKeyword = keyword)) when
+ Range.equals ty.Range interfaceData.Range
+ ->
+ Some keyword
+ | SyntaxNode.SynExpr(SynExpr.ObjExpr(objType = ty; withKeyword = keyword)) when
+ Range.equals ty.Range interfaceData.Range
+ ->
+ Some keyword
+ | _ -> None)
+ |> Option.flatten
+
+ let memberEnds =
+ match interfaceData with
+ | InterfaceData.Interface(_, Some members) -> members |> List.map _.Range.End
+ | InterfaceData.ObjExpr(_, bindings) -> bindings |> List.map _.RangeOfBindingWithRhs.End
+ | _ -> []
+
+ let insertPosition =
+ match memberEnds with
+ | [] -> withKeyword |> Option.map _.End |> Option.defaultValue interfaceData.Range.End
+ | positions ->
+ let last = positions |> List.maxBy (fun position -> position.Line, position.Column)
+
+ match interfaceData with
+ | InterfaceData.Interface _ ->
+ Position.mkPos last.Line (ctx.SourceText.GetLineString(last.Line - 1).Length)
+ | InterfaceData.ObjExpr _ -> last
+
+ let newline =
+ if
+ ctx.SourceText.GetSubTextString(0, ctx.SourceText.Length).Contains("\r\n", StringComparison.Ordinal)
+ then
+ "\r\n"
+ else
+ "\n"
+
+ let stub =
+ stub.Replace("\r\n", "\n").TrimStart(' ', '\t').Replace("\n", newline).TrimEnd('\r', '\n')
+
+ let insertText = (if withKeyword.IsSome then "" else " with") + stub
+
+ let insertRange = Range.mkRange ctx.FilePath insertPosition insertPosition
+
+ return makeCodeAction "Implement interface" ctx.DocumentUri insertRange insertText
+ | _ -> return [||]
+ | None -> return [||]
+ }
+
+ // ---------------------------------------------------------------
+ // Registration
+ // ---------------------------------------------------------------
+
+ let private sync name fix =
+ name, (fun ctx -> async { return fix ctx })
+
+ let private allCodeFixes =
+ [
+ "FS0760", sync (nameof addNewKeywordFix) addNewKeywordFix
+ "FS0043", sync (nameof convertToSingleEquals) convertToSingleEquals
+ "FS0043", sync (nameof convertToNotEquals) convertToNotEquals
+ "FS3198", sync (nameof changeToUpcast) changeToUpcast
+ "FS3366", sync (nameof fixIndexerAccess) fixIndexerAccess
+ "FS0597", sync (nameof wrapExpressionInParentheses) wrapExpressionInParentheses
+ "FS0003", sync (nameof changePrefixNegationToInfixSubtraction) changePrefixNegationToInfixSubtraction
+ "FS0039", sync (nameof convertCSharpUsingToFSharpOpen) convertCSharpUsingToFSharpOpen
+ "FS0201", sync (nameof convertCSharpUsingToFSharpOpen) convertCSharpUsingToFSharpOpen
+ "FS0673", sync (nameof addInstanceMemberParameter) addInstanceMemberParameter
+ "FS0010", sync (nameof addMissingEqualsToTypeDefinition) addMissingEqualsToTypeDefinition
+ "FS0010", sync (nameof addMissingFunKeyword) addMissingFunKeyword
+ "FS0010", sync (nameof changeEqualsInFieldTypeToColon) changeEqualsInFieldTypeToColon
+ "FS0576", sync (nameof addMissingRecToMutuallyRecFunctions) addMissingRecToMutuallyRecFunctions
+ "FS0039", sync (nameof makeOuterBindingRecursive) makeOuterBindingRecursive
+ "FS0039", sync (nameof convertCSharpLambdaToFSharpLambda) convertCSharpLambdaToFSharpLambda
+ "FS0747", sync (nameof removeReturnOrYield) removeReturnOrYield
+ "FS0748", sync (nameof removeReturnOrYield) removeReturnOrYield
+ "FS0020", sync (nameof useMutationWhenValueIsMutable) useMutationWhenValueIsMutable
+ "FS3373", sync (nameof useTripleQuotedInterpolation) useTripleQuotedInterpolation
+ "FS0001", sync (nameof changeRefCellDerefToNotExpression) changeRefCellDerefToNotExpression
+ "FS0039", sync (nameof replaceWithSuggestion) replaceWithSuggestion
+ "FS0495", sync (nameof replaceWithSuggestion) replaceWithSuggestion
+ "FS1129", sync (nameof replaceWithSuggestion) replaceWithSuggestion
+ "FS1182", sync (nameof prefixUnusedValue) prefixUnusedValue
+ "FS1182", sync (nameof discardUnusedValue) discardUnusedValue
+ "FS0027", sync (nameof makeDeclarationMutable) makeDeclarationMutable
+ "FS0072", sync (nameof addTypeAnnotationToObjectOfIndeterminateType) addTypeAnnotationToObjectOfIndeterminateType
+ "FS3245", sync (nameof addTypeAnnotationToObjectOfIndeterminateType) addTypeAnnotationToObjectOfIndeterminateType
+ "FS0039", sync (nameof convertToAnonymousRecord) convertToAnonymousRecord
+ "FS3578", sync (nameof convertToAnonymousRecord) convertToAnonymousRecord
+ "FS3873", sync (nameof addMissingSeq) addMissingSeq
+ "FS0740", sync (nameof addMissingSeq) addMissingSeq
+ "FS0725", sync (nameof removeSuperfluousCaptureForUnionCaseWithNoData) removeSuperfluousCaptureForUnionCaseWithNoData
+ "FS3548", sync (nameof removeSuperfluousCaptureForUnionCaseWithNoData) removeSuperfluousCaptureForUnionCaseWithNoData
+ "FS3218", sync (nameof renameParamToMatchSignature) renameParamToMatchSignature
+ "FS0366", (nameof implementInterface, implementInterface)
+ ]
+
+ let private codeFixMap =
+ allCodeFixes
+ |> List.groupBy fst
+ |> List.map (fun (code, fixes) -> code, fixes |> List.map snd |> Array.ofList)
+ |> dict
+
+ // Get all applicable code actions for diagnostics overlapping the requested range.
+ let getCodeActions
+ (sourceText: ISourceText)
+ (parseResults: FSharpParseFileResults)
+ (checkResults: FSharpCheckFileResults option)
+ (diagnostics: FSharpDiagnostic array)
+ (requestRange: LspRange)
+ (filePath: string)
+ (documentUri: Uri)
+ (logger: ILspLogger)
+ (telemetry: ILspTelemetry)
+ =
+ async {
+ let actions = ResizeArray()
+
+ for d in diagnostics |> Array.filter (fun d -> rangesOverlap requestRange d.Range) do
+ match codeFixMap.TryGetValue(d.ErrorNumberText) with
+ | true, fixes ->
+ let ctx =
+ {
+ SourceText = sourceText
+ ParseResults = parseResults
+ CheckResults = checkResults
+ Diagnostic = d
+ DiagnosticRange = d.Range
+ FilePath = filePath
+ DocumentUri = documentUri
+ }
+
+ for name, fix in fixes do
+ try
+ let! result = fix ctx
+ actions.AddRange result
+ with
+ | :? OperationCanceledException as ex -> System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(ex).Throw()
+ | ex ->
+ logger.LogException(ex, $"Code fix '{name}' failed")
+
+ Telemetry.reportEvent
+ telemetry
+ TelemetryEvents.ServerFault
+ [| "fix_name", name :> obj; "exception_type", ex.GetType().Name :> obj |]
+ | false, _ -> ()
+
+ return actions.ToArray()
+ }
+
+type CodeActionHandler() =
+ interface IMethodHandler with
+ member _.MutatesSolutionState = false
+
+ interface IRequestHandler with
+ []
+ member _.HandleRequestAsync(request: CodeActionParams, context: FSharpRequestContext, cancellationToken: CancellationToken) =
+ cancellableTask {
+ let telemetry = context.LspServices.GetRequiredService()
+
+ use _scope =
+ telemetry.ReportEventWithDuration(
+ TelemetryEvents.GetCodeActions,
+ [| "uri_hash", hash request.TextDocument.Uri :> obj |]
+ )
+
+ let uri = request.TextDocument.Uri
+ let projectContext = tryGetProjectContext request.TextDocument
+
+ let! diagnosticReport = context.Workspace.Query.GetDiagnosticsForFile(uri, ?context = projectContext)
+
+ let! parseResultsOpt, checkResultsOpt =
+ context.Workspace.Query.GetParseAndCheckResultsForFile(uri, ?context = projectContext)
+
+ let! sourceTextOpt = context.Workspace.Query.GetSource uri
+
+ match parseResultsOpt, sourceTextOpt with
+ | Some parseResults, Some sourceText ->
+ let filePath = uri.LocalPath
+
+ return!
+ LspCodeFixes.getCodeActions
+ sourceText
+ parseResults
+ checkResultsOpt
+ diagnosticReport.Diagnostics
+ request.Range
+ filePath
+ uri
+ context.Logger
+ telemetry
+ | _ -> return [||]
+ }
+ |> CancellableTask.start cancellationToken
diff --git a/src/FSharp.Compiler.LanguageServer/Handlers/DocumentStateHandler.fs b/src/FSharp.Compiler.LanguageServer/Handlers/DocumentStateHandler.fs
index b7f281ed9e8..a7f764cf0a8 100644
--- a/src/FSharp.Compiler.LanguageServer/Handlers/DocumentStateHandler.fs
+++ b/src/FSharp.Compiler.LanguageServer/Handlers/DocumentStateHandler.fs
@@ -2,6 +2,7 @@
open Microsoft.CommonLanguageServerProtocol.Framework
open Microsoft.VisualStudio.LanguageServer.Protocol
+open FSharp.Compiler.LanguageServer
open FSharp.Compiler.LanguageServer.Common
open System.Threading
@@ -18,9 +19,13 @@ type DocumentStateHandler() =
member _.HandleRequestAsync
(request: DidOpenTextDocumentParams, context: FSharpRequestContext, _cancellationToken: CancellationToken)
=
+ let telemetry = context.LspServices.GetRequiredService()
let contextHolder = context.LspServices.GetRequiredService()
contextHolder.UpdateWorkspace _.Files.Open(request.TextDocument.Uri, request.TextDocument.Text)
+ context.OpenDocument(request.TextDocument.Uri)
+
+ telemetry.ReportEvent(TelemetryEvents.DocumentOpened, [| "uri_hash", hash request.TextDocument.Uri :> obj |])
Task.FromResult(SemanticTokensDeltaPartialResult())
@@ -32,6 +37,7 @@ type DocumentStateHandler() =
let contextHolder = context.LspServices.GetRequiredService()
contextHolder.UpdateWorkspace _.Files.Edit(request.TextDocument.Uri, request.ContentChanges.[0].Text)
+ context.OpenDocument(request.TextDocument.Uri)
Task.FromResult(SemanticTokensDeltaPartialResult())
@@ -40,8 +46,13 @@ type DocumentStateHandler() =
member _.HandleNotificationAsync
(request: DidCloseTextDocumentParams, context: FSharpRequestContext, _cancellationToken: CancellationToken)
=
+ let telemetry = context.LspServices.GetRequiredService()
let contextHolder = context.LspServices.GetRequiredService()
contextHolder.UpdateWorkspace _.Files.Close(request.TextDocument.Uri)
+ context.CloseDocument(request.TextDocument.Uri)
+ context.ClearActiveProjectContext(request.TextDocument.Uri)
+
+ telemetry.ReportEvent(TelemetryEvents.DocumentClosed, [| "uri_hash", hash request.TextDocument.Uri :> obj |])
Task.CompletedTask
diff --git a/src/FSharp.Compiler.LanguageServer/Handlers/LanguageFeaturesHandler.fs b/src/FSharp.Compiler.LanguageServer/Handlers/LanguageFeaturesHandler.fs
index 474758924d0..f5e0bd9cee2 100644
--- a/src/FSharp.Compiler.LanguageServer/Handlers/LanguageFeaturesHandler.fs
+++ b/src/FSharp.Compiler.LanguageServer/Handlers/LanguageFeaturesHandler.fs
@@ -1,4 +1,4 @@
-namespace FSharp.Compiler.LanguageServer.Handlers
+namespace FSharp.Compiler.LanguageServer.Handlers
open Microsoft.CommonLanguageServerProtocol.Framework
open Microsoft.VisualStudio.LanguageServer.Protocol
@@ -7,8 +7,6 @@ open FSharp.Compiler.LanguageServer.Common
open FSharp.Compiler.LanguageServer
open System.Threading.Tasks
open System.Threading
-open System.Collections.Generic
-open Microsoft.VisualStudio.FSharp.Editor
#nowarn "57"
@@ -26,34 +24,24 @@ type LanguageFeaturesHandler() =
(request: DocumentDiagnosticParams, context: FSharpRequestContext, cancellationToken: CancellationToken)
=
cancellableTask {
+ let telemetry = context.LspServices.GetRequiredService()
+ let uri = request.TextDocument.Uri
+ let projectContext = tryGetProjectContext request.TextDocument
- let! fsharpDiagnosticReport = context.Workspace.Query.GetDiagnosticsForFile request.TextDocument.Uri
+ use _scope =
+ telemetry.ReportEventWithDuration(TelemetryEvents.GetDiagnostics, [| "uri_hash", hash uri :> obj |])
- let report =
- FullDocumentDiagnosticReport(
- Items = (fsharpDiagnosticReport.Diagnostics |> Array.map (_.ToLspDiagnostic())),
- ResultId = fsharpDiagnosticReport.ResultId
- )
-
- let relatedDocuments = Dictionary()
+ let! fsharpDiagnosticReport = context.Workspace.Query.GetDiagnosticsForFile(uri, ?context = projectContext)
- relatedDocuments.Add(
- request.TextDocument.Uri,
- SumType report
- )
+ let snapshots = context.Workspace.Query.GetProjectSnapshotsForFile(uri)
+ let projects = snapshotsToProjectInfos snapshots
return
SumType(
- RelatedFullDocumentDiagnosticReport(RelatedDocuments = relatedDocuments)
+ RelatedFullDocumentDiagnosticReport(
+ Items = (fsharpDiagnosticReport.Diagnostics |> Array.map (_.ToVsDiagnostic(projects))),
+ ResultId = fsharpDiagnosticReport.ResultId
+ )
)
}
|> CancellableTask.start cancellationToken
-
- interface IRequestHandler with
- []
- member _.HandleRequestAsync(request: SemanticTokensParams, context: FSharpRequestContext, cancellationToken: CancellationToken) =
- cancellableTask {
- let! tokens = context.GetSemanticTokensForFile(request.TextDocument.Uri)
- return SemanticTokens(Data = tokens)
- }
- |> CancellableTask.start cancellationToken
diff --git a/src/FSharp.Compiler.LanguageServer/Handlers/ProjectContextsHandler.fs b/src/FSharp.Compiler.LanguageServer/Handlers/ProjectContextsHandler.fs
new file mode 100644
index 00000000000..060ef4ac700
--- /dev/null
+++ b/src/FSharp.Compiler.LanguageServer/Handlers/ProjectContextsHandler.fs
@@ -0,0 +1,167 @@
+namespace FSharp.Compiler.LanguageServer.Handlers
+
+open System.IO
+open System
+
+open Microsoft.CommonLanguageServerProtocol.Framework
+open Microsoft.VisualStudio.LanguageServer.Protocol
+open FSharp.Compiler.LanguageServer
+open FSharp.Compiler.LanguageServer.Common
+open FSharp.Compiler.CodeAnalysis.ProjectSnapshot
+open StreamJsonRpc
+
+open System.Threading
+open System.Threading.Tasks
+
+#nowarn "57"
+#nowarn "3261"
+
+[]
+type ActiveProjectContextParams =
+ {
+ Uri: Uri
+ ProjectFileName: string
+ OutputFileName: string
+ }
+
+type ActiveProjectContextHandler() =
+
+ interface IMethodHandler with
+ member _.MutatesSolutionState = true
+
+ interface IRequestHandler with
+ []
+ member _.HandleRequestAsync
+ (request: ActiveProjectContextParams, context: FSharpRequestContext, cancellationToken: CancellationToken)
+ =
+ task {
+ let telemetry = context.LspServices.GetRequiredService()
+
+ let report outcome =
+ Telemetry.reportEvent telemetry TelemetryEvents.SetActiveProjectContext [| "outcome", outcome :> obj |]
+
+ if
+ isNull request.Uri
+ || not request.Uri.IsAbsoluteUri
+ || not request.Uri.IsFile
+ || String.IsNullOrWhiteSpace request.ProjectFileName
+ || String.IsNullOrWhiteSpace request.OutputFileName
+ then
+ context.Logger.LogWarning("Invalid active project context.", [||])
+ report "invalid"
+ return false
+ else
+ let comparison =
+ if OperatingSystem.IsWindows() then
+ StringComparison.OrdinalIgnoreCase
+ else
+ StringComparison.Ordinal
+
+ let normalize projectFileName outputFileName =
+ Path.GetFullPath(outputFileName, Path.GetDirectoryName(Path.GetFullPath(projectFileName)))
+
+ let outputPath = normalize request.ProjectFileName request.OutputFileName
+ let projectPath = Path.GetFullPath request.ProjectFileName
+
+ let snapshot =
+ context.Workspace.Query.GetProjectSnapshotsForFile(request.Uri)
+ |> Array.tryFind (fun snapshot ->
+ let compilerOutput = snapshot.ProjectConfig.CommandLineOptions |> findOutputFileName
+
+ String.Equals(Path.GetFullPath(snapshot.ProjectFileName), projectPath, comparison)
+ && (String.Equals(
+ normalize snapshot.ProjectFileName snapshot.Identifier.OutputFileName,
+ outputPath,
+ comparison
+ )
+ || (compilerOutput
+ |> Option.exists (fun output ->
+ String.Equals(normalize snapshot.ProjectFileName output, outputPath, comparison)))))
+
+ match snapshot with
+ | None ->
+ report "not_found"
+
+ context.Logger.LogWarning(
+ "Active project context does not match a workspace snapshot.",
+ [| request.ProjectFileName :> obj; request.OutputFileName :> obj |]
+ )
+
+ return false
+ | Some snapshot ->
+ let changed =
+ context.TryGetActiveProjectContext(request.Uri) <> Some snapshot.Identifier
+
+ let config = context.LspServices.GetRequiredService()
+
+ let initialize =
+ context.LspServices.GetRequiredService>()
+
+ let capabilities = initialize.GetInitializeParams().Capabilities
+
+ let supportsRefresh =
+ not (isNull capabilities)
+ && not (isNull capabilities.Workspace)
+ && not (isNull capabilities.Workspace.SemanticTokens)
+ && capabilities.Workspace.SemanticTokens.RefreshSupport
+
+ if changed && config.EnabledFeatures.SemanticTokens && supportsRefresh then
+ let server = context.LspServices.GetRequiredService()
+
+ try
+ do! server.InvokeWithParameterObjectAsync("workspace/semanticTokens/refresh", null, cancellationToken)
+ context.SetActiveProjectContext(request.Uri, snapshot.Identifier)
+ report "refreshed"
+ return true
+ with error when (error :? RemoteInvocationException) || (error :? ConnectionLostException) ->
+ context.Logger.LogException(
+ error,
+ "Unable to refresh semantic tokens after a project context change.",
+ [||]
+ )
+
+ report "refresh_failed"
+
+ Telemetry.reportFault
+ telemetry
+ TelemetryEvents.SetActiveProjectContext
+ "Semantic token refresh failed."
+ error
+
+ return false
+ else
+ context.SetActiveProjectContext(request.Uri, snapshot.Identifier)
+ report (if changed then "selected" else "unchanged")
+ return true
+ }
+
+type ProjectContextsHandler() =
+ interface IMethodHandler with
+ member _.MutatesSolutionState = false
+
+ interface IRequestHandler with
+ []
+ member _.HandleRequestAsync
+ (request: VSGetProjectContextsParams, context: FSharpRequestContext, _cancellationToken: CancellationToken)
+ =
+ let telemetry = context.LspServices.GetRequiredService()
+ let empty = VSProjectContextList(ProjectContexts = [||], DefaultIndex = 0)
+
+ match request.TextDocument with
+ | null -> Task.FromResult(empty)
+ | textDoc when isNull textDoc.Uri -> Task.FromResult(empty)
+ | textDoc ->
+ let snapshots = context.Workspace.Query.GetProjectSnapshotsForFile(textDoc.Uri)
+
+ let projectContexts =
+ snapshots
+ |> Array.map (fun snapshot ->
+ VSProjectContext(
+ Label = Path.GetFileNameWithoutExtension(snapshot.ProjectFileName),
+ Id = makeProjectContextId snapshot.Identifier,
+ Kind = VSProjectKind.FSharp
+ ))
+
+ telemetry.ReportEvent(TelemetryEvents.GetProjectContexts, [| "project_count", projectContexts.Length :> obj |])
+
+ Task.FromResult(VSProjectContextList(ProjectContexts = projectContexts, DefaultIndex = 0))
diff --git a/src/FSharp.Compiler.LanguageServer/Handlers/SemanticTokensHandler.fs b/src/FSharp.Compiler.LanguageServer/Handlers/SemanticTokensHandler.fs
new file mode 100644
index 00000000000..fb9a1fa353c
--- /dev/null
+++ b/src/FSharp.Compiler.LanguageServer/Handlers/SemanticTokensHandler.fs
@@ -0,0 +1,140 @@
+namespace FSharp.Compiler.LanguageServer.Handlers
+
+open System
+open System.Diagnostics
+open System.Runtime.ExceptionServices
+open System.Threading
+
+open Microsoft.CommonLanguageServerProtocol.Framework
+open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks
+open Microsoft.VisualStudio.LanguageServer.Protocol
+
+open FSharp.Compiler.CodeAnalysis
+open FSharp.Compiler.EditorServices
+open FSharp.Compiler.LanguageServer
+open FSharp.Compiler.LanguageServer.Common
+
+#nowarn "57"
+
+type SemanticTokensHandler() =
+ interface IMethodHandler with
+ member _.MutatesSolutionState = false
+
+ member private _.GetParsingOptions
+ (uri: System.Uri, context: FSharpRequestContext, projectContext: ProjectSnapshot.FSharpProjectIdentifier option)
+ =
+ context.Workspace.Query.GetProjectSnapshotForFile(uri, ?context = projectContext)
+ |> Option.map (fun snapshot ->
+ let checker = context.Workspace.Checker
+
+ let opts, _ =
+ // Should these be cached somewhere?
+ checker.GetParsingOptionsFromCommandLineArgs(
+ [ uri.LocalPath ],
+ snapshot.ProjectConfig.CommandLineOptions,
+ isEditing = true
+ )
+
+ opts)
+
+ member private this.GetSemanticTokens
+ (textDocument: TextDocumentIdentifier, context: FSharpRequestContext, range: Range option, cancellationToken: CancellationToken)
+ =
+ cancellableTask {
+ let stopwatch = Stopwatch.StartNew()
+ let uri = textDocument.Uri
+ let explicitContext = tryGetProjectContext textDocument
+ let activeContext = context.TryGetActiveProjectContext uri
+ let projectContext = explicitContext |> Option.orElse activeContext
+ let telemetry = context.LspServices.GetRequiredService()
+ let handlerStopwatch = Stopwatch.StartNew()
+ let mutable outcome = "faulted"
+ let mutable semanticData = false
+ let mutable documentLength = -1
+ let mutable spanLength = -1
+ let mutable tokenCount = 0
+
+ try
+ try
+ let! source = context.Workspace.Query.GetSource uri
+
+ match source with
+ | None ->
+ outcome <- "no_source"
+ return LspSemanticTokens.emptyResponse ()
+ | Some source ->
+ documentLength <- source.Length
+
+ spanLength <-
+ match range with
+ | None -> source.Length
+ | Some r when r.Start.Line = r.End.Line -> max 0 (r.End.Character - r.Start.Character)
+ | Some _ -> -1
+
+ let! semanticClassification = context.Workspace.Query.GetSemanticClassification(uri, ?context = projectContext)
+ semanticData <- semanticClassification.IsSome
+
+ let semanticItems =
+ match semanticClassification with
+ | None -> [||]
+ | Some view ->
+ let items = ResizeArray()
+ view.ForEach items.Add
+ items.ToArray()
+
+ let parsingOptions = this.GetParsingOptions(uri, context, projectContext)
+
+ let response =
+ LspSemanticTokens.responseFor source uri.LocalPath semanticItems range parsingOptions cancellationToken
+
+ tokenCount <- response.Data.Length / 5
+ outcome <- "completed"
+ return response
+ with :? OperationCanceledException as error ->
+ outcome <- "cancelled"
+ ExceptionDispatchInfo.Capture(error).Throw()
+ return Unchecked.defaultof<_>
+ finally
+ Telemetry.reportEvent
+ telemetry
+ TelemetryEvents.GetSemanticTokens
+ [|
+ "uri_hash", hash uri :> obj
+ "request_kind", (if range.IsSome then "range" else "full") :> obj
+ "context_source",
+ (if explicitContext.IsSome then "explicit"
+ elif activeContext.IsSome then "active"
+ else "default")
+ :> obj
+ "isOpenDocument", context.IsDocumentOpen uri :> obj
+ "outcome", outcome :> obj
+ "semantic_data_available", semanticData :> obj
+ "token_count", tokenCount :> obj
+ "provider_duration_ms", stopwatch.ElapsedMilliseconds :> obj
+ "duration_ms", handlerStopwatch.ElapsedMilliseconds :> obj
+ if documentLength >= 0 then
+ "document_length", documentLength :> obj
+ if spanLength >= 0 then
+ "textSpanLength", spanLength :> obj
+ match range with
+ | Some r ->
+ "range_start_line", r.Start.Line :> obj
+ "range_start_character", r.Start.Character :> obj
+ "range_end_line", r.End.Line :> obj
+ "range_end_character", r.End.Character :> obj
+ | None -> ()
+ |]
+ }
+ |> CancellableTask.start cancellationToken
+
+ interface IRequestHandler with
+ []
+ member this.HandleRequestAsync(request: SemanticTokensParams, context: FSharpRequestContext, cancellationToken: CancellationToken) =
+ this.GetSemanticTokens(request.TextDocument, context, None, cancellationToken)
+
+ interface IRequestHandler with
+ []
+ member this.HandleRequestAsync
+ (request: SemanticTokensRangeParams, context: FSharpRequestContext, cancellationToken: CancellationToken)
+ =
+ this.GetSemanticTokens(request.TextDocument, context, Some request.Range, cancellationToken)
diff --git a/src/FSharp.Compiler.LanguageServer/SemanticTokens.fs b/src/FSharp.Compiler.LanguageServer/SemanticTokens.fs
new file mode 100644
index 00000000000..cf80a5c0026
--- /dev/null
+++ b/src/FSharp.Compiler.LanguageServer/SemanticTokens.fs
@@ -0,0 +1,501 @@
+namespace FSharp.Compiler.LanguageServer
+
+open System.Collections.Generic
+open System.Threading
+
+open FSharp.Compiler
+open FSharp.Compiler.CodeAnalysis
+open FSharp.Compiler.EditorServices
+open FSharp.Compiler.Syntax
+open FSharp.Compiler.Text
+open FSharp.Compiler.Tokenization
+open Microsoft.VisualStudio.LanguageServer.Protocol
+
+#nowarn "57"
+
+module LspSemanticTokens =
+
+ type TokenSource =
+ | Semantic
+ | Syntactic
+
+ type NormalizedToken =
+ {
+ Line: int
+ StartChar: int
+ Length: int
+ TokenType: string
+ TokenModifiers: string list
+ Source: TokenSource
+ }
+
+ let private classNameTokenType = "class name"
+ let private structNameTokenType = "struct name"
+ let private enumNameTokenType = "enum name"
+ let private interfaceNameTokenType = "interface name"
+ let private typeParameterNameTokenType = "type parameter name"
+ let private delegateNameTokenType = "delegate name"
+ let private fieldNameTokenType = "field name"
+ let private propertyNameTokenType = "property name"
+ let private eventNameTokenType = "event name"
+ let private constantNameTokenType = "constant name"
+ let private labelNameTokenType = "label name"
+ let private extensionMethodNameTokenType = "extension method name"
+ let private methodNameTokenType = "method name"
+ let private namespaceNameTokenType = "namespace name"
+ let private localNameTokenType = "local name"
+ let private stringEscapeCharacterTokenType = "string - escape character"
+
+ // F#-specific token type names. These match the MEF classification types registered in
+ // vsintegration/src/FSharp.Editor/Classification/ClassificationDefinitions.fs and rely on the
+ // VS LSP client forwarding them as identity entries (see RoslynSemanticTokenTypes / its F#
+ // extender provided counterpart) so the existing ThemeColors / EditorFormatDefinition pipeline
+ // applies the F# theme colors and honors user overrides set in Fonts and Colors.
+ let private fsharpFunctionTokenType = "FSharp.Function"
+ let private fsharpMutableVarTokenType = "FSharp.MutableVar"
+ let private fsharpPrintfTokenType = "FSharp.Printf"
+ let private fsharpDisposableTypeTokenType = "FSharp.DisposableType"
+ let private fsharpDisposableLocalValueTokenType = "FSharp.DisposableLocalValue"
+
+ let private fsharpDisposableTopLevelValueTokenType =
+ "FSharp.DisposableTopLevelValue"
+
+ let tokenTypes =
+ [|
+ SemanticTokenTypes.Variable
+ SemanticTokenTypes.Keyword
+ SemanticTokenTypes.Comment
+ SemanticTokenTypes.String
+ SemanticTokenTypes.Number
+ SemanticTokenTypes.Operator
+ "excluded code"
+ classNameTokenType
+ structNameTokenType
+ enumNameTokenType
+ interfaceNameTokenType
+ typeParameterNameTokenType
+ delegateNameTokenType
+ fieldNameTokenType
+ propertyNameTokenType
+ eventNameTokenType
+ constantNameTokenType
+ labelNameTokenType
+ extensionMethodNameTokenType
+ methodNameTokenType
+ namespaceNameTokenType
+ localNameTokenType
+ stringEscapeCharacterTokenType
+ fsharpFunctionTokenType
+ fsharpMutableVarTokenType
+ fsharpPrintfTokenType
+ fsharpDisposableTypeTokenType
+ fsharpDisposableLocalValueTokenType
+ fsharpDisposableTopLevelValueTokenType
+ |]
+
+ let tokenModifiers = [| SemanticTokenModifiers.Static |]
+
+ let legend () =
+ SemanticTokensLegend(TokenTypes = tokenTypes, TokenModifiers = tokenModifiers)
+
+ let private tokenTypeIndexes =
+ tokenTypes
+ |> Array.mapi (fun index tokenType -> tokenType, index)
+ |> Map.ofArray
+
+ let private tokenModifierIndexes =
+ tokenModifiers
+ |> Array.mapi (fun index modifier -> modifier, index)
+ |> Map.ofArray
+
+ let private sourcePriority =
+ function
+ | TokenSource.Semantic -> 0
+ | TokenSource.Syntactic -> 1
+
+ // Mapping mirrors vsintegration/src/FSharp.Editor/Classification/ClassificationDefinitions.fs::getClassificationTypeName
+ // so that VS receives the same classification names through the LSP client. Six F#-specific
+ // classifications (Function, MutableVar, Printf, Disposable*) require a matching F# entry in the
+ // VS LSP client's token-type-to-classification provider; if that mapping is not present they
+ // fall back to "text" in the editor.
+ let private mapSemanticClassification =
+ function
+ | SemanticClassificationType.ReferenceType
+ | SemanticClassificationType.ConstructorForReferenceType
+ | SemanticClassificationType.Type
+ | SemanticClassificationType.TypeDef
+ | SemanticClassificationType.Module
+ | SemanticClassificationType.Exception -> Some classNameTokenType
+ | SemanticClassificationType.ValueType
+ | SemanticClassificationType.ConstructorForValueType -> Some structNameTokenType
+ | SemanticClassificationType.UnionCase
+ | SemanticClassificationType.Enumeration -> Some enumNameTokenType
+ | SemanticClassificationType.Interface -> Some interfaceNameTokenType
+ | SemanticClassificationType.TypeArgument -> Some typeParameterNameTokenType
+ | SemanticClassificationType.Delegate -> Some delegateNameTokenType
+ | SemanticClassificationType.UnionCaseField
+ | SemanticClassificationType.Field -> Some fieldNameTokenType
+ | SemanticClassificationType.Property
+ | SemanticClassificationType.RecordField
+ | SemanticClassificationType.RecordFieldAsFunction -> Some propertyNameTokenType
+ | SemanticClassificationType.Event -> Some eventNameTokenType
+ | SemanticClassificationType.NamedArgument -> Some labelNameTokenType
+ | SemanticClassificationType.Literal -> Some constantNameTokenType
+ | SemanticClassificationType.Method -> Some methodNameTokenType
+ | SemanticClassificationType.ExtensionMethod -> Some extensionMethodNameTokenType
+ | SemanticClassificationType.Namespace -> Some namespaceNameTokenType
+ | SemanticClassificationType.LocalValue -> Some localNameTokenType
+ | SemanticClassificationType.Value -> Some SemanticTokenTypes.Variable
+ | SemanticClassificationType.ComputationExpression
+ | SemanticClassificationType.IntrinsicFunction -> Some SemanticTokenTypes.Keyword
+ | SemanticClassificationType.Operator -> Some SemanticTokenTypes.Operator
+ | SemanticClassificationType.Function -> Some fsharpFunctionTokenType
+ | SemanticClassificationType.MutableVar
+ | SemanticClassificationType.MutableRecordField -> Some fsharpMutableVarTokenType
+ | SemanticClassificationType.Printf -> Some fsharpPrintfTokenType
+ | SemanticClassificationType.DisposableType -> Some fsharpDisposableTypeTokenType
+ | SemanticClassificationType.DisposableLocalValue -> Some fsharpDisposableLocalValueTokenType
+ | SemanticClassificationType.DisposableTopLevelValue -> Some fsharpDisposableTopLevelValueTokenType
+ | SemanticClassificationType.Plaintext -> None
+ | _ -> None
+
+ let private mapSyntacticToken (token: FSharpTokenInfo) =
+ match token.ColorClass with
+ | FSharpTokenColorKind.Keyword
+ | FSharpTokenColorKind.PreprocessorKeyword -> Some SemanticTokenTypes.Keyword
+ | FSharpTokenColorKind.Comment -> Some SemanticTokenTypes.Comment
+ | FSharpTokenColorKind.String -> Some SemanticTokenTypes.String
+ | FSharpTokenColorKind.Number -> Some SemanticTokenTypes.Number
+ | FSharpTokenColorKind.Operator -> Some SemanticTokenTypes.Operator
+ | FSharpTokenColorKind.InactiveCode -> Some "excluded code"
+ | _ ->
+ match token.CharClass with
+ | FSharpTokenCharKind.Keyword -> Some SemanticTokenTypes.Keyword
+ | FSharpTokenCharKind.LineComment
+ | FSharpTokenCharKind.Comment -> Some SemanticTokenTypes.Comment
+ | FSharpTokenCharKind.String -> Some SemanticTokenTypes.String
+ | FSharpTokenCharKind.Literal -> Some SemanticTokenTypes.Number
+ | FSharpTokenCharKind.Operator -> Some SemanticTokenTypes.Operator
+ | _ -> None
+
+ let private lineLength (source: ISourceText) line =
+ if line >= 0 && line < source.GetLineCount() then
+ (source.GetLineString line).Length
+ else
+ 0
+
+ let private clamp minValue maxValue value = value |> max minValue |> min maxValue
+
+ // Mirrors vsintegration/src/FSharp.Editor/LanguageService/Tokenizer.fs::fixupSpan.
+ // FCS reports classifications for qualified names at the range of the full long ident
+ // (e.g. SemanticClassificationType.DisposableType at the range of "System.IO.FileStream").
+ // Without trimming, the outer range overlaps every per-segment classification (Namespace
+ // at "System", Namespace at "System.IO") which resolveOverlaps then drops; the result is
+ // the entire qualified name being colored as if it were the rightmost identifier.
+ // Trim the range down to the rightmost identifier so each segment gets its own classification.
+ // The Printf classification covers an intentionally non-identifier span ("%s" etc.) and must
+ // not be trimmed.
+ let private fixupRange (source: ISourceText) (tokenType: string) (range: range) =
+ if
+ tokenType = stringEscapeCharacterTokenType
+ || tokenType = fsharpPrintfTokenType
+ || range.StartLine <> range.EndLine
+ then
+ range
+ else
+ let line = range.StartLine - 1
+
+ if line < 0 || line >= source.GetLineCount() then
+ range
+ else
+ let lineText = source.GetLineString line
+ let startCol = clamp 0 lineText.Length range.StartColumn
+ let endCol = clamp startCol lineText.Length range.EndColumn
+
+ if endCol <= startCol then
+ range
+ else
+ let text = lineText.Substring(startCol, endCol - startCol)
+
+ if text.EndsWith("``") then
+ let searchEnd = text.Length - 3
+
+ if searchEnd < 0 then
+ range
+ else
+ match text.LastIndexOf("``", searchEnd) with
+ | -1
+ | 0 -> range
+ | index -> Range.mkRange range.FileName (Position.mkPos range.StartLine (startCol + index)) range.End
+ elif PrettyNaming.IsOperatorDisplayName text then
+ range
+ else
+ match text.LastIndexOf '.' with
+ | -1
+ | 0 -> range
+ | index -> Range.mkRange range.FileName (Position.mkPos range.StartLine (startCol + index + 1)) range.End
+
+ let private splitRange
+ (source: ISourceText)
+ (range: range)
+ tokenType
+ modifiers
+ sourceKind
+ (requestedRange: Range option)
+ (cancellationToken: CancellationToken)
+ =
+ [|
+ let startLine = range.StartLine - 1
+ let endLine = range.EndLine - 1
+
+ if startLine >= 0 && endLine >= startLine then
+ let firstLine, lastLine =
+ match requestedRange with
+ | None -> startLine, endLine
+ | Some requested -> max startLine requested.Start.Line, min endLine requested.End.Line
+
+ for line in firstLine..lastLine do
+ cancellationToken.ThrowIfCancellationRequested()
+ let lengthOfLine = lineLength source line
+
+ let startChar =
+ if line = startLine then
+ clamp 0 lengthOfLine range.StartColumn
+ else
+ 0
+
+ let endChar =
+ if line = endLine then
+ clamp 0 lengthOfLine range.EndColumn
+ else
+ lengthOfLine
+
+ let length = endChar - startChar
+
+ if length > 0 then
+ {
+ Line = line
+ StartChar = startChar
+ Length = length
+ TokenType = tokenType
+ TokenModifiers = modifiers
+ Source = sourceKind
+ }
+ |]
+
+ let fromSemanticClassifications
+ (source: ISourceText)
+ (items: SemanticClassificationItem array)
+ (range: Range option)
+ (cancellationToken: CancellationToken)
+ =
+ items
+ |> Array.collect (fun item ->
+ cancellationToken.ThrowIfCancellationRequested()
+
+ let inRange =
+ match range with
+ | None -> true
+ | Some range ->
+ item.Range.EndLine - 1 >= range.Start.Line
+ && item.Range.StartLine - 1 <= range.End.Line
+
+ match mapSemanticClassification item.Type with
+ | Some tokenType when inRange ->
+ let fixedRange = fixupRange source tokenType item.Range
+ splitRange source fixedRange tokenType [] TokenSource.Semantic range cancellationToken
+ | _ -> [||])
+
+ let fromTokenizer
+ (parsingOptions: FSharpParsingOptions option)
+ (fileName: string)
+ (source: ISourceText)
+ (range: Range option)
+ (cancellationToken: CancellationToken)
+ =
+ let defines, langVersion =
+ match parsingOptions with
+ | Some opts -> CompilerEnvironment.GetConditionalDefinesForEditing opts, Some opts.LangVersionText
+ | None -> [], None
+
+ let tokenizer = FSharpSourceTokenizer(defines, Some fileName, langVersion)
+
+ let mutable state = FSharpTokenizerLexState.Initial
+
+ let firstLine, lastLine =
+ match range with
+ | None -> 0, source.GetLineCount() - 1
+ | Some range -> range.Start.Line, min range.End.Line (source.GetLineCount() - 1)
+
+ [|
+ for line in 0..lastLine do
+ cancellationToken.ThrowIfCancellationRequested()
+ let lineText = source.GetLineString line
+ // Should we cache the results?
+ let lineTokenizer = tokenizer.CreateLineTokenizer lineText
+ let mutable keepScanning = true
+
+ while keepScanning do
+ let token, nextState = lineTokenizer.ScanToken state
+ state <- nextState
+
+ match token with
+ | Some tokenInfo when line >= firstLine ->
+ match mapSyntacticToken tokenInfo with
+ | Some tokenType ->
+ let length = tokenInfo.RightColumn - tokenInfo.LeftColumn + 1
+
+ if length > 0 then
+ {
+ Line = line
+ StartChar = tokenInfo.LeftColumn
+ Length = length
+ TokenType = tokenType
+ TokenModifiers = []
+ Source = TokenSource.Syntactic
+ }
+ | None -> ()
+ | Some _ -> ()
+ | None -> keepScanning <- false
+ |]
+
+ let private overlaps existing candidate =
+ existing.Line = candidate.Line
+ && candidate.StartChar < existing.StartChar + existing.Length
+ && existing.StartChar < candidate.StartChar + candidate.Length
+
+ let private subtractOverlap existing candidate =
+ if not (overlaps existing candidate) then
+ [ candidate ]
+ else
+ let existingEnd = existing.StartChar + existing.Length
+ let candidateEnd = candidate.StartChar + candidate.Length
+
+ [
+ if candidate.StartChar < existing.StartChar then
+ { candidate with
+ Length = existing.StartChar - candidate.StartChar
+ }
+
+ if existingEnd < candidateEnd then
+ { candidate with
+ StartChar = existingEnd
+ Length = candidateEnd - existingEnd
+ }
+ ]
+
+ let resolveOverlaps (cancellationToken: CancellationToken) (tokens: NormalizedToken array) =
+ cancellationToken.ThrowIfCancellationRequested()
+
+ let sorted =
+ tokens
+ |> Array.sortBy (fun token -> sourcePriority token.Source, token.Line, token.StartChar, -token.Length)
+
+ let accepted = ResizeArray()
+ let acceptedByLine = Dictionary>()
+
+ let mutable previousLine = -1
+
+ for token in sorted do
+ if token.Line <> previousLine then
+ cancellationToken.ThrowIfCancellationRequested()
+ previousLine <- token.Line
+
+ let lineTokens =
+ match acceptedByLine.TryGetValue token.Line with
+ | true, tokens -> tokens
+ | _ ->
+ let tokens = ResizeArray()
+ acceptedByLine.Add(token.Line, tokens)
+ tokens
+
+ let accept token =
+ accepted.Add token
+ lineTokens.Add token
+
+ match token.Source with
+ | TokenSource.Semantic ->
+ if not (lineTokens.Exists(fun existing -> overlaps existing token)) then
+ accept token
+ | TokenSource.Syntactic ->
+ let fragments =
+ ([ token ], lineTokens)
+ ||> Seq.fold (fun fragments existing -> fragments |> List.collect (subtractOverlap existing))
+
+ for fragment in fragments do
+ if
+ fragment.Length > 0
+ && not (lineTokens.Exists(fun existing -> overlaps existing fragment))
+ then
+ accept fragment
+
+ accepted.ToArray()
+
+ // Assumes single-line tokens (NormalizedToken has no end-line field; splitRange already
+ // breaks any multi-line semantic range into one token per line).
+ let private intersectsRange (range: Range) token =
+ let line = token.Line
+
+ line >= range.Start.Line
+ && line <= range.End.Line
+ && (line <> range.Start.Line
+ || token.StartChar + token.Length > range.Start.Character)
+ && (line <> range.End.Line || token.StartChar < range.End.Character)
+
+ let filterRange (range: Range option) (tokens: NormalizedToken array) =
+ match range with
+ | None -> tokens
+ | Some range -> tokens |> Array.filter (intersectsRange range)
+
+ let encode (tokens: NormalizedToken array) =
+ let sorted = tokens |> Array.sortBy (fun token -> token.Line, token.StartChar)
+ let data = ResizeArray(sorted.Length * 5)
+ let mutable previousLine = 0
+ let mutable previousStart = 0
+
+ for token in sorted do
+ match tokenTypeIndexes |> Map.tryFind token.TokenType with
+ | None -> ()
+ | Some tokenTypeIndex ->
+ let deltaLine = token.Line - previousLine
+
+ let deltaStart =
+ if deltaLine = 0 then
+ token.StartChar - previousStart
+ else
+ token.StartChar
+
+ let modifierBits =
+ token.TokenModifiers
+ |> List.fold
+ (fun bits modifier ->
+ match tokenModifierIndexes |> Map.tryFind modifier with
+ | Some index -> bits ||| (1 <<< index)
+ | None -> bits)
+ 0
+
+ data.Add deltaLine
+ data.Add deltaStart
+ data.Add token.Length
+ data.Add tokenTypeIndex
+ data.Add modifierBits
+
+ previousLine <- token.Line
+ previousStart <- token.StartChar
+
+ data.ToArray()
+
+ let emptyResponse () = SemanticTokens(Data = [||])
+
+ let responseFor source fileName semanticItems range parsingOptions (cancellationToken: CancellationToken) =
+ cancellationToken.ThrowIfCancellationRequested()
+
+ let tokens =
+ Array.append
+ (fromSemanticClassifications source semanticItems range cancellationToken)
+ (fromTokenizer parsingOptions fileName source range cancellationToken)
+ |> resolveOverlaps cancellationToken
+ |> filterRange range
+
+ cancellationToken.ThrowIfCancellationRequested()
+ SemanticTokens(Data = encode tokens)
diff --git a/src/FSharp.Compiler.LanguageServer/Telemetry.fs b/src/FSharp.Compiler.LanguageServer/Telemetry.fs
new file mode 100644
index 00000000000..6984929bd87
--- /dev/null
+++ b/src/FSharp.Compiler.LanguageServer/Telemetry.fs
@@ -0,0 +1,110 @@
+// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
+
+namespace FSharp.Compiler.LanguageServer
+
+open System
+open System.Diagnostics
+
+/// Event name constants for LSP telemetry.
+[]
+module TelemetryEvents =
+ []
+ let LspServerInitialized = "lsp/initialized"
+
+ []
+ let GetDiagnostics = "lsp/getdiagnostics"
+
+ []
+ let GetCodeActions = "lsp/getcodeactions"
+
+ []
+ let DocumentOpened = "lsp/documentopened"
+
+ []
+ let DocumentChanged = "lsp/documentchanged"
+
+ []
+ let DocumentClosed = "lsp/documentclosed"
+
+ []
+ let GetProjectContexts = "lsp/getprojectcontexts"
+
+ []
+ let GetSemanticTokens = "lsp/getsemantictokens"
+
+ []
+ let ServerFault = "lsp/fault"
+
+ []
+ let SetActiveProjectContext = "lsp/setactiveprojectcontext"
+
+/// Abstraction for telemetry reporting in the F# LSP server.
+/// Implementations can route events to VS telemetry, OpenTelemetry, or a no-op sink.
+type ILspTelemetry =
+
+ /// Report a simple telemetry event with optional properties.
+ abstract ReportEvent: name: string * properties: (string * obj) array -> unit
+
+ /// Report an event that measures duration. Dispose the returned handle to complete the measurement.
+ abstract ReportEventWithDuration: name: string * properties: (string * obj) array -> IDisposable
+
+ /// Report a fault/error event.
+ abstract ReportFault: name: string * description: string * exn: exn option -> unit
+
+module internal Telemetry =
+ let reportEvent (telemetry: ILspTelemetry) name properties =
+ try
+ telemetry.ReportEvent(name, properties)
+ with _ ->
+ Trace.TraceWarning("F# LSP telemetry could not be posted.")
+
+ let reportFault (telemetry: ILspTelemetry) name description error =
+ try
+ telemetry.ReportFault(name, description, Some error)
+ with _ ->
+ Trace.TraceWarning("F# LSP fault telemetry could not be posted.")
+
+/// No-op telemetry implementation for standalone/testing use.
+type NullTelemetry() =
+ static let noopDisposable =
+ { new IDisposable with
+ member _.Dispose() = ()
+ }
+
+ static member val Instance = NullTelemetry() :> ILspTelemetry
+
+ interface ILspTelemetry with
+ member _.ReportEvent(_name, _properties) = ()
+
+ member _.ReportEventWithDuration(_name, _properties) = noopDisposable
+
+ member _.ReportFault(_name, _description, _exn) = ()
+
+/// Telemetry implementation that writes to System.Diagnostics.Trace (useful for debugging).
+type TraceTelemetry() =
+
+ interface ILspTelemetry with
+ member _.ReportEvent(name, properties) =
+ let propsStr =
+ properties |> Array.map (fun (k, v) -> $"{k}={v}") |> String.concat ", "
+
+ Trace.TraceInformation($"[Telemetry] {name} ({propsStr})")
+
+ member _.ReportEventWithDuration(name, properties) =
+ let sw = Stopwatch.StartNew()
+ Trace.TraceInformation($"[Telemetry] {name} started")
+
+ { new IDisposable with
+ member _.Dispose() =
+ sw.Stop()
+
+ let propsStr =
+ properties |> Array.map (fun (k, v) -> $"{k}={v}") |> String.concat ", "
+
+ Trace.TraceInformation($"[Telemetry] {name} completed in {sw.ElapsedMilliseconds}ms ({propsStr})")
+ }
+
+ member _.ReportFault(name, description, exn) =
+ match exn with
+ | Some e -> Trace.TraceError($"[Telemetry] FAULT {name}: {description} - {e}")
+ | None -> Trace.TraceError($"[Telemetry] FAULT {name}: {description}")
diff --git a/src/FSharp.Compiler.LanguageServer/Utils.fs b/src/FSharp.Compiler.LanguageServer/Utils.fs
index c5283b6fb76..aba24ac0311 100644
--- a/src/FSharp.Compiler.LanguageServer/Utils.fs
+++ b/src/FSharp.Compiler.LanguageServer/Utils.fs
@@ -3,33 +3,93 @@ namespace FSharp.Compiler.LanguageServer
open Microsoft.CommonLanguageServerProtocol.Framework
open Microsoft.VisualStudio.LanguageServer.Protocol
+open System
open System.Diagnostics
+open System.Security.Cryptography
+open System.Text
+open FSharp.Compiler.CodeAnalysis.ProjectSnapshot
open FSharp.Compiler.Diagnostics
open System.Runtime.CompilerServices
+#nowarn "57"
+
[]
module Utils =
type LspRange = Microsoft.VisualStudio.LanguageServer.Protocol.Range
+ /// Encodes a stable, round-trippable identifier for a project context. The encoding includes the
+ /// output file name so that different target frameworks of a multi-targeting project produce
+ /// distinct ids (their project file name is the same but their output file name differs).
+ /// Format: "{guid}|{outputFileName}|{projectFileName}". The GUID is derived from the output file
+ /// name so that the same TFM of the same project always maps to the same context id.
+ let makeProjectContextId (identifier: FSharpProjectIdentifier) =
+ let (FSharpProjectIdentifier(projectFileName, outputFileName)) = identifier
+ let guid = Guid(MD5.HashData(Encoding.UTF8.GetBytes(outputFileName)))
+ $"{guid}|{outputFileName}|{projectFileName}"
+
+ /// Recovers an FSharpProjectIdentifier from a context id produced by makeProjectContextId.
+ /// Returns None for malformed ids.
+ let tryParseProjectContextId (contextId: string) : FSharpProjectIdentifier option =
+ if String.IsNullOrEmpty contextId then
+ None
+ else
+ let parts = contextId.Split('|', 3)
+
+ if parts.Length = 3 then
+ Some(FSharpProjectIdentifier(parts[2], parts[1]))
+ else
+ None
+
+ /// Extracts the FSharpProjectIdentifier from the VS project context attached to a text document
+ /// identifier, if any. Returns None when the client didn't send a context (e.g. non-VS clients
+ /// or when navbar isn't available) or when the id is malformed.
+ let tryGetProjectContext (textDocument: TextDocumentIdentifier) : FSharpProjectIdentifier option =
+ match textDocument with
+ | :? VSTextDocumentIdentifier as vsDoc ->
+ match vsDoc.ProjectContext with
+ | null -> None
+ | ctx -> tryParseProjectContextId ctx.Id
+ | _ -> None
+
+ let snapshotsToProjectInfos (snapshots: FSharpProjectSnapshot array) =
+ snapshots
+ |> Array.map (fun s ->
+ VSDiagnosticProjectInformation(
+ ProjectName = IO.Path.GetFileNameWithoutExtension(s.ProjectFileName),
+ ProjectIdentifier = makeProjectContextId s.Identifier
+ ))
+
let LspLogger (output: string -> unit) =
{ new ILspLogger with
- member this.LogEndContext(message: string, ``params``: obj array) : unit =
- output $"EndContext :: {message} %A{``params``}"
+ member _.CreateContext(context: string) =
+ output $"StartContext :: {context}"
+
+ { new IDisposable with
+ member _.Dispose() = output $"EndContext :: {context}"
+ }
+
+ member _.CreateLanguageContext(language: string | null) =
+ output $"StartLanguageContext :: {language}"
+
+ { new IDisposable with
+ member _.Dispose() =
+ output $"EndLanguageContext :: {language}"
+ }
+
+ member _.LogDebug(message: string, ``params``: obj array) : unit =
+ output $"DEBUG :: {message} %A{``params``}"
member this.LogError(message: string, ``params``: obj array) : unit =
output $"ERROR :: {message} %A{``params``}"
- member this.LogException(``exception``: exn, message: string, ``params``: obj array) : unit =
+ member this.LogException(``exception``: exn, message: string | null, ``params``: obj array) : unit =
output $"EXCEPTION :: %A{``exception``} {message} %A{``params``}"
member this.LogInformation(message: string, ``params``: obj array) : unit =
output $"INFO :: {message} %A{``params``}"
- member this.LogStartContext(message: string, ``params``: obj array) : unit =
- output $"StartContext :: {message} %A{``params``}"
-
member this.LogWarning(message: string, ``params``: obj array) : unit =
output $"WARNING :: {message} %A{``params``}"
}
@@ -46,13 +106,21 @@ module Utils =
type FSharpDiagnosticExtensions =
[]
- static member ToLspDiagnostic(this: FSharpDiagnostic) =
- Diagnostic(
+ static member ToVsDiagnostic(this: FSharpDiagnostic, projects: VSDiagnosticProjectInformation[]) =
+ let severity =
+ match this.Severity with
+ | FSharpDiagnosticSeverity.Error -> DiagnosticSeverity.Error
+ | FSharpDiagnosticSeverity.Warning -> DiagnosticSeverity.Warning
+ | FSharpDiagnosticSeverity.Info -> DiagnosticSeverity.Information
+ | FSharpDiagnosticSeverity.Hidden -> DiagnosticSeverity.Hint
+
+ VSDiagnostic(
Range = this.Range.ToLspRange(),
- Severity = DiagnosticSeverity.Error,
+ Severity = severity,
Message = $"LSP: {this.Message}",
- //Source = "Intellisense",
- Code = SumType this.ErrorNumberText
+ Code = SumType this.ErrorNumberText,
+ Projects = projects,
+ Identifier = string (hash (this.ErrorNumberText, this.Range, this.Message))
)
module Activity =
diff --git a/src/FSharp.VisualStudio.Extension/.vsextension/string-resources.json b/src/FSharp.VisualStudio.Extension/.vsextension/string-resources.json
index 5f1633293d7..22f8b021339 100644
--- a/src/FSharp.VisualStudio.Extension/.vsextension/string-resources.json
+++ b/src/FSharp.VisualStudio.Extension/.vsextension/string-resources.json
@@ -1,4 +1,9 @@
{
- "FSharpLspExtension.FSharpLanguageServerProvider.DisplayName": "FSharp Analyzer LSP server"
-
+ "FSharpLspExtension.FSharpLanguageServerProvider.DisplayName": "FSharp Analyzer LSP server",
+ "F#": "F# LSP",
+ "FSharpSettings.Old": "Old extension (devenv process)",
+ "FSharpSettings.LSP": "LSP (separate process)",
+ "FSharpSettings.Unset": "Default",
+ "FSharpSettings.SemanticClassificationProvider": "Semantic Classification Provider",
+ "FSharpSettings.SemanticClassificationProvider.Description": "Which extension provides semantic classification (restart required)"
}
diff --git a/src/FSharp.VisualStudio.Extension/ExtensionEntrypoint.cs b/src/FSharp.VisualStudio.Extension/ExtensionEntrypoint.cs
index ba47320b925..f1cce243d7e 100644
--- a/src/FSharp.VisualStudio.Extension/ExtensionEntrypoint.cs
+++ b/src/FSharp.VisualStudio.Extension/ExtensionEntrypoint.cs
@@ -5,6 +5,7 @@
using System.Threading;
using System;
using Extension = Microsoft.VisualStudio.Extensibility.Extension;
+using System.Diagnostics;
///
/// Extension entrypoint for the VisualStudio.Extensibility extension.
@@ -21,7 +22,10 @@ internal class ExtensionEntrypoint : Extension
version: this.ExtensionAssemblyVersion,
publisherName: "Publisher name",
displayName: "FSharp.VisualStudio.Extension",
- description: "Extension description"),
+ description: "Extension description")
+ {
+ InstallationTargetVersion = "[18.14, 19.0)",
+ },
};
///
diff --git a/src/FSharp.VisualStudio.Extension/FSharp.VisualStudio.Extension.csproj b/src/FSharp.VisualStudio.Extension/FSharp.VisualStudio.Extension.csproj
index f2f8ab61ede..5510ee5efe0 100644
--- a/src/FSharp.VisualStudio.Extension/FSharp.VisualStudio.Extension.csproj
+++ b/src/FSharp.VisualStudio.Extension/FSharp.VisualStudio.Extension.csproj
@@ -1,12 +1,12 @@
- net8.0-windows
-
-
- net8.0
+ net10.0-windows8.0
+ true
enable
12
en-US
+ false
+ LSPComponent
@@ -16,8 +16,7 @@
-
-
+
+ false
+
+
diff --git a/tests/FSharp.Compiler.LanguageServer.IntegrationTests/ErrorListDiagnosticsTests.cs b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/ErrorListDiagnosticsTests.cs
new file mode 100644
index 00000000000..6cbf44c7792
--- /dev/null
+++ b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/ErrorListDiagnosticsTests.cs
@@ -0,0 +1,242 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System;
+using System.Diagnostics;
+using System.Linq;
+using Microsoft.Test.Apex.Services;
+using Microsoft.Test.Apex.VisualStudio.Shell;
+using Microsoft.Test.Apex.VisualStudio.Shell.ToolWindows.ErrorHub;
+using Microsoft.Test.Apex.VisualStudio.Solution;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace FSharp.Compiler.LanguageServer.IntegrationTests;
+
+///
+/// Tests that F# LSP diagnostics appear in the VS Error List with correctly populated columns,
+/// including the Project column which requires VSDiagnosticProjectInformation from the language server.
+///
+[TestClass]
+[Ignore("LSP diagnostics are hard-coded off in Visual Studio.")]
+// These tests may need migration to the new Apex suite when LSP diagnostics are enabled.
+public class ErrorListDiagnosticsTests : FSharpApexTestBase
+{
+ ///
+ /// Code that produces a type mismatch error (FS0001) on line 4.
+ ///
+ private const string CodeWithError = """
+ module Test
+
+ let add (x: int) (y: int) = x + y
+ let result = add 1 "hello"
+ """;
+
+ ///
+ /// Code that produces a warning about IDisposable construction (FS0760).
+ ///
+ private const string CodeWithWarning = """
+ module Test
+
+ open System.IO
+
+ let stream = MemoryStream()
+ """;
+
+ ///
+ /// Clean code that should produce no diagnostics.
+ ///
+ private const string CleanCode = """
+ module Test
+
+ let answer = 42
+ """;
+
+ private IErrorHubService GetErrorHub()
+ {
+ var errorHub = VsHost.ObjectModel.Shell.ToolWindows.ErrorHub;
+ errorHub.ShowErrors();
+ errorHub.ShowWarnings();
+ errorHub.HideMessages();
+ errorHub.FilterScope = ErrorListFilterScope.Off;
+ _ = errorHub.TryWaitForReady();
+ errorHub.ToolWindow.ShowNoActivate();
+ return errorHub;
+ }
+
+ ///
+ /// Waits for at least error list entries to appear,
+ /// optionally filtering to LSP-only entries when is true.
+ ///
+ private IErrorEntryTestExtension[] WaitForEntries(
+ IErrorHubService errorHub,
+ int minCount,
+ bool lspOnly = false,
+ TimeSpan? timeout = null)
+ {
+ timeout ??= TimeSpan.FromSeconds(120);
+ var sw = Stopwatch.StartNew();
+ IErrorEntryTestExtension[] entries = [];
+
+ do
+ {
+ _ = errorHub.TryWaitForErrorListEntries();
+ _ = errorHub.TryWaitForReady();
+
+ entries = lspOnly
+ ? errorHub.AllVisibleEntries
+ .Where(e => e.Description?.Contains("LSP:") == true)
+ .ToArray()
+ : errorHub.AllVisibleEntries;
+
+ if (entries.Length >= minCount)
+ return entries;
+
+ System.Threading.Thread.Sleep(2000);
+ } while (sw.Elapsed < timeout);
+
+ Assert.Fail(
+ $"Expected at least {minCount} error list entries (lspOnly={lspOnly}), " +
+ $"but found {entries.Length} after {timeout}.");
+ return entries;
+ }
+
+ [TestMethod]
+ [TestCategory("Integration")]
+ public void LspDiagnostics_ErrorList_ProjectColumnPopulated()
+ {
+ // Arrange: create a project with code that has a type error
+ var solution = VsHost.ObjectModel.Solution;
+ var project = solution.CreateProject(ProjectLanguage.FSharp, ProjectTemplate.Default);
+ var projectName = project.Name;
+
+ var document = VsHost.ObjectModel.WindowManager.ActiveDocumentWindowAsTextEditor;
+ var editor = document.Editor;
+
+ editor.Selection.SelectAll();
+ editor.KeyboardCommands.Delete();
+ editor.Edit.InsertTextInBuffer(CodeWithError);
+
+ // Act: wait for LSP diagnostics to appear
+ var errorHub = GetErrorHub();
+ var lspEntries = WaitForEntries(errorHub, minCount: 1, lspOnly: true);
+
+ // Assert: each LSP diagnostic should have a non-empty ProjectName
+ foreach (var entry in lspEntries)
+ {
+ Assert.IsFalse(
+ string.IsNullOrEmpty(entry.ProjectName),
+ $"LSP diagnostic '{entry.Description}' has an empty Project column. " +
+ $"Expected project name containing '{projectName}'.");
+ }
+ }
+
+ [TestMethod]
+ [TestCategory("Integration")]
+ public void LspDiagnostics_ErrorList_ProjectNameMatchesProjectFile()
+ {
+ // Arrange: create a project and introduce an error
+ var solution = VsHost.ObjectModel.Solution;
+ var project = solution.CreateProject(ProjectLanguage.FSharp, ProjectTemplate.Default);
+ var projectFileName = project.FileName;
+
+ var document = VsHost.ObjectModel.WindowManager.ActiveDocumentWindowAsTextEditor;
+ var editor = document.Editor;
+
+ editor.Selection.SelectAll();
+ editor.KeyboardCommands.Delete();
+ editor.Edit.InsertTextInBuffer(CodeWithError);
+
+ // Act: wait for LSP diagnostics
+ var errorHub = GetErrorHub();
+ var lspEntries = WaitForEntries(errorHub, minCount: 1, lspOnly: true);
+
+ // Assert: the project name should match the project file name
+ // (This is the same format used by legacy diagnostics)
+ foreach (var entry in lspEntries)
+ {
+ Assert.IsTrue(
+ entry.ProjectName?.Contains(projectFileName) == true ||
+ projectFileName?.Contains(entry.ProjectName ?? "") == true,
+ $"LSP diagnostic project name '{entry.ProjectName}' does not match " +
+ $"expected project file '{projectFileName}'.");
+ }
+ }
+
+ [TestMethod]
+ [TestCategory("Integration")]
+ public void LspDiagnostics_ErrorList_WarningHasProjectColumn()
+ {
+ // Arrange: create a project with code that produces a warning
+ var solution = VsHost.ObjectModel.Solution;
+ var project = solution.CreateProject(ProjectLanguage.FSharp, ProjectTemplate.Default);
+
+ var document = VsHost.ObjectModel.WindowManager.ActiveDocumentWindowAsTextEditor;
+ var editor = document.Editor;
+
+ // Small delay to let VS settle focus after prior test's error hub interaction.
+ System.Threading.Thread.Sleep(2000);
+ editor.Selection.SelectAll();
+ editor.KeyboardCommands.Delete();
+ editor.Edit.InsertTextInBuffer(CodeWithWarning);
+
+ // Act: wait for LSP warnings
+ var errorHub = GetErrorHub();
+ var lspEntries = WaitForEntries(errorHub, minCount: 1, lspOnly: true);
+
+ // Assert: warnings should also have the project column populated
+ var lspWarnings = lspEntries
+ .Where(e => e.Severity == ErrorItemSeverity.Warning)
+ .ToArray();
+
+ if (lspWarnings.Length == 0)
+ {
+ Assert.Inconclusive("No LSP warnings appeared in the error list.");
+ }
+
+ foreach (var warning in lspWarnings)
+ {
+ Assert.IsFalse(
+ string.IsNullOrEmpty(warning.ProjectName),
+ $"LSP warning '{warning.Description}' has an empty Project column.");
+ }
+ }
+
+ [TestMethod]
+ [TestCategory("Integration")]
+ public void LspDiagnostics_ErrorList_AllColumnsPopulated()
+ {
+ // Arrange: create a project with code that has a type error
+ var solution = VsHost.ObjectModel.Solution;
+ var project = solution.CreateProject(ProjectLanguage.FSharp, ProjectTemplate.Default);
+
+ var document = VsHost.ObjectModel.WindowManager.ActiveDocumentWindowAsTextEditor;
+ var editor = document.Editor;
+
+ editor.Selection.SelectAll();
+ editor.KeyboardCommands.Delete();
+ editor.Edit.InsertTextInBuffer(CodeWithError);
+
+ // Act: wait for LSP diagnostics
+ var errorHub = GetErrorHub();
+ var lspEntries = WaitForEntries(errorHub, minCount: 1, lspOnly: true);
+
+ // Assert: all standard error list columns should be populated
+ foreach (var entry in lspEntries)
+ {
+ Assert.IsFalse(string.IsNullOrEmpty(entry.Description),
+ "LSP diagnostic has empty Description.");
+
+ Assert.IsFalse(string.IsNullOrEmpty(entry.ProjectName),
+ $"LSP diagnostic '{entry.Description}' has empty Project column.");
+
+ Assert.IsFalse(string.IsNullOrEmpty(entry.FileName),
+ $"LSP diagnostic '{entry.Description}' has empty File column.");
+
+ Assert.IsNotNull(entry.LineNumber,
+ $"LSP diagnostic '{entry.Description}' has null Line number.");
+
+ Assert.IsFalse(string.IsNullOrEmpty(entry.ErrorCode),
+ $"LSP diagnostic '{entry.Description}' has empty Error Code.");
+ }
+ }
+}
diff --git a/tests/FSharp.Compiler.LanguageServer.IntegrationTests/FSharp.Compiler.LanguageServer.IntegrationTests.csproj b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/FSharp.Compiler.LanguageServer.IntegrationTests.csproj
new file mode 100644
index 00000000000..21d9b9b7181
--- /dev/null
+++ b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/FSharp.Compiler.LanguageServer.IntegrationTests.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net472
+ false
+ true
+ mstest
+ preview
+ enable
+ true
+ $(NoWarn);MSB3277;NETSDK1023
+ $(MSBuildProjectDirectory)\.runsettings
+
+
+
+
+
+
+
+
+
diff --git a/tests/FSharp.Compiler.LanguageServer.IntegrationTests/FSharpApexTestBase.cs b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/FSharpApexTestBase.cs
new file mode 100644
index 00000000000..5de11fbe18f
--- /dev/null
+++ b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/FSharpApexTestBase.cs
@@ -0,0 +1,98 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System;
+using Microsoft.Test.Apex;
+using Microsoft.Test.Apex.VisualStudio;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace FSharp.Compiler.LanguageServer.IntegrationTests;
+
+///
+/// Base class for F# LSP integration tests that run inside Visual Studio.
+/// Uses Apex to launch VS experimental instance with the F# extension deployed.
+///
+[TestClass]
+public class FSharpApexTestBase : ApexTest
+{
+ private static readonly VisualStudioHostConfiguration s_configuration = CreateConfiguration();
+ internal static VisualStudioHost VsHost = default!;
+
+ private static VisualStudioHostConfiguration CreateConfiguration()
+ {
+ return new VisualStudioHostConfiguration
+ {
+ RootSuffix = "Exp",
+ SkipCodeMarkerInitialization = true,
+ EnableUnifiedSettings = true,
+ // Automatically dismiss unexpected dialogs (e.g., the file
+ // recovery dialog that appears after a crash or forced kill).
+ AutomaticallyDismissMessageBoxes = true,
+ };
+ }
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ if (VsHost?.IsRunning == true)
+ {
+ return;
+ }
+
+ // Previous host may be a zombie (crashed but not disposed).
+ // Dispose it before creating a new one.
+ try { VsHost?.Stop(); } catch { /* best effort cleanup */ }
+
+ StartVisualStudioHost();
+ }
+
+ protected void RestartVisualStudioHost()
+ {
+ if (VsHost?.IsRunning == true)
+ {
+ VsHost.Stop();
+ }
+
+ StartVisualStudioHost();
+ }
+
+ private void StartVisualStudioHost()
+ {
+ VsHost = Operations.CreateHost(s_configuration);
+ VsHost.Start();
+ }
+
+ [TestCleanup]
+ public override void TestCleanup()
+ {
+ try
+ {
+ if (VsHost?.IsRunning == true)
+ {
+ if (VsHost.ObjectModel.Solution?.IsOpen == true)
+ {
+ // SaveAndClose avoids leaving unsaved recovery files that
+ // trigger a file-recovery dialog on the next VS launch.
+ VsHost.ObjectModel.Solution.SaveAndClose();
+ }
+ }
+ }
+ catch (Exception)
+ {
+ // VS may have crashed — mark the host as dead so Initialize
+ // creates a fresh instance for the next test.
+ VsHost = null!;
+ }
+
+ base.TestCleanup();
+ }
+
+ [AssemblyCleanup]
+ public static void AssemblyCleanup()
+ {
+ if (VsHost?.IsRunning == true)
+ {
+ VsHost?.Stop();
+ }
+ }
+}
diff --git a/tests/FSharp.Compiler.LanguageServer.IntegrationTests/Fixtures/SyntaxHighlightingSample.fs b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/Fixtures/SyntaxHighlightingSample.fs
new file mode 100644
index 00000000000..098dc241791
--- /dev/null
+++ b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/Fixtures/SyntaxHighlightingSample.fs
@@ -0,0 +1,87 @@
+// Sample F# source exercising every semantic and syntactic classification the LSP server emits.
+// Used for manual spot-checks of syntax highlighting in VS / VS Code and as a fixture for
+// integration tests. Keep it warning-free.
+
+module FSharp.Samples.Highlighting
+
+open System
+open System.IO
+open System.Runtime.CompilerServices
+
+/// Top-level literal -> "constant name".
+[]
+let MaxItems = 100
+
+/// Top-level non-disposable, non-literal binding -> "variable" (FCS Value).
+let pi = 3.14159
+
+[]
+type Vector = { X: float; Y: float }
+
+[]
+type Point(x: int, y: int) =
+ member _.X = x
+ member _.Y = y
+
+type Color =
+ | Red = 0
+ | Green = 1
+ | Blue = 2
+
+type Shape =
+ | Circle of radius: float
+ | Rectangle of width: float * height: float
+
+type Person =
+ { Name: string
+ mutable Age: int
+ Greet: string -> string }
+
+type IShape =
+ abstract member Area: float
+ abstract member Describe: prefix: string -> string
+
+type Greeter = delegate of string -> unit
+
+exception InvalidShape of string
+
+type Counter() =
+ let mutable count = 0
+ member _.Count = count
+ member _.Increment() = count <- count + 1
+
+[]
+type StringExtensions =
+ []
+ static member Shout(s: string) = s.ToUpper()
+
+module Sample =
+
+ let identity<'T> (x: 'T) = x
+
+ let inline (++) (a: int) (b: int) = a + b
+
+ let greet (name: string) =
+ printfn "Hello, %s (%d chars)" name name.Length
+
+ let openFile () =
+ let stream = new FileStream("local.txt", FileMode.Create)
+ stream.Close()
+
+ let topStream = new FileStream("top.txt", FileMode.Create)
+
+ let counter = Counter()
+ let p = Point(1, 2)
+
+ let pipeline () =
+ async {
+ let! n = async.Return 42
+ return n + 1
+ }
+
+ let nameOfShape (s: Shape) = nameof s
+
+ let area (s: Shape) =
+ match s with
+ | Circle r -> r * r * Math.PI
+ | Rectangle (w, h) -> w * h
diff --git a/tests/FSharp.Compiler.LanguageServer.IntegrationTests/LightBulbCodeActionTests.cs b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/LightBulbCodeActionTests.cs
new file mode 100644
index 00000000000..159e4b4fb59
--- /dev/null
+++ b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/LightBulbCodeActionTests.cs
@@ -0,0 +1,299 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Reflection;
+using System.Runtime.Remoting;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Microsoft.Test.Apex.Editor;
+using Microsoft.Test.Apex.VisualStudio.Solution;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace FSharp.Compiler.LanguageServer.IntegrationTests;
+
+[TestClass]
+[Ignore("LSP code actions are suppressed by VsServerCapabilitiesOverride in Visual Studio.")]
+// These tests may need migration to the new Apex suite when LSP code actions are enabled.
+public class LightBulbCodeActionTests : FSharpApexTestBase
+{
+ private const string CodeWithMissingNewKeyword = """
+ module Test
+
+ let x = System.IO.FileStream("dummy.txt", System.IO.FileMode.Create)
+ """;
+
+ private const string CodeWithEqualityOperator = """
+ module Test
+
+ let x = 1 == 2
+ """;
+
+ private const string CodeWithNotMutableAssignment = """
+ module Test
+
+ let x = 1
+ x <- 2
+ """;
+
+ private const string CodeWithMissingFunKeyword = """
+ module Test
+
+ let f = x -> x + 1
+ """;
+
+ [TestMethod]
+ [TestCategory("Integration")]
+ public void ConstructorCall_OffersCodeActionToAddNewKeyword()
+ {
+ // Arrange
+ var solution = VsHost.ObjectModel.Solution;
+ _ = solution.CreateProject(ProjectLanguage.FSharp, ProjectTemplate.Default);
+
+ var document = VsHost.ObjectModel.WindowManager.ActiveDocumentWindowAsTextEditor;
+ var editor = document.Editor;
+
+ editor.Selection.SelectAll();
+ editor.KeyboardCommands.Delete();
+ editor.Edit.InsertTextInBuffer(CodeWithMissingNewKeyword);
+
+ // Position the caret on the constructor call line so the light bulb
+ // appears at the location with the diagnostic.
+ MoveCaret(editor, line: 3);
+
+ ILightBulbService? lightBulbService = TryGetLightBulbService(editor);
+ Assert.IsNotNull(lightBulbService, "Could not access light bulb service from the active text editor.");
+
+ // Act: create a light bulb session and read actions, retrying if
+ // the session is dismissed (e.g., when diagnostics refresh).
+ // CreateLightBulbSession blocks until VS has computed code actions,
+ // so it implicitly waits for the language server to produce diagnostics.
+ var actionTitles = WaitForLightBulbActions(lightBulbService, timeout: TimeSpan.FromSeconds(120));
+
+ // Assert
+ bool hasAddNewKeywordAction = actionTitles.Any(title =>
+ title.Contains("Add 'new' keyword", StringComparison.OrdinalIgnoreCase));
+
+ Assert.IsTrue(
+ hasAddNewKeywordAction,
+ $"Expected a light bulb action containing \"Add 'new' keyword\" but found: {string.Join(", ", actionTitles)}");
+ }
+
+ [TestMethod]
+ [TestCategory("Integration")]
+ public void EqualityOperator_OffersConvertToEquals()
+ {
+ // Arrange
+ var solution = VsHost.ObjectModel.Solution;
+ _ = solution.CreateProject(ProjectLanguage.FSharp, ProjectTemplate.Default);
+
+ var document = VsHost.ObjectModel.WindowManager.ActiveDocumentWindowAsTextEditor;
+ var editor = document.Editor;
+
+ editor.Selection.SelectAll();
+ editor.KeyboardCommands.Delete();
+ editor.Edit.InsertTextInBuffer(CodeWithEqualityOperator);
+
+ MoveCaret(editor, line: 3);
+
+ ILightBulbService? lightBulbService = TryGetLightBulbService(editor);
+ Assert.IsNotNull(lightBulbService, "Could not access light bulb service from the active text editor.");
+
+ // Act
+ var actionTitles = WaitForLightBulbActions(lightBulbService, timeout: TimeSpan.FromSeconds(120));
+
+ // Assert
+ bool hasEqualsAction = actionTitles.Any(title =>
+ title.Contains("Use '=' for equality", StringComparison.OrdinalIgnoreCase));
+
+ Assert.IsTrue(
+ hasEqualsAction,
+ $"Expected a light bulb action containing \"Use '=' for equality\" but found: {string.Join(", ", actionTitles)}");
+ }
+
+ [TestMethod]
+ [TestCategory("Integration")]
+ public void NotMutableAssignment_OffersMakeDeclarationMutable()
+ {
+ // Arrange
+ var solution = VsHost.ObjectModel.Solution;
+ _ = solution.CreateProject(ProjectLanguage.FSharp, ProjectTemplate.Default);
+
+ var document = VsHost.ObjectModel.WindowManager.ActiveDocumentWindowAsTextEditor;
+ var editor = document.Editor;
+
+ editor.Selection.SelectAll();
+ editor.KeyboardCommands.Delete();
+ editor.Edit.InsertTextInBuffer(CodeWithNotMutableAssignment);
+
+ MoveCaret(editor, line: 4);
+
+ ILightBulbService? lightBulbService = TryGetLightBulbService(editor);
+ Assert.IsNotNull(lightBulbService, "Could not access light bulb service from the active text editor.");
+
+ // Act
+ var actionTitles = WaitForLightBulbActions(lightBulbService, timeout: TimeSpan.FromSeconds(120));
+
+ // Assert
+ bool hasMutableAction = actionTitles.Any(title =>
+ title.Contains("mutable", StringComparison.OrdinalIgnoreCase));
+
+ Assert.IsTrue(
+ hasMutableAction,
+ $"Expected a light bulb action containing \"mutable\" but found: {string.Join(", ", actionTitles)}");
+ }
+
+ [TestMethod]
+ [TestCategory("Integration")]
+ public void MissingFunKeyword_OffersAddFunKeyword()
+ {
+ // Arrange
+ var solution = VsHost.ObjectModel.Solution;
+ _ = solution.CreateProject(ProjectLanguage.FSharp, ProjectTemplate.Default);
+
+ var document = VsHost.ObjectModel.WindowManager.ActiveDocumentWindowAsTextEditor;
+ var editor = document.Editor;
+
+ editor.Selection.SelectAll();
+ editor.KeyboardCommands.Delete();
+ editor.Edit.InsertTextInBuffer(CodeWithMissingFunKeyword);
+
+ MoveCaret(editor, line: 3);
+
+ ILightBulbService? lightBulbService = TryGetLightBulbService(editor);
+ Assert.IsNotNull(lightBulbService, "Could not access light bulb service from the active text editor.");
+
+ // Act
+ var actionTitles = WaitForLightBulbActions(lightBulbService, timeout: TimeSpan.FromSeconds(120));
+
+ // Assert
+ bool hasFunAction = actionTitles.Any(title =>
+ title.Contains("Add 'fun' keyword", StringComparison.OrdinalIgnoreCase));
+
+ Assert.IsTrue(
+ hasFunAction,
+ $"Expected a light bulb action containing \"Add 'fun' keyword\" but found: {string.Join(", ", actionTitles)}");
+ }
+
+ private static ILightBulbService? TryGetLightBulbService(object editor)
+ {
+ var lightBulbProperty = editor.GetType().GetProperty("LightBulb", BindingFlags.Public | BindingFlags.Instance);
+ return lightBulbProperty?.GetValue(editor) as ILightBulbService;
+ }
+
+ private static void MoveCaret(object editor, int line)
+ {
+ var caretProperty = editor.GetType().GetProperty("Caret", BindingFlags.Public | BindingFlags.Instance);
+ var caret = caretProperty?.GetValue(editor);
+ var moveMethod = caret?.GetType().GetMethod("MoveToLine", [typeof(int)]);
+ moveMethod?.Invoke(caret, [line]);
+ }
+
+ ///
+ /// Creates a light bulb session, expands it, and reads action titles.
+ /// is a blocking RPC
+ /// call that can hang indefinitely when no lightbulb is available. Each
+ /// attempt is run on a thread-pool thread with a per-call timeout so the
+ /// test can fail cleanly instead of hanging.
+ ///
+ private static IReadOnlyList WaitForLightBulbActions(
+ ILightBulbService lightBulbService,
+ TimeSpan timeout)
+ {
+ var sw = Stopwatch.StartNew();
+ var perCallTimeout = TimeSpan.FromSeconds(30);
+
+ while (sw.Elapsed < timeout)
+ {
+ try
+ {
+ // Run the blocking RPC call on a background thread so we can
+ // enforce a per-call timeout and avoid hanging the test.
+ using var cts = new CancellationTokenSource();
+ var sessionTask = Task.Run(() =>
+ lightBulbService.CreateLightBulbSession(LightBulbPriority.Medium), cts.Token);
+
+ var remaining = timeout - sw.Elapsed;
+ var waitTime = remaining < perCallTimeout ? remaining : perCallTimeout;
+
+ if (!sessionTask.Wait(waitTime))
+ {
+ // CreateLightBulbSession is still blocking — cancel and suppress
+ // any unobserved exception before retrying.
+ cts.Cancel();
+ sessionTask.ContinueWith(t => _ = t.Exception, TaskContinuationOptions.OnlyOnFaulted);
+ Thread.Sleep(2000);
+ continue;
+ }
+
+ var lightBulb = sessionTask.Result;
+
+ if (lightBulb == null)
+ {
+ Thread.Sleep(2000);
+ continue;
+ }
+
+ if (!lightBulb.IsExpanded)
+ {
+ lightBulb.Expand();
+ }
+
+ List titles = [];
+ foreach (LightBulbSuggestedAction action in lightBulb.Actions)
+ {
+ CollectActionTitles(action, titles);
+ }
+
+ if (titles.Count > 0)
+ {
+ return titles;
+ }
+
+ // Dismiss the empty session before retrying.
+ lightBulb.Dismiss();
+ Thread.Sleep(2000);
+ }
+ catch (EditorException)
+ {
+ // The light bulb session was dismissed (e.g., diagnostics refreshed).
+ // Wait for the language server to stabilize and retry.
+ Thread.Sleep(5000);
+ }
+ catch (RemotingException)
+ {
+ // VS process crashed or IPC pipe broke.
+ VsHost = null!;
+ Assert.Fail("VS host process crashed (IPC pipe ended) while waiting for light bulb actions.");
+ return [];
+ }
+ catch (AggregateException ex) when (ex.InnerException is RemotingException)
+ {
+ VsHost = null!;
+ Assert.Fail("VS host process crashed (IPC pipe ended) while waiting for light bulb actions.");
+ return [];
+ }
+ catch (AggregateException ex) when (ex.InnerException is EditorException)
+ {
+ Thread.Sleep(5000);
+ }
+ }
+
+ return [];
+ }
+
+ private static void CollectActionTitles(LightBulbSuggestedAction action, ICollection titles)
+ {
+ titles.Add(action.Text ?? string.Empty);
+
+ foreach (LightBulbSuggestedAction nestedAction in action.NestedActions)
+ {
+ CollectActionTitles(nestedAction, titles);
+ }
+ }
+}
+
diff --git a/tests/FSharp.Compiler.LanguageServer.IntegrationTests/RunTests.cmd b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/RunTests.cmd
new file mode 100644
index 00000000000..d6222316d13
--- /dev/null
+++ b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/RunTests.cmd
@@ -0,0 +1,47 @@
+@echo off
+setlocal
+
+set "TESTDIR=%~dp0"
+set "REPOROOT=%TESTDIR%..\.."
+set "PROJECT=%TESTDIR%FSharp.Compiler.LanguageServer.IntegrationTests.csproj"
+set "RUNSETTINGS=%TESTDIR%.runsettings"
+set "CONFIG=Release"
+
+:: Resolve vstest.console.dll path
+if not defined VSTEST_CONSOLE_PATH (
+ for /f "usebackq delims=" %%i in (`"%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" -latest -prerelease -products * -find **\vstest.console.dll`) do if not defined VSTEST_CONSOLE_PATH set "VSTEST_CONSOLE_PATH=%%i"
+)
+
+if not exist "%VSTEST_CONSOLE_PATH%" (
+ echo ERROR: vstest.console.dll not found at:
+ echo %VSTEST_CONSOLE_PATH%
+ echo.
+ echo Set the VSTEST_CONSOLE_PATH environment variable to the correct path.
+ exit /b 1
+)
+
+:: Build the test project
+echo === Building %PROJECT% ===
+dotnet build "%PROJECT%" -c %CONFIG%
+if %ERRORLEVEL% neq 0 (
+ echo ERROR: Build failed.
+ exit /b %ERRORLEVEL%
+)
+
+:: Locate the compiled test assembly
+set "TESTDLL=%REPOROOT%\artifacts\bin\FSharp.Compiler.LanguageServer.IntegrationTests\%CONFIG%\net472\FSharp.Compiler.LanguageServer.IntegrationTests.dll"
+if not exist "%TESTDLL%" (
+ echo ERROR: Test assembly not found at:
+ echo %TESTDLL%
+ exit /b 1
+)
+
+:: Run the tests
+echo.
+echo === Running tests ===
+echo vstest: %VSTEST_CONSOLE_PATH%
+echo assembly: %TESTDLL%
+echo.
+dotnet exec "%VSTEST_CONSOLE_PATH%" "%TESTDLL%" /Settings:"%RUNSETTINGS%" %*
+
+exit /b %ERRORLEVEL%
diff --git a/tests/FSharp.Compiler.LanguageServer.IntegrationTests/SemanticHighlightingTests.cs b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/SemanticHighlightingTests.cs
new file mode 100644
index 00000000000..d9a817de23c
--- /dev/null
+++ b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/SemanticHighlightingTests.cs
@@ -0,0 +1,132 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+
+using Microsoft.Test.Apex.Editor;
+using Microsoft.Test.Apex.VisualStudio.Shell;
+using Microsoft.Test.Apex.VisualStudio.Solution;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace FSharp.Compiler.LanguageServer.IntegrationTests;
+
+[TestClass]
+public class SemanticHighlightingTests : FSharpApexTestBase
+{
+ private const string Code = """
+ namespace Sample
+
+ module Test =
+ let hello name =
+ printfn "Hello %s" name
+ """;
+
+ [TestMethod]
+ [TestCategory("Integration")]
+ public void LspSemanticHighlighting_ClassifiesFunctionsLocalsAndPrintfSpecifier()
+ {
+ SetSemanticClassificationProvider("lsp");
+ RestartVisualStudioHost();
+
+ var solution = VsHost.ObjectModel.Solution;
+ _ = solution.CreateProject(ProjectLanguage.FSharp, ProjectTemplate.Default);
+
+ var document = VsHost.ObjectModel.WindowManager.ActiveDocumentWindowAsTextEditor;
+ var editor = document.Editor;
+
+ editor.Selection.SelectAll();
+ editor.KeyboardCommands.Delete();
+ editor.Edit.InsertTextInBuffer(Code);
+
+ AssertClassification(editor, "Sample", ["namespace name"]);
+ AssertClassification(editor, "Test", ["class name"]);
+ AssertClassification(editor, "hello", ["method name"]);
+ AssertClassification(editor, "name", ["local name"], occurrence: 2);
+ AssertClassification(editor, "printfn", ["method name"]);
+ AssertClassification(editor, "name", ["local name"], occurrence: 3);
+ AssertClassification(editor, "%s", ["string - escape character"]);
+ }
+
+ private void SetSemanticClassificationProvider(string provider)
+ {
+ var settings = VsHost.Get();
+ settings.SetValue("fsharp.semanticClassificationProvider", provider);
+ }
+
+ private static void AssertClassification(
+ ITextEditorTestExtension editor,
+ string text,
+ IReadOnlyCollection expectedClassifications,
+ int occurrence = 1)
+ {
+ var classification = WaitForClassification(editor, text, expectedClassifications, occurrence);
+
+ Assert.IsTrue(
+ IsExpectedClassification(classification.Name, expectedClassifications),
+ $"Expected '{text}' occurrence {occurrence} to have one of classifications " +
+ $"[{string.Join(", ", expectedClassifications)}], but got '{classification.Name}'. " +
+ $"All classifications: {string.Join(", ", editor.Classification.GetAllClassifications().Select(c => $"{c.Contents}:{c.Name}"))}");
+ }
+
+ private static Classification WaitForClassification(
+ ITextEditorTestExtension editor,
+ string text,
+ IReadOnlyCollection expectedClassifications,
+ int occurrence)
+ {
+ var sw = Stopwatch.StartNew();
+ Classification? lastClassification = null;
+
+ while (sw.Elapsed < TimeSpan.FromSeconds(120))
+ {
+ var position = PositionOf(editor.Contents, text, occurrence);
+ lastClassification = editor.Classification.GetClassification(editor.GetPositionFromBufferLocation(position));
+
+ if (lastClassification is not null && IsExpectedClassification(lastClassification.Name, expectedClassifications))
+ {
+ return lastClassification;
+ }
+
+ System.Threading.Thread.Sleep(500);
+ }
+
+ Assert.Fail(
+ $"Timed out waiting for '{text}' occurrence {occurrence} to have one of classifications " +
+ $"[{string.Join(", ", expectedClassifications)}]. Last classification: '{lastClassification?.Name ?? ""}'.");
+
+ throw new InvalidOperationException("Unreachable");
+ }
+
+ private static bool IsExpectedClassification(string actualClassification, IReadOnlyCollection expectedClassifications)
+ {
+ var actualClassificationParts = actualClassification.Split(';');
+
+ return expectedClassifications.Any(expectedClassification =>
+ string.Equals(actualClassification, expectedClassification, StringComparison.Ordinal) ||
+ actualClassificationParts.Any(part => string.Equals(part, expectedClassification, StringComparison.Ordinal)) ||
+ actualClassificationParts.Any(part => string.Equals(part, $"Semantic - {expectedClassification}", StringComparison.Ordinal)) ||
+ actualClassification.Contains($"Semantic - {expectedClassification}", StringComparison.Ordinal));
+ }
+
+ private static int PositionOf(string contents, string text, int occurrence)
+ {
+ var start = 0;
+
+ for (var i = 0; i < occurrence; i++)
+ {
+ var index = contents.IndexOf(text, start, StringComparison.Ordinal);
+ Assert.AreNotEqual(-1, index, $"Could not find occurrence {occurrence} of '{text}'.");
+ start = index + text.Length;
+
+ if (i == occurrence - 1)
+ {
+ return index;
+ }
+ }
+
+ throw new InvalidOperationException("Unreachable");
+ }
+}
diff --git a/tests/FSharp.Compiler.LanguageServer.Tests/CodeActionProtocolTests.fs b/tests/FSharp.Compiler.LanguageServer.Tests/CodeActionProtocolTests.fs
new file mode 100644
index 00000000000..f1c11990e6f
--- /dev/null
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/CodeActionProtocolTests.fs
@@ -0,0 +1,297 @@
+module LanguageServer.CodeActionProtocolTests
+
+open System
+open Xunit
+
+open Microsoft.VisualStudio.LanguageServer.Protocol
+open FSharp.Test.ProjectGeneration.WorkspaceHelpers
+open FSharp.Compiler.CodeAnalysis.Workspace
+open FSharp.Compiler.CodeAnalysis.ProjectSnapshot
+open FSharp.Compiler.LanguageServer
+
+open LanguageServer.ProtocolHelpers
+
+#nowarn "57"
+
+let private initializeCodeActionServer () =
+ let config =
+ { FSharpLanguageServerConfig.Default with
+ EnabledFeatures = { FSharpLanguageServerFeatures.Default with CodeActions = true } }
+ createLanguageServer (FSharpWorkspace()) (Some config)
+
+let private applyAction (source: string) (fileUri: Uri) (action: CodeAction) =
+ let offset (position: Position) =
+ let mutable start = 0
+ for _ in 1 .. position.Line do
+ start <- source.IndexOf('\n', start) + 1
+ start + position.Character
+
+ action.Edit.Changes[fileUri.AbsoluteUri]
+ |> Array.sortByDescending (fun edit -> offset edit.Range.Start)
+ |> Array.fold (fun (text: string) edit ->
+ let start = offset edit.Range.Start
+ text.Remove(start, offset edit.Range.End - start).Insert(start, edit.NewText)) source
+
+/// Helper: open, pull diagnostics, then request code actions on ALL diagnostic lines.
+/// Returns (diagnostics, codeActions) tuple for inspection.
+let openDiagnoseThenRequestActions (client: TestRpcClient) (fileUri: Uri) (content: string) =
+ task {
+ do! openDocument client fileUri content 1
+ let! report = pullDiagnostics client fileUri
+ let diagnostics = report.Items
+ // Request code actions covering all diagnostic lines
+ let! allActions =
+ task {
+ if diagnostics.Length = 0 then
+ return [||]
+ else
+ let minLine = diagnostics |> Array.map (fun d -> d.Range.Start.Line) |> Array.min
+ return! requestCodeActions client fileUri minLine
+ }
+ return diagnostics, allActions
+ }
+
+// ---------------------------------------------------------------
+// Negative case
+// ---------------------------------------------------------------
+
+[]
+let ``Clean code produces no code actions`` () =
+ task {
+ let! client = initializeCodeActionServer ()
+ let content = "module M\nlet x = 1"
+ let fileOnDisk = setupSingleFileProject client content
+ let! _diags, actions = openDiagnoseThenRequestActions client fileOnDisk content
+ Assert.Empty(actions)
+ }
+
+// ---------------------------------------------------------------
+// Phase 1: Simple text fixes
+// ---------------------------------------------------------------
+
+[]
+let ``FS0760 - Add new keyword for IDisposable constructor`` () =
+ task {
+ let content = "module M\nlet x = System.IO.FileStream(\"dummy.txt\", System.IO.FileMode.Create)"
+ let! client = initializeCodeActionServer ()
+ let fileOnDisk = setupSingleFileProject client content
+ let! diags, actions = openDiagnoseThenRequestActions client fileOnDisk content
+ Assert.True(diags.Length > 0, "Expected diagnostics")
+ Assert.Contains(actions, fun a -> a.Title = "Add 'new' keyword")
+ }
+
+[]
+let ``FS0043 - Replace == with =`` () =
+ task {
+ let content = "module M\nlet x = 1 == 2"
+ let! client = initializeCodeActionServer ()
+ let fileOnDisk = setupSingleFileProject client content
+ let! diags, actions = openDiagnoseThenRequestActions client fileOnDisk content
+ Assert.True(diags |> Array.exists (fun d -> d.Code.Value.Second = "FS0043"), "Expected FS0043")
+ Assert.Contains(actions, fun a -> a.Title = "Use '=' for equality")
+ }
+
+[]
+let ``FS0043 - Replace != with <>`` () =
+ task {
+ let content = "module M\nlet x = 1 != 2"
+ let! client = initializeCodeActionServer ()
+ let fileOnDisk = setupSingleFileProject client content
+ let! diags, actions = openDiagnoseThenRequestActions client fileOnDisk content
+ Assert.True(diags |> Array.exists (fun d -> d.Code.Value.Second = "FS0043"), "Expected FS0043")
+ Assert.Contains(actions, fun a -> a.Title = "Use '<>' for inequality")
+ }
+
+[]
+let ``FS0673 - Add instance member parameter`` () =
+ task {
+ let content = "module M\ntype T() =\n member Foo() = ()"
+ let! client = initializeCodeActionServer ()
+ let fileOnDisk = setupSingleFileProject client content
+ let! diags, actions = openDiagnoseThenRequestActions client fileOnDisk content
+ Assert.True(diags |> Array.exists (fun d -> d.Code.Value.Second = "FS0673"), "Expected FS0673")
+ Assert.Contains(actions, fun a -> a.Title = "Add missing instance member parameter")
+ }
+
+// ---------------------------------------------------------------
+// Phase 2: Parse-tree fixes
+// ---------------------------------------------------------------
+
+[]
+[]
+[]
+[]
+let ``FS0010 - Add fun keyword before argument`` (argument: string) =
+ task {
+ let content = $"module M\nlet f = {argument} -> 1"
+ let! client = initializeCodeActionServer ()
+ let fileOnDisk = setupSingleFileProject client content
+ let! diags, actions = openDiagnoseThenRequestActions client fileOnDisk content
+ Assert.True(diags |> Array.exists (fun d -> d.Code.Value.Second = "FS0010"), "Expected FS0010")
+ let action = actions |> Array.find (fun a -> a.Title = "Add 'fun' keyword")
+ Assert.Equal($"module M\nlet f = fun {argument} -> 1", applyAction content fileOnDisk action)
+ }
+
+[]
+[]
+[]
+let ``FS0020 - Mutation requires a writable symbol`` (body: string) (expected: string) =
+ task {
+ let content = "module M\n" + body
+ let! client = initializeCodeActionServer ()
+ let fileUri = setupSingleFileProject client content
+ let! diags, actions = openDiagnoseThenRequestActions client fileUri content
+ Assert.True(diags |> Array.exists (fun d -> d.Code.Value.Second = "FS0020"), "Expected FS0020")
+ let action = actions |> Array.find (fun a -> a.Title = "Use '<-' for mutation")
+ Assert.Equal("module M\n" + expected, applyAction content fileUri action)
+ }
+
+[]
+[]
+[ List.map (fun v -> v = 1)")>]
+[ List.map (fun v -> v = 1)")>]
+let ``FS0020 - Does not mutate immutable bindings or nested equality`` (body: string) =
+ task {
+ let content = "module M\n" + body
+ let! client = initializeCodeActionServer ()
+ let fileUri = setupSingleFileProject client content
+ let! diags, actions = openDiagnoseThenRequestActions client fileUri content
+ Assert.True(diags |> Array.exists (fun d -> d.Code.Value.Second = "FS0020"), "Expected FS0020")
+ Assert.DoesNotContain(actions, fun a -> a.Title = "Use '<-' for mutation")
+ }
+
+[]
+[]
+[]
+[]
+let ``FS0366 - Implement only missing interface members preserving newlines`` (partial: bool) (newline: string) =
+ task {
+ let header = "module M\ntype I =\n abstract A: int\n abstract B: int\ntype C() =\n interface I"
+ let content = (header + if partial then " with\n member _.A = 1 // existing member" else "").Replace("\n", newline)
+ let! client = initializeCodeActionServer ()
+ let fileUri = setupSingleFileProject client content
+ let! diags, actions = openDiagnoseThenRequestActions client fileUri content
+ Assert.True(diags |> Array.exists (fun d -> d.Code.Value.Second = "FS0366"), "Expected FS0366")
+ let action = actions |> Array.find (fun a -> a.Title = "Implement interface")
+ let memberStub name = $"\n member this.{name}: int = \n raise (System.NotImplementedException())"
+ let expected =
+ header + " with"
+ + (if partial then "\n member _.A = 1 // existing member" else memberStub "A")
+ + memberStub "B"
+ Assert.Equal(expected.Replace("\n", newline), applyAction content fileUri action)
+ }
+
+[]
+[]
+[]
+let ``FS3218 - Rename parameter and body uses to match signature`` (signatureName: string) (implementationName: string) =
+ task {
+ let! client = initializeCodeActionServer ()
+ let basePath = System.IO.Path.Combine(Environment.CurrentDirectory, $"CodeActionSignature{Guid.NewGuid():N}")
+ let signatureUri = Uri(basePath + ".fsi")
+ let fileUri = Uri(basePath + ".fs")
+ let signature = $"module M\nval f: {signatureName}: int -> int"
+ let content = $"module M\nlet f {implementationName} = {implementationName} + 1"
+ client.Workspace.Files.Open(signatureUri, signature)
+ client.Workspace.Files.Open(fileUri, content)
+ client.Workspace.Projects.AddOrUpdate(ProjectConfig.Create(), [signatureUri.LocalPath; fileUri.LocalPath]) |> ignore
+ do! openDocument client signatureUri signature 1
+ let! diags, actions = openDiagnoseThenRequestActions client fileUri content
+ Assert.True(diags |> Array.exists (fun d -> d.Code.Value.Second = "FS3218"), $"Expected FS3218: %A{diags}")
+ let action = actions |> Array.find (fun a -> a.Title.StartsWith("Replace with '", StringComparison.Ordinal))
+ Assert.Equal($"module M\nlet f {signatureName} = {signatureName} + 1", applyAction content fileUri action)
+ }
+
+[]
+let ``FS0039 - Make outer binding recursive`` () =
+ task {
+ let content = "module M\nlet f x = if x <= 0 then 1 else f (x - 1)"
+ let! client = initializeCodeActionServer ()
+ let fileOnDisk = setupSingleFileProject client content
+ let! diags, actions = openDiagnoseThenRequestActions client fileOnDisk content
+ Assert.True(diags |> Array.exists (fun d -> d.Code.Value.Second = "FS0039"), "Expected FS0039")
+ Assert.Contains(actions, fun a -> a.Title.Contains("recursive"))
+ }
+
+[]
+let ``FS0027 - Offers make mutable code action`` () =
+ task {
+ // FS0027: "This value is not mutable"
+ let content = "module M\nlet x = 1\nx <- 2"
+ let! client = initializeCodeActionServer ()
+ let fileOnDisk = setupSingleFileProject client content
+ let! diags, actions = openDiagnoseThenRequestActions client fileOnDisk content
+ Assert.True(diags |> Array.exists (fun d -> d.Code.Value.Second = "FS0027"), "Expected FS0027")
+ Assert.Contains(actions, fun a -> a.Title.Contains("mutable"))
+ }
+
+// ---------------------------------------------------------------
+// Code action edit content verification
+// ---------------------------------------------------------------
+
+[]
+let ``FS0043 code action edit replaces == with =`` () =
+ task {
+ let content = "module M\nlet x = 1 == 2"
+ let! client = initializeCodeActionServer ()
+ let fileOnDisk = setupSingleFileProject client content
+ let! _diags, actions = openDiagnoseThenRequestActions client fileOnDisk content
+ let action = actions |> Array.find (fun a -> a.Title = "Use '=' for equality")
+ Assert.NotNull(action.Edit)
+ let edits = action.Edit.Changes |> Seq.head |> fun kv -> kv.Value
+ Assert.Contains(edits, fun e -> e.NewText = "=")
+ }
+
+[]
+let ``FS0760 code action edit inserts new keyword`` () =
+ task {
+ let content = "module M\nlet x = System.IO.FileStream(\"dummy.txt\", System.IO.FileMode.Create)"
+ let! client = initializeCodeActionServer ()
+ let fileOnDisk = setupSingleFileProject client content
+ let! _diags, actions = openDiagnoseThenRequestActions client fileOnDisk content
+ let action = actions |> Array.find (fun a -> a.Title = "Add 'new' keyword")
+ Assert.NotNull(action.Edit)
+ let edits = action.Edit.Changes |> Seq.head |> fun kv -> kv.Value
+ Assert.Contains(edits, fun e -> e.NewText.StartsWith("new "))
+ }
+
+// ---------------------------------------------------------------
+// Multiple code actions for same diagnostic code
+// ---------------------------------------------------------------
+
+[]
+let ``FS0043 produces both == and != fixes for respective operators`` () =
+ task {
+ // == fix
+ let content1 = "module M\nlet x = 1 == 2"
+ let! client1 = initializeCodeActionServer ()
+ let file1 = setupSingleFileProject client1 content1
+ let! _diags1, actions1 = openDiagnoseThenRequestActions client1 file1 content1
+ Assert.Contains(actions1, fun a -> a.Title = "Use '=' for equality")
+
+ // != fix
+ let content2 = "module M\nlet x = 1 != 2"
+ let! client2 = initializeCodeActionServer ()
+ let file2 = setupSingleFileProject client2 content2
+ let! _diags2, actions2 = openDiagnoseThenRequestActions client2 file2 content2
+ Assert.Contains(actions2, fun a -> a.Title = "Use '<>' for inequality")
+ }
+
+// ---------------------------------------------------------------
+// Code actions capability
+// ---------------------------------------------------------------
+
+[]
+let ``Server with code actions disabled does not advertise code action capabilities`` () =
+ task {
+ let! client = initializeLanguageServer None
+ Assert.False(client.Capabilities.CodeActionProvider.HasValue)
+ }
+
+[]
+let ``Server with code actions enabled advertises code action capabilities`` () =
+ task {
+ let! client = initializeCodeActionServer ()
+ Assert.True(client.Capabilities.CodeActionProvider.HasValue)
+ }
diff --git a/tests/FSharp.Compiler.LanguageServer.Tests/DiagnosticsProtocolTests.fs b/tests/FSharp.Compiler.LanguageServer.Tests/DiagnosticsProtocolTests.fs
new file mode 100644
index 00000000000..9534fa8e9c1
--- /dev/null
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/DiagnosticsProtocolTests.fs
@@ -0,0 +1,345 @@
+module LanguageServer.DiagnosticsProtocolTests
+
+open System
+open Xunit
+
+open Microsoft.VisualStudio.LanguageServer.Protocol
+open FSharp.Test.ProjectGeneration.WorkspaceHelpers
+open FSharp.Compiler.CodeAnalysis.Workspace
+open FSharp.Compiler.CodeAnalysis.ProjectSnapshot
+open FSharp.Compiler.LanguageServer
+
+open LanguageServer.ProtocolHelpers
+
+#nowarn "57"
+
+[]
+[]
+[]
+let ``Erroneous code produces Error-severity diagnostics`` (content: string) =
+ task {
+ let! client = initializeLanguageServer None
+ let fileOnDisk = setupSingleFileProject client content
+ let! diagnostics = openAndPullDiagnostics client fileOnDisk content
+ Assert.True(diagnostics.Length > 0, $"Expected at least one diagnostic for: {content}")
+ Assert.True(diagnostics |> Array.exists (fun d -> d.Severity.HasValue && d.Severity.Value = DiagnosticSeverity.Error),
+ "Expected at least one Error-severity diagnostic")
+ }
+
+[]
+let ``Diagnostics report warnings with Warning severity`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let content = "[]\nlet f () = 1\nlet x = f ()"
+ let fileOnDisk = setupSingleFileProject client content
+ let! diagnostics = openAndPullDiagnostics client fileOnDisk content
+ let warnings = diagnostics |> Array.filter (fun d -> d.Severity.HasValue && d.Severity.Value = DiagnosticSeverity.Warning)
+ Assert.True(warnings.Length > 0, "Expected at least one warning diagnostic")
+ }
+
+[]
+let ``Diagnostics have FS-prefixed codes, LSP-prefixed messages, and valid ranges`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let fileOnDisk = setupSingleFileProject client notMutableCode
+ let! diagnostics = openAndPullDiagnostics client fileOnDisk notMutableCode
+ Assert.True(diagnostics.Length > 0, "Expected diagnostics for code with mutability error")
+ Assert.All(diagnostics, fun d ->
+ let code = d.Code.Value.Second
+ Assert.StartsWith("FS", code)
+ Assert.True(code.Length > 2 && code.Substring(2) |> Seq.forall System.Char.IsDigit,
+ $"Error code '{code}' should have format 'FS' followed by digits")
+ Assert.StartsWith("LSP:", d.Message)
+ Assert.True(
+ d.Range.Start.Line < d.Range.End.Line ||
+ (d.Range.Start.Line = d.Range.End.Line && d.Range.Start.Character <= d.Range.End.Character),
+ $"Invalid range: start ({d.Range.Start.Line},{d.Range.Start.Character}) should not be after end ({d.Range.End.Line},{d.Range.End.Character})"))
+ let mutabilityError = diagnostics |> Array.tryFind (fun d -> d.Message.Contains("mutable"))
+ Assert.True(mutabilityError.IsSome, "Expected a diagnostic mentioning 'mutable'")
+ Assert.Equal(1, mutabilityError.Value.Range.Start.Line)
+ }
+
+[]
+[]
+[]
+let ``Valid content produces no diagnostics`` (content: string) =
+ task {
+ let! client = initializeLanguageServer None
+ let fileOnDisk = setupSingleFileProject client content
+ let! diagnostics = openAndPullDiagnostics client fileOnDisk content
+ Assert.Equal(0, diagnostics.Length)
+ }
+
+[]
+let ``Diagnostics appear after introducing an error`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let fileOnDisk = setupSingleFileProject client cleanCode
+ let! cleanDiags = openAndPullDiagnostics client fileOnDisk cleanCode
+ Assert.Equal(0, cleanDiags.Length)
+
+ do! changeDocument client fileOnDisk notMutableCode 2
+ let! brokenReport = pullDiagnostics client fileOnDisk
+ Assert.True(brokenReport.Items.Length > 0, "Expected diagnostics after introducing an error")
+ }
+
+[]
+let ``Diagnostics disappear after fixing an error`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let fileOnDisk = setupSingleFileProject client notMutableCode
+ let! brokenDiags = openAndPullDiagnostics client fileOnDisk notMutableCode
+ Assert.True(brokenDiags.Length > 0, "Expected diagnostics for code with mutability error")
+
+ let fixedContent = "let mutable x = 1\nx <- 2"
+ do! changeDocument client fileOnDisk fixedContent 2
+ let! fixedReport = pullDiagnostics client fileOnDisk
+ Assert.Equal(0, fixedReport.Items.Length)
+ }
+
+[]
+let ``Diagnostics revert to disk content after close`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let fileOnDisk = setupSingleFileProject client cleanCode
+ do! openDocument client fileOnDisk cleanCode 1
+
+ do! changeDocument client fileOnDisk notMutableCode 2
+ let! editedReport = pullDiagnostics client fileOnDisk
+ Assert.True(editedReport.Items.Length > 0, "Should have diagnostics after editing to broken state")
+
+ do! closeDocument client fileOnDisk
+ let! closedReport = pullDiagnostics client fileOnDisk
+ Assert.Equal(0, closedReport.Items.Length)
+ }
+
+[]
+let ``Multiple diagnostics reported for multiple errors`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let content = "let x = 1\nx <- 2\nlet y = \"hello\"\ny <- \"world\""
+ let fileOnDisk = setupSingleFileProject client content
+ let! diagnostics = openAndPullDiagnostics client fileOnDisk content
+ Assert.True(diagnostics.Length >= 2, $"Expected at least 2 diagnostics but got {diagnostics.Length}")
+ }
+
+[]
+let ``Unresolved identifier produces diagnostics`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let content = "let x = undefinedFunction 42"
+ let fileOnDisk = setupSingleFileProject client content
+ let! diagnostics = openAndPullDiagnostics client fileOnDisk content
+ Assert.True(diagnostics.Length > 0, "Expected at least one diagnostic for erroneous code")
+ Assert.True(diagnostics |> Array.exists (fun d -> d.Message.Contains("is not defined")),
+ "Expected diagnostic about undefined identifier")
+ }
+
+[]
+let ``Diagnostics update correctly through multiple edits`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let fileOnDisk = setupSingleFileProject client cleanCode
+ let! d1 = openAndPullDiagnostics client fileOnDisk cleanCode
+ Assert.Equal(0, d1.Length)
+
+ do! changeDocument client fileOnDisk notMutableCode 2
+ let! r2 = pullDiagnostics client fileOnDisk
+ Assert.True(r2.Items.Length > 0, "Expected diagnostics after introducing mutability error")
+
+ do! changeDocument client fileOnDisk "let mutable x = 1\nx <- 2" 3
+ let! r3 = pullDiagnostics client fileOnDisk
+ Assert.Equal(0, r3.Items.Length)
+
+ do! changeDocument client fileOnDisk "let x: int = \"hello\"" 4
+ let! r4 = pullDiagnostics client fileOnDisk
+ Assert.True(r4.Items.Length > 0, "Expected diagnostics after introducing a type mismatch")
+ }
+
+[]
+let ``Diagnostics for file not in any project`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let fileOnDisk = sourceFileOnDisk cleanCode
+ do! openDocument client fileOnDisk cleanCode 1
+ let! response = pullDiagnosticResponse client fileOnDisk
+ let report = response.First
+ Assert.NotNull(report.Items)
+ Assert.Equal(0, report.Items.Length)
+ }
+
+[]
+let ``Server with diagnostics disabled does not advertise diagnostic capabilities`` () =
+ task {
+ let config = { FSharpLanguageServerConfig.Default with EnabledFeatures = { FSharpLanguageServerFeatures.Default with Diagnostics = false; CodeActions = false } }
+ let! client = createLanguageServer (FSharpWorkspace()) (Some config)
+ Assert.Null(client.Capabilities.DiagnosticOptions)
+ }
+
+[]
+let ``Diagnostics for specific file in multi-file project`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let content1 = "module A\nlet x = 1"
+ let content2 = "module B\nlet y = A.x + 1"
+ let file1 = sourceFileOnDisk content1
+ let file2 = sourceFileOnDisk content2
+ let _pid = client.Workspace.Projects.AddOrUpdate(ProjectConfig.Create(), [ file1.LocalPath; file2.LocalPath ])
+ let! diagnostics = openAndPullDiagnostics client file2 content2
+ Assert.Equal(0, diagnostics.Length)
+ do! openDocument client file1 "module A" 1
+ let! diagsAfterBreak = pullDiagnostics client file2
+ Assert.True(diagsAfterBreak.Items.Length > 0, "Expected diagnostics in file2 after removing A.x from file1")
+ }
+
+[]
+let ``Diagnostic response has Items and a valid ResultId`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let fileOnDisk = setupSingleFileProject client cleanCode
+ do! openDocument client fileOnDisk cleanCode 1
+ let! response = pullDiagnosticResponse client fileOnDisk
+ let report = response.First
+ Assert.NotNull(report.Items)
+ Assert.NotNull(report.ResultId)
+ Assert.False(String.IsNullOrWhiteSpace(report.ResultId), "ResultId should not be empty")
+ }
+
+[]
+let ``Diagnostics work correctly after open-close-reopen cycle`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let fileOnDisk = setupSingleFileProject client notMutableCode
+ let! d1 = openAndPullDiagnostics client fileOnDisk notMutableCode
+ Assert.True(d1.Length > 0, "Expected diagnostics for code with mutability error")
+
+ do! closeDocument client fileOnDisk
+
+ do! openDocument client fileOnDisk notMutableCode 1
+ let! r2 = pullDiagnostics client fileOnDisk
+ Assert.True(r2.Items.Length > 0, "Expected diagnostics after reopening file with errors")
+ Assert.Equal(d1.Length, r2.Items.Length)
+ }
+
+[]
+let ``Diagnostics work for file with many errors`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let lines = [| for i in 1..10 -> $"let x{i} = undefinedVar{i}" |]
+ let content = lines |> String.concat "\n"
+ let fileOnDisk = setupSingleFileProject client content
+ let! diagnostics = openAndPullDiagnostics client fileOnDisk content
+ Assert.True(diagnostics.Length >= 5, $"Expected at least 5 diagnostics for 10 error lines but got {diagnostics.Length}")
+ }
+
+[]
+let ``All diagnostics have a valid LSP severity`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let fileOnDisk = setupSingleFileProject client notMutableCode
+ let! diagnostics = openAndPullDiagnostics client fileOnDisk notMutableCode
+ Assert.True(diagnostics.Length > 0, "Expected at least one diagnostic")
+ Assert.All(diagnostics, fun d ->
+ Assert.True(d.Severity.HasValue, "Every diagnostic should have a severity")
+ let sev = d.Severity.Value
+ Assert.True(
+ sev = DiagnosticSeverity.Error || sev = DiagnosticSeverity.Warning ||
+ sev = DiagnosticSeverity.Information || sev = DiagnosticSeverity.Hint,
+ $"Unexpected severity value: {sev}"))
+ }
+
+[]
+let ``ResultId changes when diagnostics change`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let fileOnDisk = setupSingleFileProject client cleanCode
+ do! openDocument client fileOnDisk cleanCode 1
+ let! report1 = pullDiagnostics client fileOnDisk
+ Assert.False(String.IsNullOrWhiteSpace(report1.ResultId), "First ResultId should not be empty")
+
+ do! changeDocument client fileOnDisk notMutableCode 2
+ let! report2 = pullDiagnostics client fileOnDisk
+ Assert.False(String.IsNullOrWhiteSpace(report2.ResultId), "Second ResultId should not be empty")
+ Assert.NotEqual(report1.ResultId, report2.ResultId)
+ }
+
+[]
+let ``Diagnostics include project information`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let projectName = "MyTestProject"
+ let fileOnDisk = setupMultiProjectFile client notMutableCode [ projectName ]
+ let! response = openAndPullVsDiagnosticsRaw client fileOnDisk notMutableCode
+ let items = getVsDiagnosticItems response
+ Assert.True(items.Length > 0, "Expected at least one diagnostic")
+ for item in items do
+ let projects = getVsProjects item
+ Assert.NotNull(projects)
+ Assert.True(projects.Length > 0, "Expected at least one project in _vs_projects")
+ let projName = getVsProjectName projects[0]
+ Assert.Equal(projectName, projName)
+ }
+
+[]
+let ``Diagnostics project identifier matches project context id format`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let projectName = "IdentifierProject"
+ let fileOnDisk = setupMultiProjectFile client notMutableCode [ projectName ]
+ let! response = openAndPullVsDiagnosticsRaw client fileOnDisk notMutableCode
+ let items = getVsDiagnosticItems response
+ Assert.True(items.Length > 0, "Expected at least one diagnostic")
+ let! contexts = getProjectContexts client fileOnDisk
+ Assert.True(contexts.ProjectContexts.Length > 0, "Expected at least one project context")
+
+ let contextId = contexts.ProjectContexts[0].Id
+ let firstItem = items[0]
+ let vsProjects = getVsProjects firstItem
+ let diagProjectId = getVsProjectIdentifier vsProjects[0]
+ Assert.Equal(contextId, diagProjectId)
+ }
+
+[]
+let ``Diagnostics for file in multiple projects include all project names`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let content = "module Shared\nlet x: int = \"hello\""
+ let fileOnDisk = setupMultiProjectFile client content [ "ProjectAlpha"; "ProjectBeta" ]
+ let! response = openAndPullVsDiagnosticsRaw client fileOnDisk content
+ let items = getVsDiagnosticItems response
+ Assert.True(items.Length > 0, "Expected at least one diagnostic")
+ let firstItem = items[0]
+ let vsProjects = getVsProjects firstItem
+ let projNames = [| for p in vsProjects -> getVsProjectName p |] |> Array.sort
+ Assert.Equal(2, projNames.Length)
+ Assert.Equal("ProjectAlpha", projNames[0])
+ Assert.Equal("ProjectBeta", projNames[1])
+ }
+
+[]
+let ``VS diagnostics for file not in any project return empty items`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let fileOnDisk = sourceFileOnDisk notMutableCode
+ let! response = openAndPullVsDiagnosticsRaw client fileOnDisk notMutableCode
+ let items = getVsDiagnosticItems response
+ Assert.Equal(0, items.Length)
+ }
+
+[]
+let ``Typed deserialization produces VSDiagnostic instances with project info`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let projectName = "TypedDeserProject"
+ let fileOnDisk = setupMultiProjectFile client notMutableCode [ projectName ]
+ do! openDocument client fileOnDisk notMutableCode 1
+ let! response = pullDiagnosticResponse client fileOnDisk
+ let report = response.First
+ Assert.True(report.Items.Length > 0, "Expected at least one diagnostic")
+ for diag in report.Items do
+ let vsDiag = Assert.IsType(diag)
+ Assert.NotNull(vsDiag.Projects)
+ Assert.True(vsDiag.Projects.Length > 0, "VSDiagnostic should have project information")
+ Assert.Equal(projectName, vsDiag.Projects[0].ProjectName)
+ }
diff --git a/tests/FSharp.Compiler.LanguageServer.Tests/FSharp.Compiler.LanguageServer.Tests.fsproj b/tests/FSharp.Compiler.LanguageServer.Tests/FSharp.Compiler.LanguageServer.Tests.fsproj
index 90993cf5b32..a6639b8b3e5 100644
--- a/tests/FSharp.Compiler.LanguageServer.Tests/FSharp.Compiler.LanguageServer.Tests.fsproj
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/FSharp.Compiler.LanguageServer.Tests.fsproj
@@ -16,7 +16,15 @@
+
+
+
+
+
+
+
+
@@ -26,6 +34,7 @@
+
diff --git a/tests/FSharp.Compiler.LanguageServer.Tests/ProjectContextSelectionTests.fs b/tests/FSharp.Compiler.LanguageServer.Tests/ProjectContextSelectionTests.fs
new file mode 100644
index 00000000000..ca7e5530690
--- /dev/null
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/ProjectContextSelectionTests.fs
@@ -0,0 +1,118 @@
+module LanguageServer.ProjectContextSelectionTests
+
+open System
+open Xunit
+open Microsoft.VisualStudio.LanguageServer.Protocol
+open FSharp.Compiler.CodeAnalysis.ProjectSnapshot
+open FSharp.Compiler.LanguageServer
+open LanguageServer.ProtocolHelpers
+
+#nowarn "57"
+
+[]
+let ``makeProjectContextId and tryParseProjectContextId round-trip`` () =
+ let identifier = FSharpProjectIdentifier(@"C:\repo\proj.fsproj", @"C:\repo\bin\net8.0\proj.dll")
+ let id = Utils.makeProjectContextId identifier
+ Assert.Equal(Some identifier, Utils.tryParseProjectContextId id)
+
+[]
+let ``makeProjectContextId distinguishes TFMs that share the project file`` () =
+ let net8 = FSharpProjectIdentifier(@"C:\repo\proj.fsproj", @"C:\repo\bin\net8.0\proj.dll")
+ let net9 = FSharpProjectIdentifier(@"C:\repo\proj.fsproj", @"C:\repo\bin\net9.0\proj.dll")
+ Assert.NotEqual(Utils.makeProjectContextId net8, Utils.makeProjectContextId net9)
+
+[]
+[]
+[]
+[]
+[]
+let ``tryParseProjectContextId returns None for malformed input`` (contextId: string) =
+ Assert.Equal(None, Utils.tryParseProjectContextId contextId)
+
+[]
+let ``tryGetProjectContext returns None for plain TextDocumentIdentifier`` () =
+ let doc = TextDocumentIdentifier(Uri = Uri("file:///c:/foo.fs"))
+ Assert.Equal(None, Utils.tryGetProjectContext doc)
+
+[]
+let ``tryGetProjectContext returns None when VS ProjectContext is null`` () =
+ let doc = VSTextDocumentIdentifier(Uri = Uri("file:///c:/foo.fs"))
+ Assert.Equal(None, Utils.tryGetProjectContext doc)
+
+[]
+let ``tryGetProjectContext extracts identifier from VS ProjectContext`` () =
+ let identifier = FSharpProjectIdentifier(@"C:\repo\proj.fsproj", @"C:\repo\bin\net8.0\proj.dll")
+
+ let doc =
+ VSTextDocumentIdentifier(
+ Uri = Uri("file:///c:/foo.fs"),
+ ProjectContext = VSProjectContext(Id = Utils.makeProjectContextId identifier, Label = "proj", Kind = VSProjectKind.FSharp)
+ )
+
+ Assert.Equal(Some identifier, Utils.tryGetProjectContext doc)
+
+[]
+let ``GetProjectContexts returns distinct ids for multi-target-like projects`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let content = "module M\nlet x = 1"
+ let fileOnDisk, _, _ = setupMultiTargetLikeProject client content "multitarget.fsproj" "TFM_A" "TFM_B"
+ do! openDocument client fileOnDisk content 1
+
+ let! result = getProjectContexts client fileOnDisk
+ Assert.Equal(2, result.ProjectContexts.Length)
+ let ids = result.ProjectContexts |> Array.map (fun c -> c.Id) |> Array.distinct
+ Assert.Equal(2, ids.Length)
+ }
+
+let private multiTargetSource =
+ """module M
+#if TFM_A
+let a: int = "not an int"
+#else
+let b: string = 1
+#endif
+"""
+
+let private diagnosticStartLines (report: RelatedFullDocumentDiagnosticReport) =
+ report.Items |> Array.map (fun diagnostic -> diagnostic.Range.Start.Line)
+
+[]
+let ``Diagnostics differ when client selects different project context`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let fileOnDisk, pidA, pidB = setupMultiTargetLikeProject client multiTargetSource "multitarget.fsproj" "TFM_A" "TFM_B"
+ do! openDocument client fileOnDisk multiTargetSource 1
+
+ let ctxA = VSProjectContext(Id = Utils.makeProjectContextId pidA, Label = "multitarget", Kind = VSProjectKind.FSharp)
+ let ctxB = VSProjectContext(Id = Utils.makeProjectContextId pidB, Label = "multitarget", Kind = VSProjectKind.FSharp)
+ let! reportA = pullDiagnosticsInContext client fileOnDisk ctxA
+ let! reportB = pullDiagnosticsInContext client fileOnDisk ctxB
+
+ let linesA = diagnosticStartLines reportA
+ let linesB = diagnosticStartLines reportB
+ Assert.Contains(2, linesA)
+ Assert.DoesNotContain(4, linesA)
+ Assert.Contains(4, linesB)
+ Assert.DoesNotContain(2, linesB)
+ }
+
+[]
+let ``Diagnostics with unknown project context fall back to first project`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let fileOnDisk, _, _ = setupMultiTargetLikeProject client multiTargetSource "multitarget.fsproj" "TFM_A" "TFM_B"
+ do! openDocument client fileOnDisk multiTargetSource 1
+
+ let bogusContext =
+ VSProjectContext(
+ Id = Utils.makeProjectContextId (FSharpProjectIdentifier("ghost.fsproj", "ghost.dll")),
+ Label = "ghost",
+ Kind = VSProjectKind.FSharp
+ )
+
+ let! defaultReport = pullDiagnostics client fileOnDisk
+ let! report = pullDiagnosticsInContext client fileOnDisk bogusContext
+ Assert.NotEmpty(defaultReport.Items)
+ Assert.Equal(diagnosticStartLines defaultReport, diagnosticStartLines report)
+ }
diff --git a/tests/FSharp.Compiler.LanguageServer.Tests/ProjectContextsProtocolTests.fs b/tests/FSharp.Compiler.LanguageServer.Tests/ProjectContextsProtocolTests.fs
new file mode 100644
index 00000000000..eba3563ae9f
--- /dev/null
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/ProjectContextsProtocolTests.fs
@@ -0,0 +1,81 @@
+module LanguageServer.ProjectContextsProtocolTests
+
+open System
+open Xunit
+
+open Microsoft.VisualStudio.LanguageServer.Protocol
+open FSharp.Test.ProjectGeneration.WorkspaceHelpers
+open FSharp.Compiler.CodeAnalysis.ProjectSnapshot
+
+open LanguageServer.ProtocolHelpers
+
+#nowarn "57"
+
+[]
+let ``GetProjectContexts response format matches VS expectations`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let projectName = "TestProject"
+ let fileOnDisk = setupMultiProjectFile client cleanCode [ projectName ]
+ do! openDocument client fileOnDisk cleanCode 1
+
+ let! result = getProjectContexts client fileOnDisk
+ Assert.NotNull(result)
+ Assert.Equal(1, result.ProjectContexts.Length)
+ Assert.Equal(0, result.DefaultIndex)
+
+ let ctx = result.ProjectContexts[0]
+ Assert.Equal(projectName, ctx.Label)
+ Assert.Equal(VSProjectKind.FSharp, ctx.Kind)
+ Assert.False(String.IsNullOrWhiteSpace(ctx.Id), "Project context Id should not be empty")
+ Assert.Contains("|", ctx.Id)
+ let parts = ctx.Id.Split('|', 2)
+ Assert.True(Guid.TryParse(parts[0]) |> fst, $"First part of Id should be a valid GUID, got: '{parts[0]}'")
+ Assert.False(String.IsNullOrWhiteSpace(parts[1]), "Second part of Id (debug name) should not be empty")
+ }
+
+[]
+let ``GetProjectContexts returns empty list for file not in any project`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let fileOnDisk = sourceFileOnDisk cleanCode
+ do! openDocument client fileOnDisk cleanCode 1
+
+ let! result = getProjectContexts client fileOnDisk
+ Assert.NotNull(result)
+ Assert.NotNull(result.ProjectContexts)
+ Assert.Equal(0, result.ProjectContexts.Length)
+ Assert.Equal(0, result.DefaultIndex)
+ }
+
+[]
+let ``GetProjectContexts returns multiple FSharp contexts with distinct labels and ids for file in multiple projects`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let fileOnDisk = setupMultiProjectFile client sharedModuleContent [ "ProjectA"; "ProjectB" ]
+
+ do! openDocument client fileOnDisk sharedModuleContent 1
+
+ let! result = getProjectContexts client fileOnDisk
+ Assert.NotNull(result)
+ Assert.True(result.ProjectContexts.Length >= 2, $"Expected at least 2 project contexts but got {result.ProjectContexts.Length}")
+ Assert.Equal(0, result.DefaultIndex)
+ Assert.All(result.ProjectContexts, fun c -> Assert.Equal(VSProjectKind.FSharp, c.Kind))
+ let labels = result.ProjectContexts |> Array.map (fun c -> c.Label) |> Array.distinct
+ Assert.Equal(result.ProjectContexts.Length, labels.Length)
+ let ids = result.ProjectContexts |> Array.map (fun c -> c.Id) |> Array.distinct
+ Assert.Equal(result.ProjectContexts.Length, ids.Length)
+ }
+
+[]
+let ``GetProjectContexts returns results for file not opened via didOpen`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let fileOnDisk = setupSingleFileProject client cleanCode
+
+ let! result = getProjectContexts client fileOnDisk
+ Assert.NotNull(result)
+ Assert.NotNull(result.ProjectContexts)
+ Assert.Equal(1, result.ProjectContexts.Length)
+ Assert.Equal(VSProjectKind.FSharp, result.ProjectContexts[0].Kind)
+ }
diff --git a/tests/FSharp.Compiler.LanguageServer.Tests/Protocol.fs b/tests/FSharp.Compiler.LanguageServer.Tests/Protocol.fs
index 9072fbfb460..224d8260844 100644
--- a/tests/FSharp.Compiler.LanguageServer.Tests/Protocol.fs
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/Protocol.fs
@@ -1,69 +1,55 @@
module LanguageServer.Protocol
open System
+open System.Text.Json
open Xunit
open FSharp.Compiler.LanguageServer
-open StreamJsonRpc
-open System.IO
-open System.Diagnostics
-
open Microsoft.VisualStudio.LanguageServer.Protocol
-open Nerdbank.Streams
-
-open FSharp.Test.ProjectGeneration.WorkspaceHelpers
open FSharp.Compiler.CodeAnalysis.Workspace
-open FSharp.Compiler.CodeAnalysis.ProjectSnapshot
-
-#nowarn "57"
-
-type TestRpcClient(jsonRpc, rpcTrace, workspace, initializeResult: InitializeResult) =
-
- member val JsonRpc = jsonRpc
- member val RpcTraceWriter = rpcTrace
- member val Workspace = workspace
- member val Capabilities = initializeResult.Capabilities
-
- member _.RpcTrace = rpcTrace.ToString()
-
-let initializeLanguageServer (workspace) =
-
- let workspace = defaultArg workspace (FSharpWorkspace())
-
- // Create a StringWriter to capture the output
- let rpcTrace = new StringWriter()
- let (inputStream, outputStream), server = FSharpLanguageServer.Create(workspace)
+open LanguageServer.ProtocolHelpers
- let formatter = new JsonMessageFormatter()
-
- let messageHandler =
- new HeaderDelimitedMessageHandler(inputStream, outputStream, formatter)
-
- let jsonRpc = new JsonRpc(messageHandler)
-
- // Create a new TraceListener with the StringWriter
- let listener = new TextWriterTraceListener(rpcTrace)
-
- // Add the listener to the JsonRpc TraceSource
- server.JsonRpc.TraceSource.Listeners.Add(listener) |> ignore
-
- // Set the TraceLevel to Information to get all informational, warning and error messages
- server.JsonRpc.TraceSource.Switch.Level <- SourceLevels.All
-
- let initializeParams =
- InitializeParams(
- ProcessId = System.Diagnostics.Process.GetCurrentProcess().Id,
- RootUri = Uri("file:///c:/temp"),
- InitializationOptions = None
- )
+#nowarn "57"
- jsonRpc.StartListening()
+[]
+let ``Protocol formatter round trips all VS base type mappings`` () =
+ let options = (createConfiguredFormatter ()).JsonSerializerOptions
+ let uri = Uri("file:///c:/project%20name/file%23name.fs")
+ let range = Range(Start = Position(Line = 1, Character = 2), End = Position(Line = 3, Character = 4))
+
+ let values =
+ [ Diagnostic(Range = range, Message = "diagnostic") :> obj, typeof, typeof
+ Location(Uri = uri, Range = range) :> obj, typeof, typeof
+ ServerCapabilities() :> obj, typeof, typeof
+ SymbolInformation(Name = "symbol", Kind = SymbolKind.Function, Location = Location(Uri = uri, Range = range)) :> obj,
+ typeof,
+ typeof
+ TextDocumentIdentifier(Uri = uri) :> obj, typeof, typeof ]
+
+ for value, baseType, extensionType in values do
+ let json = JsonSerializer.Serialize(value, baseType, options)
+ let result = JsonSerializer.Deserialize(json, baseType, options)
+ Assert.NotNull(result)
+ Assert.Equal(extensionType, result.GetType())
+ use original = JsonDocument.Parse(json)
+ use roundTripped = JsonDocument.Parse(JsonSerializer.Serialize(result, baseType, options))
+
+ for property in original.RootElement.EnumerateObject() do
+ Assert.True(JsonElement.DeepEquals(property.Value, roundTripped.RootElement.GetProperty(property.Name)))
- task {
- let! response = jsonRpc.InvokeAsync("initialize", initializeParams)
- return TestRpcClient(jsonRpc, rpcTrace, workspace, response)
- }
+[]
+let ``Protocol formatter preserves VS project context and document URI`` () =
+ let options = (createConfiguredFormatter ()).JsonSerializerOptions
+ let uri = Uri("file:///c:/project%20name/file%23name.fs")
+ let context = VSProjectContext(Id = "project-id", Label = "project", Kind = VSProjectKind.CSharp)
+ let identifier = VSTextDocumentIdentifier(Uri = uri, ProjectContext = context)
+ let json = JsonSerializer.Serialize(identifier, options)
+ let result = Assert.IsType(JsonSerializer.Deserialize(json, options))
+ Assert.Equal(uri, result.Uri)
+ Assert.Equal(context.Id, result.ProjectContext.Id)
+ Assert.Equal(context.Label, result.ProjectContext.Label)
+ Assert.Null(JsonSerializer.Deserialize("null", options))
[]
let ``The server can process the initialization message`` () =
@@ -74,112 +60,26 @@ let ``The server can process the initialization message`` () =
}
-/// Initialize workspace, open a document, get diagnostics, edit the document, get diagnostics, close the document
[]
let ``Basic server workflow`` () =
task {
-
let! client = initializeLanguageServer None
+ let fileOnDisk = setupSingleFileProject client cleanCode
+ do! openDocument client fileOnDisk cleanCode 1
- let workspace = client.Workspace
-
- let contentOnDisk = "let x = 1"
- let fileOnDisk = sourceFileOnDisk contentOnDisk
-
- let _projectIdentifier =
- workspace.Projects.AddOrUpdate(ProjectConfig.Create(), [ fileOnDisk.LocalPath ])
-
- do!
- client.JsonRpc.NotifyAsync(
- Methods.TextDocumentDidOpenName,
- DidOpenTextDocumentParams(
- TextDocument = TextDocumentItem(Uri = fileOnDisk, LanguageId = "F#", Version = 1, Text = contentOnDisk)
- )
- )
-
- let! diagnosticsResponse =
- client.JsonRpc.InvokeAsync>(
- Methods.TextDocumentDiagnosticName,
- DocumentDiagnosticParams(TextDocument = TextDocumentIdentifier(Uri = fileOnDisk))
- )
-
- Assert.Equal(
- 0,
- (diagnosticsResponse.First.RelatedDocuments
- |> Seq.head
- |> _.Value.First.Items.Length)
- )
-
- let contentEdit = $"{contentOnDisk}\nx <- 2"
-
- do!
- client.JsonRpc.NotifyAsync(
- Methods.TextDocumentDidChangeName,
- DidChangeTextDocumentParams(
- TextDocument = VersionedTextDocumentIdentifier(Uri = fileOnDisk, Version = 2),
- ContentChanges = [| TextDocumentContentChangeEvent(Text = contentEdit) |]
- )
- )
-
- let! diagnosticsResponse =
- client.JsonRpc.InvokeAsync>(
- Methods.TextDocumentDiagnosticName,
- DocumentDiagnosticParams(TextDocument = TextDocumentIdentifier(Uri = fileOnDisk))
- )
-
- let diagnostics =
- diagnosticsResponse.First.RelatedDocuments |> Seq.head |> _.Value.First.Items
-
- Assert.Equal(1, diagnostics.Length)
- Assert.Contains("This value is not mutable", diagnostics[0].Message)
-
- do!
- client.JsonRpc.NotifyAsync(
- Methods.TextDocumentDidCloseName,
- DidCloseTextDocumentParams(TextDocument = TextDocumentIdentifier(Uri = fileOnDisk))
- )
-
- let! diagnosticsResponse =
- client.JsonRpc.InvokeAsync>(
- Methods.TextDocumentDiagnosticName,
- DocumentDiagnosticParams(TextDocument = TextDocumentIdentifier(Uri = fileOnDisk))
- )
-
- // We didn't save the file, so it should be again read from disk and have no diagnostics
- Assert.Equal(
- 0,
- (diagnosticsResponse.First.RelatedDocuments
- |> Seq.head
- |> _.Value.First.Items.Length)
- )
- }
+ let! report = pullDiagnostics client fileOnDisk
+ Assert.Equal(0, report.Items.Length)
-[]
-let ``Full semantic tokens`` () =
+ do! changeDocument client fileOnDisk notMutableCode 2
- task {
- let! client = initializeLanguageServer None
- let workspace = client.Workspace
- let contentOnDisk = "let x = 1"
- let fileOnDisk = sourceFileOnDisk contentOnDisk
- let _projectIdentifier =
- workspace.Projects.AddOrUpdate(ProjectConfig.Create(), [ fileOnDisk.LocalPath ])
- do!
- client.JsonRpc.NotifyAsync(
- Methods.TextDocumentDidOpenName,
- DidOpenTextDocumentParams(
- TextDocument = TextDocumentItem(Uri = fileOnDisk, LanguageId = "F#", Version = 1, Text = contentOnDisk)
- )
- )
- let! semanticTokensResponse =
- client.JsonRpc.InvokeAsync(
- Methods.TextDocumentSemanticTokensFullName,
- SemanticTokensParams(TextDocument = TextDocumentIdentifier(Uri = fileOnDisk))
- )
-
- let expected = [| 0; 0; 0; 1; 0; 0; 0; 3; 15; 0; 0; 4; 1; 17; 0; 0; 4; 1; 19; 0 |]
-
- Assert.Equal(expected, semanticTokensResponse.Data)
+ let! report = pullDiagnostics client fileOnDisk
+ Assert.Equal(1, report.Items.Length)
+ Assert.Contains("This value is not mutable", report.Items[0].Message)
+
+ do! closeDocument client fileOnDisk
+
+ let! report = pullDiagnostics client fileOnDisk
+ Assert.Equal(0, report.Items.Length)
}
[]
@@ -191,3 +91,22 @@ let ``Shutdown and exit`` () =
do! client.JsonRpc.NotifyAsync(Methods.ExitName)
}
+
+[]
+let ``Server capabilities include diagnostics support`` () =
+ task {
+ let! client = initializeLanguageServer None
+ Assert.NotNull(client.Capabilities.DiagnosticOptions)
+ Assert.True(client.Capabilities.DiagnosticOptions.InterFileDependencies, "DiagnosticOptions should advertise InterFileDependencies")
+ Assert.True(client.Capabilities.DiagnosticOptions.WorkspaceDiagnostics, "DiagnosticOptions should advertise WorkspaceDiagnostics")
+ }
+
+[]
+let ``Server capabilities include text document sync`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let syncOptions: TextDocumentSyncOptions = unbox client.Capabilities.TextDocumentSync
+ Assert.True(syncOptions.OpenClose, "TextDocumentSync should support OpenClose")
+ Assert.True(syncOptions.Change.HasValue, "TextDocumentSync should specify a Change kind")
+ Assert.Equal(TextDocumentSyncKind.Full, syncOptions.Change.Value)
+ }
diff --git a/tests/FSharp.Compiler.LanguageServer.Tests/ProtocolHelpers.fs b/tests/FSharp.Compiler.LanguageServer.Tests/ProtocolHelpers.fs
new file mode 100644
index 00000000000..677fbee0d43
--- /dev/null
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/ProtocolHelpers.fs
@@ -0,0 +1,250 @@
+module LanguageServer.ProtocolHelpers
+
+open System
+open Xunit
+
+open FSharp.Compiler.LanguageServer
+open StreamJsonRpc
+open System.IO
+open System.Diagnostics
+open System.Text.Json
+
+open Microsoft.VisualStudio.LanguageServer.Protocol
+open Microsoft.Extensions.DependencyInjection
+open Nerdbank.Streams
+
+open FSharp.Test.ProjectGeneration.WorkspaceHelpers
+open FSharp.Compiler.CodeAnalysis.Workspace
+open FSharp.Compiler.CodeAnalysis.ProjectSnapshot
+
+#nowarn "57"
+
+type TestRpcClient(jsonRpc, rpcTrace, workspace, initializeResult: InitializeResult) =
+
+ member val JsonRpc = jsonRpc
+ member val RpcTraceWriter = rpcTrace
+ member val Workspace = workspace
+ member val Capabilities = initializeResult.Capabilities
+
+ member _.RpcTrace = rpcTrace.ToString()
+
+let createLanguageServerWithServices
+ (workspace: FSharpWorkspace)
+ (config: FSharpLanguageServerConfig option)
+ (configureClient: JsonRpc -> unit)
+ (configureServices: IServiceCollection -> unit)
+ =
+ let rpcTrace = new StringWriter()
+
+ let (inputStream, outputStream), server =
+ FSharpLanguageServer.Create(
+ LspLogger Trace.TraceInformation,
+ workspace,
+ addExtraHandlers = Action(configureServices),
+ ?config = config
+ )
+
+ let formatter = createConfiguredFormatter ()
+
+ let messageHandler =
+ new HeaderDelimitedMessageHandler(inputStream, outputStream, formatter)
+
+ let jsonRpc = new JsonRpc(messageHandler)
+
+ let listener = new TextWriterTraceListener(rpcTrace)
+ server.JsonRpc.TraceSource.Listeners.Add(listener) |> ignore
+ server.JsonRpc.TraceSource.Switch.Level <- SourceLevels.All
+
+ let initializeParams =
+ InitializeParams(
+ ProcessId = Process.GetCurrentProcess().Id,
+ RootUri = Uri("file:///c:/temp"),
+ Capabilities =
+ ClientCapabilities(
+ Workspace =
+ WorkspaceClientCapabilities(
+ SemanticTokens = SemanticTokensWorkspaceSetting(RefreshSupport = true))),
+ InitializationOptions = None
+ )
+
+ configureClient jsonRpc
+ jsonRpc.StartListening()
+
+ task {
+ let! response = jsonRpc.InvokeWithParameterObjectAsync("initialize", initializeParams)
+ return TestRpcClient(jsonRpc, rpcTrace, workspace, response)
+ }
+
+let createLanguageServerWithClient workspace config configureClient =
+ createLanguageServerWithServices workspace config configureClient ignore
+
+let createLanguageServer workspace config =
+ createLanguageServerWithClient workspace config ignore
+
+let initializeLanguageServer (workspace) =
+ let workspace = defaultArg workspace (FSharpWorkspace())
+ createLanguageServer workspace None
+
+[]
+let cleanCode = "let x = 1"
+
+[]
+let notMutableCode = "let x = 1\nx <- 2"
+
+let openDocument (client: TestRpcClient) (fileUri: Uri) (content: string) (version: int) =
+ client.JsonRpc.NotifyWithParameterObjectAsync(
+ Methods.TextDocumentDidOpenName,
+ DidOpenTextDocumentParams(
+ TextDocument = TextDocumentItem(Uri = fileUri, LanguageId = "F#", Version = version, Text = content)))
+
+let changeDocument (client: TestRpcClient) (fileUri: Uri) (content: string) (version: int) =
+ client.JsonRpc.NotifyWithParameterObjectAsync(
+ Methods.TextDocumentDidChangeName,
+ DidChangeTextDocumentParams(
+ TextDocument = VersionedTextDocumentIdentifier(Uri = fileUri, Version = version),
+ ContentChanges = [| TextDocumentContentChangeEvent(Text = content) |]))
+
+let closeDocument (client: TestRpcClient) (fileUri: Uri) =
+ client.JsonRpc.NotifyWithParameterObjectAsync(
+ Methods.TextDocumentDidCloseName,
+ DidCloseTextDocumentParams(TextDocument = TextDocumentIdentifier(Uri = fileUri)))
+
+let pullDiagnosticResponse (client: TestRpcClient) (fileUri: Uri) =
+ client.JsonRpc.InvokeWithParameterObjectAsync>(
+ Methods.TextDocumentDiagnosticName,
+ DocumentDiagnosticParams(TextDocument = TextDocumentIdentifier(Uri = fileUri)))
+
+let pullDiagnostics (client: TestRpcClient) (fileUri: Uri) =
+ task {
+ let! response = pullDiagnosticResponse client fileUri
+ return response.First
+ }
+
+let setupSingleFileProject (client: TestRpcClient) (content: string) =
+ let fileOnDisk = sourceFileOnDisk content
+ let _pid = client.Workspace.Projects.AddOrUpdate(ProjectConfig.Create(), [ fileOnDisk.LocalPath ])
+ fileOnDisk
+
+let openAndPullDiagnostics (client: TestRpcClient) (fileUri: Uri) (content: string) =
+ task {
+ do! openDocument client fileUri content 1
+ let! report = pullDiagnostics client fileUri
+ return report.Items
+ }
+
+let pullVsDiagnosticsRaw(client: TestRpcClient) (fileUri: Uri) =
+ client.JsonRpc.InvokeWithParameterObjectAsync(
+ Methods.TextDocumentDiagnosticName,
+ DocumentDiagnosticParams(TextDocument = TextDocumentIdentifier(Uri = fileUri)))
+
+let openAndPullVsDiagnosticsRaw (client: TestRpcClient) (fileUri: Uri) (content: string) =
+ task {
+ do! openDocument client fileUri content 1
+ return! pullVsDiagnosticsRaw client fileUri
+ }
+
+let getVsDiagnosticItems (response: JsonElement) =
+ response.GetProperty("items").EnumerateArray() |> Seq.toArray
+
+let getVsProjects (diagnosticItem: JsonElement) =
+ diagnosticItem.GetProperty("_vs_projects").EnumerateArray() |> Seq.toArray
+
+let getVsProjectName (project: JsonElement) =
+ project.GetProperty("_vs_projectName").GetString()
+
+let getVsProjectIdentifier (project: JsonElement) =
+ project.GetProperty("_vs_projectIdentifier").GetString()
+
+let getProjectContexts (client: TestRpcClient) (fileUri: Uri) =
+ client.JsonRpc.InvokeWithParameterObjectAsync(
+ "textDocument/_vs_getProjectContexts",
+ VSGetProjectContextsParams(TextDocument = TextDocumentItem(Uri = fileUri)))
+
+[]
+let sharedModuleContent = "module Shared\nlet x = 1"
+
+let setupMultiProjectFile (client: TestRpcClient) (content: string) (projectNames: string list) =
+ let fileOnDisk = sourceFileOnDisk content
+ for name in projectNames do
+ client.Workspace.Projects.AddOrUpdate(ProjectConfig.Empty(name = name), [ fileOnDisk.LocalPath ]) |> ignore
+ fileOnDisk
+
+let requestCodeActions (client: TestRpcClient) (fileUri: Uri) (line: int) =
+ let range = Range(Start = Position(Line = line, Character = 0), End = Position(Line = line, Character = 999))
+ client.JsonRpc.InvokeWithParameterObjectAsync(
+ Methods.TextDocumentCodeActionName,
+ CodeActionParams(
+ TextDocument = TextDocumentIdentifier(Uri = fileUri),
+ Range = range,
+ Context = CodeActionContext(Diagnostics = [||])))
+
+let openAndRequestCodeActions (client: TestRpcClient) (fileUri: Uri) (content: string) (line: int) =
+ task {
+ do! openDocument client fileUri content 1
+ // Pull diagnostics first so the server has them cached
+ let! _diags = pullDiagnostics client fileUri
+ return! requestCodeActions client fileUri line
+ }
+
+let pullSemanticTokensFull (client: TestRpcClient) (fileUri: Uri) =
+ client.JsonRpc.InvokeWithParameterObjectAsync(
+ Methods.TextDocumentSemanticTokensFullName,
+ SemanticTokensParams(TextDocument = TextDocumentIdentifier(Uri = fileUri)))
+
+let pullSemanticTokensFullInContext (client: TestRpcClient) (fileUri: Uri) (projectContext: VSProjectContext) =
+ client.JsonRpc.InvokeWithParameterObjectAsync(
+ Methods.TextDocumentSemanticTokensFullName,
+ SemanticTokensParams(TextDocument = VSTextDocumentIdentifier(Uri = fileUri, ProjectContext = projectContext)))
+
+let pullDiagnosticsInContext (client: TestRpcClient) (fileUri: Uri) (projectContext: VSProjectContext) =
+ task {
+ let! response =
+ client.JsonRpc.InvokeWithParameterObjectAsync>(
+ Methods.TextDocumentDiagnosticName,
+ DocumentDiagnosticParams(TextDocument = VSTextDocumentIdentifier(Uri = fileUri, ProjectContext = projectContext)))
+ return response.First
+ }
+
+/// Adds two projects sharing the same source file but using different conditional compilation
+/// defines. The two projects also have distinct output paths so they look like two TFMs of the
+/// same project to the workspace. Returns the file URI and the two FSharpProjectIdentifiers.
+let setupMultiTargetLikeProject (client: TestRpcClient) (content: string) (sharedProjectFileName: string) (defineA: string) (defineB: string) =
+ let fileOnDisk = sourceFileOnDisk content
+ let dir = System.IO.Path.GetDirectoryName(fileOnDisk.LocalPath)
+ let projectFileName = System.IO.Path.Combine(dir, sharedProjectFileName)
+ let compilerOutputA = System.IO.Path.Combine(dir, "obj", $"out.{defineA}.dll")
+ let compilerOutputB = System.IO.Path.Combine(dir, "obj", $"out.{defineB}.dll")
+
+ let configA =
+ ProjectConfig(
+ projectFileName,
+ outputFileName = Some (System.IO.Path.Combine(dir, $"out.{defineA}.dll")),
+ referencesOnDisk = [],
+ otherOptions = [ $"--define:{defineA}"; $"-o:{compilerOutputA}" ])
+ let configB =
+ ProjectConfig(
+ projectFileName,
+ outputFileName = Some (System.IO.Path.Combine(dir, $"out.{defineB}.dll")),
+ referencesOnDisk = [],
+ otherOptions = [ $"--define:{defineB}"; $"-o:{compilerOutputB}" ])
+
+ let pidA = client.Workspace.Projects.AddOrUpdate(configA, [ fileOnDisk.LocalPath ])
+ let pidB = client.Workspace.Projects.AddOrUpdate(configB, [ fileOnDisk.LocalPath ])
+ fileOnDisk, pidA, pidB
+
+let pullSemanticTokensRange (client: TestRpcClient) (fileUri: Uri) (range: Range) =
+ client.JsonRpc.InvokeWithParameterObjectAsync(
+ Methods.TextDocumentSemanticTokensRangeName,
+ SemanticTokensRangeParams(TextDocument = TextDocumentIdentifier(Uri = fileUri), Range = range))
+
+let openAndPullSemanticTokens (client: TestRpcClient) (fileUri: Uri) (content: string) =
+ task {
+ do! openDocument client fileUri content 1
+ return! pullSemanticTokensFull client fileUri
+ }
+
+let getSemanticTokensLegend (client: TestRpcClient) =
+ let options = client.Capabilities.SemanticTokensOptions
+ if isNull (box options) then
+ failwith "Server did not advertise SemanticTokensOptions capability"
+ options.Legend
diff --git a/tests/FSharp.Compiler.LanguageServer.Tests/SemanticTokensHelpers.fs b/tests/FSharp.Compiler.LanguageServer.Tests/SemanticTokensHelpers.fs
new file mode 100644
index 00000000000..0a9f6c9d764
--- /dev/null
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/SemanticTokensHelpers.fs
@@ -0,0 +1,135 @@
+module LanguageServer.SemanticTokensHelpers
+
+open System
+open System.Text.RegularExpressions
+
+open Microsoft.VisualStudio.LanguageServer.Protocol
+
+// A decoded semantic token with absolute positions and resolved type/modifier names.
+type DecodedToken =
+ {
+ Line: int
+ StartChar: int
+ Length: int
+ TokenType: string
+ TokenModifiers: string list
+ }
+
+// Decode the LSP semantic tokens data array (relative encoding, 5 ints per token)
+// into a list of tokens with absolute positions and resolved type/modifier names.
+let decodeSemanticTokens (legend: SemanticTokensLegend) (data: int array) : DecodedToken list =
+ if data = null || data.Length = 0 then
+ []
+ else
+ assert (data.Length % 5 = 0)
+
+ let tokenCount = data.Length / 5
+ let mutable prevLine = 0
+ let mutable prevStartChar = 0
+
+ [ for i in 0 .. tokenCount - 1 do
+ let offset = i * 5
+ let deltaLine = data[offset]
+ let deltaStart = data[offset + 1]
+ let length = data[offset + 2]
+ let tokenTypeIdx = data[offset + 3]
+ let modifierBits = data[offset + 4]
+
+ let line = prevLine + deltaLine
+
+ let startChar =
+ if deltaLine = 0 then
+ prevStartChar + deltaStart
+ else
+ deltaStart
+
+ prevLine <- line
+ prevStartChar <- startChar
+
+ let tokenType =
+ if tokenTypeIdx >= 0 && tokenTypeIdx < legend.TokenTypes.Length then
+ legend.TokenTypes[tokenTypeIdx]
+ else
+ $""
+
+ let modifiers =
+ [ for bit in 0 .. legend.TokenModifiers.Length - 1 do
+ if modifierBits &&& (1 <<< bit) <> 0 then
+ legend.TokenModifiers[bit] ]
+
+ { Line = line
+ StartChar = startChar
+ Length = length
+ TokenType = tokenType
+ TokenModifiers = modifiers } ]
+
+// Marker pattern: (*N*) where N is one or more characters.
+let private markerPattern = Regex(@"\(\*\d+\*\)", RegexOptions.Compiled)
+
+// Strip all (*N*) markers from source text and return the cleaned source
+// plus a map from marker name to (line, character) in the cleaned source.
+let stripMarkers (source: string) : string * Map =
+ let mutable cleanedParts = System.Text.StringBuilder()
+ let mutable markerPositions = Map.empty
+ let mutable srcIdx = 0
+ let mutable line = 0
+ let mutable col = 0
+
+ let matches = markerPattern.Matches(source) |> Seq.cast |> Seq.toList
+
+ for m in matches do
+ // Append text before this marker
+ for i in srcIdx .. m.Index - 1 do
+ cleanedParts.Append(source[i]) |> ignore
+
+ if source[i] = '\n' then
+ line <- line + 1
+ col <- 0
+ else
+ col <- col + 1
+
+ // Record marker position (position right after the marker in cleaned text)
+ markerPositions <- markerPositions |> Map.add m.Value (line, col)
+ srcIdx <- m.Index + m.Length
+
+ // Append remaining text
+ for i in srcIdx .. source.Length - 1 do
+ cleanedParts.Append(source[i]) |> ignore
+
+ cleanedParts.ToString(), markerPositions
+
+// Find the token at a specific (line, character) position.
+let findTokenAtPosition (line: int) (character: int) (tokens: DecodedToken list) : DecodedToken option =
+ tokens
+ |> List.tryFind (fun t -> t.Line = line && t.StartChar <= character && character < t.StartChar + t.Length)
+
+// Find the token right after a marker position. The marker position should point
+// to the start of the token immediately following the marker.
+let findTokenAtMarker (markerPositions: Map) (marker: string) (tokens: DecodedToken list) : DecodedToken option =
+ match markerPositions |> Map.tryFind marker with
+ | None -> failwith $"Marker '{marker}' not found in marker positions map"
+ | Some(line, col) -> findTokenAtPosition line col tokens
+
+// Find all tokens on a given line.
+let findTokensOnLine (line: int) (tokens: DecodedToken list) : DecodedToken list =
+ tokens |> List.filter (fun t -> t.Line = line)
+
+// Validate that decoded tokens are in monotonically increasing order,
+// have positive lengths, and don't overlap on the same line.
+let validateTokenOrdering (tokens: DecodedToken list) : unit =
+ let mutable prevLine = -1
+ let mutable prevEnd = -1
+
+ for t in tokens do
+ if t.Line < prevLine then
+ failwith $"Tokens not in line order: token at line {t.Line} appears after line {prevLine}"
+
+ if t.Line = prevLine && t.StartChar < prevEnd then
+ failwith
+ $"Overlapping tokens on line {t.Line}: token at col {t.StartChar} overlaps with previous token ending at col {prevEnd}"
+
+ if t.Length <= 0 then
+ failwith $"Token at ({t.Line},{t.StartChar}) has non-positive length {t.Length}"
+
+ prevLine <- t.Line
+ prevEnd <- t.StartChar + t.Length
diff --git a/tests/FSharp.Compiler.LanguageServer.Tests/SemanticTokensProtocolTests.fs b/tests/FSharp.Compiler.LanguageServer.Tests/SemanticTokensProtocolTests.fs
new file mode 100644
index 00000000000..eabecadf865
--- /dev/null
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/SemanticTokensProtocolTests.fs
@@ -0,0 +1,932 @@
+module LanguageServer.SemanticTokensProtocolTests
+
+open System
+open System.Threading
+open Xunit
+
+open Microsoft.VisualStudio.LanguageServer.Protocol
+open FSharp.Compiler.CodeAnalysis.Workspace
+open FSharp.Compiler.LanguageServer
+open FSharp.Compiler.LanguageServer.Handlers
+
+open LanguageServer.ProtocolHelpers
+open LanguageServer.SemanticTokensHelpers
+
+#nowarn "57"
+
+[]
+let ``Semantic token response observes cancellation before processing`` () =
+ Assert.Throws(fun () ->
+ LspSemanticTokens.responseFor
+ (FSharp.Compiler.Text.SourceText.ofString "")
+ "cancelled.fs"
+ [||]
+ None
+ None
+ (CancellationToken(true))
+ |> ignore)
+ |> ignore
+
+[]
+[]
+[]
+let ``Range tokens equal filtered full tokens inside multiline lexical constructs`` (startLine: int, endLine: int) =
+ task {
+ let! client = initializeLanguageServer None
+ let content =
+ String.concat "\n"
+ [ "module M"
+ "let text = \"\"\"first"
+ "second"
+ "third\"\"\""
+ "(* comment"
+ "second"
+ "third *)"
+ "let result = text.Length" ]
+ let fileOnDisk = setupSingleFileProject client content
+ let range = Range(Start = Position(Line = startLine, Character = 1), End = Position(Line = endLine, Character = 3))
+ let! full = openAndPullSemanticTokens client fileOnDisk content
+ let! ranged = pullSemanticTokensRange client fileOnDisk range
+ let legend = getSemanticTokensLegend client
+ let expected =
+ decodeSemanticTokens legend full.Data
+ |> List.filter (fun token ->
+ token.Line >= startLine && token.Line <= endLine
+ && (token.Line <> startLine || token.StartChar + token.Length > range.Start.Character)
+ && (token.Line <> endLine || token.StartChar < range.End.Character))
+ Assert.NotEmpty(expected)
+ Assert.Equal(expected, decodeSemanticTokens legend ranged.Data)
+ }
+
+[]
+[]
+[]
+let ``Legacy navbar context switches refresh and classify both frameworks without source changes`` (rangeRequest: bool) =
+ task {
+ let mutable refreshCount = 0
+ let mutable failRefresh = true
+ let! client =
+ createLanguageServerWithClient (FSharpWorkspace()) None (fun rpc ->
+ rpc.AddLocalRpcMethod(
+ "workspace/semanticTokens/refresh",
+ Action(fun () ->
+ if failRefresh then
+ failwith "Simulated refresh failure"
+ refreshCount <- refreshCount + 1)))
+
+ let content =
+ """module M
+#if NET8_0
+let onlyOnNet8 x = x
+let result = onlyOnNet8 8
+#else
+let onlyOnNet9 x = x
+let result = onlyOnNet9 9
+#endif
+"""
+
+ let file, net8, net9 = setupMultiTargetLikeProject client content "multitarget.fsproj" "NET8_0" "NET9_0"
+ do! openDocument client file content 1
+ let legend = getSemanticTokensLegend client
+ let defaultIdentifier = client.Workspace.Query.GetProjectSnapshotForFile(file) |> Option.get |> _.Identifier
+ let! failedRefresh =
+ client.JsonRpc.InvokeWithParameterObjectAsync(
+ "textDocument/fsharp/setActiveProjectContext",
+ {
+ Uri = file
+ ProjectFileName = net8.ProjectFileName
+ OutputFileName = net8.OutputFileName
+ })
+ Assert.False(failedRefresh)
+ failRefresh <- false
+
+ for index, identifier in [ net8; net9; net8; net9; net8; net9 ] |> List.indexed do
+ let parameters =
+ {
+ Uri = file
+ ProjectFileName = identifier.ProjectFileName
+ OutputFileName = System.IO.Path.Combine(System.IO.Path.GetDirectoryName(identifier.OutputFileName), "obj", System.IO.Path.GetFileName(identifier.OutputFileName))
+ }
+
+ let! accepted =
+ client.JsonRpc.InvokeWithParameterObjectAsync(
+ "textDocument/fsharp/setActiveProjectContext",
+ parameters)
+
+ Assert.True(accepted)
+ let! repeated = client.JsonRpc.InvokeWithParameterObjectAsync("textDocument/fsharp/setActiveProjectContext", parameters)
+ Assert.True(repeated)
+ Assert.Equal(index + 1, refreshCount)
+
+ let! response =
+ if rangeRequest then
+ pullSemanticTokensRange client file (Range(Start = Position(Line = 0), End = Position(Line = 8)))
+ else
+ pullSemanticTokensFull client file
+
+ let tokens = decodeSemanticTokens legend response.Data
+ let activeLine, inactiveLine = if identifier = net8 then 2, 5 else 5, 2
+ let active = findTokenAtPosition activeLine 4 tokens
+ Assert.Equal(Some "FSharp.Function", active |> Option.map _.TokenType)
+ let inactive = findTokenAtPosition inactiveLine 4 tokens
+ Assert.Equal(Some "excluded code", inactive |> Option.map _.TokenType)
+
+ let explicitNet8 = VSProjectContext(Id = makeProjectContextId net8)
+ let! explicitResponse = pullSemanticTokensFullInContext client file explicitNet8
+ let explicitTokens = decodeSemanticTokens legend explicitResponse.Data
+ Assert.Equal(Some "FSharp.Function", findTokenAtPosition 2 4 explicitTokens |> Option.map _.TokenType)
+ Assert.Equal(Some "excluded code", findTokenAtPosition 5 4 explicitTokens |> Option.map _.TokenType)
+
+ do! closeDocument client file
+ do! openDocument client file content 2
+ let! reopened = pullSemanticTokensFull client file
+ let! defaultContext = pullSemanticTokensFullInContext client file (VSProjectContext(Id = makeProjectContextId defaultIdentifier))
+ Assert.Equal(defaultContext.Data, reopened.Data)
+ }
+
+// ---------------------------------------------------------------------------
+// Capability Tests
+// ---------------------------------------------------------------------------
+
+[]
+let ``Server capabilities include semantic tokens support`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let options = client.Capabilities.SemanticTokensOptions
+ Assert.NotNull(box options)
+ Assert.NotNull(options.Legend)
+ Assert.NotNull(options.Legend.TokenTypes)
+ Assert.NotNull(options.Legend.TokenModifiers)
+ Assert.True(options.Legend.TokenTypes.Length > 0, "Legend should have at least one token type")
+ }
+
+[]
+let ``Server capabilities advertise full document semantic tokens`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let options = client.Capabilities.SemanticTokensOptions
+ Assert.NotNull(box options)
+ // Full should be advertised (either as bool or object)
+ Assert.NotNull(box options.Full)
+ }
+
+[]
+let ``Server with semantic tokens disabled does not advertise capability`` () =
+ task {
+ let config =
+ { FSharpLanguageServerConfig.Default with
+ EnabledFeatures =
+ { FSharpLanguageServerFeatures.Default with
+ SemanticTokens = false
+ }
+ }
+
+ let! client = createLanguageServer (FSharpWorkspace()) (Some config)
+ Assert.Null(box client.Capabilities.SemanticTokensOptions)
+ }
+
+[]
+let ``Semantic tokens legend contains expected token types`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+ let tokenTypes = set legend.TokenTypes
+
+ // Standard LSP types we still emit (syntactic + a couple of semantic fallbacks).
+ let standardLspTypes =
+ [ SemanticTokenTypes.Variable
+ SemanticTokenTypes.Keyword
+ SemanticTokenTypes.Comment
+ SemanticTokenTypes.String
+ SemanticTokenTypes.Number
+ SemanticTokenTypes.Operator ]
+
+ // Roslyn classification names that pass through the VS LSP client's
+ // RoslynSemanticTokenTypes identity map so VS picks up the same colors
+ // the legacy F# extension uses.
+ let roslynTypes =
+ [ "class name"
+ "struct name"
+ "enum name"
+ "interface name"
+ "type parameter name"
+ "delegate name"
+ "field name"
+ "property name"
+ "event name"
+ "constant name"
+ "label name"
+ "extension method name"
+ "method name"
+ "namespace name"
+ "local name"
+ "string - escape character" ]
+
+ // F#-specific token types — the client side must forward these as identity entries so that
+ // the in-proc MEF classifications (FSharp.MutableVar, etc.) and their EditorFormatDefinitions
+ // get applied.
+ let fsharpTypes =
+ [ "FSharp.Function"
+ "FSharp.MutableVar"
+ "FSharp.Printf"
+ "FSharp.DisposableType"
+ "FSharp.DisposableLocalValue"
+ "FSharp.DisposableTopLevelValue" ]
+
+ for expected in standardLspTypes @ roslynTypes @ fsharpTypes do
+ Assert.True(tokenTypes.Contains(expected), $"Legend should contain token type '{expected}'")
+ }
+
+[]
+let ``Semantic tokens legend contains standard modifiers`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+ let modifiers = set legend.TokenModifiers
+
+ // At minimum, the legend should contain the 'static' and 'deprecated' modifiers
+ Assert.True(
+ modifiers.Contains(SemanticTokenModifiers.Static),
+ $"Legend should contain modifier '{SemanticTokenModifiers.Static}'"
+ )
+ }
+
+// ---------------------------------------------------------------------------
+// Request / Response Shape Tests
+// ---------------------------------------------------------------------------
+
+[]
+let ``Full semantic tokens request returns valid response`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let fileOnDisk = setupSingleFileProject client "let x = 1"
+ let! result = openAndPullSemanticTokens client fileOnDisk "let x = 1"
+ Assert.NotNull(result)
+ Assert.NotNull(result.Data)
+ Assert.True(result.Data.Length > 0, "Expected at least some token data for 'let x = 1'")
+ }
+
+[]
+let ``Semantic tokens data length is a multiple of 5`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let fileOnDisk = setupSingleFileProject client "let x = 1"
+ let! result = openAndPullSemanticTokens client fileOnDisk "let x = 1"
+ Assert.NotNull(result)
+ Assert.True(
+ result.Data.Length % 5 = 0,
+ $"Data length {result.Data.Length} is not a multiple of 5"
+ )
+ }
+
+[]
+let ``Semantic tokens data has valid token type indices`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+ let content = "let add x y = x + y"
+ let fileOnDisk = setupSingleFileProject client content
+ let! result = openAndPullSemanticTokens client fileOnDisk content
+ Assert.NotNull(result)
+
+ let tokenCount = result.Data.Length / 5
+
+ for i in 0 .. tokenCount - 1 do
+ let tokenTypeIdx = result.Data[i * 5 + 3]
+
+ Assert.True(
+ tokenTypeIdx >= 0 && tokenTypeIdx < legend.TokenTypes.Length,
+ $"Token {i}: tokenType index {tokenTypeIdx} is out of bounds (legend has {legend.TokenTypes.Length} types)"
+ )
+ }
+
+[]
+let ``Semantic tokens have valid delta positions`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let content = "let x = 1\nlet y = 2\nlet z = x + y"
+ let fileOnDisk = setupSingleFileProject client content
+ let! result = openAndPullSemanticTokens client fileOnDisk content
+ Assert.NotNull(result)
+
+ let tokenCount = result.Data.Length / 5
+
+ for i in 0 .. tokenCount - 1 do
+ let offset = i * 5
+ let deltaLine = result.Data[offset]
+ let deltaStart = result.Data[offset + 1]
+ let length = result.Data[offset + 2]
+
+ Assert.True(deltaLine >= 0, $"Token {i}: deltaLine {deltaLine} is negative")
+
+ if i = 0 || deltaLine > 0 then
+ Assert.True(deltaStart >= 0, $"Token {i}: deltaStart {deltaStart} should be non-negative on a new line")
+
+ Assert.True(length > 0, $"Token {i}: length {length} should be positive")
+ }
+
+[]
+let ``Decoded tokens are in monotonic order with no overlaps`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+ let content = "module Test\nlet add (x: int) (y: int) = x + y\nlet result = add 1 2"
+ let fileOnDisk = setupSingleFileProject client content
+ let! result = openAndPullSemanticTokens client fileOnDisk content
+ Assert.NotNull(result)
+
+ let tokens = decodeSemanticTokens legend result.Data
+ validateTokenOrdering tokens
+ }
+
+[]
+let ``Semantic tokens for empty file returns empty data`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let content = ""
+ let fileOnDisk = setupSingleFileProject client content
+ let! result = openAndPullSemanticTokens client fileOnDisk content
+ // An empty file should return either null or a response with empty data
+ if not (isNull result) && not (isNull result.Data) then
+ Assert.Equal(0, result.Data.Length)
+ }
+
+[]
+let ``Semantic tokens for file not in any project returns graceful response`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let fileOnDisk = FSharp.Test.ProjectGeneration.WorkspaceHelpers.sourceFileOnDisk "let x = 1"
+ do! openDocument client fileOnDisk "let x = 1" 1
+ // File not in any project — should not crash
+ let! result = pullSemanticTokensFull client fileOnDisk
+
+ if not (isNull result) && not (isNull result.Data) then
+ Assert.True(result.Data.Length % 5 = 0, "Even for non-project files, data should be well-formed")
+ }
+
+[