Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions docs/release-notes/.VisualStudio/18.vNext.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
* Reduce allocations in the VS project options reactor: the command-line options and project options caches and the mailbox reply payloads now hold struct tuples, and `IProjectSite.CompilationBinOutputPath` returns `string voption` picked with a new `Array.tryPickV`. ([PR #20413](https://github.com/dotnet/fsharp/pull/20413))
* Build a single-file project's `OtherOptions` reference flags with one array comprehension instead of two `Array.ofSeq` calls and an `Array.append`. ([PR #20499](https://github.com/dotnet/fsharp/pull/20499))
* 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 All (Ctrl+T) searches the documents Visual Studio marks as priority — the ones the user has open — before the rest of a project, so a search the next keystroke cancels has covered them first. ([PR #20615](https://github.com/dotnet/fsharp/pull/20615))
* 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() =
Expand Down
172 changes: 125 additions & 47 deletions vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,49 @@ open Microsoft.VisualStudio.LanguageServices
open Microsoft.VisualStudio.Text.PatternMatching

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.
[<Struct>]
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.
[<Struct>]
type private NavigableItemsEntry =
{
Version: VersionStamp
Items: NavigableItem array
}

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

let cache = ConcurrentDictionary<DocumentId, VersionStamp * NavigableItem array>()
/// 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<NavigableItemsKey, NavigableItemsEntry>()

/// 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.
[<Literal>]
let AnyDefines = "?"

do
if workspace <> null then
Expand All @@ -33,18 +68,59 @@ type internal FSharpNavigateToSearchService
if e.NewSolution.Id <> e.OldSolution.Id then
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) =
cancellableTask {
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, 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))
let items = NavigateTo.GetNavigableItems parseResults.ParseTree
cache[document.Id] <- currentVersion, items
return items
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
}

let kindsProvided =
Expand Down Expand Up @@ -145,71 +221,73 @@ type internal FSharpNavigateToSearchService

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 ->
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
|]
}

interface IFSharpNavigateToSearchService with
member _.SearchProjectAsync
(project, _priorityDocuments, searchPattern, kinds, cancellationToken)
(project, priorityDocuments, searchPattern, kinds, cancellationToken)
: Task<ImmutableArray<FSharpNavigateToSearchResult>> =
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 priorityIds = ImmutableHashSet.CreateRange(priorityDocuments |> Seq.map _.Id)

let priority, rest =
project.Documents
|> Seq.toArray
|> Array.partition (fun document -> priorityIds.Contains document.Id)

let search documents =
documents
|> 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)

// The documents the user has open go first, so they are parsed and cached before the rest of the project.
let! priorityResults = search priority

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 🕵️ [P2] Priority batching delays all Navigate To results — two 700 ms document loads on 16 workers took about 1.4 s instead of 0.8 s; this API publishes nothing until both batches finish.

let! results = search (Seq.append priority rest)
return results |> Array.concat |> Array.toImmutableArray

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one I do not think holds, because of which method it is on.

IFSharpNavigateToSearchService.SearchProjectAsync returns Task<ImmutableArray<FSharpNavigateToSearchResult>>: it has no callback and cannot publish anything before it returns, so the user sees nothing from this project until the whole project is searched, with or without the priority split. Ordering the documents inside it cannot delay that — the same documents are searched on the same throttle, only in a different order — and the measured 1.4 s against 0.8 s is what two batches of one document each cost when the batches are run one after another, which is the shape the repro builds rather than the shape here: priority documents and the rest go through one whenAllThrottled over one sequence, not two awaited halves.

Where publishing early does exist is the loading path, SearchCachedDocumentsAsync, which takes onResultsFound and reports per document as each finishes — and that one already searches priority documents first, for exactly the reason you are pointing at.

If the ordering inside a single throttled pass is measurably worse than none, I would like to see it as a run of the editor tests rather than a synthetic two-document case, since the whole point of the parameter is that Roslyn hands us the documents the user is looking at.

let! restResults = search rest

return
Array.append priorityResults restResults
|> Array.concat
|> Array.toImmutableArray
}
|> CancellableTask.start cancellationToken

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
<Compile Include="QuickInfoTests.fs" />
<Compile Include="TaskListServiceTests.fs" />
<Compile Include="NavigateToSearchServiceTests.fs" />
<Compile Include="MultiTargetNavigateToSearchTests.fs" />
<Compile Include="CodeFixes\CodeFixTestFramework.fs" />
<Compile Include="CodeFixes\AddInstanceMemberParameterTests.fs" />
<Compile Include="CodeFixes\ConvertToAnonymousRecordTests.fs" />
Expand Down
Loading
Loading