Skip to content
Draft
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
3 changes: 3 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,9 @@
* 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))
* Find All References and Rename search each file of a multi-targeted F# project once instead of once per target framework, revisiting a file in another instance only when it is compiled there alone or under conditional compilation; searches start as each project's snapshot is ready and type checks are bounded across the solution. ([PR #20464](https://github.com/dotnet/fsharp/pull/20464))
* Find Implementations no longer searches the whole solution for uses it then discards; it reports the declarations alone.
* Find All References reports the uses of a document in one go instead of starting a task per reference, stops when the request is cancelled, and searches the documents of a project through a fixed set of workers under one solution-wide budget that leaves a core to the editor.

### Changed

Expand Down
25 changes: 25 additions & 0 deletions vsintegration/src/FSharp.Editor/Common/CancellableTasks.fs
Original file line number Diff line number Diff line change
Expand Up @@ -1132,6 +1132,31 @@ module CancellableTasks =
return! allTask
}

/// Runs the work over the items with at most maxDegreeOfParallelism of them in flight. A worker
/// takes the next item when it frees up, so cancellation cancels the workers rather than a
/// pending task per item.
let forEachThrottled maxDegreeOfParallelism (work: 'T -> CancellableTask<unit>) (items: 'T seq) =
cancellableTask {
let! ct = getCancellationToken ()
let items = Seq.toArray items
let mutable next = -1

let worker () =
backgroundTask {
let mutable index = Interlocked.Increment &next

while index < items.Length do
ct.ThrowIfCancellationRequested()
do! work items[index] ct
index <- Interlocked.Increment &next
}

let workers =
Array.init (min (max 1 maxDegreeOfParallelism) items.Length) (fun _ -> worker ())

do! (Task.WhenAll workers :> Task)
}

let inline whenAllTasks (tasks: CancellableTask seq) =
cancellableTask {
let! ct = getCancellationToken ()
Expand Down
166 changes: 117 additions & 49 deletions vsintegration/src/FSharp.Editor/LanguageService/SymbolHelpers.fs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ open System.Collections.Concurrent
open System.Collections.Generic
open System.Collections.Immutable
open System.IO
open System.Threading
open System.Threading.Tasks

open Microsoft.CodeAnalysis
Expand Down Expand Up @@ -70,34 +71,107 @@ module internal SymbolHelpers =
return symbolUses
}

let getSymbolUsesInProjects (symbol: FSharpSymbol, projects: Project list, onFound: Document -> range -> CancellableTask<unit>) =
match projects with
/// Ranks the target-framework instances of a project file: the one the current document lives in,
/// then those in its dependency closure, whose references resolve the symbol the same way.
let private rankInstances (currentProject: Project) =
let graph = currentProject.Solution.GetProjectDependencyGraph()

let related =
HashSet
[
yield! graph.GetProjectsThatThisProjectTransitivelyDependsOn currentProject.Id
yield! graph.GetProjectsThatTransitivelyDependOnThisProject currentProject.Id
]

fun (project: Project) ->
if project.Id = currentProject.Id then 0
elif related.Contains project.Id then 1
else 2

/// One search per project file: the best-ranked target-framework instance is searched in full,
/// the others only where their sources can differ from it.
let private groupInstances (currentProject: Project) (projects: Project seq) =
let rank = rankInstances currentProject

seq {
for _, instances in projects |> Seq.groupBy _.FilePath do
let instances = Seq.toArray instances
// Ordering the rest buys nothing, and ranking every instance of the solution at once
// measures slower than one pass per group.
let primary = Array.minBy rank instances
struct (primary, instances |> Seq.filter (fun instance -> instance.Id <> primary.Id))
}

/// One core is left to the thread that has to stay responsive, and every search in the editor
/// shares what remains.
let searchThrottle = new SemaphoreSlim(max 1 (Environment.ProcessorCount - 1))

let getSymbolUsesInProjects
(symbol: FSharpSymbol, currentProject: Project, projects: Project list, onFound: Document -> range seq -> CancellableTask<unit>)
=
match projects |> List.filter _.IsFSharp with
| [] -> CancellableTask.singleton ()
| firstProject :: _ ->
| firstProject :: _ as projects ->
let isFastFindReferencesEnabled = firstProject.IsFastFindReferencesEnabled

// TODO: this needs to use already boxed boolean instead of boxing it every time.
let props =
[| nameof isFastFindReferencesEnabled, isFastFindReferencesEnabled :> obj |]

let groups =
if isFastFindReferencesEnabled then
groupInstances currentProject projects
else
seq { for project in projects -> struct (project, Seq.empty) }

