From 6154e388800776cb36cfdabe13bf52813eb3757e Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 31 Aug 2026 23:03:16 +0200 Subject: [PATCH 01/31] Offer F# declarations to the Copilot chat "#" mention picker Copilot's built-in symbol provider reads symbols off the Roslyn compilation, which F# projects do not have, so F# declarations never appeared in the picker shown for "#". Proffer a brokered service from FSharp.Editor implementing Copilot's context-provider and mention-queryable contracts. Declarations come from the NavigateTo parse-tree cache, so the picker answers without waiting for a project check; that cache moves into a shared FSharpNavigableItemsCache used by both features. A picked mention resolves by fully qualified name against the current solution, so it survives a file moving, and carries the whole declaration - doc comment included - as its snippet. FSharpPackage now registers the provider moniker with Copilot after package load. The override is no longer DEBUG-only, so it calls its base implementation, which registers the editor factories. Co-Authored-By: Claude Fable 5 --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + eng/Packages.props | 3 + .../src/FSharp.Editor/Common/Constants.fs | 5 + .../Copilot/CopilotContextProvider.fs | 327 ++++++++++++++++++ .../Copilot/CopilotSymbolMapping.fs | 62 ++++ .../Copilot/CopilotSymbolSnippets.fs | 40 +++ .../src/FSharp.Editor/FSharp.Editor.fsproj | 4 + .../LanguageService/LanguageService.fs | 40 ++- .../Navigation/NavigateToSearchService.fs | 72 ++-- .../CopilotContextProviderTests.fs | 110 ++++++ .../FSharp.Editor.Tests.fsproj | 1 + 11 files changed, 633 insertions(+), 32 deletions(-) create mode 100644 vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs create mode 100644 vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs create mode 100644 vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs create mode 100644 vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 2d032095814..0454ae7e3e8 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -4,6 +4,7 @@ * 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)) * Added a **Tools > Options > F# Tools > Compiler** option, **"Use the .NET SDK F# compiler for builds"**, on by default. Visual Studio uses the .NET SDK F# compiler when the project supplies SDK paths, matching `dotnet build`. Otherwise, it uses the bundled .NET Framework compiler. Turn the option off to use the bundled compiler for all projects. Changes are saved on Apply or OK; Cancel discards unsaved changes. Command-line builds are unaffected. ([Issue #20484](https://github.com/dotnet/fsharp/issues/20484), [PR #20485](https://github.com/dotnet/fsharp/pull/20485)) +* F# types, modules, members and values now appear in the GitHub Copilot Chat `#` mention picker, and attach their declaration source as context. ### Fixed diff --git a/eng/Packages.props b/eng/Packages.props index fb6753942db..5a15e374dda 100644 --- a/eng/Packages.props +++ b/eng/Packages.props @@ -65,6 +65,9 @@ ComponentModelHost would otherwise stay at 17.x; that 17.x/18.x split makes S/IComponentModel ambiguous (CS0433). Pin to the SDK 18.9.496 version so those types resolve to a single assembly. --> + + diff --git a/vsintegration/src/FSharp.Editor/Common/Constants.fs b/vsintegration/src/FSharp.Editor/Common/Constants.fs index ead451467cf..d0f493af6df 100644 --- a/vsintegration/src/FSharp.Editor/Common/Constants.fs +++ b/vsintegration/src/FSharp.Editor/Common/Constants.fs @@ -43,6 +43,11 @@ module internal FSharpConstants = /// "F# Language Service" let FSharpLanguageServiceCallbackName = "F# Language Service" + [] + /// Brokered service offering F# declarations to the Copilot chat "#" mention picker. + let copilotSymbolProviderName = + "Microsoft.VisualStudio.FSharp.CopilotSymbolContextProvider" + [] /// "FSharp" let FSharpLanguageLongName = "FSharp" diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs new file mode 100644 index 00000000000..c11ac6f22ad --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs @@ -0,0 +1,327 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Editor + +open System +open System.Collections.Generic +open System.ComponentModel.Composition +open System.IO +open System.Threading.Tasks + +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Navigation +open Microsoft.CodeAnalysis.Text +open Microsoft.ServiceHub.Framework +open Microsoft.VisualStudio.Copilot +open Microsoft.VisualStudio.LanguageServices +open Microsoft.VisualStudio.Shell +open Microsoft.VisualStudio.Shell.ServiceBroker + +open FSharp.Compiler.EditorServices +open CancellableTasks + +/// Solution-wide lookup of F# declarations behind the Copilot chat "#" mention picker. +/// Kept apart from the brokered service so it can be exercised without a Visual Studio workspace. +module internal CopilotSymbolQuery = + + [] + let private MaxMentions = 20 + + /// Overloads and partial definitions share one fully qualified name; a handful of them is plenty of context. + [] + let private MaxDeclarations = 4 + + [] + let private UserOpName = "CopilotSymbolContext" + + let private fsharpDocuments (solution: Solution) = + seq { + for project in solution.Projects do + if project.Language = FSharpConstants.FSharpLanguageName then + yield! project.Documents + } + + let describe (item: NavigableItem) (document: Document) = + let container = + match item.Container.FullName with + | "" -> Path.GetFileName document.FilePath + | name -> name + + if document.IsFSharpSignatureFile then + $"signature, {container} - {document.Project.Name}" + else + $"{container} - {document.Project.Name}" + + /// Declarations whose fully qualified name matches `searchText`, best match first, one entry per name. + let search (cache: FSharpNavigableItemsCache) (solution: Solution) (searchText: string) = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + let tryMatch = cache.CreateMatcherFor searchText + let hits = ResizeArray() + + for document in fsharpDocuments solution do + ct.ThrowIfCancellationRequested() + let! items = cache.GetNavigableItems document + + for item in items do + match tryMatch item with + | ValueSome patternMatch -> hits.Add(struct (patternMatch.Kind, item, document)) + | ValueNone -> () + + return + hits + |> Seq.sortBy (fun (struct (kind, item: NavigableItem, document: Document)) -> + document.IsFSharpSignatureFile, kind, item.Name.Length) + |> Seq.distinctBy (fun (struct (_, item, _)) -> CopilotSymbolMapping.fullyQualifiedName item) + |> Seq.truncate MaxMentions + |> Seq.map (fun (struct (_, item, document)) -> struct (item, document)) + |> Seq.toArray + } + + /// Declarations carrying exactly this fully qualified name. Signature files answer only when no + /// implementation declares the name. + let declarationsOf (cache: FSharpNavigableItemsCache) (solution: Solution) (fullyQualifiedName: string) = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + let hits = ResizeArray() + + for document in fsharpDocuments solution do + ct.ThrowIfCancellationRequested() + let! items = cache.GetNavigableItems document + + for item in items do + if String.Equals(CopilotSymbolMapping.fullyQualifiedName item, fullyQualifiedName, StringComparison.Ordinal) then + hits.Add(struct (item, document)) + + let implementations = + hits + |> Seq.filter (fun (struct (_, document: Document)) -> not document.IsFSharpSignatureFile) + + let preferred = + if Seq.isEmpty implementations then + hits :> _ seq + else + implementations + + return preferred |> Seq.truncate MaxDeclarations |> Seq.toArray + } + + /// The source of the whole declaration `item` names, together with the span it occupies. + let snippetOf (item: NavigableItem) (document: Document) = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + let! sourceText = document.GetTextAsync ct + let! parseResults = document.GetFSharpParseResultsAsync UserOpName + + let sourceLines = + Array.init sourceText.Lines.Count (fun line -> sourceText.Lines[line].ToString()) + + let scopes = Structure.getOutliningRanges sourceLines parseResults.ParseTree + let firstLine, lastLine = CopilotSymbolSnippets.definitionLines scopes item + + let firstLine = max 1 firstLine + let lastLine = min sourceText.Lines.Count lastLine + + let span = + TextSpan.FromBounds(sourceText.Lines[firstLine - 1].Start, sourceText.Lines[lastLine - 1].End) + + return struct (sourceText.GetSubText(span).ToString(), span) + } + + let symbolContext (cache: FSharpNavigableItemsCache) (solution: Solution) (fullyQualifiedName: string) = + cancellableTask { + let! declarations = declarationsOf cache solution fullyQualifiedName + + match Array.tryHead declarations with + | None -> return ValueNone + | Some(struct (first, _)) -> + let snippets = ResizeArray() + let locations = ResizeArray() + + for struct (item, document) in declarations do + let! struct (text, span) = snippetOf item document + snippets.Add text + locations.Add(SnippetLocation(document.FilePath, CopilotSpan(span.Start, span.Length))) + + return + ValueSome( + CopilotSymbolContext( + fullyQualifiedName, + first.Name, + String.Join(Environment.NewLine + Environment.NewLine, snippets), + CopilotSymbolMapping.symbolContextType first.Kind, + locations.ToArray() + ) + ) + } + +/// Offers F# declarations to Copilot chat, which merges them into the picker shown for "#". +/// Copilot's own symbol provider reads the Roslyn compilation, which F# projects do not have. +[; typeof |], + Audience = (ServiceAudience.PublicSdk ||| ServiceAudience.Local))>] +type internal FSharpCopilotContextProvider + [] + (cache: FSharpNavigableItemsCache, [] workspace: VisualStudioWorkspace) = + + static let moniker = + ServiceMoniker(FSharpConstants.copilotSymbolProviderName, Version CopilotDescriptors.CurrentContextProviderVersion) + + static let descriptor = + CopilotContextDescriptor( + CopilotSymbolMapping.SymbolMember, + "An F# type, module, member or value declared in the current solution.", + CopilotDefaultTypes.SymbolContextName, + [| + CopilotInputDescriptor( + CopilotSymbolMapping.FullyQualifiedNameInput, + "Fully qualified name of the F# declaration.", + CopilotDefaultTypes.StringName, + IsRequired = true + ) + |] + ) + + static let members = [| descriptor |] :> IReadOnlyList + + static let memberNames = [| CopilotSymbolMapping.SymbolMember |] :> IReadOnlyList + + static let noMentions = + Array.empty :> IReadOnlyCollection + + let mentionFor (item: NavigableItem) (document: Document) = + let inputs = Dictionary(StringComparer.Ordinal) + + inputs[CopilotSymbolMapping.FullyQualifiedNameInput] <- + CopilotValue(CopilotDefaultTypes.StringName, CopilotSymbolMapping.fullyQualifiedName item) + + let description = CopilotSymbolQuery.describe item document + + CopilotQueriedContextMention( + moniker, + descriptor, + inputs, + item.Name, + Description = description, + Tooltip = description, + Icon = Nullable(CopilotSymbolMapping.icon item.Kind), + IsNavigable = true + ) + :> CopilotQueriedMention + + /// The user is still typing, so the trailing input is the search text. It is preceded by the member + /// name once the mention has been committed, as in "#fsharpSymbol:Namespace.Type". + let searchTextOf (query: CopilotMentionQuery) = + match query.Type, query.Inputs with + | CopilotMentionType.Context, null -> ValueNone + | CopilotMentionType.Context, inputs when inputs.Count > 0 -> + match inputs[inputs.Count - 1] with + | text when String.IsNullOrWhiteSpace text -> ValueNone + | text when String.Equals(text, CopilotSymbolMapping.SymbolMember, StringComparison.Ordinal) -> ValueNone + | text -> ValueSome text + | _ -> ValueNone + + let queryMentions (query: CopilotMentionQuery) = + cancellableTask { + match workspace, searchTextOf query with + | null, _ + | _, ValueNone -> return noMentions + | workspace, ValueSome searchText -> + let! hits = CopilotSymbolQuery.search cache workspace.CurrentSolution searchText + + return + hits |> Array.map (fun (struct (item, document)) -> mentionFor item document) + :> IReadOnlyCollection + } + + let fullyQualifiedNameOf (inputs: IReadOnlyDictionary) = + match inputs with + | null -> ValueNone + | inputs -> + match inputs.TryGetValue CopilotSymbolMapping.FullyQualifiedNameInput with + | true, value -> + match value.TryGetValue() with + | true, name when not (String.IsNullOrWhiteSpace name) -> ValueSome name + | _ -> ValueNone + | _ -> ValueNone + + interface IExportedBrokeredService with + member _.Descriptor = CopilotDescriptors.CreateContextProviderDescriptor moniker + + member _.InitializeAsync _cancellationToken = Task.CompletedTask + + interface ICopilotContextReducer with + member _.ReduceAsync(context, _reduction, _counter, _cancellationToken) = Task.FromResult context + + interface ICopilotContextProvider with + member _.GetMembersAsync _cancellationToken = + ValueTask> members + + member _.GetMembersAsync(_requestId, _cancellationToken) = Task.FromResult memberNames + + member _.StoreAsync(_requestId, _cancellationToken) = ValueTask() + + member _.ReleaseAsync(_requestId, _cancellationToken) = ValueTask() + + member _.GetContextAsync(requestId, memberName, inputs, cancellationToken) : Task = + match workspace, fullyQualifiedNameOf inputs with + | null, _ + | _, ValueNone -> Task.FromResult null + | workspace, ValueSome fullyQualifiedName when + String.Equals(memberName, CopilotSymbolMapping.SymbolMember, StringComparison.Ordinal) + -> + cancellableTask { + let! symbol = CopilotSymbolQuery.symbolContext cache workspace.CurrentSolution fullyQualifiedName + + match symbol with + | ValueNone -> return null + | ValueSome symbol -> return CopilotContext(moniker, descriptor, requestId, symbol, CanReduce = false) + } + |> CancellableTask.start cancellationToken + | _ -> Task.FromResult null + + interface ICopilotMentionQueryable with + member _.QueryMentionAsync(query, cancellationToken) : Task> = + queryMentions query |> CancellableTask.start cancellationToken + + member _.NavigateToMentionableAsync(mention, cancellationToken) : Task = + match workspace, fullyQualifiedNameOf mention.Inputs with + | null, _ + | _, ValueNone -> Task.FromResult false + | workspace, ValueSome fullyQualifiedName -> + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + let solution = workspace.CurrentSolution + let! declarations = CopilotSymbolQuery.declarationsOf cache solution fullyQualifiedName + + match Array.tryHead declarations with + | None -> return false + | Some(struct (item, document)) -> + let! sourceText = document.GetTextAsync ct + + match RoslynHelpers.TryFSharpRangeToTextSpan(sourceText, item.Range) with + | ValueNone -> return false + | ValueSome span -> + do! ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync ct + + let navigation = + solution.Workspace.Services.GetService() + + return navigation.TryNavigateToSpan(solution.Workspace, document.Id, span, ct) + } + |> CancellableTask.start cancellationToken + + // Copilot's own picker providers answer through the batch interface, one result collection per query. + interface ICopilotMentionBatchQueryable with + member _.QueryMentionBatchAsync(queries, cancellationToken) : Task>> = + cancellableTask { + let results = ResizeArray queries.Count + + for query in queries do + let! mentions = queryMentions query + results.Add mentions + + return results :> IReadOnlyList> + } + |> CancellableTask.start cancellationToken diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs new file mode 100644 index 00000000000..b155ccdc1d8 --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Editor + +open Microsoft.VisualStudio.Copilot +open Microsoft.VisualStudio.Imaging + +open FSharp.Compiler.EditorServices + +/// Translates F# navigable items into the shapes the Copilot chat "#" mention picker understands. +module internal CopilotSymbolMapping = + + /// Name of the context member. It becomes the mention prefix the user sees and re-types, + /// as in "#fsharpSymbol:Namespace.Type.Member". + [] + let SymbolMember = "fsharpSymbol" + + [] + let FullyQualifiedNameInput = "fullyQualifiedName" + + /// The parse tree cannot tell an interface, struct or record apart from a plain class, so every + /// type-like declaration is reported as a class. + let symbolContextType kind = + match kind with + | NavigableItemKind.Module + | NavigableItemKind.ModuleAbbreviation + | NavigableItemKind.Exception + | NavigableItemKind.Type -> CopilotSymbolContextType.Class + | NavigableItemKind.ModuleValue -> CopilotSymbolContextType.Function + | NavigableItemKind.Field + | NavigableItemKind.Property -> CopilotSymbolContextType.Field + | NavigableItemKind.Constructor + | NavigableItemKind.Member -> CopilotSymbolContextType.Method + | NavigableItemKind.EnumCase -> CopilotSymbolContextType.Constant + | NavigableItemKind.UnionCase -> CopilotSymbolContextType.Union + + let private imageId kind = + match kind with + | NavigableItemKind.Module + | NavigableItemKind.ModuleAbbreviation -> KnownImageIds.ModulePublic + | NavigableItemKind.Exception -> KnownImageIds.ExceptionPublic + | NavigableItemKind.Type -> KnownImageIds.ClassPublic + | NavigableItemKind.ModuleValue + | NavigableItemKind.Constructor + | NavigableItemKind.Member -> KnownImageIds.MethodPublic + | NavigableItemKind.Field -> KnownImageIds.FieldPublic + | NavigableItemKind.Property -> KnownImageIds.PropertyPublic + | NavigableItemKind.EnumCase + | NavigableItemKind.UnionCase -> KnownImageIds.EnumerationItemPublic + + let icon kind = + let mutable moniker = CopilotImageMoniker() + moniker.Guid <- KnownImageIds.ImageCatalogGuid + moniker.Id <- imageId kind + moniker + + /// Dotted path that both drives the picker's pattern matching and identifies a picked mention + /// when it is resolved back to source. + let fullyQualifiedName (item: NavigableItem) = + match item.Container.FullName with + | "" -> item.Name + | container -> $"{container}.{item.Name}" diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs new file mode 100644 index 00000000000..a3c2b4b58e1 --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Editor + +open FSharp.Compiler.EditorServices + +/// Widens the identifier range of a navigable item to the declaration a reader would recognise. +module internal CopilotSymbolSnippets = + + /// A module scope can span a whole file, which is more than a chat prompt can usefully carry. + [] + let MaxSnippetLines = 200 + + /// Inclusive, 1-based line bounds of the declaration `item` names, including its doc comment. + let definitionLines (scopes: Structure.ScopeRange seq) (item: NavigableItem) = + let declarationLine = item.Range.StartLine + + // A construct's outlining range reaches back over the doc comment in front of it, so it is the + // collapse range - the body proper - that tells which construct is declared on this line. + let declaredHere (scope: Structure.ScopeRange) = + scope.CollapseRange.StartLine = declarationLine + && scope.Range.EndLine >= item.Range.EndLine + && scope.Scope <> Structure.Scope.Comment + && scope.Scope <> Structure.Scope.XmlDocComment + + let mutable widest = ValueNone + + for scope in scopes do + if declaredHere scope then + match widest with + | ValueSome(previous: Structure.ScopeRange) when previous.Range.EndLine >= scope.Range.EndLine -> () + | _ -> widest <- ValueSome scope + + // A one-line member declares no scope of its own; it stands for itself rather than for the type around it. + let firstLine, lastLine = + match widest with + | ValueSome scope -> scope.Range.StartLine, scope.Range.EndLine + | ValueNone -> declarationLine, item.Range.EndLine + + firstLine, min lastLine (firstLine + MaxSnippetLines - 1) diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj index 319bdd5a264..3176e1b964c 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj @@ -94,6 +94,9 @@ + + + @@ -179,6 +182,7 @@ + diff --git a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs index ac8ceffd4f9..1fb9e9a6b66 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs @@ -6,6 +6,7 @@ open System open System.ComponentModel.Design open System.Runtime.InteropServices open System.Threading +open System.Threading.Tasks open System.IO open System.Collections.Immutable open Microsoft.CodeAnalysis @@ -13,13 +14,16 @@ open Microsoft.CodeAnalysis.Options open FSharp.Compiler open FSharp.Compiler.CodeAnalysis open FSharp.NativeInterop +open Microsoft.ServiceHub.Framework open Microsoft.VisualStudio +open Microsoft.VisualStudio.Copilot open Microsoft.VisualStudio.FSharp.Editor open Microsoft.VisualStudio.LanguageServices open Microsoft.VisualStudio.LanguageServices.Implementation.LanguageService open Microsoft.VisualStudio.LanguageServices.Implementation.ProjectSystem open Microsoft.VisualStudio.Shell open Microsoft.VisualStudio.Shell.Interop +open Microsoft.VisualStudio.Shell.ServiceBroker open Microsoft.VisualStudio.Text.Outlining open Microsoft.CodeAnalysis.ExternalAccess.FSharp open Microsoft.CodeAnalysis.Host.Mef @@ -409,8 +413,42 @@ type internal FSharpPackage() as this = |> CancellableTask.startAsTask cancellationToken) ) + override this.RegisterOnAfterPackageLoadedAsyncWork(afterPackageLoadedTasks: PackageLoadTasks) = + base.RegisterOnAfterPackageLoadedAsyncWork(afterPackageLoadedTasks) + + afterPackageLoadedTasks.AddTask( + false, + fun _ cancellationToken -> + task { + let! container = this.GetServiceAsync(typeof) + + match container with + | :? IBrokeredServiceContainer as container -> + // The Interactions service also serves the registration interface. It is absent when + // GitHub Copilot is not installed, in which case the proxy is null and F# stays out of the picker. + let! registration = + container + .GetFullAccessServiceBroker() + .GetProxyAsync(CopilotDescriptors.InteractionService, cancellationToken) + + use registration = registration + + match registration with + | null -> () + | registration -> + let moniker = + ServiceMoniker( + FSharpConstants.copilotSymbolProviderName, + Version CopilotDescriptors.CurrentContextProviderVersion + ) + + do! registration.RegisterContextProviderAsync(moniker, cancellationToken) + | _ -> () + } + :> Task + ) + #if DEBUG - override _.RegisterOnAfterPackageLoadedAsyncWork(afterPackageLoadedTasks: PackageLoadTasks) = afterPackageLoadedTasks.AddTask( false, fun _ _ -> diff --git a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs index 546b00e1b16..b3273e36a19 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs @@ -19,8 +19,10 @@ open Microsoft.VisualStudio.Text.PatternMatching open FSharp.Compiler.EditorServices open CancellableTasks -[); Shared>] -type internal FSharpNavigateToSearchService +/// Parse-tree navigable items per document, cached on the document's text version. +/// Shared by NavigateTo and by the Copilot chat mention provider. +[] +type internal FSharpNavigableItemsCache [] (patternMatcherFactory: IPatternMatcherFactory, [] workspace: VisualStudioWorkspace) = @@ -33,7 +35,7 @@ type internal FSharpNavigateToSearchService if e.NewSolution.Id <> e.OldSolution.Id then cache.Clear() - let getNavigableItems (document: Document) = + member _.GetNavigableItems(document: Document) = cancellableTask { let! ct = CancellableTask.getCancellationToken () let! currentVersion = document.GetTextVersionAsync(ct) @@ -41,12 +43,45 @@ type internal FSharpNavigateToSearchService match cache.TryGetValue document.Id with | true, (version, items) when version = currentVersion -> return items | _ -> - let! parseResults = document.GetFSharpParseResultsAsync(nameof (FSharpNavigateToSearchService)) + let! parseResults = document.GetFSharpParseResultsAsync(nameof (FSharpNavigableItemsCache)) let items = NavigateTo.GetNavigableItems parseResults.ParseTree cache[document.Id] <- currentVersion, items return items } + member _.CreateMatcherFor(searchPattern: string) = + let patternMatcher = + patternMatcherFactory.CreatePatternMatcher( + searchPattern, + PatternMatcherCreationOptions( + cultureInfo = CultureInfo.CurrentUICulture, + flags = PatternMatcherCreationFlags.AllowFuzzyMatching, + containerSplitCharacters = [ '.' ] + ) + ) + + fun (item: NavigableItem) -> + // PatternMatcher will not match operators and some backtick escaped identifiers. + // To handle them, we fall back to simple substring match. + let name = item.Name + + if item.NeedsBackticks then + match name.IndexOf(searchPattern, StringComparison.CurrentCultureIgnoreCase) with + | i when i > 0 -> ValueSome(PatternMatch(PatternMatchKind.Substring, false, false)) + | 0 when name.Length = searchPattern.Length -> ValueSome(PatternMatch(PatternMatchKind.Exact, false, false)) + | 0 -> ValueSome(PatternMatch(PatternMatchKind.Prefix, false, false)) + | _ -> ValueNone + else + // full name with dots allows for path matching, e.g. + // "f.c.so.elseif" will match "Fantomas.Core.SyntaxOak.ElseIfNode" + patternMatcher.TryMatch $"{item.Container.FullName}.{name}" + |> ValueOption.ofNullable + +[); Shared>] +type internal FSharpNavigateToSearchService [] (itemsCache: FSharpNavigableItemsCache) = + + let getNavigableItems (document: Document) = itemsCache.GetNavigableItems document + let kindsProvided = ImmutableHashSet.Create( FSharpNavigateToItemKind.Module, @@ -115,33 +150,8 @@ type internal FSharpNavigateToSearchService | PatternMatchKind.Fuzzy -> FSharpNavigateToMatchKind.Fuzzy | _ -> FSharpNavigateToMatchKind.None - let createMatcherFor searchPattern = - let patternMatcher = - patternMatcherFactory.CreatePatternMatcher( - searchPattern, - PatternMatcherCreationOptions( - cultureInfo = CultureInfo.CurrentUICulture, - flags = PatternMatcherCreationFlags.AllowFuzzyMatching, - containerSplitCharacters = [ '.' ] - ) - ) - - fun (item: NavigableItem) -> - // PatternMatcher will not match operators and some backtick escaped identifiers. - // To handle them, we fall back to simple substring match. - let name = item.Name - - if item.NeedsBackticks then - match name.IndexOf(searchPattern, StringComparison.CurrentCultureIgnoreCase) with - | i when i > 0 -> ValueSome(PatternMatch(PatternMatchKind.Substring, false, false)) - | 0 when name.Length = searchPattern.Length -> ValueSome(PatternMatch(PatternMatchKind.Exact, false, false)) - | 0 -> ValueSome(PatternMatch(PatternMatchKind.Prefix, false, false)) - | _ -> ValueNone - else - // full name with dots allows for path matching, e.g. - // "f.c.so.elseif" will match "Fantomas.Core.SyntaxOak.ElseIfNode" - patternMatcher.TryMatch $"{item.Container.FullName}.{name}" - |> ValueOption.ofNullable + let createMatcherFor (searchPattern: string) = + itemsCache.CreateMatcherFor searchPattern let processDocument (tryMatch: NavigableItem -> PatternMatch voption) (kinds: IImmutableSet) (document: Document) = cancellableTask { diff --git a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs new file mode 100644 index 00000000000..cd20c8e2d4d --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace FSharp.Editor.Tests + +open System.Threading + +open Xunit + +open Microsoft.VisualStudio.Copilot +open Microsoft.VisualStudio.FSharp.Editor + +open FSharp.Editor.Tests.Helpers +open CancellableTasks + +module CopilotContextProviderTests = + + let fileContents = + """ +module Widgets + +/// Counts things that matter. +type Counter(start: int) = + let mutable value = start + + member _.Value = value + + member _.Bump() = + value <- value + 1 + value + +type Shape = + | Circle of radius: float + | Square of side: float + +let describeShape shape = + match shape with + | Circle r -> $"circle {r}" + | Square s -> $"square {s}" +""" + + let solution = RoslynTestHelpers.CreateSolution fileContents + + let private cache = + MefHelpers.createExportProvider().GetExportedValue() + + let private run computation = + computation |> CancellableTask.start CancellationToken.None |> _.Result + + let private search pattern = + CopilotSymbolQuery.search cache solution pattern + |> run + |> Array.map (fun (struct (item, _)) -> CopilotSymbolMapping.fullyQualifiedName item) + + let private symbolContext name = + CopilotSymbolQuery.symbolContext cache solution name |> run + + let private contextOf name = + match symbolContext name with + | ValueSome context -> context + | ValueNone -> failwith $"expected a symbol context for {name}" + + [] + [] + [] + [] + [] + let ``search finds a declaration by its fully qualified name`` (pattern: string, expected: string) = + Assert.Contains(expected, search pattern) + + [] + let ``search reports each declaration once`` () = + let names = search "Counter" + Assert.Equal((Array.distinct names).Length, names.Length) + + [] + let ``an unknown name has no context`` () = + Assert.True((symbolContext "Widgets.NoSuchThing").IsNone) + + [] + let ``a type context carries the whole declaration and its doc comment`` () = + let context = contextOf "Widgets.Counter" + + Assert.Equal("Widgets.Counter", context.FullyQualifiedName) + Assert.Equal("Counter", context.UnqualifiedName) + Assert.Contains("Counts things that matter.", context.Snippet) + Assert.Contains("member _.Bump()", context.Snippet) + + [] + let ``a member context carries the member body alone`` () = + let context = contextOf "Widgets.Counter.Bump" + + Assert.Contains("value <- value + 1", context.Snippet) + Assert.DoesNotContain("type Counter", context.Snippet) + + [] + [] + [] + [] + [] + [] + let ``declaration kinds map onto Copilot symbol types`` (name: string, expected: CopilotSymbolContextType) = + Assert.Equal(expected, (contextOf name).SymbolType) + + [] + let ``a context points back at the source it was taken from`` () = + let context = contextOf "Widgets.Counter" + let location = Assert.Single context.SnippetLocations + + Assert.Equal("C:\\test.fs", location.FilePath) + Assert.Equal(context.Snippet.Length, location.Span.Length) diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj index a62b25af1cf..87f071ddbfd 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj +++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj @@ -33,6 +33,7 @@ + From dea1d9857a55c61a5ea88743ce2d6948bb29ee05 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Tue, 1 Sep 2026 00:41:18 +0200 Subject: [PATCH 02/31] Parallelize Copilot symbol lookup and cut allocations on the hot cache path Sequential per-document scanning made "search" and "declarationsOf" as slow as the slowest single file; run them across documents concurrently instead, throttled the same way FindReferencesAsync throttles its per-document typechecks, so a solution-wide scan does not launch a parse per document all at once. FSharpNavigableItemsCache's version-stamp entries move to struct tuples and its null workspace check to a match, matching this repo's allocation and null-narrowing conventions on a path every keystroke in the mention picker hits. CopilotSymbolMapping collapses its wrapping module into a single qualified top-level module declaration. Co-Authored-By: Claude Fable 5 --- .../Copilot/CopilotContextProvider.fs | 79 +++++++++----- .../Copilot/CopilotSymbolMapping.fs | 103 +++++++++--------- .../Copilot/CopilotSymbolSnippets.fs | 64 ++++++----- .../LanguageService/LanguageService.fs | 14 +-- .../Navigation/NavigateToSearchService.fs | 51 ++++----- 5 files changed, 157 insertions(+), 154 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs index c11ac6f22ad..f63398e8c69 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs @@ -35,11 +35,9 @@ module internal CopilotSymbolQuery = let private UserOpName = "CopilotSymbolContext" let private fsharpDocuments (solution: Solution) = - seq { - for project in solution.Projects do - if project.Language = FSharpConstants.FSharpLanguageName then - yield! project.Documents - } + solution.Projects + |> Seq.where (fun project -> project.Language = FSharpConstants.FSharpLanguageName) + |> Seq.collect _.Documents let describe (item: NavigableItem) (document: Document) = let container = @@ -57,19 +55,28 @@ module internal CopilotSymbolQuery = cancellableTask { let! ct = CancellableTask.getCancellationToken () let tryMatch = cache.CreateMatcherFor searchText - let hits = ResizeArray() - for document in fsharpDocuments solution do - ct.ThrowIfCancellationRequested() - let! items = cache.GetNavigableItems document + let matchesIn (document: Document) = + cancellableTask { + ct.ThrowIfCancellationRequested() + let! items = cache.GetNavigableItems document + + return + items + |> Seq.chooseV (fun item -> + tryMatch item + |> ValueOption.map (fun patternMatch -> struct (patternMatch.Kind, item, document))) + } - for item in items do - match tryMatch item with - | ValueSome patternMatch -> hits.Add(struct (patternMatch.Kind, item, document)) - | ValueNone -> () + let! hits = + fsharpDocuments solution + |> Seq.map matchesIn + // Throttle to avoid launching a parse per document in the solution all at once. + |> CancellableTask.whenAllThrottled (max 1 Environment.ProcessorCount) return hits + |> Seq.collect id |> Seq.sortBy (fun (struct (kind, item: NavigableItem, document: Document)) -> document.IsFSharpSignatureFile, kind, item.Name.Length) |> Seq.distinctBy (fun (struct (_, item, _)) -> CopilotSymbolMapping.fullyQualifiedName item) @@ -83,15 +90,29 @@ module internal CopilotSymbolQuery = let declarationsOf (cache: FSharpNavigableItemsCache) (solution: Solution) (fullyQualifiedName: string) = cancellableTask { let! ct = CancellableTask.getCancellationToken () - let hits = ResizeArray() - for document in fsharpDocuments solution do - ct.ThrowIfCancellationRequested() - let! items = cache.GetNavigableItems document + let matchesIn (document: Document) = + cancellableTask { + ct.ThrowIfCancellationRequested() + let! items = cache.GetNavigableItems document + + return + items + |> Seq.chooseV (fun item -> + if + String.Equals(CopilotSymbolMapping.fullyQualifiedName item, fullyQualifiedName, StringComparison.Ordinal) + then + ValueSome struct (item, document) + else + ValueNone) + } - for item in items do - if String.Equals(CopilotSymbolMapping.fullyQualifiedName item, fullyQualifiedName, StringComparison.Ordinal) then - hits.Add(struct (item, document)) + let! hits = + fsharpDocuments solution + |> Seq.map matchesIn + // Throttle to avoid launching a parse per document in the solution all at once. + |> CancellableTask.whenAllThrottled (max 1 Environment.ProcessorCount) + |> CancellableTask.map (Seq.collect id) let implementations = hits @@ -117,7 +138,7 @@ module internal CopilotSymbolQuery = Array.init sourceText.Lines.Count (fun line -> sourceText.Lines[line].ToString()) let scopes = Structure.getOutliningRanges sourceLines parseResults.ParseTree - let firstLine, lastLine = CopilotSymbolSnippets.definitionLines scopes item + let struct (firstLine, lastLine) = CopilotSymbolSnippets.definitionLines scopes item let firstLine = max 1 firstLine let lastLine = min sourceText.Lines.Count lastLine @@ -132,9 +153,9 @@ module internal CopilotSymbolQuery = cancellableTask { let! declarations = declarationsOf cache solution fullyQualifiedName - match Array.tryHead declarations with - | None -> return ValueNone - | Some(struct (first, _)) -> + match Array.tryHeadV declarations with + | ValueNone -> return ValueNone + | ValueSome(struct (first, _)) -> let snippets = ResizeArray() let locations = ResizeArray() @@ -163,7 +184,7 @@ module internal CopilotSymbolQuery = Audience = (ServiceAudience.PublicSdk ||| ServiceAudience.Local))>] type internal FSharpCopilotContextProvider [] - (cache: FSharpNavigableItemsCache, [] workspace: VisualStudioWorkspace) = + (cache: FSharpNavigableItemsCache, [] workspace: VisualStudioWorkspace | null) = static let moniker = ServiceMoniker(FSharpConstants.copilotSymbolProviderName, Version CopilotDescriptors.CurrentContextProviderVersion) @@ -235,7 +256,7 @@ type internal FSharpCopilotContextProvider :> IReadOnlyCollection } - let fullyQualifiedNameOf (inputs: IReadOnlyDictionary) = + let fullyQualifiedNameOf (inputs: IReadOnlyDictionary | null) = match inputs with | null -> ValueNone | inputs -> @@ -295,9 +316,9 @@ type internal FSharpCopilotContextProvider let solution = workspace.CurrentSolution let! declarations = CopilotSymbolQuery.declarationsOf cache solution fullyQualifiedName - match Array.tryHead declarations with - | None -> return false - | Some(struct (item, document)) -> + match Array.tryHeadV declarations with + | ValueNone -> return false + | ValueSome(struct (item, document)) -> let! sourceText = document.GetTextAsync ct match RoslynHelpers.TryFSharpRangeToTextSpan(sourceText, item.Range) with diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs index b155ccdc1d8..a23d7aab14d 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs @@ -1,62 +1,57 @@ // Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. -namespace Microsoft.VisualStudio.FSharp.Editor +/// Translates F# navigable items into the shapes the Copilot chat "#" mention picker understands. +module internal Microsoft.VisualStudio.FSharp.Editor.CopilotSymbolMapping open Microsoft.VisualStudio.Copilot open Microsoft.VisualStudio.Imaging open FSharp.Compiler.EditorServices -/// Translates F# navigable items into the shapes the Copilot chat "#" mention picker understands. -module internal CopilotSymbolMapping = - - /// Name of the context member. It becomes the mention prefix the user sees and re-types, - /// as in "#fsharpSymbol:Namespace.Type.Member". - [] - let SymbolMember = "fsharpSymbol" - - [] - let FullyQualifiedNameInput = "fullyQualifiedName" - - /// The parse tree cannot tell an interface, struct or record apart from a plain class, so every - /// type-like declaration is reported as a class. - let symbolContextType kind = - match kind with - | NavigableItemKind.Module - | NavigableItemKind.ModuleAbbreviation - | NavigableItemKind.Exception - | NavigableItemKind.Type -> CopilotSymbolContextType.Class - | NavigableItemKind.ModuleValue -> CopilotSymbolContextType.Function - | NavigableItemKind.Field - | NavigableItemKind.Property -> CopilotSymbolContextType.Field - | NavigableItemKind.Constructor - | NavigableItemKind.Member -> CopilotSymbolContextType.Method - | NavigableItemKind.EnumCase -> CopilotSymbolContextType.Constant - | NavigableItemKind.UnionCase -> CopilotSymbolContextType.Union - - let private imageId kind = - match kind with - | NavigableItemKind.Module - | NavigableItemKind.ModuleAbbreviation -> KnownImageIds.ModulePublic - | NavigableItemKind.Exception -> KnownImageIds.ExceptionPublic - | NavigableItemKind.Type -> KnownImageIds.ClassPublic - | NavigableItemKind.ModuleValue - | NavigableItemKind.Constructor - | NavigableItemKind.Member -> KnownImageIds.MethodPublic - | NavigableItemKind.Field -> KnownImageIds.FieldPublic - | NavigableItemKind.Property -> KnownImageIds.PropertyPublic - | NavigableItemKind.EnumCase - | NavigableItemKind.UnionCase -> KnownImageIds.EnumerationItemPublic - - let icon kind = - let mutable moniker = CopilotImageMoniker() - moniker.Guid <- KnownImageIds.ImageCatalogGuid - moniker.Id <- imageId kind - moniker - - /// Dotted path that both drives the picker's pattern matching and identifies a picked mention - /// when it is resolved back to source. - let fullyQualifiedName (item: NavigableItem) = - match item.Container.FullName with - | "" -> item.Name - | container -> $"{container}.{item.Name}" +/// Name of the context member. It becomes the mention prefix the user sees and re-types, +/// as in "#fsharpSymbol:Namespace.Type.Member". +[] +let SymbolMember = "fsharpSymbol" + +[] +let FullyQualifiedNameInput = "fullyQualifiedName" + +/// The parse tree cannot tell an interface, struct or record apart from a plain class, so every +/// type-like declaration is reported as a class. +let symbolContextType kind = + match kind with + | NavigableItemKind.Module + | NavigableItemKind.ModuleAbbreviation + | NavigableItemKind.Exception + | NavigableItemKind.Type -> CopilotSymbolContextType.Class + | NavigableItemKind.ModuleValue -> CopilotSymbolContextType.Function + | NavigableItemKind.Field + | NavigableItemKind.Property -> CopilotSymbolContextType.Field + | NavigableItemKind.Constructor + | NavigableItemKind.Member -> CopilotSymbolContextType.Method + | NavigableItemKind.EnumCase -> CopilotSymbolContextType.Constant + | NavigableItemKind.UnionCase -> CopilotSymbolContextType.Union + +let private imageId kind = + match kind with + | NavigableItemKind.Module + | NavigableItemKind.ModuleAbbreviation -> KnownImageIds.ModulePublic + | NavigableItemKind.Exception -> KnownImageIds.ExceptionPublic + | NavigableItemKind.Type -> KnownImageIds.ClassPublic + | NavigableItemKind.ModuleValue + | NavigableItemKind.Constructor + | NavigableItemKind.Member -> KnownImageIds.MethodPublic + | NavigableItemKind.Field -> KnownImageIds.FieldPublic + | NavigableItemKind.Property -> KnownImageIds.PropertyPublic + | NavigableItemKind.EnumCase + | NavigableItemKind.UnionCase -> KnownImageIds.EnumerationItemPublic + +let icon kind = + CopilotImageMoniker(Guid = KnownImageIds.ImageCatalogGuid, Id = imageId kind) + +/// Dotted path that both drives the picker's pattern matching and identifies a picked mention +/// when it is resolved back to source. +let fullyQualifiedName (item: NavigableItem) = + match item.Container.FullName with + | "" -> item.Name + | container -> $"{container}.{item.Name}" diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs index a3c2b4b58e1..b2bb73958df 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs @@ -1,40 +1,38 @@ // Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. -namespace Microsoft.VisualStudio.FSharp.Editor +/// Widens the identifier range of a navigable item to the declaration a reader would recognise. +module internal Microsoft.VisualStudio.FSharp.Editor.CopilotSymbolSnippets open FSharp.Compiler.EditorServices -/// Widens the identifier range of a navigable item to the declaration a reader would recognise. -module internal CopilotSymbolSnippets = - - /// A module scope can span a whole file, which is more than a chat prompt can usefully carry. - [] - let MaxSnippetLines = 200 - - /// Inclusive, 1-based line bounds of the declaration `item` names, including its doc comment. - let definitionLines (scopes: Structure.ScopeRange seq) (item: NavigableItem) = - let declarationLine = item.Range.StartLine - - // A construct's outlining range reaches back over the doc comment in front of it, so it is the - // collapse range - the body proper - that tells which construct is declared on this line. - let declaredHere (scope: Structure.ScopeRange) = - scope.CollapseRange.StartLine = declarationLine - && scope.Range.EndLine >= item.Range.EndLine - && scope.Scope <> Structure.Scope.Comment - && scope.Scope <> Structure.Scope.XmlDocComment - - let mutable widest = ValueNone - - for scope in scopes do - if declaredHere scope then - match widest with - | ValueSome(previous: Structure.ScopeRange) when previous.Range.EndLine >= scope.Range.EndLine -> () - | _ -> widest <- ValueSome scope - - // A one-line member declares no scope of its own; it stands for itself rather than for the type around it. - let firstLine, lastLine = +/// A module scope can span a whole file, which is more than a chat prompt can usefully carry. +[] +let MaxSnippetLines = 200 + +/// Inclusive, 1-based line bounds of the declaration `item` names, including its doc comment. +let definitionLines (scopes: Structure.ScopeRange seq) (item: NavigableItem) = + let declarationLine = item.Range.StartLine + + // A construct's outlining range reaches back over the doc comment in front of it, so it is the + // collapse range - the body proper - that tells which construct is declared on this line. + let declaredHere (scope: Structure.ScopeRange) = + scope.CollapseRange.StartLine = declarationLine + && scope.Range.EndLine >= item.Range.EndLine + && scope.Scope <> Structure.Scope.Comment + && scope.Scope <> Structure.Scope.XmlDocComment + + let mutable widest = ValueNone + + for scope in scopes do + if declaredHere scope then match widest with - | ValueSome scope -> scope.Range.StartLine, scope.Range.EndLine - | ValueNone -> declarationLine, item.Range.EndLine + | ValueSome(previous: Structure.ScopeRange) when previous.Range.EndLine >= scope.Range.EndLine -> () + | _ -> widest <- ValueSome scope + + // A one-line member declares no scope of its own; it stands for itself rather than for the type around it. + let firstLine, lastLine = + match widest with + | ValueSome scope -> scope.Range.StartLine, scope.Range.EndLine + | ValueNone -> declarationLine, item.Range.EndLine - firstLine, min lastLine (firstLine + MaxSnippetLines - 1) + struct (firstLine, min lastLine (firstLine + MaxSnippetLines - 1)) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs index 1fb9e9a6b66..08921a63581 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs @@ -10,10 +10,9 @@ open System.Threading.Tasks open System.IO open System.Collections.Immutable open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.ExternalAccess.FSharp +open Microsoft.CodeAnalysis.Host.Mef open Microsoft.CodeAnalysis.Options -open FSharp.Compiler -open FSharp.Compiler.CodeAnalysis -open FSharp.NativeInterop open Microsoft.ServiceHub.Framework open Microsoft.VisualStudio open Microsoft.VisualStudio.Copilot @@ -25,12 +24,13 @@ open Microsoft.VisualStudio.Shell open Microsoft.VisualStudio.Shell.Interop open Microsoft.VisualStudio.Shell.ServiceBroker open Microsoft.VisualStudio.Text.Outlining -open Microsoft.CodeAnalysis.ExternalAccess.FSharp -open Microsoft.CodeAnalysis.Host.Mef +open Microsoft.VisualStudio.Editor open Microsoft.VisualStudio.FSharp.Editor.Telemetry -open CancellableTasks +open FSharp.Compiler +open FSharp.Compiler.CodeAnalysis +open FSharp.NativeInterop open FSharp.Compiler.Text -open Microsoft.VisualStudio.Editor +open CancellableTasks #nowarn "9" // NativePtr.toNativeInt #nowarn "57" // Experimental stuff diff --git a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs index b3273e36a19..75a349040b2 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs @@ -7,8 +7,9 @@ open System.IO open System.Composition open System.Collections.Immutable open System.Collections.Concurrent -open System.Threading.Tasks open System.Globalization +open System.Linq +open System.Threading.Tasks open Microsoft.CodeAnalysis open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Navigation @@ -26,14 +27,16 @@ type internal FSharpNavigableItemsCache [] (patternMatcherFactory: IPatternMatcherFactory, [] workspace: VisualStudioWorkspace) = - let cache = ConcurrentDictionary() + let cache = + ConcurrentDictionary() do - if workspace <> null then - workspace.WorkspaceChanged.Add - <| fun e -> + match workspace with + | null -> () + | workspace -> + workspace.WorkspaceChanged.Add(fun e -> if e.NewSolution.Id <> e.OldSolution.Id then - cache.Clear() + cache.Clear()) member _.GetNavigableItems(document: Document) = cancellableTask { @@ -41,11 +44,11 @@ type internal FSharpNavigableItemsCache let! currentVersion = document.GetTextVersionAsync(ct) match cache.TryGetValue document.Id with - | true, (version, items) when version = currentVersion -> return items + | true, struct (version, items) when version = currentVersion -> return items | _ -> let! parseResults = document.GetFSharpParseResultsAsync(nameof (FSharpNavigableItemsCache)) let items = NavigateTo.GetNavigableItems parseResults.ParseTree - cache[document.Id] <- currentVersion, items + cache[document.Id] <- struct (currentVersion, items) return items } @@ -162,7 +165,7 @@ type internal FSharpNavigateToSearchService [] (itemsCache let! items = getNavigableItems document let processed = - [| + seq { for item in items do let contains = kinds.Contains(navigateToItemKindToRoslynKind item.Kind) let patternMatch = tryMatch item @@ -192,9 +195,9 @@ type internal FSharpNavigateToSearchService [] (itemsCache ) ) | _ -> () - |] + } - return processed + return processed |> Seq.toImmutableArray } interface IFSharpNavigateToSearchService with @@ -204,31 +207,17 @@ type internal FSharpNavigateToSearchService [] (itemsCache cancellableTask { let tryMatch = createMatcherFor searchPattern - let tasks = - [| - for doc in project.Documents do - yield processDocument tryMatch kinds doc - |] - - let! results = CancellableTask.whenAll tasks - - let results' = ImmutableArray.CreateBuilder() - - for navResults in results do - for navResult in navResults do - results'.Add navResult - - return results'.ToImmutable() + let! results = + project.Documents + |> Seq.map (processDocument tryMatch kinds) + |> CancellableTask.whenAll + return results |> Seq.collect _.AsEnumerable() |> Seq.toImmutableArray } |> CancellableTask.start cancellationToken member _.SearchDocumentAsync(document: Document, searchPattern, kinds, cancellationToken) = - cancellableTask { - let! result = processDocument (createMatcherFor searchPattern) kinds document - return Array.toImmutableArray result - } - |> CancellableTask.start cancellationToken + processDocument (createMatcherFor searchPattern) kinds document cancellationToken member _.KindsProvided = kindsProvided From 18a8d27a5f8d50142df57f81ebda763d8fcb6d8e Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Tue, 1 Sep 2026 00:54:58 +0200 Subject: [PATCH 03/31] Link the Copilot mention picker release note to its PR Co-Authored-By: Claude Fable 5 --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 0454ae7e3e8..f0a8e008989 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -5,6 +5,7 @@ * 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)) * Added a **Tools > Options > F# Tools > Compiler** option, **"Use the .NET SDK F# compiler for builds"**, on by default. Visual Studio uses the .NET SDK F# compiler when the project supplies SDK paths, matching `dotnet build`. Otherwise, it uses the bundled .NET Framework compiler. Turn the option off to use the bundled compiler for all projects. Changes are saved on Apply or OK; Cancel discards unsaved changes. Command-line builds are unaffected. ([Issue #20484](https://github.com/dotnet/fsharp/issues/20484), [PR #20485](https://github.com/dotnet/fsharp/pull/20485)) * F# types, modules, members and values now appear in the GitHub Copilot Chat `#` mention picker, and attach their declaration source as context. +* F# types, modules, members and values now appear in the GitHub Copilot Chat `#` mention picker, and attach their declaration source as context. ([PR #20409](https://github.com/dotnet/fsharp/pull/20409)) ### Fixed From e0e94db441efcd773a146f2016eecd1c3433cd8d Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 3 Sep 2026 19:24:32 +0200 Subject: [PATCH 04/31] Harden Copilot provider registration and widen one-line snippets Package load runs its tasks back to back on a single loop, so an exception from the Copilot registration task escaped into F# package load. A Copilot contract version the installed build does not serve would have taken the whole package down; catch and log instead, leaving cancellation alone. A doc comment is only reported as an outlining scope once it spans several lines, so a one-line "///" in front of a declaration was invisible to the scope search and dropped from the snippet. Walk back over the preceding "///" lines directly. Batch mention queries scanned the solution once per query, serially. Distinct search texts now scan concurrently and repeated ones share a single scan. The snippet-location test asserted a hardcoded "C:\test.fs" rather than asking the solution where its document lives. Co-Authored-By: Claude Fable 5.1 --- .../Copilot/CopilotContextProvider.fs | 22 ++++---- .../Copilot/CopilotSymbolSnippets.fs | 17 +++++- .../LanguageService/LanguageService.fs | 53 ++++++++++--------- .../CopilotContextProviderTests.fs | 14 ++++- 4 files changed, 70 insertions(+), 36 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs index f63398e8c69..71b6457d83e 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs @@ -138,7 +138,9 @@ module internal CopilotSymbolQuery = Array.init sourceText.Lines.Count (fun line -> sourceText.Lines[line].ToString()) let scopes = Structure.getOutliningRanges sourceLines parseResults.ParseTree - let struct (firstLine, lastLine) = CopilotSymbolSnippets.definitionLines scopes item + + let struct (firstLine, lastLine) = + CopilotSymbolSnippets.definitionLines sourceLines scopes item let firstLine = max 1 firstLine let lastLine = min sourceText.Lines.Count lastLine @@ -243,9 +245,9 @@ type internal FSharpCopilotContextProvider | text -> ValueSome text | _ -> ValueNone - let queryMentions (query: CopilotMentionQuery) = + let mentionsFor (searchText: string voption) = cancellableTask { - match workspace, searchTextOf query with + match workspace, searchText with | null, _ | _, ValueNone -> return noMentions | workspace, ValueSome searchText -> @@ -304,7 +306,7 @@ type internal FSharpCopilotContextProvider interface ICopilotMentionQueryable with member _.QueryMentionAsync(query, cancellationToken) : Task> = - queryMentions query |> CancellableTask.start cancellationToken + mentionsFor (searchTextOf query) |> CancellableTask.start cancellationToken member _.NavigateToMentionableAsync(mention, cancellationToken) : Task = match workspace, fullyQualifiedNameOf mention.Inputs with @@ -334,15 +336,15 @@ type internal FSharpCopilotContextProvider |> CancellableTask.start cancellationToken // Copilot's own picker providers answer through the batch interface, one result collection per query. + // Each distinct search text scans the solution once, and the scans run side by side. interface ICopilotMentionBatchQueryable with member _.QueryMentionBatchAsync(queries, cancellationToken) : Task>> = cancellableTask { - let results = ResizeArray queries.Count - - for query in queries do - let! mentions = queryMentions query - results.Add mentions + let searchTexts = queries |> Seq.map searchTextOf |> Seq.toArray + let distinct = Array.distinct searchTexts + let! mentions = distinct |> Array.map mentionsFor |> CancellableTask.whenAll + let byText = Array.zip distinct mentions |> dict - return results :> IReadOnlyList> + return searchTexts |> Array.map (fun text -> byText[text]) :> IReadOnlyList> } |> CancellableTask.start cancellationToken diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs index b2bb73958df..59c98c3fd21 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs @@ -3,6 +3,8 @@ /// Widens the identifier range of a navigable item to the declaration a reader would recognise. module internal Microsoft.VisualStudio.FSharp.Editor.CopilotSymbolSnippets +open System + open FSharp.Compiler.EditorServices /// A module scope can span a whole file, which is more than a chat prompt can usefully carry. @@ -10,7 +12,7 @@ open FSharp.Compiler.EditorServices let MaxSnippetLines = 200 /// Inclusive, 1-based line bounds of the declaration `item` names, including its doc comment. -let definitionLines (scopes: Structure.ScopeRange seq) (item: NavigableItem) = +let definitionLines (sourceLines: string array) (scopes: Structure.ScopeRange seq) (item: NavigableItem) = let declarationLine = item.Range.StartLine // A construct's outlining range reaches back over the doc comment in front of it, so it is the @@ -35,4 +37,17 @@ let definitionLines (scopes: Structure.ScopeRange seq) (item: NavigableItem) = | ValueSome scope -> scope.Range.StartLine, scope.Range.EndLine | ValueNone -> declarationLine, item.Range.EndLine + // Outlining reports a doc comment only once it spans several lines, so a one-line "///" in front of + // a declaration is invisible to the scopes above. + let isDocComment line = + sourceLines[line - 1].TrimStart().StartsWith("///", StringComparison.Ordinal) + + let rec docCommentStart line = + if line > 1 && isDocComment (line - 1) then + docCommentStart (line - 1) + else + line + + let firstLine = docCommentStart firstLine + struct (firstLine, min lastLine (firstLine + MaxSnippetLines - 1)) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs index 08921a63581..80b91e23d36 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs @@ -420,30 +420,35 @@ type internal FSharpPackage() as this = false, fun _ cancellationToken -> task { - let! container = this.GetServiceAsync(typeof) - - match container with - | :? IBrokeredServiceContainer as container -> - // The Interactions service also serves the registration interface. It is absent when - // GitHub Copilot is not installed, in which case the proxy is null and F# stays out of the picker. - let! registration = - container - .GetFullAccessServiceBroker() - .GetProxyAsync(CopilotDescriptors.InteractionService, cancellationToken) - - use registration = registration - - match registration with - | null -> () - | registration -> - let moniker = - ServiceMoniker( - FSharpConstants.copilotSymbolProviderName, - Version CopilotDescriptors.CurrentContextProviderVersion - ) - - do! registration.RegisterContextProviderAsync(moniker, cancellationToken) - | _ -> () + try + let! container = this.GetServiceAsync(typeof) + + match container with + | :? IBrokeredServiceContainer as container -> + // The Interactions service also serves the registration interface. It is absent when + // GitHub Copilot is not installed, in which case the proxy is null and F# stays out of the picker. + let! registration = + container + .GetFullAccessServiceBroker() + .GetProxyAsync(CopilotDescriptors.InteractionService, cancellationToken) + + use registration = registration + + match registration with + | null -> () + | registration -> + let moniker = + ServiceMoniker( + FSharpConstants.copilotSymbolProviderName, + Version CopilotDescriptors.CurrentContextProviderVersion + ) + + do! registration.RegisterContextProviderAsync(moniker, cancellationToken) + | _ -> () + // Package load runs its tasks back to back on one loop, so a Copilot failure - a contract + // version the installed build does not serve, say - must not take the F# package down with it. + with ex when not (ex :? OperationCanceledException) -> + DebugHelpers.FSharpOutputPane.logExceptionWithContext (ex, "Registering the Copilot context provider") } :> Task ) diff --git a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs index cd20c8e2d4d..f7fc6966b52 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs @@ -36,6 +36,9 @@ let describeShape shape = match shape with | Circle r -> $"circle {r}" | Square s -> $"square {s}" + +/// Twice the value. +let twice x = x * 2 """ let solution = RoslynTestHelpers.CreateSolution fileContents @@ -92,6 +95,14 @@ let describeShape shape = Assert.Contains("value <- value + 1", context.Snippet) Assert.DoesNotContain("type Counter", context.Snippet) + [] + let ``a one-line declaration keeps its doc comment`` () = + let context = contextOf "Widgets.twice" + + Assert.Contains("Twice the value.", context.Snippet) + Assert.Contains("let twice x", context.Snippet) + Assert.DoesNotContain("describeShape", context.Snippet) + [] [] [] @@ -105,6 +116,7 @@ let describeShape shape = let ``a context points back at the source it was taken from`` () = let context = contextOf "Widgets.Counter" let location = Assert.Single context.SnippetLocations + let document = solution.Projects |> Seq.exactlyOne |> _.Documents |> Seq.exactlyOne - Assert.Equal("C:\\test.fs", location.FilePath) + Assert.Equal(document.FilePath, location.FilePath) Assert.Equal(context.Snippet.Length, location.Span.Length) From ff1df3a676fe0690d462036a9f4b64eaae0dc84b Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 3 Sep 2026 19:37:43 +0200 Subject: [PATCH 05/31] Match declaration names over spans instead of building them Resolving a picked mention walks every declaration in every document of the solution, and asked each one for its dotted path as a fresh string purely to compare it. Compare against the container and name in place instead, so the scan allocates nothing per declaration. The doc-comment probe trimmed each candidate line into a new string for the same reason. Co-Authored-By: Claude Fable 5.1 --- .../Copilot/CopilotContextProvider.fs | 4 +--- .../Copilot/CopilotSymbolMapping.fs | 17 +++++++++++++++++ .../Copilot/CopilotSymbolSnippets.fs | 2 +- .../CopilotContextProviderTests.fs | 19 +++++++++++++++++++ 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs index 71b6457d83e..7a5da42a1a2 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs @@ -99,9 +99,7 @@ module internal CopilotSymbolQuery = return items |> Seq.chooseV (fun item -> - if - String.Equals(CopilotSymbolMapping.fullyQualifiedName item, fullyQualifiedName, StringComparison.Ordinal) - then + if CopilotSymbolMapping.hasFullyQualifiedName fullyQualifiedName item then ValueSome struct (item, document) else ValueNone) diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs index a23d7aab14d..21e296c0476 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs @@ -3,6 +3,8 @@ /// Translates F# navigable items into the shapes the Copilot chat "#" mention picker understands. module internal Microsoft.VisualStudio.FSharp.Editor.CopilotSymbolMapping +open System + open Microsoft.VisualStudio.Copilot open Microsoft.VisualStudio.Imaging @@ -55,3 +57,18 @@ let fullyQualifiedName (item: NavigableItem) = match item.Container.FullName with | "" -> item.Name | container -> $"{container}.{item.Name}" + +/// Answers what comparing against `fullyQualifiedName` would, without building the dotted path - +/// a solution-wide scan asks this of every declaration it walks past. +let hasFullyQualifiedName (candidate: string) (item: NavigableItem) = + let candidate = candidate.AsSpan() + let container = item.Container.FullName + let name = item.Name.AsSpan() + + if container.Length = 0 then + candidate.Equals(name, StringComparison.Ordinal) + else + candidate.Length = container.Length + 1 + name.Length + && candidate[container.Length] = '.' + && candidate.Slice(0, container.Length).Equals(container.AsSpan(), StringComparison.Ordinal) + && candidate.Slice(container.Length + 1).Equals(name, StringComparison.Ordinal) diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs index 59c98c3fd21..b31d9c192bc 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs @@ -40,7 +40,7 @@ let definitionLines (sourceLines: string array) (scopes: Structure.ScopeRange se // Outlining reports a doc comment only once it spans several lines, so a one-line "///" in front of // a declaration is invisible to the scopes above. let isDocComment line = - sourceLines[line - 1].TrimStart().StartsWith("///", StringComparison.Ordinal) + sourceLines[line - 1].AsSpan().TrimStart().StartsWith("///".AsSpan(), StringComparison.Ordinal) let rec docCommentStart line = if line > 1 && isDocComment (line - 1) then diff --git a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs index f7fc6966b52..c7ad810ab4a 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs @@ -70,6 +70,25 @@ let twice x = x * 2 let ``search finds a declaration by its fully qualified name`` (pattern: string, expected: string) = Assert.Contains(expected, search pattern) + [] + [] + [] + [] + [] + [] + [] + let ``a name matches only the declaration it spells out`` (candidate: string, expected: bool) = + let item = + CopilotSymbolQuery.search cache solution "Counter" + |> run + |> Array.pick (fun (struct (item, _)) -> + if CopilotSymbolMapping.fullyQualifiedName item = "Widgets.Counter" then + Some item + else + None) + + Assert.Equal(expected, CopilotSymbolMapping.hasFullyQualifiedName candidate item) + [] let ``search reports each declaration once`` () = let names = search "Counter" From 85a19e216e01e3643b2b7ca9ad46ab26d9c21682 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 3 Sep 2026 22:01:34 +0200 Subject: [PATCH 06/31] Slice source text instead of copying it line by line for outlining Every consumer of Structure.getOutliningRanges built its sourceLines array by calling ToString() per line, allocating a fresh string for the entire file on every outlining pass - once per keystroke for the editor's block structure, and once per resolved Copilot mention for the snippet extent. getOutliningRanges now takes ReadOnlyMemory[] and slices the already-materialized source text once (SourceText.GetLinesAsMemory()) instead. ReadOnlySpanCharExtensions in illib mirrors the existing Ordinal string helpers so span call sites read the same way string call sites do. A local recursive function closing over a ReadOnlySpan-typed sibling cannot be compiled - the CLR disallows instantiating FSharpFunc, _> as a closure field (FS0412) - so commentTypeOf moves to module scope, next to the CommentType it classifies. StructureTests.fs slices its own lines the same way at the call site, and FSharp.Compiler.Service.Tests needs a direct System.Memory PackageReference: FSharp.Compiler.Service's own reference to it is only transitive through the net472 ProjectReference's SetTargetFramework override, mirroring the FSharp.Core pin already in this project for the same reason. Co-Authored-By: Claude Fable 5.1 --- .../.FSharp.Compiler.Service/11.0.100.md | 1 + src/Compiler/Service/ServiceStructure.fs | 61 ++++++++++--------- src/Compiler/Service/ServiceStructure.fsi | 3 +- src/Compiler/Utilities/illib.fs | 49 +++++++++++++++ src/Compiler/Utilities/illib.fsi | 42 +++++++++++++ ...iler.Service.SurfaceArea.netstandard20.bsl | 2 +- .../FSharp.Compiler.Service.Tests.fsproj | 6 ++ .../StructureTests.fs | 3 +- .../src/FSharp.Editor/Common/Extensions.fs | 8 +++ .../Copilot/CopilotContextProvider.fs | 3 +- .../Copilot/CopilotSymbolSnippets.fs | 4 +- .../Structure/BlockStructureService.fs | 2 +- 12 files changed, 148 insertions(+), 36 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md index f383908e195..f18745054b6 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md @@ -255,3 +255,4 @@ * `FSharp.Compiler.Syntax.SynComponentInfo` now holds the type name as `synType: SynType option` instead of the previous `longId: LongIdent` field, so tuple-type extensions such as `type ('T1 * 'T2) with` can be represented. A `member LongIdent` compatibility property returns the long identifier for named types and an empty list for tuple or erroneous type names. AST consumers that pattern-matched on the `longId` field must switch to the `synType` field or the `LongIdent` member. ([PR #19602](https://github.com/dotnet/fsharp/pull/19602)) * Optimizer: don't inline named functions in debug builds ([PR #19548](https://github.com/dotnet/fsharp/pull/19548) * LexFilter: drop non-strict mode ([PR #20106](https://github.com/dotnet/fsharp/pull/20106)) +* `FSharp.Compiler.EditorServices.Structure.getOutliningRanges` now takes the source lines as `ReadOnlyMemory[]` instead of `string[]`, so a caller that already holds the whole text can slice it instead of building a string per line. Callers passing a `string[]` can migrate with `Array.map (fun line -> line.AsMemory())`. diff --git a/src/Compiler/Service/ServiceStructure.fs b/src/Compiler/Service/ServiceStructure.fs index 99dd528eeb0..f686d9ec452 100644 --- a/src/Compiler/Service/ServiceStructure.fs +++ b/src/Compiler/Service/ServiceStructure.fs @@ -2,6 +2,7 @@ namespace FSharp.Compiler.EditorServices +open System open Internal.Utilities.Library open FSharp.Compiler.Syntax open FSharp.Compiler.SyntaxTreeOps @@ -186,12 +187,21 @@ module Structure = } type LineNumber = int - type LineStr = string + type LineStr = ReadOnlyMemory type CommentType = | SingleLine | XmlDoc + /// Determine if a line is a single line or xml documentation comment. + /// Kept at module scope: a local recursive function capturing a `ReadOnlySpan`-typed + /// helper as a closure field would need to instantiate `FSharpFunc, _>`, + /// which the CLR disallows for byref-like type arguments (FS0412). + let commentTypeOf (line: ReadOnlySpan) = + if line.StartsWithOrdinal("///") then ValueSome XmlDoc + elif line.StartsWithOrdinal("//") then ValueSome SingleLine + else ValueNone + [] type CommentList = { @@ -206,7 +216,7 @@ module Structure = } /// Returns outlining ranges for given parsed input. - let getOutliningRanges (sourceLines: string[]) (parsedInput: ParsedInput) = + let getOutliningRanges (sourceLines: ReadOnlyMemory[]) (parsedInput: ParsedInput) = let acc = ResizeArray() /// Validation function to ensure that ranges yielded for outlining span 2 or more lines @@ -661,7 +671,7 @@ module Structure = | r :: rest, last :: _ when r.StartLine = last.EndLine + 1 || sourceLines[last.EndLine .. r.StartLine - 2] - |> Array.forall System.String.IsNullOrWhiteSpace + |> Array.forall (fun line -> line.Span.IsWhiteSpace()) -> loop rest res (r :: currentBulk) | r :: rest, _ -> loop rest (currentBulk :: res) [ r ] @@ -719,7 +729,7 @@ module Structure = let collectConditionalDirectives directives sourceLines = // Adds a fold region from prevRange.Start to the line above nextLine - let addSectionFold (prevRange: range) (nextLine: int) (sourceLines: string array) = + let addSectionFold (prevRange: range) (nextLine: int) (sourceLines: ReadOnlyMemory[]) = let startLineIndex = nextLine - 2 if startLineIndex >= 0 then @@ -753,7 +763,7 @@ module Structure = | ConditionalDirectiveTrivia.Else r -> ValueSome r | _ -> ValueNone - let rec group directives stack (sourceLines: string array) = + let rec group directives stack (sourceLines: ReadOnlyMemory[]) = match directives with | [] -> () | ConditionalDirectiveTrivia.If _ as ifDirective :: directives -> group directives (ifDirective :: stack) sourceLines @@ -822,19 +832,15 @@ module Structure = collectOpens decls List.iter parseDeclaration decls - /// Determine if a line is a single line or xml documentation comment - let (|Comment|_|) (line: string) = - if line.StartsWithOrdinal("///") then Some XmlDoc - elif line.StartsWithOrdinal("//") then Some SingleLine - else None - - let getCommentRanges trivia (lines: string[]) = - let rec loop (lastLineNum, currentComment, result as state) (lines: string list) lineNum = - match lines with - | [] -> state - | lineStr :: rest -> - match lineStr.TrimStart(), currentComment with - | Comment commentType, Some comment -> + let getCommentRanges trivia (lines: ReadOnlyMemory[]) = + let rec loop (lastLineNum, currentComment, result as state) lineNum = + if lineNum = lines.Length then + state + else + let lineStr = lines[lineNum] + + match commentTypeOf (lineStr.Span.TrimStart()), currentComment with + | ValueSome commentType, Some comment -> loop (if comment.Type = commentType && lineNum = lastLineNum + 1 then comment.Lines.Add(lineNum, lineStr) @@ -842,16 +848,15 @@ module Structure = else let comments = CommentList.New commentType (lineNum, lineStr) lineNum, Some comments, comment :: result) - rest (lineNum + 1) - | Comment commentType, None -> + | ValueSome commentType, None -> let comments = CommentList.New commentType (lineNum, lineStr) - loop (lineNum, Some comments, result) rest (lineNum + 1) - | _, Some comment -> loop (lineNum, None, comment :: result) rest (lineNum + 1) - | _ -> loop (lineNum, None, result) rest (lineNum + 1) + loop (lineNum, Some comments, result) (lineNum + 1) + | ValueNone, Some comment -> loop (lineNum, None, comment :: result) (lineNum + 1) + | ValueNone, None -> loop (lineNum, None, result) (lineNum + 1) let comments = - let _, lastComment, comments = loop (-1, None, []) (List.ofArray lines) 0 + let _, lastComment, comments = loop (-1, None, []) 0 match lastComment with | Some comment -> comment :: comments @@ -859,13 +864,13 @@ module Structure = |> List.rev comments - |> List.filter (fun comment -> comment.Lines.Count > 1) - |> List.map (fun comment -> + |> Seq.filter (fun comment -> comment.Lines.Count > 1) + |> Seq.map (fun comment -> let lines = comment.Lines let startLine, startStr = lines[0] let endLine, endStr = lines[lines.Count - 1] - let startCol = startStr.IndexOf '/' - let endCol = endStr.TrimEnd().Length + let startCol = startStr.Span.IndexOf '/' + let endCol = endStr.Span.TrimEnd().Length let scopeType = match comment.Type with diff --git a/src/Compiler/Service/ServiceStructure.fsi b/src/Compiler/Service/ServiceStructure.fsi index 87711629676..3695e7148ac 100644 --- a/src/Compiler/Service/ServiceStructure.fsi +++ b/src/Compiler/Service/ServiceStructure.fsi @@ -2,6 +2,7 @@ namespace FSharp.Compiler.EditorServices +open System open FSharp.Compiler.Syntax open FSharp.Compiler.Text @@ -79,4 +80,4 @@ module public Structure = } /// Returns outlining ranges for given parsed input. - val getOutliningRanges: sourceLines: string[] -> parsedInput: ParsedInput -> seq + val getOutliningRanges: sourceLines: ReadOnlyMemory[] -> parsedInput: ParsedInput -> seq diff --git a/src/Compiler/Utilities/illib.fs b/src/Compiler/Utilities/illib.fs index 7a4f8a76927..95ad29422f5 100644 --- a/src/Compiler/Utilities/illib.fs +++ b/src/Compiler/Utilities/illib.fs @@ -7,6 +7,7 @@ open System.Collections.Generic open System.Collections.Concurrent open System.Diagnostics open System.IO +open System.Linq open System.Threading open System.Threading.Tasks open System.Runtime.CompilerServices @@ -112,6 +113,54 @@ module internal PervasiveAutoOpens = member inline x.IndexOfOrdinal(value: string, startIndex, count) = x.IndexOf(value, startIndex, count, StringComparison.Ordinal) + [] + type ReadOnlySpanCharExtensions = + + static member inline StartsWithOrdinal(str: ReadOnlySpan, value: ReadOnlySpan) = + str.StartsWith(value, StringComparison.Ordinal) + + static member inline StartsWithOrdinal(str: ReadOnlySpan, value: string) = + str.StartsWith(value.AsSpan(), StringComparison.Ordinal) + + static member inline EndsWithOrdinal(str: ReadOnlySpan, value: ReadOnlySpan) = + str.EndsWith(value, StringComparison.Ordinal) + + static member inline EndsWithOrdinal(str: ReadOnlySpan, value: string) = + str.EndsWith(value.AsSpan(), StringComparison.Ordinal) + + static member inline EndsWithOrdinalIgnoreCase(str: ReadOnlySpan, value: ReadOnlySpan) = + str.EndsWith(value, StringComparison.OrdinalIgnoreCase) + + static member inline EndsWithOrdinalIgnoreCase(str: ReadOnlySpan, value: string) = + str.EndsWith(value.AsSpan(), StringComparison.OrdinalIgnoreCase) + + static member IndexOf(str: ReadOnlySpan, value: char) = + let mutable index = -1 + let mutable i = 0 + + while i < str.Length && index = -1 do + if str[i] = value then index <- i else i <- i + 1 + + index + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: ReadOnlySpan) = + str.IndexOf(value, StringComparison.Ordinal) + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: string) = + str.IndexOf(value.AsSpan(), StringComparison.Ordinal) + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: ReadOnlySpan, startIndex) = + str.Slice(startIndex).IndexOf(value, StringComparison.Ordinal) + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: string, startIndex) = + str.Slice(startIndex).IndexOf(value.AsSpan(), StringComparison.Ordinal) + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: ReadOnlySpan, startIndex, count) = + str.Slice(startIndex, count).IndexOf(value, StringComparison.Ordinal) + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: string, startIndex, count) = + str.Slice(startIndex, count).IndexOf(value.AsSpan(), StringComparison.Ordinal) + /// Get an initialization hole let getHole (r: _ ref) = match r.Value with diff --git a/src/Compiler/Utilities/illib.fsi b/src/Compiler/Utilities/illib.fsi index 53dda1ff8a3..1fa49e167de 100644 --- a/src/Compiler/Utilities/illib.fsi +++ b/src/Compiler/Utilities/illib.fsi @@ -68,6 +68,48 @@ module internal PervasiveAutoOpens = member inline IndexOfOrdinal: value: string * startIndex: int * count: int -> int + [] + type ReadOnlySpanCharExtensions = + + [] + static member inline StartsWithOrdinal: str : ReadOnlySpan * value: ReadOnlySpan -> bool + + [] + static member inline StartsWithOrdinal: str : ReadOnlySpan * value: string -> bool + + [] + static member inline EndsWithOrdinal: str : ReadOnlySpan * value: ReadOnlySpan -> bool + + [] + static member inline EndsWithOrdinal: str : ReadOnlySpan * value: string -> bool + + [] + static member inline EndsWithOrdinalIgnoreCase: str : ReadOnlySpan * value: ReadOnlySpan -> bool + + [] + static member inline EndsWithOrdinalIgnoreCase: str : ReadOnlySpan * value: string -> bool + + [] + static member IndexOf: str : ReadOnlySpan * value: char -> int + + [] + static member inline IndexOfOrdinal: str : ReadOnlySpan * value: ReadOnlySpan -> int + + [] + static member inline IndexOfOrdinal: str : ReadOnlySpan * value: string -> int + + [] + static member inline IndexOfOrdinal: str : ReadOnlySpan * value: ReadOnlySpan * startIndex: int -> int + + [] + static member inline IndexOfOrdinal: str : ReadOnlySpan * value: string * startIndex: int -> int + + [] + static member inline IndexOfOrdinal: str : ReadOnlySpan * value: ReadOnlySpan * startIndex: int * count: int -> int + + [] + static member inline IndexOfOrdinal: str : ReadOnlySpan * value: string * startIndex: int * count: int -> int + type Async with /// Runs the computation synchronously, always starting on the current thread. 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 b65bfa60260..dd72786cd0f 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 @@ -4747,7 +4747,7 @@ FSharp.Compiler.EditorServices.Structure+ScopeRange: Void .ctor(Scope, Collapse, FSharp.Compiler.EditorServices.Structure: FSharp.Compiler.EditorServices.Structure+Collapse FSharp.Compiler.EditorServices.Structure: FSharp.Compiler.EditorServices.Structure+Scope FSharp.Compiler.EditorServices.Structure: FSharp.Compiler.EditorServices.Structure+ScopeRange -FSharp.Compiler.EditorServices.Structure: System.Collections.Generic.IEnumerable`1[FSharp.Compiler.EditorServices.Structure+ScopeRange] getOutliningRanges(System.String[], FSharp.Compiler.Syntax.ParsedInput) +FSharp.Compiler.EditorServices.Structure: System.Collections.Generic.IEnumerable`1[FSharp.Compiler.EditorServices.Structure+ScopeRange] getOutliningRanges(System.ReadOnlyMemory`1[System.Char][], FSharp.Compiler.Syntax.ParsedInput) FSharp.Compiler.EditorServices.ToolTipElement+CompositionError: System.String errorText FSharp.Compiler.EditorServices.ToolTipElement+CompositionError: System.String get_errorText() FSharp.Compiler.EditorServices.ToolTipElement+Group: Microsoft.FSharp.Collections.FSharpList`1[FSharp.Compiler.EditorServices.ToolTipElementData] elements diff --git a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj index c8bcf19a133..9ad9e410189 100644 --- a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj +++ b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj @@ -226,4 +226,10 @@ + + + + + diff --git a/tests/FSharp.Compiler.Service.Tests/StructureTests.fs b/tests/FSharp.Compiler.Service.Tests/StructureTests.fs index c0ae0d3fdff..d3bbe73e4b9 100644 --- a/tests/FSharp.Compiler.Service.Tests/StructureTests.fs +++ b/tests/FSharp.Compiler.Service.Tests/StructureTests.fs @@ -1,5 +1,6 @@ module FSharp.Compiler.Service.Tests.StructureTests +open System open System.IO open Xunit open FSharp.Compiler.EditorServices.Structure @@ -35,7 +36,7 @@ let (=>) (source: string) (expectedRanges: (Range * Range) list) = let ast = parseSourceCode(fileName, source) try let actual = - getOutliningRanges lines ast + getOutliningRanges (lines |> Array.map (fun line -> line.AsMemory())) ast |> Seq.filter (fun sr -> sr.Range.StartLine <> sr.Range.EndLine) |> Seq.map (fun sr -> getRange sr.Range, getRange sr.CollapseRange) |> Seq.sort diff --git a/vsintegration/src/FSharp.Editor/Common/Extensions.fs b/vsintegration/src/FSharp.Editor/Common/Extensions.fs index 79ad4e213a0..3d5d6429ac7 100644 --- a/vsintegration/src/FSharp.Editor/Common/Extensions.fs +++ b/vsintegration/src/FSharp.Editor/Common/Extensions.fs @@ -296,6 +296,14 @@ type SourceText with member this.ToFSharpSourceText() = SourceText.weakTable.GetValue(this, Runtime.CompilerServices.ConditionalWeakTable<_, _>.CreateValueCallback(SourceText.create)) + /// The lines of the text, as slices of a single string rather than one string per line. + member this.GetLinesAsMemory() = + let text = this.ToString() + + Array.init this.Lines.Count (fun i -> + let line = this.Lines[i] + text.AsMemory(line.Start, line.End - line.Start)) + type NavigationItem with member x.RoslynGlyph: FSharpRoslynGlyph = diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs index 7a5da42a1a2..73c1e3b166b 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs @@ -132,8 +132,7 @@ module internal CopilotSymbolQuery = let! sourceText = document.GetTextAsync ct let! parseResults = document.GetFSharpParseResultsAsync UserOpName - let sourceLines = - Array.init sourceText.Lines.Count (fun line -> sourceText.Lines[line].ToString()) + let sourceLines = sourceText.GetLinesAsMemory() let scopes = Structure.getOutliningRanges sourceLines parseResults.ParseTree diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs index b31d9c192bc..fe53cac4f7d 100644 --- a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs @@ -12,7 +12,7 @@ open FSharp.Compiler.EditorServices let MaxSnippetLines = 200 /// Inclusive, 1-based line bounds of the declaration `item` names, including its doc comment. -let definitionLines (sourceLines: string array) (scopes: Structure.ScopeRange seq) (item: NavigableItem) = +let definitionLines (sourceLines: ReadOnlyMemory array) (scopes: Structure.ScopeRange seq) (item: NavigableItem) = let declarationLine = item.Range.StartLine // A construct's outlining range reaches back over the doc comment in front of it, so it is the @@ -40,7 +40,7 @@ let definitionLines (sourceLines: string array) (scopes: Structure.ScopeRange se // Outlining reports a doc comment only once it spans several lines, so a one-line "///" in front of // a declaration is invisible to the scopes above. let isDocComment line = - sourceLines[line - 1].AsSpan().TrimStart().StartsWith("///".AsSpan(), StringComparison.Ordinal) + sourceLines[line - 1].Span.TrimStart().StartsWith("///".AsSpan(), StringComparison.Ordinal) let rec docCommentStart line = if line > 1 && isDocComment (line - 1) then diff --git a/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs b/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs index d0326d84311..b087cb56782 100644 --- a/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs +++ b/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs @@ -119,7 +119,7 @@ module internal BlockStructure = let ellipsis = "..." let createBlockSpans isBlockStructureEnabled (sourceText: SourceText) (parsedInput: ParsedInput) = - let linetext = sourceText.Lines |> Seq.map (fun x -> x.ToString()) |> Seq.toArray + let linetext = sourceText.GetLinesAsMemory() Structure.getOutliningRanges linetext parsedInput |> Seq.distinctBy (fun x -> x.Range.StartLine) From a812dc2187442f1e0d2eece962fe7024dffe5fdd Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 3 Sep 2026 22:36:11 +0200 Subject: [PATCH 07/31] Track comment lines by number instead of storing their text CommentList kept a copy of every comment line next to its line number, but the number alone identifies the line in the source array the function already holds, and only the first and last lines of a group are ever read back to compute the fold's columns. Store the numbers and index the source at the end, so grouping comments allocates no tuple per line. Co-Authored-By: Claude Fable 5.1 --- src/Compiler/Service/ServiceStructure.fs | 26 ++-- .../StructureTests.fs | 116 +++++++++--------- 2 files changed, 69 insertions(+), 73 deletions(-) diff --git a/src/Compiler/Service/ServiceStructure.fs b/src/Compiler/Service/ServiceStructure.fs index f686d9ec452..6937981ecec 100644 --- a/src/Compiler/Service/ServiceStructure.fs +++ b/src/Compiler/Service/ServiceStructure.fs @@ -187,7 +187,6 @@ module Structure = } type LineNumber = int - type LineStr = ReadOnlyMemory type CommentType = | SingleLine @@ -205,14 +204,14 @@ module Structure = [] type CommentList = { - Lines: ResizeArray + Lines: ResizeArray Type: CommentType } - static member New ty lineStr = + static member New ty lineNum = { Type = ty - Lines = ResizeArray [ lineStr ] + Lines = ResizeArray [ lineNum ] } /// Returns outlining ranges for given parsed input. @@ -837,20 +836,18 @@ module Structure = if lineNum = lines.Length then state else - let lineStr = lines[lineNum] - - match commentTypeOf (lineStr.Span.TrimStart()), currentComment with + match commentTypeOf (lines[lineNum].Span.TrimStart()), currentComment with | ValueSome commentType, Some comment -> loop (if comment.Type = commentType && lineNum = lastLineNum + 1 then - comment.Lines.Add(lineNum, lineStr) + comment.Lines.Add lineNum lineNum, currentComment, result else - let comments = CommentList.New commentType (lineNum, lineStr) + let comments = CommentList.New commentType lineNum lineNum, Some comments, comment :: result) (lineNum + 1) | ValueSome commentType, None -> - let comments = CommentList.New commentType (lineNum, lineStr) + let comments = CommentList.New commentType lineNum loop (lineNum, Some comments, result) (lineNum + 1) | ValueNone, Some comment -> loop (lineNum, None, comment :: result) (lineNum + 1) | ValueNone, None -> loop (lineNum, None, result) (lineNum + 1) @@ -866,11 +863,10 @@ module Structure = comments |> Seq.filter (fun comment -> comment.Lines.Count > 1) |> Seq.map (fun comment -> - let lines = comment.Lines - let startLine, startStr = lines[0] - let endLine, endStr = lines[lines.Count - 1] - let startCol = startStr.Span.IndexOf '/' - let endCol = endStr.Span.TrimEnd().Length + let startLine = comment.Lines[0] + let endLine = comment.Lines[comment.Lines.Count - 1] + let startCol = lines[startLine].Span.IndexOf '/' + let endCol = lines[endLine].Span.TrimEnd().Length let scopeType = match comment.Type with diff --git a/tests/FSharp.Compiler.Service.Tests/StructureTests.fs b/tests/FSharp.Compiler.Service.Tests/StructureTests.fs index d3bbe73e4b9..322a0fa3bda 100644 --- a/tests/FSharp.Compiler.Service.Tests/StructureTests.fs +++ b/tests/FSharp.Compiler.Service.Tests/StructureTests.fs @@ -36,7 +36,7 @@ let (=>) (source: string) (expectedRanges: (Range * Range) list) = let ast = parseSourceCode(fileName, source) try let actual = - getOutliningRanges (lines |> Array.map (fun line -> line.AsMemory())) ast + getOutliningRanges (lines |> Array.map _.AsMemory()) ast |> Seq.filter (fun sr -> sr.Range.StartLine <> sr.Range.EndLine) |> Seq.map (fun sr -> getRange sr.Range, getRange sr.CollapseRange) |> Seq.sort @@ -152,7 +152,7 @@ module MyModule = // 2 type Color = // 7 { Red: int Green: int - Blue: int + Blue: int } interface IDisposable with // 13 @@ -164,7 +164,7 @@ module MyModule = // 2 type RecordColor = // 19 { Red: int Green: int - Blue: int + Blue: int } interface IDisposable with // 25 @@ -190,31 +190,31 @@ module MyModule = // 2 [] let ``open statements``() = """ -open M -open N - -module M = - let x = 1 - - open M - open N - - module M = - open M - - let x = 1 - - module M = - open M - open N - let x = 1 - -open M -open N -open H - -open G -open H +open M +open N + +module M = + let x = 1 + + open M + open N + + module M = + open M + + let x = 1 + + module M = + open M + open N + let x = 1 + +open M +open N +open H + +open G +open H """ => [ (2, 0, 3, 6), (2, 0, 3, 6) (5, 0, 19, 17), (5, 8, 19, 17) @@ -227,28 +227,28 @@ open H [] let ``hash directives``() = """ -#r @"a" -#r "b" - -#r "c" - -#r "d" -#r "e" -let x = 1 - -#r "f" -#r "g" -#load "x" -#r "y" - -#load "a" - "b" - "c" - -#load "a" - "b" - "c" -#r "d" +#r @"a" +#r "b" + +#r "c" + +#r "d" +#r "e" +let x = 1 + +#r "f" +#r "g" +#load "x" +#r "y" + +#load "a" + "b" + "c" + +#load "a" + "b" + "c" +#r "d" """ => [ (2, 3, 8, 6), (2, 3, 8, 6) (11, 3, 23, 6), (11, 3, 23, 6) ] @@ -326,7 +326,7 @@ seq { // 2 [] let ``list``() = """ -let _ = +let _ = [ 1; 2 3 ] """ @@ -383,7 +383,7 @@ finally // 5 let ``if - then - else``() = """ if true then - let f x = + let f x = () () else @@ -449,7 +449,7 @@ for x = 100 downto 10 do [] let ``for each``() = """ -for x in 0 .. 100 -> +for x in 0 .. 100 -> () () """ @@ -468,7 +468,7 @@ let ``tuple``() = [] let ``do!``() = """ -do! +do! printfn "allo" printfn "allo" """ @@ -478,10 +478,10 @@ do! let ``cexpr yield yield!``() = """ cexpr{ - yield! + yield! cexpr{ - yield - + yield + 10 } } @@ -660,7 +660,7 @@ let ``Abstract members`` () = type T() = abstract Foo: int - + [] abstract Foo: int From 2c4c5502101ffc2eb18123b9b5de741773fb76ec Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Tue, 8 Sep 2026 02:16:14 +0200 Subject: [PATCH 08/31] Take the main thread before registering the Copilot context provider GetProxyAsync is an exported brokered service, so calling it from a background package-load task constructs Copilot's MEF part graph on that thread. Its constructor does a blocking JoinableTask wait for the main thread; meanwhile the Git provider asks for the same proxy from the main thread while building its own services at solution open, and blocks inside MEF's PartLifecycleTracker waiting for the part the background thread owns. Neither side can proceed and Visual Studio hangs permanently. Move the registration out of the background package-load task and into LoadComponentsInBackgroundAfterSolutionFullyLoadedAsync (run after the solution is fully loaded, the way Roslyn's AbstractPackage defers this kind of work), and switch to the main thread before asking for the proxy so the two requesters serialise instead of deadlocking. --- .../LanguageService/LanguageService.fs | 86 +++++++++++-------- 1 file changed, 49 insertions(+), 37 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs index 80b91e23d36..4c62e468593 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs @@ -416,43 +416,6 @@ type internal FSharpPackage() as this = override this.RegisterOnAfterPackageLoadedAsyncWork(afterPackageLoadedTasks: PackageLoadTasks) = base.RegisterOnAfterPackageLoadedAsyncWork(afterPackageLoadedTasks) - afterPackageLoadedTasks.AddTask( - false, - fun _ cancellationToken -> - task { - try - let! container = this.GetServiceAsync(typeof) - - match container with - | :? IBrokeredServiceContainer as container -> - // The Interactions service also serves the registration interface. It is absent when - // GitHub Copilot is not installed, in which case the proxy is null and F# stays out of the picker. - let! registration = - container - .GetFullAccessServiceBroker() - .GetProxyAsync(CopilotDescriptors.InteractionService, cancellationToken) - - use registration = registration - - match registration with - | null -> () - | registration -> - let moniker = - ServiceMoniker( - FSharpConstants.copilotSymbolProviderName, - Version CopilotDescriptors.CurrentContextProviderVersion - ) - - do! registration.RegisterContextProviderAsync(moniker, cancellationToken) - | _ -> () - // Package load runs its tasks back to back on one loop, so a Copilot failure - a contract - // version the installed build does not serve, say - must not take the F# package down with it. - with ex when not (ex :? OperationCanceledException) -> - DebugHelpers.FSharpOutputPane.logExceptionWithContext (ex, "Registering the Copilot context provider") - } - :> Task - ) - #if DEBUG afterPackageLoadedTasks.AddTask( false, @@ -465,6 +428,55 @@ type internal FSharpPackage() as this = ) #endif + /// Copilot's registration service is an exported brokered service whose MEF part constructor blocks waiting + /// for the main thread. Asking for the proxy from a background thread therefore deadlocks against anyone + /// asking for it from the main thread - the Git provider does, while creating its services at solution open - + /// so take the main thread dependency deliberately, the way Roslyn does for a proxy that has one. + member private this.RegisterCopilotContextProviderAsync(cancellationToken: CancellationToken) : Task = + task { + try + do! this.JoinableTaskFactory.SwitchToMainThreadAsync(alwaysYield = true, cancellationToken = cancellationToken) + + let! container = this.GetServiceAsync(typeof) + + match container with + | :? IBrokeredServiceContainer as container -> + // The Interactions service also serves the registration interface. It is absent when + // GitHub Copilot is not installed, in which case the proxy is null and F# stays out of the picker. + let! registration = + container + .GetFullAccessServiceBroker() + .GetProxyAsync(CopilotDescriptors.InteractionService, cancellationToken) + + use registration = registration + + match registration with + | null -> () + | registration -> + let moniker = + ServiceMoniker( + FSharpConstants.copilotSymbolProviderName, + Version CopilotDescriptors.CurrentContextProviderVersion + ) + + do! registration.RegisterContextProviderAsync(moniker, cancellationToken) + | _ -> () + // A Copilot failure - a contract version the installed build does not serve, say - must not take the + // rest of the post-load work down with it. + with ex when not (ex :? OperationCanceledException) -> + DebugHelpers.FSharpOutputPane.logExceptionWithContext (ex, "Registering the Copilot context provider") + } + + override this.LoadComponentsInBackgroundAfterSolutionFullyLoadedAsync(cancellationToken) : Task = + // 'base' cannot be captured by the state machine, so start the base work before entering it. + let baseComponents = + base.LoadComponentsInBackgroundAfterSolutionFullyLoadedAsync(cancellationToken) + + task { + do! baseComponents + do! this.RegisterCopilotContextProviderAsync(cancellationToken) + } + override _.RoslynLanguageName = FSharpConstants.FSharpLanguageName (*override this.CreateWorkspace() = this.ComponentModel.GetService() *) override this.CreateLanguageService() = FSharpLanguageService(this) From cd88846a4d9df721b88fd3d71bf860030e3619f1 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Tue, 8 Sep 2026 02:18:45 +0200 Subject: [PATCH 09/31] Trace Copilot context-provider registration through the output pane Diagnostic aid: on a large solution the "#" mention picker stays empty and nothing in the Debug pane says why. Log each step of RegisterCopilotContextProviderAsync so a hang or an early return (no brokered service container, a null proxy) is visible without a debugger attached. --- .../FSharp.Editor/LanguageService/LanguageService.fs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs index 4c62e468593..dc66bdd613f 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs @@ -435,14 +435,18 @@ type internal FSharpPackage() as this = member private this.RegisterCopilotContextProviderAsync(cancellationToken: CancellationToken) : Task = task { try + DebugHelpers.FSharpOutputPane.logInfo "Copilot: registering context provider (switching to main thread)…" do! this.JoinableTaskFactory.SwitchToMainThreadAsync(alwaysYield = true, cancellationToken = cancellationToken) + DebugHelpers.FSharpOutputPane.logInfo "Copilot: getting brokered service container…" let! container = this.GetServiceAsync(typeof) match container with | :? IBrokeredServiceContainer as container -> // The Interactions service also serves the registration interface. It is absent when // GitHub Copilot is not installed, in which case the proxy is null and F# stays out of the picker. + DebugHelpers.FSharpOutputPane.logInfo "Copilot: getting registration service proxy…" + let! registration = container .GetFullAccessServiceBroker() @@ -451,8 +455,10 @@ type internal FSharpPackage() as this = use registration = registration match registration with - | null -> () + | null -> DebugHelpers.FSharpOutputPane.logInfo "Copilot: service proxy is null (Copilot not installed)" | registration -> + DebugHelpers.FSharpOutputPane.logInfo "Copilot: registering F# context provider…" + let moniker = ServiceMoniker( FSharpConstants.copilotSymbolProviderName, @@ -460,7 +466,8 @@ type internal FSharpPackage() as this = ) do! registration.RegisterContextProviderAsync(moniker, cancellationToken) - | _ -> () + DebugHelpers.FSharpOutputPane.logInfo "Copilot: registration complete" + | _ -> DebugHelpers.FSharpOutputPane.logInfo "Copilot: container is not IBrokeredServiceContainer" // A Copilot failure - a contract version the installed build does not serve, say - must not take the // rest of the post-load work down with it. with ex when not (ex :? OperationCanceledException) -> From cbe5526a78b3500b72118622f3f48142fc7e4199 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 11 Sep 2026 17:32:07 +0200 Subject: [PATCH 10/31] Give the quick parsing options the file they parse With no options from the project system yet, the quick parsing options carried no source files, and ParseFile throws looking for the last compiland. Nothing parsed with them before a project had loaded; the Navigate To search that runs during load does. Co-Authored-By: Claude Opus 5 --- .../LanguageService/FSharpProjectOptionsManager.fs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs b/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs index f5ea528a9e6..4c5a01b6682 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs @@ -663,7 +663,9 @@ type internal FSharpProjectOptionsManager(checker: FSharpChecker, workspace: Wor match reactor.TryGetCachedOptionsByProjectId(documentId.ProjectId) with | Some(_, parsingOptions, _) -> parsingOptions | _ -> + // ParseFile takes the last entry of SourceFiles as the last compiland; with none it throws. { FSharpParsingOptions.Default with + SourceFiles = [| path |] IsInteractive = CompilerEnvironment.IsScriptFile path } From 2daa1d704b69a6b8e6012e38c08936960031c7ad Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Wed, 9 Sep 2026 01:46:43 +0200 Subject: [PATCH 11/31] Answer the Navigate-To search that runs while the solution loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Navigate To runs a search of its own during load, dispatched through `IAdvancedNavigateToSearchService`. F# did not implement it, so every F# project was reported complete and searched not at all, and no full search follows by design — nothing F# declares could be found until the user searched again. The search is a parse away. What it lacked is the project's compilation options, which do not exist yet during load, so `GetFSharpParseResultsAsync` raises. `GetFSharpQuickParseResultsAsync` parses with whatever parsing options the project system has already produced, or defaults: a dictionary read, no reactor, no I/O, which is what makes it safe to call for every document of every project while the solution loads. Those defines can be the wrong ones, and the document's version does not change when the real options arrive, so the version stamp alone would let an approximate parse answer the accurate search: a declaration behind `#if` could be missed, or reported from a branch that never compiles. The cache entry carries whether it was approximate, and the accurate path refuses those, reparsing instead. The loading path takes either, since its contract allows out-of-date results. `SearchCachedDocumentsAsync` follows the shape of the C# and VB service: priority documents and the projects that hold them are searched first, results are reported per document rather than per project so the first ones appear while the rest are still parsing, and each project is reported complete once it is done. The parses of every project take turns on one throttle that leaves a core free, so a search during load cannot take the cores away from the load itself. Co-Authored-By: Claude Opus 5 --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + .../LanguageService/WorkspaceExtensions.fs | 6 + .../Navigation/NavigateToSearchService.fs | 124 ++++++++++++++-- .../FSharp.Editor.Tests.fsproj | 1 + .../NavigateToSearchWhileLoadingTests.fs | 132 ++++++++++++++++++ 5 files changed, 253 insertions(+), 11 deletions(-) create mode 100644 vsintegration/tests/FSharp.Editor.Tests/NavigateToSearchWhileLoadingTests.fs diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index f0a8e008989..b127a0fa191 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -11,6 +11,7 @@ * 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)) +* Navigate To lists F# declarations while the solution is still loading. Until now the search that runs during load skipped F# entirely, and the full search that follows it is never started, so nothing F# declares could be found until the next search. ([PR #20492](https://github.com/dotnet/fsharp/pull/20492)) * Improve Find All References performance by throttling parallel typechecks. ([PR #20128](https://github.com/dotnet/fsharp/pull/20128)) * Fixed Rename incorrectly renaming `get` and `set` keywords for properties with explicit accessors. ([Issue #18270](https://github.com/dotnet/fsharp/issues/18270), [PR #19252](https://github.com/dotnet/fsharp/pull/19252)) * Fixed Find All References crash when F# project contains non-F# files like `.cshtml`. ([Issue #16394](https://github.com/dotnet/fsharp/issues/16394), [PR #19252](https://github.com/dotnet/fsharp/pull/19252)) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/WorkspaceExtensions.fs b/vsintegration/src/FSharp.Editor/LanguageService/WorkspaceExtensions.fs index 2406f3a6e32..0d11a4e6b8d 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/WorkspaceExtensions.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/WorkspaceExtensions.fs @@ -581,6 +581,12 @@ type Document with return! checker.ParseDocument(this, parsingOptions, userOpName) } + /// Parses the given F# document with the parsing options its project has already produced, or with defaults when + /// it has none yet: the only parse available while the project system is still loading. The defines can be the + /// wrong ones, so the tree describes a compilation that may never happen. + member this.GetFSharpQuickParseResultsAsync(userOpName) = + this.GetFSharpChecker().ParseDocument(this, this.GetFSharpQuickParsingOptions(), userOpName) + /// Parses and checks the given F# document. member this.GetFSharpParseAndCheckResultsAsync(userOpName) = cancellableTask { diff --git a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs index 75a349040b2..642ef91e60e 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs @@ -9,6 +9,7 @@ open System.Collections.Immutable open System.Collections.Concurrent open System.Globalization open System.Linq +open System.Threading open System.Threading.Tasks open Microsoft.CodeAnalysis @@ -20,6 +21,16 @@ open Microsoft.VisualStudio.Text.PatternMatching open FSharp.Compiler.EditorServices open CancellableTasks +/// The navigable items of one parse of a document, and the text version it was taken from. +[] +type private NavigableItemsEntry = + { + Version: VersionStamp + /// Parsed without the project's compilation options, while the solution was still loading. + Approximate: bool + Items: NavigableItem array + } + /// Parse-tree navigable items per document, cached on the document's text version. /// Shared by NavigateTo and by the Copilot chat mention provider. [] @@ -27,8 +38,7 @@ type internal FSharpNavigableItemsCache [] (patternMatcherFactory: IPatternMatcherFactory, [] workspace: VisualStudioWorkspace) = - let cache = - ConcurrentDictionary() + let cache = ConcurrentDictionary() do match workspace with @@ -38,18 +48,43 @@ type internal FSharpNavigableItemsCache if e.NewSolution.Id <> e.OldSolution.Id then cache.Clear()) + let store (document: Document) version approximate parseTree = + let items = NavigateTo.GetNavigableItems parseTree + + cache[document.Id] <- + { + Version = version + Approximate = approximate + Items = items + } + + items + member _.GetNavigableItems(document: Document) = cancellableTask { let! ct = CancellableTask.getCancellationToken () let! currentVersion = document.GetTextVersionAsync(ct) match cache.TryGetValue document.Id with - | true, struct (version, items) when version = currentVersion -> return items + | true, entry when entry.Version = currentVersion && not entry.Approximate -> return entry.Items | _ -> let! parseResults = document.GetFSharpParseResultsAsync(nameof (FSharpNavigableItemsCache)) - let items = NavigateTo.GetNavigableItems parseResults.ParseTree - cache[document.Id] <- struct (currentVersion, items) - return items + return store document currentVersion false parseResults.ParseTree + } + + /// The items of a parse that does not wait for the project's compilation options, for the search that runs while + /// the solution is still loading. A file behind `#if` can be read under the wrong defines, so the entry it leaves + /// behind never answers `GetNavigableItems`. + member _.GetNavigableItemsWhileLoading(document: Document) = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + let! currentVersion = document.GetTextVersionAsync(ct) + + match cache.TryGetValue document.Id with + | true, entry when entry.Version = currentVersion -> return entry.Items + | _ -> + let! parseResults = document.GetFSharpQuickParseResultsAsync(nameof (FSharpNavigableItemsCache)) + return store document currentVersion true parseResults.ParseTree } member _.CreateMatcherFor(searchPattern: string) = @@ -83,7 +118,9 @@ type internal FSharpNavigableItemsCache [); Shared>] type internal FSharpNavigateToSearchService [] (itemsCache: FSharpNavigableItemsCache) = - let getNavigableItems (document: Document) = itemsCache.GetNavigableItems document + /// The parses of the search that runs while the solution loads take turns across all its projects, and leave a + /// core to the load itself. + let loadingThrottle = new SemaphoreSlim(max 1 (Environment.ProcessorCount - 1)) let kindsProvided = ImmutableHashSet.Create( @@ -156,13 +193,18 @@ type internal FSharpNavigateToSearchService [] (itemsCache let createMatcherFor (searchPattern: string) = itemsCache.CreateMatcherFor searchPattern - let processDocument (tryMatch: NavigableItem -> PatternMatch voption) (kinds: IImmutableSet) (document: Document) = + let processDocument + (getItems: Document -> CancellableTask) + (tryMatch: NavigableItem -> PatternMatch voption) + (kinds: IImmutableSet) + (document: Document) + = cancellableTask { let! ct = CancellableTask.getCancellationToken () let! sourceText = document.GetTextAsync ct - let! items = getNavigableItems document + let! items = getItems document let processed = seq { @@ -200,6 +242,22 @@ type internal FSharpNavigateToSearchService [] (itemsCache return processed |> Seq.toImmutableArray } + /// Priority items first, each half in its original order, as NavigateTo's own service orders its work. + let prioritize isPriority items = + let priority, rest = items |> Seq.toArray |> Array.partition isPriority + [| yield! priority; yield! rest |] + + let throttled (work: CancellableTask<'a>) = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + do! loadingThrottle.WaitAsync ct + + try + return! work + finally + loadingThrottle.Release() |> ignore + } + interface IFSharpNavigateToSearchService with member _.SearchProjectAsync (project, _priorityDocuments, searchPattern, kinds, cancellationToken) @@ -209,7 +267,7 @@ type internal FSharpNavigateToSearchService [] (itemsCache let! results = project.Documents - |> Seq.map (processDocument tryMatch kinds) + |> Seq.map (processDocument itemsCache.GetNavigableItems tryMatch kinds) |> CancellableTask.whenAll return results |> Seq.collect _.AsEnumerable() |> Seq.toImmutableArray @@ -217,8 +275,52 @@ type internal FSharpNavigateToSearchService [] (itemsCache |> CancellableTask.start cancellationToken member _.SearchDocumentAsync(document: Document, searchPattern, kinds, cancellationToken) = - processDocument (createMatcherFor searchPattern) kinds document cancellationToken + processDocument itemsCache.GetNavigableItems (createMatcherFor searchPattern) kinds document cancellationToken member _.KindsProvided = kindsProvided member _.CanFilter = true + + interface IFSharpAdvancedNavigateToSearchService with + member _.SearchCachedDocumentsAsync + ( + _solution, + projects, + priorityDocuments, + searchPattern, + kinds, + _activeDocument, + onResultsFound, + onProjectCompleted, + cancellationToken + ) : Task = + let tryMatch = createMatcherFor searchPattern + let priorityIds = ImmutableHashSet.CreateRange(priorityDocuments |> Seq.map _.Id) + let isPriority (document: Document) = priorityIds.Contains document.Id + + let searchDocumentWhileLoading document = + cancellableTask { + let! results = throttled (processDocument itemsCache.GetNavigableItemsWhileLoading tryMatch kinds document) + + if results.Length > 0 then + do! onResultsFound.Invoke results + } + + // Every document waits on the throttle in the order it is started, so priority documents, and the + // projects that hold them, are parsed first. + let searchProjectWhileLoading (project: Project) = + cancellableTask { + let! _ = + project.Documents + |> prioritize isPriority + |> Seq.map searchDocumentWhileLoading + |> CancellableTask.whenAll + + do! onProjectCompleted.Invoke() + } + + projects + |> prioritize (fun project -> project.Documents |> Seq.exists isPriority) + |> Seq.map searchProjectWhileLoading + |> CancellableTask.whenAll + |> CancellableTask.startAsTask cancellationToken diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj index 87f071ddbfd..107145ebd80 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj +++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj @@ -33,6 +33,7 @@ + diff --git a/vsintegration/tests/FSharp.Editor.Tests/NavigateToSearchWhileLoadingTests.fs b/vsintegration/tests/FSharp.Editor.Tests/NavigateToSearchWhileLoadingTests.fs new file mode 100644 index 00000000000..0f171ded8ca --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/NavigateToSearchWhileLoadingTests.fs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +/// Navigate To runs a search of its own while the solution is still loading, and nothing it searches +/// then has its compilation options yet. +module FSharp.Editor.Tests.NavigateToSearchWhileLoadingTests + +open System +open System.Collections.Immutable +open System.Threading +open System.Threading.Tasks + +open Xunit + +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.NavigateTo + +open FSharp.Editor.Tests.Helpers + +/// A project the project system has not yet handed its command line options: the state every project +/// of the solution is in until it has loaded. +let private loadingProject name source = + let projectId = ProjectId.CreateNewId() + + [ RoslynTestHelpers.CreateDocumentInfo projectId $"C:\\{name}.fs" source ] + |> RoslynTestHelpers.CreateProjectInfo projectId $"C:\\{name}.fsproj" + +let private loadingSolution source = + let project = loadingProject "test" source + let solution = RoslynTestHelpers.CreateSolution [ project ] + project.Id, solution, solution.Projects |> Seq.exactlyOne + +/// One export provider per test: the navigable items are cached in a shared one. +let private searchServices () = + let service: IFSharpNavigateToSearchService = + MefHelpers.createExportProvider().GetExportedValue() + + service, service :?> IFSharpAdvancedNavigateToSearchService + +let private namesFound (results: ImmutableArray) = results |> Seq.map _.Name |> Seq.toList + +/// The loading search as the searcher drives it: results and project completions arrive through callbacks. +/// Returns the names found and how many times a project was reported complete. +let private searchWhileLoading + (service: IFSharpNavigateToSearchService, advanced: IFSharpAdvancedNavigateToSearchService) + (projects: Project list) + pattern + = + task { + let found = ResizeArray() + let completed = ref 0 + + do! + advanced.SearchCachedDocumentsAsync( + (List.head projects).Solution, + ImmutableArray.CreateRange projects, + ImmutableArray.Empty, + pattern, + service.KindsProvided, + null, + (fun results -> + lock found (fun () -> found.AddRange results) + Task.CompletedTask), + (fun () -> + Interlocked.Increment &completed.contents |> ignore + Task.CompletedTask), + CancellationToken.None + ) + + return found |> Seq.map _.Name |> Seq.toList, completed.Value + } + +[] +let ``the loading search finds what the search that waits for the options cannot`` () : Task = + task { + let _, _, project = + loadingSolution "module Sample =\n let declaredWhileLoading = 1\n" + + let (service, _) as services = searchServices () + + let searchAccurately () = + service.SearchProjectAsync(project, ImmutableArray.Empty, "declaredWhileLoading", service.KindsProvided, CancellationToken.None) + :> Task + + let! _ = Assert.ThrowsAnyAsync(fun () -> searchAccurately ()) + + let! names, completed = searchWhileLoading services [ project ] "declaredWhileLoading" + + Assert.Equal([ "declaredWhileLoading" ], names) + Assert.Equal(1, completed) + + // What the loading search left in the cache must not be handed to the accurate search: it was + // read without the project's defines. + let! _ = Assert.ThrowsAnyAsync(fun () -> searchAccurately ()) + () + } + +[] +let ``the loading search reads a file under the wrong defines and does not keep the answer`` () : Task = + task { + let projectId, solution, project = + loadingSolution "#if FOO\nlet fooOnly = 1\n#endif\n" + + let (service, _) as services = searchServices () + + let! namesWhileLoading, _ = searchWhileLoading services [ project ] "fooOnly" + Assert.Equal([], namesWhileLoading) + + { RoslynTestHelpers.DefaultProjectOptions with + OtherOptions = [| "--define:FOO" |] + } + |> RoslynTestHelpers.SetProjectOptions projectId solution + + let! found = service.SearchProjectAsync(project, ImmutableArray.Empty, "fooOnly", service.KindsProvided, CancellationToken.None) + + Assert.Equal([ "fooOnly" ], namesFound found) + } + +[] +let ``every project is reported complete once, whether or not anything is found in it`` () : Task = + task { + let solution = + RoslynTestHelpers.CreateSolution + [ + loadingProject "first" "let found = 1\n" + loadingProject "second" "let other = 2\n" + ] + + let! names, completed = searchWhileLoading (searchServices ()) (solution.Projects |> Seq.toList) "found" + + Assert.Equal([ "found" ], names) + Assert.Equal(2, completed) + } From b0eecd66521d620fa37ac7bacdff3aa4e4d2826c Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 26 Sep 2026 13:47:47 +0200 Subject: [PATCH 12/31] Move the release note to the version in development `main` opened 11.0.200 for SDK 11.0.200; 11.0.100 has shipped. Co-Authored-By: Claude Opus 5 (1M context) --- docs/release-notes/.FSharp.Compiler.Service/11.0.100.md | 1 - docs/release-notes/.FSharp.Compiler.Service/11.0.200.md | 4 ++++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md index f18745054b6..f383908e195 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md @@ -255,4 +255,3 @@ * `FSharp.Compiler.Syntax.SynComponentInfo` now holds the type name as `synType: SynType option` instead of the previous `longId: LongIdent` field, so tuple-type extensions such as `type ('T1 * 'T2) with` can be represented. A `member LongIdent` compatibility property returns the long identifier for named types and an empty list for tuple or erroneous type names. AST consumers that pattern-matched on the `longId` field must switch to the `synType` field or the `LongIdent` member. ([PR #19602](https://github.com/dotnet/fsharp/pull/19602)) * Optimizer: don't inline named functions in debug builds ([PR #19548](https://github.com/dotnet/fsharp/pull/19548) * LexFilter: drop non-strict mode ([PR #20106](https://github.com/dotnet/fsharp/pull/20106)) -* `FSharp.Compiler.EditorServices.Structure.getOutliningRanges` now takes the source lines as `ReadOnlyMemory[]` instead of `string[]`, so a caller that already holds the whole text can slice it instead of building a string per line. Callers passing a `string[]` can migrate with `Array.map (fun line -> line.AsMemory())`. 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 4cd1275d7d6..35bf3d05dee 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -20,3 +20,7 @@ * Add `SynExceptionDefnReprTrivia` with the range of the `exception` keyword to `SynExceptionDefnRepr`. ([Fantomas Issue #3483](https://github.com/fsprojects/fantomas/issues/3483), [PR #20612](https://github.com/dotnet/fsharp/pull/20612)) * Add `WhenKeyword` to `SynMatchClauseTrivia` with the range of the `when` keyword in match clauses. ([PR #20734](https://github.com/dotnet/fsharp/pull/20734)) * Add `OfKeyword` to `SynUnionCaseTrivia` with the range of the `of` keyword in union cases and exception definitions. ([PR #20635](https://github.com/dotnet/fsharp/pull/20635)) + +### Breaking Changes + +* `FSharp.Compiler.EditorServices.Structure.getOutliningRanges` now takes the source lines as `ReadOnlyMemory[]` instead of `string[]`, so a caller that already holds the whole text can slice it instead of building a string per line. Callers passing a `string[]` can migrate with `Array.map (fun line -> line.AsMemory())`. From 6e2d31abf556f8cc81fb18374f55245696ea9c7c Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 10 Sep 2026 12:09:46 +0200 Subject: [PATCH 13/31] Let a consumer take a navigable container apart and rebuild one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `NavigateTo.GetNavigableItems` hands back items whose `Container` can be read but not constructed: the signature seals the type and hides the `File` and `Container` cases. That is enough to display a result and not enough to carry one across a process boundary, which is what caching navigable items on disk needs — the reader has to rebuild the container it deserialized. Publish both cases. While the shape is still private, replace `Container`'s three-element tuple with a named record so the parts have names at the point of use, and make it a struct: a struct record is laid out inside the case exactly as the tuple was, so this costs nothing. Building a file plus three nested containers a million times allocates 144 bytes per chain either way, where a reference record would take 216. Co-Authored-By: Claude Opus 5 --- .../.FSharp.Compiler.Service/11.0.100.md | 1 + src/Compiler/Service/ServiceNavigation.fs | 53 +++++++++++++++---- src/Compiler/Service/ServiceNavigation.fsi | 20 ++++++- ...iler.Service.SurfaceArea.netstandard20.bsl | 35 ++++++++++++ 4 files changed, 97 insertions(+), 12 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md index f383908e195..c426006d0b9 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md @@ -209,6 +209,7 @@ * IL: fix the per-reader string cache sizing ([PR #20261](https://github.com/dotnet/fsharp/pull/20261)) * Name resolution: group C#-style extension members per `open` and extended type ([PR #20298](https://github.com/dotnet/fsharp/pull/20298)) * Symbols: add `FSharpDisplayContext.WithNullnessAnnotations` ([PR #20507](https://github.com/dotnet/fsharp/pull/20507)) +* `NavigableContainer` publishes its `File` and `Container` cases, so a consumer of `NavigateTo.GetNavigableItems` can take a container apart and rebuild one — needed to cache navigable items outside the process that produced them. `Container` now carries a `[]` record, `NavigableContainerInfo`, in place of its three-element tuple: the fields gain names at no cost, since a struct record is laid out inside the case exactly as the tuple was. ### Improved diff --git a/src/Compiler/Service/ServiceNavigation.fs b/src/Compiler/Service/ServiceNavigation.fs index f42b335da53..abce3428365 100755 --- a/src/Compiler/Service/ServiceNavigation.fs +++ b/src/Compiler/Service/ServiceNavigation.fs @@ -733,28 +733,36 @@ type NavigableContainerType = | Type | Exception -type NavigableContainer = +[] +type NavigableContainerInfo = + { + ContainerType: NavigableContainerType + NameParts: string list + Parent: NavigableContainer + } + +and NavigableContainer = | File of fileName: string - | Container of containerType: NavigableContainerType * nameParts: string list * parent: NavigableContainer + | Container of info: NavigableContainerInfo member x.FullName = let rec loop acc = function | File _ -> acc - | Container(_, nameParts, parent) -> loop (nameParts @ acc) parent + | Container info -> loop (info.NameParts @ acc) info.Parent loop [] x |> textOfPath member x.Type = match x with | File _ -> NavigableContainerType.File - | Container(t, _, _) -> t + | Container info -> info.ContainerType member x.Name = match x with | File name -> name - | Container(nameParts = []) -> "" - | Container(nameParts = ns) -> ns |> List.last + | Container { NameParts = [] } -> "" + | Container { NameParts = ns } -> ns |> List.last type NavigableItem = { @@ -815,13 +823,24 @@ module NavigateTo = let addExceptionRepr exnRepr isSig container = let (SynExceptionDefnRepr(caseName = SynUnionCase(ident = SynIdent(id, _)))) = exnRepr addIdent NavigableItemKind.Exception id isSig container - NavigableContainer.Container(NavigableContainerType.Exception, [ id.idText ], container) + + NavigableContainer.Container + { + ContainerType = NavigableContainerType.Exception + NameParts = [ id.idText ] + Parent = container + } let addComponentInfo containerType kind (info: SynComponentInfo) isSig container = let lid = info.LongIdent addLongIdent kind lid isSig container - NavigableContainer.Container(containerType, pathOfLid lid, container) + NavigableContainer.Container + { + ContainerType = containerType + NameParts = pathOfLid lid + Parent = container + } let addValSig kind synValSig isSig container = let (SynValSig(ident = SynIdent(id, _))) = synValSig @@ -899,7 +918,14 @@ module NavigateTo = NavigableContainerType.Namespace for decl in decls do - walkSynModuleSigDecl decl (NavigableContainer.Container(ctype, pathOfLid lid, container)) + walkSynModuleSigDecl + decl + (NavigableContainer.Container + { + ContainerType = ctype + NameParts = pathOfLid lid + Parent = container + }) and walkSynModuleSigDecl (decl: SynModuleSigDecl) container = match decl with @@ -958,7 +984,14 @@ module NavigateTo = NavigableContainerType.Namespace for decl in decls do - walkSynModuleDecl decl (NavigableContainer.Container(ctype, pathOfLid lid, container)) + walkSynModuleDecl + decl + (NavigableContainer.Container + { + ContainerType = ctype + NameParts = pathOfLid lid + Parent = container + }) and walkSynModuleDecl (decl: SynModuleDecl) container = match decl with diff --git a/src/Compiler/Service/ServiceNavigation.fsi b/src/Compiler/Service/ServiceNavigation.fsi index cfccd6ef20c..8063a107d91 100755 --- a/src/Compiler/Service/ServiceNavigation.fsi +++ b/src/Compiler/Service/ServiceNavigation.fsi @@ -99,8 +99,24 @@ type NavigableContainerType = | Type | Exception -[] -type NavigableContainer = +/// What a container declared inside a file is, and what encloses it. +[] +type NavigableContainerInfo = + { + /// The kind of container. + ContainerType: NavigableContainerType + + /// The name of the container, one element per dotted part. + NameParts: string list + + /// The container this one is declared in, ending at the file. + Parent: NavigableContainer + } + +and NavigableContainer = + | File of fileName: string + | Container of info: NavigableContainerInfo + /// The kind of container. member Type: NavigableContainerType 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 dd72786cd0f..04743af1ee6 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 @@ -3880,9 +3880,25 @@ FSharp.Compiler.EditorServices.ModuleKind: Int32 GetHashCode() FSharp.Compiler.EditorServices.ModuleKind: Int32 GetHashCode(System.Collections.IEqualityComparer) FSharp.Compiler.EditorServices.ModuleKind: System.String ToString() FSharp.Compiler.EditorServices.ModuleKind: Void .ctor(Boolean, Boolean) +FSharp.Compiler.EditorServices.NavigableContainer+Container: FSharp.Compiler.EditorServices.NavigableContainerInfo get_info() +FSharp.Compiler.EditorServices.NavigableContainer+Container: FSharp.Compiler.EditorServices.NavigableContainerInfo info +FSharp.Compiler.EditorServices.NavigableContainer+File: System.String fileName +FSharp.Compiler.EditorServices.NavigableContainer+File: System.String get_fileName() +FSharp.Compiler.EditorServices.NavigableContainer+Tags: Int32 Container +FSharp.Compiler.EditorServices.NavigableContainer+Tags: Int32 File FSharp.Compiler.EditorServices.NavigableContainer: Boolean Equals(FSharp.Compiler.EditorServices.NavigableContainer) +FSharp.Compiler.EditorServices.NavigableContainer: Boolean Equals(FSharp.Compiler.EditorServices.NavigableContainer, System.Collections.IEqualityComparer) FSharp.Compiler.EditorServices.NavigableContainer: Boolean Equals(System.Object) FSharp.Compiler.EditorServices.NavigableContainer: Boolean Equals(System.Object, System.Collections.IEqualityComparer) +FSharp.Compiler.EditorServices.NavigableContainer: Boolean IsContainer +FSharp.Compiler.EditorServices.NavigableContainer: Boolean IsFile +FSharp.Compiler.EditorServices.NavigableContainer: Boolean get_IsContainer() +FSharp.Compiler.EditorServices.NavigableContainer: Boolean get_IsFile() +FSharp.Compiler.EditorServices.NavigableContainer: FSharp.Compiler.EditorServices.NavigableContainer NewContainer(FSharp.Compiler.EditorServices.NavigableContainerInfo) +FSharp.Compiler.EditorServices.NavigableContainer: FSharp.Compiler.EditorServices.NavigableContainer NewFile(System.String) +FSharp.Compiler.EditorServices.NavigableContainer: FSharp.Compiler.EditorServices.NavigableContainer+Container +FSharp.Compiler.EditorServices.NavigableContainer: FSharp.Compiler.EditorServices.NavigableContainer+File +FSharp.Compiler.EditorServices.NavigableContainer: FSharp.Compiler.EditorServices.NavigableContainer+Tags FSharp.Compiler.EditorServices.NavigableContainer: FSharp.Compiler.EditorServices.NavigableContainerType Type FSharp.Compiler.EditorServices.NavigableContainer: FSharp.Compiler.EditorServices.NavigableContainerType get_Type() FSharp.Compiler.EditorServices.NavigableContainer: Int32 CompareTo(FSharp.Compiler.EditorServices.NavigableContainer) @@ -3890,11 +3906,30 @@ FSharp.Compiler.EditorServices.NavigableContainer: Int32 CompareTo(System.Object FSharp.Compiler.EditorServices.NavigableContainer: Int32 CompareTo(System.Object, System.Collections.IComparer) FSharp.Compiler.EditorServices.NavigableContainer: Int32 GetHashCode() FSharp.Compiler.EditorServices.NavigableContainer: Int32 GetHashCode(System.Collections.IEqualityComparer) +FSharp.Compiler.EditorServices.NavigableContainer: Int32 Tag +FSharp.Compiler.EditorServices.NavigableContainer: Int32 get_Tag() FSharp.Compiler.EditorServices.NavigableContainer: System.String FullName FSharp.Compiler.EditorServices.NavigableContainer: System.String Name FSharp.Compiler.EditorServices.NavigableContainer: System.String ToString() FSharp.Compiler.EditorServices.NavigableContainer: System.String get_FullName() FSharp.Compiler.EditorServices.NavigableContainer: System.String get_Name() +FSharp.Compiler.EditorServices.NavigableContainerInfo: Boolean Equals(FSharp.Compiler.EditorServices.NavigableContainerInfo) +FSharp.Compiler.EditorServices.NavigableContainerInfo: Boolean Equals(FSharp.Compiler.EditorServices.NavigableContainerInfo, System.Collections.IEqualityComparer) +FSharp.Compiler.EditorServices.NavigableContainerInfo: Boolean Equals(System.Object) +FSharp.Compiler.EditorServices.NavigableContainerInfo: Boolean Equals(System.Object, System.Collections.IEqualityComparer) +FSharp.Compiler.EditorServices.NavigableContainerInfo: FSharp.Compiler.EditorServices.NavigableContainer Parent +FSharp.Compiler.EditorServices.NavigableContainerInfo: FSharp.Compiler.EditorServices.NavigableContainer get_Parent() +FSharp.Compiler.EditorServices.NavigableContainerInfo: FSharp.Compiler.EditorServices.NavigableContainerType ContainerType +FSharp.Compiler.EditorServices.NavigableContainerInfo: FSharp.Compiler.EditorServices.NavigableContainerType get_ContainerType() +FSharp.Compiler.EditorServices.NavigableContainerInfo: Int32 CompareTo(FSharp.Compiler.EditorServices.NavigableContainerInfo) +FSharp.Compiler.EditorServices.NavigableContainerInfo: Int32 CompareTo(System.Object) +FSharp.Compiler.EditorServices.NavigableContainerInfo: Int32 CompareTo(System.Object, System.Collections.IComparer) +FSharp.Compiler.EditorServices.NavigableContainerInfo: Int32 GetHashCode() +FSharp.Compiler.EditorServices.NavigableContainerInfo: Int32 GetHashCode(System.Collections.IEqualityComparer) +FSharp.Compiler.EditorServices.NavigableContainerInfo: Microsoft.FSharp.Collections.FSharpList`1[System.String] NameParts +FSharp.Compiler.EditorServices.NavigableContainerInfo: Microsoft.FSharp.Collections.FSharpList`1[System.String] get_NameParts() +FSharp.Compiler.EditorServices.NavigableContainerInfo: System.String ToString() +FSharp.Compiler.EditorServices.NavigableContainerInfo: Void .ctor(FSharp.Compiler.EditorServices.NavigableContainerType, Microsoft.FSharp.Collections.FSharpList`1[System.String], FSharp.Compiler.EditorServices.NavigableContainer) FSharp.Compiler.EditorServices.NavigableContainerType+Tags: Int32 Exception FSharp.Compiler.EditorServices.NavigableContainerType+Tags: Int32 File FSharp.Compiler.EditorServices.NavigableContainerType+Tags: Int32 Module From b3a4147891758bdcb644d53fc14e4e67f42f28c2 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 11 Sep 2026 20:21:00 +0200 Subject: [PATCH 14/31] Link the release note to PR #20529 Co-Authored-By: Claude Opus 5 --- docs/release-notes/.FSharp.Compiler.Service/11.0.100.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md index c426006d0b9..b15950cefc9 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md @@ -210,6 +210,7 @@ * Name resolution: group C#-style extension members per `open` and extended type ([PR #20298](https://github.com/dotnet/fsharp/pull/20298)) * Symbols: add `FSharpDisplayContext.WithNullnessAnnotations` ([PR #20507](https://github.com/dotnet/fsharp/pull/20507)) * `NavigableContainer` publishes its `File` and `Container` cases, so a consumer of `NavigateTo.GetNavigableItems` can take a container apart and rebuild one — needed to cache navigable items outside the process that produced them. `Container` now carries a `[]` record, `NavigableContainerInfo`, in place of its three-element tuple: the fields gain names at no cost, since a struct record is laid out inside the case exactly as the tuple was. +* `NavigableContainer` publishes its `File` and `Container` cases, so a consumer of `NavigateTo.GetNavigableItems` can take a container apart and rebuild one — needed to cache navigable items outside the process that produced them. `Container` now carries a `[]` record, `NavigableContainerInfo`, in place of its three-element tuple: the fields gain names at no cost, since a struct record is laid out inside the case exactly as the tuple was. ([PR #20529](https://github.com/dotnet/fsharp/pull/20529)) ### Improved From 9ae4707d4d117bc3e4d7da3aa55144993c9d7d04 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 26 Sep 2026 13:46:07 +0200 Subject: [PATCH 15/31] Keep one release note, in the version in development Linking the note added a copy of it instead of editing the first, and `main` has since opened 11.0.200 for SDK 11.0.200, leaving 11.0.100 shipped. Co-Authored-By: Claude Opus 5 (1M context) --- docs/release-notes/.FSharp.Compiler.Service/11.0.100.md | 2 -- docs/release-notes/.FSharp.Compiler.Service/11.0.200.md | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md index b15950cefc9..f383908e195 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md @@ -209,8 +209,6 @@ * IL: fix the per-reader string cache sizing ([PR #20261](https://github.com/dotnet/fsharp/pull/20261)) * Name resolution: group C#-style extension members per `open` and extended type ([PR #20298](https://github.com/dotnet/fsharp/pull/20298)) * Symbols: add `FSharpDisplayContext.WithNullnessAnnotations` ([PR #20507](https://github.com/dotnet/fsharp/pull/20507)) -* `NavigableContainer` publishes its `File` and `Container` cases, so a consumer of `NavigateTo.GetNavigableItems` can take a container apart and rebuild one — needed to cache navigable items outside the process that produced them. `Container` now carries a `[]` record, `NavigableContainerInfo`, in place of its three-element tuple: the fields gain names at no cost, since a struct record is laid out inside the case exactly as the tuple was. -* `NavigableContainer` publishes its `File` and `Container` cases, so a consumer of `NavigateTo.GetNavigableItems` can take a container apart and rebuild one — needed to cache navigable items outside the process that produced them. `Container` now carries a `[]` record, `NavigableContainerInfo`, in place of its three-element tuple: the fields gain names at no cost, since a struct record is laid out inside the case exactly as the tuple was. ([PR #20529](https://github.com/dotnet/fsharp/pull/20529)) ### Improved 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 35bf3d05dee..c3ff6795186 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -1,5 +1,6 @@ ### Added +* `NavigableContainer` publishes its `File` and `Container` cases, so a consumer of `NavigateTo.GetNavigableItems` can take a container apart and rebuild one — needed to cache navigable items outside the process that produced them. `Container` now carries a `[]` record, `NavigableContainerInfo`, in place of its three-element tuple: the fields gain names at no cost, since a struct record is laid out inside the case exactly as the tuple was. ([PR #20529](https://github.com/dotnet/fsharp/pull/20529)) * 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)) ### Fixed From 0f3fdc33970b5f411e1ad68603aaf530f28e806e Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Tue, 29 Sep 2026 19:18:16 +0200 Subject: [PATCH 16/31] Put the container's fields back on its case A struct record payload is boxed by the union's structural comparison; three case fields are not. Co-Authored-By: Claude Opus 5.5 --- .../.FSharp.Compiler.Service/11.0.200.md | 2 +- src/Compiler/Service/ServiceNavigation.fs | 53 ++++--------------- src/Compiler/Service/ServiceNavigation.fsi | 20 ++----- ...iler.Service.SurfaceArea.netstandard20.bsl | 27 +++------- 4 files changed, 22 insertions(+), 80 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 c3ff6795186..0dae45784bc 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -1,6 +1,6 @@ ### Added -* `NavigableContainer` publishes its `File` and `Container` cases, so a consumer of `NavigateTo.GetNavigableItems` can take a container apart and rebuild one — needed to cache navigable items outside the process that produced them. `Container` now carries a `[]` record, `NavigableContainerInfo`, in place of its three-element tuple: the fields gain names at no cost, since a struct record is laid out inside the case exactly as the tuple was. ([PR #20529](https://github.com/dotnet/fsharp/pull/20529)) +* `NavigableContainer` publishes its `File` and `Container` cases, so a consumer of `NavigateTo.GetNavigableItems` can take a container apart and rebuild one — needed to cache navigable items outside the process that produced them. ([PR #20529](https://github.com/dotnet/fsharp/pull/20529)) * 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)) ### Fixed diff --git a/src/Compiler/Service/ServiceNavigation.fs b/src/Compiler/Service/ServiceNavigation.fs index abce3428365..f42b335da53 100755 --- a/src/Compiler/Service/ServiceNavigation.fs +++ b/src/Compiler/Service/ServiceNavigation.fs @@ -733,36 +733,28 @@ type NavigableContainerType = | Type | Exception -[] -type NavigableContainerInfo = - { - ContainerType: NavigableContainerType - NameParts: string list - Parent: NavigableContainer - } - -and NavigableContainer = +type NavigableContainer = | File of fileName: string - | Container of info: NavigableContainerInfo + | Container of containerType: NavigableContainerType * nameParts: string list * parent: NavigableContainer member x.FullName = let rec loop acc = function | File _ -> acc - | Container info -> loop (info.NameParts @ acc) info.Parent + | Container(_, nameParts, parent) -> loop (nameParts @ acc) parent loop [] x |> textOfPath member x.Type = match x with | File _ -> NavigableContainerType.File - | Container info -> info.ContainerType + | Container(t, _, _) -> t member x.Name = match x with | File name -> name - | Container { NameParts = [] } -> "" - | Container { NameParts = ns } -> ns |> List.last + | Container(nameParts = []) -> "" + | Container(nameParts = ns) -> ns |> List.last type NavigableItem = { @@ -823,24 +815,13 @@ module NavigateTo = let addExceptionRepr exnRepr isSig container = let (SynExceptionDefnRepr(caseName = SynUnionCase(ident = SynIdent(id, _)))) = exnRepr addIdent NavigableItemKind.Exception id isSig container - - NavigableContainer.Container - { - ContainerType = NavigableContainerType.Exception - NameParts = [ id.idText ] - Parent = container - } + NavigableContainer.Container(NavigableContainerType.Exception, [ id.idText ], container) let addComponentInfo containerType kind (info: SynComponentInfo) isSig container = let lid = info.LongIdent addLongIdent kind lid isSig container - NavigableContainer.Container - { - ContainerType = containerType - NameParts = pathOfLid lid - Parent = container - } + NavigableContainer.Container(containerType, pathOfLid lid, container) let addValSig kind synValSig isSig container = let (SynValSig(ident = SynIdent(id, _))) = synValSig @@ -918,14 +899,7 @@ module NavigateTo = NavigableContainerType.Namespace for decl in decls do - walkSynModuleSigDecl - decl - (NavigableContainer.Container - { - ContainerType = ctype - NameParts = pathOfLid lid - Parent = container - }) + walkSynModuleSigDecl decl (NavigableContainer.Container(ctype, pathOfLid lid, container)) and walkSynModuleSigDecl (decl: SynModuleSigDecl) container = match decl with @@ -984,14 +958,7 @@ module NavigateTo = NavigableContainerType.Namespace for decl in decls do - walkSynModuleDecl - decl - (NavigableContainer.Container - { - ContainerType = ctype - NameParts = pathOfLid lid - Parent = container - }) + walkSynModuleDecl decl (NavigableContainer.Container(ctype, pathOfLid lid, container)) and walkSynModuleDecl (decl: SynModuleDecl) container = match decl with diff --git a/src/Compiler/Service/ServiceNavigation.fsi b/src/Compiler/Service/ServiceNavigation.fsi index 8063a107d91..c282abda0b2 100755 --- a/src/Compiler/Service/ServiceNavigation.fsi +++ b/src/Compiler/Service/ServiceNavigation.fsi @@ -99,23 +99,11 @@ type NavigableContainerType = | Type | Exception -/// What a container declared inside a file is, and what encloses it. -[] -type NavigableContainerInfo = - { - /// The kind of container. - ContainerType: NavigableContainerType - - /// The name of the container, one element per dotted part. - NameParts: string list - - /// The container this one is declared in, ending at the file. - Parent: NavigableContainer - } - -and NavigableContainer = +type NavigableContainer = | File of fileName: string - | Container of info: NavigableContainerInfo + /// A container declared inside a file: its kind, its name one element per dotted part, and the + /// container it is declared in, ending at the file. + | Container of containerType: NavigableContainerType * nameParts: string list * parent: NavigableContainer /// The kind of container. member Type: NavigableContainerType 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 04743af1ee6..e798cfafcc0 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 @@ -3880,8 +3880,12 @@ FSharp.Compiler.EditorServices.ModuleKind: Int32 GetHashCode() FSharp.Compiler.EditorServices.ModuleKind: Int32 GetHashCode(System.Collections.IEqualityComparer) FSharp.Compiler.EditorServices.ModuleKind: System.String ToString() FSharp.Compiler.EditorServices.ModuleKind: Void .ctor(Boolean, Boolean) -FSharp.Compiler.EditorServices.NavigableContainer+Container: FSharp.Compiler.EditorServices.NavigableContainerInfo get_info() -FSharp.Compiler.EditorServices.NavigableContainer+Container: FSharp.Compiler.EditorServices.NavigableContainerInfo info +FSharp.Compiler.EditorServices.NavigableContainer+Container: FSharp.Compiler.EditorServices.NavigableContainer get_parent() +FSharp.Compiler.EditorServices.NavigableContainer+Container: FSharp.Compiler.EditorServices.NavigableContainer parent +FSharp.Compiler.EditorServices.NavigableContainer+Container: FSharp.Compiler.EditorServices.NavigableContainerType containerType +FSharp.Compiler.EditorServices.NavigableContainer+Container: FSharp.Compiler.EditorServices.NavigableContainerType get_containerType() +FSharp.Compiler.EditorServices.NavigableContainer+Container: Microsoft.FSharp.Collections.FSharpList`1[System.String] get_nameParts() +FSharp.Compiler.EditorServices.NavigableContainer+Container: Microsoft.FSharp.Collections.FSharpList`1[System.String] nameParts FSharp.Compiler.EditorServices.NavigableContainer+File: System.String fileName FSharp.Compiler.EditorServices.NavigableContainer+File: System.String get_fileName() FSharp.Compiler.EditorServices.NavigableContainer+Tags: Int32 Container @@ -3894,7 +3898,7 @@ FSharp.Compiler.EditorServices.NavigableContainer: Boolean IsContainer FSharp.Compiler.EditorServices.NavigableContainer: Boolean IsFile FSharp.Compiler.EditorServices.NavigableContainer: Boolean get_IsContainer() FSharp.Compiler.EditorServices.NavigableContainer: Boolean get_IsFile() -FSharp.Compiler.EditorServices.NavigableContainer: FSharp.Compiler.EditorServices.NavigableContainer NewContainer(FSharp.Compiler.EditorServices.NavigableContainerInfo) +FSharp.Compiler.EditorServices.NavigableContainer: FSharp.Compiler.EditorServices.NavigableContainer NewContainer(FSharp.Compiler.EditorServices.NavigableContainerType, Microsoft.FSharp.Collections.FSharpList`1[System.String], FSharp.Compiler.EditorServices.NavigableContainer) FSharp.Compiler.EditorServices.NavigableContainer: FSharp.Compiler.EditorServices.NavigableContainer NewFile(System.String) FSharp.Compiler.EditorServices.NavigableContainer: FSharp.Compiler.EditorServices.NavigableContainer+Container FSharp.Compiler.EditorServices.NavigableContainer: FSharp.Compiler.EditorServices.NavigableContainer+File @@ -3913,23 +3917,6 @@ FSharp.Compiler.EditorServices.NavigableContainer: System.String Name FSharp.Compiler.EditorServices.NavigableContainer: System.String ToString() FSharp.Compiler.EditorServices.NavigableContainer: System.String get_FullName() FSharp.Compiler.EditorServices.NavigableContainer: System.String get_Name() -FSharp.Compiler.EditorServices.NavigableContainerInfo: Boolean Equals(FSharp.Compiler.EditorServices.NavigableContainerInfo) -FSharp.Compiler.EditorServices.NavigableContainerInfo: Boolean Equals(FSharp.Compiler.EditorServices.NavigableContainerInfo, System.Collections.IEqualityComparer) -FSharp.Compiler.EditorServices.NavigableContainerInfo: Boolean Equals(System.Object) -FSharp.Compiler.EditorServices.NavigableContainerInfo: Boolean Equals(System.Object, System.Collections.IEqualityComparer) -FSharp.Compiler.EditorServices.NavigableContainerInfo: FSharp.Compiler.EditorServices.NavigableContainer Parent -FSharp.Compiler.EditorServices.NavigableContainerInfo: FSharp.Compiler.EditorServices.NavigableContainer get_Parent() -FSharp.Compiler.EditorServices.NavigableContainerInfo: FSharp.Compiler.EditorServices.NavigableContainerType ContainerType -FSharp.Compiler.EditorServices.NavigableContainerInfo: FSharp.Compiler.EditorServices.NavigableContainerType get_ContainerType() -FSharp.Compiler.EditorServices.NavigableContainerInfo: Int32 CompareTo(FSharp.Compiler.EditorServices.NavigableContainerInfo) -FSharp.Compiler.EditorServices.NavigableContainerInfo: Int32 CompareTo(System.Object) -FSharp.Compiler.EditorServices.NavigableContainerInfo: Int32 CompareTo(System.Object, System.Collections.IComparer) -FSharp.Compiler.EditorServices.NavigableContainerInfo: Int32 GetHashCode() -FSharp.Compiler.EditorServices.NavigableContainerInfo: Int32 GetHashCode(System.Collections.IEqualityComparer) -FSharp.Compiler.EditorServices.NavigableContainerInfo: Microsoft.FSharp.Collections.FSharpList`1[System.String] NameParts -FSharp.Compiler.EditorServices.NavigableContainerInfo: Microsoft.FSharp.Collections.FSharpList`1[System.String] get_NameParts() -FSharp.Compiler.EditorServices.NavigableContainerInfo: System.String ToString() -FSharp.Compiler.EditorServices.NavigableContainerInfo: Void .ctor(FSharp.Compiler.EditorServices.NavigableContainerType, Microsoft.FSharp.Collections.FSharpList`1[System.String], FSharp.Compiler.EditorServices.NavigableContainer) FSharp.Compiler.EditorServices.NavigableContainerType+Tags: Int32 Exception FSharp.Compiler.EditorServices.NavigableContainerType+Tags: Int32 File FSharp.Compiler.EditorServices.NavigableContainerType+Tags: Int32 Module From 68df300e66373c9e5d3098d9ba72743303e3a0ad Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 6 Sep 2026 09:42:41 +0200 Subject: [PATCH 17/31] Build multi-project and multi-target Roslyn solutions in FSharp.Editor tests Test helpers so far put every synthetic file into one Roslyn project. CreateMultiProjectSolution creates one project per synthetic project with project references, the way VS wires project-to-project references; CreateMultiTargetSolution creates one project per target instance sharing the project path and the document paths, the way VS loads a multi-targeted project. Co-Authored-By: Claude Fable 5.1 --- .../Helpers/RoslynHelpers.fs | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs index 72e5ac9a27d..89a449eceb7 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs @@ -203,7 +203,6 @@ type TestHostServices() = /// One Roslyn project instance of a multi-targeted F# project: its extra defines and the /// synthetic files left out of it, as VS does per target framework. -[] type TargetInstance = { Defines: string list @@ -383,9 +382,8 @@ type RoslynTestHelpers private () = let projects = syntheticProject.GetAllProjects() - |> Seq.distinctBy _.Name - |> Seq.map (fun project -> struct (project, ProjectId.CreateNewId())) - |> Seq.toArray + |> List.distinctBy _.Name + |> List.map (fun project -> project, ProjectId.CreateNewId()) let projectIds = dict [ for project, id in projects -> project.Name, id ] @@ -415,15 +413,10 @@ type RoslynTestHelpers private () = project.GetProjectOptions checker |> RoslynTestHelpers.SetProjectOptions id solution - struct (solution, checker) + solution, checker - /// /// One Roslyn project per target instance, all sharing the .fsproj path and the document file /// paths, like the per-target-framework projects VS creates for a multi-targeted project. - /// - /// The project every instance is made from; it must not depend on other projects. - /// One Roslyn project per entry: its extra defines and the synthetic files left out of it. - /// The solution and the id of each instance's project, in the order of . static member CreateMultiTargetSolution(syntheticProject: SyntheticProject, instances: TargetInstance list) = assert (syntheticProject.DependsOn = []) @@ -436,13 +429,13 @@ type RoslynTestHelpers private () = for instance in instances -> let excludedPaths = HashSet( - seq { + [ for fileId in instance.ExcludedFileIds do syntheticProject.GetFilePath fileId if (syntheticProject.Find fileId).HasSignatureFile then syntheticProject.GetSignatureFilePath fileId - }, + ], StringComparer.OrdinalIgnoreCase ) @@ -474,7 +467,7 @@ type RoslynTestHelpers private () = |] } - struct (id, projectInfo, instanceOptions) + id, projectInfo, instanceOptions ] let solution = @@ -483,7 +476,7 @@ type RoslynTestHelpers private () = for id, _, instanceOptions in instances do RoslynTestHelpers.SetProjectOptions id solution instanceOptions - struct (solution, [ for id, _, _ in instances -> id ]) + solution, [ for id, _, _ in instances -> id ] static member GetFsDocument(code, ?customProjectOption: string, ?customEditorOptions) = let customProjectOptions = From 1537a01b372bfee8dcf1bd5d91b829702a6a2a8d Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 7 Sep 2026 20:15:02 +0200 Subject: [PATCH 18/31] Search each file of a multi-targeted project once in NavigateTo and read its text only for matches Roslyn's NavigateTo searcher hands the F# service every target-framework instance of a project, one after another, and the service parsed every file of each instance, read the text of every file before matching anything, and started all of that for a project at once. On a solution with 135 project instances that meant one parse and one file read per file per framework, thousands of concurrent tasks, and results that arrived long after the user stopped typing. The first instance of a project file in the solution now searches every file; the other instances only search the files they alone compile and the files whose parse depends on the defines, known from whichever instance parsed the file first. A file's text is read only when one of its declarations matched, and a project's files are searched at most ProcessorCount at a time. Co-Authored-By: Claude Fable 5.1 --- .../Navigation/NavigateToSearchService.fs | 293 +++++++----------- 1 file changed, 113 insertions(+), 180 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs index 642ef91e60e..a529739d16d 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs @@ -7,10 +7,8 @@ open System.IO open System.Composition open System.Collections.Immutable open System.Collections.Concurrent -open System.Globalization -open System.Linq -open System.Threading open System.Threading.Tasks +open System.Globalization open Microsoft.CodeAnalysis open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Navigation @@ -19,108 +17,79 @@ open Microsoft.VisualStudio.LanguageServices open Microsoft.VisualStudio.Text.PatternMatching open FSharp.Compiler.EditorServices +open FSharp.Compiler.Syntax open CancellableTasks -/// The navigable items of one parse of a document, and the text version it was taken from. -[] -type private NavigableItemsEntry = - { - Version: VersionStamp - /// Parsed without the project's compilation options, while the solution was still loading. - Approximate: bool - Items: NavigableItem array - } - -/// Parse-tree navigable items per document, cached on the document's text version. -/// Shared by NavigateTo and by the Copilot chat mention provider. -[] -type internal FSharpNavigableItemsCache +[); Shared>] +type internal FSharpNavigateToSearchService [] (patternMatcherFactory: IPatternMatcherFactory, [] workspace: VisualStudioWorkspace) = - let cache = ConcurrentDictionary() + let cache = ConcurrentDictionary() + + /// Whether the file's parse depends on the defines, by file path: known once any instance has parsed it. + let conditionalDirectives = + ConcurrentDictionary(StringComparer.OrdinalIgnoreCase) do - match workspace with - | null -> () - | workspace -> - workspace.WorkspaceChanged.Add(fun e -> + if workspace <> null then + workspace.WorkspaceChanged.Add + <| fun e -> if e.NewSolution.Id <> e.OldSolution.Id then - cache.Clear()) - - let store (document: Document) version approximate parseTree = - let items = NavigateTo.GetNavigableItems parseTree + cache.Clear() + conditionalDirectives.Clear() - cache[document.Id] <- - { - Version = version - Approximate = approximate - Items = items - } + let hasConditionalDirectives (parseTree: ParsedInput) = + match parseTree with + | ParsedInput.ImplFile file -> not file.Trivia.ConditionalDirectives.IsEmpty + | ParsedInput.SigFile file -> not file.Trivia.ConditionalDirectives.IsEmpty - items - - member _.GetNavigableItems(document: Document) = + let getNavigableItems (document: Document) = cancellableTask { let! ct = CancellableTask.getCancellationToken () let! currentVersion = document.GetTextVersionAsync(ct) match cache.TryGetValue document.Id with - | true, entry when entry.Version = currentVersion && not entry.Approximate -> return entry.Items + | true, (version, items) when version = currentVersion -> return items | _ -> - let! parseResults = document.GetFSharpParseResultsAsync(nameof (FSharpNavigableItemsCache)) - return store document currentVersion false parseResults.ParseTree - } + let! parseResults = document.GetFSharpParseResultsAsync(nameof (FSharpNavigateToSearchService)) + let items = NavigateTo.GetNavigableItems parseResults.ParseTree + cache[document.Id] <- currentVersion, items - /// The items of a parse that does not wait for the project's compilation options, for the search that runs while - /// the solution is still loading. A file behind `#if` can be read under the wrong defines, so the entry it leaves - /// behind never answers `GetNavigableItems`. - member _.GetNavigableItemsWhileLoading(document: Document) = - cancellableTask { - let! ct = CancellableTask.getCancellationToken () - let! currentVersion = document.GetTextVersionAsync(ct) + match document.FilePath with + | null -> () + | path -> conditionalDirectives[path] <- hasConditionalDirectives parseResults.ParseTree - match cache.TryGetValue document.Id with - | true, entry when entry.Version = currentVersion -> return entry.Items - | _ -> - let! parseResults = document.GetFSharpQuickParseResultsAsync(nameof (FSharpNavigableItemsCache)) - return store document currentVersion true parseResults.ParseTree + return items } - member _.CreateMatcherFor(searchPattern: string) = - let patternMatcher = - patternMatcherFactory.CreatePatternMatcher( - searchPattern, - PatternMatcherCreationOptions( - cultureInfo = CultureInfo.CurrentUICulture, - flags = PatternMatcherCreationFlags.AllowFuzzyMatching, - containerSplitCharacters = [ '.' ] - ) - ) - - fun (item: NavigableItem) -> - // PatternMatcher will not match operators and some backtick escaped identifiers. - // To handle them, we fall back to simple substring match. - let name = item.Name - - if item.NeedsBackticks then - match name.IndexOf(searchPattern, StringComparison.CurrentCultureIgnoreCase) with - | i when i > 0 -> ValueSome(PatternMatch(PatternMatchKind.Substring, false, false)) - | 0 when name.Length = searchPattern.Length -> ValueSome(PatternMatch(PatternMatchKind.Exact, false, false)) - | 0 -> ValueSome(PatternMatch(PatternMatchKind.Prefix, false, false)) - | _ -> ValueNone - else - // full name with dots allows for path matching, e.g. - // "f.c.so.elseif" will match "Fantomas.Core.SyntaxOak.ElseIfNode" - patternMatcher.TryMatch $"{item.Container.FullName}.{name}" - |> ValueOption.ofNullable - -[); Shared>] -type internal FSharpNavigateToSearchService [] (itemsCache: FSharpNavigableItemsCache) = - - /// The parses of the search that runs while the solution loads take turns across all its projects, and leave a - /// core to the load itself. - let loadingThrottle = new SemaphoreSlim(max 1 (Environment.ProcessorCount - 1)) + /// A multi-targeted project is one Roslyn project per target framework over the same files. The + /// first instance in the solution searches every file; the others only the files they alone compile + /// and the files whose parse depends on the defines. + let searchedIn (project: Project) = + match project.FilePath with + | null -> fun (_: Document) -> true + | projectPath -> + let instances = + project.Solution.Projects + |> Seq.filter (fun p -> p.FilePath = projectPath) + |> Seq.map _.Id + |> List.ofSeq + + fun (document: Document) -> + match document.FilePath with + | null -> true + | path -> + let documentIds = project.Solution.GetDocumentIdsWithFilePath path + + let owner = + instances + |> List.find (fun id -> documentIds |> Seq.exists (fun documentId -> documentId.ProjectId = id)) + + owner = project.Id + || (match conditionalDirectives.TryGetValue path with + | true, dependsOnDefines -> dependsOnDefines + | _ -> true) let kindsProvided = ImmutableHashSet.Create( @@ -190,72 +159,74 @@ type internal FSharpNavigateToSearchService [] (itemsCache | PatternMatchKind.Fuzzy -> FSharpNavigateToMatchKind.Fuzzy | _ -> FSharpNavigateToMatchKind.None - let createMatcherFor (searchPattern: string) = - itemsCache.CreateMatcherFor searchPattern + let createMatcherFor searchPattern = + let patternMatcher = + patternMatcherFactory.CreatePatternMatcher( + searchPattern, + PatternMatcherCreationOptions( + cultureInfo = CultureInfo.CurrentUICulture, + flags = PatternMatcherCreationFlags.AllowFuzzyMatching, + containerSplitCharacters = [ '.' ] + ) + ) - let processDocument - (getItems: Document -> CancellableTask) - (tryMatch: NavigableItem -> PatternMatch voption) - (kinds: IImmutableSet) - (document: Document) - = - cancellableTask { - let! ct = CancellableTask.getCancellationToken () + fun (item: NavigableItem) -> + // PatternMatcher will not match operators and some backtick escaped identifiers. + // To handle them, we fall back to simple substring match. + let name = item.Name - let! sourceText = document.GetTextAsync ct + if item.NeedsBackticks then + match name.IndexOf(searchPattern, StringComparison.CurrentCultureIgnoreCase) with + | i when i > 0 -> ValueSome(PatternMatch(PatternMatchKind.Substring, false, false)) + | 0 when name.Length = searchPattern.Length -> ValueSome(PatternMatch(PatternMatchKind.Exact, false, false)) + | 0 -> ValueSome(PatternMatch(PatternMatchKind.Prefix, false, false)) + | _ -> ValueNone + else + // full name with dots allows for path matching, e.g. + // "f.c.so.elseif" will match "Fantomas.Core.SyntaxOak.ElseIfNode" + patternMatcher.TryMatch $"{item.Container.FullName}.{name}" + |> ValueOption.ofNullable - let! items = getItems document + let processDocument (tryMatch: NavigableItem -> PatternMatch voption) (kinds: IImmutableSet) (document: Document) = + cancellableTask { + let! items = getNavigableItems document - let processed = - seq { + let matches = + [| for item in items do - let contains = kinds.Contains(navigateToItemKindToRoslynKind item.Kind) - let patternMatch = tryMatch item + if kinds.Contains(navigateToItemKindToRoslynKind item.Kind) then + match tryMatch item with + | ValueSome m -> yield struct (item, m) + | ValueNone -> () + |] - match contains, patternMatch with - | true, ValueSome m -> - let sourceSpan = RoslynHelpers.TryFSharpRangeToTextSpan(sourceText, item.Range) + // The text, read from disk for a closed document, is only needed to place the matches. + if matches.Length = 0 then + return [||] + else + let! ct = CancellableTask.getCancellationToken () + let! sourceText = document.GetTextAsync ct - match sourceSpan with + return + [| + for struct (item, m) in matches do + match RoslynHelpers.TryFSharpRangeToTextSpan(sourceText, item.Range) with | ValueNone -> () | ValueSome sourceSpan -> - let glyph = navigateToItemKindToGlyph item.Kind - let kind = navigateToItemKindToRoslynKind item.Kind - let additionalInfo = formatInfo item.Container document - yield FSharpNavigateToSearchResult( - additionalInfo, - kind, + formatInfo item.Container document, + navigateToItemKindToRoslynKind item.Kind, patternMatchKindToNavigateToMatchKind m.Kind, item.Name, FSharpNavigableItem( - glyph, + navigateToItemKindToGlyph item.Kind, ImmutableArray.Create(TaggedText(TextTags.Text, item.Name)), document, sourceSpan ) ) - | _ -> () - } - - return processed |> Seq.toImmutableArray - } - - /// Priority items first, each half in its original order, as NavigateTo's own service orders its work. - let prioritize isPriority items = - let priority, rest = items |> Seq.toArray |> Array.partition isPriority - [| yield! priority; yield! rest |] - - let throttled (work: CancellableTask<'a>) = - cancellableTask { - let! ct = CancellableTask.getCancellationToken () - do! loadingThrottle.WaitAsync ct - - try - return! work - finally - loadingThrottle.Release() |> ignore + |] } interface IFSharpNavigateToSearchService with @@ -267,60 +238,22 @@ type internal FSharpNavigateToSearchService [] (itemsCache let! results = project.Documents - |> Seq.map (processDocument itemsCache.GetNavigableItems tryMatch kinds) - |> CancellableTask.whenAll + |> Seq.filter (searchedIn project) + |> Seq.map (processDocument tryMatch kinds) + // Throttle to avoid launching a parse per document in the project all at once. + |> CancellableTask.whenAllThrottled (max 1 Environment.ProcessorCount) - return results |> Seq.collect _.AsEnumerable() |> Seq.toImmutableArray + return results |> Array.concat |> Array.toImmutableArray } |> CancellableTask.start cancellationToken member _.SearchDocumentAsync(document: Document, searchPattern, kinds, cancellationToken) = - processDocument itemsCache.GetNavigableItems (createMatcherFor searchPattern) kinds document cancellationToken + cancellableTask { + let! result = processDocument (createMatcherFor searchPattern) kinds document + return Array.toImmutableArray result + } + |> CancellableTask.start cancellationToken member _.KindsProvided = kindsProvided member _.CanFilter = true - - interface IFSharpAdvancedNavigateToSearchService with - member _.SearchCachedDocumentsAsync - ( - _solution, - projects, - priorityDocuments, - searchPattern, - kinds, - _activeDocument, - onResultsFound, - onProjectCompleted, - cancellationToken - ) : Task = - let tryMatch = createMatcherFor searchPattern - let priorityIds = ImmutableHashSet.CreateRange(priorityDocuments |> Seq.map _.Id) - let isPriority (document: Document) = priorityIds.Contains document.Id - - let searchDocumentWhileLoading document = - cancellableTask { - let! results = throttled (processDocument itemsCache.GetNavigableItemsWhileLoading tryMatch kinds document) - - if results.Length > 0 then - do! onResultsFound.Invoke results - } - - // Every document waits on the throttle in the order it is started, so priority documents, and the - // projects that hold them, are parsed first. - let searchProjectWhileLoading (project: Project) = - cancellableTask { - let! _ = - project.Documents - |> prioritize isPriority - |> Seq.map searchDocumentWhileLoading - |> CancellableTask.whenAll - - do! onProjectCompleted.Invoke() - } - - projects - |> prioritize (fun project -> project.Documents |> Seq.exists isPriority) - |> Seq.map searchProjectWhileLoading - |> CancellableTask.whenAll - |> CancellableTask.startAsTask cancellationToken From 7fe895938995a58d19253d1c1cc026ace7158973 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 7 Sep 2026 20:15:04 +0200 Subject: [PATCH 19/31] Test NavigateTo across the target-framework instances of a project Co-Authored-By: Claude Fable 5.1 --- .../FSharp.Editor.Tests.fsproj | 1 + .../MultiTargetNavigateToSearchTests.fs | 70 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 vsintegration/tests/FSharp.Editor.Tests/MultiTargetNavigateToSearchTests.fs diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj index 107145ebd80..974b8667abd 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj +++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj @@ -35,6 +35,7 @@ + diff --git a/vsintegration/tests/FSharp.Editor.Tests/MultiTargetNavigateToSearchTests.fs b/vsintegration/tests/FSharp.Editor.Tests/MultiTargetNavigateToSearchTests.fs new file mode 100644 index 00000000000..c1febedfd5e --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/MultiTargetNavigateToSearchTests.fs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +/// One project loaded as two target-framework instances: `plain` compiles without the fourth file +/// and without FOO, `foo` compiles everything with FOO defined. +module FSharp.Editor.Tests.MultiTargetNavigateToSearchTests + +open System.Collections.Immutable +open System.Threading +open Xunit +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.NavigateTo +open FSharp.Editor.Tests.Helpers +open FSharp.Test.ProjectGeneration + +let private project = + SyntheticProject.Create( + { sourceFile "First" [] with + ExtraSource = "let sharedFunc funcParam = funcParam * 2\n" + }, + { sourceFile "Second" [ "First" ] with + ExtraSource = "let plainUse x = ModuleFirst.sharedFunc x" + }, + { sourceFile "Third" [ "First" ] with + ExtraSource = "#if FOO\nlet fooUse x = ModuleFirst.sharedFunc x\n#endif" + }, + { sourceFile "Fourth" [ "First" ] with + ExtraSource = "let fooOnlyFileUse x = ModuleFirst.sharedFunc x" + } + ) + +let private solution, instances = + RoslynTestHelpers.CreateMultiTargetSolution( + project, + [ + { + Defines = [] + ExcludedFileIds = [ "Fourth" ] + } + { + Defines = [ "FOO" ] + ExcludedFileIds = [] + } + ] + ) + +let private service: IFSharpNavigateToSearchService = + MefHelpers.createExportProvider().GetExportedValue() + +/// Every instance searched in solution order, as the NavigateTo searcher does. +let private search pattern = + [ + for id in instances do + yield! + service + .SearchProjectAsync( + solution.GetProject id, + ImmutableArray.Empty, + pattern, + service.KindsProvided, + CancellationToken.None + ) + .Result + ] + +[] +[] +[] +[] +let ``a declaration is reported once across the instances of a project`` (name: string) = + let names = search name |> List.map _.Name |> List.filter ((=) name) + Assert.Equal([ name ], names) From ad5a67fc5093eb60835513af668c4ed5aa04fb9a Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 7 Sep 2026 22:00:42 +0200 Subject: [PATCH 20/31] Add the release note for the NavigateTo multi-target fix Co-Authored-By: Claude Fable 5.1 --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index b127a0fa191..5d91c1c50af 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -26,6 +26,7 @@ * 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)) * Fix syntax coloring being lost for a whole file when one symbol resolves into metadata that could not be read. ([Issue #20269](https://github.com/dotnet/fsharp/issues/20269), [PR #20274](https://github.com/dotnet/fsharp/pull/20274)) * Go To Definition into an F# project built with a path map (`DeterministicSourcePaths` or `PathMap`) opens its source instead of a generated signature: the IDE no longer applies `--pathmap` to the project options it checks with. ([PR #20470](https://github.com/dotnet/fsharp/pull/20470)) +* Go To All (Ctrl+T) on a multi-targeted F# project no longer parses and reads every file once per target framework: the first instance searches every file, the others only the files they alone compile or that use conditional compilation, and a file's text is read only for a matched declaration. ([PR #20483](https://github.com/dotnet/fsharp/pull/20483)) ### Changed From f74eace2b056aad2a1edc559e604c2bc9c05bfd4 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Thu, 10 Sep 2026 02:03:32 +0200 Subject: [PATCH 21/31] Share the parse instead of skipping the file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skipping a file in every instance but the first is wrong twice over, as the review showed. Go To All scoped to the current project submits only that project, so the instance that was told to stay quiet is the only one asked, and a shared declaration disappears from it once the parse has taught the service the file holds no directives. And the flag that decision reads was keyed by path alone, so an edit that puts a declaration behind `#if` left the previous answer in place. Nothing needs skipping. `NavigateToSearcher` pools its seen set with `NavigateToSearchResultComparer`, which already collapses results by file path and span, so every instance can report what it compiles. What the instances should share is the parse, which is what costs. A parse whose tree holds no conditional directives does not depend on the defines, so it is kept under a key no define set can equal and every instance reuses it; one that does hold them is kept per define set, since those instances genuinely parse the file differently. The entry carries the text version, so an edit is a new entry rather than a stale flag. The test that asserted a declaration is reported once across the instances went with the skip: that is the searcher's job, not this service's. In its place are the two cases from the review — a lone instance reporting a shared declaration, and a declaration an edit puts behind a directive — and one that keeps the define-keyed parse honest by checking such a declaration does not reach the instance without the define. Co-Authored-By: Claude Opus 5 --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + .../Navigation/NavigateToSearchService.fs | 100 ++++++++++-------- .../MultiTargetNavigateToSearchTests.fs | 74 +++++++++---- 3 files changed, 107 insertions(+), 68 deletions(-) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 5d91c1c50af..76c9430403b 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -27,6 +27,7 @@ * Fix syntax coloring being lost for a whole file when one symbol resolves into metadata that could not be read. ([Issue #20269](https://github.com/dotnet/fsharp/issues/20269), [PR #20274](https://github.com/dotnet/fsharp/pull/20274)) * Go To Definition into an F# project built with a path map (`DeterministicSourcePaths` or `PathMap`) opens its source instead of a generated signature: the IDE no longer applies `--pathmap` to the project options it checks with. ([PR #20470](https://github.com/dotnet/fsharp/pull/20470)) * Go To All (Ctrl+T) on a multi-targeted F# project no longer parses and reads every file once per target framework: the first instance searches every file, the others only the files they alone compile or that use conditional compilation, and a file's text is read only for a matched declaration. ([PR #20483](https://github.com/dotnet/fsharp/pull/20483)) +* Go To All (Ctrl+T) on a multi-targeted F# project no longer parses every file once per target framework: a file whose parse holds no conditional directives does not depend on the defines, so every instance reuses the one parse, and a file's text is read only for a matched declaration. ([PR #20483](https://github.com/dotnet/fsharp/pull/20483)) ### Changed diff --git a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs index a529739d16d..bc3ef82da74 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs @@ -25,11 +25,27 @@ type internal FSharpNavigateToSearchService [] (patternMatcherFactory: IPatternMatcherFactory, [] workspace: VisualStudioWorkspace) = - let cache = ConcurrentDictionary() - - /// Whether the file's parse depends on the defines, by file path: known once any instance has parsed it. - let conditionalDirectives = - ConcurrentDictionary(StringComparer.OrdinalIgnoreCase) + /// A multi-targeted project is one Roslyn project per target framework over the same files, so the same + /// file is searched once per instance. What that costs is the parse, and a parse whose tree holds no + /// conditional directives does not depend on the defines: it is stored under `AnyDefines` and every + /// instance reuses it. One that does hold them is stored per define set, because those instances + /// genuinely parse the file differently. + /// + /// The duplicate results this produces are not for this service to remove. `NavigateToSearcher` pools its + /// seen set with `NavigateToSearchResultComparer`, which already collapses results by file path and span. + let cache = + ConcurrentDictionary< + struct (string * string), + struct {| + Version: VersionStamp + Items: NavigableItem array + |} + >() + + /// The key for a parse that does not depend on the defines. Not a define set any instance can have, + /// since defines are identifiers — an instance with none of its own must not read this entry as its own. + [] + let AnyDefines = "?" do if workspace <> null then @@ -37,9 +53,8 @@ type internal FSharpNavigateToSearchService <| fun e -> if e.NewSolution.Id <> e.OldSolution.Id then cache.Clear() - conditionalDirectives.Clear() - let hasConditionalDirectives (parseTree: ParsedInput) = + let dependsOnDefines (parseTree: ParsedInput) = match parseTree with | ParsedInput.ImplFile file -> not file.Trivia.ConditionalDirectives.IsEmpty | ParsedInput.SigFile file -> not file.Trivia.ConditionalDirectives.IsEmpty @@ -49,48 +64,40 @@ type internal FSharpNavigateToSearchService let! ct = CancellableTask.getCancellationToken () let! currentVersion = document.GetTextVersionAsync(ct) - match cache.TryGetValue document.Id with - | true, (version, items) when version = currentVersion -> return items - | _ -> + match document.FilePath with + | null -> let! parseResults = document.GetFSharpParseResultsAsync(nameof (FSharpNavigateToSearchService)) - let items = NavigateTo.GetNavigableItems parseResults.ParseTree - cache[document.Id] <- currentVersion, items - - match document.FilePath with - | null -> () - | path -> conditionalDirectives[path] <- hasConditionalDirectives parseResults.ParseTree - - return items + return NavigateTo.GetNavigableItems parseResults.ParseTree + | path -> + let defines = document.GetFSharpQuickDefines() |> String.concat ";" + + let cached key = + match cache.TryGetValue(struct (key, path)) with + | true, entry when entry.Version = currentVersion -> ValueSome entry.Items + | _ -> ValueNone + + match cached AnyDefines, cached defines with + | ValueSome items, _ + | _, ValueSome items -> return items + | ValueNone, ValueNone -> + let! parseResults = document.GetFSharpParseResultsAsync(nameof (FSharpNavigateToSearchService)) + let items = NavigateTo.GetNavigableItems parseResults.ParseTree + + let key = + if dependsOnDefines parseResults.ParseTree then + defines + else + AnyDefines + + cache[struct (key, path)] <- + {| + Version = currentVersion + Items = items + |} + + return items } - /// A multi-targeted project is one Roslyn project per target framework over the same files. The - /// first instance in the solution searches every file; the others only the files they alone compile - /// and the files whose parse depends on the defines. - let searchedIn (project: Project) = - match project.FilePath with - | null -> fun (_: Document) -> true - | projectPath -> - let instances = - project.Solution.Projects - |> Seq.filter (fun p -> p.FilePath = projectPath) - |> Seq.map _.Id - |> List.ofSeq - - fun (document: Document) -> - match document.FilePath with - | null -> true - | path -> - let documentIds = project.Solution.GetDocumentIdsWithFilePath path - - let owner = - instances - |> List.find (fun id -> documentIds |> Seq.exists (fun documentId -> documentId.ProjectId = id)) - - owner = project.Id - || (match conditionalDirectives.TryGetValue path with - | true, dependsOnDefines -> dependsOnDefines - | _ -> true) - let kindsProvided = ImmutableHashSet.Create( FSharpNavigateToItemKind.Module, @@ -238,7 +245,6 @@ type internal FSharpNavigateToSearchService let! results = project.Documents - |> Seq.filter (searchedIn project) |> Seq.map (processDocument tryMatch kinds) // Throttle to avoid launching a parse per document in the project all at once. |> CancellableTask.whenAllThrottled (max 1 Environment.ProcessorCount) diff --git a/vsintegration/tests/FSharp.Editor.Tests/MultiTargetNavigateToSearchTests.fs b/vsintegration/tests/FSharp.Editor.Tests/MultiTargetNavigateToSearchTests.fs index c1febedfd5e..65c1dc1c3eb 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/MultiTargetNavigateToSearchTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/MultiTargetNavigateToSearchTests.fs @@ -7,6 +7,8 @@ module FSharp.Editor.Tests.MultiTargetNavigateToSearchTests open System.Collections.Immutable open System.Threading open Xunit +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.Text open Microsoft.CodeAnalysis.ExternalAccess.FSharp.NavigateTo open FSharp.Editor.Tests.Helpers open FSharp.Test.ProjectGeneration @@ -45,26 +47,56 @@ let private solution, instances = let private service: IFSharpNavigateToSearchService = MefHelpers.createExportProvider().GetExportedValue() -/// Every instance searched in solution order, as the NavigateTo searcher does. -let private search pattern = - [ - for id in instances do - yield! - service - .SearchProjectAsync( - solution.GetProject id, - ImmutableArray.Empty, - pattern, - service.KindsProvided, - CancellationToken.None - ) - .Result - ] +let private searchIn (project: Project) pattern = + service.SearchProjectAsync(project, ImmutableArray.Empty, pattern, service.KindsProvided, CancellationToken.None).Result + |> Seq.map _.Name + |> Seq.filter ((=) pattern) + |> Seq.toList +let private documentNamed (name: string) (project: Project) = + project.Documents |> Seq.find (fun document -> document.Name.Contains name) + +/// Roslyn submits only the active project when the search is scoped to the current one, so an instance has +/// to report what it compiles even when a sibling compiles the same file. The second search is the one that +/// used to come back empty: by then the file had been parsed, and the parse said it did not depend on the +/// defines. [] -[] -[] -[] -let ``a declaration is reported once across the instances of a project`` (name: string) = - let names = search name |> List.map _.Name |> List.filter ((=) name) - Assert.Equal([ name ], names) +[] +[] +let ``an instance reports a shared declaration on its own, and again once the parse is cached`` (instance: int) = + let project = solution.GetProject instances[instance] + + Assert.Equal([ "plainUse" ], searchIn project "plainUse") + Assert.Equal([ "plainUse" ], searchIn project "plainUse") + +/// The parse of a file that does hold directives is kept per define set, so reusing it across the instances +/// must not leak the declaration into the instance that does not define FOO. +[] +let ``a declaration behind a directive is reported only by the instance that defines it`` () = + let plain = solution.GetProject instances[0] + let foo = solution.GetProject instances[1] + + Assert.Equal([ "fooUse" ], searchIn foo "fooUse") + Assert.Equal([], searchIn plain "fooUse") + +/// A file with no directives has its parse shared by every instance. When an edit gives it one, that shared +/// parse is stale: the entry is keyed by the text version, so the edit is a different entry rather than a +/// flag left over from before. +[] +let ``a declaration an edit puts behind a directive is reported by the instance that defines it`` () = + let edited = + (solution, instances) + ||> Seq.fold (fun (solution: Solution) instance -> + let document = solution.GetProject instance |> documentNamed "Second" + + solution.WithDocumentText( + document.Id, + SourceText.From "module ModuleSecond\n#if FOO\nlet addedFoo = 1\n#endif\n" + )) + + let foo = edited.GetProject instances[1] + let plain = edited.GetProject instances[0] + + // The instance without FOO goes first: that order is what left the stale answer behind. + Assert.Equal([], searchIn plain "addedFoo") + Assert.Equal([ "addedFoo" ], searchIn foo "addedFoo") From 156981c8c3d088beadf11b48aeba6f12ce8e16aa Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 11 Sep 2026 03:24:58 +0200 Subject: [PATCH 22/31] Name the navigable items cache's key and entry The cache was a dictionary from a struct tuple of two strings to an anonymous struct record, spelled out in the type arguments at the one place it is declared, and read back as `struct (key, path)` wherever it is used. Nothing at those sites said which string was the defines and which the path. Give both a name at the top of the file. They stay struct records, so the key keeps the same equality and the entries allocate no more than before. Co-Authored-By: Claude Opus 5 --- .../Navigation/NavigateToSearchService.fs | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs index bc3ef82da74..fc90d787204 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs @@ -20,6 +20,19 @@ open FSharp.Compiler.EditorServices open FSharp.Compiler.Syntax open CancellableTasks +/// Where a parse of a file is kept: under the defines it was parsed with, or under `AnyDefines` when its tree +/// holds no conditional directives and so reads the same under any of them. +[] +type private NavigableItemsKey = { Defines: string; FilePath: string } + +/// The navigable items of one parse of a file, and the text version it was taken from. +[] +type private NavigableItemsEntry = + { + Version: VersionStamp + Items: NavigableItem array + } + [); Shared>] type internal FSharpNavigateToSearchService [] @@ -33,14 +46,7 @@ type internal FSharpNavigateToSearchService /// /// The duplicate results this produces are not for this service to remove. `NavigateToSearcher` pools its /// seen set with `NavigateToSearchResultComparer`, which already collapses results by file path and span. - let cache = - ConcurrentDictionary< - struct (string * string), - struct {| - Version: VersionStamp - Items: NavigableItem array - |} - >() + let cache = ConcurrentDictionary() /// The key for a parse that does not depend on the defines. Not a define set any instance can have, /// since defines are identifiers — an instance with none of its own must not read this entry as its own. @@ -72,7 +78,7 @@ type internal FSharpNavigateToSearchService let defines = document.GetFSharpQuickDefines() |> String.concat ";" let cached key = - match cache.TryGetValue(struct (key, path)) with + match cache.TryGetValue({ Defines = key; FilePath = path }) with | true, entry when entry.Version = currentVersion -> ValueSome entry.Items | _ -> ValueNone @@ -89,11 +95,11 @@ type internal FSharpNavigateToSearchService else AnyDefines - cache[struct (key, path)] <- - {| + cache[{ Defines = key; FilePath = path }] <- + { Version = currentVersion Items = items - |} + } return items } From 1aa2de1bb9e4750b80b40a6f760748a80f945f77 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 11 Sep 2026 04:24:15 +0200 Subject: [PATCH 23/31] Format the multi-target NavigateTo tests Co-Authored-By: Claude Opus 5 --- .../FSharp.Editor.Tests/MultiTargetNavigateToSearchTests.fs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/MultiTargetNavigateToSearchTests.fs b/vsintegration/tests/FSharp.Editor.Tests/MultiTargetNavigateToSearchTests.fs index 65c1dc1c3eb..a8074d1de06 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/MultiTargetNavigateToSearchTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/MultiTargetNavigateToSearchTests.fs @@ -89,10 +89,7 @@ let ``a declaration an edit puts behind a directive is reported by the instance ||> Seq.fold (fun (solution: Solution) instance -> let document = solution.GetProject instance |> documentNamed "Second" - solution.WithDocumentText( - document.Id, - SourceText.From "module ModuleSecond\n#if FOO\nlet addedFoo = 1\n#endif\n" - )) + solution.WithDocumentText(document.Id, SourceText.From "module ModuleSecond\n#if FOO\nlet addedFoo = 1\n#endif\n")) let foo = edited.GetProject instances[1] let plain = edited.GetProject instances[0] From b1394fb855b9d0b4f742894471999fe00f0a2406 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 11 Sep 2026 17:40:05 +0200 Subject: [PATCH 24/31] Build the synthetic projects' id list through Seq and materialize once Co-Authored-By: Claude Opus 5 --- .../tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs index 89a449eceb7..fcc6c341868 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs @@ -382,8 +382,9 @@ type RoslynTestHelpers private () = let projects = syntheticProject.GetAllProjects() - |> List.distinctBy _.Name - |> List.map (fun project -> project, ProjectId.CreateNewId()) + |> Seq.distinctBy _.Name + |> Seq.map (fun project -> project, ProjectId.CreateNewId()) + |> Seq.toList let projectIds = dict [ for project, id in projects -> project.Name, id ] From f7267419da136c9eb576c0b98b683127ffcb502b Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sat, 26 Sep 2026 13:38:36 +0200 Subject: [PATCH 25/31] Key a kept parse by everything it depends on The defines a file was parsed with are not the whole of what its tree depends on: the language version decides what the parser accepts, and the defines were taken from the editing defaults when the project had not produced its options yet - a set another project really has, so its parse answered for a project that never asked for it, with the branch of a `#if` that project chose. The key now carries the language version, and both it and the defines are read only where the project has produced them: a project without options parses for itself instead of reading an entry that is not about it. The rest of the parsing options cannot differ for one path - the editor never applies line directives, and whether a file is interactive follows from its extension. Co-Authored-By: Claude Opus 5 (1M context) --- .../FSharpProjectOptionsManager.fs | 9 ++++ .../LanguageService/WorkspaceExtensions.fs | 7 +++ .../Navigation/NavigateToSearchService.fs | 45 +++++++++++----- .../NavigateToSearchServiceTests.fs | 51 +++++++++++++++++++ 4 files changed, 99 insertions(+), 13 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs b/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs index 4c5a01b6682..5b5d9ab0ea4 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs @@ -646,6 +646,15 @@ type internal FSharpProjectOptionsManager(checker: FSharpChecker, workspace: Wor CompilerEnvironment.GetConditionalDefinesForEditing parsingOptions, parsingOptions.LangVersionText + /// The defines and language version the document's project has produced, and nothing where it has not + /// produced its options yet: what the editing defaults answer there is a set no project asked for, so + /// anything kept under it belongs to no project either. + member _.TryGetCompilationDefinesAndLangVersionForEditingDocument(document: Document) = + match reactor.TryGetCachedOptionsByProjectId(document.Project.Id) with + | Some(_, parsingOptions, _) -> + ValueSome(struct (CompilerEnvironment.GetConditionalDefinesForEditing parsingOptions, parsingOptions.LangVersionText)) + | _ -> ValueNone + member _.TryGetOptionsByProject(project) = reactor.TryGetOptionsByProjectAsync(project) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/WorkspaceExtensions.fs b/vsintegration/src/FSharp.Editor/LanguageService/WorkspaceExtensions.fs index 0d11a4e6b8d..a3964f1b704 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/WorkspaceExtensions.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/WorkspaceExtensions.fs @@ -564,6 +564,13 @@ type Document with let workspaceService = this.Project.Solution.GetFSharpWorkspaceService() workspaceService.FSharpProjectOptionsManager.GetCompilationDefinesAndLangVersionForEditingDocument(this) + /// A non-async call that gets the defines and F# language version of the given F# document, and nothing + /// where its project has not produced its options yet: a guess names what the project never asked for, + /// which a parse must not be kept under. + member this.TryGetFSharpParsingOptionsData() = + let workspaceService = this.Project.Solution.GetFSharpWorkspaceService() + workspaceService.FSharpProjectOptionsManager.TryGetCompilationDefinesAndLangVersionForEditingDocument(this) + /// A non-async call that quickly gets the defines of the given F# document. /// This tries to get the defines by looking at an internal cache; if it doesn't exist in the cache it will create an inaccurate but usable form of the defines. member this.GetFSharpQuickDefines() = diff --git a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs index fc90d787204..406c2569274 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs @@ -20,10 +20,18 @@ open FSharp.Compiler.EditorServices open FSharp.Compiler.Syntax open CancellableTasks -/// Where a parse of a file is kept: under the defines it was parsed with, or under `AnyDefines` when its tree -/// holds no conditional directives and so reads the same under any of them. +/// Where a parse of a file is kept: under what the parse depends on besides the text. The defines are those it +/// was parsed with, or `AnyDefines` when its tree holds no conditional directives and so reads the same under +/// any of them; the language version decides what the parser accepts and is never shared across versions. The +/// remaining parsing options cannot differ for one path: the editor never applies line directives, and whether +/// a file is interactive follows from its own extension. [] -type private NavigableItemsKey = { Defines: string; FilePath: string } +type private NavigableItemsKey = + { + Defines: string + LangVersion: string + FilePath: string + } /// The navigable items of one parse of a file, and the text version it was taken from. [] @@ -70,15 +78,26 @@ type internal FSharpNavigateToSearchService let! ct = CancellableTask.getCancellationToken () let! currentVersion = document.GetTextVersionAsync(ct) - match document.FilePath with - | null -> + match document.FilePath, document.TryGetFSharpParsingOptionsData() with + // A document with no path is keyed by nothing, and one whose project has not produced its options + // yet knows neither the defines nor the language version its own parse depends on - an entry + // another instance wrote under what it really parsed with must not answer for it. + | null, _ + | _, ValueNone -> let! parseResults = document.GetFSharpParseResultsAsync(nameof (FSharpNavigateToSearchService)) return NavigateTo.GetNavigableItems parseResults.ParseTree - | path -> - let defines = document.GetFSharpQuickDefines() |> String.concat ";" - - let cached key = - match cache.TryGetValue({ Defines = key; FilePath = path }) with + | path, ValueSome(struct (documentDefines, langVersion)) -> + let defines = documentDefines |> String.concat ";" + + let keyOf defines = + { + Defines = defines + LangVersion = langVersion + FilePath = path + } + + let cached defines = + match cache.TryGetValue(keyOf defines) with | true, entry when entry.Version = currentVersion -> ValueSome entry.Items | _ -> ValueNone @@ -91,11 +110,11 @@ type internal FSharpNavigateToSearchService let key = if dependsOnDefines parseResults.ParseTree then - defines + keyOf defines else - AnyDefines + keyOf AnyDefines - cache[{ Defines = key; FilePath = path }] <- + cache[key] <- { Version = currentVersion Items = items diff --git a/vsintegration/tests/FSharp.Editor.Tests/NavigateToSearchServiceTests.fs b/vsintegration/tests/FSharp.Editor.Tests/NavigateToSearchServiceTests.fs index 08816ea6191..40960d9342c 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/NavigateToSearchServiceTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/NavigateToSearchServiceTests.fs @@ -3,7 +3,9 @@ namespace FSharp.Editor.Tests open Xunit +open System.Collections.Immutable open System.Threading +open Microsoft.CodeAnalysis open Microsoft.CodeAnalysis.Text open Microsoft.VisualStudio.FSharp.Editor open FSharp.Compiler.Text @@ -72,3 +74,52 @@ module HeyHo = [] let ``nested containers`` () = assertResultsContain "hh.a.b.g.d" "Delta" + + /// A project whose options have not arrived knows neither the defines nor the language version its own + /// parse depends on. The editing defaults it would otherwise be keyed under are a define set a real + /// project has — the one that defines nothing of its own — so keying it that way lets it answer from that + /// project's parse, with the branch of a `#if` that project chose. It asks for the parse it cannot have. + [] + let ``a project whose options have not arrived does not answer from another project's parse`` () = + let path = "C:\Shared.fs" + let source = "module Shared\n#if FOO\nlet fooOnly = 1\n#endif\nlet always = 2\n" + + // One loader for both copies of the file, as a linked file open in the editor has: the two documents + // then report the same text version, which is what lets one project read the other's parse at all. + let loader = + TextLoader.From(SourceText.From(source).Container, VersionStamp.Create()) + + let projectOf name = + let projectId = ProjectId.CreateNewId() + + projectId, + [ + DocumentInfo.Create(DocumentId.CreateNewId projectId, path, loader = loader, filePath = path) + ] + |> RoslynTestHelpers.CreateProjectInfo projectId $"C:\{name}.fsproj" + + let definesNothingId, definesNothing = projectOf "DefinesNothing" + let noOptionsId, noOptions = projectOf "NoOptions" + + let solution = RoslynTestHelpers.CreateSolution [ definesNothing; noOptions ] + + { RoslynTestHelpers.DefaultProjectOptions with + SourceFiles = [| path |] + } + |> RoslynTestHelpers.SetProjectOptions definesNothingId solution + + let service: IFSharpNavigateToSearchService = provider.GetExportedValue() + + let search (project: Project) pattern = + service.SearchProjectAsync(project, ImmutableArray.Empty, pattern, service.KindsProvided, CancellationToken.None).Result + |> Seq.map _.Name + |> Seq.filter ((=) pattern) + |> Seq.toList + + // Parses the file with no defines and keeps that parse under them. + Assert.Equal([ "always" ], search (solution.GetProject definesNothingId) "always") + + let searching = + Assert.ThrowsAny(fun () -> search (solution.GetProject noOptionsId) "always" |> ignore) + + Assert.IsAssignableFrom(searching.GetBaseException()) From 46d226e5e859d92ba61a7c2d9cbc5176f6d975d6 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Thu, 1 Oct 2026 19:16:33 +0200 Subject: [PATCH 26/31] Take the shared test fixture as #20462 lands it The multi-project and multi-target helpers now match the ones #20462 adds, struct results included, so this branch rebases onto it without reconciling two copies. A struct tuple cannot be bound by a `let private` pattern, so the module-level fixture bindings lose the modifier. Co-Authored-By: Claude Opus 5.5 --- .../Helpers/RoslynHelpers.fs | 20 ++++++++++++------- .../MultiTargetNavigateToSearchTests.fs | 2 +- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs index fcc6c341868..72e5ac9a27d 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs @@ -203,6 +203,7 @@ type TestHostServices() = /// One Roslyn project instance of a multi-targeted F# project: its extra defines and the /// synthetic files left out of it, as VS does per target framework. +[] type TargetInstance = { Defines: string list @@ -383,8 +384,8 @@ type RoslynTestHelpers private () = let projects = syntheticProject.GetAllProjects() |> Seq.distinctBy _.Name - |> Seq.map (fun project -> project, ProjectId.CreateNewId()) - |> Seq.toList + |> Seq.map (fun project -> struct (project, ProjectId.CreateNewId())) + |> Seq.toArray let projectIds = dict [ for project, id in projects -> project.Name, id ] @@ -414,10 +415,15 @@ type RoslynTestHelpers private () = project.GetProjectOptions checker |> RoslynTestHelpers.SetProjectOptions id solution - solution, checker + struct (solution, checker) + /// /// One Roslyn project per target instance, all sharing the .fsproj path and the document file /// paths, like the per-target-framework projects VS creates for a multi-targeted project. + /// + /// The project every instance is made from; it must not depend on other projects. + /// One Roslyn project per entry: its extra defines and the synthetic files left out of it. + /// The solution and the id of each instance's project, in the order of . static member CreateMultiTargetSolution(syntheticProject: SyntheticProject, instances: TargetInstance list) = assert (syntheticProject.DependsOn = []) @@ -430,13 +436,13 @@ type RoslynTestHelpers private () = for instance in instances -> let excludedPaths = HashSet( - [ + seq { for fileId in instance.ExcludedFileIds do syntheticProject.GetFilePath fileId if (syntheticProject.Find fileId).HasSignatureFile then syntheticProject.GetSignatureFilePath fileId - ], + }, StringComparer.OrdinalIgnoreCase ) @@ -468,7 +474,7 @@ type RoslynTestHelpers private () = |] } - id, projectInfo, instanceOptions + struct (id, projectInfo, instanceOptions) ] let solution = @@ -477,7 +483,7 @@ type RoslynTestHelpers private () = for id, _, instanceOptions in instances do RoslynTestHelpers.SetProjectOptions id solution instanceOptions - solution, [ for id, _, _ in instances -> id ] + struct (solution, [ for id, _, _ in instances -> id ]) static member GetFsDocument(code, ?customProjectOption: string, ?customEditorOptions) = let customProjectOptions = diff --git a/vsintegration/tests/FSharp.Editor.Tests/MultiTargetNavigateToSearchTests.fs b/vsintegration/tests/FSharp.Editor.Tests/MultiTargetNavigateToSearchTests.fs index a8074d1de06..f659ca0d77b 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/MultiTargetNavigateToSearchTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/MultiTargetNavigateToSearchTests.fs @@ -29,7 +29,7 @@ let private project = } ) -let private solution, instances = +let struct (solution, instances) = RoslynTestHelpers.CreateMultiTargetSolution( project, [ From 8080942f65a9804e865cb8ae5b1534b650a1caa2 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Thu, 1 Oct 2026 22:22:29 +0200 Subject: [PATCH 27/31] Keep the shared fixture's results private A struct tuple cannot be bound with `let private` (FS0010: the grammar takes an access modifier only before a name), so the binding sits in a private AutoOpen module: the tests here see both names, nothing outside does. Co-Authored-By: Claude Opus 5.5 --- .../MultiTargetNavigateToSearchTests.fs | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/MultiTargetNavigateToSearchTests.fs b/vsintegration/tests/FSharp.Editor.Tests/MultiTargetNavigateToSearchTests.fs index f659ca0d77b..715c75dc5ea 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/MultiTargetNavigateToSearchTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/MultiTargetNavigateToSearchTests.fs @@ -29,20 +29,22 @@ let private project = } ) -let struct (solution, instances) = - RoslynTestHelpers.CreateMultiTargetSolution( - project, - [ - { - Defines = [] - ExcludedFileIds = [ "Fourth" ] - } - { - Defines = [ "FOO" ] - ExcludedFileIds = [] - } - ] - ) +[] +module private Fixture = + let struct (solution, instances) = + RoslynTestHelpers.CreateMultiTargetSolution( + project, + [ + { + Defines = [] + ExcludedFileIds = [ "Fourth" ] + } + { + Defines = [ "FOO" ] + ExcludedFileIds = [] + } + ] + ) let private service: IFSharpNavigateToSearchService = MefHelpers.createExportProvider().GetExportedValue() From c523be897c89f902f3bbd28161045e2f3a8a133d Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 2 Oct 2026 00:44:51 +0200 Subject: [PATCH 28/31] Keep Navigate To's items on disk between sessions Navigate To parses every F# file of a solution the first time it searches it in a session, and while the solution loads it can only parse with guessed options. FSharpNavigableItemsCache now looks in storage before it parses and writes after every parse made with the project's options: under a checksum of the text when the tree holds no conditional directives, and of the text and the defines when it does - the two keys the memory cache already keeps a parse under. A file without directives is answered before its project has options at all. The store is one file per document in the folder Visual Studio keeps the solution's state in (.vs\), which IVsSolutionWorkingFolders names. A solution with no such folder keeps nothing. Co-Authored-By: Claude Opus 5.5 --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + .../Common/FilePersistentStorage.fs | 172 ++++++++ .../FSharp.Editor/Common/PersistentStorage.fs | 85 ++++ .../src/FSharp.Editor/FSharp.Editor.fsproj | 3 + .../Navigation/NavigableItemsIndex.fs | 247 +++++++++++ .../Navigation/NavigateToSearchService.fs | 384 +++++++++++++----- .../FSharp.Editor.Tests.fsproj | 1 + .../Helpers/RoslynHelpers.fs | 6 +- .../NavigateToPersistentCacheTests.fs | 220 ++++++++++ 9 files changed, 1017 insertions(+), 102 deletions(-) create mode 100644 vsintegration/src/FSharp.Editor/Common/FilePersistentStorage.fs create mode 100644 vsintegration/src/FSharp.Editor/Common/PersistentStorage.fs create mode 100644 vsintegration/src/FSharp.Editor/Navigation/NavigableItemsIndex.fs create mode 100644 vsintegration/tests/FSharp.Editor.Tests/NavigateToPersistentCacheTests.fs diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 76c9430403b..ebe3746babe 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -5,6 +5,7 @@ * 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)) * Added a **Tools > Options > F# Tools > Compiler** option, **"Use the .NET SDK F# compiler for builds"**, on by default. Visual Studio uses the .NET SDK F# compiler when the project supplies SDK paths, matching `dotnet build`. Otherwise, it uses the bundled .NET Framework compiler. Turn the option off to use the bundled compiler for all projects. Changes are saved on Apply or OK; Cancel discards unsaved changes. Command-line builds are unaffected. ([Issue #20484](https://github.com/dotnet/fsharp/issues/20484), [PR #20485](https://github.com/dotnet/fsharp/pull/20485)) * F# types, modules, members and values now appear in the GitHub Copilot Chat `#` mention picker, and attach their declaration source as context. +* Navigate To (Ctrl+T) keeps the declarations it finds in each F# file in the solution's `.vs` folder, under a checksum of the file's text — and, for a file with `#if`, of the defines it was parsed with — so the next session reads them back instead of parsing the file again, including while the solution is still loading. * F# types, modules, members and values now appear in the GitHub Copilot Chat `#` mention picker, and attach their declaration source as context. ([PR #20409](https://github.com/dotnet/fsharp/pull/20409)) ### Fixed diff --git a/vsintegration/src/FSharp.Editor/Common/FilePersistentStorage.fs b/vsintegration/src/FSharp.Editor/Common/FilePersistentStorage.fs new file mode 100644 index 00000000000..ab7e7e64249 --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Common/FilePersistentStorage.fs @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Editor + +open System +open System.Composition +open System.IO +open System.Runtime.InteropServices +open System.Threading.Tasks + +open Microsoft.CodeAnalysis +open Microsoft.Internal.VisualStudio.Shell.Interop +open Microsoft.VisualStudio.Shell +open Microsoft.VisualStudio.Shell.Interop +open Microsoft.VisualStudio.Threading + +open CancellableTasks + +/// Keeps the data of a solution in the folder Visual Studio keeps the solution's own state in — `.vs\` — +/// so it goes away with the solution and nothing of it is left under the user's profile. A solution Visual Studio +/// gives no such folder keeps nothing. One directory per format version, so a newer layout starts empty. +[); Export; Shared>] +type internal FSharpPersistentStorageConfiguration + [] + ([] serviceProvider: SVsServiceProvider) = + + static let invalidPathChars = set [ yield! Path.GetInvalidPathChars(); '/' ] + + static let safeName (fullPath: string) = + let fileName = Path.GetFileName fullPath + + let prefix = + if fileName.Length > 20 then + fileName.Substring(0, 20) + else + fileName + + match + $"{prefix}-{FSharpChecksum.Create fullPath}" + |> String.filter (invalidPathChars.Contains >> not) + with + | name when String.IsNullOrWhiteSpace name -> "None" + | name -> name + + /// The state folder of the solution open in Visual Studio, not specific to its version: the data has a format + /// version of its own. A solution not yet saved has only a temporary one, which does not outlive the session. + static let stateFolderOf (solution: obj) = + match solution with + | :? IVsSolutionWorkingFolders as folders -> + try + match + folders.GetFolder( + uint32 __SolutionWorkingFolder.SlnWF_StatePersistence, + Guid.Empty, + fVersionSpecific = false, + fEnsureCreated = true + ) + with + | false, folder when not (String.IsNullOrEmpty folder) -> ValueSome folder + | _ -> ValueNone + with :? COMException -> + ValueNone + | _ -> ValueNone + + /// Where the data of every solution goes instead of its state folder. Set by tests, which run without Visual + /// Studio. + member val CacheDirectory: string voption = ValueNone with get, set + + interface IFSharpPersistentStorageConfiguration with + member this.TryGetStorageLocationAsync(solution, cancellationToken) = + cancellableTask { + match solution.FilePath with + | null -> return ValueNone + | path when not (Path.IsPathRooted path) -> return ValueNone + | path -> + match this.CacheDirectory, serviceProvider with + | ValueSome directory, _ -> return ValueSome(Path.Combine(directory, safeName path, "v1")) + | ValueNone, null -> return ValueNone + | ValueNone, serviceProvider -> + do! ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken) + let stateFolder = stateFolderOf (serviceProvider.GetService typeof) + do! TaskScheduler.Default.SwitchTo() + + return + stateFolder + |> ValueOption.map (fun folder -> Path.Combine(folder, "FSharp", "v1")) + } + |> CancellableTask.start cancellationToken + +/// One file per document and name, named by a hash of the project's and the document's paths and names — never by +/// the document's id, which lasts one session. The file starts with the checksum it was written with. +type private FilePersistentStorage(directory: string) = + + static let magic = "FSPS"B + + static let header (checksum: FSharpChecksum) = Array.append magic (checksum.ToBytes()) + + let pathOf (document: Document) name = + let project = document.Project + + let key = + FSharpChecksum.Create [ project.FilePath; project.Name; document.FilePath; document.Name; name ] + + Path.Combine(directory, BitConverter.ToString(key.ToBytes()).Replace("-", "")) + + interface IFSharpChecksummedPersistentStorage with + member _.ReadStreamAsync(document, name, checksum, cancellationToken) = + cancellationToken.ThrowIfCancellationRequested() + let path = pathOf document name + let expected = header checksum + + match File.Exists path with + | false -> Task.FromResult null + | true -> + match File.ReadAllBytes path with + | bytes when bytes.Length >= expected.Length && Array.sub bytes 0 expected.Length = expected -> + Task.FromResult(new MemoryStream(bytes, expected.Length, bytes.Length - expected.Length, false) :> Stream) + | _ -> Task.FromResult null + + member _.WriteStreamAsync(document, name, stream, checksum, cancellationToken) = + cancellationToken.ThrowIfCancellationRequested() + Directory.CreateDirectory directory |> ignore + let path = pathOf document name + let written = $"{path}.{Guid.NewGuid():N}.tmp" + + try + do + use file = + new FileStream(written, FileMode.CreateNew, FileAccess.Write, FileShare.None) + + let header = header checksum + file.Write(header, 0, header.Length) + stream.CopyTo file + + // Another writer of the same entry that gets there first makes this one fail, and its data stands. + if File.Exists path then + File.Replace(written, path, null) + else + File.Move(written, path) + + Task.FromResult true + finally + File.Delete written + +/// The storage of the solution it was last asked for, as Roslyn keeps one database open. +[); Shared>] +type internal FSharpFilePersistentStorageService [] (configuration: IFSharpPersistentStorageConfiguration) = + + static let noStorage = + { new IFSharpChecksummedPersistentStorage with + member _.ReadStreamAsync(_, _, _, _) = Task.FromResult null + member _.WriteStreamAsync(_, _, _, _, _) = Task.FromResult false + } + + let mutable current = (null: string), noStorage + + interface IFSharpChecksummedPersistentStorageService with + member _.GetStorageAsync(solution, cancellationToken) = + match current with + | solutionPath, storage when String.Equals(solutionPath, solution.FilePath, StringComparison.Ordinal) -> Task.FromResult storage + | _ -> + task { + let! location = configuration.TryGetStorageLocationAsync(solution, cancellationToken) + + let storage = + match location with + | ValueSome directory -> FilePersistentStorage directory :> IFSharpChecksummedPersistentStorage + | ValueNone -> noStorage + + current <- solution.FilePath, storage + return storage + } diff --git a/vsintegration/src/FSharp.Editor/Common/PersistentStorage.fs b/vsintegration/src/FSharp.Editor/Common/PersistentStorage.fs new file mode 100644 index 00000000000..1db79a03fe9 --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Common/PersistentStorage.fs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Editor + +open System +open System.Collections.Immutable +open System.IO +open System.Security.Cryptography +open System.Text +open System.Threading +open System.Threading.Tasks + +open Microsoft.CodeAnalysis + +/// A hash of what persisted data was computed from. 16 bytes, the size of Roslyn's `Checksum`, so that Roslyn's own +/// storage can take it as it is. +[] +type internal FSharpChecksum = + { + Data1: int64 + Data2: int64 + } + + static member Create(bytes: byte array) : FSharpChecksum = + use sha256 = SHA256.Create() + let hash = sha256.ComputeHash bytes + + { + Data1 = BitConverter.ToInt64(hash, 0) + Data2 = BitConverter.ToInt64(hash, 8) + } + + static member Create(bytes: ImmutableArray) : FSharpChecksum = + FSharpChecksum.Create(Seq.toArray bytes) + + static member Create(value: string) : FSharpChecksum = + FSharpChecksum.Create(Encoding.UTF8.GetBytes value) + + /// The values in order, each told apart from its neighbours and from a missing one. + static member Create(values: string seq) : FSharpChecksum = + use buffer = new MemoryStream() + + do + use writer = new BinaryWriter(buffer, Encoding.UTF8, leaveOpen = true) + + for value in values do + match value with + | null -> writer.Write false + | value -> + writer.Write true + writer.Write value + + FSharpChecksum.Create(buffer.ToArray()) + + static member Create(first: FSharpChecksum, second: FSharpChecksum) : FSharpChecksum = + FSharpChecksum.Create(Array.append (first.ToBytes()) (second.ToBytes())) + + member this.ToBytes() : byte array = + Array.append (BitConverter.GetBytes this.Data1) (BitConverter.GetBytes this.Data2) + + override this.ToString() = Convert.ToBase64String(this.ToBytes()) + +/// The per-document part of Roslyn's `IChecksummedPersistentStorage`, which this mirrors so that the store behind it +/// can become Roslyn's own. +type internal IFSharpChecksummedPersistentStorage = + + /// The data stored for the document under the name; null if there is none, or it was written with another + /// checksum. + abstract ReadStreamAsync: + document: Document * name: string * checksum: FSharpChecksum * cancellationToken: CancellationToken -> Task + + /// Stores the data for the document under the name, with the checksum a read compares; false if it was not stored. + abstract WriteStreamAsync: + document: Document * name: string * stream: Stream * checksum: FSharpChecksum * cancellationToken: CancellationToken -> Task + +/// Hands out the storage of a solution, as Roslyn's `IChecksummedPersistentStorageService` does. +type internal IFSharpChecksummedPersistentStorageService = + abstract GetStorageAsync: solution: Solution * cancellationToken: CancellationToken -> Task + +/// Where the data of a solution is kept, as Roslyn's `IPersistentStorageConfiguration` decides it. +type internal IFSharpPersistentStorageConfiguration = + + /// The directory, or ValueNone to keep nothing for the solution. Asynchronous, unlike Roslyn's, because Visual + /// Studio answers where a solution keeps its state only on the main thread. + abstract TryGetStorageLocationAsync: solution: Solution * cancellationToken: CancellationToken -> Task diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj index 3176e1b964c..5d921b9eaa3 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj @@ -37,6 +37,8 @@ + + @@ -91,6 +93,7 @@ + diff --git a/vsintegration/src/FSharp.Editor/Navigation/NavigableItemsIndex.fs b/vsintegration/src/FSharp.Editor/Navigation/NavigableItemsIndex.fs new file mode 100644 index 00000000000..7a1fe09255a --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Navigation/NavigableItemsIndex.fs @@ -0,0 +1,247 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +/// The navigable items of a document as persistent storage keeps them, after Roslyn's `AbstractSyntaxIndex`: stored +/// with a checksum of what they were parsed from, compressed. +module internal Microsoft.VisualStudio.FSharp.Editor.NavigableItemsIndex + +open System +open System.Collections.Generic +open System.IO +open System.IO.Compression +open System.Security +open System.Text + +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.Text + +open FSharp.Compiler.EditorServices +open FSharp.Compiler.Text +open CancellableTasks + +[] +let PersistenceName = "FSharpNavigableItemsIndex" + +/// Changes with what is written: the layout below, and what `NavigateTo.GetNavigableItems` makes of a tree, which +/// ships in another assembly and so is tied to that assembly's build. +let private formatChecksum = + FSharpChecksum.Create + [ + "2" + typeof.Assembly.ManifestModule.ModuleVersionId.ToString() + ] + +/// Identifies a parse whose tree holds no conditional directives, and so reads the same under any defines. +let textChecksum (text: SourceText) = + FSharpChecksum.Create(FSharpChecksum.Create(text.GetChecksum()), formatChecksum) + +/// Identifies a parse whose tree does hold them, which only stands for the defines it was made with. +let textAndDefinesChecksum textChecksum (defines: string) = + FSharpChecksum.Create(textChecksum, FSharpChecksum.Create defines) + +[] +let private FileTag = 0uy + +[] +let private ContainerTag = 1uy + +[] +let private ItemTag = 2uy + +[] +let private EndTag = 3uy + +let private containerTypeTag containerType = + match containerType with + | NavigableContainerType.File -> 0uy + | NavigableContainerType.Namespace -> 1uy + | NavigableContainerType.Module -> 2uy + | NavigableContainerType.Type -> 3uy + | NavigableContainerType.Exception -> 4uy + +let private containerTypeOfTag tag = + match tag with + | 0uy -> NavigableContainerType.File + | 1uy -> NavigableContainerType.Namespace + | 2uy -> NavigableContainerType.Module + | 3uy -> NavigableContainerType.Type + | 4uy -> NavigableContainerType.Exception + | tag -> raise (InvalidDataException $"No container type has tag %d{tag}.") + +let private kindTag kind = + match kind with + | NavigableItemKind.Module -> 0uy + | NavigableItemKind.ModuleAbbreviation -> 1uy + | NavigableItemKind.Exception -> 2uy + | NavigableItemKind.Type -> 3uy + | NavigableItemKind.ModuleValue -> 4uy + | NavigableItemKind.Field -> 5uy + | NavigableItemKind.Property -> 6uy + | NavigableItemKind.Constructor -> 7uy + | NavigableItemKind.Member -> 8uy + | NavigableItemKind.EnumCase -> 9uy + | NavigableItemKind.UnionCase -> 10uy + +let private kindOfTag tag = + match tag with + | 0uy -> NavigableItemKind.Module + | 1uy -> NavigableItemKind.ModuleAbbreviation + | 2uy -> NavigableItemKind.Exception + | 3uy -> NavigableItemKind.Type + | 4uy -> NavigableItemKind.ModuleValue + | 5uy -> NavigableItemKind.Field + | 6uy -> NavigableItemKind.Property + | 7uy -> NavigableItemKind.Constructor + | 8uy -> NavigableItemKind.Member + | 9uy -> NavigableItemKind.EnumCase + | 10uy -> NavigableItemKind.UnionCase + | tag -> raise (InvalidDataException $"No navigable item kind has tag %d{tag}.") + +/// Containers are numbered in the order they are written, each after its parent, and an item names its container by +/// that number: items of one container share it once read back, as they did when parsed. +let private write (writer: BinaryWriter) (items: NavigableItem array) = + let numbers = Dictionary(HashIdentity.Reference) + + let rec writeContainer container = + if not (numbers.ContainsKey container) then + match container with + | NavigableContainer.File fileName -> + writer.Write FileTag + writer.Write fileName + | NavigableContainer.Container(containerType, nameParts, parent) -> + writeContainer parent + writer.Write ContainerTag + writer.Write(containerTypeTag containerType) + writer.Write nameParts.Length + + for part in nameParts do + writer.Write part + + writer.Write numbers[parent] + + numbers[container] <- numbers.Count + + for item in items do + writeContainer item.Container + writer.Write ItemTag + writer.Write item.Name + writer.Write item.NeedsBackticks + writer.Write item.IsSignature + writer.Write(kindTag item.Kind) + writer.Write numbers[item.Container] + writer.Write item.Range.FileName + writer.Write item.Range.StartLine + writer.Write item.Range.StartColumn + writer.Write item.Range.EndLine + writer.Write item.Range.EndColumn + + writer.Write EndTag + +let private read (reader: BinaryReader) = + let containers = ResizeArray() + let items = ResizeArray() + + let container number = + if number >= 0 && number < containers.Count then + containers[number] + else + raise (InvalidDataException $"No container has number %d{number}.") + + let readContainer () = + let containerType = containerTypeOfTag (reader.ReadByte()) + let nameParts = List.init (reader.ReadInt32()) (fun _ -> reader.ReadString()) + let parent = container (reader.ReadInt32()) + + NavigableContainer.Container(containerType, nameParts, parent) + + let readItem () = + let name = reader.ReadString() + let needsBackticks = reader.ReadBoolean() + let isSignature = reader.ReadBoolean() + let kind = kindOfTag (reader.ReadByte()) + let itemContainer = container (reader.ReadInt32()) + let fileName = reader.ReadString() + let start = Position.mkPos (reader.ReadInt32()) (reader.ReadInt32()) + let finish = Position.mkPos (reader.ReadInt32()) (reader.ReadInt32()) + + { + Name = name + NeedsBackticks = needsBackticks + Range = Range.mkRange fileName start finish + IsSignature = isSignature + Kind = kind + Container = itemContainer + } + + let mutable finished = false + + while not finished do + match reader.ReadByte() with + | FileTag -> containers.Add(NavigableContainer.File(reader.ReadString())) + | ContainerTag -> containers.Add(readContainer ()) + | ItemTag -> items.Add(readItem ()) + | EndTag -> finished <- true + | tag -> raise (InvalidDataException $"No entry has tag %d{tag}.") + + items.ToArray() + +let private serialize items = + let stream = new MemoryStream() + + do + use compressed = new GZipStream(stream, CompressionLevel.Optimal, leaveOpen = true) + use writer = new BinaryWriter(compressed, Encoding.UTF8, leaveOpen = true) + write writer items + + stream.Position <- 0L + stream + +let private deserialize (stream: Stream) = + use compressed = + new GZipStream(stream, CompressionMode.Decompress, leaveOpen = true) + + use reader = new BinaryReader(compressed, Encoding.UTF8, leaveOpen = true) + read reader + +/// What Roslyn's `IOUtilities.IsNormalIOException` lets storage throw — a damaged entry included — without failing +/// the feature that asked. +let private isNormalIOException (e: exn) = + match e with + | :? IOException + | :? SecurityException + | :? ArgumentException + | :? UnauthorizedAccessException + | :? NotSupportedException + | :? InvalidOperationException + | :? InvalidDataException -> true + | _ -> false + +/// The items stored for the document, if they were stored with the checksum. +let tryLoad (storageService: IFSharpChecksummedPersistentStorageService) (document: Document) checksum = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + + try + let! storage = storageService.GetStorageAsync(document.Project.Solution, ct) + let! stream = storage.ReadStreamAsync(document, PersistenceName, checksum, ct) + + match stream with + | null -> return ValueNone + | stream -> + use stream = stream + return ValueSome(deserialize stream) + with e when isNormalIOException e -> + return ValueNone + } + +let save (storageService: IFSharpChecksummedPersistentStorageService) (document: Document) checksum items = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + + try + let! storage = storageService.GetStorageAsync(document.Project.Solution, ct) + use stream = serialize items + let! _ = storage.WriteStreamAsync(document, PersistenceName, stream, checksum, ct) + () + with e when isNormalIOException e -> + () + } diff --git a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs index 406c2569274..4f514886557 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs @@ -7,12 +7,15 @@ open System.IO open System.Composition open System.Collections.Immutable open System.Collections.Concurrent -open System.Threading.Tasks open System.Globalization +open System.Linq +open System.Threading +open System.Threading.Tasks open Microsoft.CodeAnalysis open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Navigation open Microsoft.CodeAnalysis.ExternalAccess.FSharp.NavigateTo +open Microsoft.CodeAnalysis.Text open Microsoft.VisualStudio.LanguageServices open Microsoft.VisualStudio.Text.PatternMatching @@ -20,11 +23,11 @@ open FSharp.Compiler.EditorServices open FSharp.Compiler.Syntax open CancellableTasks -/// Where a parse of a file is kept: under what the parse depends on besides the text. The defines are those it -/// was parsed with, or `AnyDefines` when its tree holds no conditional directives and so reads the same under -/// any of them; the language version decides what the parser accepts and is never shared across versions. The -/// remaining parsing options cannot differ for one path: the editor never applies line directives, and whether -/// a file is interactive follows from its own extension. +/// Where a parse of a file is kept: under what the parse depends on besides the text. The defines are those it was +/// parsed with, or `AnyDefines` when its tree holds no conditional directives and so reads the same under any of +/// them; the language version decides what the parser accepts and is never shared across versions. The remaining +/// parsing options cannot differ for one path: the editor never applies line directives, and whether a file is +/// interactive follows from its own extension. [] type private NavigableItemsKey = { @@ -38,22 +41,30 @@ type private NavigableItemsKey = type private NavigableItemsEntry = { Version: VersionStamp + /// Parsed without the project's defines, while the solution was still loading. + Approximate: bool Items: NavigableItem array } -[); Shared>] -type internal FSharpNavigateToSearchService +/// Parse-tree navigable items per document, cached on the document's text version, and kept in persistent storage +/// on the text's checksum so that the next session reads them back instead of parsing. +/// Shared by NavigateTo and by the Copilot chat mention provider. +[] +type internal FSharpNavigableItemsCache [] - (patternMatcherFactory: IPatternMatcherFactory, [] workspace: VisualStudioWorkspace) = + ( + patternMatcherFactory: IPatternMatcherFactory, + storageService: IFSharpChecksummedPersistentStorageService, + [] workspace: VisualStudioWorkspace + ) = /// A multi-targeted project is one Roslyn project per target framework over the same files, so the same - /// file is searched once per instance. What that costs is the parse, and a parse whose tree holds no - /// conditional directives does not depend on the defines: it is stored under `AnyDefines` and every - /// instance reuses it. One that does hold them is stored per define set, because those instances - /// genuinely parse the file differently. + /// file is parsed once per instance. A parse whose tree holds no conditional directives does not depend + /// on the defines: it is stored under `AnyDefines` and every instance reuses it. One that does hold them + /// is stored per define set, because those instances genuinely parse the file differently. /// - /// The duplicate results this produces are not for this service to remove. `NavigateToSearcher` pools its - /// seen set with `NavigateToSearchResultComparer`, which already collapses results by file path and span. + /// The duplicate results that produces are not for this service to remove: `NavigateToSearcher` pools its + /// seen set with `NavigateToSearchResultComparer`, which collapses results by file path and span. let cache = ConcurrentDictionary() /// The key for a parse that does not depend on the defines. Not a define set any instance can have, @@ -61,68 +72,204 @@ type internal FSharpNavigateToSearchService [] let AnyDefines = "?" + /// The key for a parse made before the project's options arrived, with the editing defaults in place of its + /// defines. Not a define set any instance can have, for the same reason as `AnyDefines`. + [] + let UnknownDefines = "??" + do - if workspace <> null then - workspace.WorkspaceChanged.Add - <| fun e -> + match workspace with + | null -> () + | workspace -> + workspace.WorkspaceChanged.Add(fun e -> if e.NewSolution.Id <> e.OldSolution.Id then - cache.Clear() + cache.Clear()) let dependsOnDefines (parseTree: ParsedInput) = match parseTree with | ParsedInput.ImplFile file -> not file.Trivia.ConditionalDirectives.IsEmpty | ParsedInput.SigFile file -> not file.Trivia.ConditionalDirectives.IsEmpty - let getNavigableItems (document: Document) = + let definesOf (document: Document) = + document.GetFSharpQuickDefines() |> String.concat ";" + + let keyOf (document: Document) defines path = + { + Defines = defines + LangVersion = snd (document.GetFsharpParsingOptions()) + FilePath = path + } + + /// The entry for the file, from the parse every instance shares when it has no directives, otherwise from + /// the one parsed with this instance's defines. `matchesVersion` is false for the caller that takes the + /// last parse whatever version it came from. + let tryCached (document: Document) matchesVersion = + match document.FilePath with + | null -> ValueNone + | path -> + let entry defines = + match cache.TryGetValue(keyOf document defines path) with + | true, entry when matchesVersion entry.Version -> ValueSome entry + | _ -> ValueNone + + // The defines are the project's own only once it has produced its options. Before that they are the + // editing defaults, which a project that defines nothing of its own really has, so a project without + // options would read that project's parse as if it were its own. + let ownParse = + match document.TryGetFSharpParsingOptionsData() with + | ValueSome _ -> entry (definesOf document) + | ValueNone -> ValueNone + + match entry AnyDefines with + | ValueSome found -> ValueSome found + | ValueNone -> + match ownParse with + | ValueSome found -> ValueSome found + // Only the search that runs while the solution loads takes this one: it is approximate, and the + // exact search refuses an approximate entry. + | ValueNone -> entry UnknownDefines + + let remember (document: Document) defines version approximate items = + match document.FilePath with + | null -> () + | path -> + cache[keyOf document defines path] <- + { + Version = version + Approximate = approximate + Items = items + } + + let store (document: Document) version approximate (parseTree: ParsedInput) = + let items = NavigateTo.GetNavigableItems parseTree + + let defines = + if not (dependsOnDefines parseTree) then + AnyDefines + elif approximate then + // A project without its options yet parses with the editing defaults, which are nobody's defines: + // an instance whose own defines happen to equal them must not read this entry as its own parse. + UnknownDefines + else + definesOf document + + remember document defines version approximate items + items + + /// Storage keeps one entry per document, under the checksum of the text alone when its tree holds no conditional + /// directives and of the text and the defines when it does — as the memory entry is kept under `AnyDefines` or + /// the defines — so a read tries one checksum, then the other. + let tryRestore (document: Document) defines version approximate checksum = + cancellableTask { + match! NavigableItemsIndex.tryLoad storageService document checksum with + | ValueSome items -> + remember document defines version approximate items + return ValueSome items + | ValueNone -> return ValueNone + } + + member _.GetNavigableItems(document: Document) = cancellableTask { let! ct = CancellableTask.getCancellationToken () let! currentVersion = document.GetTextVersionAsync(ct) - match document.FilePath, document.TryGetFSharpParsingOptionsData() with - // A document with no path is keyed by nothing, and one whose project has not produced its options - // yet knows neither the defines nor the language version its own parse depends on - an entry - // another instance wrote under what it really parsed with must not answer for it. - | null, _ - | _, ValueNone -> - let! parseResults = document.GetFSharpParseResultsAsync(nameof (FSharpNavigateToSearchService)) - return NavigateTo.GetNavigableItems parseResults.ParseTree - | path, ValueSome(struct (documentDefines, langVersion)) -> - let defines = documentDefines |> String.concat ";" - - let keyOf defines = - { - Defines = defines - LangVersion = langVersion - FilePath = path - } - - let cached defines = - match cache.TryGetValue(keyOf defines) with - | true, entry when entry.Version = currentVersion -> ValueSome entry.Items - | _ -> ValueNone - - match cached AnyDefines, cached defines with - | ValueSome items, _ - | _, ValueSome items -> return items - | ValueNone, ValueNone -> - let! parseResults = document.GetFSharpParseResultsAsync(nameof (FSharpNavigateToSearchService)) - let items = NavigateTo.GetNavigableItems parseResults.ParseTree - - let key = - if dependsOnDefines parseResults.ParseTree then - keyOf defines - else - keyOf AnyDefines - - cache[key] <- - { - Version = currentVersion - Items = items - } - - return items + match tryCached document ((=) currentVersion) with + | ValueSome entry when not entry.Approximate -> return entry.Items + | _ -> + let! text = document.GetTextAsync(ct) + let textChecksum = NavigableItemsIndex.textChecksum text + + match! tryRestore document AnyDefines currentVersion false textChecksum with + | ValueSome items -> return items + | ValueNone -> + // The defines are only the project's once it has its options. + let! _ = document.GetFSharpCompilationOptionsAsync(nameof (FSharpNavigableItemsCache)) + let defines = definesOf document + + let definesChecksum = + NavigableItemsIndex.textAndDefinesChecksum textChecksum defines + + match! tryRestore document defines currentVersion false definesChecksum with + | ValueSome items -> return items + | ValueNone -> + let! parseResults = document.GetFSharpParseResultsAsync(nameof (FSharpNavigableItemsCache)) + let parseTree = parseResults.ParseTree + let items = store document currentVersion false parseTree + + let checksum = + if dependsOnDefines parseTree then + definesChecksum + else + textChecksum + + do! NavigableItemsIndex.save storageService document checksum items + return items } + /// The items of a parse that does not wait for the project's compilation options, for the search that runs while + /// the solution is still loading. A file behind `#if` can be read under the wrong defines, so the entry it leaves + /// behind never answers `GetNavigableItems`, and is not stored. + member _.GetNavigableItemsWhileLoading(document: Document) = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + let! currentVersion = document.GetTextVersionAsync(ct) + + match tryCached document ((=) currentVersion) with + | ValueSome entry -> return entry.Items + | ValueNone -> + let! text = document.GetTextAsync(ct) + let textChecksum = NavigableItemsIndex.textChecksum text + + match! tryRestore document AnyDefines currentVersion false textChecksum with + | ValueSome items -> return items + | ValueNone -> + let defines = definesOf document + + let definesChecksum = + NavigableItemsIndex.textAndDefinesChecksum textChecksum defines + + match! tryRestore document defines currentVersion true definesChecksum with + | ValueSome items -> return items + | ValueNone -> + let! parseResults = document.GetFSharpQuickParseResultsAsync(nameof (FSharpNavigableItemsCache)) + return store document currentVersion true parseResults.ParseTree + } + + member _.CreateMatcherFor(searchPattern: string) = + let patternMatcher = + patternMatcherFactory.CreatePatternMatcher( + searchPattern, + PatternMatcherCreationOptions( + cultureInfo = CultureInfo.CurrentUICulture, + flags = PatternMatcherCreationFlags.AllowFuzzyMatching, + containerSplitCharacters = [ '.' ] + ) + ) + + fun (item: NavigableItem) -> + // PatternMatcher will not match operators and some backtick escaped identifiers. + // To handle them, we fall back to simple substring match. + let name = item.Name + + if item.NeedsBackticks then + match name.IndexOf(searchPattern, StringComparison.CurrentCultureIgnoreCase) with + | i when i > 0 -> ValueSome(PatternMatch(PatternMatchKind.Substring, false, false)) + | 0 when name.Length = searchPattern.Length -> ValueSome(PatternMatch(PatternMatchKind.Exact, false, false)) + | 0 -> ValueSome(PatternMatch(PatternMatchKind.Prefix, false, false)) + | _ -> ValueNone + else + // full name with dots allows for path matching, e.g. + // "f.c.so.elseif" will match "Fantomas.Core.SyntaxOak.ElseIfNode" + patternMatcher.TryMatch $"{item.Container.FullName}.{name}" + |> ValueOption.ofNullable + +[); Shared>] +type internal FSharpNavigateToSearchService [] (itemsCache: FSharpNavigableItemsCache) = + + /// The parses of the search that runs while the solution loads take turns across all its projects, and leave a + /// core to the load itself. + let loadingThrottle = new SemaphoreSlim(max 1 (Environment.ProcessorCount - 1)) + let kindsProvided = ImmutableHashSet.Create( FSharpNavigateToItemKind.Module, @@ -191,37 +338,17 @@ type internal FSharpNavigateToSearchService | PatternMatchKind.Fuzzy -> FSharpNavigateToMatchKind.Fuzzy | _ -> FSharpNavigateToMatchKind.None - let createMatcherFor searchPattern = - let patternMatcher = - patternMatcherFactory.CreatePatternMatcher( - searchPattern, - PatternMatcherCreationOptions( - cultureInfo = CultureInfo.CurrentUICulture, - flags = PatternMatcherCreationFlags.AllowFuzzyMatching, - containerSplitCharacters = [ '.' ] - ) - ) - - fun (item: NavigableItem) -> - // PatternMatcher will not match operators and some backtick escaped identifiers. - // To handle them, we fall back to simple substring match. - let name = item.Name - - if item.NeedsBackticks then - match name.IndexOf(searchPattern, StringComparison.CurrentCultureIgnoreCase) with - | i when i > 0 -> ValueSome(PatternMatch(PatternMatchKind.Substring, false, false)) - | 0 when name.Length = searchPattern.Length -> ValueSome(PatternMatch(PatternMatchKind.Exact, false, false)) - | 0 -> ValueSome(PatternMatch(PatternMatchKind.Prefix, false, false)) - | _ -> ValueNone - else - // full name with dots allows for path matching, e.g. - // "f.c.so.elseif" will match "Fantomas.Core.SyntaxOak.ElseIfNode" - patternMatcher.TryMatch $"{item.Container.FullName}.{name}" - |> ValueOption.ofNullable + let createMatcherFor (searchPattern: string) = + itemsCache.CreateMatcherFor searchPattern - let processDocument (tryMatch: NavigableItem -> PatternMatch voption) (kinds: IImmutableSet) (document: Document) = + let processDocument + (getItems: Document -> CancellableTask) + (tryMatch: NavigableItem -> PatternMatch voption) + (kinds: IImmutableSet) + (document: Document) + = cancellableTask { - let! items = getNavigableItems document + let! items = getItems document let matches = [| @@ -234,7 +361,7 @@ type internal FSharpNavigateToSearchService // The text, read from disk for a closed document, is only needed to place the matches. if matches.Length = 0 then - return [||] + return ImmutableArray.Empty else let! ct = CancellableTask.getCancellationToken () let! sourceText = document.GetTextAsync ct @@ -259,6 +386,23 @@ type internal FSharpNavigateToSearchService ) ) |] + |> Array.toImmutableArray + } + + /// Priority items first, each half in its original order, as NavigateTo's own service orders its work. + let prioritize isPriority items = + let priority, rest = items |> Seq.toArray |> Array.partition isPriority + [| yield! priority; yield! rest |] + + let throttled (work: CancellableTask<'a>) = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + do! loadingThrottle.WaitAsync ct + + try + return! work + finally + loadingThrottle.Release() |> ignore } interface IFSharpNavigateToSearchService with @@ -270,21 +414,61 @@ type internal FSharpNavigateToSearchService let! results = project.Documents - |> Seq.map (processDocument tryMatch kinds) + |> Seq.map (processDocument itemsCache.GetNavigableItems tryMatch kinds) // Throttle to avoid launching a parse per document in the project all at once. |> CancellableTask.whenAllThrottled (max 1 Environment.ProcessorCount) - return results |> Array.concat |> Array.toImmutableArray + return results |> Seq.collect _.AsEnumerable() |> Seq.toImmutableArray } |> CancellableTask.start cancellationToken member _.SearchDocumentAsync(document: Document, searchPattern, kinds, cancellationToken) = - cancellableTask { - let! result = processDocument (createMatcherFor searchPattern) kinds document - return Array.toImmutableArray result - } - |> CancellableTask.start cancellationToken + processDocument itemsCache.GetNavigableItems (createMatcherFor searchPattern) kinds document cancellationToken member _.KindsProvided = kindsProvided member _.CanFilter = true + + interface IFSharpAdvancedNavigateToSearchService with + member _.SearchCachedDocumentsAsync + ( + _solution, + projects, + priorityDocuments, + searchPattern, + kinds, + _activeDocument, + onResultsFound, + onProjectCompleted, + cancellationToken + ) : Task = + let tryMatch = createMatcherFor searchPattern + let priorityIds = ImmutableHashSet.CreateRange(priorityDocuments |> Seq.map _.Id) + let isPriority (document: Document) = priorityIds.Contains document.Id + + let searchDocumentWhileLoading document = + cancellableTask { + let! results = throttled (processDocument itemsCache.GetNavigableItemsWhileLoading tryMatch kinds document) + + if results.Length > 0 then + do! onResultsFound.Invoke results + } + + // Every document waits on the throttle in the order it is started, so priority documents, and the + // projects that hold them, are parsed first. + let searchProjectWhileLoading (project: Project) = + cancellableTask { + let! _ = + project.Documents + |> prioritize isPriority + |> Seq.map searchDocumentWhileLoading + |> CancellableTask.whenAll + + do! onProjectCompleted.Invoke() + } + + projects + |> prioritize (fun project -> project.Documents |> Seq.exists isPriority) + |> Seq.map searchProjectWhileLoading + |> CancellableTask.whenAll + |> CancellableTask.startAsTask cancellationToken diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj index 974b8667abd..6d0e9393e47 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj +++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj @@ -34,6 +34,7 @@ + diff --git a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs index 72e5ac9a27d..a5ebea3631a 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Helpers/RoslynHelpers.fs @@ -238,12 +238,14 @@ type RoslynTestHelpers private () = | _ -> failwith "not supported" static member CreateSolution projects = + RoslynTestHelpers.CreateSolutionAt "test.sln" projects + + static member CreateSolutionAt (filePath: string) projects = let workspace = new AdhocWorkspace(TestHostServices()) let id = SolutionId.CreateNewId() let versionStamp = VersionStamp.Create(DateTime.UtcNow) - let slnPath = "test.sln" - let solutionInfo = SolutionInfo.Create(id, versionStamp, slnPath, projects) + let solutionInfo = SolutionInfo.Create(id, versionStamp, filePath, projects) let solution = workspace.AddSolution(solutionInfo) solution diff --git a/vsintegration/tests/FSharp.Editor.Tests/NavigateToPersistentCacheTests.fs b/vsintegration/tests/FSharp.Editor.Tests/NavigateToPersistentCacheTests.fs new file mode 100644 index 00000000000..e1a0d40437c --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/NavigateToPersistentCacheTests.fs @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +/// Navigate To keeps the navigable items of a document in persistent storage, so that the next session reads them +/// back instead of parsing — which, before a project has its options, it cannot do at all. +module FSharp.Editor.Tests.NavigateToPersistentCacheTests + +open System +open System.Collections.Immutable +open System.IO +open System.Threading +open System.Threading.Tasks + +open Xunit + +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.NavigateTo +open Microsoft.VisualStudio.FSharp.Editor + +open FSharp.Compiler.CodeAnalysis +open FSharp.Compiler.EditorServices +open FSharp.Compiler.Text + +open FSharp.Editor.Tests.Helpers + +[] +let private ImplementationSource = + """ +namespace Persisted.Space + +exception Failed of string + +module Outer = + type Shape = + | Dot + | Circle of radius: float + + type Point = { X: int; Y: int } + + type Colour = + | Red = 1 + | Green = 2 + + type Counter(start: int) = + new() = Counter 0 + member val Count = start with get, set + member this.Increment() = this.Count <- this.Count + 1 + + module Inner = + let ``needs backticks`` = 1 + let twice x = 2 * x + + module Alias = Inner +""" + +[] +let private SignatureSource = + """ +namespace Persisted.Space + +module Outer = + type Shape = + | Dot + | Circle of radius: float + + val twice: int -> int +""" + +let private checker = FSharpChecker.Create() + +let private navigableItems (fileName: string) source = + task { + let! results = + checker.ParseFile( + fileName, + SourceText.ofString source, + { FSharpParsingOptions.Default with + SourceFiles = [| fileName |] + } + ) + |> Async.StartAsTask + + return NavigateTo.GetNavigableItems results.ParseTree + } + +/// Runs the test against a cache directory of its own, deleted afterwards. +let private withCacheDirectory (test: string -> Task) : Task = + task { + let cacheDirectory = + Path.Combine(Path.GetTempPath(), "FSharp.Editor.Tests", Guid.NewGuid().ToString "N") + + try + do! test cacheDirectory + finally + if Directory.Exists cacheDirectory then + Directory.Delete(cacheDirectory, true) + } + +/// A Visual Studio session: an export provider of its own, so nothing another session parsed is in memory, keeping +/// its data under the cache directory. +let private session cacheDirectory = + let exportProvider = MefHelpers.createExportProvider () + exportProvider.GetExportedValue().CacheDirectory <- ValueSome cacheDirectory + exportProvider + +let private searchService cacheDirectory : IFSharpNavigateToSearchService = + (session cacheDirectory).GetExportedValue() + +/// The same solution in every session: storage finds a document by the paths and names of the solution, the project +/// and the document. None of the projects has options until a test sets them. +let private openSolution (fileName: string) source = + let projectId = ProjectId.CreateNewId() + + let project = + [ RoslynTestHelpers.CreateDocumentInfo projectId fileName source ] + |> RoslynTestHelpers.CreateProjectInfo projectId "C:\\test.fsproj" + + let solution = + RoslynTestHelpers.CreateSolutionAt "C:\\Persisted\\test.sln" [ project ] + + projectId, solution, solution.GetProject projectId + +let private search (service: IFSharpNavigateToSearchService) (project: Project) pattern = + task { + let! results = service.SearchProjectAsync(project, ImmutableArray.Empty, pattern, service.KindsProvided, CancellationToken.None) + return results |> Seq.map _.Name |> Seq.toList + } + +[] +[] +[] +let ``navigable items read back as they were written, and only with the checksum they were written with`` + (fileName: string, source: string) + : Task = + withCacheDirectory (fun cacheDirectory -> + task { + let! items = navigableItems fileName source + + let storage: IFSharpChecksummedPersistentStorageService = + (session cacheDirectory).GetExportedValue() + + let _, _, project = openSolution fileName source + let document = project.Documents |> Seq.exactlyOne + let written = FSharpChecksum.Create "written" + + do! NavigableItemsIndex.save storage document written items CancellationToken.None + let! restored = NavigableItemsIndex.tryLoad storage document written CancellationToken.None + let! underAnotherChecksum = NavigableItemsIndex.tryLoad storage document (FSharpChecksum.Create "other") CancellationToken.None + + Assert.Equal(items, restored |> ValueOption.defaultValue [||]) + Assert.Equal(ValueNone, underAnotherChecksum) + }) + +[] +let ``a file without directives is answered from storage before its project has options`` () : Task = + withCacheDirectory (fun cacheDirectory -> + task { + let source = "module Persisted\n\nlet declaredEarlier = 1\n" + + let projectId, solution, project = openSolution "C:\\test.fs" source + RoslynTestHelpers.SetProjectOptions projectId solution RoslynTestHelpers.DefaultProjectOptions + let! found = search (searchService cacheDirectory) project "declaredEarlier" + Assert.Equal([ "declaredEarlier" ], found) + + let _, _, reopened = openSolution "C:\\test.fs" source + + let! _ = + Assert.ThrowsAnyAsync(fun () -> + search (searchService (Path.Combine(cacheDirectory, "empty"))) reopened "declaredEarlier" :> Task) + + let! found = search (searchService cacheDirectory) reopened "declaredEarlier" + Assert.Equal([ "declaredEarlier" ], found) + }) + +[] +let ``a file with directives is answered from storage only under the defines it was parsed with`` () : Task = + withCacheDirectory (fun cacheDirectory -> + task { + let source = + "module Persisted\n\n#if FOO\nlet fooOnly = 1\n#endif\nlet everywhere = 1\n" + + let projectId, solution, project = openSolution "C:\\test.fs" source + + { RoslynTestHelpers.DefaultProjectOptions with + OtherOptions = [| "--define:FOO" |] + } + |> RoslynTestHelpers.SetProjectOptions projectId solution + + let! found = search (searchService cacheDirectory) project "fooOnly" + Assert.Equal([ "fooOnly" ], found) + + let projectId, solution, reopened = openSolution "C:\\test.fs" source + let service = searchService cacheDirectory + + let! _ = Assert.ThrowsAnyAsync(fun () -> search service reopened "everywhere" :> Task) + + RoslynTestHelpers.SetProjectOptions projectId solution RoslynTestHelpers.DefaultProjectOptions + let! found = search service reopened "fooOnly" + Assert.Equal([], found) + }) + +[] +let ``a solution without a rooted path keeps nothing`` () : Task = + withCacheDirectory (fun cacheDirectory -> + task { + let projectId = ProjectId.CreateNewId() + + let project = + [ + RoslynTestHelpers.CreateDocumentInfo projectId "C:\\test.fs" "module Persisted\n\nlet unkept = 1\n" + ] + |> RoslynTestHelpers.CreateProjectInfo projectId "C:\\test.fsproj" + + let solution = RoslynTestHelpers.CreateSolution [ project ] + RoslynTestHelpers.SetProjectOptions projectId solution RoslynTestHelpers.DefaultProjectOptions + + let! found = search (searchService cacheDirectory) (solution.GetProject projectId) "unkept" + + Assert.Equal([ "unkept" ], found) + Assert.False(Directory.Exists cacheDirectory) + }) From 0c3ebd8f7c3a3c057c6654575e6d11a246387c63 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Fri, 2 Oct 2026 00:45:15 +0200 Subject: [PATCH 29/31] Link the release note to PR #20689 Co-Authored-By: Claude Opus 5.5 --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index ebe3746babe..1a8822d5f4b 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -6,6 +6,7 @@ * Added a **Tools > Options > F# Tools > Compiler** option, **"Use the .NET SDK F# compiler for builds"**, on by default. Visual Studio uses the .NET SDK F# compiler when the project supplies SDK paths, matching `dotnet build`. Otherwise, it uses the bundled .NET Framework compiler. Turn the option off to use the bundled compiler for all projects. Changes are saved on Apply or OK; Cancel discards unsaved changes. Command-line builds are unaffected. ([Issue #20484](https://github.com/dotnet/fsharp/issues/20484), [PR #20485](https://github.com/dotnet/fsharp/pull/20485)) * F# types, modules, members and values now appear in the GitHub Copilot Chat `#` mention picker, and attach their declaration source as context. * Navigate To (Ctrl+T) keeps the declarations it finds in each F# file in the solution's `.vs` folder, under a checksum of the file's text — and, for a file with `#if`, of the defines it was parsed with — so the next session reads them back instead of parsing the file again, including while the solution is still loading. +* Navigate To (Ctrl+T) keeps the declarations it finds in each F# file in the solution's `.vs` folder, under a checksum of the file's text — and, for a file with `#if`, of the defines it was parsed with — so the next session reads them back instead of parsing the file again, including while the solution is still loading. ([PR #20689](https://github.com/dotnet/fsharp/pull/20689)) * F# types, modules, members and values now appear in the GitHub Copilot Chat `#` mention picker, and attach their declaration source as context. ([PR #20409](https://github.com/dotnet/fsharp/pull/20409)) ### Fixed From e9bf72eaf135e09d4b64411257a1081a7564cc5c Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sun, 11 Oct 2026 11:52:08 +0200 Subject: [PATCH 30/31] Remove the duplicated release note Co-Authored-By: Claude Opus 5.5 --- docs/release-notes/.VisualStudio/18.vNext.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 1a8822d5f4b..1e81a99a3a9 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -5,7 +5,6 @@ * 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)) * Added a **Tools > Options > F# Tools > Compiler** option, **"Use the .NET SDK F# compiler for builds"**, on by default. Visual Studio uses the .NET SDK F# compiler when the project supplies SDK paths, matching `dotnet build`. Otherwise, it uses the bundled .NET Framework compiler. Turn the option off to use the bundled compiler for all projects. Changes are saved on Apply or OK; Cancel discards unsaved changes. Command-line builds are unaffected. ([Issue #20484](https://github.com/dotnet/fsharp/issues/20484), [PR #20485](https://github.com/dotnet/fsharp/pull/20485)) * F# types, modules, members and values now appear in the GitHub Copilot Chat `#` mention picker, and attach their declaration source as context. -* Navigate To (Ctrl+T) keeps the declarations it finds in each F# file in the solution's `.vs` folder, under a checksum of the file's text — and, for a file with `#if`, of the defines it was parsed with — so the next session reads them back instead of parsing the file again, including while the solution is still loading. * Navigate To (Ctrl+T) keeps the declarations it finds in each F# file in the solution's `.vs` folder, under a checksum of the file's text — and, for a file with `#if`, of the defines it was parsed with — so the next session reads them back instead of parsing the file again, including while the solution is still loading. ([PR #20689](https://github.com/dotnet/fsharp/pull/20689)) * F# types, modules, members and values now appear in the GitHub Copilot Chat `#` mention picker, and attach their declaration source as context. ([PR #20409](https://github.com/dotnet/fsharp/pull/20409)) From 0c65afece7f4adc0fe33ea7bbe4539e8735f7681 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sun, 11 Oct 2026 12:00:16 +0200 Subject: [PATCH 31/31] Remove the duplicated release note Co-Authored-By: Claude Opus 5.5 --- docs/release-notes/.VisualStudio/18.vNext.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 1e81a99a3a9..cf133760af6 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -4,7 +4,6 @@ * 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)) * Added a **Tools > Options > F# Tools > Compiler** option, **"Use the .NET SDK F# compiler for builds"**, on by default. Visual Studio uses the .NET SDK F# compiler when the project supplies SDK paths, matching `dotnet build`. Otherwise, it uses the bundled .NET Framework compiler. Turn the option off to use the bundled compiler for all projects. Changes are saved on Apply or OK; Cancel discards unsaved changes. Command-line builds are unaffected. ([Issue #20484](https://github.com/dotnet/fsharp/issues/20484), [PR #20485](https://github.com/dotnet/fsharp/pull/20485)) -* F# types, modules, members and values now appear in the GitHub Copilot Chat `#` mention picker, and attach their declaration source as context. * Navigate To (Ctrl+T) keeps the declarations it finds in each F# file in the solution's `.vs` folder, under a checksum of the file's text — and, for a file with `#if`, of the defines it was parsed with — so the next session reads them back instead of parsing the file again, including while the solution is still loading. ([PR #20689](https://github.com/dotnet/fsharp/pull/20689)) * F# types, modules, members and values now appear in the GitHub Copilot Chat `#` mention picker, and attach their declaration source as context. ([PR #20409](https://github.com/dotnet/fsharp/pull/20409))