Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [0.86.1] - 2026-10-29

### Changed

- [Fewer progress notifications](https://github.com/ionide/FsAutoComplete/pull/1545) - Typechecks share one "Typechecking" progress report, instead of one report per file. Its message lists the files being checked and, after a save, how many of the dependent files are done, with a percentage. Cancelling the report stops the checks of the dependent files that did not run yet. With `backgroundServiceProgress` on, the analyzers of a file share one "Analyzing" report the same way. (thanks @TheAngryByrd!)

## [0.86.0] - 2026-10-23

### Changed
Expand Down
217 changes: 116 additions & 101 deletions src/FsAutoComplete/LspServers/AdaptiveServerState.fs
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,19 @@ type ServerProgressLookup() =
/// See <see href="https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#window_workDoneProgress_cancel">LSP Spec on WorkDoneProgress Cancel</see> for more information.
/// </remarks>
member x.Cancel(token: ProgressToken) =
lock progressTable (fun () ->
match progressTable.TryGetValue(token) with
| true, weakRef ->
match weakRef.TryGetTarget() with
| true, progress ->
progress.Cancel()
let progress =
lock progressTable (fun () ->
match progressTable.TryGetValue(token) with
| true, weakRef ->
progressTable.Remove(token) |> ignore<bool>
| _ -> ()
| _ -> ())

match weakRef.TryGetTarget() with
| true, progress -> Some progress
| _ -> None
| _ -> None)

// Cancel outside the lock: the token callbacks may take other locks.
progress |> Option.iter (fun p -> p.Cancel())

interface IDisposable with
member x.Dispose() =
Expand Down Expand Up @@ -219,15 +223,32 @@ type AdaptiveState
let progressLookup = new ServerProgressLookup()
do disposables.Add progressLookup

let config = cval<FSharpConfig> FSharpConfig.Default

let typecheckProgressReporter =
new SharedProgressReporter(
"Typechecking",
(fun () -> progressLookup.CreateProgressReport(lspClient, cancellable = true))
)

do disposables.Add typecheckProgressReporter

let analyzerProgressReporter =
new SharedProgressReporter(
"Analyzing",
(fun () -> progressLookup.CreateProgressReport(lspClient, cancellable = true)),
isEnabled = (fun () -> (AVal.force config).Notifications.BackgroundServiceProgress)
)

do disposables.Add analyzerProgressReporter

let serilogLoggerFactory = new SerilogLoggerFactory(Serilog.Log.Logger)
do disposables.Add serilogLoggerFactory

let projectSelector = cval<IFindProject>(FindFirstProject())

let rootPath = cval<string option> None

let config = cval<FSharpConfig> FSharpConfig.Default

let checker =
config
|> AVal.map (fun c ->
Expand Down Expand Up @@ -531,84 +552,85 @@ type AdaptiveState

let inline getSourceLine lineNo = (source: ISourceText).GetLineString(lineNo - 1)

let checkUnusedOpens =
let checkUnusedOpens (batch: SharedProgressBatch) =
asyncEx {
try
let! ct = Async.CancellationToken
use _operation = batch.Begin()
use! _l = analyzersLocker.LockAsync(ct)
use progress = progressLookup.CreateProgressReport(lspClient, cancellable = true)

if config.Notifications.BackgroundServiceProgress then
do! progress.Begin($"Checking unused opens {fileName}...", message = filePathUntag)
batch.CancellationToken.ThrowIfCancellationRequested()
use _running = analyzerProgressReporter.Begin($"Unused opens - {fileName}")

let! unused =
UnusedOpens.getUnusedOpens (tyRes.GetCheckResults, getSourceLine)
|> Async.withCancellation progress.CancellationToken
|> Async.withCancellation batch.CancellationToken

do!
triggerNotificationAndWait
(NotificationEvent.UnusedOpens(filePath, (unused |> List.toArray), file.Version))
ct
with e ->
logger.error (Log.setMessage "checkUnusedOpens failed" >> Log.addExn e)
with
| :? OperationCanceledException -> logger.debug (Log.setMessage "checkUnusedOpens cancelled")
| e -> logger.error (Log.setMessage "checkUnusedOpens failed" >> Log.addExn e)
}

let checkUnusedDeclarations =
let checkUnusedDeclarations (batch: SharedProgressBatch) =
asyncEx {
try
let! ct = Async.CancellationToken
use _operation = batch.Begin()
use! _l = analyzersLocker.LockAsync(ct)
use progress = progressLookup.CreateProgressReport(lspClient, cancellable = true)

if config.Notifications.BackgroundServiceProgress then
do! progress.Begin($"Checking unused declarations {fileName}...", message = filePathUntag)
batch.CancellationToken.ThrowIfCancellationRequested()
use _running = analyzerProgressReporter.Begin($"Unused declarations - {fileName}")

let isScript = Utils.isAScript (filePathUntag)

let! unused =
UnusedDeclarations.getUnusedDeclarations (tyRes.GetCheckResults, isScript)
|> Async.withCancellation progress.CancellationToken
|> Async.withCancellation batch.CancellationToken

let unused = unused |> Seq.toArray

do! triggerNotificationAndWait (NotificationEvent.UnusedDeclarations(filePath, unused, file.Version)) ct
with e ->
logger.error (Log.setMessage "checkUnusedDeclarations failed" >> Log.addExn e)
with
| :? OperationCanceledException -> logger.debug (Log.setMessage "checkUnusedDeclarations cancelled")
| e -> logger.error (Log.setMessage "checkUnusedDeclarations failed" >> Log.addExn e)
}

let checkSimplifiedNames =
let checkSimplifiedNames (batch: SharedProgressBatch) =
asyncEx {
try
let! ct = Async.CancellationToken
use _operation = batch.Begin()
use! _l = analyzersLocker.LockAsync(ct)
use progress = progressLookup.CreateProgressReport(lspClient, cancellable = true)

if config.Notifications.BackgroundServiceProgress then
do! progress.Begin($"Checking simplifying of names {fileName}...", message = filePathUntag)
batch.CancellationToken.ThrowIfCancellationRequested()
use _running = analyzerProgressReporter.Begin($"Simplify names - {fileName}")

let! simplified =
SimplifyNames.getSimplifiableNames (tyRes.GetCheckResults, getSourceLine)
|> Async.withCancellation progress.CancellationToken
|> Async.withCancellation batch.CancellationToken

let simplified = Array.ofSeq simplified
do! triggerNotificationAndWait (NotificationEvent.SimplifyNames(filePath, simplified, file.Version)) ct
with e ->
logger.error (Log.setMessage "checkSimplifiedNames failed" >> Log.addExn e)
with
| :? OperationCanceledException -> logger.debug (Log.setMessage "checkSimplifiedNames cancelled")
| e -> logger.error (Log.setMessage "checkSimplifiedNames failed" >> Log.addExn e)
}

let checkUnnecessaryParentheses =
let checkUnnecessaryParentheses (batch: SharedProgressBatch) =
asyncEx {
try
let! ct = Async.CancellationToken
use _operation = batch.Begin()
use! _l = analyzersLocker.LockAsync(ct)
use progress = progressLookup.CreateProgressReport(lspClient)

if config.Notifications.BackgroundServiceProgress then
do! progress.Begin($"Checking for unnecessary parentheses {fileName}...", message = filePathUntag)
batch.CancellationToken.ThrowIfCancellationRequested()
use _running = analyzerProgressReporter.Begin($"Unnecessary parens - {fileName}")

let unnecessaryParentheses =
(System.Collections.Generic.HashSet(comparer = Range.comparer), tyRes.GetAST)
||> ParsedInput.fold (fun ranges path node ->
batch.CancellationToken.ThrowIfCancellationRequested()

match node with
| SyntaxNode.SynExpr(SynExpr.Paren(expr = inner; rightParenRange = Some _; range = range)) when
not (SynExpr.shouldBeParenthesizedInContext getSourceLine path inner)
Expand All @@ -624,12 +646,15 @@ type AdaptiveState

| _ -> ranges)

batch.CancellationToken.ThrowIfCancellationRequested()

do!
triggerNotificationAndWait
(NotificationEvent.UnnecessaryParentheses(filePath, Array.ofSeq unnecessaryParentheses, file.Version))
ct
with e ->
logger.error (Log.setMessage "checkUnnecessaryParentheses failed" >> Log.addExn e)
with
| :? OperationCanceledException -> logger.debug (Log.setMessage "checkUnnecessaryParentheses cancelled")
| e -> logger.error (Log.setMessage "checkUnnecessaryParentheses failed" >> Log.addExn e)
}

let inline isNotExcluded (exclusions: Regex array) =
Expand Down Expand Up @@ -658,7 +683,17 @@ type AdaptiveState
checkUnnecessaryParentheses
]

async { do! analyzers |> Async.parallel75 |> Async.Ignore<unit[]> }
async {
// The analyzers run one at a time behind analyzersLocker. One batch keeps them in one progress report,
// and a cancel of that report stops the analyzers that did not run yet.
use batch = analyzerProgressReporter.BeginBatch(analyzers.Length)

do!
analyzers
|> List.map (fun check -> check batch)
|> Async.parallel75
|> Async.Ignore<unit[]>
}

let tryUriCreate (s: string) =
match Uri.TryCreate(s, UriKind.Absolute) with
Expand All @@ -678,10 +713,8 @@ type AdaptiveState
let file = volatileFile.FileName

try
use progress = new ServerProgressReport(lspClient)

if config.Notifications.BackgroundServiceProgress then
do! progress.Begin("Running analyzers...", message = UMX.untag file)
let fileName = Path.GetFileName(UMX.untag file)
use progress = analyzerProgressReporter.Begin($"External analyzers - {fileName}")

Loggers.analyzers.info (
Log.setMessage "begin analysis of {file}"
Expand Down Expand Up @@ -731,6 +764,7 @@ type AdaptiveState
analyzerOptions,
analyzerPredicate
)
|> Async.withCancellation progress.CancellationToken

let! ct = Async.CancellationToken
do! triggerNotificationAndWait (NotificationEvent.AnalyzerMessage(res, file, volatileFile.Version)) ct
Expand All @@ -741,8 +775,9 @@ type AdaptiveState
Loggers.analyzers.info (Log.setMessageI $"missing components of {file:file} to run analyzers, skipped them")

()
with ex ->
Loggers.analyzers.error (Log.setMessageI $"Run failed for {file:file}" >> Log.addExn ex)
with
| :? OperationCanceledException -> Loggers.analyzers.debug (Log.setMessageI $"Run cancelled for {file:file}")
| ex -> Loggers.analyzers.error (Log.setMessageI $"Run failed for {file:file}" >> Log.addExn ex)
}


