From f28a6d2145c0042c84b499fa221b06b3e30548e6 Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Wed, 22 Apr 2026 11:19:57 +0200
Subject: [PATCH 01/30] VSIX packaging cleanup and semantic tokens removal
- Merge LSP VSIX into the old VSIX (CreateVsixContainer, AssemblyVSIXSubPath)
- Remove unfinished semantic tokens endpoint from LanguageFeaturesHandler
- Remove SemanticTokensOptions from CapabilitiesManager and VS provider
- Remove TokenTypes module from FSharpRequestContext
- Refactor ClassificationService: simplify classification logic
- Remove unused WorkspaceExtensions and SemanticHighlighting setting
- Rename config field SemanticHighlighting -> CodeActions (default false)
- Bump LSP protocol packages to 17.13.9, add Protocol.Extensions
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
VSFSharpExtension.slnx | 3 +
.../Common/CapabilitiesManager.fs | 14 +-
.../Common/FSharpRequestContext.fs | 148 ----------
.../FSharp.Compiler.LanguageServer.fsproj | 5 +
.../FSharpLanguageServerConfig.fs | 4 +-
.../Handlers/LanguageFeaturesHandler.fs | 12 +-
.../.vsextension/string-resources.json | 10 +-
.../ExtensionEntrypoint.cs | 7 +-
.../FSharp.VisualStudio.Extension.csproj | 7 +
.../FSharpExtensionSettings.cs | 14 +-
.../FSharpLanguageServerProvider.cs | 16 +-
.../Source.extension.vsixmanifest | 2 +-
.../VisualFSharp.Core.targets | 13 +
.../Classification/ClassificationService.fs | 275 +++++++++---------
.../LanguageService/WorkspaceExtensions.fs | 5 -
15 files changed, 181 insertions(+), 354 deletions(-)
diff --git a/VSFSharpExtension.slnx b/VSFSharpExtension.slnx
index 740a2f7c8b3..a7b63f08c0c 100644
--- a/VSFSharpExtension.slnx
+++ b/VSFSharpExtension.slnx
@@ -12,5 +12,8 @@
+
+
+
diff --git a/src/FSharp.Compiler.LanguageServer/Common/CapabilitiesManager.fs b/src/FSharp.Compiler.LanguageServer/Common/CapabilitiesManager.fs
index 556a1d96edc..d85debebb1b 100644
--- a/src/FSharp.Compiler.LanguageServer/Common/CapabilitiesManager.fs
+++ b/src/FSharp.Compiler.LanguageServer/Common/CapabilitiesManager.fs
@@ -31,21 +31,9 @@ type CapabilitiesManager(config: FSharpLanguageServerConfig, scOverrides: IServe
InterFileDependencies = true,
Identifier = "potato",
WorkspaceDiagnostics = true
- )),
+ ))
//CompletionProvider = CompletionOptions(TriggerCharacters = [| "."; " " |], ResolveProvider = true, WorkDoneProgress = true),
//HoverProvider = SumType(HoverOptions(WorkDoneProgress = true))
- SemanticTokensOptions =
- addIf
- config.EnabledFeatures.SemanticHighlighting
-
- (SemanticTokensOptions(
- Legend =
- SemanticTokensLegend(
- TokenTypes = (SemanticTokenTypes.AllTypes |> Seq.toArray), // XXX should be extended
- TokenModifiers = (SemanticTokenModifiers.AllModifiers |> Seq.toArray)
- ),
- Range = false
- ))
)
interface IInitializeManager with
diff --git a/src/FSharp.Compiler.LanguageServer/Common/FSharpRequestContext.fs b/src/FSharp.Compiler.LanguageServer/Common/FSharpRequestContext.fs
index fd096d3e582..0f3da484971 100644
--- a/src/FSharp.Compiler.LanguageServer/Common/FSharpRequestContext.fs
+++ b/src/FSharp.Compiler.LanguageServer/Common/FSharpRequestContext.fs
@@ -4,163 +4,15 @@ open Microsoft.CommonLanguageServerProtocol.Framework
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
#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
- 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)
-
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 [||]
- }
-
type ContextHolder(workspace, lspServices: ILspServices) =
let logger = lspServices.GetRequiredService()
diff --git a/src/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.fsproj b/src/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.fsproj
index 6fed6f5a940..b4eaacad940 100644
--- a/src/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.fsproj
+++ b/src/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.fsproj
@@ -15,6 +15,11 @@
+
+
+
+
+
diff --git a/src/FSharp.Compiler.LanguageServer/FSharpLanguageServerConfig.fs b/src/FSharp.Compiler.LanguageServer/FSharpLanguageServerConfig.fs
index a1acd2b88da..0ddfefe636b 100644
--- a/src/FSharp.Compiler.LanguageServer/FSharpLanguageServerConfig.fs
+++ b/src/FSharp.Compiler.LanguageServer/FSharpLanguageServerConfig.fs
@@ -3,13 +3,13 @@ namespace FSharp.Compiler.LanguageServer
type FSharpLanguageServerFeatures =
{
Diagnostics: bool
- SemanticHighlighting: bool
+ CodeActions: bool
}
static member Default =
{
Diagnostics = true
- SemanticHighlighting = true
+ CodeActions = false
}
type FSharpLanguageServerConfig =
diff --git a/src/FSharp.Compiler.LanguageServer/Handlers/LanguageFeaturesHandler.fs b/src/FSharp.Compiler.LanguageServer/Handlers/LanguageFeaturesHandler.fs
index 474758924d0..1754820ac5a 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
@@ -8,7 +8,6 @@ open FSharp.Compiler.LanguageServer
open System.Threading.Tasks
open System.Threading
open System.Collections.Generic
-open Microsoft.VisualStudio.FSharp.Editor
#nowarn "57"
@@ -48,12 +47,3 @@ type LanguageFeaturesHandler() =
)
}
|> 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.VisualStudio.Extension/.vsextension/string-resources.json b/src/FSharp.VisualStudio.Extension/.vsextension/string-resources.json
index 5f1633293d7..5e669b53287 100644
--- a/src/FSharp.VisualStudio.Extension/.vsextension/string-resources.json
+++ b/src/FSharp.VisualStudio.Extension/.vsextension/string-resources.json
@@ -1,4 +1,10 @@
{
- "FSharpLspExtension.FSharpLanguageServerProvider.DisplayName": "FSharp Analyzer LSP server"
-
+ "FSharpLspExtension.FSharpLanguageServerProvider.DisplayName": "FSharp Analyzer LSP server",
+ "F#": "F# LSP",
+ "FSharpSettings.Old": "Old extension (devenv process)",
+ "FSharpSettings.Both": "Both extensions",
+ "FSharpSettings.LSP": "LSP (separate process)",
+ "FSharpSettings.Unset": "Default",
+ "FSharpSettings.GetDiagnosticsFrom": "Get diagnostics from",
+ "FSharpSettings.GetDiagnosticsFrom.Description": "Which extension provides diagnostics (restart required)"
}
diff --git a/src/FSharp.VisualStudio.Extension/ExtensionEntrypoint.cs b/src/FSharp.VisualStudio.Extension/ExtensionEntrypoint.cs
index ba47320b925..6611bace471 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,11 @@ internal class ExtensionEntrypoint : Extension
version: this.ExtensionAssemblyVersion,
publisherName: "Publisher name",
displayName: "FSharp.VisualStudio.Extension",
- description: "Extension description"),
+ description: "Extension description")
+ {
+ // TODO: Probably should be replaced by a range of versions
+ InstallationTargetVersion = "[17.14, 18.0]",
+ },
};
///
diff --git a/src/FSharp.VisualStudio.Extension/FSharp.VisualStudio.Extension.csproj b/src/FSharp.VisualStudio.Extension/FSharp.VisualStudio.Extension.csproj
index f2f8ab61ede..8f20a05ba48 100644
--- a/src/FSharp.VisualStudio.Extension/FSharp.VisualStudio.Extension.csproj
+++ b/src/FSharp.VisualStudio.Extension/FSharp.VisualStudio.Extension.csproj
@@ -7,6 +7,8 @@
enable
12
en-US
+ false
+ LSPComponent
@@ -18,6 +20,11 @@
+
+
+
+
+
+ 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..acca6019502
--- /dev/null
+++ b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/ErrorListDiagnosticsTests.cs
@@ -0,0 +1,240 @@
+// 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]
+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..c336403153e
--- /dev/null
+++ b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/FSharp.Compiler.LanguageServer.IntegrationTests.csproj
@@ -0,0 +1,22 @@
+
+
+
+ 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..27f2396e754
--- /dev/null
+++ b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/FSharpApexTestBase.cs
@@ -0,0 +1,80 @@
+// 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)
+ {
+ // Previous host may be a zombie (crashed but not disposed).
+ // Dispose it before creating a new one.
+ try { VsHost?.Stop(); } catch { /* best effort cleanup */ }
+ 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/LightBulbCodeActionTests.cs b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/LightBulbCodeActionTests.cs
new file mode 100644
index 00000000000..cd63f384743
--- /dev/null
+++ b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/LightBulbCodeActionTests.cs
@@ -0,0 +1,297 @@
+// 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]
+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..4fc6dabf263
--- /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 (
+ set "VSTEST_CONSOLE_PATH=C:\Program Files\Microsoft Visual Studio\18\IntPreview\Common7\IDE\CommonExtensions\Microsoft\TestWindow\VsTest\vstest.console.dll"
+)
+
+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.Tests/FSharp.Compiler.LanguageServer.Tests.fsproj b/tests/FSharp.Compiler.LanguageServer.Tests/FSharp.Compiler.LanguageServer.Tests.fsproj
index 5fa8a3ad0af..1ea97918b7f 100644
--- a/tests/FSharp.Compiler.LanguageServer.Tests/FSharp.Compiler.LanguageServer.Tests.fsproj
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/FSharp.Compiler.LanguageServer.Tests.fsproj
@@ -1,4 +1,4 @@
-
+
$(FSharpNetCoreProductTargetFramework)
@@ -50,4 +50,3 @@
-
From 80fa009a38d6ba5f6631efb82c6d21a19a1cbdc6 Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Wed, 22 Apr 2026 11:29:45 +0200
Subject: [PATCH 07/30] Add VS local development and MCP testing skills
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.github/skills/vs-local-development/SKILL.md | 127 +++++++++++++
.../scripts/close-exp-instances.ps1 | 7 +
.../scripts/deploy-to-vs.ps1 | 57 ++++++
.../skills/vs-mcp-testing-tool/REFERENCE.md | 176 ++++++++++++++++++
.github/skills/vs-mcp-testing-tool/SKILL.md | 35 ++++
5 files changed, 402 insertions(+)
create mode 100644 .github/skills/vs-local-development/SKILL.md
create mode 100644 .github/skills/vs-local-development/scripts/close-exp-instances.ps1
create mode 100644 .github/skills/vs-local-development/scripts/deploy-to-vs.ps1
create mode 100644 .github/skills/vs-mcp-testing-tool/REFERENCE.md
create mode 100644 .github/skills/vs-mcp-testing-tool/SKILL.md
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-tool/REFERENCE.md b/.github/skills/vs-mcp-testing-tool/REFERENCE.md
new file mode 100644
index 00000000000..af1a105f4ab
--- /dev/null
+++ b/.github/skills/vs-mcp-testing-tool/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-tool/SKILL.md b/.github/skills/vs-mcp-testing-tool/SKILL.md
new file mode 100644
index 00000000000..f0448c42038
--- /dev/null
+++ b/.github/skills/vs-mcp-testing-tool/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.
From 242b7aca7ecac277bff98d34019159866975ee29 Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Thu, 30 Apr 2026 12:17:23 +0200
Subject: [PATCH 08/30] Add copilot skills for VS integration
---
VSFSharpExtension.slnx | 1 +
1 file changed, 1 insertion(+)
diff --git a/VSFSharpExtension.slnx b/VSFSharpExtension.slnx
index a7b63f08c0c..b1c10cefe88 100644
--- a/VSFSharpExtension.slnx
+++ b/VSFSharpExtension.slnx
@@ -14,6 +14,7 @@
+
From bcf3fbdeec38c41526b3e220794e79d744a7e83a Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Thu, 11 Jun 2026 11:43:55 +0200
Subject: [PATCH 09/30] Use project context when querying workspace
---
src/Compiler/Service/FSharpWorkspaceQuery.fs | 36 +++++++++------
.../Handlers/CodeActionHandler.fs | 5 ++-
.../Handlers/LanguageFeaturesHandler.fs | 8 ++--
.../Handlers/ProjectContextsHandler.fs | 2 +-
src/FSharp.Compiler.LanguageServer/Utils.fs | 44 ++++++++++++++-----
.../FSharpLanguageServerProvider.cs | 3 +-
.../ProtocolHelpers.fs | 34 ++++++++++++++
7 files changed, 100 insertions(+), 32 deletions(-)
diff --git a/src/Compiler/Service/FSharpWorkspaceQuery.fs b/src/Compiler/Service/FSharpWorkspaceQuery.fs
index 6e95a1fffb2..d25abb714ad 100644
--- a/src/Compiler/Service/FSharpWorkspaceQuery.fs
+++ b/src/Compiler/Service/FSharpWorkspaceQuery.fs
@@ -46,16 +46,24 @@ 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)
+ match matching with
+ | Some _ -> matching
+ | None -> candidates |> Seq.tryHead
+ | None -> candidates |> Seq.tryHead
member _.GetProjectSnapshotsForFile(file: Uri) =
use _ =
@@ -64,14 +72,14 @@ type FSharpWorkspaceQuery internal (depGraph: IThreadSafeDependencyGraph<_, _>,
depGraph.GetProjectsContaining file.LocalPath
|> Seq.toArray
- member this.GetParseAndCheckResultsForFile(file: Uri) =
+ 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)
@@ -85,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
@@ -103,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/FSharp.Compiler.LanguageServer/Handlers/CodeActionHandler.fs b/src/FSharp.Compiler.LanguageServer/Handlers/CodeActionHandler.fs
index 03d7a210bc9..7db7067f164 100644
--- a/src/FSharp.Compiler.LanguageServer/Handlers/CodeActionHandler.fs
+++ b/src/FSharp.Compiler.LanguageServer/Handlers/CodeActionHandler.fs
@@ -762,9 +762,10 @@ type CodeActionHandler() =
)
let uri = request.TextDocument.Uri
+ let projectContext = tryGetProjectContext request.TextDocument
- let! diagnosticReport = context.Workspace.Query.GetDiagnosticsForFile uri
- let! parseResultsOpt, checkResultsOpt = context.Workspace.Query.GetParseAndCheckResultsForFile uri
+ 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
diff --git a/src/FSharp.Compiler.LanguageServer/Handlers/LanguageFeaturesHandler.fs b/src/FSharp.Compiler.LanguageServer/Handlers/LanguageFeaturesHandler.fs
index 5bf2431975a..1b3d7c6e6dc 100644
--- a/src/FSharp.Compiler.LanguageServer/Handlers/LanguageFeaturesHandler.fs
+++ b/src/FSharp.Compiler.LanguageServer/Handlers/LanguageFeaturesHandler.fs
@@ -25,16 +25,18 @@ type LanguageFeaturesHandler() =
=
cancellableTask {
let telemetry = context.LspServices.GetRequiredService()
+ let uri = request.TextDocument.Uri
+ let projectContext = tryGetProjectContext request.TextDocument
use _scope =
telemetry.ReportEventWithDuration(
TelemetryEvents.GetDiagnostics,
- [| "uri_hash", hash request.TextDocument.Uri :> obj |]
+ [| "uri_hash", hash uri :> obj |]
)
- let! fsharpDiagnosticReport = context.Workspace.Query.GetDiagnosticsForFile request.TextDocument.Uri
+ let! fsharpDiagnosticReport = context.Workspace.Query.GetDiagnosticsForFile(uri, ?context = projectContext)
- let snapshots = context.Workspace.Query.GetProjectSnapshotsForFile(request.TextDocument.Uri)
+ let snapshots = context.Workspace.Query.GetProjectSnapshotsForFile(uri)
let projects = snapshotsToProjectInfos snapshots
return
diff --git a/src/FSharp.Compiler.LanguageServer/Handlers/ProjectContextsHandler.fs b/src/FSharp.Compiler.LanguageServer/Handlers/ProjectContextsHandler.fs
index 529254da00f..82d328f638f 100644
--- a/src/FSharp.Compiler.LanguageServer/Handlers/ProjectContextsHandler.fs
+++ b/src/FSharp.Compiler.LanguageServer/Handlers/ProjectContextsHandler.fs
@@ -37,7 +37,7 @@ type ProjectContextsHandler() =
|> Array.map (fun snapshot ->
VSProjectContext(
Label = Path.GetFileNameWithoutExtension(snapshot.ProjectFileName),
- Id = makeProjectContextId(snapshot.ProjectFileName, snapshot.ProjectId),
+ Id = makeProjectContextId snapshot.Identifier,
Kind = VSProjectKind.FSharp
))
diff --git a/src/FSharp.Compiler.LanguageServer/Utils.fs b/src/FSharp.Compiler.LanguageServer/Utils.fs
index db616081c3b..31a431fbac0 100644
--- a/src/FSharp.Compiler.LanguageServer/Utils.fs
+++ b/src/FSharp.Compiler.LanguageServer/Utils.fs
@@ -19,23 +19,45 @@ module Utils =
type LspRange = Microsoft.VisualStudio.LanguageServer.Protocol.Range
- let makeProjectContextId (projectFileName: string, projectId: string option) =
- let guid =
- match projectId with
- | Some id ->
- match Guid.TryParse(id) with
- | true, g -> g
- | _ -> Guid(MD5.HashData(Encoding.UTF8.GetBytes(projectFileName)))
- | None -> Guid(MD5.HashData(Encoding.UTF8.GetBytes(projectFileName)))
-
- $"{guid}|{projectFileName}"
+ /// 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.ProjectFileName, s.ProjectId)
+ ProjectIdentifier = makeProjectContextId s.Identifier
))
let LspLogger (output: string -> unit) =
diff --git a/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs b/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs
index e93675f2482..53a04430c9e 100644
--- a/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs
+++ b/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs
@@ -96,7 +96,8 @@ internal class VsDiagnosticsHandler
[LanguageServerEndpoint(VSInternalMethods.DocumentPullDiagnosticName, LanguageServerConstants.DefaultLanguageName)]
public async Task HandleRequestAsync(VSInternalDiagnosticParams request, FSharpRequestContext context, CancellationToken cancellationToken)
{
- var report = await context.Workspace.Query.GetDiagnosticsForFile(request!.TextDocument!.Uri).Please(cancellationToken);
+ var projectContext = Utils.tryGetProjectContext(request!.TextDocument!);
+ var report = await context.Workspace.Query.GetDiagnosticsForFile(request.TextDocument!.Uri, projectContext).Please(cancellationToken);
var snapshots = context.Workspace.Query.GetProjectSnapshotsForFile(request.TextDocument.Uri);
var projects = Utils.snapshotsToProjectInfos(snapshots);
diff --git a/tests/FSharp.Compiler.LanguageServer.Tests/ProtocolHelpers.fs b/tests/FSharp.Compiler.LanguageServer.Tests/ProtocolHelpers.fs
index c0c73148442..8c8dd23094f 100644
--- a/tests/FSharp.Compiler.LanguageServer.Tests/ProtocolHelpers.fs
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/ProtocolHelpers.fs
@@ -172,3 +172,37 @@ let openAndRequestCodeActions (client: TestRpcClient) (fileUri: Uri) (content: s
let! _diags = pullDiagnostics client fileUri
return! requestCodeActions client fileUri line
}
+
+let pullDiagnosticsInContext (client: TestRpcClient) (fileUri: Uri) (projectContext: VSProjectContext) =
+ task {
+ let! response =
+ client.JsonRpc.InvokeAsync>(
+ 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 configA =
+ ProjectConfig(
+ projectFileName,
+ outputFileName = Some (System.IO.Path.Combine(dir, $"out.{defineA}.dll")),
+ referencesOnDisk = [],
+ otherOptions = [ $"--define:{defineA}" ])
+ let configB =
+ ProjectConfig(
+ projectFileName,
+ outputFileName = Some (System.IO.Path.Combine(dir, $"out.{defineB}.dll")),
+ referencesOnDisk = [],
+ otherOptions = [ $"--define:{defineB}" ])
+
+ let pidA = client.Workspace.Projects.AddOrUpdate(configA, [ fileOnDisk.LocalPath ])
+ let pidB = client.Workspace.Projects.AddOrUpdate(configB, [ fileOnDisk.LocalPath ])
+ fileOnDisk, pidA, pidB
From cf995b2d9da610dad7551aab0d353633eba13768 Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Thu, 11 Jun 2026 12:21:16 +0200
Subject: [PATCH 10/30] Default diagnostics rollout to old extension
Set the effective default of fsharp.getDiagnosticsFrom to 'old' so the
in-proc (old) extension serves diagnostics and the LSP diagnostics
endpoint stays off by default (but remains switchable to lsp/both).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../FSharpLanguageServerProvider.cs | 4 ++--
.../FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs b/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs
index 53a04430c9e..39366c573b1 100644
--- a/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs
+++ b/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs
@@ -259,14 +259,14 @@ internal class FSharpLanguageServerProvider : LanguageServerProvider
foreach (var (setting, value) in settingValues.Where(x => x.Item2 == FSharpExtensionSettings.UNSET))
{
await this.Extensibility.Settings().WriteAsync(batch =>
- batch.WriteSetting(setting, FSharpExtensionSettings.BOTH), "write default settings", cancellationToken);
+ batch.WriteSetting(setting, FSharpExtensionSettings.OLD), "write default settings", cancellationToken);
}
var enabled = new[] { FSharpExtensionSettings.LSP, FSharpExtensionSettings.BOTH };
var serverConfig = new FSharpLanguageServerConfig(
new FSharpLanguageServerFeatures(
- diagnostics: enabled.Contains(settingsReadResult.ValueOrDefault(FSharpExtensionSettings.GetDiagnosticsFrom, defaultValue: FSharpExtensionSettings.BOTH)),
+ diagnostics: enabled.Contains(settingsReadResult.ValueOrDefault(FSharpExtensionSettings.GetDiagnosticsFrom, defaultValue: FSharpExtensionSettings.OLD)),
codeActions: true
));
diff --git a/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs b/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs
index 4f38cc94bdd..21a0fe6df4b 100644
--- a/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs
+++ b/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs
@@ -50,7 +50,7 @@ type internal FSharpDocumentDiagnosticAnalyzer
let settingsManager = serviceProvider.GetService(typeof) :?> Microsoft.VisualStudio.Utilities.UnifiedSettings.ISettingsManager
let reader = settingsManager.GetReader()
reader.GetValueOrThrow("fsharp.getDiagnosticsFrom")
- with _ -> "both"
+ with _ -> "old"
match settingValue with
| "both"
| "old" -> true
From 0f889f612185575f7aef88dc812f6dad620419e0 Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Thu, 11 Jun 2026 12:25:56 +0200
Subject: [PATCH 11/30] Re-introduce semantic tokens endpoint and disable LSP
diagnostics
Stacks the semantic-tokens-rollout work on top of the diagnostics
rollout branch: adds the textDocument/semanticTokens endpoint and
disables the LSP diagnostics endpoint (the in-proc extension stays
authoritative for diagnostics until that rollout graduates).
This reconstructs the tree of the original semantic-tokens-rollout-prep
branch (preserved as backup) on top of lsp-diagnostics-rollout.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../Common/CapabilitiesManager.fs | 15 +-
.../FSharp.Compiler.LanguageServer.fsproj | 2 +
.../FSharpLanguageServer.fs | 1 +
.../FSharpLanguageServerConfig.fs | 2 +
.../Handlers/SemanticTokensHandler.fs | 77 ++
.../SemanticTokens.fs | 430 ++++++++++
.../Telemetry.fs | 11 +-
.../.vsextension/string-resources.json | 5 +-
.../FSharpExtensionSettings.cs | 30 +-
.../FSharpLanguageServerProvider.cs | 67 +-
.../SemanticClassificationRegressions.fs | 41 +
.../FSharpApexTestBase.cs | 30 +-
.../Fixtures/SyntaxHighlightingSample.fs | 87 ++
.../SemanticHighlightingTests.cs | 132 +++
.../CodeActionProtocolTests.fs | 2 +-
.../DiagnosticsProtocolTests.fs | 2 +-
...Sharp.Compiler.LanguageServer.Tests.fsproj | 3 +
.../ProtocolHelpers.fs | 27 +
.../SemanticTokensHelpers.fs | 135 +++
.../SemanticTokensProtocolTests.fs | 799 ++++++++++++++++++
.../Classification/ClassificationService.fs | 199 +++--
.../Diagnostics/DocumentDiagnosticAnalyzer.fs | 32 +-
.../LanguageService/WorkspaceExtensions.fs | 57 --
23 files changed, 1940 insertions(+), 246 deletions(-)
create mode 100644 src/FSharp.Compiler.LanguageServer/Handlers/SemanticTokensHandler.fs
create mode 100644 src/FSharp.Compiler.LanguageServer/SemanticTokens.fs
create mode 100644 tests/FSharp.Compiler.LanguageServer.IntegrationTests/Fixtures/SyntaxHighlightingSample.fs
create mode 100644 tests/FSharp.Compiler.LanguageServer.IntegrationTests/SemanticHighlightingTests.cs
create mode 100644 tests/FSharp.Compiler.LanguageServer.Tests/SemanticTokensHelpers.fs
create mode 100644 tests/FSharp.Compiler.LanguageServer.Tests/SemanticTokensProtocolTests.fs
diff --git a/src/FSharp.Compiler.LanguageServer/Common/CapabilitiesManager.fs b/src/FSharp.Compiler.LanguageServer/Common/CapabilitiesManager.fs
index 88cdd7d0dfe..daac044f200 100644
--- a/src/FSharp.Compiler.LanguageServer/Common/CapabilitiesManager.fs
+++ b/src/FSharp.Compiler.LanguageServer/Common/CapabilitiesManager.fs
@@ -36,14 +36,19 @@ type CapabilitiesManager(config: FSharpLanguageServerConfig, scOverrides: IServe
(if config.EnabledFeatures.CodeActions then
System.Nullable(
SumType(
- CodeActionOptions(
- CodeActionKinds = [| CodeActionKind.QuickFix |],
- ResolveProvider = false
- )
+ CodeActionOptions(CodeActionKinds = [| CodeActionKind.QuickFix |], ResolveProvider = false)
)
)
else
- System.Nullable())
+ System.Nullable()),
+ SemanticTokensOptions =
+ addIf
+ config.EnabledFeatures.SemanticTokens
+ (SemanticTokensOptions(
+ 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))
)
diff --git a/src/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.fsproj b/src/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.fsproj
index fbff4700197..86911cbea56 100644
--- a/src/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.fsproj
+++ b/src/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.fsproj
@@ -44,11 +44,13 @@
+
+
diff --git a/src/FSharp.Compiler.LanguageServer/FSharpLanguageServer.fs b/src/FSharp.Compiler.LanguageServer/FSharpLanguageServer.fs
index 7cf4e2f68b2..689870e67c5 100644
--- a/src/FSharp.Compiler.LanguageServer/FSharpLanguageServer.fs
+++ b/src/FSharp.Compiler.LanguageServer/FSharpLanguageServer.fs
@@ -68,6 +68,7 @@ type FSharpLanguageServer
.AddSingleton>()
.AddSingleton()
.AddSingleton()
+ .AddSingleton()
.AddSingleton()
.AddSingleton()
.AddSingleton(logger)
diff --git a/src/FSharp.Compiler.LanguageServer/FSharpLanguageServerConfig.fs b/src/FSharp.Compiler.LanguageServer/FSharpLanguageServerConfig.fs
index e312a766c9e..4e21a7fde96 100644
--- a/src/FSharp.Compiler.LanguageServer/FSharpLanguageServerConfig.fs
+++ b/src/FSharp.Compiler.LanguageServer/FSharpLanguageServerConfig.fs
@@ -4,12 +4,14 @@ type FSharpLanguageServerFeatures =
{
Diagnostics: bool
CodeActions: bool
+ SemanticTokens: bool
}
static member Default =
{
Diagnostics = true
CodeActions = true
+ SemanticTokens = true
}
type FSharpLanguageServerConfig =
diff --git a/src/FSharp.Compiler.LanguageServer/Handlers/SemanticTokensHandler.fs b/src/FSharp.Compiler.LanguageServer/Handlers/SemanticTokensHandler.fs
new file mode 100644
index 00000000000..337f10419ee
--- /dev/null
+++ b/src/FSharp.Compiler.LanguageServer/Handlers/SemanticTokensHandler.fs
@@ -0,0 +1,77 @@
+namespace FSharp.Compiler.LanguageServer.Handlers
+
+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
+
+open System.Threading
+
+#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 uri = textDocument.Uri
+ let projectContext = tryGetProjectContext textDocument
+ let telemetry = context.LspServices.GetRequiredService()
+
+ use _scope =
+ telemetry.ReportEventWithDuration(TelemetryEvents.GetSemanticTokens, [| "uri_hash", hash uri :> obj |])
+
+ let! source = context.Workspace.Query.GetSource uri
+
+ match source with
+ | None -> return LspSemanticTokens.emptyResponse ()
+ | Some source ->
+ let! semanticClassification = context.Workspace.Query.GetSemanticClassification(uri, ?context = projectContext)
+
+ let semanticItems =
+ match semanticClassification with
+ | None -> [||]
+ | Some view ->
+ let items = ResizeArray()
+ view.ForEach items.Add
+ items.ToArray()
+
+ let parsingOptions = this.GetParsingOptions(uri, context, projectContext)
+
+ return LspSemanticTokens.responseFor source uri.LocalPath semanticItems range parsingOptions
+ }
+ |> 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..892fb354d83
--- /dev/null
+++ b/src/FSharp.Compiler.LanguageServer/SemanticTokens.fs
@@ -0,0 +1,430 @@
+namespace FSharp.Compiler.LanguageServer
+
+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
+ 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
+ | _ ->
+ 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 =
+ [|
+ let startLine = range.StartLine - 1
+ let endLine = range.EndLine - 1
+
+ if startLine >= 0 && endLine >= startLine then
+ for line in startLine..endLine do
+ 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) =
+ items
+ |> Array.collect (fun item ->
+ match mapSemanticClassification item.Type with
+ | Some tokenType ->
+ let fixedRange = fixupRange source tokenType item.Range
+ splitRange source fixedRange tokenType [] TokenSource.Semantic
+ | None -> [||])
+
+ let fromTokenizer (parsingOptions: FSharpParsingOptions option) (fileName: string) (source: ISourceText) =
+ let defines, langVersion, strictIndentation =
+ match parsingOptions with
+ | Some opts -> CompilerEnvironment.GetConditionalDefinesForEditing opts, Some opts.LangVersionText, opts.StrictIndentation
+ | None -> [], None, None
+
+ let tokenizer =
+ FSharpSourceTokenizer(defines, Some fileName, langVersion, strictIndentation)
+
+ let mutable state = FSharpTokenizerLexState.Initial
+
+ [|
+ for line in 0 .. source.GetLineCount() - 1 do
+ 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 ->
+ 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 -> ()
+ | 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 (tokens: NormalizedToken array) =
+ let sorted =
+ tokens
+ |> Array.sortBy (fun token -> sourcePriority token.Source, token.Line, token.StartChar, -token.Length)
+
+ let accepted = ResizeArray()
+
+ for token in sorted do
+ match token.Source with
+ | TokenSource.Semantic ->
+ if not (accepted |> Seq.exists (fun existing -> overlaps existing token)) then
+ accepted.Add token
+ | TokenSource.Syntactic ->
+ let fragments =
+ ([ token ], accepted)
+ ||> Seq.fold (fun fragments existing -> fragments |> List.collect (subtractOverlap existing))
+
+ for fragment in fragments do
+ if
+ fragment.Length > 0
+ && not (accepted |> Seq.exists (fun existing -> overlaps existing fragment))
+ then
+ accepted.Add 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 =
+ let tokens =
+ Array.append (fromSemanticClassifications source semanticItems) (fromTokenizer parsingOptions fileName source)
+ |> resolveOverlaps
+ |> filterRange range
+
+ SemanticTokens(Data = encode tokens)
diff --git a/src/FSharp.Compiler.LanguageServer/Telemetry.fs b/src/FSharp.Compiler.LanguageServer/Telemetry.fs
index 913baea1f6b..014d61b4fb1 100644
--- a/src/FSharp.Compiler.LanguageServer/Telemetry.fs
+++ b/src/FSharp.Compiler.LanguageServer/Telemetry.fs
@@ -29,6 +29,9 @@ module TelemetryEvents =
[]
let GetProjectContexts = "lsp/getprojectcontexts"
+ []
+ let GetSemanticTokens = "lsp/getsemantictokens"
+
[]
let ServerFault = "lsp/fault"
@@ -67,9 +70,7 @@ type TraceTelemetry() =
interface ILspTelemetry with
member _.ReportEvent(name, properties) =
let propsStr =
- properties
- |> Array.map (fun (k, v) -> $"{k}={v}")
- |> String.concat ", "
+ properties |> Array.map (fun (k, v) -> $"{k}={v}") |> String.concat ", "
Trace.TraceInformation($"[Telemetry] {name} ({propsStr})")
@@ -82,9 +83,7 @@ type TraceTelemetry() =
sw.Stop()
let propsStr =
- properties
- |> Array.map (fun (k, v) -> $"{k}={v}")
- |> String.concat ", "
+ properties |> Array.map (fun (k, v) -> $"{k}={v}") |> String.concat ", "
Trace.TraceInformation($"[Telemetry] {name} completed in {sw.ElapsedMilliseconds}ms ({propsStr})")
}
diff --git a/src/FSharp.VisualStudio.Extension/.vsextension/string-resources.json b/src/FSharp.VisualStudio.Extension/.vsextension/string-resources.json
index 5e669b53287..22f8b021339 100644
--- a/src/FSharp.VisualStudio.Extension/.vsextension/string-resources.json
+++ b/src/FSharp.VisualStudio.Extension/.vsextension/string-resources.json
@@ -2,9 +2,8 @@
"FSharpLspExtension.FSharpLanguageServerProvider.DisplayName": "FSharp Analyzer LSP server",
"F#": "F# LSP",
"FSharpSettings.Old": "Old extension (devenv process)",
- "FSharpSettings.Both": "Both extensions",
"FSharpSettings.LSP": "LSP (separate process)",
"FSharpSettings.Unset": "Default",
- "FSharpSettings.GetDiagnosticsFrom": "Get diagnostics from",
- "FSharpSettings.GetDiagnosticsFrom.Description": "Which extension provides diagnostics (restart required)"
+ "FSharpSettings.SemanticClassificationProvider": "Semantic Classification Provider",
+ "FSharpSettings.SemanticClassificationProvider.Description": "Which extension provides semantic classification (restart required)"
}
diff --git a/src/FSharp.VisualStudio.Extension/FSharpExtensionSettings.cs b/src/FSharp.VisualStudio.Extension/FSharpExtensionSettings.cs
index 9279500ef93..89a2e9cc136 100644
--- a/src/FSharp.VisualStudio.Extension/FSharpExtensionSettings.cs
+++ b/src/FSharp.VisualStudio.Extension/FSharpExtensionSettings.cs
@@ -1,4 +1,5 @@
-using Microsoft.VisualStudio.Extensibility;
+using System.Linq;
+using Microsoft.VisualStudio.Extensibility;
using Microsoft.VisualStudio.Extensibility.Settings;
namespace FSharp.VisualStudio.Extension
@@ -9,14 +10,14 @@ internal static class FSharpExtensionSettings
{
public const string OLD = "old";
public const string LSP = "lsp";
- public const string BOTH = "both";
public const string UNSET = "unset";
- public static EnumSettingEntry[] ExtensionChoice { get; } =
+ // Semantic classification can only come from one source at a time
+ // (the two would fight over the same spans), so there is no "both".
+ public static EnumSettingEntry[] ExtensionEitherChoice { get; } =
[
new(OLD, "%FSharpSettings.Old%"),
new(LSP, "%FSharpSettings.LSP%"),
- new(BOTH, "%FSharpSettings.Both%"),
new(UNSET, "%FSharpSettings.Unset%"),
];
@@ -25,19 +26,26 @@ internal static class FSharpExtensionSettings
public static SettingCategory FSharpCategory { get; } = new("fsharp", "%F#%");
[VisualStudioContribution]
- public static Setting.Enum GetDiagnosticsFrom { get; } = new(
- "getDiagnosticsFrom",
- "%FSharpSettings.GetDiagnosticsFrom%",
+ public static Setting.Enum SemanticClassificationProvider { get; } = new(
+ "semanticClassificationProvider",
+ "%FSharpSettings.SemanticClassificationProvider%",
FSharpCategory,
- ExtensionChoice,
+ ExtensionEitherChoice,
defaultValue: UNSET)
{
- Description = "%FSharpSettings.GetDiagnosticsFrom.Description%",
+ Description = "%FSharpSettings.SemanticClassificationProvider.Description%",
};
- public static Setting[] AllStringSettings { get; } =
+ // Each setting carries the concrete value to persist (and treat as effective)
+ // the first time it is read as UNSET. Semantic classification defaults to
+ // OLD so the in-proc classifier stays in charge until the user opts in
+ // (or is flipped into the LSP path by a controlled rollout).
+ public static (Setting Setting, string DefaultIfUnset)[] AllSettingsWithDefaults { get; } =
[
- GetDiagnosticsFrom,
+ (SemanticClassificationProvider, OLD),
];
+
+ public static Setting[] AllStringSettings { get; } =
+ AllSettingsWithDefaults.Select(x => x.Setting).ToArray();
}
}
diff --git a/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs b/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs
index 39366c573b1..1581139a16d 100644
--- a/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs
+++ b/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs
@@ -44,45 +44,19 @@ internal class VsServerCapabilitiesOverride : IServerCapabilitiesOverride
{
public ServerCapabilities OverrideServerCapabilities(FSharpLanguageServerConfig config, ServerCapabilities value, ClientCapabilities clientCapabilities)
{
+ // Only the semantic-tokens capability is advertised. Diagnostics and
+ // code actions are unconditionally suppressed so the in-proc (devenv)
+ // implementations remain authoritative until the LSP server's
+ // diagnostics + code actions are graduated in a follow-up rollout.
+ // Project contexts stay on because they're metadata for the client, not UX.
var capabilities = new VSInternalServerCapabilities
{
TextDocumentSync = value.TextDocumentSync,
- SupportsDiagnosticRequests = config.EnabledFeatures.Diagnostics,
+ SupportsDiagnosticRequests = false,
ProjectContextProvider = true,
- CodeActionProvider = config.EnabledFeatures.CodeActions
- ? new CodeActionOptions
- {
- CodeActionKinds = [CodeActionKind.QuickFix],
- ResolveProvider = false,
- }
- : null,
- DiagnosticProvider =
- config.EnabledFeatures.Diagnostics ?
-
- new()
- {
- SupportsMultipleContextsDiagnostics = false,
- DiagnosticKinds = [
- // Support a specialized requests dedicated to task-list items. This way the client can ask just
- // for these, independently of other diagnostics. They can also throttle themselves to not ask if
- // the task list would not be visible.
- //VSInternalDiagnosticKind.Task,
- // Dedicated request for workspace-diagnostics only. We will only respond to these if FSA is on.
- VSInternalDiagnosticKind.Syntax,
- // Fine-grained diagnostics requests. Importantly, this separates out syntactic vs semantic
- // requests, allowing the former to quickly reach the user without blocking on the latter. In a
- // similar vein, compiler diagnostics are explicitly distinct from analyzer-diagnostics, allowing
- // the former to appear as soon as possible as they are much more critical for the user and should
- // not be delayed by a slow analyzer.
- //new("Semantic"),
- //new(PullDiagnosticCategories.DocumentAnalyzerSyntax),
- //new(PullDiagnosticCategories.DocumentAnalyzerSemantic),
- ]
- } : null,
- //HoverProvider = new HoverOptions()
- //{
- // WorkDoneProgress = true
- //}
+ SemanticTokensOptions = value.SemanticTokensOptions,
+ CodeActionProvider = null,
+ DiagnosticProvider = null,
};
return capabilities;
}
@@ -253,21 +227,27 @@ internal class FSharpLanguageServerProvider : LanguageServerProvider
var settingsReadResult = await this.Extensibility.Settings().ReadEffectiveValuesAsync(FSharpExtensionSettings.AllStringSettings, cancellationToken);
- var settingValues = FSharpExtensionSettings.AllStringSettings.Select(
- setting => (setting, settingsReadResult.ValueOrDefault(setting, defaultValue: FSharpExtensionSettings.UNSET)));
+ var settingValues = FSharpExtensionSettings.AllSettingsWithDefaults.Select(
+ x => (x.Setting, x.DefaultIfUnset, value: settingsReadResult.ValueOrDefault(x.Setting, defaultValue: FSharpExtensionSettings.UNSET)));
- foreach (var (setting, value) in settingValues.Where(x => x.Item2 == FSharpExtensionSettings.UNSET))
+ foreach (var (setting, defaultIfUnset, value) in settingValues.Where(x => x.value == FSharpExtensionSettings.UNSET))
{
await this.Extensibility.Settings().WriteAsync(batch =>
- batch.WriteSetting(setting, FSharpExtensionSettings.OLD), "write default settings", cancellationToken);
+ batch.WriteSetting(setting, defaultIfUnset), "write default settings", cancellationToken);
}
- var enabled = new[] { FSharpExtensionSettings.LSP, FSharpExtensionSettings.BOTH };
+ var effectiveSettingValues = settingValues.ToDictionary(
+ x => x.Setting,
+ x => x.value == FSharpExtensionSettings.UNSET ? x.DefaultIfUnset : x.value);
+ // Only semantic tokens are wired up. Diagnostics and code actions stay
+ // false here so the capabilities override (which also hardcodes them
+ // off) and the feature config agree.
var serverConfig = new FSharpLanguageServerConfig(
new FSharpLanguageServerFeatures(
- diagnostics: enabled.Contains(settingsReadResult.ValueOrDefault(FSharpExtensionSettings.GetDiagnosticsFrom, defaultValue: FSharpExtensionSettings.OLD)),
- codeActions: true
+ diagnostics: false,
+ codeActions: false,
+ semanticTokens: effectiveSettingValues[FSharpExtensionSettings.SemanticClassificationProvider] == FSharpExtensionSettings.LSP
));
var disposeToEndSubscription =
@@ -343,8 +323,7 @@ await this.Extensibility.Settings().WriteAsync(batch =>
await projectQuery(project.AsQueryable()).SubscribeAsync(observer, CancellationToken.None);
- // TODO: should we do this, or are we guaranteed it will get processed?
- // observer.ProcessProject(project);
+ //observer.ProcessProject(project);
}
var ((inputStream, outputStream), _server) = FSharpLanguageServer.Create(workspace, serverConfig, (serviceCollection) =>
diff --git a/tests/FSharp.Compiler.ComponentTests/FSharpChecker/SemanticClassificationRegressions.fs b/tests/FSharp.Compiler.ComponentTests/FSharpChecker/SemanticClassificationRegressions.fs
index d77b221db87..215ebab34d4 100644
--- a/tests/FSharp.Compiler.ComponentTests/FSharpChecker/SemanticClassificationRegressions.fs
+++ b/tests/FSharp.Compiler.ComponentTests/FSharpChecker/SemanticClassificationRegressions.fs
@@ -864,3 +864,44 @@ let () = r.Count <- r.Count + 1
sprintf "Expected MutableRecordField classification for `Count`, found: %A"
(items |> Array.map (fun i -> i.Range.StartLine, i.Range.StartColumn, i.Range.EndColumn, i.Type)))
}
+/// Computation expression builders like `async { }` are captured by FCS as BOTH
+/// `Item.Value` (the identifier resolution) and `Item.CustomBuilder` (the builder usage)
+/// at the same range. `takeCustomBuilder` deduplicates them in favor of the
+/// `Item.CustomBuilder` so the identifier shows up as a `ComputationExpression` (rendered
+/// as a keyword by editors). Before the fix, the deduplication only ran when callers
+/// passed a `Some range`; whole-file callers (`None` range, used by the LSP background
+/// classifier) saw `Item.Value` win and `async` rendered as a regular value.
+[]
+let ``Computation expression builder is classified as ComputationExpression (whole-file)`` () =
+ let source =
+ """
+module Test
+
+let _ =
+ async {
+ let! v = async.Return 1
+ return v
+ }
+"""
+
+ let items = getClassifications source
+
+ // Line 5 contains " async {". Look for a classification covering 'async' (col 4..9).
+ let asyncBuilderItems =
+ items
+ |> Array.filter (fun item ->
+ item.Range.StartLine = 5
+ && item.Range.StartColumn = 4
+ && item.Range.EndColumn = 9)
+
+ let computationExpressionItems =
+ asyncBuilderItems
+ |> Array.filter (fun item -> item.Type = SemanticClassificationType.ComputationExpression)
+
+ Assert.True(
+ computationExpressionItems.Length = 1,
+ sprintf
+ "Expected exactly one ComputationExpression classification for 'async' on line 5, got %d. All classifications at that span: %A"
+ computationExpressionItems.Length
+ (asyncBuilderItems |> Array.map (fun i -> i.Type))
+ )
diff --git a/tests/FSharp.Compiler.LanguageServer.IntegrationTests/FSharpApexTestBase.cs b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/FSharpApexTestBase.cs
index 27f2396e754..5de11fbe18f 100644
--- a/tests/FSharp.Compiler.LanguageServer.IntegrationTests/FSharpApexTestBase.cs
+++ b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/FSharpApexTestBase.cs
@@ -34,14 +34,32 @@ private static VisualStudioHostConfiguration CreateConfiguration()
[TestInitialize]
public void Initialize()
{
- if (VsHost?.IsRunning != true)
+ if (VsHost?.IsRunning == true)
{
- // Previous host may be a zombie (crashed but not disposed).
- // Dispose it before creating a new one.
- try { VsHost?.Stop(); } catch { /* best effort cleanup */ }
- VsHost = Operations.CreateHost(s_configuration);
- VsHost.Start();
+ 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]
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/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
index 5c8916f8dee..5bc6b82bf02 100644
--- a/tests/FSharp.Compiler.LanguageServer.Tests/CodeActionProtocolTests.fs
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/CodeActionProtocolTests.fs
@@ -191,7 +191,7 @@ let ``FS0043 produces both == and != fixes for respective operators`` () =
[]
let ``Server with code actions disabled does not advertise code action capabilities`` () =
task {
- let config = { FSharpLanguageServerConfig.Default with EnabledFeatures = { Diagnostics = true; CodeActions = false } }
+ let config = { FSharpLanguageServerConfig.Default with EnabledFeatures = { FSharpLanguageServerFeatures.Default with Diagnostics = true; CodeActions = false } }
let! client = createLanguageServer (FSharpWorkspace()) (Some config)
Assert.False(client.Capabilities.CodeActionProvider.HasValue)
}
diff --git a/tests/FSharp.Compiler.LanguageServer.Tests/DiagnosticsProtocolTests.fs b/tests/FSharp.Compiler.LanguageServer.Tests/DiagnosticsProtocolTests.fs
index 2f024d27959..b13ec04662a 100644
--- a/tests/FSharp.Compiler.LanguageServer.Tests/DiagnosticsProtocolTests.fs
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/DiagnosticsProtocolTests.fs
@@ -172,7 +172,7 @@ let ``Diagnostics for file not in any project`` () =
[]
let ``Server with diagnostics disabled does not advertise diagnostic capabilities`` () =
task {
- let config = { FSharpLanguageServerConfig.Default with EnabledFeatures = { Diagnostics = false; CodeActions = false } }
+ let config = { FSharpLanguageServerConfig.Default with EnabledFeatures = { FSharpLanguageServerFeatures.Default with Diagnostics = false; CodeActions = false } }
let! client = createLanguageServer (FSharpWorkspace()) (Some config)
Assert.Null(client.Capabilities.DiagnosticOptions)
}
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 1ea97918b7f..25512cb0eda 100644
--- a/tests/FSharp.Compiler.LanguageServer.Tests/FSharp.Compiler.LanguageServer.Tests.fsproj
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/FSharp.Compiler.LanguageServer.Tests.fsproj
@@ -17,10 +17,13 @@
+
+
+
diff --git a/tests/FSharp.Compiler.LanguageServer.Tests/ProtocolHelpers.fs b/tests/FSharp.Compiler.LanguageServer.Tests/ProtocolHelpers.fs
index 8c8dd23094f..168888b4fb4 100644
--- a/tests/FSharp.Compiler.LanguageServer.Tests/ProtocolHelpers.fs
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/ProtocolHelpers.fs
@@ -173,6 +173,16 @@ let openAndRequestCodeActions (client: TestRpcClient) (fileUri: Uri) (content: s
return! requestCodeActions client fileUri line
}
+let pullSemanticTokensFull (client: TestRpcClient) (fileUri: Uri) =
+ client.JsonRpc.InvokeAsync(
+ Methods.TextDocumentSemanticTokensFullName,
+ SemanticTokensParams(TextDocument = TextDocumentIdentifier(Uri = fileUri)))
+
+let pullSemanticTokensFullInContext (client: TestRpcClient) (fileUri: Uri) (projectContext: VSProjectContext) =
+ client.JsonRpc.InvokeAsync(
+ Methods.TextDocumentSemanticTokensFullName,
+ SemanticTokensParams(TextDocument = VSTextDocumentIdentifier(Uri = fileUri, ProjectContext = projectContext)))
+
let pullDiagnosticsInContext (client: TestRpcClient) (fileUri: Uri) (projectContext: VSProjectContext) =
task {
let! response =
@@ -206,3 +216,20 @@ let setupMultiTargetLikeProject (client: TestRpcClient) (content: string) (share
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.InvokeAsync(
+ 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..e4495332089
--- /dev/null
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/SemanticTokensProtocolTests.fs
@@ -0,0 +1,799 @@
+module LanguageServer.SemanticTokensProtocolTests
+
+open System
+open Xunit
+
+open Microsoft.VisualStudio.LanguageServer.Protocol
+open FSharp.Compiler.CodeAnalysis.Workspace
+open FSharp.Compiler.LanguageServer
+
+open LanguageServer.ProtocolHelpers
+open LanguageServer.SemanticTokensHelpers
+
+#nowarn "57"
+
+// ---------------------------------------------------------------------------
+// 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")
+ }
+
+[]
+let ``Semantic tokens response has a resultId when delta support is advertised`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let supportsDelta =
+ let options = client.Capabilities.SemanticTokensOptions
+
+ if isNull (box options) || not options.Full.HasValue then
+ false
+ else
+ let mutable fullOptions = Unchecked.defaultof
+ options.Full.Value.TryGetSecond(&fullOptions) && fullOptions.Delta
+
+ let content = "let x = 1"
+ let fileOnDisk = setupSingleFileProject client content
+ let! result = openAndPullSemanticTokens client fileOnDisk content
+ Assert.NotNull(result)
+
+ Assert.True(
+ not supportsDelta || not (String.IsNullOrEmpty(result.ResultId)),
+ "ResultId should be present when semantic token delta support is advertised"
+ )
+ }
+
+// ---------------------------------------------------------------------------
+// Encoding Tests
+// ---------------------------------------------------------------------------
+
+[]
+let ``Tokens on same line have relative deltaStart`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+ // "let x = 1" should have tokens on line 0: "let" at col 0, "x" at col 4, "1" at col 8 (approx)
+ let content = "let x = 1"
+ let fileOnDisk = setupSingleFileProject client content
+ let! result = openAndPullSemanticTokens client fileOnDisk content
+ Assert.NotNull(result)
+
+ let tokens = decodeSemanticTokens legend result.Data
+ let line0Tokens = findTokensOnLine 0 tokens
+
+ Assert.True(
+ line0Tokens.Length >= 2,
+ $"Expected at least 2 tokens on line 0 for 'let x = 1', got {line0Tokens.Length}"
+ )
+
+ // All tokens should be on line 0
+ for t in line0Tokens do
+ Assert.Equal(0, t.Line)
+
+ // In the raw data, consecutive same-line tokens should have deltaLine = 0
+ let tokenCount = result.Data.Length / 5
+
+ if tokenCount >= 2 then
+ let secondDeltaLine = result.Data[5]
+
+ if secondDeltaLine = 0 then
+ // deltaStart should be relative (positive, representing gap from first token)
+ let secondDeltaStart = result.Data[6]
+ Assert.True(secondDeltaStart > 0, "Second same-line token should have positive relative deltaStart")
+ }
+
+[]
+let ``Tokens on different lines have absolute deltaStart`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+ let content = "let x = 1\nlet y = 2"
+ let fileOnDisk = setupSingleFileProject client content
+ let! result = openAndPullSemanticTokens client fileOnDisk content
+ Assert.NotNull(result)
+
+ let tokens = decodeSemanticTokens legend result.Data
+
+ // Find the first token on line 1
+ let line1Tokens = findTokensOnLine 1 tokens
+ Assert.True(line1Tokens.Length > 0, "Expected tokens on line 1")
+
+ // The first token on line 1 ("let" at col 0) should have startChar = 0
+ let firstLine1Token = line1Tokens |> List.minBy (fun t -> t.StartChar)
+ Assert.Equal(0, firstLine1Token.StartChar)
+ }
+
+// ---------------------------------------------------------------------------
+// Integration Tests - F# Classification
+// ---------------------------------------------------------------------------
+
+[]
+[]
+[]
+[]
+[]
+let ``Keywords are classified as keyword`` (source: string, marker: string, expectedType: string) =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+ let cleanSource, markerPositions = stripMarkers source
+ let fileOnDisk = setupSingleFileProject client cleanSource
+ let! result = openAndPullSemanticTokens client fileOnDisk cleanSource
+ Assert.NotNull(result)
+
+ let tokens = decodeSemanticTokens legend result.Data
+ let token = findTokenAtMarker markerPositions marker tokens
+ Assert.True(token.IsSome, $"Expected a token at marker {marker}")
+ Assert.Equal(expectedType, token.Value.TokenType)
+ }
+
+[]
+let ``Namespace is classified as namespace name for VS highlighting`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+ let source = "open (*1*)System"
+ let cleanSource, markerPositions = stripMarkers source
+ let fileOnDisk = setupSingleFileProject client cleanSource
+ let! result = openAndPullSemanticTokens client fileOnDisk cleanSource
+ Assert.NotNull(result)
+
+ let tokens = decodeSemanticTokens legend result.Data
+ let token = findTokenAtMarker markerPositions "(*1*)" tokens
+ Assert.True(token.IsSome, "Expected a token for 'System'")
+ Assert.Equal("namespace name", token.Value.TokenType)
+ }
+
+[]
+let ``Reference type is classified as class name for VS highlighting`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+ let source = "type (*1*)Foo() = class end"
+ let cleanSource, markerPositions = stripMarkers source
+ let fileOnDisk = setupSingleFileProject client cleanSource
+ let! result = openAndPullSemanticTokens client fileOnDisk cleanSource
+ Assert.NotNull(result)
+
+ let tokens = decodeSemanticTokens legend result.Data
+ let token = findTokenAtMarker markerPositions "(*1*)" tokens
+ Assert.True(token.IsSome, "Expected a token for 'Foo'")
+ Assert.Equal("class name", token.Value.TokenType)
+ }
+
+[]
+let ``Value type is classified as struct name for VS highlighting`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+ let source = "[]\ntype (*1*)Bar = { X: int }"
+ let cleanSource, markerPositions = stripMarkers source
+ let fileOnDisk = setupSingleFileProject client cleanSource
+ let! result = openAndPullSemanticTokens client fileOnDisk cleanSource
+ Assert.NotNull(result)
+
+ let tokens = decodeSemanticTokens legend result.Data
+ let token = findTokenAtMarker markerPositions "(*1*)" tokens
+ Assert.True(token.IsSome, "Expected a token for 'Bar'")
+ Assert.Equal("struct name", token.Value.TokenType)
+ }
+
+[]
+let ``Interface is classified as interface name for VS highlighting`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+ let source = "type (*1*)IFoo = abstract member Bar: unit -> unit"
+ let cleanSource, markerPositions = stripMarkers source
+ let fileOnDisk = setupSingleFileProject client cleanSource
+ let! result = openAndPullSemanticTokens client fileOnDisk cleanSource
+ Assert.NotNull(result)
+
+ let tokens = decodeSemanticTokens legend result.Data
+ let token = findTokenAtMarker markerPositions "(*1*)" tokens
+ Assert.True(token.IsSome, "Expected a token for 'IFoo'")
+ Assert.Equal("interface name", token.Value.TokenType)
+ }
+
+[]
+let ``Enum is classified as enum name for VS highlighting`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+ let source = "type (*1*)Color = Red = 0 | Green = 1 | Blue = 2"
+ let cleanSource, markerPositions = stripMarkers source
+ let fileOnDisk = setupSingleFileProject client cleanSource
+ let! result = openAndPullSemanticTokens client fileOnDisk cleanSource
+ Assert.NotNull(result)
+
+ let tokens = decodeSemanticTokens legend result.Data
+ let token = findTokenAtMarker markerPositions "(*1*)" tokens
+ Assert.True(token.IsSome, "Expected a token for 'Color'")
+ Assert.Equal("enum name", token.Value.TokenType)
+ }
+
+[]
+let ``Union case is classified as enum name for VS highlighting`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+ let source = "type Shape = (*1*)Circle | Square\nlet s = (*2*)Circle"
+ let cleanSource, markerPositions = stripMarkers source
+ let fileOnDisk = setupSingleFileProject client cleanSource
+ let! result = openAndPullSemanticTokens client fileOnDisk cleanSource
+ Assert.NotNull(result)
+
+ let tokens = decodeSemanticTokens legend result.Data
+
+ // Definition of union case
+ let defToken = findTokenAtMarker markerPositions "(*1*)" tokens
+ Assert.True(defToken.IsSome, "Expected a token for 'Circle' definition")
+ Assert.Equal("enum name", defToken.Value.TokenType)
+
+ // Usage of union case
+ let useToken = findTokenAtMarker markerPositions "(*2*)" tokens
+ Assert.True(useToken.IsSome, "Expected a token for 'Circle' usage")
+ Assert.Equal("enum name", useToken.Value.TokenType)
+ }
+
+[]
+let ``Type parameter is classified as type parameter name for VS highlighting`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+ let source = "let f (x: (*1*)'a) = x"
+ let cleanSource, markerPositions = stripMarkers source
+ let fileOnDisk = setupSingleFileProject client cleanSource
+ let! result = openAndPullSemanticTokens client fileOnDisk cleanSource
+ Assert.NotNull(result)
+
+ let tokens = decodeSemanticTokens legend result.Data
+ let token = findTokenAtMarker markerPositions "(*1*)" tokens
+ Assert.True(token.IsSome, "Expected a token for type parameter 'a")
+ Assert.Equal("type parameter name", token.Value.TokenType)
+ }
+
+[]
+let ``Function is classified with the F#-specific function token type`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+ let source = "let (*1*)add x y = x + y"
+ let cleanSource, markerPositions = stripMarkers source
+ let fileOnDisk = setupSingleFileProject client cleanSource
+ let! result = openAndPullSemanticTokens client fileOnDisk cleanSource
+ Assert.NotNull(result)
+
+ let tokens = decodeSemanticTokens legend result.Data
+ let token = findTokenAtMarker markerPositions "(*1*)" tokens
+ Assert.True(token.IsSome, "Expected a token for 'add'")
+ Assert.Equal("FSharp.Function", token.Value.TokenType)
+ }
+
+[]
+let ``Semantic tokens classify names and printf specifiers for VS highlighting`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+
+ let source =
+ "namespace (*6*)Sample\n\nmodule (*7*)Test =\n\n let (*1*)hello (*2*)name =\n (*3*)printfn \"Hello (*5*)%s\" (*4*)name"
+
+ let cleanSource, markerPositions = stripMarkers source
+ let fileOnDisk = setupSingleFileProject client cleanSource
+ let! result = openAndPullSemanticTokens client fileOnDisk cleanSource
+ Assert.NotNull(result)
+
+ let tokens = decodeSemanticTokens legend result.Data
+
+ let assertToken marker expectedType message =
+ let token = findTokenAtMarker markerPositions marker tokens
+ Assert.True(token.IsSome, message)
+ Assert.Equal(expectedType, token.Value.TokenType)
+
+ assertToken "(*1*)" "FSharp.Function" "Expected an F# function token for 'hello'"
+ assertToken "(*2*)" "local name" "Expected a local-name token for the 'name' parameter definition"
+ assertToken "(*3*)" "FSharp.Function" "Expected an F# function token for 'printfn'"
+ assertToken "(*4*)" "local name" "Expected a local-name token for the 'name' parameter usage"
+ assertToken "(*5*)" "FSharp.Printf" "Expected an F# printf token for the '%s' specifier"
+ assertToken "(*6*)" "namespace name" "Expected a namespace-name token for 'Sample'"
+ assertToken "(*7*)" "class name" "Expected a class-name token for the module 'Test'"
+ }
+
+[]
+let ``Method is classified as method name for VS highlighting`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+ let source = "type T() =\n member _.(*1*)Foo() = 1"
+ let cleanSource, markerPositions = stripMarkers source
+ let fileOnDisk = setupSingleFileProject client cleanSource
+ let! result = openAndPullSemanticTokens client fileOnDisk cleanSource
+ Assert.NotNull(result)
+
+ let tokens = decodeSemanticTokens legend result.Data
+ let token = findTokenAtMarker markerPositions "(*1*)" tokens
+ Assert.True(token.IsSome, "Expected a token for 'Foo'")
+ Assert.Equal("method name", token.Value.TokenType)
+ }
+
+[]
+let ``Property is classified as property name for VS highlighting`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+ let source = "type T() =\n member _.(*1*)Value = 42"
+ let cleanSource, markerPositions = stripMarkers source
+ let fileOnDisk = setupSingleFileProject client cleanSource
+ let! result = openAndPullSemanticTokens client fileOnDisk cleanSource
+ Assert.NotNull(result)
+
+ let tokens = decodeSemanticTokens legend result.Data
+ let token = findTokenAtMarker markerPositions "(*1*)" tokens
+ Assert.True(token.IsSome, "Expected a token for 'Value'")
+ Assert.Equal("property name", token.Value.TokenType)
+ }
+
+[]
+let ``Record field is classified as field name for VS highlighting`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+ let source = "type R = { (*1*)Name: string }"
+ let cleanSource, markerPositions = stripMarkers source
+ let fileOnDisk = setupSingleFileProject client cleanSource
+ let! result = openAndPullSemanticTokens client fileOnDisk cleanSource
+ Assert.NotNull(result)
+
+ let tokens = decodeSemanticTokens legend result.Data
+ let token = findTokenAtMarker markerPositions "(*1*)" tokens
+ Assert.True(token.IsSome, "Expected a token for 'Name'")
+ Assert.Equal("property name", token.Value.TokenType)
+ }
+
+[]
+let ``Operator is classified as operator`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+ let source = "let x = 1 (*1*)+ 2"
+ let cleanSource, markerPositions = stripMarkers source
+ let fileOnDisk = setupSingleFileProject client cleanSource
+ let! result = openAndPullSemanticTokens client fileOnDisk cleanSource
+ Assert.NotNull(result)
+
+ let tokens = decodeSemanticTokens legend result.Data
+ let token = findTokenAtMarker markerPositions "(*1*)" tokens
+ Assert.True(token.IsSome, "Expected a token for '+'")
+ Assert.Equal(SemanticTokenTypes.Operator, token.Value.TokenType)
+ }
+
+[]
+let ``Event is classified as event name for VS highlighting`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+ let source =
+ "open System\ntype T() =\n let ev = new Event()\n []\n member _.(*1*)MyEvent = ev.Publish"
+
+ let cleanSource, markerPositions = stripMarkers source
+ let fileOnDisk = setupSingleFileProject client cleanSource
+ let! result = openAndPullSemanticTokens client fileOnDisk cleanSource
+ Assert.NotNull(result)
+
+ let tokens = decodeSemanticTokens legend result.Data
+ let token = findTokenAtMarker markerPositions "(*1*)" tokens
+ Assert.True(token.IsSome, "Expected a token for 'MyEvent'")
+ Assert.Equal("event name", token.Value.TokenType)
+ }
+
+// ---------------------------------------------------------------------------
+// Multiline / Positional Tests
+// ---------------------------------------------------------------------------
+
+[]
+let ``All tokens in multiline code are correctly positioned`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+
+ 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 tokens = decodeSemanticTokens legend result.Data
+
+ // Verify tokens span multiple lines
+ let lines = tokens |> List.map (fun t -> t.Line) |> List.distinct |> List.sort
+ Assert.True(lines.Length >= 2, $"Expected tokens on at least 2 lines, got lines: {lines}")
+
+ // Validate ordering
+ validateTokenOrdering tokens
+ }
+
+[]
+let ``Non-ASCII content does not corrupt token positions`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+ // The emoji "😀" is 2 UTF-16 code units (a surrogate pair)
+ let content = "let s = \"😀\"\nlet (*1*)x = 1"
+ let cleanSource, markerPositions = stripMarkers content
+ let fileOnDisk = setupSingleFileProject client cleanSource
+ let! result = openAndPullSemanticTokens client fileOnDisk cleanSource
+ Assert.NotNull(result)
+
+ let tokens = decodeSemanticTokens legend result.Data
+ let token = findTokenAtMarker markerPositions "(*1*)" tokens
+ Assert.True(token.IsSome, "Expected a token for 'x' after emoji string")
+
+ // "x" should be on line 1, at col 4 (after "let ")
+ Assert.Equal(1, token.Value.Line)
+ Assert.Equal(4, token.Value.StartChar)
+ }
+
+// ---------------------------------------------------------------------------
+// Baseline Comparison: LSP vs Compiler API
+// ---------------------------------------------------------------------------
+
+[]
+let ``Semantic tokens cover the same ranges as compiler GetSemanticClassification`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+
+ let content =
+ "module Test\nopen System\ntype Foo() =\n member _.Bar() = 42\nlet f x = x + 1"
+
+ let fileOnDisk = setupSingleFileProject client content
+ let! result = openAndPullSemanticTokens client fileOnDisk content
+ Assert.NotNull(result)
+
+ let lspTokens = decodeSemanticTokens legend result.Data
+
+ // We just verify that the LSP endpoint returns a meaningful number of tokens
+ // covering multiple lines. The exact mapping is tested in individual classification tests above.
+ Assert.True(lspTokens.Length >= 5, $"Expected at least 5 tokens, got {lspTokens.Length}")
+
+ let coveredLines =
+ lspTokens |> List.map (fun t -> t.Line) |> List.distinct
+
+ Assert.True(
+ coveredLines.Length >= 3,
+ $"Expected tokens on at least 3 lines, got {coveredLines.Length} lines: {coveredLines}"
+ )
+
+ // All token types should be valid legend entries (no )
+ for t in lspTokens do
+ Assert.False(
+ t.TokenType.StartsWith("]
+let ``Qualified type reference classifies each segment separately``
+ ()
+ =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+
+ // FCS produces classifications at the outer long-ident range; without per-segment
+ // trimming the DisposableType classification would cover the whole "System.IO.FileStream"
+ // and the Namespace classifications at "System" and "System.IO" would be hidden.
+ let source =
+ "let _ = typeof<(*1*)System.(*2*)IO.(*3*)FileStream>"
+
+ let cleanSource, markerPositions = stripMarkers source
+ let fileOnDisk = setupSingleFileProject client cleanSource
+ let! result = openAndPullSemanticTokens client fileOnDisk cleanSource
+ Assert.NotNull(result)
+
+ let tokens = decodeSemanticTokens legend result.Data
+
+ let assertToken marker expectedType message =
+ let token = findTokenAtMarker markerPositions marker tokens
+ Assert.True(token.IsSome, message)
+ Assert.Equal(expectedType, token.Value.TokenType)
+
+ assertToken "(*1*)" "namespace name" "Expected 'System' to be classified as a namespace"
+ assertToken "(*2*)" "namespace name" "Expected 'IO' to be classified as a namespace"
+ assertToken "(*3*)" "FSharp.DisposableType" "Expected 'FileStream' to be classified as a disposable type"
+ }
+
+[]
+let ``Qualified function reference classifies module and function separately`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+
+ let source =
+ "module Say =\n let hello name = printfn \"Hello %s\" name\n\nlet _ = (*1*)Say.(*2*)hello \"world\""
+
+ let cleanSource, markerPositions = stripMarkers source
+ let fileOnDisk = setupSingleFileProject client cleanSource
+ let! result = openAndPullSemanticTokens client fileOnDisk cleanSource
+ Assert.NotNull(result)
+
+ let tokens = decodeSemanticTokens legend result.Data
+
+ let assertToken marker expectedType message =
+ let token = findTokenAtMarker markerPositions marker tokens
+ Assert.True(token.IsSome, message)
+ Assert.Equal(expectedType, token.Value.TokenType)
+
+ assertToken "(*1*)" "class name" "Expected the 'Say' module to be classified as a module/class"
+ assertToken "(*2*)" "FSharp.Function" "Expected the 'hello' function reference to be classified as a function"
+ }
+
+[]
+let ``Backticked qualified identifier keeps only the backticked tail`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+
+ // The fixupRange logic must keep the entire backticked tail intact rather than
+ // trimming after the last '.' inside the backticks.
+ let source =
+ "module Mod =\n let ``my fun`` () = 1\n\nlet _ = (*1*)Mod.(*2*)``my fun`` ()"
+
+ let cleanSource, markerPositions = stripMarkers source
+ let fileOnDisk = setupSingleFileProject client cleanSource
+ let! result = openAndPullSemanticTokens client fileOnDisk cleanSource
+ Assert.NotNull(result)
+
+ let tokens = decodeSemanticTokens legend result.Data
+
+ let assertToken marker expectedType message =
+ let token = findTokenAtMarker markerPositions marker tokens
+ Assert.True(token.IsSome, message)
+ Assert.Equal(expectedType, token.Value.TokenType)
+
+ assertToken "(*1*)" "class name" "Expected the 'Mod' module to be classified as a module/class"
+ assertToken "(*2*)" "FSharp.Function" "Expected the backticked function to be classified as a function"
+ }
+
+[]
+let ``Computation expression builder is classified as keyword`` () =
+ task {
+ let! client = initializeLanguageServer None
+ let legend = getSemanticTokensLegend client
+
+ // FCS captures the builder identifier as both Item.Value and Item.CustomBuilder; only
+ // the latter produces the ComputationExpression classification. The whole-file path
+ // (range = None) must apply the same dedup as the ranged path so 'async' renders as a
+ // keyword, the same way the legacy classifier paints it.
+ let source =
+ "let _ = (*1*)async {\n let! v = async.Return 1\n return v\n}"
+
+ let cleanSource, markerPositions = stripMarkers source
+ let fileOnDisk = setupSingleFileProject client cleanSource
+ let! result = openAndPullSemanticTokens client fileOnDisk cleanSource
+ Assert.NotNull(result)
+
+ let tokens = decodeSemanticTokens legend result.Data
+ let token = findTokenAtMarker markerPositions "(*1*)" tokens
+ Assert.True(token.IsSome, "Expected a token for the 'async' builder identifier")
+ Assert.Equal(SemanticTokenTypes.Keyword, token.Value.TokenType)
+ }
diff --git a/vsintegration/src/FSharp.Editor/Classification/ClassificationService.fs b/vsintegration/src/FSharp.Editor/Classification/ClassificationService.fs
index 2c99aaee42c..5b13f70a4e2 100644
--- a/vsintegration/src/FSharp.Editor/Classification/ClassificationService.fs
+++ b/vsintegration/src/FSharp.Editor/Classification/ClassificationService.fs
@@ -7,12 +7,16 @@ open System.Composition
open System.Collections.Generic
open System.Collections.Immutable
open System.Threading
+open System.Threading.Tasks
open System.Runtime.Caching
open Microsoft.CodeAnalysis
open Microsoft.CodeAnalysis.Classification
open Microsoft.CodeAnalysis.Text
open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Classification
+open Microsoft.Internal.VisualStudio.Shell.Interop
+open Microsoft.VisualStudio.Settings
+open Microsoft.VisualStudio.Shell
open FSharp.Compiler.CodeAnalysis
open FSharp.Compiler.EditorServices
@@ -31,7 +35,27 @@ type SemanticClassificationData = SemanticClassificationView
type SemanticClassificationLookup = IReadOnlyDictionary>
[)>]
-type internal FSharpClassificationService [] () =
+type internal FSharpClassificationService [] (serviceProvider: SVsServiceProvider) =
+
+ let produceSemanticClassification =
+ let settingValue =
+ try
+ let settingsManager =
+ serviceProvider.GetService(typeof)
+ :?> Microsoft.VisualStudio.Utilities.UnifiedSettings.ISettingsManager
+
+ let reader = settingsManager.GetReader()
+ reader.GetValueOrThrow("fsharp.semanticClassificationProvider")
+ with _ ->
+ // Setting not yet written or settings manager unavailable: fall back
+ // to the legacy default so users keep seeing in-proc colouring.
+ "old"
+
+ // Only "lsp" disables the in-proc classifier; "old" and any unrecognised
+ // value (including the first-run "unset" sentinel) keep it on.
+ match settingValue with
+ | "lsp" -> false
+ | _ -> true
static let getLexicalClassifications (filePath: string, defines, text: SourceText, textSpan: TextSpan, ct: CancellationToken) =
@@ -202,97 +226,100 @@ type internal FSharpClassificationService [] () =
(document: Document, textSpan: TextSpan, result: List, cancellationToken: CancellationToken)
=
- cancellableTask {
- use _logBlock = Logger.LogBlock(LogEditorFunctionId.Classification_Semantic)
+ if not produceSemanticClassification then
+ Task.CompletedTask
+ else
+ cancellableTask {
+ use _logBlock = Logger.LogBlock(LogEditorFunctionId.Classification_Semantic)
+
+ let! sourceText = document.GetTextAsync(cancellationToken)
+
+ // If we are trying to get semantic classification for a document that is not open, get the results from the background and cache it.
+ // We do this for find all references when it is populating results.
+ // We cache it temporarily so we do not have to continuously call into the checker and perform a background operation.
+ let isOpenDocument = document.Project.Solution.Workspace.IsDocumentOpen document.Id
+
+ if not isOpenDocument then
+ match! unopenedDocumentsSemanticClassificationCache.TryGetValueAsync document with
+ | ValueSome classificationDataLookup ->
+ let eventProps: (string * obj) array =
+ [|
+ "context.document.project.id", document.Project.Id.Id.ToString()
+ "context.document.id", document.Id.Id.ToString()
+ "isOpenDocument", isOpenDocument
+ "textSpanLength", textSpan.Length
+ "cacheHit", true
+ |]
+
+ use _eventDuration =
+ TelemetryReporter.ReportSingleEventWithDuration(TelemetryEvents.AddSemanticClassifications, eventProps)
+
+ addSemanticClassificationByLookup sourceText textSpan classificationDataLookup result
+ | ValueNone ->
+ let eventProps: (string * obj) array =
+ [|
+ "context.document.project.id", document.Project.Id.Id.ToString()
+ "context.document.id", document.Id.Id.ToString()
+ "isOpenDocument", isOpenDocument
+ "textSpanLength", textSpan.Length
+ "cacheHit", false
+ |]
+
+ use _eventDuration =
+ TelemetryReporter.ReportSingleEventWithDuration(TelemetryEvents.AddSemanticClassifications, eventProps)
+
+ let! classificationData = document.GetFSharpSemanticClassificationAsync(nameof (FSharpClassificationService))
+
+ let classificationDataLookup = toSemanticClassificationLookup classificationData
+ do! unopenedDocumentsSemanticClassificationCache.SetAsync(document, classificationDataLookup)
+ addSemanticClassificationByLookup sourceText textSpan classificationDataLookup result
+ else
- let! sourceText = document.GetTextAsync(cancellationToken)
+ match! openedDocumentsSemanticClassificationCache.TryGetValueAsync document with
+ | ValueSome classificationDataLookup ->
+ let eventProps: (string * obj) array =
+ [|
+ "context.document.project.id", document.Project.Id.Id.ToString()
+ "context.document.id", document.Id.Id.ToString()
+ "isOpenDocument", isOpenDocument
+ "textSpanLength", textSpan.Length
+ "cacheHit", true
+ |]
- // If we are trying to get semantic classification for a document that is not open, get the results from the background and cache it.
- // We do this for find all references when it is populating results.
- // We cache it temporarily so we do not have to continuously call into the checker and perform a background operation.
- let isOpenDocument = document.Project.Solution.Workspace.IsDocumentOpen document.Id
+ use _eventDuration =
+ TelemetryReporter.ReportSingleEventWithDuration(TelemetryEvents.AddSemanticClassifications, eventProps)
- if not isOpenDocument then
- match! unopenedDocumentsSemanticClassificationCache.TryGetValueAsync document with
- | ValueSome classificationDataLookup ->
- let eventProps: (string * obj) array =
- [|
- "context.document.project.id", document.Project.Id.Id.ToString()
- "context.document.id", document.Id.Id.ToString()
- "isOpenDocument", isOpenDocument
- "textSpanLength", textSpan.Length
- "cacheHit", true
- |]
-
- use _eventDuration =
- TelemetryReporter.ReportSingleEventWithDuration(TelemetryEvents.AddSemanticClassifications, eventProps)
-
- addSemanticClassificationByLookup sourceText textSpan classificationDataLookup result
- | ValueNone ->
- let eventProps: (string * obj) array =
- [|
- "context.document.project.id", document.Project.Id.Id.ToString()
- "context.document.id", document.Id.Id.ToString()
- "isOpenDocument", isOpenDocument
- "textSpanLength", textSpan.Length
- "cacheHit", false
- |]
-
- use _eventDuration =
- TelemetryReporter.ReportSingleEventWithDuration(TelemetryEvents.AddSemanticClassifications, eventProps)
-
- let! classificationData = document.GetFSharpSemanticClassificationAsync(nameof (FSharpClassificationService))
-
- let classificationDataLookup = toSemanticClassificationLookup classificationData
- do! unopenedDocumentsSemanticClassificationCache.SetAsync(document, classificationDataLookup)
- addSemanticClassificationByLookup sourceText textSpan classificationDataLookup result
- else
+ addSemanticClassificationByLookup sourceText textSpan classificationDataLookup result
+ | ValueNone ->
- match! openedDocumentsSemanticClassificationCache.TryGetValueAsync document with
- | ValueSome classificationDataLookup ->
- let eventProps: (string * obj) array =
- [|
- "context.document.project.id", document.Project.Id.Id.ToString()
- "context.document.id", document.Id.Id.ToString()
- "isOpenDocument", isOpenDocument
- "textSpanLength", textSpan.Length
- "cacheHit", true
- |]
-
- use _eventDuration =
- TelemetryReporter.ReportSingleEventWithDuration(TelemetryEvents.AddSemanticClassifications, eventProps)
-
- addSemanticClassificationByLookup sourceText textSpan classificationDataLookup result
- | ValueNone ->
-
- let eventProps: (string * obj) array =
- [|
- "context.document.project.id", document.Project.Id.Id.ToString()
- "context.document.id", document.Id.Id.ToString()
- "isOpenDocument", isOpenDocument
- "textSpanLength", textSpan.Length
- "cacheHit", false
- |]
-
- use _eventDuration =
- TelemetryReporter.ReportSingleEventWithDuration(TelemetryEvents.AddSemanticClassifications, eventProps)
-
- let! _, checkResults = document.GetFSharpParseAndCheckResultsAsync(nameof (IFSharpClassificationService))
-
- let targetRange =
- RoslynHelpers.TextSpanToFSharpRange(document.FilePath, textSpan, sourceText)
-
- let classificationData =
- checkResults.GetSemanticClassification(Some targetRange, RelatedSymbolUseKind.All)
-
- if classificationData.Length > 0 then
- let classificationDataLookup = itemToSemanticClassificationLookup classificationData
- do! unopenedDocumentsSemanticClassificationCache.SetAsync(document, classificationDataLookup)
+ let eventProps: (string * obj) array =
+ [|
+ "context.document.project.id", document.Project.Id.Id.ToString()
+ "context.document.id", document.Id.Id.ToString()
+ "isOpenDocument", isOpenDocument
+ "textSpanLength", textSpan.Length
+ "cacheHit", false
+ |]
+
+ use _eventDuration =
+ TelemetryReporter.ReportSingleEventWithDuration(TelemetryEvents.AddSemanticClassifications, eventProps)
+
+ let! _, checkResults = document.GetFSharpParseAndCheckResultsAsync(nameof (IFSharpClassificationService))
+
+ let targetRange =
+ RoslynHelpers.TextSpanToFSharpRange(document.FilePath, textSpan, sourceText)
+
+ let classificationData =
+ checkResults.GetSemanticClassification(Some targetRange, RelatedSymbolUseKind.All)
+
+ if classificationData.Length > 0 then
+ let classificationDataLookup = itemToSemanticClassificationLookup classificationData
+ do! unopenedDocumentsSemanticClassificationCache.SetAsync(document, classificationDataLookup)
addSemanticClassification sourceText textSpan classificationData result
- }
- |> CancellableTask.ifCanceledReturn ()
- |> CancellableTask.startAsTask cancellationToken
+ }
+ |> CancellableTask.ifCanceledReturn ()
+ |> CancellableTask.startAsTask cancellationToken
// Do not perform classification if we don't have project options (#defines matter)
member _.AdjustStaleClassification(_: SourceText, classifiedSpan: ClassifiedSpan) : ClassifiedSpan = classifiedSpan
diff --git a/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs b/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs
index 21a0fe6df4b..88541eb3150 100644
--- a/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs
+++ b/vsintegration/src/FSharp.Editor/Diagnostics/DocumentDiagnosticAnalyzer.fs
@@ -14,12 +14,9 @@ open Microsoft.CodeAnalysis
open Microsoft.CodeAnalysis.Text
open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Diagnostics
-open Microsoft.VisualStudio.Shell
open FSharp.Compiler.Diagnostics
open CancellableTasks
open Microsoft.VisualStudio.FSharp.Editor.Telemetry
-open Microsoft.VisualStudio.Settings
-open Microsoft.Internal.VisualStudio.Shell.Interop
[]
type internal DiagnosticsType =
@@ -38,29 +35,12 @@ type private CachedDiagnostics =
}
[); PartCreationPolicy(CreationPolicy.Shared)>]
-type internal FSharpDocumentDiagnosticAnalyzer
- []
- (
- [)>] serviceProvider: IServiceProvider
- ) =
-
- let produceDiagnostic =
- let settingValue =
- try
- let settingsManager = serviceProvider.GetService(typeof) :?> Microsoft.VisualStudio.Utilities.UnifiedSettings.ISettingsManager
- let reader = settingsManager.GetReader()
- reader.GetValueOrThrow("fsharp.getDiagnosticsFrom")
- with _ -> "old"
- match settingValue with
- | "both"
- | "old" -> true
- | "unset"
- | "lsp" -> false
- | _ -> true
-
-
- let shouldProduceDiagnostics (_document: Document) =
- produceDiagnostic
+type internal FSharpDocumentDiagnosticAnalyzer [] () =
+
+ // The in-proc (devenv) analyzer always produces diagnostics. The LSP
+ // diagnostics endpoint is intentionally not advertised, so there is no
+ // setting to consult here.
+ let shouldProduceDiagnostics (_document: Document) = true
// DocumentId keys survive solution snapshots, and holding them weakly means a document dropped from the
diff --git a/vsintegration/src/FSharp.Editor/LanguageService/WorkspaceExtensions.fs b/vsintegration/src/FSharp.Editor/LanguageService/WorkspaceExtensions.fs
index 7bc85d7528d..62525889bf9 100644
--- a/vsintegration/src/FSharp.Editor/LanguageService/WorkspaceExtensions.fs
+++ b/vsintegration/src/FSharp.Editor/LanguageService/WorkspaceExtensions.fs
@@ -15,8 +15,6 @@ open FSharp.Compiler.BuildGraph
open CancellableTasks
-open System.IO
-
open Internal.Utilities.Collections
open Newtonsoft.Json
open Newtonsoft.Json.Linq
@@ -33,67 +31,12 @@ module internal ProjectCache =
let Projects =
ConditionalWeakTable()
-module internal SolutionConfigCache =
-
- type FSharpExtensionConfig =
- {
- GetDiagnosticsFrom: string
- }
-
- static member Old = "old"
- static member Lsp = "lsp"
- static member Both = "both"
-
- static member Default =
- {
- GetDiagnosticsFrom = FSharpExtensionConfig.Both
- }
-
- member this.ShouldProduceDiagnostics() =
- Set.contains this.GetDiagnosticsFrom (set [ FSharpExtensionConfig.Old; FSharpExtensionConfig.Both ])
-
-
- let readFSharpExtensionConfig (solutionPath: string) =
- if String.IsNullOrEmpty(solutionPath) then
- System.Diagnostics.Trace.TraceWarning("Solution path is null or empty. Using default config.")
- FSharpExtensionConfig.Default
- else
- let configFilePath =
- Path.Combine(solutionPath, "extensibility.settings.VisualStudio.json")
-
- if File.Exists configFilePath then
- try
- let json = File.ReadAllText configFilePath
- let jObject = JObject.Parse json
-
- {
- GetDiagnosticsFrom = jObject["fsharp.getDiagnosticsFrom"].ToString().ToLower()
- }
- with ex ->
- System.Diagnostics.Trace.TraceError($"Error reading FSharpExtensionConfig from {configFilePath}", ex)
- FSharpExtensionConfig.Default
- else
- System.Diagnostics.Trace.TraceInformation(
- $"extensibility.settings.VisualStudio.json not found in {solutionPath}. Using default config."
- )
-
- FSharpExtensionConfig.Default
-
- let ExtensionConfig = ConditionalWeakTable()
-
type Solution with
/// Get the instance of IFSharpWorkspaceService.
member internal this.GetFSharpWorkspaceService() =
this.Workspace.Services.GetRequiredService()
- member internal this.GetFSharpExtensionConfig() =
- SolutionConfigCache.ExtensionConfig.GetValue(
- this,
- ConditionalWeakTable<_, _>.CreateValueCallback(fun _ ->
- SolutionConfigCache.readFSharpExtensionConfig (Path.GetDirectoryName this.FilePath))
- )
-
module internal FSharpProjectSnapshotSerialization =
let serializeFileSnapshot (snapshot: FSharpFileSnapshot) =
From fad499ff2bf765f7d392de1bc62387a73493a806 Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:28:16 +0200
Subject: [PATCH 12/30] Align rebased LSP projects with central package
management
Remove duplicate inline package pins, centralize LSP and Apex test dependencies, and adapt tokenizer and classification regression tests to current main APIs.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: df139595-f788-456b-908f-83de4c89548c
---
docs/release-notes/.VisualStudio/18.vNext.md | 1 +
eng/Packages.props | 15 +-----
eng/Versions.props | 2 +-
.../FSharp.Compiler.LanguageServer.fsproj | 6 +--
.../SemanticTokens.fs | 9 ++--
.../FSharp.VisualStudio.Extension.csproj | 5 --
.../SemanticClassificationRegressions.fs | 48 ++++++++++---------
...ler.LanguageServer.IntegrationTests.csproj | 7 ++-
...Sharp.Compiler.LanguageServer.Tests.fsproj | 3 +-
.../Classification/ClassificationService.fs | 2 +-
.../Diagnostics/DocumentDiagnosticAnalyzer.fs | 1 -
11 files changed, 38 insertions(+), 61 deletions(-)
diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md
index 776ec02f5b0..114421f3f40 100644
--- a/docs/release-notes/.VisualStudio/18.vNext.md
+++ b/docs/release-notes/.VisualStudio/18.vNext.md
@@ -36,6 +36,7 @@
* Rename "inline hints" to "inlay hints" in VS options for consistency with industry terminology. ([PR #19318](https://github.com/dotnet/fsharp/pull/19318))
* 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.
* 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/Packages.props b/eng/Packages.props
index 6a1d0ce9261..d1a940ee385 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)
-
-
@@ -81,6 +67,7 @@
+
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/src/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.fsproj b/src/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.fsproj
index 86911cbea56..10d23ee0272 100644
--- a/src/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.fsproj
+++ b/src/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.fsproj
@@ -15,11 +15,7 @@
-
-
-
-
-
+
diff --git a/src/FSharp.Compiler.LanguageServer/SemanticTokens.fs b/src/FSharp.Compiler.LanguageServer/SemanticTokens.fs
index 892fb354d83..1bb7f6acf8a 100644
--- a/src/FSharp.Compiler.LanguageServer/SemanticTokens.fs
+++ b/src/FSharp.Compiler.LanguageServer/SemanticTokens.fs
@@ -274,13 +274,12 @@ module LspSemanticTokens =
| None -> [||])
let fromTokenizer (parsingOptions: FSharpParsingOptions option) (fileName: string) (source: ISourceText) =
- let defines, langVersion, strictIndentation =
+ let defines, langVersion =
match parsingOptions with
- | Some opts -> CompilerEnvironment.GetConditionalDefinesForEditing opts, Some opts.LangVersionText, opts.StrictIndentation
- | None -> [], None, None
+ | Some opts -> CompilerEnvironment.GetConditionalDefinesForEditing opts, Some opts.LangVersionText
+ | None -> [], None
- let tokenizer =
- FSharpSourceTokenizer(defines, Some fileName, langVersion, strictIndentation)
+ let tokenizer = FSharpSourceTokenizer(defines, Some fileName, langVersion)
let mutable state = FSharpTokenizerLexState.Initial
diff --git a/src/FSharp.VisualStudio.Extension/FSharp.VisualStudio.Extension.csproj b/src/FSharp.VisualStudio.Extension/FSharp.VisualStudio.Extension.csproj
index 8f20a05ba48..e31b9163d9b 100644
--- a/src/FSharp.VisualStudio.Extension/FSharp.VisualStudio.Extension.csproj
+++ b/src/FSharp.VisualStudio.Extension/FSharp.VisualStudio.Extension.csproj
@@ -20,11 +20,6 @@
-
-
-
-
-
diff --git a/eng/Version.Details.props b/eng/Version.Details.props
index 1da73807b44..8590809a340 100644
--- a/eng/Version.Details.props
+++ b/eng/Version.Details.props
@@ -25,6 +25,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
@@ -33,6 +34,8 @@ This file should be imported by eng/Versions.props
5.14.0-1.26557.1
5.14.0-1.26557.1
5.14.0-1.26557.1
+
+ 10.0.10
@@ -56,6 +59,7 @@ This file should be imported by eng/Versions.props
$(optimizationwindows_ntx64MIBCRuntimePackageVersion)
$(optimizationwindows_ntx86MIBCRuntimePackageVersion)
+ $(MicrosoftCommonLanguageServerProtocolFrameworkPackageVersion)
$(MicrosoftCodeAnalysisPackageVersion)
$(MicrosoftCodeAnalysisCompilersPackageVersion)
$(MicrosoftCodeAnalysisCSharpPackageVersion)
@@ -64,5 +68,7 @@ This file should be imported by eng/Versions.props
$(MicrosoftCodeAnalysisFeaturesPackageVersion)
$(MicrosoftVisualStudioLanguageServicesPackageVersion)
$(MicrosoftVisualStudioLanguageServicesExternalAccessPackageVersion)
+
+ $(SystemTextJsonPackageVersion)
diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml
index a181c872f28..1153341dc63 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,12 @@
https://github.com/dotnet/dotnet
295ff616832ba1f8b192f8178c4188a133affe93
+
+
+ https://github.com/dotnet/runtime
+
+
+
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/FSharpRequestContext.fs b/src/FSharp.Compiler.LanguageServer/Common/FSharpRequestContext.fs
index 0f3da484971..4d466076c4b 100644
--- a/src/FSharp.Compiler.LanguageServer/Common/FSharpRequestContext.fs
+++ b/src/FSharp.Compiler.LanguageServer/Common/FSharpRequestContext.fs
@@ -29,7 +29,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 10d23ee0272..c0d35b9e2b3 100644
--- a/src/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.fsproj
+++ b/src/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.fsproj
@@ -16,6 +16,7 @@
+
diff --git a/src/FSharp.Compiler.LanguageServer/FSharpLanguageServer.fs b/src/FSharp.Compiler.LanguageServer/FSharpLanguageServer.fs
index 689870e67c5..c9a36dcbcb6 100644
--- a/src/FSharp.Compiler.LanguageServer/FSharpLanguageServer.fs
+++ b/src/FSharp.Compiler.LanguageServer/FSharpLanguageServer.fs
@@ -3,6 +3,8 @@ 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
@@ -23,10 +25,36 @@ module Stuff =
[]
let FSharpLanguageName = "F#"
- let createConfiguredSerializer () =
- let s = Newtonsoft.Json.JsonSerializer.CreateDefault()
- VSExtensionUtilities.AddVSExtensionConverters(s)
- s
+ // 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 =
@@ -38,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, createConfiguredSerializer (), logger)
+ inherit SystemTextJsonLanguageServer(jsonRpc, serializerOptions)
let config = defaultArg config FSharpLanguageServerConfig.Default
let initialWorkspace = defaultArg initialWorkspace (FSharpWorkspace())
@@ -76,7 +104,7 @@ type FSharpLanguageServer
.AddSingleton, FShapRequestContextFactory>()
.AddSingleton, CapabilitiesManager>()
.AddSingleton(this)
- .AddSingleton(new LspServiceLifeCycleManager())
+ .AddSingleton(new LspServiceLifeCycleManager(logger))
match addExtraHandlers with
| Some handler -> handler.Invoke(serviceCollection)
@@ -105,8 +133,7 @@ type FSharpLanguageServer
let struct (clientStream, serverStream) = FullDuplexStream.CreatePair()
// TODO: handle disposal of these
- let formatter = new JsonMessageFormatter()
- VSExtensionUtilities.AddVSExtensionConverters(formatter.JsonSerializer)
+ let formatter = createConfiguredFormatter ()
let messageHandler =
new HeaderDelimitedMessageHandler(serverStream, serverStream, formatter)
@@ -119,7 +146,14 @@ type FSharpLanguageServer
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/Utils.fs b/src/FSharp.Compiler.LanguageServer/Utils.fs
index 31a431fbac0..aba24ac0311 100644
--- a/src/FSharp.Compiler.LanguageServer/Utils.fs
+++ b/src/FSharp.Compiler.LanguageServer/Utils.fs
@@ -36,6 +36,7 @@ module Utils =
None
else
let parts = contextId.Split('|', 3)
+
if parts.Length = 3 then
Some(FSharpProjectIdentifier(parts[2], parts[1]))
else
@@ -62,21 +63,33 @@ module Utils =
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``}"
}
@@ -100,6 +113,7 @@ type FSharpDiagnosticExtensions =
| FSharpDiagnosticSeverity.Warning -> DiagnosticSeverity.Warning
| FSharpDiagnosticSeverity.Info -> DiagnosticSeverity.Information
| FSharpDiagnosticSeverity.Hidden -> DiagnosticSeverity.Hint
+
VSDiagnostic(
Range = this.Range.ToLspRange(),
Severity = severity,
diff --git a/src/Microsoft.CommonLanguageServerProtocol.Framework.Proxy/Microsoft.CommonLanguageServerProtocol.Framework.Proxy.csproj b/src/Microsoft.CommonLanguageServerProtocol.Framework.Proxy/Microsoft.CommonLanguageServerProtocol.Framework.Proxy.csproj
index 3c060dec7b4..88293f87d9e 100644
--- a/src/Microsoft.CommonLanguageServerProtocol.Framework.Proxy/Microsoft.CommonLanguageServerProtocol.Framework.Proxy.csproj
+++ b/src/Microsoft.CommonLanguageServerProtocol.Framework.Proxy/Microsoft.CommonLanguageServerProtocol.Framework.Proxy.csproj
@@ -4,6 +4,7 @@
net8.0
enable
enable
+ 14.0
diff --git a/tests/FSharp.Compiler.ComponentTests/FSharpChecker/SemanticClassificationRegressions.fs b/tests/FSharp.Compiler.ComponentTests/FSharpChecker/SemanticClassificationRegressions.fs
index 2d2d67d23d5..06be629594f 100644
--- a/tests/FSharp.Compiler.ComponentTests/FSharpChecker/SemanticClassificationRegressions.fs
+++ b/tests/FSharp.Compiler.ComponentTests/FSharpChecker/SemanticClassificationRegressions.fs
@@ -25,6 +25,29 @@ let private substringOfRange (source: string) (r: Range) =
let line = lines[r.StartLine - 1]
line.Substring(r.StartColumn, r.EndColumn - r.StartColumn)
+[]
+[]
+[]
+let ``CLI event declarations and uses are classified as events`` (binding: string) (memberDeclaration: string) (eventUse: string) =
+ task {
+ let source =
+ $"""module Test
+open System
+type T() =
+ {binding} ev = new Event()
+ []
+ {memberDeclaration} = ev.Publish
+let subscribe (t: T) = {eventUse}.Add(fun _ -> ())
+"""
+ let! items = getClassifications source
+ let events =
+ items
+ |> Array.filter (fun item -> (substringOfRange source item.Range).EndsWith("MyEvent", System.StringComparison.Ordinal))
+ Assert.Equal(2, events.Length)
+ for item in events do
+ Assert.Equal(SemanticClassificationType.Event, item.Type)
+ }
+
/// (#15290 regression) Copy-and-update record fields must not be classified as type names.
/// Before the fix, Item.Types was registered with mWholeExpr and ItemOccurrence.Use, producing
/// a wide type classification that overshadowed the correct RecordField classification.
diff --git a/tests/FSharp.Compiler.LanguageServer.Tests/DiagnosticsProtocolTests.fs b/tests/FSharp.Compiler.LanguageServer.Tests/DiagnosticsProtocolTests.fs
index b13ec04662a..9534fa8e9c1 100644
--- a/tests/FSharp.Compiler.LanguageServer.Tests/DiagnosticsProtocolTests.fs
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/DiagnosticsProtocolTests.fs
@@ -272,11 +272,11 @@ let ``Diagnostics include project information`` () =
let fileOnDisk = setupMultiProjectFile client notMutableCode [ projectName ]
let! response = openAndPullVsDiagnosticsRaw client fileOnDisk notMutableCode
let items = getVsDiagnosticItems response
- Assert.True(items.Count > 0, "Expected at least one diagnostic")
+ 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.Count > 0, "Expected at least one project in _vs_projects")
+ Assert.True(projects.Length > 0, "Expected at least one project in _vs_projects")
let projName = getVsProjectName projects[0]
Assert.Equal(projectName, projName)
}
@@ -289,7 +289,7 @@ let ``Diagnostics project identifier matches project context id format`` () =
let fileOnDisk = setupMultiProjectFile client notMutableCode [ projectName ]
let! response = openAndPullVsDiagnosticsRaw client fileOnDisk notMutableCode
let items = getVsDiagnosticItems response
- Assert.True(items.Count > 0, "Expected at least one diagnostic")
+ 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")
@@ -308,7 +308,7 @@ let ``Diagnostics for file in multiple projects include all project names`` () =
let fileOnDisk = setupMultiProjectFile client content [ "ProjectAlpha"; "ProjectBeta" ]
let! response = openAndPullVsDiagnosticsRaw client fileOnDisk content
let items = getVsDiagnosticItems response
- Assert.True(items.Count > 0, "Expected at least one diagnostic")
+ 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
@@ -324,7 +324,7 @@ let ``VS diagnostics for file not in any project return empty items`` () =
let fileOnDisk = sourceFileOnDisk notMutableCode
let! response = openAndPullVsDiagnosticsRaw client fileOnDisk notMutableCode
let items = getVsDiagnosticItems response
- Assert.Equal(0, items.Count)
+ Assert.Equal(0, items.Length)
}
[]
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/Protocol.fs b/tests/FSharp.Compiler.LanguageServer.Tests/Protocol.fs
index 42085606f8b..224d8260844 100644
--- a/tests/FSharp.Compiler.LanguageServer.Tests/Protocol.fs
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/Protocol.fs
@@ -1,6 +1,7 @@
module LanguageServer.Protocol
open System
+open System.Text.Json
open Xunit
open FSharp.Compiler.LanguageServer
@@ -11,6 +12,45 @@ open LanguageServer.ProtocolHelpers
#nowarn "57"
+[]
+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)))
+
+[]
+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`` () =
task {
@@ -70,4 +110,3 @@ let ``Server capabilities include text document sync`` () =
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
index 168888b4fb4..542e9493676 100644
--- a/tests/FSharp.Compiler.LanguageServer.Tests/ProtocolHelpers.fs
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/ProtocolHelpers.fs
@@ -7,6 +7,7 @@ open FSharp.Compiler.LanguageServer
open StreamJsonRpc
open System.IO
open System.Diagnostics
+open System.Text.Json
open Microsoft.VisualStudio.LanguageServer.Protocol
open Nerdbank.Streams
@@ -34,8 +35,7 @@ let createLanguageServer (workspace: FSharpWorkspace) (config: FSharpLanguageSer
| Some cfg -> FSharpLanguageServer.Create(LspLogger Trace.TraceInformation, workspace, config = cfg)
| None -> FSharpLanguageServer.Create(workspace)
- let formatter = new JsonMessageFormatter()
- VSExtensionUtilities.AddVSExtensionConverters(formatter.JsonSerializer)
+ let formatter = createConfiguredFormatter ()
let messageHandler =
new HeaderDelimitedMessageHandler(inputStream, outputStream, formatter)
@@ -56,7 +56,7 @@ let createLanguageServer (workspace: FSharpWorkspace) (config: FSharpLanguageSer
jsonRpc.StartListening()
task {
- let! response = jsonRpc.InvokeAsync("initialize", initializeParams)
+ let! response = jsonRpc.InvokeWithParameterObjectAsync("initialize", initializeParams)
return TestRpcClient(jsonRpc, rpcTrace, workspace, response)
}
@@ -71,25 +71,25 @@ let cleanCode = "let x = 1"
let notMutableCode = "let x = 1\nx <- 2"
let openDocument (client: TestRpcClient) (fileUri: Uri) (content: string) (version: int) =
- client.JsonRpc.NotifyAsync(
+ 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.NotifyAsync(
+ client.JsonRpc.NotifyWithParameterObjectAsync(
Methods.TextDocumentDidChangeName,
DidChangeTextDocumentParams(
TextDocument = VersionedTextDocumentIdentifier(Uri = fileUri, Version = version),
ContentChanges = [| TextDocumentContentChangeEvent(Text = content) |]))
let closeDocument (client: TestRpcClient) (fileUri: Uri) =
- client.JsonRpc.NotifyAsync(
+ client.JsonRpc.NotifyWithParameterObjectAsync(
Methods.TextDocumentDidCloseName,
DidCloseTextDocumentParams(TextDocument = TextDocumentIdentifier(Uri = fileUri)))
let pullDiagnosticResponse (client: TestRpcClient) (fileUri: Uri) =
- client.JsonRpc.InvokeAsync>(
+ client.JsonRpc.InvokeWithParameterObjectAsync>(
Methods.TextDocumentDiagnosticName,
DocumentDiagnosticParams(TextDocument = TextDocumentIdentifier(Uri = fileUri)))
@@ -112,7 +112,7 @@ let openAndPullDiagnostics (client: TestRpcClient) (fileUri: Uri) (content: stri
}
let pullVsDiagnosticsRaw(client: TestRpcClient) (fileUri: Uri) =
- client.JsonRpc.InvokeWithParameterObjectAsync(
+ client.JsonRpc.InvokeWithParameterObjectAsync(
Methods.TextDocumentDiagnosticName,
DocumentDiagnosticParams(TextDocument = TextDocumentIdentifier(Uri = fileUri)))
@@ -122,28 +122,20 @@ let openAndPullVsDiagnosticsRaw (client: TestRpcClient) (fileUri: Uri) (content:
return! pullVsDiagnosticsRaw client fileUri
}
-let getVsDiagnosticItems (response: Newtonsoft.Json.Linq.JToken) =
- let items = response["items"]
- Assert.True(items <> null, "Expected 'items' property in diagnostic response")
- items :?> Newtonsoft.Json.Linq.JArray
+let getVsDiagnosticItems (response: JsonElement) =
+ response.GetProperty("items").EnumerateArray() |> Seq.toArray
-let getVsProjects (diagnosticItem: Newtonsoft.Json.Linq.JToken) =
- let projects = diagnosticItem["_vs_projects"]
- Assert.True(projects <> null, "Expected '_vs_projects' property in VS diagnostic")
- projects :?> Newtonsoft.Json.Linq.JArray
+let getVsProjects (diagnosticItem: JsonElement) =
+ diagnosticItem.GetProperty("_vs_projects").EnumerateArray() |> Seq.toArray
-let getVsProjectName (project: Newtonsoft.Json.Linq.JToken) =
- let name = project["_vs_projectName"]
- Assert.True(name <> null, "Expected '_vs_projectName' property in project info")
- name.ToString()
+let getVsProjectName (project: JsonElement) =
+ project.GetProperty("_vs_projectName").GetString()
-let getVsProjectIdentifier (project: Newtonsoft.Json.Linq.JToken) =
- let id = project["_vs_projectIdentifier"]
- Assert.True(id <> null, "Expected '_vs_projectIdentifier' property in project info")
- id.ToString()
+let getVsProjectIdentifier (project: JsonElement) =
+ project.GetProperty("_vs_projectIdentifier").GetString()
let getProjectContexts (client: TestRpcClient) (fileUri: Uri) =
- client.JsonRpc.InvokeAsync(
+ client.JsonRpc.InvokeWithParameterObjectAsync(
"textDocument/_vs_getProjectContexts",
VSGetProjectContextsParams(TextDocument = TextDocumentItem(Uri = fileUri)))
@@ -158,7 +150,7 @@ let setupMultiProjectFile (client: TestRpcClient) (content: string) (projectName
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.InvokeAsync(
+ client.JsonRpc.InvokeWithParameterObjectAsync(
Methods.TextDocumentCodeActionName,
CodeActionParams(
TextDocument = TextDocumentIdentifier(Uri = fileUri),
@@ -174,19 +166,19 @@ let openAndRequestCodeActions (client: TestRpcClient) (fileUri: Uri) (content: s
}
let pullSemanticTokensFull (client: TestRpcClient) (fileUri: Uri) =
- client.JsonRpc.InvokeAsync(
+ client.JsonRpc.InvokeWithParameterObjectAsync(
Methods.TextDocumentSemanticTokensFullName,
SemanticTokensParams(TextDocument = TextDocumentIdentifier(Uri = fileUri)))
let pullSemanticTokensFullInContext (client: TestRpcClient) (fileUri: Uri) (projectContext: VSProjectContext) =
- client.JsonRpc.InvokeAsync(
+ 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.InvokeAsync>(
+ client.JsonRpc.InvokeWithParameterObjectAsync>(
Methods.TextDocumentDiagnosticName,
DocumentDiagnosticParams(TextDocument = VSTextDocumentIdentifier(Uri = fileUri, ProjectContext = projectContext)))
return response.First
@@ -218,7 +210,7 @@ let setupMultiTargetLikeProject (client: TestRpcClient) (content: string) (share
fileOnDisk, pidA, pidB
let pullSemanticTokensRange (client: TestRpcClient) (fileUri: Uri) (range: Range) =
- client.JsonRpc.InvokeAsync(
+ client.JsonRpc.InvokeWithParameterObjectAsync(
Methods.TextDocumentSemanticTokensRangeName,
SemanticTokensRangeParams(TextDocument = TextDocumentIdentifier(Uri = fileUri), Range = range))
diff --git a/tests/FSharp.Compiler.LanguageServer.Tests/SemanticTokensProtocolTests.fs b/tests/FSharp.Compiler.LanguageServer.Tests/SemanticTokensProtocolTests.fs
index e4495332089..535985eed7e 100644
--- a/tests/FSharp.Compiler.LanguageServer.Tests/SemanticTokensProtocolTests.fs
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/SemanticTokensProtocolTests.fs
@@ -587,7 +587,7 @@ let ``Event is classified as event name for VS highlighting`` () =
let! client = initializeLanguageServer None
let legend = getSemanticTokensLegend client
let source =
- "open System\ntype T() =\n let ev = new Event()\n []\n member _.(*1*)MyEvent = ev.Publish"
+ "open System\ntype T() =\n let ev = new Event()\n []\n member _.(*1*)MyEvent = ev.Publish\nlet subscribe (t: T) = t.(*2*)MyEvent.Add(fun _ -> ())"
let cleanSource, markerPositions = stripMarkers source
let fileOnDisk = setupSingleFileProject client cleanSource
@@ -595,9 +595,10 @@ let ``Event is classified as event name for VS highlighting`` () =
Assert.NotNull(result)
let tokens = decodeSemanticTokens legend result.Data
- let token = findTokenAtMarker markerPositions "(*1*)" tokens
- Assert.True(token.IsSome, "Expected a token for 'MyEvent'")
- Assert.Equal("event name", token.Value.TokenType)
+ for marker in [ "(*1*)"; "(*2*)" ] do
+ let token = findTokenAtMarker markerPositions marker tokens
+ Assert.True(token.IsSome, $"Expected a token for 'MyEvent' at {marker}")
+ Assert.Equal("event name", token.Value.TokenType)
}
// ---------------------------------------------------------------------------
From 9d0176c615845185597f286b68d90449dcc72e0a Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Tue, 13 Oct 2026 20:57:05 +0200
Subject: [PATCH 14/30] Fix LSP highlighting after target framework switches
Forward Roslyn active document contexts to the LSP server, resolve compiler output identities, and refresh classifications without stale responses. Add alternating-framework and refresh-retry protocol coverage.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5a0d205e-f647-4704-a3f6-1dcd028a0b44
---
docs/lsp.md | 13 ++
docs/release-notes/.VisualStudio/18.vNext.md | 1 +
.../Common/FSharpRequestContext.fs | 23 ++++
.../FSharpLanguageServer.fs | 4 +-
.../Handlers/DocumentStateHandler.fs | 11 +-
.../Handlers/ProjectContextsHandler.fs | 119 ++++++++++++++++--
.../Handlers/SemanticTokensHandler.fs | 10 +-
.../SemanticTokens.fs | 2 +
...CancellableSystemTextJsonLanguageServer.cs | 23 ++++
.../ProtocolHelpers.fs | 17 ++-
.../SemanticTokensProtocolTests.fs | 87 +++++++++++++
.../src/FSharp.Editor/FSharp.Editor.fsproj | 2 +
.../LspProjectContextListener.fs | 80 ++++++++++++
13 files changed, 366 insertions(+), 26 deletions(-)
create mode 100644 src/Microsoft.CommonLanguageServerProtocol.Framework.Proxy/CancellableSystemTextJsonLanguageServer.cs
create mode 100644 vsintegration/src/FSharp.Editor/LanguageService/LspProjectContextListener.fs
diff --git a/docs/lsp.md b/docs/lsp.md
index cde099e5d32..570090126e4 100644
--- a/docs/lsp.md
+++ b/docs/lsp.md
@@ -143,6 +143,19 @@ 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.
+
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.
diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md
index 008e6b86ddf..df6df79eb52 100644
--- a/docs/release-notes/.VisualStudio/18.vNext.md
+++ b/docs/release-notes/.VisualStudio/18.vNext.md
@@ -23,6 +23,7 @@
* Make Alt+F1 (momentary toggle) work for inlay hints. ([PR #19421](https://github.com/dotnet/fsharp/pull/19421))
* 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))
diff --git a/src/FSharp.Compiler.LanguageServer/Common/FSharpRequestContext.fs b/src/FSharp.Compiler.LanguageServer/Common/FSharpRequestContext.fs
index 4d466076c4b..b703dd9dee1 100644
--- a/src/FSharp.Compiler.LanguageServer/Common/FSharpRequestContext.fs
+++ b/src/FSharp.Compiler.LanguageServer/Common/FSharpRequestContext.fs
@@ -1,18 +1,41 @@
namespace FSharp.Compiler.LanguageServer.Common
open Microsoft.CommonLanguageServerProtocol.Framework
+open System
+open System.Collections.Concurrent
open System.Threading
open System.Threading.Tasks
open FSharp.Compiler.CodeAnalysis.Workspace
+open FSharp.Compiler.CodeAnalysis.ProjectSnapshot
#nowarn "57"
type FSharpRequestContext(lspServices: ILspServices, logger: ILspLogger, workspace: FSharpWorkspace) =
+ let pathComparer =
+ if OperatingSystem.IsWindows() then
+ StringComparer.OrdinalIgnoreCase
+ else
+ StringComparer.Ordinal
+
+ let activeProjectContexts =
+ ConcurrentDictionary(pathComparer)
+
member _.LspServices = lspServices
member _.Logger = logger
member _.Workspace = workspace
+ 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) =
let logger = lspServices.GetRequiredService()
diff --git a/src/FSharp.Compiler.LanguageServer/FSharpLanguageServer.fs b/src/FSharp.Compiler.LanguageServer/FSharpLanguageServer.fs
index c9a36dcbcb6..d4a8368f4d0 100644
--- a/src/FSharp.Compiler.LanguageServer/FSharpLanguageServer.fs
+++ b/src/FSharp.Compiler.LanguageServer/FSharpLanguageServer.fs
@@ -73,7 +73,7 @@ type FSharpLanguageServer
?config: FSharpLanguageServerConfig
) =
- inherit SystemTextJsonLanguageServer(jsonRpc, serializerOptions)
+ inherit CancellableSystemTextJsonLanguageServer(jsonRpc, serializerOptions)
let config = defaultArg config FSharpLanguageServerConfig.Default
let initialWorkspace = defaultArg initialWorkspace (FSharpWorkspace())
@@ -90,6 +90,7 @@ type FSharpLanguageServer
let _ =
serviceCollection
.AddSingleton(initialWorkspace)
+ .AddSingleton(jsonRpc)
.AddSingleton()
.AddSingleton(config)
.AddSingleton>()
@@ -99,6 +100,7 @@ type FSharpLanguageServer
.AddSingleton()
.AddSingleton()
.AddSingleton()
+ .AddSingleton()
.AddSingleton(logger)
.AddSingleton(NullTelemetry.Instance)
.AddSingleton, FShapRequestContextFactory>()
diff --git a/src/FSharp.Compiler.LanguageServer/Handlers/DocumentStateHandler.fs b/src/FSharp.Compiler.LanguageServer/Handlers/DocumentStateHandler.fs
index a074e7972cc..88cd03d3d84 100644
--- a/src/FSharp.Compiler.LanguageServer/Handlers/DocumentStateHandler.fs
+++ b/src/FSharp.Compiler.LanguageServer/Handlers/DocumentStateHandler.fs
@@ -24,10 +24,7 @@ type DocumentStateHandler() =
contextHolder.UpdateWorkspace _.Files.Open(request.TextDocument.Uri, request.TextDocument.Text)
- telemetry.ReportEvent(
- TelemetryEvents.DocumentOpened,
- [| "uri_hash", hash request.TextDocument.Uri :> obj |]
- )
+ telemetry.ReportEvent(TelemetryEvents.DocumentOpened, [| "uri_hash", hash request.TextDocument.Uri :> obj |])
Task.FromResult(SemanticTokensDeltaPartialResult())
@@ -51,10 +48,8 @@ type DocumentStateHandler() =
let contextHolder = context.LspServices.GetRequiredService()
contextHolder.UpdateWorkspace _.Files.Close(request.TextDocument.Uri)
+ context.ClearActiveProjectContext(request.TextDocument.Uri)
- telemetry.ReportEvent(
- TelemetryEvents.DocumentClosed,
- [| "uri_hash", hash request.TextDocument.Uri :> obj |]
- )
+ telemetry.ReportEvent(TelemetryEvents.DocumentClosed, [| "uri_hash", hash request.TextDocument.Uri :> obj |])
Task.CompletedTask
diff --git a/src/FSharp.Compiler.LanguageServer/Handlers/ProjectContextsHandler.fs b/src/FSharp.Compiler.LanguageServer/Handlers/ProjectContextsHandler.fs
index 82d328f638f..4f93706ff68 100644
--- a/src/FSharp.Compiler.LanguageServer/Handlers/ProjectContextsHandler.fs
+++ b/src/FSharp.Compiler.LanguageServer/Handlers/ProjectContextsHandler.fs
@@ -1,11 +1,14 @@
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
@@ -13,8 +16,108 @@ open System.Threading.Tasks
#nowarn "57"
#nowarn "3261"
-type ProjectContextsHandler() =
+[]
+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 {
+ 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.", [||])
+ 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 ->
+ 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)
+ return true
+ with error when (error :? RemoteInvocationException) || (error :? ConnectionLostException) ->
+ context.Logger.LogException(
+ error,
+ "Unable to refresh semantic tokens after a project context change.",
+ [||]
+ )
+
+ return false
+ else
+ context.SetActiveProjectContext(request.Uri, snapshot.Identifier)
+ return true
+ }
+
+type ProjectContextsHandler() =
interface IMethodHandler with
member _.MutatesSolutionState = false
@@ -41,14 +144,6 @@ type ProjectContextsHandler() =
Kind = VSProjectKind.FSharp
))
- telemetry.ReportEvent(
- TelemetryEvents.GetProjectContexts,
- [| "project_count", projectContexts.Length :> obj |]
- )
-
- Task.FromResult(
- VSProjectContextList(
- ProjectContexts = projectContexts,
- DefaultIndex = 0
- )
- )
+ 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
index 337f10419ee..98995a0aa9b 100644
--- a/src/FSharp.Compiler.LanguageServer/Handlers/SemanticTokensHandler.fs
+++ b/src/FSharp.Compiler.LanguageServer/Handlers/SemanticTokensHandler.fs
@@ -17,7 +17,9 @@ type SemanticTokensHandler() =
interface IMethodHandler with
member _.MutatesSolutionState = false
- member private _.GetParsingOptions(uri: System.Uri, context: FSharpRequestContext, projectContext: ProjectSnapshot.FSharpProjectIdentifier option) =
+ 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
@@ -37,7 +39,11 @@ type SemanticTokensHandler() =
=
cancellableTask {
let uri = textDocument.Uri
- let projectContext = tryGetProjectContext textDocument
+
+ let projectContext =
+ tryGetProjectContext textDocument
+ |> Option.orElseWith (fun () -> context.TryGetActiveProjectContext uri)
+
let telemetry = context.LspServices.GetRequiredService()
use _scope =
diff --git a/src/FSharp.Compiler.LanguageServer/SemanticTokens.fs b/src/FSharp.Compiler.LanguageServer/SemanticTokens.fs
index 1bb7f6acf8a..12e4d702e88 100644
--- a/src/FSharp.Compiler.LanguageServer/SemanticTokens.fs
+++ b/src/FSharp.Compiler.LanguageServer/SemanticTokens.fs
@@ -65,6 +65,7 @@ module LspSemanticTokens =
SemanticTokenTypes.String
SemanticTokenTypes.Number
SemanticTokenTypes.Operator
+ "excluded code"
classNameTokenType
structNameTokenType
enumNameTokenType
@@ -163,6 +164,7 @@ module LspSemanticTokens =
| 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
diff --git a/src/Microsoft.CommonLanguageServerProtocol.Framework.Proxy/CancellableSystemTextJsonLanguageServer.cs b/src/Microsoft.CommonLanguageServerProtocol.Framework.Proxy/CancellableSystemTextJsonLanguageServer.cs
new file mode 100644
index 00000000000..f4ff487a9ff
--- /dev/null
+++ b/src/Microsoft.CommonLanguageServerProtocol.Framework.Proxy/CancellableSystemTextJsonLanguageServer.cs
@@ -0,0 +1,23 @@
+namespace Microsoft.CommonLanguageServerProtocol.Framework;
+
+using System.Text.Json;
+using StreamJsonRpc;
+
+internal abstract class CancellableSystemTextJsonLanguageServer(
+ JsonRpc jsonRpc, JsonSerializerOptions options)
+ : SystemTextJsonLanguageServer(jsonRpc, options)
+{
+ protected override IRequestExecutionQueue ConstructRequestExecutionQueue()
+ {
+ var queue = new CancellableRequestExecutionQueue(this, HandlerProvider);
+ queue.Start();
+ return queue;
+ }
+
+ private sealed class CancellableRequestExecutionQueue(
+ AbstractLanguageServer server, AbstractHandlerProvider handlers)
+ : RequestExecutionQueue(server, handlers)
+ {
+ protected override bool CancelInProgressWorkUponMutatingRequest => true;
+ }
+}
diff --git a/tests/FSharp.Compiler.LanguageServer.Tests/ProtocolHelpers.fs b/tests/FSharp.Compiler.LanguageServer.Tests/ProtocolHelpers.fs
index 542e9493676..01ee2c24609 100644
--- a/tests/FSharp.Compiler.LanguageServer.Tests/ProtocolHelpers.fs
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/ProtocolHelpers.fs
@@ -27,7 +27,7 @@ type TestRpcClient(jsonRpc, rpcTrace, workspace, initializeResult: InitializeRes
member _.RpcTrace = rpcTrace.ToString()
-let createLanguageServer (workspace: FSharpWorkspace) (config: FSharpLanguageServerConfig option) =
+let createLanguageServerWithClient (workspace: FSharpWorkspace) (config: FSharpLanguageServerConfig option) (configureClient: JsonRpc -> unit) =
let rpcTrace = new StringWriter()
let (inputStream, outputStream), server =
@@ -50,9 +50,15 @@ let createLanguageServer (workspace: FSharpWorkspace) (config: FSharpLanguageSer
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 {
@@ -60,6 +66,9 @@ let createLanguageServer (workspace: FSharpWorkspace) (config: FSharpLanguageSer
return TestRpcClient(jsonRpc, rpcTrace, workspace, response)
}
+let createLanguageServer workspace config =
+ createLanguageServerWithClient workspace config ignore
+
let initializeLanguageServer (workspace) =
let workspace = defaultArg workspace (FSharpWorkspace())
createLanguageServer workspace None
@@ -191,19 +200,21 @@ let setupMultiTargetLikeProject (client: TestRpcClient) (content: string) (share
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}" ])
+ otherOptions = [ $"--define:{defineA}"; $"-o:{compilerOutputA}" ])
let configB =
ProjectConfig(
projectFileName,
outputFileName = Some (System.IO.Path.Combine(dir, $"out.{defineB}.dll")),
referencesOnDisk = [],
- otherOptions = [ $"--define:{defineB}" ])
+ otherOptions = [ $"--define:{defineB}"; $"-o:{compilerOutputB}" ])
let pidA = client.Workspace.Projects.AddOrUpdate(configA, [ fileOnDisk.LocalPath ])
let pidB = client.Workspace.Projects.AddOrUpdate(configB, [ fileOnDisk.LocalPath ])
diff --git a/tests/FSharp.Compiler.LanguageServer.Tests/SemanticTokensProtocolTests.fs b/tests/FSharp.Compiler.LanguageServer.Tests/SemanticTokensProtocolTests.fs
index 535985eed7e..c3b097e9534 100644
--- a/tests/FSharp.Compiler.LanguageServer.Tests/SemanticTokensProtocolTests.fs
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/SemanticTokensProtocolTests.fs
@@ -6,12 +6,99 @@ 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 ``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
// ---------------------------------------------------------------------------
diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
index c0dc0a96846..60986bb3fa4 100644
--- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
+++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
@@ -61,6 +61,7 @@
+
@@ -181,6 +182,7 @@
+
diff --git a/vsintegration/src/FSharp.Editor/LanguageService/LspProjectContextListener.fs b/vsintegration/src/FSharp.Editor/LanguageService/LspProjectContextListener.fs
new file mode 100644
index 00000000000..662e9a750c2
--- /dev/null
+++ b/vsintegration/src/FSharp.Editor/LanguageService/LspProjectContextListener.fs
@@ -0,0 +1,80 @@
+namespace Microsoft.VisualStudio.FSharp.Editor
+
+open System
+open System.ComponentModel.Composition
+open System.Threading
+open System.Threading.Tasks
+
+open Microsoft.CodeAnalysis
+open Microsoft.CodeAnalysis.Text
+open Microsoft.VisualStudio.LanguageServer.Client
+open Microsoft.VisualStudio.LanguageServices
+open Microsoft.VisualStudio.Shell
+open Microsoft.VisualStudio.Text.Editor
+open Microsoft.VisualStudio.Threading
+open Microsoft.VisualStudio.Utilities
+
+[)>]
+[]
+[]
+type internal LspProjectContextListener [] (broker: ILanguageServiceBroker2, workspace: VisualStudioWorkspace) =
+ interface IWpfTextViewCreationListener with
+ member _.TextViewCreated(view) =
+ let cancellation = new CancellationTokenSource()
+ let requests = ThreadHelper.JoinableTaskContext.CreateCollection()
+ let factory = ThreadHelper.JoinableTaskContext.CreateFactory requests
+
+ let updateContext () =
+ factory
+ .RunAsync(fun () ->
+ task {
+ let document = view.TextSnapshot.GetOpenDocumentInCurrentContextWithChanges()
+
+ if not (isNull document) && not (isNull document.Project.OutputFilePath) then
+ // The Roslyn navbar does not populate the LSP client's navbar context.
+ let parameters =
+ {|
+ Uri = Uri(document.FilePath)
+ ProjectFileName = document.Project.FilePath
+ OutputFileName = document.Project.OutputFilePath
+ |}
+
+ let request =
+ DocumentRequest<_, bool>(
+ TextBuffer = view.TextBuffer,
+ Method = "textDocument/fsharp/setActiveProjectContext",
+ LanguageServerName = "FSharp Analyzer LSP server",
+ ParameterFactory = (fun _ -> parameters)
+ )
+
+ let! accepted = broker.RequestAsync(request, cancellation.Token)
+
+ if not accepted then
+ System.Diagnostics.Trace.TraceWarning("F# LSP active project context was not accepted.")
+ }
+ :> Task)
+ .FileAndForget("vs/fsharp/lsp/projectContext")
+
+ let handler =
+ EventHandler(fun _ args ->
+ if args.SourceTextContainer = view.TextBuffer.AsTextContainer() then
+ updateContext ())
+
+ workspace.DocumentActiveContextChanged.AddHandler handler
+
+ view.Closed.Add(fun _ ->
+ workspace.DocumentActiveContextChanged.RemoveHandler handler
+ cancellation.Cancel()
+
+ ThreadHelper.JoinableTaskFactory
+ .RunAsync(fun () ->
+ task {
+ try
+ do! requests.JoinTillEmptyAsync()
+ finally
+ cancellation.Dispose()
+ }
+ :> Task)
+ .FileAndForget("vs/fsharp/lsp/projectContext/close"))
+
+ updateContext ()
From 0f64a67dc7242a173bab150e1bcbd2b50ae455d0 Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Wed, 4 Nov 2026 18:16:00 +0100
Subject: [PATCH 15/30] Align FSharp.Editor LSP client dependencies with Roslyn
Upgrade the LSP client to 18.12.55 and protocol packages to 18.12.5 to resolve the assembly version conflict.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a8b27f92-13b7-400d-8af8-1f85b6162e46
---
docs/release-notes/.VisualStudio/18.vNext.md | 1 +
eng/Packages.props | 9 ++++-----
vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj | 2 +-
3 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md
index df6df79eb52..131eab0eb78 100644
--- a/docs/release-notes/.VisualStudio/18.vNext.md
+++ b/docs/release-notes/.VisualStudio/18.vNext.md
@@ -39,6 +39,7 @@
* 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/Packages.props b/eng/Packages.props
index b4375444f65..5d72879f8fa 100644
--- a/eng/Packages.props
+++ b/eng/Packages.props
@@ -64,11 +64,10 @@
-
-
-
-
+
+
+
+
diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
index 60986bb3fa4..9e49315dc55 100644
--- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
+++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
@@ -182,7 +182,7 @@
-
+
From 636b4f2822f1f9e68c7d6f4ac9fca9028362cf5e Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Wed, 4 Nov 2026 19:59:52 +0100
Subject: [PATCH 16/30] Upgrade F# LSP extension to Extensibility 18.5 and .NET
10
Align dependent packages, require Visual Studio 18.5, and update VSIX metadata paths.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a8b27f92-13b7-400d-8af8-1f85b6162e46
---
docs/release-notes/.VisualStudio/18.vNext.md | 2 +-
eng/Packages.props | 11 +++++------
.../ExtensionEntrypoint.cs | 3 +--
.../FSharp.VisualStudio.Extension.csproj | 9 +++------
.../VisualFSharpFull/Source.extension.vsixmanifest | 5 ++---
.../Vsix/VisualFSharpFull/VisualFSharp.Core.targets | 2 +-
6 files changed, 13 insertions(+), 19 deletions(-)
diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md
index 131eab0eb78..70bdc537be7 100644
--- a/docs/release-notes/.VisualStudio/18.vNext.md
+++ b/docs/release-notes/.VisualStudio/18.vNext.md
@@ -35,7 +35,7 @@
* `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 17.14.40313, track CLaSP with Roslyn dependency updates, and use System.Text.Json while preserving Visual Studio project-context and diagnostic metadata.
+* Update the F# LSP extension to Visual Studio Extensibility 18.5.38846-Preview and .NET 10 (requiring Visual Studio 18.5 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.
diff --git a/eng/Packages.props b/eng/Packages.props
index 5d72879f8fa..fad61576805 100644
--- a/eng/Packages.props
+++ b/eng/Packages.props
@@ -30,10 +30,9 @@
-
-
+
@@ -58,8 +57,8 @@
-
-
+
+
@@ -78,7 +77,7 @@
-
+
diff --git a/src/FSharp.VisualStudio.Extension/ExtensionEntrypoint.cs b/src/FSharp.VisualStudio.Extension/ExtensionEntrypoint.cs
index 6611bace471..6bc13bf5919 100644
--- a/src/FSharp.VisualStudio.Extension/ExtensionEntrypoint.cs
+++ b/src/FSharp.VisualStudio.Extension/ExtensionEntrypoint.cs
@@ -24,8 +24,7 @@ internal class ExtensionEntrypoint : Extension
displayName: "FSharp.VisualStudio.Extension",
description: "Extension description")
{
- // TODO: Probably should be replaced by a range of versions
- InstallationTargetVersion = "[17.14, 18.0]",
+ InstallationTargetVersion = "[18.5, 19.0)",
},
};
diff --git a/src/FSharp.VisualStudio.Extension/FSharp.VisualStudio.Extension.csproj b/src/FSharp.VisualStudio.Extension/FSharp.VisualStudio.Extension.csproj
index e31b9163d9b..fc6e7794ae7 100644
--- a/src/FSharp.VisualStudio.Extension/FSharp.VisualStudio.Extension.csproj
+++ b/src/FSharp.VisualStudio.Extension/FSharp.VisualStudio.Extension.csproj
@@ -1,9 +1,7 @@
- net8.0-windows
-
-
- net8.0
+ net10.0-windows8.0
+ true
enable
12
en-US
@@ -18,8 +16,7 @@
-
-
+
-
+
@@ -32,13 +32,13 @@
-
+
-
-
+
+
@@ -57,8 +57,8 @@
-
-
+
+
@@ -77,7 +77,7 @@
-
+
@@ -90,7 +90,7 @@
-
+
@@ -112,9 +112,8 @@
-
+ Shared with Roslyn and the Extensibility SDK to avoid downgrade conflicts. -->
+
diff --git a/src/FSharp.VisualStudio.Extension/ExtensionEntrypoint.cs b/src/FSharp.VisualStudio.Extension/ExtensionEntrypoint.cs
index 6bc13bf5919..f1cce243d7e 100644
--- a/src/FSharp.VisualStudio.Extension/ExtensionEntrypoint.cs
+++ b/src/FSharp.VisualStudio.Extension/ExtensionEntrypoint.cs
@@ -24,7 +24,7 @@ internal class ExtensionEntrypoint : Extension
displayName: "FSharp.VisualStudio.Extension",
description: "Extension description")
{
- InstallationTargetVersion = "[18.5, 19.0)",
+ InstallationTargetVersion = "[18.14, 19.0)",
},
};
diff --git a/vsintegration/Vsix/VisualFSharpFull/Source.extension.vsixmanifest b/vsintegration/Vsix/VisualFSharpFull/Source.extension.vsixmanifest
index b56c759393c..6d22be9eb03 100644
--- a/vsintegration/Vsix/VisualFSharpFull/Source.extension.vsixmanifest
+++ b/vsintegration/Vsix/VisualFSharpFull/Source.extension.vsixmanifest
@@ -9,7 +9,7 @@
https://learn.microsoft.com/dotnet/articles/fsharp/
-
@@ -62,6 +62,6 @@
-
+
From c23637909ff596f39285f4fb597a2025cfaaa608 Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Tue, 10 Nov 2026 20:52:39 +0100
Subject: [PATCH 18/30] Bridge legacy and LSP highlighting telemetry
Track eligible rollout exposure and provider outcomes, apply shared consent with the Microsoft-internal override, and keep policy forwarding alive beyond document views. Add bounded startup retries, regression tests, and telemetry documentation.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6449d63c-e5d8-4984-b4f5-8330252348fc
---
VSFSharpExtension.slnx | 1 +
VisualFSharp.slnx | 3 +
docs/lsp.md | 139 ++++++
docs/release-notes/.VisualStudio/18.vNext.md | 1 +
eng/Build.ps1 | 1 +
.../Common/FSharpRequestContext.fs | 8 +
.../Handlers/DocumentStateHandler.fs | 3 +
.../Handlers/ProjectContextsHandler.fs | 18 +
.../Handlers/SemanticTokensHandler.fs | 115 +++--
.../Telemetry.fs | 16 +
.../FSharp.VisualStudio.Extension.csproj | 1 +
.../FSharpLanguageServerProvider.cs | 24 +-
.../VsTelemetryReporter.cs | 206 ++++++--
...Sharp.Compiler.LanguageServer.Tests.fsproj | 1 +
.../ProtocolHelpers.fs | 20 +-
.../TelemetryProtocolTests.fs | 156 +++++++
...FSharp.VisualStudio.Extension.Tests.csproj | 19 +
.../TelemetryTests.cs | 174 +++++++
.../Classification/ClassificationService.fs | 186 ++++----
.../src/FSharp.Editor/FSharp.Editor.fsproj | 1 +
.../LspProjectContextListener.fs | 45 ++
.../TextViewCreationListener.fs | 12 +-
.../FSharp.Editor/Options/EditorOptions.fs | 12 +-
.../SemanticHighlightingTelemetry.fs | 263 +++++++++++
.../FSharp.Editor.Tests.fsproj | 1 +
.../FSharp.Editor.Tests/TelemetryTests.fs | 441 ++++++++++++++++++
26 files changed, 1693 insertions(+), 174 deletions(-)
create mode 100644 tests/FSharp.Compiler.LanguageServer.Tests/TelemetryProtocolTests.fs
create mode 100644 tests/FSharp.VisualStudio.Extension.Tests/FSharp.VisualStudio.Extension.Tests.csproj
create mode 100644 tests/FSharp.VisualStudio.Extension.Tests/TelemetryTests.cs
create mode 100644 vsintegration/src/FSharp.Editor/Telemetry/SemanticHighlightingTelemetry.fs
create mode 100644 vsintegration/tests/FSharp.Editor.Tests/TelemetryTests.fs
diff --git a/VSFSharpExtension.slnx b/VSFSharpExtension.slnx
index b1c10cefe88..a11f2038605 100644
--- a/VSFSharpExtension.slnx
+++ b/VSFSharpExtension.slnx
@@ -13,6 +13,7 @@
+
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 570090126e4..6f19d8a27a7 100644
--- a/docs/lsp.md
+++ b/docs/lsp.md
@@ -160,6 +160,145 @@ 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`/`disabled` 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.
+
+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.
diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md
index 39d14a01fa1..cdbbf503815 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))
diff --git a/eng/Build.ps1 b/eng/Build.ps1
index a82d0a9a972..4b7aa3a9dae 100644
--- a/eng/Build.ps1
+++ b/eng/Build.ps1
@@ -950,6 +950,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) {
diff --git a/src/FSharp.Compiler.LanguageServer/Common/FSharpRequestContext.fs b/src/FSharp.Compiler.LanguageServer/Common/FSharpRequestContext.fs
index b703dd9dee1..0d7e883a7fa 100644
--- a/src/FSharp.Compiler.LanguageServer/Common/FSharpRequestContext.fs
+++ b/src/FSharp.Compiler.LanguageServer/Common/FSharpRequestContext.fs
@@ -21,6 +21,14 @@ type FSharpRequestContext(lspServices: ILspServices, logger: ILspLogger, workspa
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
+
member _.LspServices = lspServices
member _.Logger = logger
member _.Workspace = workspace
diff --git a/src/FSharp.Compiler.LanguageServer/Handlers/DocumentStateHandler.fs b/src/FSharp.Compiler.LanguageServer/Handlers/DocumentStateHandler.fs
index 88cd03d3d84..a7f764cf0a8 100644
--- a/src/FSharp.Compiler.LanguageServer/Handlers/DocumentStateHandler.fs
+++ b/src/FSharp.Compiler.LanguageServer/Handlers/DocumentStateHandler.fs
@@ -23,6 +23,7 @@ type DocumentStateHandler() =
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 |])
@@ -36,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())
@@ -48,6 +50,7 @@ type DocumentStateHandler() =
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 |])
diff --git a/src/FSharp.Compiler.LanguageServer/Handlers/ProjectContextsHandler.fs b/src/FSharp.Compiler.LanguageServer/Handlers/ProjectContextsHandler.fs
index 4f93706ff68..060ef4ac700 100644
--- a/src/FSharp.Compiler.LanguageServer/Handlers/ProjectContextsHandler.fs
+++ b/src/FSharp.Compiler.LanguageServer/Handlers/ProjectContextsHandler.fs
@@ -35,6 +35,11 @@ type ActiveProjectContextHandler() =
(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
@@ -43,6 +48,7 @@ type ActiveProjectContextHandler() =
|| String.IsNullOrWhiteSpace request.OutputFileName
then
context.Logger.LogWarning("Invalid active project context.", [||])
+ report "invalid"
return false
else
let comparison =
@@ -74,6 +80,8 @@ type ActiveProjectContextHandler() =
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 |]
@@ -103,6 +111,7 @@ type ActiveProjectContextHandler() =
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(
@@ -111,9 +120,18 @@ type ActiveProjectContextHandler() =
[||]
)
+ 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
}
diff --git a/src/FSharp.Compiler.LanguageServer/Handlers/SemanticTokensHandler.fs b/src/FSharp.Compiler.LanguageServer/Handlers/SemanticTokensHandler.fs
index 98995a0aa9b..a46da0f66b6 100644
--- a/src/FSharp.Compiler.LanguageServer/Handlers/SemanticTokensHandler.fs
+++ b/src/FSharp.Compiler.LanguageServer/Handlers/SemanticTokensHandler.fs
@@ -1,5 +1,10 @@
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
@@ -9,8 +14,6 @@ open FSharp.Compiler.EditorServices
open FSharp.Compiler.LanguageServer
open FSharp.Compiler.LanguageServer.Common
-open System.Threading
-
#nowarn "57"
type SemanticTokensHandler() =
@@ -38,35 +41,89 @@ type SemanticTokensHandler() =
(textDocument: TextDocumentIdentifier, context: FSharpRequestContext, range: Range option, cancellationToken: CancellationToken)
=
cancellableTask {
+ let stopwatch = Stopwatch.StartNew()
let uri = textDocument.Uri
-
- let projectContext =
- tryGetProjectContext textDocument
- |> Option.orElseWith (fun () -> context.TryGetActiveProjectContext uri)
-
+ let explicitContext = tryGetProjectContext textDocument
+ let activeContext = context.TryGetActiveProjectContext uri
+ let projectContext = explicitContext |> Option.orElse activeContext
let telemetry = context.LspServices.GetRequiredService()
-
- use _scope =
- telemetry.ReportEventWithDuration(TelemetryEvents.GetSemanticTokens, [| "uri_hash", hash uri :> obj |])
-
- let! source = context.Workspace.Query.GetSource uri
-
- match source with
- | None -> return LspSemanticTokens.emptyResponse ()
- | Some source ->
- let! semanticClassification = context.Workspace.Query.GetSemanticClassification(uri, ?context = projectContext)
-
- let semanticItems =
- match semanticClassification with
- | None -> [||]
- | Some view ->
- let items = ResizeArray()
- view.ForEach items.Add
- items.ToArray()
-
- let parsingOptions = this.GetParsingOptions(uri, context, projectContext)
-
- return LspSemanticTokens.responseFor source uri.LocalPath semanticItems range parsingOptions
+ 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
+
+ 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
diff --git a/src/FSharp.Compiler.LanguageServer/Telemetry.fs b/src/FSharp.Compiler.LanguageServer/Telemetry.fs
index 014d61b4fb1..6984929bd87 100644
--- a/src/FSharp.Compiler.LanguageServer/Telemetry.fs
+++ b/src/FSharp.Compiler.LanguageServer/Telemetry.fs
@@ -35,6 +35,9 @@ module TelemetryEvents =
[]
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 =
@@ -48,6 +51,19 @@ type ILspTelemetry =
/// 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 =
diff --git a/src/FSharp.VisualStudio.Extension/FSharp.VisualStudio.Extension.csproj b/src/FSharp.VisualStudio.Extension/FSharp.VisualStudio.Extension.csproj
index fc6e7794ae7..5510ee5efe0 100644
--- a/src/FSharp.VisualStudio.Extension/FSharp.VisualStudio.Extension.csproj
+++ b/src/FSharp.VisualStudio.Extension/FSharp.VisualStudio.Extension.csproj
@@ -29,6 +29,7 @@
+
diff --git a/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs b/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs
index 1581139a16d..d1c5af7db04 100644
--- a/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs
+++ b/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs
@@ -212,11 +212,17 @@ internal class FSharpLanguageServerProvider : LanguageServerProvider
[Microsoft.VisualStudio.Extensibility.DocumentFilter.FromDocumentType(FSharpDocumentType)]);
private VsTelemetryReporter? _telemetryReporter;
+ private volatile TelemetryConfiguration? _telemetryConfiguration;
+ private bool _semanticTokensEnabled;
///
public override async Task CreateServerConnectionAsync(CancellationToken cancellationToken)
{
- _telemetryReporter = new VsTelemetryReporter();
+ _telemetryReporter = new VsTelemetryReporter(configurationChanged: value => _telemetryConfiguration = value);
+ if (_telemetryConfiguration is { } configuration)
+ {
+ _telemetryReporter.Configure(configuration);
+ }
var activitySourceName = "fsc";
FSharp.Compiler.LanguageServer.Activity.listenToSome();
@@ -249,6 +255,7 @@ await this.Extensibility.Settings().WriteAsync(batch =>
codeActions: false,
semanticTokens: effectiveSettingValues[FSharpExtensionSettings.SemanticClassificationProvider] == FSharpExtensionSettings.LSP
));
+ _semanticTokensEnabled = serverConfig.EnabledFeatures.SemanticTokens;
var disposeToEndSubscription =
this.Extensibility.Settings().SubscribeAsync(
@@ -330,8 +337,10 @@ await this.Extensibility.Settings().WriteAsync(batch =>
{
serviceCollection.AddSingleton();
serviceCollection.AddSingleton();
- serviceCollection.AddSingleton(_telemetryReporter!);
+ _telemetryReporter.Register(serviceCollection);
});
+ var connectionTelemetry = _telemetryReporter;
+ _server.JsonRpc.Disconnected += (_, _) => connectionTelemetry.Disable();
var solutions = await ws.QuerySolutionAsync(
solution => solution.With(solution => solution.FileName),
@@ -359,16 +368,15 @@ public override Task OnServerInitializationResultAsync(ServerInitializationResul
{
// Log telemetry for failure and disable the server from being activated again.
this.Enabled = false;
- _telemetryReporter?.ReportFault(
- TelemetryEvents.ServerFault,
- "LSP server initialization failed",
- null);
+ _telemetryReporter?.ReportLifecycle(
+ "lsp/initializationfailed",
+ [Tuple.Create("has_failure_info", (object)(initializationFailureInfo is not null))]);
}
else
{
- _telemetryReporter?.ReportEvent(
+ _telemetryReporter?.ReportLifecycle(
TelemetryEvents.LspServerInitialized,
- []);
+ [Tuple.Create("semantic_tokens_enabled", (object)_semanticTokensEnabled)]);
}
return base.OnServerInitializationResultAsync(serverInitializationResult, initializationFailureInfo, cancellationToken);
diff --git a/src/FSharp.VisualStudio.Extension/VsTelemetryReporter.cs b/src/FSharp.VisualStudio.Extension/VsTelemetryReporter.cs
index c0c7c3f1d73..9d7766e3861 100644
--- a/src/FSharp.VisualStudio.Extension/VsTelemetryReporter.cs
+++ b/src/FSharp.VisualStudio.Extension/VsTelemetryReporter.cs
@@ -4,67 +4,221 @@
namespace FSharp.VisualStudio.Extension;
using System;
+using System.Collections.Generic;
using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
using FSharp.Compiler.LanguageServer;
+using FSharp.Compiler.LanguageServer.Common;
+using Microsoft.CommonLanguageServerProtocol.Framework;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.VisualStudio.Telemetry;
+using StreamJsonRpc;
-///
-/// VS telemetry implementation for the F# LSP server.
-/// Routes telemetry events through .
-///
-internal sealed class VsTelemetryReporter : ILspTelemetry
+internal sealed record TelemetryConfiguration(string RolloutSessionId, long Generation, bool Enabled);
+internal sealed record TelemetryConfigurationResult(long Generation, bool SemanticTokensEnabled);
+
+internal sealed class VsTelemetryReporter(
+ Action? postEvent = null,
+ TimeProvider? timeProvider = null,
+ Action? configurationChanged = null,
+ Func? isMicrosoftInternal = null) : ILspTelemetry,
+ IRequestHandler
{
private const string EventPrefix = "vs/fsharp/";
private const string PropertyPrefix = "vs.fsharp.";
+ private readonly Action postEvent = postEvent ?? TelemetryService.DefaultSession.PostEvent;
+ private readonly TimeProvider timeProvider = timeProvider ?? TimeProvider.System;
+ private readonly Func isMicrosoftInternal = isMicrosoftInternal ?? (() => TelemetryService.DefaultSession.IsUserMicrosoftInternal);
+ private readonly object gate = new();
+ private readonly Dictionary samples = new(StringComparer.Ordinal);
+ private readonly HashSet<(string, Type?, string?)> faults = [];
+ private readonly HashSet lifecycleEvents = new(StringComparer.Ordinal);
+ private readonly Dictionary[]> pendingLifecycle = new(StringComparer.Ordinal);
+ private TelemetryConfiguration? configuration;
+ private bool closed;
+ private bool CanReport => !this.closed && (this.isMicrosoftInternal() || this.configuration is { Enabled: true });
- public void ReportEvent(string name, Tuple[] properties)
+ public bool MutatesSolutionState => false;
+
+ internal void Register(IServiceCollection services)
+ {
+ services.AddSingleton(this);
+ services.AddSingleton(this);
+ }
+
+ [LanguageServerEndpoint("fsharp/telemetry/configure", LanguageServerConstants.DefaultLanguageName)]
+ public Task HandleRequestAsync(
+ TelemetryConfiguration request, FSharpRequestContext context, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var generation = Configure(request);
+ var enabled = context.LspServices.GetRequiredService().EnabledFeatures.SemanticTokens;
+ return Task.FromResult(new TelemetryConfigurationResult(generation, enabled));
+ }
+
+ internal long Configure(TelemetryConfiguration? request)
{
- var telemetryEvent = new TelemetryEvent(EventPrefix + name, TelemetrySeverity.Normal);
+ lock (this.gate)
+ {
+ if (this.closed)
+ {
+ return -1;
+ }
+
+ if (request is null || !Guid.TryParse(request.RolloutSessionId, out _) || request.Generation < 0)
+ {
+ if (this.configuration is not null)
+ {
+ this.configuration = this.configuration with { Enabled = false };
+ configurationChanged?.Invoke(this.configuration);
+ }
+
+ return -1;
+ }
+
+ if (this.configuration is not null && request.RolloutSessionId != this.configuration.RolloutSessionId)
+ {
+ return -1;
+ }
+
+ if (this.configuration is null ||
+ (request.RolloutSessionId == this.configuration.RolloutSessionId &&
+ request.Generation > this.configuration.Generation))
+ {
+ this.configuration = request;
+ configurationChanged?.Invoke(request);
+ }
+
+ if (this.CanReport)
+ {
+ foreach (var (name, properties) in this.pendingLifecycle)
+ {
+ if (this.lifecycleEvents.Add(name))
+ {
+ this.Post(new TelemetryEvent(EventPrefix + name), properties);
+ }
+ }
+ }
+
+ this.pendingLifecycle.Clear();
+ return this.configuration.Generation;
+ }
+ }
+
+ internal void Disable()
+ {
+ lock (this.gate)
+ {
+ this.configuration = null;
+ this.pendingLifecycle.Clear();
+ this.closed = true;
+ }
+ }
- foreach (var prop in properties)
+ internal void ReportLifecycle(string name, Tuple[] properties)
+ {
+ lock (this.gate)
{
- telemetryEvent.Properties[PropertyPrefix + prop.Item1] = prop.Item2;
+ if (!this.closed && !this.lifecycleEvents.Contains(name))
+ {
+ if (this.CanReport)
+ {
+ this.lifecycleEvents.Add(name);
+ this.Post(new TelemetryEvent(EventPrefix + name), properties);
+ }
+ else if (this.configuration is null)
+ {
+ this.pendingLifecycle[name] = properties;
+ }
+ }
}
+ }
- TelemetryService.DefaultSession.PostEvent(telemetryEvent);
+ public void ReportEvent(string name, Tuple[] properties)
+ {
+ lock (this.gate)
+ {
+ if (!this.CanReport)
+ {
+ return;
+ }
+
+ if (name == TelemetryEvents.GetSemanticTokens)
+ {
+ var kind = Array.Find(properties, p => p.Item1 == "request_kind")?.Item2 as string;
+ var key = name + "/" + kind;
+ var now = this.timeProvider.GetTimestamp();
+ if (this.samples.TryGetValue(key, out var previous) &&
+ this.timeProvider.GetElapsedTime(previous, now) < TimeSpan.FromSeconds(5))
+ {
+ return;
+ }
+
+ this.samples[key] = now;
+ }
+
+ this.Post(new TelemetryEvent(EventPrefix + name, TelemetrySeverity.Normal), properties);
+ }
}
public IDisposable ReportEventWithDuration(string name, Tuple[] properties)
{
- var stopwatch = Stopwatch.StartNew();
- return new TelemetryScope(name, properties, stopwatch);
+ return new TelemetryScope(this, name, properties);
}
public void ReportFault(string name, string description, Microsoft.FSharp.Core.FSharpOption? exn)
{
- var faultName = EventPrefix + name;
-
- if (exn is not null && Microsoft.FSharp.Core.FSharpOption.get_IsSome(exn))
+ var error = exn?.Value;
+ if (error is OperationCanceledException or ConnectionLostException { InnerException: OperationCanceledException })
{
- TelemetryService.DefaultSession.PostFault(faultName, description, exn.Value);
+ return;
}
- else
+
+ lock (this.gate)
{
- TelemetryService.DefaultSession.PostFault(faultName, description);
+ if (this.CanReport &&
+ this.faults.Count < 32 &&
+ this.faults.Add((name, error?.GetType(), error?.StackTrace)))
+ {
+ this.Post(new FaultEvent(EventPrefix + name, description, error), []);
+ }
}
}
- private sealed class TelemetryScope(string name, Tuple[] properties, Stopwatch stopwatch) : IDisposable
+ private void Post(TelemetryEvent telemetryEvent, Tuple[] properties)
{
- public void Dispose()
+ try
{
- stopwatch.Stop();
-
- var telemetryEvent = new TelemetryEvent(EventPrefix + name, TelemetrySeverity.Normal);
-
foreach (var prop in properties)
{
telemetryEvent.Properties[PropertyPrefix + prop.Item1] = prop.Item2;
}
- telemetryEvent.Properties[PropertyPrefix + "duration_ms"] = stopwatch.ElapsedMilliseconds;
+ telemetryEvent.Properties[PropertyPrefix + "schema_version"] = 1;
+ telemetryEvent.Properties[PropertyPrefix + "provider"] = "lsp";
+ if (this.configuration is not null)
+ {
+ telemetryEvent.Properties[PropertyPrefix + "rollout_session_id"] = this.configuration.RolloutSessionId;
+ }
+
+ this.postEvent(telemetryEvent);
+ }
+ catch (Exception)
+ {
+ Trace.TraceWarning("F# LSP telemetry could not be posted.");
+ }
+ }
- TelemetryService.DefaultSession.PostEvent(telemetryEvent);
+ private sealed class TelemetryScope(
+ VsTelemetryReporter reporter, string name, Tuple[] properties) : IDisposable
+ {
+ private readonly Stopwatch stopwatch = Stopwatch.StartNew();
+
+ public void Dispose()
+ {
+ this.stopwatch.Stop();
+ reporter.ReportEvent(name, [.. properties, Tuple.Create("duration_ms", (object)this.stopwatch.ElapsedMilliseconds)]);
}
}
}
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 988e63997ca..a6639b8b3e5 100644
--- a/tests/FSharp.Compiler.LanguageServer.Tests/FSharp.Compiler.LanguageServer.Tests.fsproj
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/FSharp.Compiler.LanguageServer.Tests.fsproj
@@ -24,6 +24,7 @@
+
diff --git a/tests/FSharp.Compiler.LanguageServer.Tests/ProtocolHelpers.fs b/tests/FSharp.Compiler.LanguageServer.Tests/ProtocolHelpers.fs
index 01ee2c24609..677fbee0d43 100644
--- a/tests/FSharp.Compiler.LanguageServer.Tests/ProtocolHelpers.fs
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/ProtocolHelpers.fs
@@ -10,6 +10,7 @@ open System.Diagnostics
open System.Text.Json
open Microsoft.VisualStudio.LanguageServer.Protocol
+open Microsoft.Extensions.DependencyInjection
open Nerdbank.Streams
open FSharp.Test.ProjectGeneration.WorkspaceHelpers
@@ -27,13 +28,21 @@ type TestRpcClient(jsonRpc, rpcTrace, workspace, initializeResult: InitializeRes
member _.RpcTrace = rpcTrace.ToString()
-let createLanguageServerWithClient (workspace: FSharpWorkspace) (config: FSharpLanguageServerConfig option) (configureClient: JsonRpc -> unit) =
+let createLanguageServerWithServices
+ (workspace: FSharpWorkspace)
+ (config: FSharpLanguageServerConfig option)
+ (configureClient: JsonRpc -> unit)
+ (configureServices: IServiceCollection -> unit)
+ =
let rpcTrace = new StringWriter()
let (inputStream, outputStream), server =
- match config with
- | Some cfg -> FSharpLanguageServer.Create(LspLogger Trace.TraceInformation, workspace, config = cfg)
- | None -> FSharpLanguageServer.Create(workspace)
+ FSharpLanguageServer.Create(
+ LspLogger Trace.TraceInformation,
+ workspace,
+ addExtraHandlers = Action(configureServices),
+ ?config = config
+ )
let formatter = createConfiguredFormatter ()
@@ -66,6 +75,9 @@ let createLanguageServerWithClient (workspace: FSharpWorkspace) (config: FSharpL
return TestRpcClient(jsonRpc, rpcTrace, workspace, response)
}
+let createLanguageServerWithClient workspace config configureClient =
+ createLanguageServerWithServices workspace config configureClient ignore
+
let createLanguageServer workspace config =
createLanguageServerWithClient workspace config ignore
diff --git a/tests/FSharp.Compiler.LanguageServer.Tests/TelemetryProtocolTests.fs b/tests/FSharp.Compiler.LanguageServer.Tests/TelemetryProtocolTests.fs
new file mode 100644
index 00000000000..f93ee58a579
--- /dev/null
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/TelemetryProtocolTests.fs
@@ -0,0 +1,156 @@
+module LanguageServer.TelemetryProtocolTests
+
+open System
+open System.Collections.Concurrent
+open System.IO
+open System.Threading.Tasks
+
+open Microsoft.Extensions.DependencyInjection
+open Microsoft.VisualStudio.LanguageServer.Protocol
+
+open FSharp.Compiler.CodeAnalysis.Workspace
+open FSharp.Compiler.LanguageServer
+open FSharp.Compiler.LanguageServer.Handlers
+open StreamJsonRpc
+open Xunit
+
+open LanguageServer.ProtocolHelpers
+
+#nowarn "57"
+
+type private RecordingTelemetry(?throwOnSemanticEvent: bool) =
+ let events = ConcurrentQueue()
+ let faults = ConcurrentQueue()
+ member _.Events = events.ToArray()
+ member _.Faults = faults.ToArray()
+
+ interface ILspTelemetry with
+ member _.ReportEvent(name, props) =
+ if name = TelemetryEvents.GetSemanticTokens && defaultArg throwOnSemanticEvent false then
+ failwith "Telemetry publisher failed"
+
+ events.Enqueue(name, props)
+
+ member _.ReportEventWithDuration(name, props) =
+ NullTelemetry.Instance.ReportEventWithDuration(name, props)
+
+ member _.ReportFault(name, _, _) = faults.Enqueue name
+
+let private createClient (telemetry: RecordingTelemetry) =
+ createLanguageServerWithServices (FSharpWorkspace()) None ignore (fun services ->
+ services.AddSingleton(telemetry) |> ignore)
+
+let private measurements (telemetry: RecordingTelemetry) =
+ telemetry.Events
+ |> Array.choose (fun (name, props) ->
+ if name = TelemetryEvents.GetSemanticTokens then Some(Map.ofArray props) else None)
+
+[]
+[]
+[]
+[]
+[]
+let ``Semantic request telemetry distinguishes semantic data from lexical only results`` rangeRequest inProject =
+ task {
+ let telemetry = RecordingTelemetry()
+ let! client = createClient telemetry
+ let content = "module M\nlet value = 1"
+ let uri =
+ if inProject then
+ setupSingleFileProject client content
+ else
+ Uri(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".fs"))
+
+ do! openDocument client uri content 1
+ let! response =
+ if rangeRequest then
+ pullSemanticTokensRange client uri (Range(Start = Position(Line = 0), End = Position(Line = 1, Character = 13)))
+ else
+ pullSemanticTokensFull client uri
+
+ Assert.NotEmpty(response.Data)
+ let props = Assert.Single(measurements telemetry)
+ Assert.Equal(box "completed", props["outcome"])
+ Assert.Equal(box inProject, props["semantic_data_available"])
+ Assert.Equal(box true, props["isOpenDocument"])
+ Assert.Equal(box content.Length, props["document_length"])
+ Assert.Equal(box (response.Data.Length / 5), props["token_count"])
+ Assert.Equal(box (if rangeRequest then "range" else "full"), props["request_kind"])
+ Assert.True(unbox props["provider_duration_ms"] >= unbox props["duration_ms"])
+ Assert.False(props.ContainsKey "cacheHit")
+ }
+
+[]
+let ``No source and empty responses have distinct outcomes`` () =
+ task {
+ let telemetry = RecordingTelemetry()
+ let! client = createClient telemetry
+ let missing = Uri(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".fs"))
+ let! noSource = pullSemanticTokensFull client missing
+ Assert.Empty(noSource.Data)
+ let empty = setupSingleFileProject client ""
+ do! openDocument client empty "" 1
+ let! emptyResponse = pullSemanticTokensFull client empty
+ Assert.Empty(emptyResponse.Data)
+ let events = measurements telemetry
+ Assert.Equal(box "no_source", events[0]["outcome"])
+ Assert.Equal(box "completed", events[1]["outcome"])
+ }
+
+[]
+let ``Open state follows document notifications and close`` () =
+ task {
+ let telemetry = RecordingTelemetry()
+ let! client = createClient telemetry
+ let uri = setupSingleFileProject client "module M\nlet x = 1"
+ do! openDocument client uri "module M\nlet x = 1" 1
+ let! _ = pullSemanticTokensFull client uri
+ do! changeDocument client uri "module M\nlet x = 2" 2
+ let! _ = pullSemanticTokensFull client uri
+ do! closeDocument client uri
+ let! _ = pullSemanticTokensFull client uri
+ Assert.Equal([| true; true; false |], measurements telemetry |> Array.map (fun p -> unbox p["isOpenDocument"]))
+ }
+
+[]
+let ``A telemetry failure does not change semantic responses`` () =
+ task {
+ let! client = createClient (RecordingTelemetry(throwOnSemanticEvent = true))
+ let content = "module M\nlet value = 1"
+ let uri = setupSingleFileProject client content
+ do! openDocument client uri content 1
+ let! response = pullSemanticTokensFull client uri
+ Assert.NotEmpty(response.Data)
+ }
+
+[]
+let ``Failed semantic requests still record their terminal outcome`` () =
+ task {
+ let telemetry = RecordingTelemetry()
+ let! client = createClient telemetry
+ let uri = setupSingleFileProject client "module M\nlet value = 1"
+ File.Delete(uri.LocalPath)
+ let! _ = Assert.ThrowsAsync(fun () -> pullSemanticTokensFull client uri :> Task)
+ Assert.Equal(box "faulted", (Assert.Single(measurements telemetry))["outcome"])
+ }
+
+[]
+let ``Handled project context refresh failures produce server fault telemetry`` () =
+ task {
+ let telemetry = RecordingTelemetry()
+ let! client =
+ createLanguageServerWithServices
+ (FSharpWorkspace())
+ None
+ (fun rpc -> rpc.AddLocalRpcMethod("workspace/semanticTokens/refresh", Action(fun () -> failwith "Refresh failed")))
+ (fun services -> services.AddSingleton(telemetry) |> ignore)
+
+ let uri, first, _ = setupMultiTargetLikeProject client "module M\nlet value = 1" "multi.fsproj" "NET8" "NET9"
+ let parameters: ActiveProjectContextParams =
+ { Uri = uri; ProjectFileName = first.ProjectFileName; OutputFileName = first.OutputFileName }
+ let! accepted = client.JsonRpc.InvokeWithParameterObjectAsync("textDocument/fsharp/setActiveProjectContext", parameters)
+ Assert.False(accepted)
+ Assert.Equal(TelemetryEvents.SetActiveProjectContext, Assert.Single(telemetry.Faults))
+ let _, props = telemetry.Events |> Array.find (fun (name, _) -> name = TelemetryEvents.SetActiveProjectContext)
+ Assert.Equal(box "refresh_failed", (Map.ofArray props)["outcome"])
+ }
diff --git a/tests/FSharp.VisualStudio.Extension.Tests/FSharp.VisualStudio.Extension.Tests.csproj b/tests/FSharp.VisualStudio.Extension.Tests/FSharp.VisualStudio.Extension.Tests.csproj
new file mode 100644
index 00000000000..93eb845c33d
--- /dev/null
+++ b/tests/FSharp.VisualStudio.Extension.Tests/FSharp.VisualStudio.Extension.Tests.csproj
@@ -0,0 +1,19 @@
+
+
+ $(FSharpNetCoreProductTargetFramework)-windows8.0
+ true
+ Exe
+ false
+ enable
+ 12
+ false
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/FSharp.VisualStudio.Extension.Tests/TelemetryTests.cs b/tests/FSharp.VisualStudio.Extension.Tests/TelemetryTests.cs
new file mode 100644
index 00000000000..3fe1750acf0
--- /dev/null
+++ b/tests/FSharp.VisualStudio.Extension.Tests/TelemetryTests.cs
@@ -0,0 +1,174 @@
+namespace FSharp.VisualStudio.Extension.Tests;
+
+using System;
+using System.Collections.Concurrent;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using FSharp.Compiler.CodeAnalysis.Workspace;
+using FSharp.Compiler.LanguageServer;
+using Microsoft.FSharp.Core;
+using Microsoft.VisualStudio.LanguageServer.Protocol;
+using Microsoft.VisualStudio.Telemetry;
+using StreamJsonRpc;
+using Xunit;
+
+public sealed class TelemetryTests
+{
+ private static readonly string SessionId = Guid.NewGuid().ToString("D");
+ private static Tuple[] Request(string kind = "full") => [Tuple.Create("request_kind", (object)kind)];
+
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(true, false)]
+ [InlineData(false, true)]
+ [InlineData(true, true)]
+ public void AllTelemetryRequiresTheCheckboxOrAMicrosoftInternalUser(bool enabled, bool internalUser)
+ {
+ var events = new ConcurrentBag();
+ var reporter = new VsTelemetryReporter(events.Add, isMicrosoftInternal: () => internalUser);
+ reporter.ReportLifecycle(TelemetryEvents.LspServerInitialized, []);
+ Assert.Equal(internalUser ? 1 : 0, events.Count);
+ reporter.Configure(new(SessionId, 1, enabled));
+ reporter.ReportEvent(TelemetryEvents.GetSemanticTokens, Request());
+ using (reporter.ReportEventWithDuration(TelemetryEvents.GetDiagnostics, [])) { }
+ reporter.ReportFault("lsp/context", "Refresh failed.", FSharpOption.Some(new InvalidOperationException()));
+ reporter.ReportLifecycle(TelemetryEvents.LspServerInitialized, []);
+ reporter.ReportLifecycle(TelemetryEvents.LspServerInitialized, []);
+ Assert.Equal(enabled || internalUser ? 4 : 0, events.Count);
+ Assert.All(events.Where(e => !e.Name.EndsWith("/initialized", StringComparison.Ordinal)),
+ e => Assert.Equal(SessionId, e.Properties["vs.fsharp.rollout_session_id"]));
+ }
+
+ [Fact]
+ public void MicrosoftInternalUsersDoNotWaitForConfigurationButClosedConnectionsStop()
+ {
+ var events = new ConcurrentBag();
+ var reporter = new VsTelemetryReporter(events.Add, isMicrosoftInternal: () => true);
+ reporter.ReportEvent(TelemetryEvents.DocumentOpened, []);
+ using (reporter.ReportEventWithDuration(TelemetryEvents.GetDiagnostics, [])) { }
+ reporter.ReportFault("lsp/context", "Refresh failed.", FSharpOption.Some(new InvalidOperationException()));
+ reporter.ReportLifecycle(TelemetryEvents.LspServerInitialized, []);
+ Assert.Equal(4, events.Count);
+ reporter.Disable();
+ reporter.ReportEvent(TelemetryEvents.DocumentClosed, []);
+ reporter.ReportLifecycle("lsp/initializationfailed", []);
+ reporter.ReportFault("lsp/closed", "Closed.", FSharpOption.Some(new InvalidOperationException()));
+ Assert.Equal(4, events.Count);
+ }
+
+ [Fact]
+ public void LifecycleWaitsForPolicyAndOptOutDiscardsPendingSummaries()
+ {
+ var events = new ConcurrentBag();
+ var reporter = new VsTelemetryReporter(events.Add, isMicrosoftInternal: () => false);
+ reporter.ReportLifecycle(TelemetryEvents.LspServerInitialized, []);
+ Assert.Empty(events);
+ reporter.Configure(new(SessionId, 1, false));
+ reporter.Configure(new(SessionId, 2, true));
+ Assert.Empty(events);
+ reporter.ReportLifecycle(TelemetryEvents.LspServerInitialized, []);
+ reporter.ReportLifecycle(TelemetryEvents.LspServerInitialized, []);
+ Assert.Single(events);
+ }
+
+ [Fact]
+ public void StalePoliciesAndInflightScopesCannotUndoOptOut()
+ {
+ var events = new ConcurrentBag();
+ var reporter = new VsTelemetryReporter(events.Add, isMicrosoftInternal: () => false);
+ reporter.Configure(new(SessionId, 2, false));
+ Assert.Equal(2, reporter.Configure(new(SessionId, 1, true)));
+ reporter.ReportEvent(TelemetryEvents.GetSemanticTokens, Request());
+ reporter.Configure(new(SessionId, 3, true));
+ var scope = reporter.ReportEventWithDuration(TelemetryEvents.GetSemanticTokens, Request());
+ reporter.Configure(new(SessionId, 4, false));
+ scope.Dispose();
+ Assert.Empty(events);
+ reporter.Configure(new(SessionId, 5, true));
+ reporter.Disable();
+ Assert.Equal(-1, reporter.Configure(new(SessionId, 6, true)));
+ reporter.ReportEvent(TelemetryEvents.GetSemanticTokens, Request());
+ Assert.Empty(events);
+ }
+
+ [Fact]
+ public void ReconnectedServersReuseOnlyTheHostLifetimePolicy()
+ {
+ TelemetryConfiguration? policy = null;
+ var events = new ConcurrentBag();
+ var first = new VsTelemetryReporter(events.Add, configurationChanged: value => policy = value, isMicrosoftInternal: () => false);
+ first.Configure(new(SessionId, 1, true));
+ first.Disable();
+ var reconnected = new VsTelemetryReporter(events.Add, isMicrosoftInternal: () => false);
+ reconnected.Configure(Assert.IsType(policy));
+ reconnected.ReportEvent(TelemetryEvents.GetSemanticTokens, Request());
+ Assert.Single(events);
+ reconnected.Configure(new("invalid", 2, true));
+ Assert.Equal(1, reconnected.Configure(new(SessionId, 0, true)));
+ reconnected.ReportEvent(TelemetryEvents.DocumentOpened, []);
+ Assert.Single(events);
+ }
+
+ [Fact]
+ public void SamplesAreBoundedPerRequestKindUnderConcurrency()
+ {
+ var events = new ConcurrentBag();
+ var clock = new Clock();
+ var reporter = new VsTelemetryReporter(events.Add, clock, isMicrosoftInternal: () => false);
+ reporter.Configure(new(SessionId, 1, true));
+ Parallel.For(0, 100, _ => reporter.ReportEvent(TelemetryEvents.GetSemanticTokens, Request()));
+ reporter.ReportEvent(TelemetryEvents.GetSemanticTokens, Request("range"));
+ Assert.Equal(2, events.Count);
+ clock.Advance();
+ reporter.ReportEvent(TelemetryEvents.GetSemanticTokens, Request());
+ Assert.Equal(3, events.Count);
+ Assert.All(events, e => Assert.Equal(1, e.Properties["vs.fsharp.schema_version"]));
+ }
+
+ [Fact]
+ public void FaultsAreGatedDeduplicatedAndDoNotIncludeCancellation()
+ {
+ var events = new ConcurrentBag();
+ var reporter = new VsTelemetryReporter(events.Add, isMicrosoftInternal: () => false);
+ var error = FSharpOption.Some(new InvalidOperationException());
+ reporter.ReportFault("lsp/context", "Refresh failed.", error);
+ Assert.Empty(events);
+ reporter.Configure(new(SessionId, 1, true));
+ reporter.ReportFault("lsp/context", "Refresh failed.", error);
+ reporter.ReportFault("lsp/context", "Refresh failed.", error);
+ reporter.ReportFault("lsp/context", "Cancelled.", FSharpOption.Some(new OperationCanceledException()));
+ reporter.ReportFault("lsp/context", "Disconnected.", FSharpOption.Some(
+ new ConnectionLostException("Cancelled", new OperationCanceledException())));
+ Assert.IsType(Assert.Single(events));
+ var failingReporter = new VsTelemetryReporter(_ => throw new InvalidOperationException(), isMicrosoftInternal: () => false);
+ failingReporter.Configure(new(SessionId, 1, true));
+ failingReporter.ReportEvent(TelemetryEvents.GetSemanticTokens, Request());
+ failingReporter.ReportFault("lsp/context", "Refresh failed.", error);
+ }
+
+ [Fact]
+ public async Task ConfigurationUsesTheRealJsonRpcAndServiceRegistration()
+ {
+ var reporter = new VsTelemetryReporter(_ => { }, isMicrosoftInternal: () => false);
+ var connection = FSharpLanguageServer.Create(new FSharpWorkspace(), reporter.Register);
+ using var serverRpc = connection.Item2.JsonRpc;
+ using var rpc = new JsonRpc(new HeaderDelimitedMessageHandler(
+ connection.Item1.Item1, connection.Item1.Item2, Stuff.createConfiguredFormatter()));
+ rpc.StartListening();
+ await rpc.InvokeWithParameterObjectAsync(
+ "initialize", new InitializeParams { Capabilities = new ClientCapabilities() }, cancellationToken: TestContext.Current.CancellationToken);
+ var result = await rpc.InvokeWithParameterObjectAsync(
+ "fsharp/telemetry/configure", new TelemetryConfiguration(SessionId, 7, false), cancellationToken: TestContext.Current.CancellationToken);
+ Assert.Equal(7, result.Generation);
+ Assert.True(result.SemanticTokensEnabled);
+ }
+
+ private sealed class Clock : TimeProvider
+ {
+ private long ticks;
+ public override long TimestampFrequency => TimeSpan.TicksPerSecond;
+ public override long GetTimestamp() => Interlocked.Read(ref this.ticks);
+ public void Advance() => Interlocked.Add(ref this.ticks, TimeSpan.FromSeconds(5).Ticks);
+ }
+}
diff --git a/vsintegration/src/FSharp.Editor/Classification/ClassificationService.fs b/vsintegration/src/FSharp.Editor/Classification/ClassificationService.fs
index 5549672e26d..06a519b5230 100644
--- a/vsintegration/src/FSharp.Editor/Classification/ClassificationService.fs
+++ b/vsintegration/src/FSharp.Editor/Classification/ClassificationService.fs
@@ -9,6 +9,8 @@ open System.Collections.Immutable
open System.Threading
open System.Threading.Tasks
open System.Runtime.Caching
+open System.Diagnostics
+open System.Runtime.ExceptionServices
open Microsoft.CodeAnalysis
open Microsoft.CodeAnalysis.Classification
@@ -35,27 +37,21 @@ type SemanticClassificationData = SemanticClassificationView
type SemanticClassificationLookup = IReadOnlyDictionary>
[)>]
-type internal FSharpClassificationService [] (serviceProvider: SVsServiceProvider) =
-
- let produceSemanticClassification =
- let settingValue =
- try
- let settingsManager =
- serviceProvider.GetService(typeof)
- :?> Microsoft.VisualStudio.Utilities.UnifiedSettings.ISettingsManager
-
- let reader = settingsManager.GetReader()
- reader.GetValueOrThrow("fsharp.semanticClassificationProvider")
- with _ ->
- // Setting not yet written or settings manager unavailable: fall back
- // to the legacy default so users keep seeing in-proc colouring.
- "old"
-
- // Only "lsp" disables the in-proc classifier; "old" and any unrecognised
- // value (including the first-run "unset" sentinel) keep it on.
- match settingValue with
- | "lsp" -> false
- | _ -> true
+type internal FSharpClassificationService [] (serviceProvider: SVsServiceProvider, options: EditorOptions) =
+
+ let struct (_, provider) = HighlightingTelemetry.readProvider serviceProvider
+ let produceSemanticClassification = provider <> "lsp"
+
+ do
+ HighlightingTelemetry.instance.ReportState(
+ "old",
+ (if produceSemanticClassification then
+ "enabled"
+ else
+ "disabled"),
+ [| "resolved_provider", box provider |],
+ (fun () -> options.Advanced.SendAdditionalTelemetry)
+ )
static let getLexicalClassifications (filePath: string, defines, text: SourceText, textSpan: TextSpan, ct: CancellationToken) =
@@ -231,92 +227,74 @@ type internal FSharpClassificationService [] (serviceProvi
else
cancellableTask {
use _logBlock = Logger.LogBlock(LogEditorFunctionId.Classification_Semantic)
-
- let! sourceText = document.GetTextAsync(cancellationToken)
-
- // If we are trying to get semantic classification for a document that is not open, get the results from the background and cache it.
- // We do this for find all references when it is populating results.
- // We cache it temporarily so we do not have to continuously call into the checker and perform a background operation.
+ let stopwatch = Stopwatch.StartNew()
+ let branchStopwatch = Stopwatch()
+ let mutable outcome = "faulted"
+ let mutable cacheHit = false
+ let mutable documentLength = -1
let isOpenDocument = document.Project.Solution.Workspace.IsDocumentOpen document.Id
- if not isOpenDocument then
- match! unopenedDocumentsSemanticClassificationCache.TryGetValueAsync document with
- | ValueSome classificationDataLookup ->
- let eventProps: (string * obj) array =
- [|
- "context.document.project.id", document.Project.Id.Id.ToString()
- "context.document.id", document.Id.Id.ToString()
- "isOpenDocument", isOpenDocument
- "textSpanLength", textSpan.Length
- "cacheHit", true
- |]
-
- use _eventDuration =
- TelemetryReporter.ReportSingleEventWithDuration(TelemetryEvents.AddSemanticClassifications, eventProps)
-
- addSemanticClassificationByLookup sourceText textSpan classificationDataLookup result
- | ValueNone ->
- let eventProps: (string * obj) array =
- [|
- "context.document.project.id", document.Project.Id.Id.ToString()
- "context.document.id", document.Id.Id.ToString()
- "isOpenDocument", isOpenDocument
- "textSpanLength", textSpan.Length
- "cacheHit", false
- |]
-
- use _eventDuration =
- TelemetryReporter.ReportSingleEventWithDuration(TelemetryEvents.AddSemanticClassifications, eventProps)
-
- let! classificationData = document.GetFSharpSemanticClassificationAsync(nameof (FSharpClassificationService))
-
- let classificationDataLookup = toSemanticClassificationLookup classificationData
- do! unopenedDocumentsSemanticClassificationCache.SetAsync(document, classificationDataLookup)
- addSemanticClassificationByLookup sourceText textSpan classificationDataLookup result
- else
-
- match! openedDocumentsSemanticClassificationCache.TryGetValueAsync document with
- | ValueSome classificationDataLookup ->
- let eventProps: (string * obj) array =
- [|
- "context.document.project.id", document.Project.Id.Id.ToString()
- "context.document.id", document.Id.Id.ToString()
- "isOpenDocument", isOpenDocument
- "textSpanLength", textSpan.Length
- "cacheHit", true
- |]
-
- use _eventDuration =
- TelemetryReporter.ReportSingleEventWithDuration(TelemetryEvents.AddSemanticClassifications, eventProps)
-
- addSemanticClassificationByLookup sourceText textSpan classificationDataLookup result
- | ValueNone ->
-
- let eventProps: (string * obj) array =
- [|
- "context.document.project.id", document.Project.Id.Id.ToString()
- "context.document.id", document.Id.Id.ToString()
- "isOpenDocument", isOpenDocument
- "textSpanLength", textSpan.Length
- "cacheHit", false
- |]
-
- use _eventDuration =
- TelemetryReporter.ReportSingleEventWithDuration(TelemetryEvents.AddSemanticClassifications, eventProps)
-
- let! _, checkResults = document.GetFSharpParseAndCheckResultsAsync(nameof (IFSharpClassificationService))
-
- let targetRange =
- RoslynHelpers.TextSpanToFSharpRange(document.FilePath, textSpan, sourceText)
-
- let classificationData =
- checkResults.GetSemanticClassification(Some targetRange, RelatedSymbolUseKind.All)
-
- if classificationData.Length > 0 then
- let classificationDataLookup = itemToSemanticClassificationLookup classificationData
+ try
+ try
+ let! sourceText = document.GetTextAsync(cancellationToken)
+ documentLength <- sourceText.Length
+
+ let cache =
+ if isOpenDocument then
+ openedDocumentsSemanticClassificationCache
+ else
+ unopenedDocumentsSemanticClassificationCache
+
+ let! cached = cache.TryGetValueAsync document
+ branchStopwatch.Start()
+
+ match cached with
+ | ValueSome classificationDataLookup ->
+ cacheHit <- true
+ addSemanticClassificationByLookup sourceText textSpan classificationDataLookup result
+ | ValueNone when not isOpenDocument ->
+ let! classificationData =
+ document.GetFSharpSemanticClassificationAsync(nameof (FSharpClassificationService))
+
+ let classificationDataLookup = toSemanticClassificationLookup classificationData
do! unopenedDocumentsSemanticClassificationCache.SetAsync(document, classificationDataLookup)
-
- addSemanticClassification sourceText textSpan classificationData result
+ addSemanticClassificationByLookup sourceText textSpan classificationDataLookup result
+ | ValueNone ->
+ let! _, checkResults = document.GetFSharpParseAndCheckResultsAsync(nameof (IFSharpClassificationService))
+
+ let targetRange =
+ RoslynHelpers.TextSpanToFSharpRange(document.FilePath, textSpan, sourceText)
+
+ let classificationData =
+ checkResults.GetSemanticClassification(Some targetRange, RelatedSymbolUseKind.All)
+
+ if classificationData.Length > 0 then
+ let classificationDataLookup = itemToSemanticClassificationLookup classificationData
+ do! unopenedDocumentsSemanticClassificationCache.SetAsync(document, classificationDataLookup)
+ addSemanticClassification sourceText textSpan classificationData result
+
+ outcome <- "completed"
+ with :? OperationCanceledException as error ->
+ outcome <- "cancelled"
+ ExceptionDispatchInfo.Capture(error).Throw()
+ finally
+ HighlightingTelemetry.instance.ReportDetails(
+ (fun () ->
+ document.Project.Solution.Workspace.Services.GetService().Advanced.SendAdditionalTelemetry),
+ [|
+ "context.document.project.id", box (document.Project.Id.Id.ToString())
+ "context.document.id", box (document.Id.Id.ToString())
+ "isOpenDocument", box isOpenDocument
+ "textSpanLength", box textSpan.Length
+ "outcome", box outcome
+ "provider_duration_ms", box stopwatch.ElapsedMilliseconds
+ if documentLength >= 0 then
+ "document_length", box documentLength
+ if branchStopwatch.IsRunning then
+ "cacheHit", box cacheHit
+ "vs_event_duration_ms", box branchStopwatch.ElapsedMilliseconds
+ |]
+ )
}
|> CancellableTask.ifCanceledReturn ()
|> CancellableTask.startAsTask cancellationToken
diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
index 9e49315dc55..60cd8d3681f 100644
--- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
+++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
@@ -47,6 +47,7 @@
+
diff --git a/vsintegration/src/FSharp.Editor/LanguageService/LspProjectContextListener.fs b/vsintegration/src/FSharp.Editor/LanguageService/LspProjectContextListener.fs
index 662e9a750c2..47cee123954 100644
--- a/vsintegration/src/FSharp.Editor/LanguageService/LspProjectContextListener.fs
+++ b/vsintegration/src/FSharp.Editor/LanguageService/LspProjectContextListener.fs
@@ -14,16 +14,59 @@ open Microsoft.VisualStudio.Text.Editor
open Microsoft.VisualStudio.Threading
open Microsoft.VisualStudio.Utilities
+#nowarn "44"
+
[)>]
[]
[]
+[]
type internal LspProjectContextListener [] (broker: ILanguageServiceBroker2, workspace: VisualStudioWorkspace) =
+ let options = workspace.Services.GetService()
+
+ let telemetry =
+ new LspTelemetryConsentForwarder(
+ HighlightingTelemetry.instance,
+ options,
+ ThreadHelper.JoinableTaskContext,
+ (fun parameters cancellationToken ->
+ let request =
+ GeneralRequest<_, HighlightingTelemetryConfigurationResult>(
+ Method = "fsharp/telemetry/configure",
+ LanguageServerName = "FSharp Analyzer LSP server",
+ Request = parameters
+ )
+
+ broker.RequestAsync(request, cancellationToken))
+ )
+
+ let loaded =
+ EventHandler(fun _ args ->
+ if args.ClientInstance.Client.Name = "FSharp Analyzer LSP server" then
+ telemetry.Configure true)
+
+ do broker.LanguageClientLoaded.AddHandler loaded
+
+ interface IDisposable with
+ member _.Dispose() =
+ broker.LanguageClientLoaded.RemoveHandler loaded
+ (telemetry :> IDisposable).Dispose()
+
interface IWpfTextViewCreationListener with
member _.TextViewCreated(view) =
let cancellation = new CancellationTokenSource()
let requests = ThreadHelper.JoinableTaskContext.CreateCollection()
let factory = ThreadHelper.JoinableTaskContext.CreateFactory requests
+ let struct (settingValue, provider) =
+ HighlightingTelemetry.readProvider ServiceProvider.GlobalProvider
+
+ telemetry.RegisterView(settingValue, provider, view.Closed)
+
+ let focused =
+ EventHandler(fun _ _ -> telemetry.Configure(retry = (provider = "lsp")))
+
+ view.GotAggregateFocus.AddHandler focused
+
let updateContext () =
factory
.RunAsync(fun () ->
@@ -58,6 +101,7 @@ type internal LspProjectContextListener [] (broker: ILangu
let handler =
EventHandler(fun _ args ->
if args.SourceTextContainer = view.TextBuffer.AsTextContainer() then
+ telemetry.Configure false
updateContext ())
workspace.DocumentActiveContextChanged.AddHandler handler
@@ -65,6 +109,7 @@ type internal LspProjectContextListener [] (broker: ILangu
view.Closed.Add(fun _ ->
workspace.DocumentActiveContextChanged.RemoveHandler handler
cancellation.Cancel()
+ view.GotAggregateFocus.RemoveHandler focused
ThreadHelper.JoinableTaskFactory
.RunAsync(fun () ->
diff --git a/vsintegration/src/FSharp.Editor/LanguageService/TextViewCreationListener.fs b/vsintegration/src/FSharp.Editor/LanguageService/TextViewCreationListener.fs
index 693d4d904e7..2a558540a28 100644
--- a/vsintegration/src/FSharp.Editor/LanguageService/TextViewCreationListener.fs
+++ b/vsintegration/src/FSharp.Editor/LanguageService/TextViewCreationListener.fs
@@ -16,7 +16,7 @@ open Microsoft.VisualStudio.OLE.Interop
[)>]
[]
[]
-type TextViewCreationListener [] (adaptersFactory: IVsEditorAdaptersFactoryService) =
+type TextViewCreationListener [] (adaptersFactory: IVsEditorAdaptersFactoryService, options: EditorOptions) =
///
/// The initializes the pguidCmdUI to an empty Guid. This means that our buffer does not receive the normal text editor command bindings.
@@ -62,4 +62,14 @@ type TextViewCreationListener [] (adaptersFactory: IVsEdit
member _.VsTextViewCreated(textViewAdapter) =
let _textView = adaptersFactory.GetWpfTextView(textViewAdapter)
+
+ if
+ not (isNull _textView)
+ && _textView.Roles.Contains(PredefinedTextViewRoles.Document)
+ then
+ let struct (settingValue, provider) =
+ HighlightingTelemetry.readProvider ServiceProvider.GlobalProvider
+
+ HighlightingTelemetry.instance.ReportExposure(settingValue, provider, (fun () -> options.Advanced.SendAdditionalTelemetry))
+
initKeyBindings textViewAdapter
diff --git a/vsintegration/src/FSharp.Editor/Options/EditorOptions.fs b/vsintegration/src/FSharp.Editor/Options/EditorOptions.fs
index 80310903254..080da158617 100644
--- a/vsintegration/src/FSharp.Editor/Options/EditorOptions.fs
+++ b/vsintegration/src/FSharp.Editor/Options/EditorOptions.fs
@@ -145,6 +145,7 @@ type FormattingOptions =
[]
type EditorOptions private (reader: ISettingsReader option) =
let cache = ConcurrentDictionary()
+ let changed = Event()
let register (value: 'T) = cache[typeof<'T>] <- value
@@ -242,7 +243,10 @@ type EditorOptions private (reader: ISettingsReader option) =
let subscription =
reader
- |> Option.map (fun reader -> UnifiedSettings.subscribeToChanges reader "languages.fsharp.*" (fun () -> refresh reader))
+ |> Option.map (fun reader ->
+ UnifiedSettings.subscribeToChanges reader "languages.fsharp.*" (fun () ->
+ refresh reader
+ changed.Trigger()))
new() = new EditorOptions(None)
@@ -271,7 +275,11 @@ type EditorOptions private (reader: ISettingsReader option) =
member _.Advanced = unbox cache[typeof]
member _.Formatting = unbox cache[typeof]
- member _.With value = register value
+ member internal _.SettingsChanged = changed.Publish
+
+ member _.With value =
+ register value
+ changed.Trigger()
interface Microsoft.CodeAnalysis.Host.IWorkspaceService
diff --git a/vsintegration/src/FSharp.Editor/Telemetry/SemanticHighlightingTelemetry.fs b/vsintegration/src/FSharp.Editor/Telemetry/SemanticHighlightingTelemetry.fs
new file mode 100644
index 00000000000..dd698b92a18
--- /dev/null
+++ b/vsintegration/src/FSharp.Editor/Telemetry/SemanticHighlightingTelemetry.fs
@@ -0,0 +1,263 @@
+namespace Microsoft.VisualStudio.FSharp.Editor
+
+open System
+open System.Collections.Generic
+open System.Diagnostics
+open System.Threading
+open System.Threading.Tasks
+
+open Microsoft.Internal.VisualStudio.Shell.Interop
+open Microsoft.VisualStudio.FSharp.Editor.Telemetry
+open Microsoft.VisualStudio.Settings
+open Microsoft.VisualStudio.Shell
+open Microsoft.VisualStudio.Telemetry
+open Microsoft.VisualStudio.Threading
+open StreamJsonRpc
+
+type internal HighlightingTelemetryConfiguration =
+ {|
+ RolloutSessionId: string
+ Generation: int64
+ Enabled: bool
+ |}
+
+[]
+type internal HighlightingTelemetryConfigurationResult =
+ {
+ Generation: int64
+ SemanticTokensEnabled: bool
+ }
+
+type internal SemanticHighlightingTelemetry
+ (postEvent: TelemetryEvent -> unit, ?now: unit -> DateTime, ?delay: int * CancellationToken -> Task, ?isMicrosoftInternal: unit -> bool)
+ =
+ let sessionId = Guid.NewGuid().ToString("D")
+ let now = defaultArg now (fun () -> DateTime.UtcNow)
+
+ let delay =
+ defaultArg delay (fun (milliseconds, ct) -> Task.Delay(milliseconds, ct))
+
+ let isMicrosoftInternal =
+ defaultArg isMicrosoftInternal (fun () -> TelemetryService.DefaultSession.IsUserMicrosoftInternal)
+
+ let mutable exposed = 0
+ let mutable generation = 0L
+ let gate = obj ()
+ let states = HashSet(StringComparer.Ordinal)
+ let mutable lastDetail = DateTime.MinValue
+
+ let canReport enabled =
+ try
+ isMicrosoftInternal () || enabled ()
+ with _ ->
+ false
+
+ let post name props enabled =
+ try
+ if canReport enabled then
+ TelemetryReporter.createEvent name [| "schema_version", box 1; "rollout_session_id", box sessionId; yield! props |]
+ |> postEvent
+ with _ ->
+ Trace.TraceWarning("F# highlighting telemetry could not be posted.")
+
+ member _.ReportExposure(settingValue, provider, enabled) =
+ if canReport enabled && Interlocked.CompareExchange(&exposed, 1, 0) = 0 then
+ post "semantichighlighting/exposure" [| "setting_value", box settingValue; "resolved_provider", box provider |] enabled
+
+ member _.ReportState(provider: string, state: string, props, enabled) =
+ if canReport enabled && lock gate (fun () -> states.Add(provider + "/" + state)) then
+ post "semantichighlighting/providerstate" [| "provider", box provider; "state", box state; yield! props |] enabled
+
+ member _.IsEnabled(options: EditorOptions) =
+ canReport (fun () -> options.Advanced.SendAdditionalTelemetry)
+
+ member this.Configure(options: EditorOptions) =
+ lock gate (fun () ->
+ {|
+ RolloutSessionId = sessionId
+ Generation = Interlocked.Increment(&generation)
+ Enabled = this.IsEnabled options
+ |})
+
+ member this.ConfigureServer
+ (
+ options: EditorOptions,
+ send: HighlightingTelemetryConfiguration -> Task,
+ retry,
+ cancellationToken: CancellationToken
+ ) =
+ task {
+ let enabled () =
+ options.Advanced.SendAdditionalTelemetry
+
+ try
+ let mutable configured = false
+ // Loaded clients need not be initialized yet. Retry only this startup handshake.
+ let delays =
+ if retry then
+ [| 0; 250; 1000; 4000; 16000; 64000; 128000 |]
+ else
+ [| 0 |]
+
+ for milliseconds in delays do
+ if not configured then
+ do! delay (milliseconds, cancellationToken)
+ let parameters = this.Configure options
+
+ let! result =
+ task {
+ try
+ return! send parameters
+ with
+ | :? TimeoutException
+ | :? ConnectionLostException
+ | :? ObjectDisposedException
+ | :? OperationCanceledException when retry && not cancellationToken.IsCancellationRequested ->
+ return Unchecked.defaultof<_>
+ }
+
+ configured <- not (isNull (box result)) && result.Generation >= parameters.Generation
+
+ if configured then
+ this.ReportState("lsp", (if result.SemanticTokensEnabled then "ready" else "disabled"), [||], enabled)
+
+ if retry && not configured then
+ this.ReportState("lsp", "unavailable", [||], enabled)
+ with
+ | :? OperationCanceledException when cancellationToken.IsCancellationRequested -> ()
+ | _ -> this.ReportState("lsp", "unavailable", [||], enabled)
+ }
+
+ member _.ReportDetails(enabled: unit -> bool, props) =
+ try
+ if canReport enabled then
+ let sampled =
+ lock gate (fun () ->
+ let current = now ()
+
+ if current - lastDetail >= TimeSpan.FromSeconds 5. then
+ lastDetail <- current
+ true
+ else
+ false)
+
+ if sampled && canReport enabled then
+ post
+ TelemetryEvents.AddSemanticClassifications
+ [| "provider", box "old"; "request_kind", box "span"; yield! props |]
+ enabled
+ with _ ->
+ Trace.TraceWarning("F# highlighting telemetry could not be collected.")
+
+type internal LspTelemetryConsentForwarder
+ (
+ telemetry: SemanticHighlightingTelemetry,
+ options: EditorOptions,
+ context: JoinableTaskContext,
+ send: HighlightingTelemetryConfiguration -> CancellationToken -> Task
+ ) =
+ let cancellation = new CancellationTokenSource()
+ let cancellationToken = cancellation.Token
+ let requests = context.CreateCollection()
+ let factory = context.CreateFactory requests
+ let subscriptions = HashSet()
+ let gate = obj ()
+ let mutable configuring = 0
+ let mutable disposed = 0
+
+ let configure retry =
+ if
+ not cancellationToken.IsCancellationRequested
+ && (not retry || Interlocked.CompareExchange(&configuring, 1, 0) = 0)
+ then
+ factory
+ .RunAsync(fun () ->
+ task {
+ try
+ do!
+ telemetry.ConfigureServer(
+ options,
+ (fun parameters -> send parameters cancellationToken),
+ retry,
+ cancellationToken
+ )
+ finally
+ if retry then
+ Interlocked.Exchange(&configuring, 0) |> ignore
+ }
+ :> Task)
+ .FileAndForget("vs/fsharp/lsp/telemetry", fileOnlyIf = Func(fun _ -> telemetry.IsEnabled options))
+
+ let settingsSubscription =
+ options.SettingsChanged.Subscribe(fun () -> configure false)
+
+ member _.Configure(retry) = configure retry
+
+ member this.RegisterView(settingValue, provider, closed: IEvent) =
+ let subscription =
+ options.SettingsChanged.Subscribe(fun () ->
+ telemetry.ReportExposure(settingValue, provider, (fun () -> options.Advanced.SendAdditionalTelemetry)))
+
+ lock gate (fun () ->
+ if Volatile.Read(&disposed) = 0 then
+ subscriptions.Add subscription |> ignore
+ else
+ subscription.Dispose())
+
+ closed.Add(fun _ ->
+ lock gate (fun () ->
+ subscriptions.Remove subscription |> ignore
+ subscription.Dispose()))
+
+ this.Configure(retry = (provider = "lsp"))
+
+ member _.WaitForPendingRequestsAsync() = requests.JoinTillEmptyAsync()
+
+ interface IDisposable with
+ member this.Dispose() =
+ if Interlocked.Exchange(&disposed, 1) = 0 then
+ settingsSubscription.Dispose()
+ cancellation.Cancel()
+
+ lock gate (fun () ->
+ for subscription in subscriptions do
+ subscription.Dispose()
+
+ subscriptions.Clear())
+
+ context.Factory
+ .RunAsync(fun () ->
+ task {
+ try
+ do! this.WaitForPendingRequestsAsync()
+ finally
+ cancellation.Dispose()
+ }
+ :> Task)
+ .FileAndForget("vs/fsharp/lsp/telemetry", fileOnlyIf = Func(fun _ -> telemetry.IsEnabled options))
+
+[]
+module internal HighlightingTelemetry =
+ let instance =
+ SemanticHighlightingTelemetry(TelemetryService.DefaultSession.PostEvent)
+
+ let normalizeProvider value =
+ let settingValue =
+ match value with
+ | "old"
+ | "lsp"
+ | "unset" -> value
+ | _ -> "unrecognized"
+
+ struct (settingValue, if value = "lsp" then "lsp" else "old")
+
+ let readProvider (serviceProvider: IServiceProvider) =
+ try
+ let settings =
+ serviceProvider.GetService(typeof)
+ :?> Microsoft.VisualStudio.Utilities.UnifiedSettings.ISettingsManager
+
+ settings.GetReader().GetValueOrThrow("fsharp.semanticClassificationProvider")
+ |> normalizeProvider
+ with _ ->
+ struct ("unavailable", "old")
diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj
index 73ee106e8a7..303ffaa00cb 100644
--- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj
+++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj
@@ -89,6 +89,7 @@
+
diff --git a/vsintegration/tests/FSharp.Editor.Tests/TelemetryTests.fs b/vsintegration/tests/FSharp.Editor.Tests/TelemetryTests.fs
new file mode 100644
index 00000000000..c0fbeac74a9
--- /dev/null
+++ b/vsintegration/tests/FSharp.Editor.Tests/TelemetryTests.fs
@@ -0,0 +1,441 @@
+namespace FSharp.Editor.Tests
+
+open System
+open System.Collections.Concurrent
+open System.Threading
+open System.Threading.Tasks
+
+open Microsoft.VisualStudio.FSharp.Editor
+open Microsoft.VisualStudio.Telemetry
+open Microsoft.VisualStudio.Threading
+open StreamJsonRpc
+open Xunit
+
+type TelemetryTests() =
+ []
+ member _.``Closing the last view does not stop consent forwarding to a pending request``() =
+ task {
+ use context = new JoinableTaskContext()
+ use options = new EditorOptions()
+ let policies = ConcurrentQueue()
+ let published = ConcurrentQueue()
+
+ let telemetry =
+ SemanticHighlightingTelemetry(ignore, isMicrosoftInternal = (fun () -> false))
+
+ let receiver =
+ SemanticHighlightingTelemetry(published.Enqueue, isMicrosoftInternal = (fun () -> false))
+
+ use forwarder =
+ new LspTelemetryConsentForwarder(
+ telemetry,
+ options,
+ context,
+ (fun parameters _ ->
+ policies.Enqueue parameters
+
+ Task.FromResult(
+ {
+ Generation = parameters.Generation
+ SemanticTokensEnabled = true
+ }
+ ))
+ )
+
+ let closed = Event()
+ forwarder.RegisterView("lsp", "lsp", closed.Publish)
+ do! forwarder.WaitForPendingRequestsAsync()
+ Assert.True((policies.ToArray() |> Array.last).Enabled)
+
+ let finish =
+ TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)
+
+ let pending =
+ task {
+ do! finish.Task
+ receiver.ReportDetails((fun () -> (policies.ToArray() |> Array.last).Enabled), [||])
+ }
+
+ closed.Trigger(null, EventArgs.Empty)
+
+ options.With(
+ { options.Advanced with
+ SendAdditionalTelemetry = false
+ }
+ )
+
+ do! forwarder.WaitForPendingRequestsAsync()
+ finish.SetResult()
+ do! pending
+ Assert.Empty(published)
+ Assert.False((policies.ToArray() |> Array.last).Enabled)
+ }
+
+ []
+ member _.``Disposing the consent service detaches settings and cancels its work``() =
+ task {
+ use context = new JoinableTaskContext()
+ use options = new EditorOptions()
+
+ let telemetry =
+ SemanticHighlightingTelemetry(ignore, isMicrosoftInternal = (fun () -> false))
+
+ let mutable calls = 0
+ let mutable token = CancellationToken.None
+
+ use forwarder =
+ new LspTelemetryConsentForwarder(
+ telemetry,
+ options,
+ context,
+ (fun parameters cancellationToken ->
+ token <- cancellationToken
+ calls <- calls + 1
+
+ Task.FromResult(
+ {
+ Generation = parameters.Generation
+ SemanticTokensEnabled = true
+ }
+ ))
+ )
+
+ let closed = Event()
+ forwarder.RegisterView("lsp", "lsp", closed.Publish)
+ do! forwarder.WaitForPendingRequestsAsync()
+ (forwarder :> IDisposable).Dispose()
+
+ options.With(
+ { options.Advanced with
+ SendAdditionalTelemetry = false
+ }
+ )
+
+ do! forwarder.WaitForPendingRequestsAsync()
+ Assert.Equal(1, calls)
+ Assert.True(token.IsCancellationRequested)
+ }
+
+ []
+ []
+ []
+ []
+ []
+ member _.``All new telemetry requires the checkbox or a Microsoft internal user``(checkbox, internalUser) =
+ let events = ConcurrentQueue()
+ use options = new EditorOptions()
+
+ options.With(
+ { options.Advanced with
+ SendAdditionalTelemetry = checkbox
+ UseTransparentCompiler = true
+ }
+ )
+
+ let enabled () =
+ options.Advanced.SendAdditionalTelemetry
+
+ let telemetry =
+ SemanticHighlightingTelemetry(events.Enqueue, isMicrosoftInternal = (fun () -> internalUser))
+
+ telemetry.ReportExposure("lsp", "lsp", enabled)
+ telemetry.ReportExposure("lsp", "lsp", enabled)
+ telemetry.ReportState("lsp", "ready", [||], enabled)
+ telemetry.ReportState("lsp", "ready", [||], enabled)
+ telemetry.ReportDetails(enabled, [||])
+ Assert.Equal(checkbox || internalUser, (telemetry.Configure options).Enabled)
+ Assert.Equal((if checkbox || internalUser then 3 else 0), events.Count)
+
+ []
+ member _.``Suppressed exposure and state do not consume their one time guards``() =
+ let events = ConcurrentQueue()
+
+ let telemetry =
+ SemanticHighlightingTelemetry(events.Enqueue, isMicrosoftInternal = (fun () -> false))
+
+ let mutable checkbox = false
+ let enabled () = checkbox
+ telemetry.ReportExposure("old", "old", enabled)
+ telemetry.ReportState("old", "enabled", [||], enabled)
+ Assert.Empty(events)
+ checkbox <- true
+
+ for _ in 1..2 do
+ telemetry.ReportExposure("old", "old", enabled)
+ telemetry.ReportState("old", "enabled", [||], enabled)
+
+ Assert.Equal(2, events.Count)
+ checkbox <- false
+ telemetry.ReportState("lsp", "ready", [||], enabled)
+ telemetry.ReportDetails(enabled, [||])
+ Assert.Equal(2, events.Count)
+
+ []
+ member _.``Failed LSP startup stays in the rollout denominator``() =
+ let events = ConcurrentQueue()
+
+ for provider, ready in [ "old", false; "lsp", true; "lsp", false ] do
+ let telemetry =
+ SemanticHighlightingTelemetry(events.Enqueue, isMicrosoftInternal = (fun () -> false))
+
+ telemetry.ReportExposure(provider, provider, (fun () -> true))
+ telemetry.ReportExposure(provider, provider, (fun () -> true))
+
+ if ready then
+ telemetry.ReportState("lsp", "ready", [||], (fun () -> true))
+
+ let exposures =
+ events
+ |> Seq.filter (fun e -> e.Name.EndsWith("/exposure", StringComparison.Ordinal))
+ |> Seq.toArray
+
+ Assert.Equal(3, exposures.Length)
+
+ Assert.Equal(
+ 2,
+ exposures
+ |> Array.filter (fun e -> e.Properties["dotnet.fsharp.resolved_provider"] = box "lsp")
+ |> Array.length
+ )
+
+ Assert.Single(
+ events
+ |> Seq.filter (fun e -> e.Name.EndsWith("/providerstate", StringComparison.Ordinal))
+ )
+ |> ignore
+
+ []
+ []
+ []
+ []
+ []
+ member _.``Exposure counts a session once regardless of views or details``(setting, expectedSetting, expectedProvider) =
+ let events = ConcurrentQueue()
+
+ let telemetry =
+ SemanticHighlightingTelemetry(events.Enqueue, isMicrosoftInternal = (fun () -> false))
+
+ let struct (settingValue, provider) =
+ HighlightingTelemetry.normalizeProvider setting
+
+ Parallel.For(0, 100, fun _ -> telemetry.ReportExposure(settingValue, provider, (fun () -> true)))
+ |> ignore
+
+ telemetry.ReportDetails((fun () -> false), [||])
+ let exposure = Assert.Single(events)
+ Assert.Equal(box expectedSetting, exposure.Properties["dotnet.fsharp.setting_value"])
+ Assert.Equal(box expectedProvider, exposure.Properties["dotnet.fsharp.resolved_provider"])
+
+ Assert.True(
+ Guid.TryParse(string exposure.Properties["dotnet.fsharp.rollout_session_id"])
+ |> fst
+ )
+
+ []
+ member _.``Highlighting details honor opt out sampling and publisher failures``() =
+ let events = ConcurrentQueue()
+ let mutable now = DateTime.UtcNow
+
+ let telemetry =
+ SemanticHighlightingTelemetry(events.Enqueue, (fun () -> now), isMicrosoftInternal = (fun () -> false))
+
+ telemetry.ReportDetails((fun () -> false), [||])
+ telemetry.ReportDetails((fun () -> true), [||])
+ telemetry.ReportDetails((fun () -> true), [||])
+ Assert.Single(events) |> ignore
+ now <- now.AddSeconds(5.)
+ telemetry.ReportDetails((fun () -> true), [||])
+ Assert.Equal(2, events.Count)
+
+ let failing =
+ SemanticHighlightingTelemetry((fun _ -> failwith "Publisher failed"), isMicrosoftInternal = (fun () -> false))
+
+ failing.ReportExposure("lsp", "lsp", (fun () -> true))
+ failing.ReportDetails((fun () -> true), [||])
+ failing.ReportDetails((fun () -> failwith "Settings unavailable"), [||])
+
+ []
+ member _.``Settings updates change the policy without compiler overrides``() =
+ use options = new EditorOptions()
+
+ let telemetry =
+ SemanticHighlightingTelemetry(ignore, isMicrosoftInternal = (fun () -> false))
+
+ let mutable changed = false
+ use _subscription = options.SettingsChanged.Subscribe(fun () -> changed <- true)
+
+ options.With(
+ { options.Advanced with
+ SendAdditionalTelemetry = false
+ UseTransparentCompiler = true
+ }
+ )
+
+ Assert.True(changed)
+ Assert.False((telemetry.Configure options).Enabled)
+
+ options.With(
+ { options.Advanced with
+ SendAdditionalTelemetry = true
+ }
+ )
+
+ Assert.True((telemetry.Configure options).Enabled)
+
+ []
+ []
+ []
+ []
+ []
+ []
+ []
+ member _.``Startup retries read the latest consent until the server is ready``(firstResponse: string) =
+ task {
+ let events = ConcurrentQueue()
+
+ let telemetry =
+ SemanticHighlightingTelemetry(
+ events.Enqueue,
+ delay = (fun _ -> Task.CompletedTask),
+ isMicrosoftInternal = (fun () -> false)
+ )
+
+ use options = new EditorOptions()
+ let mutable calls = 0
+
+ do!
+ telemetry.ConfigureServer(
+ options,
+ (fun parameters ->
+ calls <- calls + 1
+
+ if calls = 1 then
+ options.With(
+ { options.Advanced with
+ SendAdditionalTelemetry = false
+ }
+ )
+
+ match firstResponse with
+ | "null" -> Task.FromResult(Unchecked.defaultof<_>)
+ | "timeout" -> Task.FromException<_>(TimeoutException())
+ | "disconnect" -> Task.FromException<_>(ConnectionLostException())
+ | "disposed" -> Task.FromException<_>(ObjectDisposedException("rpc"))
+ | "cancelled" -> Task.FromException<_>(OperationCanceledException())
+ | "throw" -> raise (TimeoutException())
+ | _ -> failwith "Unknown startup response"
+ else
+ Assert.False(parameters.Enabled)
+
+ Task.FromResult(
+ {
+ Generation = parameters.Generation
+ SemanticTokensEnabled = true
+ }
+ )),
+ true,
+ CancellationToken.None
+ )
+
+ Assert.Equal(2, calls)
+ Assert.Empty(events)
+ }
+
+ []
+ []
+ []
+ []
+ member _.``Startup failures respect the retry budget and permanent failures stop``(retry, transient, expectedCalls) =
+ task {
+ let events = ConcurrentQueue()
+
+ let telemetry =
+ SemanticHighlightingTelemetry(
+ events.Enqueue,
+ delay = (fun _ -> Task.CompletedTask),
+ isMicrosoftInternal = (fun () -> false)
+ )
+
+ let mutable calls = 0
+
+ do!
+ telemetry.ConfigureServer(
+ new EditorOptions(),
+ (fun _ ->
+ calls <- calls + 1
+
+ let error: exn =
+ if transient then
+ TimeoutException()
+ else
+ InvalidOperationException()
+
+ Task.FromException<_>(error)),
+ retry,
+ CancellationToken.None
+ )
+
+ Assert.Equal(expectedCalls, calls)
+ Assert.Equal(box "unavailable", (Assert.Single(events)).Properties["dotnet.fsharp.state"])
+ }
+
+ []
+ member _.``Consent forwarding needs no selected provider and cancelled startup does not retry``() =
+ task {
+ use cancellation = new CancellationTokenSource()
+ let events = ConcurrentQueue()
+
+ let telemetry =
+ SemanticHighlightingTelemetry(
+ events.Enqueue,
+ delay =
+ (fun _ ->
+ cancellation.Cancel()
+ Task.FromCanceled(cancellation.Token)),
+ isMicrosoftInternal = (fun () -> false)
+ )
+
+ use options = new EditorOptions()
+ let mutable sent = false
+
+ do!
+ telemetry.ConfigureServer(
+ options,
+ (fun _ ->
+ sent <- true
+ Task.FromResult(Unchecked.defaultof<_>)),
+ true,
+ cancellation.Token
+ )
+
+ Assert.False(sent)
+ Assert.Empty(events)
+
+ options.With(
+ { options.Advanced with
+ SendAdditionalTelemetry = false
+ }
+ )
+
+ let direct =
+ SemanticHighlightingTelemetry(ignore, isMicrosoftInternal = (fun () -> false))
+
+ do!
+ direct.ConfigureServer(
+ options,
+ (fun parameters ->
+ sent <- true
+ Assert.False(parameters.Enabled)
+
+ Task.FromResult(
+ {
+ Generation = parameters.Generation
+ SemanticTokensEnabled = true
+ }
+ )),
+ false,
+ CancellationToken.None
+ )
+
+ Assert.True(sent)
+ }
From 810dc71c3fcdc844dd9478f49bc7f5a4bebac095 Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Wed, 11 Nov 2026 12:13:21 +0100
Subject: [PATCH 19/30] Rename vs-mcp-testing-tools dir
---
.../{vs-mcp-testing-tool => vs-mcp-testing-tools}/REFERENCE.md | 0
.../skills/{vs-mcp-testing-tool => vs-mcp-testing-tools}/SKILL.md | 0
2 files changed, 0 insertions(+), 0 deletions(-)
rename .github/skills/{vs-mcp-testing-tool => vs-mcp-testing-tools}/REFERENCE.md (100%)
rename .github/skills/{vs-mcp-testing-tool => vs-mcp-testing-tools}/SKILL.md (100%)
diff --git a/.github/skills/vs-mcp-testing-tool/REFERENCE.md b/.github/skills/vs-mcp-testing-tools/REFERENCE.md
similarity index 100%
rename from .github/skills/vs-mcp-testing-tool/REFERENCE.md
rename to .github/skills/vs-mcp-testing-tools/REFERENCE.md
diff --git a/.github/skills/vs-mcp-testing-tool/SKILL.md b/.github/skills/vs-mcp-testing-tools/SKILL.md
similarity index 100%
rename from .github/skills/vs-mcp-testing-tool/SKILL.md
rename to .github/skills/vs-mcp-testing-tools/SKILL.md
From ec21061128221db6d9d5c3d53efd0322f55be780 Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Wed, 11 Nov 2026 12:22:37 +0100
Subject: [PATCH 20/30] Run fantomas
---
src/Compiler/Service/FSharpWorkspaceQuery.fs | 4 +-
.../Common/CapabilitiesManager.fs | 4 +-
.../Handlers/CodeActionHandler.fs | 232 +++++++++---------
.../Handlers/LanguageFeaturesHandler.fs | 5 +-
4 files changed, 119 insertions(+), 126 deletions(-)
diff --git a/src/Compiler/Service/FSharpWorkspaceQuery.fs b/src/Compiler/Service/FSharpWorkspaceQuery.fs
index d25abb714ad..fb3af19bd67 100644
--- a/src/Compiler/Service/FSharpWorkspaceQuery.fs
+++ b/src/Compiler/Service/FSharpWorkspaceQuery.fs
@@ -60,6 +60,7 @@ type FSharpWorkspaceQuery internal (depGraph: IThreadSafeDependencyGraph<_, _>,
match context with
| Some ctx ->
let matching = candidates |> Seq.tryFind (fun s -> s.Identifier = ctx)
+
match matching with
| Some _ -> matching
| None -> candidates |> Seq.tryHead
@@ -69,8 +70,7 @@ type FSharpWorkspaceQuery internal (depGraph: IThreadSafeDependencyGraph<_, _>,
use _ =
Activity.start "GetProjectSnapshotsForFile" [ Activity.Tags.fileName, file.LocalPath ]
- depGraph.GetProjectsContaining file.LocalPath
- |> Seq.toArray
+ depGraph.GetProjectsContaining file.LocalPath |> Seq.toArray
member this.GetParseAndCheckResultsForFile(file: Uri, ?context: ProjectSnapshot.FSharpProjectIdentifier) =
async {
diff --git a/src/FSharp.Compiler.LanguageServer/Common/CapabilitiesManager.fs b/src/FSharp.Compiler.LanguageServer/Common/CapabilitiesManager.fs
index daac044f200..ba254ebd18b 100644
--- a/src/FSharp.Compiler.LanguageServer/Common/CapabilitiesManager.fs
+++ b/src/FSharp.Compiler.LanguageServer/Common/CapabilitiesManager.fs
@@ -49,8 +49,8 @@ type CapabilitiesManager(config: FSharpLanguageServerConfig, scOverrides: IServe
Full = System.Nullable(SumType(true)),
Range = System.Nullable(SumType(true))
))
- //CompletionProvider = CompletionOptions(TriggerCharacters = [| "."; " " |], ResolveProvider = true, WorkDoneProgress = true),
- //HoverProvider = SumType(HoverOptions(WorkDoneProgress = true))
+ //CompletionProvider = CompletionOptions(TriggerCharacters = [| "."; " " |], ResolveProvider = true, WorkDoneProgress = true),
+ //HoverProvider = SumType(HoverOptions(WorkDoneProgress = true))
)
interface IInitializeManager with
diff --git a/src/FSharp.Compiler.LanguageServer/Handlers/CodeActionHandler.fs b/src/FSharp.Compiler.LanguageServer/Handlers/CodeActionHandler.fs
index 7db7067f164..90beee6f646 100644
--- a/src/FSharp.Compiler.LanguageServer/Handlers/CodeActionHandler.fs
+++ b/src/FSharp.Compiler.LanguageServer/Handlers/CodeActionHandler.fs
@@ -22,13 +22,15 @@ type private LspPosition = Microsoft.VisualStudio.LanguageServer.Protocol.Positi
// Context passed to each code fix function.
type CodeFixContext =
- { SourceText: ISourceText
- ParseResults: FSharpParseFileResults
- CheckResults: FSharpCheckFileResults option
- Diagnostic: FSharpDiagnostic
- DiagnosticRange: range
- FilePath: string
- DocumentUri: Uri }
+ {
+ SourceText: ISourceText
+ ParseResults: FSharpParseFileResults
+ CheckResults: FSharpCheckFileResults option
+ Diagnostic: FSharpDiagnostic
+ DiagnosticRange: range
+ FilePath: string
+ DocumentUri: Uri
+ }
module private LspCodeFixes =
@@ -49,8 +51,7 @@ module private LspCodeFixes =
let makeCodeAction (title: string) (documentUri: Uri) (editRange: range) (newText: string) =
let changes = Dictionary()
- changes.[documentUri.AbsoluteUri] <-
- [| TextEdit(Range = fcsRangeToLspRange editRange, NewText = newText) |]
+ changes.[documentUri.AbsoluteUri] <- [| TextEdit(Range = fcsRangeToLspRange editRange, NewText = newText) |]
[|
CodeAction(Title = title, Kind = CodeActionKind.QuickFix, Edit = WorkspaceEdit(Changes = changes))
@@ -80,8 +81,7 @@ module private LspCodeFixes =
let filePath = ctx.FilePath
let documentUri = ctx.DocumentUri
- let getSourceLineStr line =
- sourceText.GetLineString(line - 1)
+ let getSourceLineStr line = sourceText.GetLineString(line - 1)
let matchingApp path node =
let (|TargetTy|_|) expr =
@@ -101,17 +101,7 @@ module private LspCodeFixes =
)
)
| SynExpr.TypeApp(SynExpr.LongIdent(longDotId = longDotId), lessRange, typeArgs, commaRanges, greaterRange, _, range) ->
- Some(
- SynType.App(
- SynType.LongIdent longDotId,
- Some lessRange,
- typeArgs,
- commaRanges,
- greaterRange,
- false,
- range
- )
- )
+ Some(SynType.App(SynType.LongIdent longDotId, Some lessRange, typeArgs, commaRanges, greaterRange, false, range))
| _ -> None
match node with
@@ -121,7 +111,10 @@ module private LspCodeFixes =
Some(targetTy, argExpr, path)
| _ -> None
- match (diagnosticRange.Start, parseResults.ParseTree) ||> ParsedInput.tryPick matchingApp with
+ match
+ (diagnosticRange.Start, parseResults.ParseTree)
+ ||> ParsedInput.tryPick matchingApp
+ with
| None -> [||]
| Some(targetTy, argExpr, path) ->
let needsParens =
@@ -133,8 +126,7 @@ module private LspCodeFixes =
let targetTyText = sourceText.GetSubTextFromRange(targetTy.Range)
let textBetween =
- let betweenRange =
- Range.mkRange filePath targetTy.Range.End argExpr.Range.Start
+ let betweenRange = Range.mkRange filePath targetTy.Range.End argExpr.Range.Start
if needsParens && betweenRange.StartLine = betweenRange.EndLine then
""
@@ -149,8 +141,7 @@ module private LspCodeFixes =
else
originalArgText
- let newText =
- $"new %s{targetTyText}%s{textBetween}%s{argExprText}"
+ let newText = $"new %s{targetTyText}%s{textBetween}%s{argExprText}"
makeCodeAction "Add 'new' keyword" documentUri diagnosticRange newText
@@ -204,20 +195,15 @@ module private LspCodeFixes =
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
+ 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 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 " "
@@ -240,7 +226,10 @@ module private LspCodeFixes =
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)
+ 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
@@ -303,8 +292,7 @@ module private LspCodeFixes =
else
let insertCol = letIdx + 3 // after "let"
- let insertPos =
- Position.mkPos ctx.DiagnosticRange.StartLine insertCol
+ let insertPos = Position.mkPos ctx.DiagnosticRange.StartLine insertCol
let insertRange = Range.mkRange ctx.FilePath insertPos insertPos
makeCodeAction "Make declaration 'rec'" ctx.DocumentUri insertRange " rec"
@@ -333,16 +321,11 @@ module private LspCodeFixes =
else
let insertCol = letIdx + 3
- let insertPos =
- Position.mkPos bindingNameRange.StartLine insertCol
+ let insertPos = Position.mkPos bindingNameRange.StartLine insertCol
let insertRange = Range.mkRange ctx.FilePath insertPos insertPos
- makeCodeAction
- $"Make '%s{bindingName}' recursive"
- ctx.DocumentUri
- insertRange
- " rec"
+ makeCodeAction $"Make '%s{bindingName}' recursive" ctx.DocumentUri insertRange " rec"
| None -> [||]
// FS0039: Convert C# lambda (=>) to F# lambda (->)
@@ -385,10 +368,16 @@ module private LspCodeFixes =
let line = ctx.SourceText.GetLineString(lineIdx)
// Look for '=' that should be '<-'
let rec findEquals i =
- if i >= line.Length then None
- elif line.[i] = '=' && (i + 1 >= line.Length || line.[i + 1] <> '=') && (i = 0 || line.[i - 1] <> '<' && line.[i - 1] <> '>' && line.[i - 1] <> '!') then
+ if i >= line.Length then
+ None
+ elif
+ line.[i] = '='
+ && (i + 1 >= line.Length || line.[i + 1] <> '=')
+ && (i = 0 || line.[i - 1] <> '<' && line.[i - 1] <> '>' && line.[i - 1] <> '!')
+ then
Some i
- else findEquals (i + 1)
+ else
+ findEquals (i + 1)
match findEquals ctx.DiagnosticRange.StartColumn with
| None -> [||]
@@ -431,7 +420,8 @@ module private LspCodeFixes =
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 m =
+ Regex.Match(msg, @"Maybe you want one of the following:\s*(.*?)$", RegexOptions.Singleline)
let suggestions =
if m.Success then
@@ -442,10 +432,7 @@ module private LspCodeFixes =
// Try single suggestion: "... Did you mean 'X'?"
let m2 = Regex.Match(msg, @"Did you mean '([^']+)'\?")
- if m2.Success then
- [| m2.Groups.[1].Value |]
- else
- [||]
+ if m2.Success then [| m2.Groups.[1].Value |] else [||]
suggestions
|> Array.map (fun suggestion ->
@@ -489,17 +476,20 @@ module private LspCodeFixes =
// 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
+
+ while endCol < lineText.Length
+ && (System.Char.IsLetterOrDigit(lineText.[endCol])
+ || lineText.[endCol] = '_'
+ || lineText.[endCol] = '\'') do
endCol <- endCol + 1
- if endCol <= startCol then [||]
+
+ if endCol <= startCol then
+ [||]
else
let identName = lineText.Substring(startCol, endCol - startCol)
+
let symbolOpt =
- checkResults.GetSymbolUseAtLocation(
- ctx.DiagnosticRange.StartLine,
- endCol,
- lineText,
- [ identName ])
+ checkResults.GetSymbolUseAtLocation(ctx.DiagnosticRange.StartLine, endCol, lineText, [ identName ])
match symbolOpt with
| Some symbolUse ->
@@ -513,8 +503,7 @@ module private LspCodeFixes =
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
+ let insertRange = Range.mkRange ctx.FilePath declRange.Start declRange.Start
makeCodeAction "Make declaration 'mutable'" ctx.DocumentUri insertRange "mutable "
else
@@ -528,12 +517,14 @@ module private LspCodeFixes =
| 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 ])
+ [ textAtRange ctx.SourceText ctx.DiagnosticRange ]
+ )
match symbolOpt with
| Some symbolUse ->
@@ -574,8 +565,7 @@ module private LspCodeFixes =
match ctx.CheckResults with
| None -> [||]
| Some checkResults ->
- let items =
- checkResults.GetSemanticClassification(Some ctx.DiagnosticRange)
+ let items = checkResults.GetSemanticClassification(Some ctx.DiagnosticRange)
let unionCaseItem =
items
@@ -593,8 +583,7 @@ module private LspCodeFixes =
let renameParamToMatchSignature (ctx: CodeFixContext) =
let msg = ctx.Diagnostic.Message
// Message format: "... The parameter 'x' expected by ... was not found. The implementation has 'y' instead."
- let m =
- Regex.Match(msg, @"parameter '([^']+)'")
+ let m = Regex.Match(msg, @"parameter '([^']+)'")
if not m.Success then
[||]
@@ -653,7 +642,10 @@ module private LspCodeFixes =
[||]
else
let insertText = " with\n" + stub
- let insertRange = Range.mkRange ctx.FilePath ctx.DiagnosticRange.End ctx.DiagnosticRange.End
+
+ let insertRange =
+ Range.mkRange ctx.FilePath ctx.DiagnosticRange.End ctx.DiagnosticRange.End
+
makeCodeAction "Implement interface" ctx.DocumentUri insertRange insertText
| _ -> [||]
| None -> [||]
@@ -666,43 +658,44 @@ module private LspCodeFixes =
// Multiple fixes can share the same diagnostic code.
let private allCodeFixes: (string * (CodeFixContext -> CodeAction array)) list =
[
- "FS0760", addNewKeywordFix
- "FS0043", convertToSingleEquals
- "FS0043", convertToNotEquals
- "FS3198", changeToUpcast
- "FS3366", fixIndexerAccess
- "FS0597", wrapExpressionInParentheses
- "FS0003", changePrefixNegationToInfixSubtraction
- "FS0039", convertCSharpUsingToFSharpOpen
- "FS0201", convertCSharpUsingToFSharpOpen
- "FS0673", addInstanceMemberParameter
- "FS0010", addMissingEqualsToTypeDefinition
- "FS0010", addMissingFunKeyword
- "FS0010", changeEqualsInFieldTypeToColon
- "FS0576", addMissingRecToMutuallyRecFunctions
- "FS0039", makeOuterBindingRecursive
- "FS0039", convertCSharpLambdaToFSharpLambda
- "FS0747", removeReturnOrYield
- "FS0748", removeReturnOrYield
- "FS0020", useMutationWhenValueIsMutable
- "FS3373", useTripleQuotedInterpolation
- "FS0001", changeRefCellDerefToNotExpression
- "FS0039", replaceWithSuggestion
- "FS0495", replaceWithSuggestion
- "FS1129", replaceWithSuggestion
- "FS1182", prefixUnusedValue
- "FS1182", discardUnusedValue
- "FS0027", makeDeclarationMutable
- "FS0072", addTypeAnnotationToObjectOfIndeterminateType
- "FS3245", addTypeAnnotationToObjectOfIndeterminateType
- "FS0039", convertToAnonymousRecord
- "FS3578", convertToAnonymousRecord
- "FS3873", addMissingSeq
- "FS0740", addMissingSeq
- "FS0725", removeSuperfluousCaptureForUnionCaseWithNoData
- "FS3548", removeSuperfluousCaptureForUnionCaseWithNoData
- "FS3218", renameParamToMatchSignature
- "FS0366", implementInterface ]
+ "FS0760", addNewKeywordFix
+ "FS0043", convertToSingleEquals
+ "FS0043", convertToNotEquals
+ "FS3198", changeToUpcast
+ "FS3366", fixIndexerAccess
+ "FS0597", wrapExpressionInParentheses
+ "FS0003", changePrefixNegationToInfixSubtraction
+ "FS0039", convertCSharpUsingToFSharpOpen
+ "FS0201", convertCSharpUsingToFSharpOpen
+ "FS0673", addInstanceMemberParameter
+ "FS0010", addMissingEqualsToTypeDefinition
+ "FS0010", addMissingFunKeyword
+ "FS0010", changeEqualsInFieldTypeToColon
+ "FS0576", addMissingRecToMutuallyRecFunctions
+ "FS0039", makeOuterBindingRecursive
+ "FS0039", convertCSharpLambdaToFSharpLambda
+ "FS0747", removeReturnOrYield
+ "FS0748", removeReturnOrYield
+ "FS0020", useMutationWhenValueIsMutable
+ "FS3373", useTripleQuotedInterpolation
+ "FS0001", changeRefCellDerefToNotExpression
+ "FS0039", replaceWithSuggestion
+ "FS0495", replaceWithSuggestion
+ "FS1129", replaceWithSuggestion
+ "FS1182", prefixUnusedValue
+ "FS1182", discardUnusedValue
+ "FS0027", makeDeclarationMutable
+ "FS0072", addTypeAnnotationToObjectOfIndeterminateType
+ "FS3245", addTypeAnnotationToObjectOfIndeterminateType
+ "FS0039", convertToAnonymousRecord
+ "FS3578", convertToAnonymousRecord
+ "FS3873", addMissingSeq
+ "FS0740", addMissingSeq
+ "FS0725", removeSuperfluousCaptureForUnionCaseWithNoData
+ "FS3548", removeSuperfluousCaptureForUnionCaseWithNoData
+ "FS3218", renameParamToMatchSignature
+ "FS0366", implementInterface
+ ]
let private codeFixMap: IDictionary CodeAction array) array> =
allCodeFixes
@@ -727,13 +720,15 @@ module private LspCodeFixes =
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 }
+ {
+ SourceText = sourceText
+ ParseResults = parseResults
+ CheckResults = checkResults
+ Diagnostic = d
+ DiagnosticRange = d.Range
+ FilePath = filePath
+ DocumentUri = documentUri
+ }
fixes
|> Array.collect (fun fix ->
@@ -749,9 +744,7 @@ type CodeActionHandler() =
interface IRequestHandler with
[]
- member _.HandleRequestAsync
- (request: CodeActionParams, context: FSharpRequestContext, cancellationToken: CancellationToken)
- =
+ member _.HandleRequestAsync(request: CodeActionParams, context: FSharpRequestContext, cancellationToken: CancellationToken) =
cancellableTask {
let telemetry = context.LspServices.GetRequiredService()
@@ -765,7 +758,10 @@ type CodeActionHandler() =
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! parseResultsOpt, checkResultsOpt =
+ context.Workspace.Query.GetParseAndCheckResultsForFile(uri, ?context = projectContext)
+
let! sourceTextOpt = context.Workspace.Query.GetSource uri
match parseResultsOpt, sourceTextOpt with
diff --git a/src/FSharp.Compiler.LanguageServer/Handlers/LanguageFeaturesHandler.fs b/src/FSharp.Compiler.LanguageServer/Handlers/LanguageFeaturesHandler.fs
index 1b3d7c6e6dc..f5e0bd9cee2 100644
--- a/src/FSharp.Compiler.LanguageServer/Handlers/LanguageFeaturesHandler.fs
+++ b/src/FSharp.Compiler.LanguageServer/Handlers/LanguageFeaturesHandler.fs
@@ -29,10 +29,7 @@ type LanguageFeaturesHandler() =
let projectContext = tryGetProjectContext request.TextDocument
use _scope =
- telemetry.ReportEventWithDuration(
- TelemetryEvents.GetDiagnostics,
- [| "uri_hash", hash uri :> obj |]
- )
+ telemetry.ReportEventWithDuration(TelemetryEvents.GetDiagnostics, [| "uri_hash", hash uri :> obj |])
let! fsharpDiagnosticReport = context.Workspace.Query.GetDiagnosticsForFile(uri, ?context = projectContext)
From 468b7f01fa50c8cd244a67d869cee164364ce53f Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Wed, 11 Nov 2026 15:22:30 +0000
Subject: [PATCH 21/30] Gate VS LSP startup on provider selection and preserve
unset settings
Disable the provider before returning no connection for legacy selections so VS does not repeatedly activate it. Keep exposure telemetry but avoid per-view LSP context wiring for the legacy provider.
Retain disabled diagnostics and code-action fixtures as ignored MSTest classes, discover vstest via vswhere, and build/run the LSP integration project through the shared Apex path with its runsettings.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0c106877-ba3b-418c-9716-697da9c76300
---
docs/lsp.md | 12 ++
docs/release-notes/.VisualStudio/18.vNext.md | 1 +
eng/Build.ps1 | 14 +-
.../FSharpExtensionSettings.cs | 6 +-
.../FSharpLanguageServerProvider.cs | 35 +++--
.../ErrorListDiagnosticsTests.cs | 2 +
.../LightBulbCodeActionTests.cs | 2 +
.../RunTests.cmd | 2 +-
.../LspProjectContextListener.fs | 130 +++++++++---------
9 files changed, 113 insertions(+), 91 deletions(-)
diff --git a/docs/lsp.md b/docs/lsp.md
index 6f19d8a27a7..6c0765b8381 100644
--- a/docs/lsp.md
+++ b/docs/lsp.md
@@ -128,6 +128,18 @@ 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`.
+
#### Transport and serialization
The standalone server uses header-delimited JSON-RPC on standard input/output; logs
diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md
index cdbbf503815..da3853e5ea6 100644
--- a/docs/release-notes/.VisualStudio/18.vNext.md
+++ b/docs/release-notes/.VisualStudio/18.vNext.md
@@ -22,6 +22,7 @@
* 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.
diff --git a/eng/Build.ps1 b/eng/Build.ps1
index 4b7aa3a9dae..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) =====
@@ -962,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/src/FSharp.VisualStudio.Extension/FSharpExtensionSettings.cs b/src/FSharp.VisualStudio.Extension/FSharpExtensionSettings.cs
index 89a2e9cc136..4156b936ecb 100644
--- a/src/FSharp.VisualStudio.Extension/FSharpExtensionSettings.cs
+++ b/src/FSharp.VisualStudio.Extension/FSharpExtensionSettings.cs
@@ -36,9 +36,9 @@ internal static class FSharpExtensionSettings
Description = "%FSharpSettings.SemanticClassificationProvider.Description%",
};
- // Each setting carries the concrete value to persist (and treat as effective)
- // the first time it is read as UNSET. Semantic classification defaults to
- // OLD so the in-proc classifier stays in charge until the user opts in
+ // Resolve UNSET at read time only; do not persist the fallback.
+ // Semantic classification defaults to OLD so the in-proc classifier
+ // stays in charge until the user opts in
// (or is flipped into the LSP path by a controlled rollout).
public static (Setting Setting, string DefaultIfUnset)[] AllSettingsWithDefaults { get; } =
[
diff --git a/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs b/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs
index d1c5af7db04..1b00ac39a57 100644
--- a/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs
+++ b/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs
@@ -218,34 +218,33 @@ internal class FSharpLanguageServerProvider : LanguageServerProvider
///
public override async Task CreateServerConnectionAsync(CancellationToken cancellationToken)
{
- _telemetryReporter = new VsTelemetryReporter(configurationChanged: value => _telemetryConfiguration = value);
- if (_telemetryConfiguration is { } configuration)
- {
- _telemetryReporter.Configure(configuration);
- }
- var activitySourceName = "fsc";
-
- FSharp.Compiler.LanguageServer.Activity.listenToSome();
-
#pragma warning disable VSEXTPREVIEW_SETTINGS // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
- // Write default settings unless they're overridden. Otherwise users can't even find which settings exist.
-
var settingsReadResult = await this.Extensibility.Settings().ReadEffectiveValuesAsync(FSharpExtensionSettings.AllStringSettings, cancellationToken);
var settingValues = FSharpExtensionSettings.AllSettingsWithDefaults.Select(
x => (x.Setting, x.DefaultIfUnset, value: settingsReadResult.ValueOrDefault(x.Setting, defaultValue: FSharpExtensionSettings.UNSET)));
- foreach (var (setting, defaultIfUnset, value) in settingValues.Where(x => x.value == FSharpExtensionSettings.UNSET))
- {
- await this.Extensibility.Settings().WriteAsync(batch =>
- batch.WriteSetting(setting, defaultIfUnset), "write default settings", cancellationToken);
- }
-
var effectiveSettingValues = settingValues.ToDictionary(
x => x.Setting,
x => x.value == FSharpExtensionSettings.UNSET ? x.DefaultIfUnset : x.value);
+ if (effectiveSettingValues[FSharpExtensionSettings.SemanticClassificationProvider] != FSharpExtensionSettings.LSP)
+ {
+ // Returning null alone leaves the provider eligible for subsequent activation attempts.
+ this.Enabled = false;
+ return null;
+ }
+
+ _telemetryReporter = new VsTelemetryReporter(configurationChanged: value => _telemetryConfiguration = value);
+ if (_telemetryConfiguration is { } configuration)
+ {
+ _telemetryReporter.Configure(configuration);
+ }
+ var activitySourceName = "fsc";
+
+ FSharp.Compiler.LanguageServer.Activity.listenToSome();
+
// Only semantic tokens are wired up. Diagnostics and code actions stay
// false here so the capabilities override (which also hardcodes them
// off) and the feature config agree.
@@ -253,7 +252,7 @@ await this.Extensibility.Settings().WriteAsync(batch =>
new FSharpLanguageServerFeatures(
diagnostics: false,
codeActions: false,
- semanticTokens: effectiveSettingValues[FSharpExtensionSettings.SemanticClassificationProvider] == FSharpExtensionSettings.LSP
+ semanticTokens: true
));
_semanticTokensEnabled = serverConfig.EnabledFeatures.SemanticTokens;
diff --git a/tests/FSharp.Compiler.LanguageServer.IntegrationTests/ErrorListDiagnosticsTests.cs b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/ErrorListDiagnosticsTests.cs
index acca6019502..6cbf44c7792 100644
--- a/tests/FSharp.Compiler.LanguageServer.IntegrationTests/ErrorListDiagnosticsTests.cs
+++ b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/ErrorListDiagnosticsTests.cs
@@ -17,6 +17,8 @@ namespace FSharp.Compiler.LanguageServer.IntegrationTests;
/// 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
{
///
diff --git a/tests/FSharp.Compiler.LanguageServer.IntegrationTests/LightBulbCodeActionTests.cs b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/LightBulbCodeActionTests.cs
index cd63f384743..159e4b4fb59 100644
--- a/tests/FSharp.Compiler.LanguageServer.IntegrationTests/LightBulbCodeActionTests.cs
+++ b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/LightBulbCodeActionTests.cs
@@ -17,6 +17,8 @@
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 = """
diff --git a/tests/FSharp.Compiler.LanguageServer.IntegrationTests/RunTests.cmd b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/RunTests.cmd
index 4fc6dabf263..d6222316d13 100644
--- a/tests/FSharp.Compiler.LanguageServer.IntegrationTests/RunTests.cmd
+++ b/tests/FSharp.Compiler.LanguageServer.IntegrationTests/RunTests.cmd
@@ -9,7 +9,7 @@ set "CONFIG=Release"
:: Resolve vstest.console.dll path
if not defined VSTEST_CONSOLE_PATH (
- set "VSTEST_CONSOLE_PATH=C:\Program Files\Microsoft Visual Studio\18\IntPreview\Common7\IDE\CommonExtensions\Microsoft\TestWindow\VsTest\vstest.console.dll"
+ 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%" (
diff --git a/vsintegration/src/FSharp.Editor/LanguageService/LspProjectContextListener.fs b/vsintegration/src/FSharp.Editor/LanguageService/LspProjectContextListener.fs
index 47cee123954..8978911f973 100644
--- a/vsintegration/src/FSharp.Editor/LanguageService/LspProjectContextListener.fs
+++ b/vsintegration/src/FSharp.Editor/LanguageService/LspProjectContextListener.fs
@@ -53,73 +53,73 @@ type internal LspProjectContextListener [] (broker: ILangu
interface IWpfTextViewCreationListener with
member _.TextViewCreated(view) =
- let cancellation = new CancellationTokenSource()
- let requests = ThreadHelper.JoinableTaskContext.CreateCollection()
- let factory = ThreadHelper.JoinableTaskContext.CreateFactory requests
-
let struct (settingValue, provider) =
HighlightingTelemetry.readProvider ServiceProvider.GlobalProvider
telemetry.RegisterView(settingValue, provider, view.Closed)
- let focused =
- EventHandler(fun _ _ -> telemetry.Configure(retry = (provider = "lsp")))
-
- view.GotAggregateFocus.AddHandler focused
-
- let updateContext () =
- factory
- .RunAsync(fun () ->
- task {
- let document = view.TextSnapshot.GetOpenDocumentInCurrentContextWithChanges()
-
- if not (isNull document) && not (isNull document.Project.OutputFilePath) then
- // The Roslyn navbar does not populate the LSP client's navbar context.
- let parameters =
- {|
- Uri = Uri(document.FilePath)
- ProjectFileName = document.Project.FilePath
- OutputFileName = document.Project.OutputFilePath
- |}
-
- let request =
- DocumentRequest<_, bool>(
- TextBuffer = view.TextBuffer,
- Method = "textDocument/fsharp/setActiveProjectContext",
- LanguageServerName = "FSharp Analyzer LSP server",
- ParameterFactory = (fun _ -> parameters)
- )
-
- let! accepted = broker.RequestAsync(request, cancellation.Token)
-
- if not accepted then
- System.Diagnostics.Trace.TraceWarning("F# LSP active project context was not accepted.")
- }
- :> Task)
- .FileAndForget("vs/fsharp/lsp/projectContext")
-
- let handler =
- EventHandler(fun _ args ->
- if args.SourceTextContainer = view.TextBuffer.AsTextContainer() then
- telemetry.Configure false
- updateContext ())
-
- workspace.DocumentActiveContextChanged.AddHandler handler
-
- view.Closed.Add(fun _ ->
- workspace.DocumentActiveContextChanged.RemoveHandler handler
- cancellation.Cancel()
- view.GotAggregateFocus.RemoveHandler focused
-
- ThreadHelper.JoinableTaskFactory
- .RunAsync(fun () ->
- task {
- try
- do! requests.JoinTillEmptyAsync()
- finally
- cancellation.Dispose()
- }
- :> Task)
- .FileAndForget("vs/fsharp/lsp/projectContext/close"))
-
- updateContext ()
+ if provider = "lsp" then
+ let cancellation = new CancellationTokenSource()
+ let requests = ThreadHelper.JoinableTaskContext.CreateCollection()
+ let factory = ThreadHelper.JoinableTaskContext.CreateFactory requests
+
+ let focused = EventHandler(fun _ _ -> telemetry.Configure(retry = true))
+
+ view.GotAggregateFocus.AddHandler focused
+
+ let updateContext () =
+ factory
+ .RunAsync(fun () ->
+ task {
+ let document = view.TextSnapshot.GetOpenDocumentInCurrentContextWithChanges()
+
+ if not (isNull document) && not (isNull document.Project.OutputFilePath) then
+ // The Roslyn navbar does not populate the LSP client's navbar context.
+ let parameters =
+ {|
+ Uri = Uri(document.FilePath)
+ ProjectFileName = document.Project.FilePath
+ OutputFileName = document.Project.OutputFilePath
+ |}
+
+ let request =
+ DocumentRequest<_, bool>(
+ TextBuffer = view.TextBuffer,
+ Method = "textDocument/fsharp/setActiveProjectContext",
+ LanguageServerName = "FSharp Analyzer LSP server",
+ ParameterFactory = (fun _ -> parameters)
+ )
+
+ let! accepted = broker.RequestAsync(request, cancellation.Token)
+
+ if not accepted then
+ System.Diagnostics.Trace.TraceWarning("F# LSP active project context was not accepted.")
+ }
+ :> Task)
+ .FileAndForget("vs/fsharp/lsp/projectContext")
+
+ let handler =
+ EventHandler(fun _ args ->
+ if args.SourceTextContainer = view.TextBuffer.AsTextContainer() then
+ telemetry.Configure false
+ updateContext ())
+
+ workspace.DocumentActiveContextChanged.AddHandler handler
+
+ view.Closed.Add(fun _ ->
+ workspace.DocumentActiveContextChanged.RemoveHandler handler
+ cancellation.Cancel()
+ view.GotAggregateFocus.RemoveHandler focused
+
+ ThreadHelper.JoinableTaskFactory
+ .RunAsync(fun () ->
+ task {
+ try
+ do! requests.JoinTillEmptyAsync()
+ finally
+ cancellation.Dispose()
+ }
+ :> Task)
+ .FileAndForget("vs/fsharp/lsp/projectContext/close"))
+
+ updateContext ()
From 63950d76e7501b57a3737cc17af64ed820b6b780 Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Wed, 11 Nov 2026 15:23:03 +0000
Subject: [PATCH 22/30] Bound semantic token processing by line and honor
cancellation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0c106877-ba3b-418c-9716-697da9c76300
---
docs/lsp.md | 5 +
.../Handlers/SemanticTokensHandler.fs | 2 +-
.../SemanticTokens.fs | 108 +++++++++++++++---
.../SemanticTokensProtocolTests.fs | 45 ++++++++
4 files changed, 140 insertions(+), 20 deletions(-)
diff --git a/docs/lsp.md b/docs/lsp.md
index 6f19d8a27a7..df9c21f3f2c 100644
--- a/docs/lsp.md
+++ b/docs/lsp.md
@@ -156,6 +156,11 @@ 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.
diff --git a/src/FSharp.Compiler.LanguageServer/Handlers/SemanticTokensHandler.fs b/src/FSharp.Compiler.LanguageServer/Handlers/SemanticTokensHandler.fs
index a46da0f66b6..fb9a1fa353c 100644
--- a/src/FSharp.Compiler.LanguageServer/Handlers/SemanticTokensHandler.fs
+++ b/src/FSharp.Compiler.LanguageServer/Handlers/SemanticTokensHandler.fs
@@ -85,7 +85,7 @@ type SemanticTokensHandler() =
let parsingOptions = this.GetParsingOptions(uri, context, projectContext)
let response =
- LspSemanticTokens.responseFor source uri.LocalPath semanticItems range parsingOptions
+ LspSemanticTokens.responseFor source uri.LocalPath semanticItems range parsingOptions cancellationToken
tokenCount <- response.Data.Length / 5
outcome <- "completed"
diff --git a/src/FSharp.Compiler.LanguageServer/SemanticTokens.fs b/src/FSharp.Compiler.LanguageServer/SemanticTokens.fs
index 12e4d702e88..cf80a5c0026 100644
--- a/src/FSharp.Compiler.LanguageServer/SemanticTokens.fs
+++ b/src/FSharp.Compiler.LanguageServer/SemanticTokens.fs
@@ -1,5 +1,8 @@
namespace FSharp.Compiler.LanguageServer
+open System.Collections.Generic
+open System.Threading
+
open FSharp.Compiler
open FSharp.Compiler.CodeAnalysis
open FSharp.Compiler.EditorServices
@@ -232,13 +235,27 @@ module LspSemanticTokens =
| 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 =
+ 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
- for line in startLine..endLine do
+ 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 =
@@ -266,16 +283,36 @@ module LspSemanticTokens =
}
|]
- let fromSemanticClassifications (source: ISourceText) (items: SemanticClassificationItem array) =
+ 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 ->
+ | Some tokenType when inRange ->
let fixedRange = fixupRange source tokenType item.Range
- splitRange source fixedRange tokenType [] TokenSource.Semantic
- | None -> [||])
-
- let fromTokenizer (parsingOptions: FSharpParsingOptions option) (fileName: string) (source: ISourceText) =
+ 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
@@ -285,8 +322,14 @@ module LspSemanticTokens =
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 .. source.GetLineCount() - 1 do
+ for line in 0..lastLine do
+ cancellationToken.ThrowIfCancellationRequested()
let lineText = source.GetLineString line
// Should we cache the results?
let lineTokenizer = tokenizer.CreateLineTokenizer lineText
@@ -297,7 +340,7 @@ module LspSemanticTokens =
state <- nextState
match token with
- | Some tokenInfo ->
+ | Some tokenInfo when line >= firstLine ->
match mapSyntacticToken tokenInfo with
| Some tokenType ->
let length = tokenInfo.RightColumn - tokenInfo.LeftColumn + 1
@@ -312,6 +355,7 @@ module LspSemanticTokens =
Source = TokenSource.Syntactic
}
| None -> ()
+ | Some _ -> ()
| None -> keepScanning <- false
|]
@@ -340,29 +384,50 @@ module LspSemanticTokens =
}
]
- let resolveOverlaps (tokens: NormalizedToken array) =
+ 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 (accepted |> Seq.exists (fun existing -> overlaps existing token)) then
- accepted.Add token
+ if not (lineTokens.Exists(fun existing -> overlaps existing token)) then
+ accept token
| TokenSource.Syntactic ->
let fragments =
- ([ token ], accepted)
+ ([ token ], lineTokens)
||> Seq.fold (fun fragments existing -> fragments |> List.collect (subtractOverlap existing))
for fragment in fragments do
if
fragment.Length > 0
- && not (accepted |> Seq.exists (fun existing -> overlaps existing fragment))
+ && not (lineTokens.Exists(fun existing -> overlaps existing fragment))
then
- accepted.Add fragment
+ accept fragment
accepted.ToArray()
@@ -422,10 +487,15 @@ module LspSemanticTokens =
let emptyResponse () = SemanticTokens(Data = [||])
- let responseFor source fileName semanticItems range parsingOptions =
+ let responseFor source fileName semanticItems range parsingOptions (cancellationToken: CancellationToken) =
+ cancellationToken.ThrowIfCancellationRequested()
+
let tokens =
- Array.append (fromSemanticClassifications source semanticItems) (fromTokenizer parsingOptions fileName source)
- |> resolveOverlaps
+ 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/tests/FSharp.Compiler.LanguageServer.Tests/SemanticTokensProtocolTests.fs b/tests/FSharp.Compiler.LanguageServer.Tests/SemanticTokensProtocolTests.fs
index c3b097e9534..eabecadf865 100644
--- a/tests/FSharp.Compiler.LanguageServer.Tests/SemanticTokensProtocolTests.fs
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/SemanticTokensProtocolTests.fs
@@ -1,6 +1,7 @@
module LanguageServer.SemanticTokensProtocolTests
open System
+open System.Threading
open Xunit
open Microsoft.VisualStudio.LanguageServer.Protocol
@@ -13,6 +14,50 @@ 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)
+ }
+
[]
[]
[]
From f67793d7cfb9caeb0aef0416abb43a713f0956d7 Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Wed, 11 Nov 2026 15:28:11 +0000
Subject: [PATCH 23/30] Correct experimental LSP code action edits
Insert fun before lambda arguments, validate writable assignment targets, preserve implemented interface members and document newlines, and rename all parameter uses to match signatures. Propagate cancellation and report named code-fix failures. Disable code actions by default and opt protocol tests in explicitly.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0c106877-ba3b-418c-9716-697da9c76300
---
docs/lsp.md | 6 +
.../FSharpLanguageServerConfig.fs | 2 +-
.../Handlers/CodeActionHandler.fs | 453 ++++++++++++------
.../CodeActionProtocolTests.fs | 131 ++++-
4 files changed, 415 insertions(+), 177 deletions(-)
diff --git a/docs/lsp.md b/docs/lsp.md
index 6f19d8a27a7..929de747de9 100644
--- a/docs/lsp.md
+++ b/docs/lsp.md
@@ -395,6 +395,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/src/FSharp.Compiler.LanguageServer/FSharpLanguageServerConfig.fs b/src/FSharp.Compiler.LanguageServer/FSharpLanguageServerConfig.fs
index 4e21a7fde96..45891f838fc 100644
--- a/src/FSharp.Compiler.LanguageServer/FSharpLanguageServerConfig.fs
+++ b/src/FSharp.Compiler.LanguageServer/FSharpLanguageServerConfig.fs
@@ -10,7 +10,7 @@ type FSharpLanguageServerFeatures =
static member Default =
{
Diagnostics = true
- CodeActions = true
+ CodeActions = false
SemanticTokens = true
}
diff --git a/src/FSharp.Compiler.LanguageServer/Handlers/CodeActionHandler.fs b/src/FSharp.Compiler.LanguageServer/Handlers/CodeActionHandler.fs
index 90beee6f646..8664cb11d8f 100644
--- a/src/FSharp.Compiler.LanguageServer/Handlers/CodeActionHandler.fs
+++ b/src/FSharp.Compiler.LanguageServer/Handlers/CodeActionHandler.fs
@@ -12,7 +12,10 @@ 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"
@@ -70,6 +73,22 @@ module private LspCodeFixes =
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
// ---------------------------------------------------------------
@@ -262,10 +281,47 @@ module private LspCodeFixes =
if text <> "->" then
[||]
else
- let insertRange =
- Range.mkRange ctx.FilePath ctx.DiagnosticRange.Start ctx.DiagnosticRange.Start
+ 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
- makeCodeAction "Add 'fun' keyword" ctx.DocumentUri insertRange "fun "
+ 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) =
@@ -362,33 +418,34 @@ module private LspCodeFixes =
let useMutationWhenValueIsMutable (ctx: CodeFixContext) =
match ctx.CheckResults with
| None -> [||]
- | Some _checkResults ->
- // Find the identifier before the '=' on the diagnostic line
- let lineIdx = ctx.DiagnosticRange.StartLine - 1
- let line = ctx.SourceText.GetLineString(lineIdx)
- // Look for '=' that should be '<-'
- let rec findEquals i =
- if i >= line.Length then
- None
- elif
- line.[i] = '='
- && (i + 1 >= line.Length || line.[i + 1] <> '=')
- && (i = 0 || line.[i - 1] <> '<' && line.[i - 1] <> '>' && line.[i - 1] <> '!')
- then
- Some i
- else
- findEquals (i + 1)
+ | 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
- match findEquals ctx.DiagnosticRange.StartColumn with
- | None -> [||]
- | Some eqCol ->
let eqRange =
- Range.mkRange
- ctx.FilePath
- (Position.mkPos ctx.DiagnosticRange.StartLine eqCol)
- (Position.mkPos ctx.DiagnosticRange.StartLine (eqCol + 1))
+ Range.mkRange ctx.FilePath equalsPosition (Position.mkPos equalsPosition.Line (eqCol + 1))
- makeCodeAction "Use '<-' for mutation" ctx.DocumentUri eqRange "<-"
+ 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) =
@@ -581,123 +638,191 @@ module private LspCodeFixes =
// FS3218: Rename parameter to match signature file
let renameParamToMatchSignature (ctx: CodeFixContext) =
- let msg = ctx.Diagnostic.Message
- // Message format: "... The parameter 'x' expected by ... was not found. The implementation has 'y' instead."
- let m = Regex.Match(msg, @"parameter '([^']+)'")
+ 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
- if not m.Success then
- [||]
- else
- let expectedName = m.Groups.[1].Value
- makeCodeAction $"Rename to '%s{expectedName}'" ctx.DocumentUri ctx.DiagnosticRange expectedName
+ match suggestion, ctx.CheckResults with
+ | ValueSome name, Some checkResults ->
+ match symbolAtPosition ctx checkResults ctx.DiagnosticRange.Start with
+ | Some symbolUse ->
+ let replacement = FSharpKeywords.NormalizeIdentifierBackticks name
- // FS0366: Implement interface members
- let implementInterface (ctx: CodeFixContext) =
- match ctx.CheckResults with
- | None -> [||]
- | Some checkResults ->
- let interfaceDataOpt =
- FSharp.Compiler.EditorServices.InterfaceStubGenerator.TryFindInterfaceDeclaration
- ctx.DiagnosticRange.Start
- ctx.ParseResults.ParseTree
+ let edits =
+ checkResults.GetUsesOfSymbolInFile(symbolUse.Symbol)
+ |> Array.map (fun useOfParameter -> useOfParameter.Range, replacement)
- match interfaceDataOpt with
+ if Array.isEmpty edits then
+ [||]
+ else
+ makeMultiEditCodeAction $"Replace with '{name}'" ctx.DocumentUri edits
| None -> [||]
- | Some interfaceData ->
- let lineText = ctx.SourceText.GetLineString(ctx.DiagnosticRange.StartLine - 1)
-
- let symbolUseOpt =
- checkResults.GetSymbolUseAtLocation(
- ctx.DiagnosticRange.StartLine,
- ctx.DiagnosticRange.EndColumn,
- lineText,
- [ textAtRange ctx.SourceText ctx.DiagnosticRange ]
- )
+ | _ -> [||]
- match symbolUseOpt with
- | Some symbolUse ->
- match symbolUse.Symbol with
- | :? FSharpEntity as entity when
- FSharp.Compiler.EditorServices.InterfaceStubGenerator.IsInterface entity
- && not (FSharp.Compiler.EditorServices.InterfaceStubGenerator.HasNoInterfaceMember entity)
- ->
- let startColumn = ctx.DiagnosticRange.StartColumn + 4
- let typeInstances = interfaceData.TypeParameters
- let methodBody = "raise (System.NotImplementedException())"
- let displayContext = symbolUse.DisplayContext
-
- let stub =
- FSharp.Compiler.EditorServices.InterfaceStubGenerator.FormatInterface
- startColumn
- 4
- typeInstances
- "this"
- methodBody
- displayContext
- Set.empty
- entity
- true
-
- if System.String.IsNullOrWhiteSpace(stub) then
- [||]
- else
- let insertText = " with\n" + stub
-
- let insertRange =
- Range.mkRange ctx.FilePath ctx.DiagnosticRange.End ctx.DiagnosticRange.End
-
- makeCodeAction "Implement interface" ctx.DocumentUri insertRange insertText
- | _ -> [||]
- | 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
// ---------------------------------------------------------------
- // All registered code fixes as (diagnosticCode, fixFunction) pairs.
- // Multiple fixes can share the same diagnostic code.
- let private allCodeFixes: (string * (CodeFixContext -> CodeAction array)) list =
+ let private allCodeFixes =
[
- "FS0760", addNewKeywordFix
- "FS0043", convertToSingleEquals
- "FS0043", convertToNotEquals
- "FS3198", changeToUpcast
- "FS3366", fixIndexerAccess
- "FS0597", wrapExpressionInParentheses
- "FS0003", changePrefixNegationToInfixSubtraction
- "FS0039", convertCSharpUsingToFSharpOpen
- "FS0201", convertCSharpUsingToFSharpOpen
- "FS0673", addInstanceMemberParameter
- "FS0010", addMissingEqualsToTypeDefinition
- "FS0010", addMissingFunKeyword
- "FS0010", changeEqualsInFieldTypeToColon
- "FS0576", addMissingRecToMutuallyRecFunctions
- "FS0039", makeOuterBindingRecursive
- "FS0039", convertCSharpLambdaToFSharpLambda
- "FS0747", removeReturnOrYield
- "FS0748", removeReturnOrYield
- "FS0020", useMutationWhenValueIsMutable
- "FS3373", useTripleQuotedInterpolation
- "FS0001", changeRefCellDerefToNotExpression
- "FS0039", replaceWithSuggestion
- "FS0495", replaceWithSuggestion
- "FS1129", replaceWithSuggestion
- "FS1182", prefixUnusedValue
- "FS1182", discardUnusedValue
- "FS0027", makeDeclarationMutable
- "FS0072", addTypeAnnotationToObjectOfIndeterminateType
- "FS3245", addTypeAnnotationToObjectOfIndeterminateType
- "FS0039", convertToAnonymousRecord
- "FS3578", convertToAnonymousRecord
- "FS3873", addMissingSeq
- "FS0740", addMissingSeq
- "FS0725", removeSuperfluousCaptureForUnionCaseWithNoData
- "FS3548", removeSuperfluousCaptureForUnionCaseWithNoData
- "FS3218", renameParamToMatchSignature
- "FS0366", implementInterface
+ "FS0760", nameof addNewKeywordFix, addNewKeywordFix
+ "FS0043", nameof convertToSingleEquals, convertToSingleEquals
+ "FS0043", nameof convertToNotEquals, convertToNotEquals
+ "FS3198", nameof changeToUpcast, changeToUpcast
+ "FS3366", nameof fixIndexerAccess, fixIndexerAccess
+ "FS0597", nameof wrapExpressionInParentheses, wrapExpressionInParentheses
+ "FS0003", nameof changePrefixNegationToInfixSubtraction, changePrefixNegationToInfixSubtraction
+ "FS0039", nameof convertCSharpUsingToFSharpOpen, convertCSharpUsingToFSharpOpen
+ "FS0201", nameof convertCSharpUsingToFSharpOpen, convertCSharpUsingToFSharpOpen
+ "FS0673", nameof addInstanceMemberParameter, addInstanceMemberParameter
+ "FS0010", nameof addMissingEqualsToTypeDefinition, addMissingEqualsToTypeDefinition
+ "FS0010", nameof addMissingFunKeyword, addMissingFunKeyword
+ "FS0010", nameof changeEqualsInFieldTypeToColon, changeEqualsInFieldTypeToColon
+ "FS0576", nameof addMissingRecToMutuallyRecFunctions, addMissingRecToMutuallyRecFunctions
+ "FS0039", nameof makeOuterBindingRecursive, makeOuterBindingRecursive
+ "FS0039", nameof convertCSharpLambdaToFSharpLambda, convertCSharpLambdaToFSharpLambda
+ "FS0747", nameof removeReturnOrYield, removeReturnOrYield
+ "FS0748", nameof removeReturnOrYield, removeReturnOrYield
+ "FS0020", nameof useMutationWhenValueIsMutable, useMutationWhenValueIsMutable
+ "FS3373", nameof useTripleQuotedInterpolation, useTripleQuotedInterpolation
+ "FS0001", nameof changeRefCellDerefToNotExpression, changeRefCellDerefToNotExpression
+ "FS0039", nameof replaceWithSuggestion, replaceWithSuggestion
+ "FS0495", nameof replaceWithSuggestion, replaceWithSuggestion
+ "FS1129", nameof replaceWithSuggestion, replaceWithSuggestion
+ "FS1182", nameof prefixUnusedValue, prefixUnusedValue
+ "FS1182", nameof discardUnusedValue, discardUnusedValue
+ "FS0027", nameof makeDeclarationMutable, makeDeclarationMutable
+ "FS0072", nameof addTypeAnnotationToObjectOfIndeterminateType, addTypeAnnotationToObjectOfIndeterminateType
+ "FS3245", nameof addTypeAnnotationToObjectOfIndeterminateType, addTypeAnnotationToObjectOfIndeterminateType
+ "FS0039", nameof convertToAnonymousRecord, convertToAnonymousRecord
+ "FS3578", nameof convertToAnonymousRecord, convertToAnonymousRecord
+ "FS3873", nameof addMissingSeq, addMissingSeq
+ "FS0740", nameof addMissingSeq, addMissingSeq
+ "FS0725", nameof removeSuperfluousCaptureForUnionCaseWithNoData, removeSuperfluousCaptureForUnionCaseWithNoData
+ "FS3548", nameof removeSuperfluousCaptureForUnionCaseWithNoData, removeSuperfluousCaptureForUnionCaseWithNoData
+ "FS3218", nameof renameParamToMatchSignature, renameParamToMatchSignature
]
+ |> List.map (fun (code, name, fix) -> code, (name, fun ctx -> async { return fix ctx }))
+ |> fun fixes -> ("FS0366", (nameof implementInterface, implementInterface)) :: fixes
- let private codeFixMap: IDictionary CodeAction array) array> =
+ let private codeFixMap =
allCodeFixes
|> List.groupBy fst
|> List.map (fun (code, fixes) -> code, fixes |> List.map snd |> Array.ofList)
@@ -712,31 +837,43 @@ module private LspCodeFixes =
(requestRange: LspRange)
(filePath: string)
(documentUri: Uri)
- : CodeAction array =
-
- diagnostics
- |> Array.filter (fun d -> rangesOverlap requestRange d.Range)
- |> Array.collect (fun d ->
- 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
- }
-
- fixes
- |> Array.collect (fun fix ->
- try
- fix ctx
- with _ ->
- [||])
- | false, _ -> [||])
+ (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
@@ -768,7 +905,7 @@ type CodeActionHandler() =
| Some parseResults, Some sourceText ->
let filePath = uri.LocalPath
- return
+ return!
LspCodeFixes.getCodeActions
sourceText
parseResults
@@ -777,6 +914,8 @@ type CodeActionHandler() =
request.Range
filePath
uri
+ context.Logger
+ telemetry
| _ -> return [||]
}
|> CancellableTask.start cancellationToken
diff --git a/tests/FSharp.Compiler.LanguageServer.Tests/CodeActionProtocolTests.fs b/tests/FSharp.Compiler.LanguageServer.Tests/CodeActionProtocolTests.fs
index 5bc6b82bf02..f1c11990e6f 100644
--- a/tests/FSharp.Compiler.LanguageServer.Tests/CodeActionProtocolTests.fs
+++ b/tests/FSharp.Compiler.LanguageServer.Tests/CodeActionProtocolTests.fs
@@ -13,6 +13,25 @@ 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) =
@@ -39,7 +58,7 @@ let openDiagnoseThenRequestActions (client: TestRpcClient) (fileUri: Uri) (conte
[]
let ``Clean code produces no code actions`` () =
task {
- let! client = initializeLanguageServer None
+ let! client = initializeCodeActionServer ()
let content = "module M\nlet x = 1"
let fileOnDisk = setupSingleFileProject client content
let! _diags, actions = openDiagnoseThenRequestActions client fileOnDisk content
@@ -54,7 +73,7 @@ let ``Clean code produces no code actions`` () =
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 = initializeLanguageServer None
+ let! client = initializeCodeActionServer ()
let fileOnDisk = setupSingleFileProject client content
let! diags, actions = openDiagnoseThenRequestActions client fileOnDisk content
Assert.True(diags.Length > 0, "Expected diagnostics")
@@ -65,7 +84,7 @@ let ``FS0760 - Add new keyword for IDisposable constructor`` () =
let ``FS0043 - Replace == with =`` () =
task {
let content = "module M\nlet x = 1 == 2"
- let! client = initializeLanguageServer None
+ 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")
@@ -76,7 +95,7 @@ let ``FS0043 - Replace == with =`` () =
let ``FS0043 - Replace != with <>`` () =
task {
let content = "module M\nlet x = 1 != 2"
- let! client = initializeLanguageServer None
+ 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")
@@ -87,7 +106,7 @@ let ``FS0043 - Replace != with <>`` () =
let ``FS0673 - Add instance member parameter`` () =
task {
let content = "module M\ntype T() =\n member Foo() = ()"
- let! client = initializeLanguageServer None
+ 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")
@@ -98,22 +117,97 @@ let ``FS0673 - Add instance member parameter`` () =
// Phase 2: Parse-tree fixes
// ---------------------------------------------------------------
-[]
-let ``FS0010 - Add fun keyword`` () =
+[]
+[]
+[]
+[]
+let ``FS0010 - Add fun keyword before argument`` (argument: string) =
task {
- let content = "module M\nlet f = x -> x + 1"
- let! client = initializeLanguageServer None
+ 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")
- Assert.Contains(actions, fun a -> a.Title = "Add 'fun' keyword")
+ 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 = initializeLanguageServer None
+ 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")
@@ -125,7 +219,7 @@ 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 = initializeLanguageServer None
+ 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")
@@ -140,7 +234,7 @@ let ``FS0027 - Offers make mutable code action`` () =
let ``FS0043 code action edit replaces == with =`` () =
task {
let content = "module M\nlet x = 1 == 2"
- let! client = initializeLanguageServer None
+ 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")
@@ -153,7 +247,7 @@ let ``FS0043 code action edit replaces == with =`` () =
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 = initializeLanguageServer None
+ 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")
@@ -171,14 +265,14 @@ let ``FS0043 produces both == and != fixes for respective operators`` () =
task {
// == fix
let content1 = "module M\nlet x = 1 == 2"
- let! client1 = initializeLanguageServer None
+ 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 = initializeLanguageServer None
+ 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")
@@ -191,14 +285,13 @@ let ``FS0043 produces both == and != fixes for respective operators`` () =
[]
let ``Server with code actions disabled does not advertise code action capabilities`` () =
task {
- let config = { FSharpLanguageServerConfig.Default with EnabledFeatures = { FSharpLanguageServerFeatures.Default with Diagnostics = true; CodeActions = false } }
- let! client = createLanguageServer (FSharpWorkspace()) (Some config)
+ let! client = initializeLanguageServer None
Assert.False(client.Capabilities.CodeActionProvider.HasValue)
}
[]
let ``Server with code actions enabled advertises code action capabilities`` () =
task {
- let! client = initializeLanguageServer None
+ let! client = initializeCodeActionServer ()
Assert.True(client.Capabilities.CodeActionProvider.HasValue)
}
From a1e96f8137848a527244a7fa79f9f20451446615 Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Wed, 11 Nov 2026 15:37:05 +0000
Subject: [PATCH 24/30] Clarify synchronous code-fix registration
Lift synchronous fixes explicitly and keep all registrations in one flat list, including interface implementation.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0c106877-ba3b-418c-9716-697da9c76300
---
.../Handlers/CodeActionHandler.fs | 78 ++++++++++---------
1 file changed, 40 insertions(+), 38 deletions(-)
diff --git a/src/FSharp.Compiler.LanguageServer/Handlers/CodeActionHandler.fs b/src/FSharp.Compiler.LanguageServer/Handlers/CodeActionHandler.fs
index 8664cb11d8f..2191c8e2c30 100644
--- a/src/FSharp.Compiler.LanguageServer/Handlers/CodeActionHandler.fs
+++ b/src/FSharp.Compiler.LanguageServer/Handlers/CodeActionHandler.fs
@@ -780,47 +780,49 @@ module private LspCodeFixes =
// Registration
// ---------------------------------------------------------------
+ let private sync name fix =
+ name, (fun ctx -> async { return fix ctx })
+
let private allCodeFixes =
[
- "FS0760", nameof addNewKeywordFix, addNewKeywordFix
- "FS0043", nameof convertToSingleEquals, convertToSingleEquals
- "FS0043", nameof convertToNotEquals, convertToNotEquals
- "FS3198", nameof changeToUpcast, changeToUpcast
- "FS3366", nameof fixIndexerAccess, fixIndexerAccess
- "FS0597", nameof wrapExpressionInParentheses, wrapExpressionInParentheses
- "FS0003", nameof changePrefixNegationToInfixSubtraction, changePrefixNegationToInfixSubtraction
- "FS0039", nameof convertCSharpUsingToFSharpOpen, convertCSharpUsingToFSharpOpen
- "FS0201", nameof convertCSharpUsingToFSharpOpen, convertCSharpUsingToFSharpOpen
- "FS0673", nameof addInstanceMemberParameter, addInstanceMemberParameter
- "FS0010", nameof addMissingEqualsToTypeDefinition, addMissingEqualsToTypeDefinition
- "FS0010", nameof addMissingFunKeyword, addMissingFunKeyword
- "FS0010", nameof changeEqualsInFieldTypeToColon, changeEqualsInFieldTypeToColon
- "FS0576", nameof addMissingRecToMutuallyRecFunctions, addMissingRecToMutuallyRecFunctions
- "FS0039", nameof makeOuterBindingRecursive, makeOuterBindingRecursive
- "FS0039", nameof convertCSharpLambdaToFSharpLambda, convertCSharpLambdaToFSharpLambda
- "FS0747", nameof removeReturnOrYield, removeReturnOrYield
- "FS0748", nameof removeReturnOrYield, removeReturnOrYield
- "FS0020", nameof useMutationWhenValueIsMutable, useMutationWhenValueIsMutable
- "FS3373", nameof useTripleQuotedInterpolation, useTripleQuotedInterpolation
- "FS0001", nameof changeRefCellDerefToNotExpression, changeRefCellDerefToNotExpression
- "FS0039", nameof replaceWithSuggestion, replaceWithSuggestion
- "FS0495", nameof replaceWithSuggestion, replaceWithSuggestion
- "FS1129", nameof replaceWithSuggestion, replaceWithSuggestion
- "FS1182", nameof prefixUnusedValue, prefixUnusedValue
- "FS1182", nameof discardUnusedValue, discardUnusedValue
- "FS0027", nameof makeDeclarationMutable, makeDeclarationMutable
- "FS0072", nameof addTypeAnnotationToObjectOfIndeterminateType, addTypeAnnotationToObjectOfIndeterminateType
- "FS3245", nameof addTypeAnnotationToObjectOfIndeterminateType, addTypeAnnotationToObjectOfIndeterminateType
- "FS0039", nameof convertToAnonymousRecord, convertToAnonymousRecord
- "FS3578", nameof convertToAnonymousRecord, convertToAnonymousRecord
- "FS3873", nameof addMissingSeq, addMissingSeq
- "FS0740", nameof addMissingSeq, addMissingSeq
- "FS0725", nameof removeSuperfluousCaptureForUnionCaseWithNoData, removeSuperfluousCaptureForUnionCaseWithNoData
- "FS3548", nameof removeSuperfluousCaptureForUnionCaseWithNoData, removeSuperfluousCaptureForUnionCaseWithNoData
- "FS3218", nameof renameParamToMatchSignature, renameParamToMatchSignature
+ "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)
]
- |> List.map (fun (code, name, fix) -> code, (name, fun ctx -> async { return fix ctx }))
- |> fun fixes -> ("FS0366", (nameof implementInterface, implementInterface)) :: fixes
let private codeFixMap =
allCodeFixes
From f465972ec2598af84faebd56380be3cac22fe50d Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Wed, 11 Nov 2026 15:43:20 +0000
Subject: [PATCH 25/30] Update workspace API baseline, run LSP tests in CI, and
track STJ through VMR
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0c106877-ba3b-418c-9716-697da9c76300
---
.../.FSharp.Compiler.Service/11.0.200.md | 1 +
eng/Version.Details.props | 6 ++----
eng/Version.Details.xml | 7 +++----
eng/tests/TestSplit.Tests.fsx | 7 +++++--
eng/tests/TestSplit.fsx | 2 +-
.../FSharp.Compiler.LanguageServer.fsproj | 1 +
...arp.Compiler.Service.SurfaceArea.netstandard20.bsl | 11 ++++++-----
7 files changed, 19 insertions(+), 16 deletions(-)
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 d1f77395e98..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))
diff --git a/eng/Version.Details.props b/eng/Version.Details.props
index 8590809a340..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
@@ -34,8 +35,6 @@ This file should be imported by eng/Versions.props
5.14.0-1.26557.1
5.14.0-1.26557.1
5.14.0-1.26557.1
-
- 10.0.10
@@ -47,6 +46,7 @@ This file should be imported by eng/Versions.props
$(SystemDiagnosticsDiagnosticSourcePackageVersion)
$(SystemReflectionMetadataPackageVersion)
$(SystemSecurityCryptographyXmlPackageVersion)
+ $(SystemTextJsonPackageVersion)
$(MicrosoftBuildPackageVersion)
$(MicrosoftBuildFrameworkPackageVersion)
@@ -68,7 +68,5 @@ This file should be imported by eng/Versions.props
$(MicrosoftCodeAnalysisFeaturesPackageVersion)
$(MicrosoftVisualStudioLanguageServicesPackageVersion)
$(MicrosoftVisualStudioLanguageServicesExternalAccessPackageVersion)
-
- $(SystemTextJsonPackageVersion)
diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml
index 1153341dc63..d203be6270c 100644
--- a/eng/Version.Details.xml
+++ b/eng/Version.Details.xml
@@ -80,10 +80,9 @@
295ff616832ba1f8b192f8178c4188a133affe93
-
- https://github.com/dotnet/runtime
-
-
+
+ https://github.com/dotnet/dotnet
+ 295ff616832ba1f8b192f8178c4188a133affe93
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/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.fsproj b/src/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.fsproj
index c0d35b9e2b3..4d7ded14be6 100644
--- a/src/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.fsproj
+++ b/src/FSharp.Compiler.LanguageServer/FSharp.Compiler.LanguageServer.fsproj
@@ -16,6 +16,7 @@
+
diff --git a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl
index 1e256677027..0cda91bea34 100644
--- a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl
+++ b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl
@@ -2719,12 +2719,13 @@ FSharp.Compiler.CodeAnalysis.Workspace.FSharpWorkspaceQuery+FSharpDiagnosticRepo
FSharp.Compiler.CodeAnalysis.Workspace.FSharpWorkspaceQuery+FSharpDiagnosticReport: FSharp.Compiler.Diagnostics.FSharpDiagnostic[] get_Diagnostics()
FSharp.Compiler.CodeAnalysis.Workspace.FSharpWorkspaceQuery+FSharpDiagnosticReport: System.String ResultId
FSharp.Compiler.CodeAnalysis.Workspace.FSharpWorkspaceQuery+FSharpDiagnosticReport: System.String get_ResultId()
-FSharp.Compiler.CodeAnalysis.Workspace.FSharpWorkspaceQuery+FSharpWorkspaceQuery: Microsoft.FSharp.Control.FSharpAsync`1[FSharp.Compiler.CodeAnalysis.Workspace.FSharpWorkspaceQuery+FSharpDiagnosticReport] GetDiagnosticsForFile(System.Uri)
-FSharp.Compiler.CodeAnalysis.Workspace.FSharpWorkspaceQuery+FSharpWorkspaceQuery: Microsoft.FSharp.Control.FSharpAsync`1[Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.CodeAnalysis.FSharpCheckFileResults]] GetCheckResultsForFile(System.Uri)
-FSharp.Compiler.CodeAnalysis.Workspace.FSharpWorkspaceQuery+FSharpWorkspaceQuery: Microsoft.FSharp.Control.FSharpAsync`1[Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.EditorServices.SemanticClassificationView]] GetSemanticClassification(System.Uri)
-FSharp.Compiler.CodeAnalysis.Workspace.FSharpWorkspaceQuery+FSharpWorkspaceQuery: Microsoft.FSharp.Control.FSharpAsync`1[System.Tuple`2[Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.CodeAnalysis.FSharpParseFileResults],Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.CodeAnalysis.FSharpCheckFileResults]]] GetParseAndCheckResultsForFile(System.Uri)
+FSharp.Compiler.CodeAnalysis.Workspace.FSharpWorkspaceQuery+FSharpWorkspaceQuery: FSharpProjectSnapshot[] GetProjectSnapshotsForFile(System.Uri)
+FSharp.Compiler.CodeAnalysis.Workspace.FSharpWorkspaceQuery+FSharpWorkspaceQuery: Microsoft.FSharp.Control.FSharpAsync`1[FSharp.Compiler.CodeAnalysis.Workspace.FSharpWorkspaceQuery+FSharpDiagnosticReport] GetDiagnosticsForFile(System.Uri, Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.CodeAnalysis.ProjectSnapshot+FSharpProjectIdentifier])
+FSharp.Compiler.CodeAnalysis.Workspace.FSharpWorkspaceQuery+FSharpWorkspaceQuery: Microsoft.FSharp.Control.FSharpAsync`1[Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.CodeAnalysis.FSharpCheckFileResults]] GetCheckResultsForFile(System.Uri, Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.CodeAnalysis.ProjectSnapshot+FSharpProjectIdentifier])
+FSharp.Compiler.CodeAnalysis.Workspace.FSharpWorkspaceQuery+FSharpWorkspaceQuery: Microsoft.FSharp.Control.FSharpAsync`1[Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.EditorServices.SemanticClassificationView]] GetSemanticClassification(System.Uri, Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.CodeAnalysis.ProjectSnapshot+FSharpProjectIdentifier])
+FSharp.Compiler.CodeAnalysis.Workspace.FSharpWorkspaceQuery+FSharpWorkspaceQuery: Microsoft.FSharp.Control.FSharpAsync`1[System.Tuple`2[Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.CodeAnalysis.FSharpParseFileResults],Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.CodeAnalysis.FSharpCheckFileResults]]] GetParseAndCheckResultsForFile(System.Uri, Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.CodeAnalysis.ProjectSnapshot+FSharpProjectIdentifier])
FSharp.Compiler.CodeAnalysis.Workspace.FSharpWorkspaceQuery+FSharpWorkspaceQuery: Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.CodeAnalysis.ProjectSnapshot+FSharpProjectSnapshot] GetProjectSnapshot(FSharpProjectIdentifier)
-FSharp.Compiler.CodeAnalysis.Workspace.FSharpWorkspaceQuery+FSharpWorkspaceQuery: Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.CodeAnalysis.ProjectSnapshot+FSharpProjectSnapshot] GetProjectSnapshotForFile(System.Uri)
+FSharp.Compiler.CodeAnalysis.Workspace.FSharpWorkspaceQuery+FSharpWorkspaceQuery: Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.CodeAnalysis.ProjectSnapshot+FSharpProjectSnapshot] GetProjectSnapshotForFile(System.Uri, Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.CodeAnalysis.ProjectSnapshot+FSharpProjectIdentifier])
FSharp.Compiler.CodeAnalysis.Workspace.FSharpWorkspaceQuery+FSharpWorkspaceQuery: System.Threading.Tasks.Task`1[Microsoft.FSharp.Core.FSharpOption`1[FSharp.Compiler.Text.ISourceTextNew]] GetSource(System.Uri)
FSharp.Compiler.CodeAnalysis.Workspace.FSharpWorkspaceQuery: FSharp.Compiler.CodeAnalysis.Workspace.FSharpWorkspaceQuery+FSharpDiagnosticReport
FSharp.Compiler.CodeAnalysis.Workspace.FSharpWorkspaceQuery: FSharp.Compiler.CodeAnalysis.Workspace.FSharpWorkspaceQuery+FSharpWorkspaceQuery
From d4416bab7ca3e67b396a89a69af2dbb1efaf75ca Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Wed, 11 Nov 2026 15:44:56 +0000
Subject: [PATCH 26/30] Preserve legacy classification for closed and preview
documents under LSP
Skip legacy semantic classification only for open host-workspace documents
when LSP is selected. Keep other workspaces, including preview and
miscellaneous files, eligible without adding a server-failure fallback.
Report fallback_only state and document the open-document FAR limitation.
Validation:
- .\Build.cmd -c Debug: passed, 0 warnings, 0 errors (after clean artifacts).
- dotnet test --project vsintegration\tests\FSharp.Editor.Tests\FSharp.Editor.Tests.fsproj -c Debug --no-build --filter-class '*Classification*' '*Telemetry*': 233 passed, 0 failed.
- Same test command with --filter-class '*SemanticClassificationPolicyTests*': 13 passed, 0 failed.
- dotnet fantomas --check on all three touched F# files: passed.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0c106877-ba3b-418c-9716-697da9c76300
---
docs/lsp.md | 11 ++-
docs/release-notes/.VisualStudio/18.vNext.md | 1 +
.../Classification/ClassificationService.fs | 28 +++---
.../SemanticClassificationServiceTests.fs | 92 ++++++++++++++++++-
.../FSharp.Editor.Tests/TelemetryTests.fs | 16 ++++
5 files changed, 130 insertions(+), 18 deletions(-)
diff --git a/docs/lsp.md b/docs/lsp.md
index 9a9b77cd392..b675eb62701 100644
--- a/docs/lsp.md
+++ b/docs/lsp.md
@@ -140,6 +140,12 @@ 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`, the legacy classifier still colors closed host-workspace documents
+and documents in other workspaces (including code-fix and rename previews).
+Find All References result lines for documents that are open in the host
+workspace 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
@@ -195,12 +201,15 @@ Properties use the `dotnet.fsharp.` prefix:
| `resolved_provider` | Selection after applying the existing fallback: `old` or `lsp` |
`dotnet/fsharp/semantichighlighting/providerstate` separately records actual
-legacy `enabled`/`disabled` decisions and LSP `ready`/`disabled`/`unavailable`
+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 host-workspace
+documents and other workspaces 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
diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md
index da3853e5ea6..3af4c171d0f 100644
--- a/docs/release-notes/.VisualStudio/18.vNext.md
+++ b/docs/release-notes/.VisualStudio/18.vNext.md
@@ -11,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))
diff --git a/vsintegration/src/FSharp.Editor/Classification/ClassificationService.fs b/vsintegration/src/FSharp.Editor/Classification/ClassificationService.fs
index 06a519b5230..25fa964e018 100644
--- a/vsintegration/src/FSharp.Editor/Classification/ClassificationService.fs
+++ b/vsintegration/src/FSharp.Editor/Classification/ClassificationService.fs
@@ -37,18 +37,12 @@ type SemanticClassificationData = SemanticClassificationView
type SemanticClassificationLookup = IReadOnlyDictionary>
[)>]
-type internal FSharpClassificationService [] (serviceProvider: SVsServiceProvider, options: EditorOptions) =
-
- let struct (_, provider) = HighlightingTelemetry.readProvider serviceProvider
- let produceSemanticClassification = provider <> "lsp"
+type internal FSharpClassificationService(provider: string, options: EditorOptions, telemetry: SemanticHighlightingTelemetry) =
do
- HighlightingTelemetry.instance.ReportState(
+ telemetry.ReportState(
"old",
- (if produceSemanticClassification then
- "enabled"
- else
- "disabled"),
+ (if provider = "lsp" then "fallback_only" else "enabled"),
[| "resolved_provider", box provider |],
(fun () -> options.Advanced.SendAdditionalTelemetry)
)
@@ -166,6 +160,14 @@ type internal FSharpClassificationService [] (serviceProvi
static let openedDocumentsSemanticClassificationCache =
new DocumentCache("fsharp-opened-documents-semantic-classification-cache", 2.)
+ []
+ new(serviceProvider: SVsServiceProvider, options: EditorOptions) =
+ let struct (_, provider) = HighlightingTelemetry.readProvider serviceProvider
+ FSharpClassificationService(provider, options, HighlightingTelemetry.instance)
+
+ static member ShouldClassify(provider, workspaceKind, isOpenDocument) =
+ provider <> "lsp" || workspaceKind <> WorkspaceKind.Host || not isOpenDocument
+
interface IFSharpClassificationService with
// Do not perform classification if we don't have project options (#defines matter)
member _.AddLexicalClassifications(_: SourceText, _: TextSpan, _: List, _: CancellationToken) = ()
@@ -222,7 +224,10 @@ type internal FSharpClassificationService [] (serviceProvi
(document: Document, textSpan: TextSpan, result: List, cancellationToken: CancellationToken)
=
- if not produceSemanticClassification then
+ let workspace = document.Project.Solution.Workspace
+ let isOpenDocument = workspace.IsDocumentOpen document.Id
+
+ if not (FSharpClassificationService.ShouldClassify(provider, workspace.Kind, isOpenDocument)) then
Task.CompletedTask
else
cancellableTask {
@@ -232,7 +237,6 @@ type internal FSharpClassificationService [] (serviceProvi
let mutable outcome = "faulted"
let mutable cacheHit = false
let mutable documentLength = -1
- let isOpenDocument = document.Project.Solution.Workspace.IsDocumentOpen document.Id
try
try
@@ -278,7 +282,7 @@ type internal FSharpClassificationService [] (serviceProvi
outcome <- "cancelled"
ExceptionDispatchInfo.Capture(error).Throw()
finally
- HighlightingTelemetry.instance.ReportDetails(
+ telemetry.ReportDetails(
(fun () ->
document.Project.Solution.Workspace.Services.GetService().Advanced.SendAdditionalTelemetry),
[|
diff --git a/vsintegration/tests/FSharp.Editor.Tests/SemanticClassificationServiceTests.fs b/vsintegration/tests/FSharp.Editor.Tests/SemanticClassificationServiceTests.fs
index 6c58f658ee9..62f2c7975d2 100644
--- a/vsintegration/tests/FSharp.Editor.Tests/SemanticClassificationServiceTests.fs
+++ b/vsintegration/tests/FSharp.Editor.Tests/SemanticClassificationServiceTests.fs
@@ -2,16 +2,98 @@
namespace FSharp.Editor.Tests
-open Xunit
+open System
+open System.Collections.Generic
+open System.Threading
+
+open Microsoft.CodeAnalysis
+open Microsoft.CodeAnalysis.Classification
+open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Classification
+open Microsoft.CodeAnalysis.Text
open Microsoft.VisualStudio.FSharp.Editor
+open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks
+open Microsoft.VisualStudio.Telemetry
+
+open FSharp.Compiler.CodeAnalysis
open FSharp.Compiler.EditorServices
open FSharp.Compiler.Text
-open Microsoft.CodeAnalysis.Text
-open Microsoft.CodeAnalysis.Classification
-open FSharp.Compiler.CodeAnalysis
open FSharp.Editor.Tests.Helpers
open FSharp.Test
-open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks
+
+open Xunit
+
+type SemanticClassificationPolicyTests() =
+ []
+ []
+ []
+ []
+ []
+ []
+ []
+ []
+ []
+ member _.``Legacy classification skips only LSP rendered documents``(provider, workspaceKind, isOpen, expected) =
+ Assert.Equal(expected, FSharpClassificationService.ShouldClassify(provider, workspaceKind, isOpen))
+
+ []
+ []
+ []
+ []
+ []
+ []
+ member _.``Legacy provider classifies closed and preview documents under LSP``(provider, workspaceKind, isOpen, expected) =
+ task {
+ use workspace = new AdhocWorkspace(TestHostServices(), workspaceKind)
+ let projectId = ProjectId.CreateNewId()
+ let source = "module Test\ntype Widget() = class end\nlet create () = Widget()"
+
+ let documentInfo =
+ RoslynTestHelpers.CreateDocumentInfo projectId "C:\\test.fs" source
+
+ let projectInfo =
+ RoslynTestHelpers.CreateProjectInfo projectId "C:\\test.fsproj" [ documentInfo ]
+
+ let project = workspace.AddProject projectInfo
+ RoslynTestHelpers.SetProjectOptions projectId project.Solution RoslynTestHelpers.DefaultProjectOptions
+
+ if isOpen then
+ workspace.OpenDocument documentInfo.Id
+
+ let document = workspace.CurrentSolution.GetDocument documentInfo.Id
+ Assert.Equal(isOpen, workspace.IsDocumentOpen document.Id)
+ use options = new EditorOptions()
+ let events = List()
+
+ let telemetry =
+ SemanticHighlightingTelemetry(events.Add, isMicrosoftInternal = (fun () -> true))
+
+ let service =
+ FSharpClassificationService(provider, options, telemetry) :> IFSharpClassificationService
+
+ let result = List()
+ do! service.AddSemanticClassificationsAsync(document, TextSpan(0, source.Length), result, CancellationToken.None)
+
+ if expected then
+ Assert.Contains(
+ result,
+ fun span ->
+ span.ClassificationType = ClassificationTypeNames.ClassName
+ && source.Substring(span.TextSpan.Start, span.TextSpan.Length) = "Widget"
+ )
+
+ Assert.Contains(result, fun span -> span.ClassificationType = FSharpClassificationTypes.Function)
+
+ let details =
+ Assert.Single(
+ events
+ |> Seq.filter (fun event -> event.Name.EndsWith("/addsemanticclassifications", StringComparison.Ordinal))
+ )
+
+ Assert.Equal(box "old", details.Properties["dotnet.fsharp.provider"])
+ else
+ Assert.Empty(result)
+ Assert.Single(events) |> ignore
+ }
type SemanticClassificationServiceTests() =
let getRanges (source: string) : SemanticClassificationItem list =
diff --git a/vsintegration/tests/FSharp.Editor.Tests/TelemetryTests.fs b/vsintegration/tests/FSharp.Editor.Tests/TelemetryTests.fs
index c0fbeac74a9..db63606f11d 100644
--- a/vsintegration/tests/FSharp.Editor.Tests/TelemetryTests.fs
+++ b/vsintegration/tests/FSharp.Editor.Tests/TelemetryTests.fs
@@ -12,6 +12,22 @@ open StreamJsonRpc
open Xunit
type TelemetryTests() =
+ []
+ []
+ []
+ member _.``Legacy provider state describes its classification scope``(provider, expectedState) =
+ use options = new EditorOptions()
+ let events = ConcurrentQueue()
+
+ let telemetry =
+ SemanticHighlightingTelemetry(events.Enqueue, isMicrosoftInternal = (fun () -> true))
+
+ FSharpClassificationService(provider, options, telemetry) |> ignore
+ let state = Assert.Single(events)
+ Assert.Equal(box "old", state.Properties["dotnet.fsharp.provider"])
+ Assert.Equal(box provider, state.Properties["dotnet.fsharp.resolved_provider"])
+ Assert.Equal(box expectedState, state.Properties["dotnet.fsharp.state"])
+
[]
member _.``Closing the last view does not stop consent forwarding to a pending request``() =
task {
From db1383bc946602f99d7a00298c2dde4a40e7bdb9 Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Wed, 11 Nov 2026 15:52:57 +0000
Subject: [PATCH 27/30] Avoid duplicate semantic classification in
miscellaneous editor buffers
Treat Host and MiscellaneousFiles as LSP-rendered editor workspaces when
open. Closed documents and other workspace kinds remain eligible for
legacy classification. Update predicate/provider tests and fallback docs.
Validation:
- .\Build.cmd -c Debug: passed, 0 warnings, 0 errors.
- dotnet test --project vsintegration\tests\FSharp.Editor.Tests\FSharp.Editor.Tests.fsproj -c Debug --no-build --filter-class '*Classification*' '*Telemetry*': 235 passed, 0 failed, 0 skipped.
- dotnet fantomas and --check on both touched F# files: passed.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0c106877-ba3b-418c-9716-697da9c76300
---
docs/lsp.md | 16 ++++++++--------
.../Classification/ClassificationService.fs | 6 +++++-
.../SemanticClassificationServiceTests.fs | 4 +++-
3 files changed, 16 insertions(+), 10 deletions(-)
diff --git a/docs/lsp.md b/docs/lsp.md
index b675eb62701..1a2ca506379 100644
--- a/docs/lsp.md
+++ b/docs/lsp.md
@@ -140,11 +140,11 @@ 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`, the legacy classifier still colors closed host-workspace documents
-and documents in other workspaces (including code-fix and rename previews).
-Find All References result lines for documents that are open in the host
-workspace remain without semantic coloring; LSP startup failure or a server
-crash does not enable legacy classification for those documents.
+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
@@ -207,9 +207,9 @@ 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 host-workspace
-documents and other workspaces under the `lsp` selection; legacy detail events
-are emitted only for requests that enter that classifier.
+`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
diff --git a/vsintegration/src/FSharp.Editor/Classification/ClassificationService.fs b/vsintegration/src/FSharp.Editor/Classification/ClassificationService.fs
index 25fa964e018..93273e5e750 100644
--- a/vsintegration/src/FSharp.Editor/Classification/ClassificationService.fs
+++ b/vsintegration/src/FSharp.Editor/Classification/ClassificationService.fs
@@ -166,7 +166,11 @@ type internal FSharpClassificationService(provider: string, options: EditorOptio
FSharpClassificationService(provider, options, HighlightingTelemetry.instance)
static member ShouldClassify(provider, workspaceKind, isOpenDocument) =
- provider <> "lsp" || workspaceKind <> WorkspaceKind.Host || not isOpenDocument
+ let isLspEditorWorkspace =
+ workspaceKind = WorkspaceKind.Host
+ || workspaceKind = WorkspaceKind.MiscellaneousFiles
+
+ provider <> "lsp" || not isLspEditorWorkspace || not isOpenDocument
interface IFSharpClassificationService with
// Do not perform classification if we don't have project options (#defines matter)
diff --git a/vsintegration/tests/FSharp.Editor.Tests/SemanticClassificationServiceTests.fs b/vsintegration/tests/FSharp.Editor.Tests/SemanticClassificationServiceTests.fs
index 62f2c7975d2..8a6171251b6 100644
--- a/vsintegration/tests/FSharp.Editor.Tests/SemanticClassificationServiceTests.fs
+++ b/vsintegration/tests/FSharp.Editor.Tests/SemanticClassificationServiceTests.fs
@@ -28,7 +28,8 @@ type SemanticClassificationPolicyTests() =
[]
[]
[]
- []
+ []
+ []
[]
[]
[]
@@ -40,6 +41,7 @@ type SemanticClassificationPolicyTests() =
[]
[]
[]
+ []
[]
member _.``Legacy provider classifies closed and preview documents under LSP``(provider, workspaceKind, isOpen, expected) =
task {
From ad7053d7821bc107589478110b34cc74837324ae Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Thu, 12 Nov 2026 15:42:41 +0100
Subject: [PATCH 28/30] Require net10.0 in vsixmanifest
---
.../Vsix/VisualFSharpFull/Source.extension.vsixmanifest | 1 +
1 file changed, 1 insertion(+)
diff --git a/vsintegration/Vsix/VisualFSharpFull/Source.extension.vsixmanifest b/vsintegration/Vsix/VisualFSharpFull/Source.extension.vsixmanifest
index 6d22be9eb03..36c1390df69 100644
--- a/vsintegration/Vsix/VisualFSharpFull/Source.extension.vsixmanifest
+++ b/vsintegration/Vsix/VisualFSharpFull/Source.extension.vsixmanifest
@@ -12,6 +12,7 @@
+ net10.0
From d29393e85e79009e6f133496e9a4280872459bc2 Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Thu, 12 Nov 2026 16:24:11 +0100
Subject: [PATCH 29/30] Don't try to start the server when LSP is off
---
.../FSharpExtensionSettings.cs | 4 ---
.../FSharpLanguageServerProvider.cs | 32 ++++++++++++-------
2 files changed, 21 insertions(+), 15 deletions(-)
diff --git a/src/FSharp.VisualStudio.Extension/FSharpExtensionSettings.cs b/src/FSharp.VisualStudio.Extension/FSharpExtensionSettings.cs
index 4156b936ecb..fe861d635ad 100644
--- a/src/FSharp.VisualStudio.Extension/FSharpExtensionSettings.cs
+++ b/src/FSharp.VisualStudio.Extension/FSharpExtensionSettings.cs
@@ -1,4 +1,3 @@
-using System.Linq;
using Microsoft.VisualStudio.Extensibility;
using Microsoft.VisualStudio.Extensibility.Settings;
@@ -44,8 +43,5 @@ internal static class FSharpExtensionSettings
[
(SemanticClassificationProvider, OLD),
];
-
- public static Setting[] AllStringSettings { get; } =
- AllSettingsWithDefaults.Select(x => x.Setting).ToArray();
}
}
diff --git a/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs b/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs
index 1b00ac39a57..88ad8f9f2cd 100644
--- a/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs
+++ b/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs
@@ -216,23 +216,32 @@ internal class FSharpLanguageServerProvider : LanguageServerProvider
private bool _semanticTokensEnabled;
///
- public override async Task CreateServerConnectionAsync(CancellationToken cancellationToken)
+ protected override async Task InitializeAsync(CancellationToken cancellationToken)
{
-#pragma warning disable VSEXTPREVIEW_SETTINGS // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+ await base.InitializeAsync(cancellationToken);
- var settingsReadResult = await this.Extensibility.Settings().ReadEffectiveValuesAsync(FSharpExtensionSettings.AllStringSettings, cancellationToken);
+ // Decide before Visual Studio's activation check: a provider that is still enabled
+ // when activated must return a connection, and a null one is reported as a failure.
+ this.Enabled = await this.ReadEffectiveProviderAsync(cancellationToken) == FSharpExtensionSettings.LSP;
+ }
- var settingValues = FSharpExtensionSettings.AllSettingsWithDefaults.Select(
- x => (x.Setting, x.DefaultIfUnset, value: settingsReadResult.ValueOrDefault(x.Setting, defaultValue: FSharpExtensionSettings.UNSET)));
+ private async Task ReadEffectiveProviderAsync(CancellationToken cancellationToken)
+ {
+#pragma warning disable VSEXTPREVIEW_SETTINGS // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+ var setting = FSharpExtensionSettings.SemanticClassificationProvider;
+ var defaultIfUnset = FSharpExtensionSettings.AllSettingsWithDefaults.Single(x => x.Setting == setting).DefaultIfUnset;
+ var settingValue = await this.Extensibility.Settings().ReadEffectiveValueAsync(setting, cancellationToken);
+ var value = settingValue.ValueOrDefault(FSharpExtensionSettings.UNSET);
+#pragma warning restore VSEXTPREVIEW_SETTINGS // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
- var effectiveSettingValues = settingValues.ToDictionary(
- x => x.Setting,
- x => x.value == FSharpExtensionSettings.UNSET ? x.DefaultIfUnset : x.value);
+ return value == FSharpExtensionSettings.UNSET ? defaultIfUnset : value;
+ }
- if (effectiveSettingValues[FSharpExtensionSettings.SemanticClassificationProvider] != FSharpExtensionSettings.LSP)
+ ///
+ public override async Task CreateServerConnectionAsync(CancellationToken cancellationToken)
+ {
+ if (!this.Enabled)
{
- // Returning null alone leaves the provider eligible for subsequent activation attempts.
- this.Enabled = false;
return null;
}
@@ -256,6 +265,7 @@ internal class FSharpLanguageServerProvider : LanguageServerProvider
));
_semanticTokensEnabled = serverConfig.EnabledFeatures.SemanticTokens;
+#pragma warning disable VSEXTPREVIEW_SETTINGS // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
var disposeToEndSubscription =
this.Extensibility.Settings().SubscribeAsync(
[FSharpExtensionSettings.FSharpCategory],
From a0f24cd7c5d0ea768256e8cb9fab29020d82890d Mon Sep 17 00:00:00 2001
From: Adam Boniecki <20281641+abonie@users.noreply.github.com>
Date: Thu, 12 Nov 2026 16:31:37 +0100
Subject: [PATCH 30/30] Remove unnecessary pragmas for now stable apis
---
.../FSharpExtensionSettings.cs | 2 --
.../FSharpLanguageServerProvider.cs | 14 ++------------
2 files changed, 2 insertions(+), 14 deletions(-)
diff --git a/src/FSharp.VisualStudio.Extension/FSharpExtensionSettings.cs b/src/FSharp.VisualStudio.Extension/FSharpExtensionSettings.cs
index fe861d635ad..29b8404c9ac 100644
--- a/src/FSharp.VisualStudio.Extension/FSharpExtensionSettings.cs
+++ b/src/FSharp.VisualStudio.Extension/FSharpExtensionSettings.cs
@@ -3,8 +3,6 @@
namespace FSharp.VisualStudio.Extension
{
-#pragma warning disable VSEXTPREVIEW_SETTINGS // The settings API is currently in preview and marked as experimental
-
internal static class FSharpExtensionSettings
{
public const string OLD = "old";
diff --git a/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs b/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs
index 88ad8f9f2cd..71a2b72e22a 100644
--- a/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs
+++ b/src/FSharp.VisualStudio.Extension/FSharpLanguageServerProvider.cs
@@ -1,4 +1,4 @@
-// Copyright (c) Microsoft. All rights reserved.
+// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
namespace FSharp.VisualStudio.Extension;
@@ -28,12 +28,6 @@ namespace FSharp.VisualStudio.Extension;
using Microsoft.VisualStudio.RpcContracts.LanguageServerProvider;
using Nerdbank.Streams;
-///
-#pragma warning disable VSEXTPREVIEW_LSP // Type is for evaluation purposes only and is subject to change or removal in future updates.
-
-#pragma warning disable VSEXTPREVIEW_PROJECTQUERY_PROPERTIES_BUILDPROPERTIES // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
-
-
internal static class Extensions
{
public static List> Please(this IAsyncQueryable x) => x.QueryAsync(CancellationToken.None).ToBlockingEnumerable().ToList();
@@ -192,7 +186,7 @@ public void OnError(Exception error)
}
}
-
+#pragma warning disable VSEXTPREVIEW_LSP // Type is for evaluation purposes only and is subject to change or removal in future updates.
[VisualStudioContribution]
internal class FSharpLanguageServerProvider : LanguageServerProvider
{
@@ -227,12 +221,10 @@ protected override async Task InitializeAsync(CancellationToken cancellationToke
private async Task ReadEffectiveProviderAsync(CancellationToken cancellationToken)
{
-#pragma warning disable VSEXTPREVIEW_SETTINGS // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
var setting = FSharpExtensionSettings.SemanticClassificationProvider;
var defaultIfUnset = FSharpExtensionSettings.AllSettingsWithDefaults.Single(x => x.Setting == setting).DefaultIfUnset;
var settingValue = await this.Extensibility.Settings().ReadEffectiveValueAsync(setting, cancellationToken);
var value = settingValue.ValueOrDefault(FSharpExtensionSettings.UNSET);
-#pragma warning restore VSEXTPREVIEW_SETTINGS // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
return value == FSharpExtensionSettings.UNSET ? defaultIfUnset : value;
}
@@ -265,7 +257,6 @@ private async Task ReadEffectiveProviderAsync(CancellationToken cancella
));
_semanticTokensEnabled = serverConfig.EnabledFeatures.SemanticTokens;
-#pragma warning disable VSEXTPREVIEW_SETTINGS // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
var disposeToEndSubscription =
this.Extensibility.Settings().SubscribeAsync(
[FSharpExtensionSettings.FSharpCategory],
@@ -275,7 +266,6 @@ private async Task ReadEffectiveProviderAsync(CancellationToken cancella
Trace.TraceInformation($"Settings update", result);
});
-#pragma warning restore VSEXTPREVIEW_SETTINGS // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
//const string vsMajorVersion = "17.0";