cancellableTask {
// TODO: this needs to be a single event with a duration
TelemetryReporter.ReportSingleEvent(TelemetryEvents.GetSymbolUsesInProjectsStarted, props)

let! ct = CancellableTask.getCancellationToken ()

// A file that several projects compile - the target-framework instances of one project
// file, or two project files sharing a source file - is searched in each of them and
// reports the same range every time. The range carries its file, so the first project
// to report a use keeps it and the rest are dropped.
let reported = ConcurrentDictionary<range, unit>()

let onFound document ranges =
let fresh =
ranges |> Seq.filter (fun range -> reported.TryAdd(range, ())) |> Seq.toArray

if fresh.Length = 0 then
CancellableTask.singleton ()
else
onFound document fresh
// Mutated by the checker while a snapshot is built, so snapshots are built one at a time.
let snapshotAccumulator = Dictionary()
let searches = ResizeArray<Task>()

let! projects =
projects
|> Seq.map (fun project ->
project.GetFSharpProjectSnapshot(snapshotAccumulator)
|> CancellableTask.map (fun s -> project, s))
|> CancellableTask.sequential
let snapshotFor (project: Project) =
if project.UseTransparentCompiler then
project.GetFSharpProjectSnapshot snapshotAccumulator
|> CancellableTask.map ValueSome
else
CancellableTask.singleton ValueNone

do!
projects
|> Seq.map (fun (project, snapshot) ->
project.FindFSharpReferencesAsync(symbol, snapshot, onFound, "getSymbolUsesInProjects"))
|> CancellableTask.whenAll
// Started, not awaited: the next project's snapshot is built while this one searches.
let startSearching (project: Project) snapshot searchedInstance =
searches.Add(
project.FindFSharpReferencesAsync
(symbol, snapshot, searchedInstance, searchThrottle, onFound, "getSymbolUsesInProjects")
ct
)

for struct (primary, secondaries) in groups do
let! snapshot = snapshotFor primary
startSearching primary snapshot ValueNone

for secondary in secondaries do
let! snapshot = snapshotFor secondary
startSearching secondary snapshot (ValueSome primary)

do! Task.WhenAll searches

TelemetryReporter.ReportSingleEvent(TelemetryEvents.GetSymbolUsesInProjectsFinished, props)
}
Expand All @@ -106,7 +180,7 @@ module internal SymbolHelpers =
(symbolUse: FSharpSymbolUse)
(currentDocument: Document)
(checkFileResults: FSharpCheckFileResults)
(onFound: Document -> range -> CancellableTask<unit>)
(onFound: Document -> range seq -> CancellableTask<unit>)
=
cancellableTask {
match symbolUse.GetSymbolScope currentDocument with
Expand All @@ -115,10 +189,7 @@ module internal SymbolHelpers =
let symbolUses =
checkFileResults.GetUsesOfSymbolInFile(symbolUse.Symbol, relatedSymbolKinds = RelatedSymbolUseKind.All)

do!
symbolUses
|> Seq.map (fun symbolUse -> onFound currentDocument symbolUse.Range)
|> CancellableTask.whenAll
do! onFound currentDocument (symbolUses |> Seq.map _.Range)

| Some SymbolScope.SignatureAndImplementation ->
let otherFile = getOtherFile currentDocument.FilePath
Expand All @@ -132,52 +203,49 @@ module internal SymbolHelpers =
}
| ValueNone -> CancellableTask.singleton []

let symbolUses =
(checkFileResults, currentDocument) :: otherFileCheckResults
|> Seq.collect (fun (checkFileResults, doc) ->
for checkFileResults, doc in (checkFileResults, currentDocument) :: otherFileCheckResults do
let symbolUses =
checkFileResults.GetUsesOfSymbolInFile(symbolUse.Symbol, relatedSymbolKinds = RelatedSymbolUseKind.All)
|> Seq.map (fun symbolUse -> (doc, symbolUse.Range)))

do! symbolUses |> Seq.map ((<||) onFound) |> CancellableTask.whenAll
do! onFound doc (symbolUses |> Seq.map _.Range)

| scope ->
| Some(SymbolScope.Projects(scopeProjects, isLocalForProject)) ->
let projectsToCheck =
match scope with
| Some(SymbolScope.CurrentDocument)
| Some(SymbolScope.SignatureAndImplementation) ->
// For current document or signature/implementation, just search current project
[ currentDocument.Project ]
| Some(SymbolScope.Projects(scopeProjects, false)) ->
if isLocalForProject then
scopeProjects
else
[
for scopeProject in scopeProjects do
yield scopeProject
yield! scopeProject.GetDependentProjects()
]
|> List.distinct
| Some(SymbolScope.Projects(scopeProjects, true)) -> scopeProjects
// The symbol is declared in .NET framework, an external assembly or in a C# project within the solution.
// Optimization: Only search projects that reference the specific assembly
| None ->
match symbolUse.Symbol.Assembly.FileName with
| Some assemblyPath ->
let referencingProjects =
ProjectFiltering.getProjectsReferencingAssembly assemblyPath currentDocument.Project.Solution

if List.isEmpty referencingProjects then
Seq.toList currentDocument.Project.Solution.Projects
else
referencingProjects
| None -> Seq.toList currentDocument.Project.Solution.Projects

do! getSymbolUsesInProjects (symbolUse.Symbol, projectsToCheck, onFound)

do! getSymbolUsesInProjects (symbolUse.Symbol, currentDocument.Project, projectsToCheck, onFound)

// The symbol is declared in .NET framework, an external assembly or in a C# project within the solution.
// Optimization: Only search projects that reference the specific assembly
| None ->
let projectsToCheck =
match symbolUse.Symbol.Assembly.FileName with
| Some assemblyPath ->
match ProjectFiltering.getProjectsReferencingAssembly assemblyPath currentDocument.Project.Solution with
| [] -> Seq.toList currentDocument.Project.Solution.Projects
| referencingProjects -> referencingProjects
| None -> Seq.toList currentDocument.Project.Solution.Projects

do! getSymbolUsesInProjects (symbolUse.Symbol, currentDocument.Project, projectsToCheck, onFound)
}

let getSymbolUses (symbolUse: FSharpSymbolUse) (currentDocument: Document) (checkFileResults: FSharpCheckFileResults) =
cancellableTask {
let symbolUses = ConcurrentBag()

let onFound =
fun document range -> cancellableTask { symbolUses.Add(document, range) }
let onFound document (ranges: range seq) =
cancellableTask {
for range in ranges do
symbolUses.Add(document, range)
}

do! findSymbolUses symbolUse currentDocument checkFileResults onFound

Expand Down
Loading
Loading