Expand Down Expand Up @@ -1950,11 +1985,10 @@ type AdaptiveState
)


use progressReport =
progressLookup.CreateProgressReport(lspClient, cancellable = true)

let simpleName = Path.GetFileName(UMX.untag file.Source.FileName)
do! progressReport.Begin($"Typechecking {simpleName}", message = $"{file.Source.FileName}")
// The token of this scope is not used: a cancelled check of an open file would stay cached until the file changes.
// The dependent-file check links the token of its batch instead.
use _progress =
typecheckProgressReporter.Begin(Path.GetFileName(UMX.untag file.Source.FileName))

let! result =
match compilerOptions with
Expand Down Expand Up @@ -2725,78 +2759,59 @@ type AdaptiveState
|> List.collect (fun (proj, snap) -> snap.SourceFilesTagged |> List.map (fun sourceFile -> proj, sourceFile))
|> List.toArray

let mutable checksCompleted = 0
let innerChecks =
Array.concat [| dependentFiles; dependentProjectsAndSourceFiles |]
|> Array.filter (fun (_, file) ->
let file = UMX.untag file

use progressReporter =
progressLookup.CreateProgressReport(lspClient, cancellable = true)
file.Contains "AssemblyInfo.fs" |> not
&& file.Contains "AssemblyAttributes.fs" |> not)

