From 07327c1b18b7d0416b60802388d267f6eff0abfd Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 13 Sep 2026 20:12:13 +0200 Subject: [PATCH 1/2] Classify each Find All References line while the search runs C# and VB classify the line of each reference as they find it and hand it to Find All References. F# handed nothing, so the window asked the F# classification service for every line again after the search, one reference at a time. ClassifiedReferenceLine classifies the line the way Roslyn does for them: from the first non-whitespace character to the end of the line, semantic spans over syntactic ones, gaps filled with text. Needs the FSharpSourceReferenceItem constructor that takes the classified line, from dotnet/roslyn#85281. Co-Authored-By: Claude Opus 5 --- docs/release-notes/.VisualStudio/18.vNext.md | 2 + .../src/FSharp.Editor/FSharp.Editor.fsproj | 1 + .../Navigation/ClassifiedReferenceLine.fs | 100 ++++++++++++++++++ .../Navigation/FindUsagesService.fs | 17 ++- .../ClassifiedReferenceLineTests.fs | 57 ++++++++++ .../FSharp.Editor.Tests.fsproj | 1 + 6 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 vsintegration/src/FSharp.Editor/Navigation/ClassifiedReferenceLine.fs create mode 100644 vsintegration/tests/FSharp.Editor.Tests/ClassifiedReferenceLineTests.fs diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index ba03f663967..f82a5a8efc9 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -5,6 +5,8 @@ ### Fixed +* Find All References classifies the line of each F# reference while it searches, as it does for C# and Visual Basic, instead of asking the F# classification service for every line again after the search. + * 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/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj index 319bdd5a264..15c2ef59252 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj @@ -92,6 +92,7 @@ + diff --git a/vsintegration/src/FSharp.Editor/Navigation/ClassifiedReferenceLine.fs b/vsintegration/src/FSharp.Editor/Navigation/ClassifiedReferenceLine.fs new file mode 100644 index 00000000000..396da992700 --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Navigation/ClassifiedReferenceLine.fs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +/// The line a Find All References entry shows, classified while the search runs, as the C# and VB searches classify it +/// with `ClassifiedSpansAndHighlightSpanFactory`, instead of by the window one entry at a time after the search. +module internal Microsoft.VisualStudio.FSharp.Editor.ClassifiedReferenceLine + +open System +open System.Collections.Generic +open System.Collections.Immutable + +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.Classification +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Classification +open Microsoft.CodeAnalysis.Text + +open CancellableTasks + +/// From the first non-whitespace character of the reference's line to the end of the line. +let lineSpanOf (sourceText: SourceText) (referenceSpan: TextSpan) = + let line = sourceText.Lines.GetLineFromPosition referenceSpan.Start + let mutable start = line.Start + + while start < line.End && Char.IsWhiteSpace sourceText[start] do + start <- start + 1 + + let firstNonWhitespace = if start = line.End then line.Start else start + TextSpan.FromBounds(min firstNonWhitespace referenceSpan.Start, line.End) + +let private byStart = + Comparison(fun left right -> left.TextSpan.Start - right.TextSpan.Start) + +/// Sorted and clipped to the line. A span that starts before the one kept ahead of it ends is emptied, as +/// `ClassifierHelper.AdjustSpans` empties it. +let private adjusted (lineSpan: TextSpan) (spans: List) = + spans.Sort byStart + let result = List(spans.Count) + + for span in spans do + let intersection = span.TextSpan.Intersection lineSpan + + let kept = + intersection.HasValue + && (result.Count = 0 + || result[result.Count - 1].TextSpan.End <= intersection.Value.Start) + + result.Add(ClassifiedSpan(span.ClassificationType, (if kept then intersection.Value else TextSpan()))) + + result + +/// Every semantic span, and what no semantic span covers of each syntactic one. +let private merged (syntactic: List) (semantic: List) = + let semanticParts = semantic.FindAll(fun span -> not span.TextSpan.IsEmpty) + let result = List(semanticParts) + + for part in syntactic do + let mutable start = part.TextSpan.Start + + for covering in semanticParts do + if covering.TextSpan.Start < part.TextSpan.End && covering.TextSpan.End > start then + if covering.TextSpan.Start > start then + result.Add(ClassifiedSpan(part.ClassificationType, TextSpan.FromBounds(start, covering.TextSpan.Start))) + + start <- max start covering.TextSpan.End + + if start < part.TextSpan.End then + result.Add(ClassifiedSpan(part.ClassificationType, TextSpan.FromBounds(start, part.TextSpan.End))) + + result.Sort byStart + result + +let private withGapsFilled (lineStart: int) (spans: List) = + let result = ImmutableArray.CreateBuilder(spans.Count) + let mutable position = lineStart + + for span in spans do + if not span.TextSpan.IsEmpty then + if position < span.TextSpan.Start then + result.Add(ClassifiedSpan(ClassificationTypeNames.Text, TextSpan.FromBounds(position, span.TextSpan.Start))) + + result.Add span + position <- span.TextSpan.End + + result.ToImmutable() + +/// The classified line of the reference, and the reference's place in it: what `ClassifiedSpansAndHighlightSpan` holds. +let classifyAsync (classifier: IFSharpClassificationService) (document: Document) (sourceText: SourceText) (referenceSpan: TextSpan) = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + let lineSpan = lineSpanOf sourceText referenceSpan + let syntactic = List() + let semantic = List() + do! classifier.AddSyntacticClassificationsAsync(document, lineSpan, syntactic, ct) + do! classifier.AddSemanticClassificationsAsync(document, lineSpan, semantic, ct) + + let classifiedSpans = + merged (adjusted lineSpan syntactic) (adjusted lineSpan semantic) + |> withGapsFilled lineSpan.Start + + return struct (classifiedSpans, TextSpan(referenceSpan.Start - lineSpan.Start, referenceSpan.Length)) + } diff --git a/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs b/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs index c313d4f27ee..6b67ce90c5d 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/FindUsagesService.fs @@ -8,6 +8,7 @@ open System.Threading.Tasks open Microsoft.CodeAnalysis open Microsoft.CodeAnalysis.ExternalAccess.FSharp +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Classification open Microsoft.CodeAnalysis.ExternalAccess.FSharp.FindUsages open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Editor.FindUsages @@ -48,11 +49,21 @@ module FSharpFindUsagesService = |> Option.map (fun (definitionItem, _) -> definitionItem) |> Option.defaultValue externalDefinitionItem - let referenceItem = - FSharpSourceReferenceItem(definitionItem, FSharpDocumentSpan(doc, fixedSpan)) + let classifier = FSharpClassificationService() :> IFSharpClassificationService // REVIEW: OnReferenceFoundAsync is throwing inside Roslyn, putting a try/with so find-all refs doesn't fail. try - do! onReferenceFoundAsync referenceItem + let! struct (classifiedSpans, highlightSpan) = + ClassifiedReferenceLine.classifyAsync classifier doc sourceText fixedSpan + + do! + onReferenceFoundAsync ( + FSharpSourceReferenceItem( + definitionItem, + FSharpDocumentSpan(doc, fixedSpan), + classifiedSpans, + highlightSpan + ) + ) with _ -> () | _ -> () diff --git a/vsintegration/tests/FSharp.Editor.Tests/ClassifiedReferenceLineTests.fs b/vsintegration/tests/FSharp.Editor.Tests/ClassifiedReferenceLineTests.fs new file mode 100644 index 00000000000..19aa1c53d02 --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/ClassifiedReferenceLineTests.fs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +module FSharp.Editor.Tests.ClassifiedReferenceLineTests + +open Xunit + +open Microsoft.CodeAnalysis.Classification +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Classification +open Microsoft.CodeAnalysis.Text +open Microsoft.VisualStudio.FSharp.Editor + +open FSharp.Editor.Tests.Helpers + +let private source = + """ +module M + +let add x y = x + y + +let result = + add 1 2 +""" + +[] +let ``A reference line is classified from its first non-whitespace character with every character covered`` () = + let document = + RoslynTestHelpers.CreateSolution(source).Projects + |> Seq.exactlyOne + |> _.Documents + |> Seq.exactlyOne + + let sourceText = document.GetTextAsync().Result + + let referenceSpan = + TextSpan(source.LastIndexOf("add", System.StringComparison.Ordinal), 3) + + let classifier = FSharpClassificationService() :> IFSharpClassificationService + + let struct (classifiedSpans, highlightSpan) = + (ClassifiedReferenceLine.classifyAsync classifier document sourceText referenceSpan) System.Threading.CancellationToken.None + |> _.Result + + let line = sourceText.Lines.GetLineFromPosition referenceSpan.Start + Assert.Equal("add 1 2", sourceText.ToString(TextSpan.FromBounds(classifiedSpans[0].TextSpan.Start, line.End))) + Assert.Equal(line.End, classifiedSpans[classifiedSpans.Length - 1].TextSpan.End) + + for previous, next in Seq.pairwise classifiedSpans do + Assert.Equal(previous.TextSpan.End, next.TextSpan.Start) + + Assert.Equal(TextSpan(0, 3), highlightSpan) + + let reference = + classifiedSpans |> Seq.find (fun span -> span.TextSpan = referenceSpan) + + Assert.NotEqual(ClassificationTypeNames.Text, reference.ClassificationType) + + Assert.Contains(classifiedSpans, (fun span -> span.ClassificationType = ClassificationTypeNames.NumericLiteral)) diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj index ecce1205b8c..b0505adec20 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj +++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj @@ -27,6 +27,7 @@ + From 66279062d62cb5a642af34fa5c904fe5b586c086 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Sun, 13 Sep 2026 20:19:38 +0200 Subject: [PATCH 2/2] Link the release note to its PR Co-Authored-By: Claude Opus 5 --- docs/release-notes/.VisualStudio/18.vNext.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index f82a5a8efc9..ac0a57bc5a6 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -5,7 +5,7 @@ ### Fixed -* Find All References classifies the line of each F# reference while it searches, as it does for C# and Visual Basic, instead of asking the F# classification service for every line again after the search. +* Find All References classifies the line of each F# reference while it searches, as it does for C# and Visual Basic, instead of asking the F# classification service for every line again after the search. ([PR #20533](https://github.com/dotnet/fsharp/pull/20533)) * 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))