Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c73e8f2
Build multi-project and multi-target Roslyn solutions in FSharp.Edito…
xperiandri Sep 6, 2026
920cbec
Search each file of a multi-targeted project once in NavigateTo and r…
xperiandri Sep 7, 2026
eabde87
Test NavigateTo across the target-framework instances of a project
xperiandri Sep 7, 2026
c1a31f3
Add the release note for the NavigateTo multi-target fix
xperiandri Sep 7, 2026
069ef8f
Share the parse instead of skipping the file
xperiandri Sep 10, 2026
4fcd477
Name the navigable items cache's key and entry
xperiandri Sep 11, 2026
2311385
Format the multi-target NavigateTo tests
xperiandri Sep 11, 2026
6257390
Build the synthetic projects' id list through Seq and materialize once
xperiandri Sep 11, 2026
453a5e9
Key a kept parse by everything it depends on
xperiandri Sep 26, 2026
49743c2
Take the shared test fixture as #20462 lands it
xperiandri Oct 1, 2026
d2fab5e
Keep the shared fixture's results private
xperiandri Oct 1, 2026
46037bb
Keep a document's own parse where its project has no options
xperiandri Oct 11, 2026
bc7365d
Key Navigate To's parse cache by the options the parse uses
xperiandri Oct 15, 2026
eed388b
Compact the Navigate To tests
xperiandri Oct 15, 2026
d6201e1
Give the instances of a file one text version in the multi-target tests
xperiandri Oct 15, 2026
dd48654
Bring the rebased branch to what it had before the rebase
xperiandri Oct 29, 2026
8d3a2fc
Return the names a Navigate To search matched as a sequence, and comp…
xperiandri Nov 5, 2026
c5a7e6a
Format the declaration of the per-document items cache
xperiandri Nov 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/release-notes/.VisualStudio/18.vNext.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,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))
* Fix completion after a closed backticked identifier on the same line. ([PR #20785](https://github.com/dotnet/fsharp/issues/20785))
* Go To All (Ctrl+T) on a multi-targeted F# project no longer parses every file once per target framework. ([PR #20483](https://github.com/dotnet/fsharp/pull/20483))

### Changed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,9 @@ type internal FSharpProjectOptionsManager(checker: FSharpChecker, workspace: Wor

CompilerEnvironment.GetConditionalDefinesForEditing parsingOptions, parsingOptions.LangVersionText

member _.TryGetCachedOptionsByProjectId(projectId) =
reactor.TryGetCachedOptionsByProjectId(projectId)

member _.TryGetOptionsByProject(project) =
reactor.TryGetOptionsByProjectAsync(project)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,40 +16,86 @@ open Microsoft.CodeAnalysis.ExternalAccess.FSharp.NavigateTo
open Microsoft.VisualStudio.LanguageServices
open Microsoft.VisualStudio.Text.PatternMatching

open FSharp.Compiler
open FSharp.Compiler.EditorServices
open FSharp.Compiler.Syntax
open CancellableTasks

[<Struct>]
type private ParseKey =
| AnyDefines of filePath: string * langVersion: string
| DefinedAs of filePath: string * langVersion: string * defines: string list

[<Export; Shared>]
type internal FSharpNavigableItemsCache
[<ImportingConstructor>]
(patternMatcherFactory: IPatternMatcherFactory, [<Import(AllowDefault = true)>] workspace: VisualStudioWorkspace) =

let cache = ConcurrentDictionary<DocumentId, VersionStamp * NavigableItem array>()
let cache = ConcurrentDictionary<ParseKey, VersionStamp * NavigableItem array>()

/// The items a document last got, under whatever options: for the reader that cannot await them.
let ofDocument =
ConcurrentDictionary<DocumentId, VersionStamp * NavigableItem array>()

do
if workspace <> null then
workspace.WorkspaceChanged.Add
<| fun e ->
if e.NewSolution.Id <> e.OldSolution.Id then
cache.Clear()
ofDocument.Clear()

// Awaiting a project's options recomputes them after an edit to a C# project it references.
let parsingOptionsOf (document: Document) =
let optionsManager =
document.Project.Solution.GetFSharpWorkspaceService().FSharpProjectOptionsManager

match optionsManager.TryGetCachedOptionsByProjectId document.Project.Id with
| Some(_, parsingOptions, _) -> CancellableTask.singleton parsingOptions
| None ->
document.GetFSharpCompilationOptionsAsync(nameof (FSharpNavigableItemsCache))
|> CancellableTask.map (fun (_, _, parsingOptions, _) -> parsingOptions)

let storedUnder anyDefinesKey definedAsKey =
function
| ParsedInput.ImplFile(ParsedImplFileInput(trivia = trivia))
| ParsedInput.SigFile(ParsedSigFileInput(trivia = trivia)) when trivia.ConditionalDirectives.IsEmpty -> anyDefinesKey
| _ -> definedAsKey

member _.GetNavigableItems(document: Document) =
cancellableTask {
let! ct = CancellableTask.getCancellationToken ()
let! currentVersion = document.GetTextVersionAsync(ct)
let! parsingOptions = parsingOptionsOf document
let anyDefinesKey = AnyDefines(document.FilePath, parsingOptions.LangVersionText)

let definedAsKey =
DefinedAs(
document.FilePath,
parsingOptions.LangVersionText,
CompilerEnvironment.GetConditionalDefinesForEditing parsingOptions
)

let cached key =
match cache.TryGetValue key with
| true, (version, items) when version = currentVersion -> ValueSome items
| _ -> ValueNone

match cache.TryGetValue document.Id with
| true, (version, items) when version = currentVersion -> return items
| _ ->
match cached anyDefinesKey |> ValueOption.orElseWith (fun () -> cached definedAsKey) with
| ValueSome items ->
ofDocument[document.Id] <- currentVersion, items
return items
| ValueNone ->
let! parseResults = document.GetFSharpParseResultsAsync(nameof (FSharpNavigableItemsCache))
let items = NavigateTo.GetNavigableItems parseResults.ParseTree
cache[document.Id] <- currentVersion, items
cache[storedUnder anyDefinesKey definedAsKey parseResults.ParseTree] <- currentVersion, items
ofDocument[document.Id] <- currentVersion, items
return items
}

/// Uses the version the document already knows: asking for it could read a closed file from disk.
member _.TryGetCachedNavigableItems(document: Document) =
match cache.TryGetValue document.Id, document.TryGetTextVersion() with
match ofDocument.TryGetValue document.Id, document.TryGetTextVersion() with
| (true, (version, items)), (true, currentVersion) when version = currentVersion -> ValueSome items
| _ -> ValueNone

Expand Down Expand Up @@ -159,20 +205,26 @@ type internal FSharpNavigateToSearchService [<ImportingConstructor>] (itemsCache

let processDocument (tryMatch: NavigableItem -> PatternMatch voption) (kinds: IImmutableSet<string>) (document: Document) =
cancellableTask {
let! ct = CancellableTask.getCancellationToken ()

let! sourceText = document.GetTextAsync ct

let! items = getNavigableItems document

let processed =
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 ->
if matches.Length = 0 then
return [||]
else
let! ct = CancellableTask.getCancellationToken ()
let! sourceText = document.GetTextAsync ct

return
[|
for struct (item, m) in matches do
let sourceSpan = RoslynHelpers.TryFSharpRangeToTextSpan(sourceText, item.Range)

match sourceSpan with
Expand All @@ -195,10 +247,7 @@ type internal FSharpNavigateToSearchService [<ImportingConstructor>] (itemsCache
sourceSpan
)
)
| _ -> ()
|]

return processed
|]
}

interface IFSharpNavigateToSearchService with
Expand All @@ -214,15 +263,9 @@ type internal FSharpNavigateToSearchService [<ImportingConstructor>] (itemsCache
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
let! results = CancellableTask.whenAllThrottled (max 1 Environment.ProcessorCount) tasks

return results'.ToImmutable()
return results |> Array.concat |> Array.toImmutableArray

}
|> CancellableTask.start cancellationToken
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
<Compile Include="TaskListServiceTests.fs" />
<Compile Include="NavigateToSearchServiceTests.fs" />
<Compile Include="CopilotContextProviderTests.fs" />
<Compile Include="MultiTargetNavigateToSearchTests.fs" />
<Compile Include="CodeFixes\CodeFixTestFramework.fs" />
<Compile Include="ActivePatternEditorTests.fs" />
<Compile Include="CodeFixes\AddInstanceMemberParameterTests.fs" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.

module FSharp.Editor.Tests.MultiTargetNavigateToSearchTests

open System.IO
open Xunit
open Microsoft.CodeAnalysis
open Microsoft.CodeAnalysis.Text
open FSharp.Editor.Tests.Helpers
open FSharp.Test.ProjectGeneration

let private exactNamesIn = NavigateToSearchServiceTests.exactNamesIn

let private syntheticProject =
SyntheticProject.Create(
{ sourceFile "Plain" [] with
ExtraSource = "let plainUse = 1"
},
{ sourceFile "Conditional" [] with
ExtraSource = "#if FOO\nlet fooUse = 1\n#endif"
}
)

let private linkedText path (text: string) (solution: Solution) =
let linked = TextAndVersion.Create(SourceText.From text, VersionStamp.Create())

(solution, solution.GetDocumentIdsWithFilePath path)
||> Seq.fold (fun (solution: Solution) id -> solution.WithDocumentText(id, linked))

[<AutoOpen>]
module private Fixture =
let struct (unlinked, instances) =
RoslynTestHelpers.CreateMultiTargetSolution(
syntheticProject,
[ for defs in [ []; [ "FOO" ] ] -> { Defines = defs; ExcludedFileIds = [] } ]
)

let solution =
(unlinked, syntheticProject.SourceFilePaths)
||> List.fold (fun solution path -> linkedText path (File.ReadAllText path) solution)

let withoutFoo, withFoo = instances[0], instances[1]

[<Theory>]
[<InlineData(0)>]
[<InlineData(1)>]
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<string>([ "plainUse" ], exactNamesIn project "plainUse")
Assert.Equal<string>([ "plainUse" ], exactNamesIn project "plainUse")

[<Fact>]
let ``an instance reuses the parse another instance made of the same text`` () =
let path = syntheticProject.GetFilePath "Plain"
let linked = linkedText path "module ModulePlain\nlet reusedUse = 1\n" solution

let searchEveryInstance () =
for instance in instances do
Assert.Equal<string>([ "reusedUse" ], exactNamesIn (linked.GetProject instance) "reusedUse")

Assert.Equal(1, NavigateToSearchServiceTests.countParseRequests path searchEveryInstance)

[<Fact>]
let ``a declaration behind a directive is reported only by the instance that defines it`` () =
Assert.Equal<string>([ "fooUse" ], exactNamesIn (solution.GetProject withFoo) "fooUse")
Assert.Empty(exactNamesIn (solution.GetProject withoutFoo) "fooUse")

[<Theory>]
[<InlineData(false)>]
[<InlineData(true)>]
let ``a declaration an edit puts behind a directive is reported by the instance that defines it`` (fooFirst: bool) =
Assert.Equal<string>([ "plainUse" ], exactNamesIn (solution.GetProject withoutFoo) "plainUse")

let edited =
solution.WithDocumentText(
solution.GetDocumentIdsWithFilePath(syntheticProject.GetFilePath "Plain"),
SourceText.From "module ModulePlain\n#if FOO\nlet addedFoo = 1\n#endif\n"
)

let searches = [ withoutFoo, []; withFoo, [ "addedFoo" ] ]

for instance, expected in (if fooFirst then List.rev searches else searches) do
Assert.Equal<string>(expected, exactNamesIn (edited.GetProject instance) "addedFoo")
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
namespace FSharp.Editor.Tests

open Xunit
open System.Diagnostics
open System.Threading
open Microsoft.CodeAnalysis
open Microsoft.CodeAnalysis.Text
open Microsoft.VisualStudio.FSharp.Editor
open FSharp.Compiler.Text
Expand Down Expand Up @@ -38,13 +40,37 @@ module HeyHo =
let project = solution.Projects |> Seq.exactlyOne
let provider = MefHelpers.createExportProvider ()

let navigateToSearch pattern =
let navigateToSearch (project: Project) pattern =
let service: IFSharpNavigateToSearchService = provider.GetExportedValue()

service.SearchProjectAsync(project, [] |> Seq.toImmutableArray, pattern, service.KindsProvided, CancellationToken.None).Result

let exactNamesIn project pattern =
navigateToSearch project pattern |> Seq.map _.Name |> Seq.filter ((=) pattern)

let countParseRequests (path: string) searches =
let isParseOfPath (activity: Activity) =
activity.OperationName = "BackgroundCompiler.ParseFile"
&& activity.Tags |> Seq.exists (fun tag -> tag.Value = path)

let parseRequests = ref 0

use listener =
new ActivityListener(
ShouldListenTo = (fun source -> source.Name = FSharp.Compiler.Diagnostics.ActivityNames.FscSourceName),
Sample = (fun _ -> ActivitySamplingResult.AllData),
ActivityStarted =
(fun activity ->
if isParseOfPath activity then
Interlocked.Increment(&parseRequests.contents) |> ignore)
)

ActivitySource.AddActivityListener listener
searches ()
parseRequests.Value

let assertResultsContain pattern expected =
navigateToSearch pattern
navigateToSearch project pattern
|> Seq.exists (fun i -> i.Name = expected)
|> Assert.True

Expand Down Expand Up @@ -72,3 +98,53 @@ module HeyHo =
[<Fact>]
let ``nested containers`` () =
assertResultsContain "hh.a.b.g.d" "Delta"

[<Fact>]
let ``a project whose options have not arrived does not answer from another project's parse`` () =
let path = "C:\\Shared.fs"
let definesNothingId, noOptionsId = ProjectId.CreateNewId(), ProjectId.CreateNewId()

let file =
RoslynTestHelpers.CreateDocumentInfo definesNothingId path "module Shared\n#if FOO\nlet fooOnly = 1\n#endif\nlet always = 2\n"

let sameVersionFile = file.WithId(DocumentId.CreateNewId noOptionsId)

let solution =
RoslynTestHelpers.CreateSolution
[
RoslynTestHelpers.CreateProjectInfo definesNothingId "C:\\DefinesNothing.fsproj" [ file ]
RoslynTestHelpers.CreateProjectInfo noOptionsId "C:\\NoOptions.fsproj" [ sameVersionFile ]
]

{ RoslynTestHelpers.DefaultProjectOptions with
SourceFiles = [| path |]
}
|> RoslynTestHelpers.SetProjectOptions definesNothingId solution

Assert.Equal<string>([ "always" ], exactNamesIn (solution.GetProject definesNothingId) "always")

let searching =
Assert.ThrowsAny<exn>(fun () -> exactNamesIn (solution.GetProject noOptionsId) "always" |> ignore)

Assert.IsAssignableFrom<System.OperationCanceledException>(searching.GetBaseException())

[<Fact>]
let ``a repeated search of an unchanged standalone script does not parse it again`` () =
let path = "C:\\Standalone.fsx"
let projectId = ProjectId.CreateNewId()

let solution =
[
RoslynTestHelpers.CreateDocumentInfo projectId path "let standaloneValue = 1\n"
]
|> RoslynTestHelpers.CreateProjectInfo projectId FSharpConstants.FSharpMiscellaneousFilesName
|> List.singleton
|> RoslynTestHelpers.CreateSolution

let script = solution.GetProject projectId

let searchTwice () =
for _ in 1..2 do
Assert.Equal<string>([ "standaloneValue" ], exactNamesIn script "standaloneValue")

Assert.Equal(1, countParseRequests path searchTwice)
Loading