let percentage numerator denominator =
if denominator = 0 then
0u
else
((float numerator) / (float denominator)) * 100.0 |> uint32
use progress = typecheckProgressReporter.BeginBatch(innerChecks.Length)
let rootToken = sourceFilePath |> getOpenFileTokenOrDefault

let checksToPerform =
let innerChecks =
Array.concat [| dependentFiles; dependentProjectsAndSourceFiles |]
|> Array.filter (fun (_, file) ->
let file = UMX.untag file

file.Contains "AssemblyInfo.fs" |> not
&& file.Contains "AssemblyAttributes.fs" |> not)

let checksToPerformLength = innerChecks.Length
let rootToken = sourceFilePath |> getOpenFileTokenOrDefault

innerChecks
|> Array.map (fun (proj, file) ->
async {

use joinedToken =
asyncEx {
let fileToken =
if file = sourceFilePath then
// dont reset the token for the incoming file as it would cancel the whole operation
CancellationTokenSource.CreateLinkedTokenSource(rootToken, progressReporter.CancellationToken)
CancellationToken.None
else
// only cancel other files
// If we have multiple saves from separate root files we want only one to be running
let fileToken = resetCancellationToken file None
// and join with the root token as well since we want to cancel the whole operation if the root files changes
CancellationTokenSource.CreateLinkedTokenSource(
rootToken,
fileToken,
progressReporter.CancellationToken
)
resetCancellationToken file None

// join with the root token as well since we want to cancel the whole operation if the root files changes
use joinedToken =
CancellationTokenSource.CreateLinkedTokenSource(rootToken, fileToken, progress.CancellationToken)

use _fileProgress = progress.Begin()

try
let! _ =
bypassAdaptiveTypeCheck (file) (proj) (AVal.force proj.FSharpProjectCompilerOptions)
|> Async.withCancellation joinedToken.Token

()
with :? OperationCanceledException ->
with :? OperationCanceledException when
fileToken.IsCancellationRequested
&& not rootToken.IsCancellationRequested
&& not progress.CancellationToken.IsCancellationRequested ->
// if a file shows up multiple times in the list such as Microsoft.NET.Test.Sdk.Program.fs we may cancel it but we don't want to stop the whole operation for it
()

let checksCompleted = Interlocked.Increment(&checksCompleted)

do!
progressReporter.Report(
message = $"{checksCompleted}/{checksToPerformLength} remaining",
percentage = percentage checksCompleted checksToPerformLength
)
})



do!
progressReporter.Begin(
"Typechecking Dependent F# files",
message = $"0/{checksToPerform.Length} remaining",
percentage = percentage 0 checksToPerform.Length
try
do! checksToPerform |> Async.parallel75 |> Async.Ignore<unit array>
with :? OperationCanceledException ->
// The root file changed again or the user cancelled the progress report.
logger.debug (
Log.setMessage "Typechecking the dependent files of {file} was cancelled"
>> Log.addContextDestructured "file" sourceFilePath
)

do! checksToPerform |> Async.parallel75 |> Async.Ignore<unit array>

}


Expand Down
Loading
Loading