diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md index 27f2c0070d7..cb0e3aa2b95 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md @@ -221,3 +221,4 @@ * `FSharp.Compiler.Syntax.SynComponentInfo` now holds the type name as `synType: SynType option` instead of the previous `longId: LongIdent` field, so tuple-type extensions such as `type ('T1 * 'T2) with` can be represented. A `member LongIdent` compatibility property returns the long identifier for named types and an empty list for tuple or erroneous type names. AST consumers that pattern-matched on the `longId` field must switch to the `synType` field or the `LongIdent` member. ([PR #19602](https://github.com/dotnet/fsharp/pull/19602)) * Optimizer: don't inline named functions in debug builds ([PR #19548](https://github.com/dotnet/fsharp/pull/19548) * LexFilter: drop non-strict mode ([PR #20106](https://github.com/dotnet/fsharp/pull/20106)) +* `FSharp.Compiler.EditorServices.Structure.getOutliningRanges` now takes the source lines as `ReadOnlyMemory[]` instead of `string[]`, so a caller that already holds the whole text can slice it instead of building a string per line. Callers passing a `string[]` can migrate with `Array.map (fun line -> line.AsMemory())`. diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index ba03f663967..8c519326f1e 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -2,9 +2,11 @@ * Code-fixes for FS3888 (compiler-semantic attribute on the `.fs` but not the `.fsi`): copy the attribute into the `.fsi`, or remove it from the `.fs`. ([Issue #19560](https://github.com/dotnet/fsharp/issues/19560), [PR #19880](https://github.com/dotnet/fsharp/pull/19880)) * Expand `` in IDE tooltips, completion, and signature help, inheriting XML documentation from base classes, interfaces, overridden members, and constructors. ([Issue #19175](https://github.com/dotnet/fsharp/issues/19175), [PR #19188](https://github.com/dotnet/fsharp/pull/19188)) +* F# types, modules, members and values now appear in the GitHub Copilot Chat `#` mention picker, and attach their declaration source as context. ([PR #20409](https://github.com/dotnet/fsharp/pull/20409)) ### Fixed +* Navigate To lists F# declarations while the solution is still loading. Until now the search that runs during load skipped F# entirely, and the full search that follows it is never started, so nothing F# declares could be found until the next search. ([PR #20492](https://github.com/dotnet/fsharp/pull/20492)) * 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/eng/Packages.props b/eng/Packages.props index c6b2eabb7a2..5c5405d922d 100644 --- a/eng/Packages.props +++ b/eng/Packages.props @@ -64,6 +64,9 @@ ComponentModelHost would otherwise stay at 17.x; that 17.x/18.x split makes S/IComponentModel ambiguous (CS0433). Pin to the SDK 18.9.496 version so those types resolve to a single assembly. --> + + diff --git a/src/Compiler/Service/ServiceStructure.fs b/src/Compiler/Service/ServiceStructure.fs index 99dd528eeb0..6937981ecec 100644 --- a/src/Compiler/Service/ServiceStructure.fs +++ b/src/Compiler/Service/ServiceStructure.fs @@ -2,6 +2,7 @@ namespace FSharp.Compiler.EditorServices +open System open Internal.Utilities.Library open FSharp.Compiler.Syntax open FSharp.Compiler.SyntaxTreeOps @@ -186,27 +187,35 @@ module Structure = } type LineNumber = int - type LineStr = string type CommentType = | SingleLine | XmlDoc + /// Determine if a line is a single line or xml documentation comment. + /// Kept at module scope: a local recursive function capturing a `ReadOnlySpan`-typed + /// helper as a closure field would need to instantiate `FSharpFunc, _>`, + /// which the CLR disallows for byref-like type arguments (FS0412). + let commentTypeOf (line: ReadOnlySpan) = + if line.StartsWithOrdinal("///") then ValueSome XmlDoc + elif line.StartsWithOrdinal("//") then ValueSome SingleLine + else ValueNone + [] type CommentList = { - Lines: ResizeArray + Lines: ResizeArray Type: CommentType } - static member New ty lineStr = + static member New ty lineNum = { Type = ty - Lines = ResizeArray [ lineStr ] + Lines = ResizeArray [ lineNum ] } /// Returns outlining ranges for given parsed input. - let getOutliningRanges (sourceLines: string[]) (parsedInput: ParsedInput) = + let getOutliningRanges (sourceLines: ReadOnlyMemory[]) (parsedInput: ParsedInput) = let acc = ResizeArray() /// Validation function to ensure that ranges yielded for outlining span 2 or more lines @@ -661,7 +670,7 @@ module Structure = | r :: rest, last :: _ when r.StartLine = last.EndLine + 1 || sourceLines[last.EndLine .. r.StartLine - 2] - |> Array.forall System.String.IsNullOrWhiteSpace + |> Array.forall (fun line -> line.Span.IsWhiteSpace()) -> loop rest res (r :: currentBulk) | r :: rest, _ -> loop rest (currentBulk :: res) [ r ] @@ -719,7 +728,7 @@ module Structure = let collectConditionalDirectives directives sourceLines = // Adds a fold region from prevRange.Start to the line above nextLine - let addSectionFold (prevRange: range) (nextLine: int) (sourceLines: string array) = + let addSectionFold (prevRange: range) (nextLine: int) (sourceLines: ReadOnlyMemory[]) = let startLineIndex = nextLine - 2 if startLineIndex >= 0 then @@ -753,7 +762,7 @@ module Structure = | ConditionalDirectiveTrivia.Else r -> ValueSome r | _ -> ValueNone - let rec group directives stack (sourceLines: string array) = + let rec group directives stack (sourceLines: ReadOnlyMemory[]) = match directives with | [] -> () | ConditionalDirectiveTrivia.If _ as ifDirective :: directives -> group directives (ifDirective :: stack) sourceLines @@ -822,36 +831,29 @@ module Structure = collectOpens decls List.iter parseDeclaration decls - /// Determine if a line is a single line or xml documentation comment - let (|Comment|_|) (line: string) = - if line.StartsWithOrdinal("///") then Some XmlDoc - elif line.StartsWithOrdinal("//") then Some SingleLine - else None - - let getCommentRanges trivia (lines: string[]) = - let rec loop (lastLineNum, currentComment, result as state) (lines: string list) lineNum = - match lines with - | [] -> state - | lineStr :: rest -> - match lineStr.TrimStart(), currentComment with - | Comment commentType, Some comment -> + let getCommentRanges trivia (lines: ReadOnlyMemory[]) = + let rec loop (lastLineNum, currentComment, result as state) lineNum = + if lineNum = lines.Length then + state + else + match commentTypeOf (lines[lineNum].Span.TrimStart()), currentComment with + | ValueSome commentType, Some comment -> loop (if comment.Type = commentType && lineNum = lastLineNum + 1 then - comment.Lines.Add(lineNum, lineStr) + comment.Lines.Add lineNum lineNum, currentComment, result else - let comments = CommentList.New commentType (lineNum, lineStr) + let comments = CommentList.New commentType lineNum lineNum, Some comments, comment :: result) - rest (lineNum + 1) - | Comment commentType, None -> - let comments = CommentList.New commentType (lineNum, lineStr) - loop (lineNum, Some comments, result) rest (lineNum + 1) - | _, Some comment -> loop (lineNum, None, comment :: result) rest (lineNum + 1) - | _ -> loop (lineNum, None, result) rest (lineNum + 1) + | ValueSome commentType, None -> + let comments = CommentList.New commentType lineNum + loop (lineNum, Some comments, result) (lineNum + 1) + | ValueNone, Some comment -> loop (lineNum, None, comment :: result) (lineNum + 1) + | ValueNone, None -> loop (lineNum, None, result) (lineNum + 1) let comments = - let _, lastComment, comments = loop (-1, None, []) (List.ofArray lines) 0 + let _, lastComment, comments = loop (-1, None, []) 0 match lastComment with | Some comment -> comment :: comments @@ -859,13 +861,12 @@ module Structure = |> List.rev comments - |> List.filter (fun comment -> comment.Lines.Count > 1) - |> List.map (fun comment -> - let lines = comment.Lines - let startLine, startStr = lines[0] - let endLine, endStr = lines[lines.Count - 1] - let startCol = startStr.IndexOf '/' - let endCol = endStr.TrimEnd().Length + |> Seq.filter (fun comment -> comment.Lines.Count > 1) + |> Seq.map (fun comment -> + let startLine = comment.Lines[0] + let endLine = comment.Lines[comment.Lines.Count - 1] + let startCol = lines[startLine].Span.IndexOf '/' + let endCol = lines[endLine].Span.TrimEnd().Length let scopeType = match comment.Type with diff --git a/src/Compiler/Service/ServiceStructure.fsi b/src/Compiler/Service/ServiceStructure.fsi index 87711629676..3695e7148ac 100644 --- a/src/Compiler/Service/ServiceStructure.fsi +++ b/src/Compiler/Service/ServiceStructure.fsi @@ -2,6 +2,7 @@ namespace FSharp.Compiler.EditorServices +open System open FSharp.Compiler.Syntax open FSharp.Compiler.Text @@ -79,4 +80,4 @@ module public Structure = } /// Returns outlining ranges for given parsed input. - val getOutliningRanges: sourceLines: string[] -> parsedInput: ParsedInput -> seq + val getOutliningRanges: sourceLines: ReadOnlyMemory[] -> parsedInput: ParsedInput -> seq diff --git a/src/Compiler/Utilities/illib.fs b/src/Compiler/Utilities/illib.fs index 244158619d2..c3a6ebb5ca8 100644 --- a/src/Compiler/Utilities/illib.fs +++ b/src/Compiler/Utilities/illib.fs @@ -7,6 +7,7 @@ open System.Collections.Generic open System.Collections.Concurrent open System.Diagnostics open System.IO +open System.Linq open System.Threading open System.Threading.Tasks open System.Runtime.CompilerServices @@ -112,6 +113,54 @@ module internal PervasiveAutoOpens = member inline x.IndexOfOrdinal(value: string, startIndex, count) = x.IndexOf(value, startIndex, count, StringComparison.Ordinal) + [] + type ReadOnlySpanCharExtensions = + + static member inline StartsWithOrdinal(str: ReadOnlySpan, value: ReadOnlySpan) = + str.StartsWith(value, StringComparison.Ordinal) + + static member inline StartsWithOrdinal(str: ReadOnlySpan, value: string) = + str.StartsWith(value.AsSpan(), StringComparison.Ordinal) + + static member inline EndsWithOrdinal(str: ReadOnlySpan, value: ReadOnlySpan) = + str.EndsWith(value, StringComparison.Ordinal) + + static member inline EndsWithOrdinal(str: ReadOnlySpan, value: string) = + str.EndsWith(value.AsSpan(), StringComparison.Ordinal) + + static member inline EndsWithOrdinalIgnoreCase(str: ReadOnlySpan, value: ReadOnlySpan) = + str.EndsWith(value, StringComparison.OrdinalIgnoreCase) + + static member inline EndsWithOrdinalIgnoreCase(str: ReadOnlySpan, value: string) = + str.EndsWith(value.AsSpan(), StringComparison.OrdinalIgnoreCase) + + static member IndexOf(str: ReadOnlySpan, value: char) = + let mutable index = -1 + let mutable i = 0 + + while i < str.Length && index = -1 do + if str[i] = value then index <- i else i <- i + 1 + + index + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: ReadOnlySpan) = + str.IndexOf(value, StringComparison.Ordinal) + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: string) = + str.IndexOf(value.AsSpan(), StringComparison.Ordinal) + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: ReadOnlySpan, startIndex) = + str.Slice(startIndex).IndexOf(value, StringComparison.Ordinal) + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: string, startIndex) = + str.Slice(startIndex).IndexOf(value.AsSpan(), StringComparison.Ordinal) + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: ReadOnlySpan, startIndex, count) = + str.Slice(startIndex, count).IndexOf(value, StringComparison.Ordinal) + + static member inline IndexOfOrdinal(str: ReadOnlySpan, value: string, startIndex, count) = + str.Slice(startIndex, count).IndexOf(value.AsSpan(), StringComparison.Ordinal) + /// Get an initialization hole let getHole (r: _ ref) = match r.Value with diff --git a/src/Compiler/Utilities/illib.fsi b/src/Compiler/Utilities/illib.fsi index 0ee41f441b5..f77340b9e7b 100644 --- a/src/Compiler/Utilities/illib.fsi +++ b/src/Compiler/Utilities/illib.fsi @@ -68,6 +68,48 @@ module internal PervasiveAutoOpens = member inline IndexOfOrdinal: value: string * startIndex: int * count: int -> int + [] + type ReadOnlySpanCharExtensions = + + [] + static member inline StartsWithOrdinal: str : ReadOnlySpan * value: ReadOnlySpan -> bool + + [] + static member inline StartsWithOrdinal: str : ReadOnlySpan * value: string -> bool + + [] + static member inline EndsWithOrdinal: str : ReadOnlySpan * value: ReadOnlySpan -> bool + + [] + static member inline EndsWithOrdinal: str : ReadOnlySpan * value: string -> bool + + [] + static member inline EndsWithOrdinalIgnoreCase: str : ReadOnlySpan * value: ReadOnlySpan -> bool + + [] + static member inline EndsWithOrdinalIgnoreCase: str : ReadOnlySpan * value: string -> bool + + [] + static member IndexOf: str : ReadOnlySpan * value: char -> int + + [] + static member inline IndexOfOrdinal: str : ReadOnlySpan * value: ReadOnlySpan -> int + + [] + static member inline IndexOfOrdinal: str : ReadOnlySpan * value: string -> int + + [] + static member inline IndexOfOrdinal: str : ReadOnlySpan * value: ReadOnlySpan * startIndex: int -> int + + [] + static member inline IndexOfOrdinal: str : ReadOnlySpan * value: string * startIndex: int -> int + + [] + static member inline IndexOfOrdinal: str : ReadOnlySpan * value: ReadOnlySpan * startIndex: int * count: int -> int + + [] + static member inline IndexOfOrdinal: str : ReadOnlySpan * value: string * startIndex: int * count: int -> int + type Async with /// Runs the computation synchronously, always starting on the current thread. diff --git a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl index 5c9c346b613..7f4e7d14ec4 100644 --- a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl +++ b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl @@ -4744,7 +4744,7 @@ FSharp.Compiler.EditorServices.Structure+ScopeRange: Void .ctor(Scope, Collapse, FSharp.Compiler.EditorServices.Structure: FSharp.Compiler.EditorServices.Structure+Collapse FSharp.Compiler.EditorServices.Structure: FSharp.Compiler.EditorServices.Structure+Scope FSharp.Compiler.EditorServices.Structure: FSharp.Compiler.EditorServices.Structure+ScopeRange -FSharp.Compiler.EditorServices.Structure: System.Collections.Generic.IEnumerable`1[FSharp.Compiler.EditorServices.Structure+ScopeRange] getOutliningRanges(System.String[], FSharp.Compiler.Syntax.ParsedInput) +FSharp.Compiler.EditorServices.Structure: System.Collections.Generic.IEnumerable`1[FSharp.Compiler.EditorServices.Structure+ScopeRange] getOutliningRanges(System.ReadOnlyMemory`1[System.Char][], FSharp.Compiler.Syntax.ParsedInput) FSharp.Compiler.EditorServices.ToolTipElement+CompositionError: System.String errorText FSharp.Compiler.EditorServices.ToolTipElement+CompositionError: System.String get_errorText() FSharp.Compiler.EditorServices.ToolTipElement+Group: Microsoft.FSharp.Collections.FSharpList`1[FSharp.Compiler.EditorServices.ToolTipElementData] elements diff --git a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj index e043d8554ad..0183589a540 100644 --- a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj +++ b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj @@ -223,4 +223,10 @@ + + + + + diff --git a/tests/FSharp.Compiler.Service.Tests/StructureTests.fs b/tests/FSharp.Compiler.Service.Tests/StructureTests.fs index c0ae0d3fdff..322a0fa3bda 100644 --- a/tests/FSharp.Compiler.Service.Tests/StructureTests.fs +++ b/tests/FSharp.Compiler.Service.Tests/StructureTests.fs @@ -1,5 +1,6 @@ module FSharp.Compiler.Service.Tests.StructureTests +open System open System.IO open Xunit open FSharp.Compiler.EditorServices.Structure @@ -35,7 +36,7 @@ let (=>) (source: string) (expectedRanges: (Range * Range) list) = let ast = parseSourceCode(fileName, source) try let actual = - getOutliningRanges lines ast + getOutliningRanges (lines |> Array.map _.AsMemory()) ast |> Seq.filter (fun sr -> sr.Range.StartLine <> sr.Range.EndLine) |> Seq.map (fun sr -> getRange sr.Range, getRange sr.CollapseRange) |> Seq.sort @@ -151,7 +152,7 @@ module MyModule = // 2 type Color = // 7 { Red: int Green: int - Blue: int + Blue: int } interface IDisposable with // 13 @@ -163,7 +164,7 @@ module MyModule = // 2 type RecordColor = // 19 { Red: int Green: int - Blue: int + Blue: int } interface IDisposable with // 25 @@ -189,31 +190,31 @@ module MyModule = // 2 [] let ``open statements``() = """ -open M -open N - -module M = - let x = 1 - - open M - open N - - module M = - open M - - let x = 1 - - module M = - open M - open N - let x = 1 - -open M -open N -open H - -open G -open H +open M +open N + +module M = + let x = 1 + + open M + open N + + module M = + open M + + let x = 1 + + module M = + open M + open N + let x = 1 + +open M +open N +open H + +open G +open H """ => [ (2, 0, 3, 6), (2, 0, 3, 6) (5, 0, 19, 17), (5, 8, 19, 17) @@ -226,28 +227,28 @@ open H [] let ``hash directives``() = """ -#r @"a" -#r "b" - -#r "c" - -#r "d" -#r "e" -let x = 1 - -#r "f" -#r "g" -#load "x" -#r "y" - -#load "a" - "b" - "c" - -#load "a" - "b" - "c" -#r "d" +#r @"a" +#r "b" + +#r "c" + +#r "d" +#r "e" +let x = 1 + +#r "f" +#r "g" +#load "x" +#r "y" + +#load "a" + "b" + "c" + +#load "a" + "b" + "c" +#r "d" """ => [ (2, 3, 8, 6), (2, 3, 8, 6) (11, 3, 23, 6), (11, 3, 23, 6) ] @@ -325,7 +326,7 @@ seq { // 2 [] let ``list``() = """ -let _ = +let _ = [ 1; 2 3 ] """ @@ -382,7 +383,7 @@ finally // 5 let ``if - then - else``() = """ if true then - let f x = + let f x = () () else @@ -448,7 +449,7 @@ for x = 100 downto 10 do [] let ``for each``() = """ -for x in 0 .. 100 -> +for x in 0 .. 100 -> () () """ @@ -467,7 +468,7 @@ let ``tuple``() = [] let ``do!``() = """ -do! +do! printfn "allo" printfn "allo" """ @@ -477,10 +478,10 @@ do! let ``cexpr yield yield!``() = """ cexpr{ - yield! + yield! cexpr{ - yield - + yield + 10 } } @@ -659,7 +660,7 @@ let ``Abstract members`` () = type T() = abstract Foo: int - + [] abstract Foo: int diff --git a/vsintegration/src/FSharp.Editor/Common/Constants.fs b/vsintegration/src/FSharp.Editor/Common/Constants.fs index ead451467cf..d0f493af6df 100644 --- a/vsintegration/src/FSharp.Editor/Common/Constants.fs +++ b/vsintegration/src/FSharp.Editor/Common/Constants.fs @@ -43,6 +43,11 @@ module internal FSharpConstants = /// "F# Language Service" let FSharpLanguageServiceCallbackName = "F# Language Service" + [] + /// Brokered service offering F# declarations to the Copilot chat "#" mention picker. + let copilotSymbolProviderName = + "Microsoft.VisualStudio.FSharp.CopilotSymbolContextProvider" + [] /// "FSharp" let FSharpLanguageLongName = "FSharp" diff --git a/vsintegration/src/FSharp.Editor/Common/Extensions.fs b/vsintegration/src/FSharp.Editor/Common/Extensions.fs index f9695e68ecf..89185ebe556 100644 --- a/vsintegration/src/FSharp.Editor/Common/Extensions.fs +++ b/vsintegration/src/FSharp.Editor/Common/Extensions.fs @@ -296,6 +296,14 @@ type SourceText with member this.ToFSharpSourceText() = SourceText.weakTable.GetValue(this, Runtime.CompilerServices.ConditionalWeakTable<_, _>.CreateValueCallback(SourceText.create)) + /// The lines of the text, as slices of a single string rather than one string per line. + member this.GetLinesAsMemory() = + let text = this.ToString() + + Array.init this.Lines.Count (fun i -> + let line = this.Lines[i] + text.AsMemory(line.Start, line.End - line.Start)) + type NavigationItem with member x.RoslynGlyph: FSharpRoslynGlyph = diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs new file mode 100644 index 00000000000..73c1e3b166b --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs @@ -0,0 +1,347 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Editor + +open System +open System.Collections.Generic +open System.ComponentModel.Composition +open System.IO +open System.Threading.Tasks + +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Navigation +open Microsoft.CodeAnalysis.Text +open Microsoft.ServiceHub.Framework +open Microsoft.VisualStudio.Copilot +open Microsoft.VisualStudio.LanguageServices +open Microsoft.VisualStudio.Shell +open Microsoft.VisualStudio.Shell.ServiceBroker + +open FSharp.Compiler.EditorServices +open CancellableTasks + +/// Solution-wide lookup of F# declarations behind the Copilot chat "#" mention picker. +/// Kept apart from the brokered service so it can be exercised without a Visual Studio workspace. +module internal CopilotSymbolQuery = + + [] + let private MaxMentions = 20 + + /// Overloads and partial definitions share one fully qualified name; a handful of them is plenty of context. + [] + let private MaxDeclarations = 4 + + [] + let private UserOpName = "CopilotSymbolContext" + + let private fsharpDocuments (solution: Solution) = + solution.Projects + |> Seq.where (fun project -> project.Language = FSharpConstants.FSharpLanguageName) + |> Seq.collect _.Documents + + let describe (item: NavigableItem) (document: Document) = + let container = + match item.Container.FullName with + | "" -> Path.GetFileName document.FilePath + | name -> name + + if document.IsFSharpSignatureFile then + $"signature, {container} - {document.Project.Name}" + else + $"{container} - {document.Project.Name}" + + /// Declarations whose fully qualified name matches `searchText`, best match first, one entry per name. + let search (cache: FSharpNavigableItemsCache) (solution: Solution) (searchText: string) = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + let tryMatch = cache.CreateMatcherFor searchText + + let matchesIn (document: Document) = + cancellableTask { + ct.ThrowIfCancellationRequested() + let! items = cache.GetNavigableItems document + + return + items + |> Seq.chooseV (fun item -> + tryMatch item + |> ValueOption.map (fun patternMatch -> struct (patternMatch.Kind, item, document))) + } + + let! hits = + fsharpDocuments solution + |> Seq.map matchesIn + // Throttle to avoid launching a parse per document in the solution all at once. + |> CancellableTask.whenAllThrottled (max 1 Environment.ProcessorCount) + + return + hits + |> Seq.collect id + |> Seq.sortBy (fun (struct (kind, item: NavigableItem, document: Document)) -> + document.IsFSharpSignatureFile, kind, item.Name.Length) + |> Seq.distinctBy (fun (struct (_, item, _)) -> CopilotSymbolMapping.fullyQualifiedName item) + |> Seq.truncate MaxMentions + |> Seq.map (fun (struct (_, item, document)) -> struct (item, document)) + |> Seq.toArray + } + + /// Declarations carrying exactly this fully qualified name. Signature files answer only when no + /// implementation declares the name. + let declarationsOf (cache: FSharpNavigableItemsCache) (solution: Solution) (fullyQualifiedName: string) = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + + let matchesIn (document: Document) = + cancellableTask { + ct.ThrowIfCancellationRequested() + let! items = cache.GetNavigableItems document + + return + items + |> Seq.chooseV (fun item -> + if CopilotSymbolMapping.hasFullyQualifiedName fullyQualifiedName item then + ValueSome struct (item, document) + else + ValueNone) + } + + let! hits = + fsharpDocuments solution + |> Seq.map matchesIn + // Throttle to avoid launching a parse per document in the solution all at once. + |> CancellableTask.whenAllThrottled (max 1 Environment.ProcessorCount) + |> CancellableTask.map (Seq.collect id) + + let implementations = + hits + |> Seq.filter (fun (struct (_, document: Document)) -> not document.IsFSharpSignatureFile) + + let preferred = + if Seq.isEmpty implementations then + hits :> _ seq + else + implementations + + return preferred |> Seq.truncate MaxDeclarations |> Seq.toArray + } + + /// The source of the whole declaration `item` names, together with the span it occupies. + let snippetOf (item: NavigableItem) (document: Document) = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + let! sourceText = document.GetTextAsync ct + let! parseResults = document.GetFSharpParseResultsAsync UserOpName + + let sourceLines = sourceText.GetLinesAsMemory() + + let scopes = Structure.getOutliningRanges sourceLines parseResults.ParseTree + + let struct (firstLine, lastLine) = + CopilotSymbolSnippets.definitionLines sourceLines scopes item + + let firstLine = max 1 firstLine + let lastLine = min sourceText.Lines.Count lastLine + + let span = + TextSpan.FromBounds(sourceText.Lines[firstLine - 1].Start, sourceText.Lines[lastLine - 1].End) + + return struct (sourceText.GetSubText(span).ToString(), span) + } + + let symbolContext (cache: FSharpNavigableItemsCache) (solution: Solution) (fullyQualifiedName: string) = + cancellableTask { + let! declarations = declarationsOf cache solution fullyQualifiedName + + match Array.tryHeadV declarations with + | ValueNone -> return ValueNone + | ValueSome(struct (first, _)) -> + let snippets = ResizeArray() + let locations = ResizeArray() + + for struct (item, document) in declarations do + let! struct (text, span) = snippetOf item document + snippets.Add text + locations.Add(SnippetLocation(document.FilePath, CopilotSpan(span.Start, span.Length))) + + return + ValueSome( + CopilotSymbolContext( + fullyQualifiedName, + first.Name, + String.Join(Environment.NewLine + Environment.NewLine, snippets), + CopilotSymbolMapping.symbolContextType first.Kind, + locations.ToArray() + ) + ) + } + +/// Offers F# declarations to Copilot chat, which merges them into the picker shown for "#". +/// Copilot's own symbol provider reads the Roslyn compilation, which F# projects do not have. +[; typeof |], + Audience = (ServiceAudience.PublicSdk ||| ServiceAudience.Local))>] +type internal FSharpCopilotContextProvider + [] + (cache: FSharpNavigableItemsCache, [] workspace: VisualStudioWorkspace | null) = + + static let moniker = + ServiceMoniker(FSharpConstants.copilotSymbolProviderName, Version CopilotDescriptors.CurrentContextProviderVersion) + + static let descriptor = + CopilotContextDescriptor( + CopilotSymbolMapping.SymbolMember, + "An F# type, module, member or value declared in the current solution.", + CopilotDefaultTypes.SymbolContextName, + [| + CopilotInputDescriptor( + CopilotSymbolMapping.FullyQualifiedNameInput, + "Fully qualified name of the F# declaration.", + CopilotDefaultTypes.StringName, + IsRequired = true + ) + |] + ) + + static let members = [| descriptor |] :> IReadOnlyList + + static let memberNames = [| CopilotSymbolMapping.SymbolMember |] :> IReadOnlyList + + static let noMentions = + Array.empty :> IReadOnlyCollection + + let mentionFor (item: NavigableItem) (document: Document) = + let inputs = Dictionary(StringComparer.Ordinal) + + inputs[CopilotSymbolMapping.FullyQualifiedNameInput] <- + CopilotValue(CopilotDefaultTypes.StringName, CopilotSymbolMapping.fullyQualifiedName item) + + let description = CopilotSymbolQuery.describe item document + + CopilotQueriedContextMention( + moniker, + descriptor, + inputs, + item.Name, + Description = description, + Tooltip = description, + Icon = Nullable(CopilotSymbolMapping.icon item.Kind), + IsNavigable = true + ) + :> CopilotQueriedMention + + /// The user is still typing, so the trailing input is the search text. It is preceded by the member + /// name once the mention has been committed, as in "#fsharpSymbol:Namespace.Type". + let searchTextOf (query: CopilotMentionQuery) = + match query.Type, query.Inputs with + | CopilotMentionType.Context, null -> ValueNone + | CopilotMentionType.Context, inputs when inputs.Count > 0 -> + match inputs[inputs.Count - 1] with + | text when String.IsNullOrWhiteSpace text -> ValueNone + | text when String.Equals(text, CopilotSymbolMapping.SymbolMember, StringComparison.Ordinal) -> ValueNone + | text -> ValueSome text + | _ -> ValueNone + + let mentionsFor (searchText: string voption) = + cancellableTask { + match workspace, searchText with + | null, _ + | _, ValueNone -> return noMentions + | workspace, ValueSome searchText -> + let! hits = CopilotSymbolQuery.search cache workspace.CurrentSolution searchText + + return + hits |> Array.map (fun (struct (item, document)) -> mentionFor item document) + :> IReadOnlyCollection + } + + let fullyQualifiedNameOf (inputs: IReadOnlyDictionary | null) = + match inputs with + | null -> ValueNone + | inputs -> + match inputs.TryGetValue CopilotSymbolMapping.FullyQualifiedNameInput with + | true, value -> + match value.TryGetValue() with + | true, name when not (String.IsNullOrWhiteSpace name) -> ValueSome name + | _ -> ValueNone + | _ -> ValueNone + + interface IExportedBrokeredService with + member _.Descriptor = CopilotDescriptors.CreateContextProviderDescriptor moniker + + member _.InitializeAsync _cancellationToken = Task.CompletedTask + + interface ICopilotContextReducer with + member _.ReduceAsync(context, _reduction, _counter, _cancellationToken) = Task.FromResult context + + interface ICopilotContextProvider with + member _.GetMembersAsync _cancellationToken = + ValueTask> members + + member _.GetMembersAsync(_requestId, _cancellationToken) = Task.FromResult memberNames + + member _.StoreAsync(_requestId, _cancellationToken) = ValueTask() + + member _.ReleaseAsync(_requestId, _cancellationToken) = ValueTask() + + member _.GetContextAsync(requestId, memberName, inputs, cancellationToken) : Task = + match workspace, fullyQualifiedNameOf inputs with + | null, _ + | _, ValueNone -> Task.FromResult null + | workspace, ValueSome fullyQualifiedName when + String.Equals(memberName, CopilotSymbolMapping.SymbolMember, StringComparison.Ordinal) + -> + cancellableTask { + let! symbol = CopilotSymbolQuery.symbolContext cache workspace.CurrentSolution fullyQualifiedName + + match symbol with + | ValueNone -> return null + | ValueSome symbol -> return CopilotContext(moniker, descriptor, requestId, symbol, CanReduce = false) + } + |> CancellableTask.start cancellationToken + | _ -> Task.FromResult null + + interface ICopilotMentionQueryable with + member _.QueryMentionAsync(query, cancellationToken) : Task> = + mentionsFor (searchTextOf query) |> CancellableTask.start cancellationToken + + member _.NavigateToMentionableAsync(mention, cancellationToken) : Task = + match workspace, fullyQualifiedNameOf mention.Inputs with + | null, _ + | _, ValueNone -> Task.FromResult false + | workspace, ValueSome fullyQualifiedName -> + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + let solution = workspace.CurrentSolution + let! declarations = CopilotSymbolQuery.declarationsOf cache solution fullyQualifiedName + + match Array.tryHeadV declarations with + | ValueNone -> return false + | ValueSome(struct (item, document)) -> + let! sourceText = document.GetTextAsync ct + + match RoslynHelpers.TryFSharpRangeToTextSpan(sourceText, item.Range) with + | ValueNone -> return false + | ValueSome span -> + do! ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync ct + + let navigation = + solution.Workspace.Services.GetService() + + return navigation.TryNavigateToSpan(solution.Workspace, document.Id, span, ct) + } + |> CancellableTask.start cancellationToken + + // Copilot's own picker providers answer through the batch interface, one result collection per query. + // Each distinct search text scans the solution once, and the scans run side by side. + interface ICopilotMentionBatchQueryable with + member _.QueryMentionBatchAsync(queries, cancellationToken) : Task>> = + cancellableTask { + let searchTexts = queries |> Seq.map searchTextOf |> Seq.toArray + let distinct = Array.distinct searchTexts + let! mentions = distinct |> Array.map mentionsFor |> CancellableTask.whenAll + let byText = Array.zip distinct mentions |> dict + + return searchTexts |> Array.map (fun text -> byText[text]) :> IReadOnlyList> + } + |> CancellableTask.start cancellationToken diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs new file mode 100644 index 00000000000..21e296c0476 --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +/// Translates F# navigable items into the shapes the Copilot chat "#" mention picker understands. +module internal Microsoft.VisualStudio.FSharp.Editor.CopilotSymbolMapping + +open System + +open Microsoft.VisualStudio.Copilot +open Microsoft.VisualStudio.Imaging + +open FSharp.Compiler.EditorServices + +/// Name of the context member. It becomes the mention prefix the user sees and re-types, +/// as in "#fsharpSymbol:Namespace.Type.Member". +[] +let SymbolMember = "fsharpSymbol" + +[] +let FullyQualifiedNameInput = "fullyQualifiedName" + +/// The parse tree cannot tell an interface, struct or record apart from a plain class, so every +/// type-like declaration is reported as a class. +let symbolContextType kind = + match kind with + | NavigableItemKind.Module + | NavigableItemKind.ModuleAbbreviation + | NavigableItemKind.Exception + | NavigableItemKind.Type -> CopilotSymbolContextType.Class + | NavigableItemKind.ModuleValue -> CopilotSymbolContextType.Function + | NavigableItemKind.Field + | NavigableItemKind.Property -> CopilotSymbolContextType.Field + | NavigableItemKind.Constructor + | NavigableItemKind.Member -> CopilotSymbolContextType.Method + | NavigableItemKind.EnumCase -> CopilotSymbolContextType.Constant + | NavigableItemKind.UnionCase -> CopilotSymbolContextType.Union + +let private imageId kind = + match kind with + | NavigableItemKind.Module + | NavigableItemKind.ModuleAbbreviation -> KnownImageIds.ModulePublic + | NavigableItemKind.Exception -> KnownImageIds.ExceptionPublic + | NavigableItemKind.Type -> KnownImageIds.ClassPublic + | NavigableItemKind.ModuleValue + | NavigableItemKind.Constructor + | NavigableItemKind.Member -> KnownImageIds.MethodPublic + | NavigableItemKind.Field -> KnownImageIds.FieldPublic + | NavigableItemKind.Property -> KnownImageIds.PropertyPublic + | NavigableItemKind.EnumCase + | NavigableItemKind.UnionCase -> KnownImageIds.EnumerationItemPublic + +let icon kind = + CopilotImageMoniker(Guid = KnownImageIds.ImageCatalogGuid, Id = imageId kind) + +/// Dotted path that both drives the picker's pattern matching and identifies a picked mention +/// when it is resolved back to source. +let fullyQualifiedName (item: NavigableItem) = + match item.Container.FullName with + | "" -> item.Name + | container -> $"{container}.{item.Name}" + +/// Answers what comparing against `fullyQualifiedName` would, without building the dotted path - +/// a solution-wide scan asks this of every declaration it walks past. +let hasFullyQualifiedName (candidate: string) (item: NavigableItem) = + let candidate = candidate.AsSpan() + let container = item.Container.FullName + let name = item.Name.AsSpan() + + if container.Length = 0 then + candidate.Equals(name, StringComparison.Ordinal) + else + candidate.Length = container.Length + 1 + name.Length + && candidate[container.Length] = '.' + && candidate.Slice(0, container.Length).Equals(container.AsSpan(), StringComparison.Ordinal) + && candidate.Slice(container.Length + 1).Equals(name, StringComparison.Ordinal) diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs new file mode 100644 index 00000000000..fe53cac4f7d --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +/// Widens the identifier range of a navigable item to the declaration a reader would recognise. +module internal Microsoft.VisualStudio.FSharp.Editor.CopilotSymbolSnippets + +open System + +open FSharp.Compiler.EditorServices + +/// A module scope can span a whole file, which is more than a chat prompt can usefully carry. +[] +let MaxSnippetLines = 200 + +/// Inclusive, 1-based line bounds of the declaration `item` names, including its doc comment. +let definitionLines (sourceLines: ReadOnlyMemory array) (scopes: Structure.ScopeRange seq) (item: NavigableItem) = + let declarationLine = item.Range.StartLine + + // A construct's outlining range reaches back over the doc comment in front of it, so it is the + // collapse range - the body proper - that tells which construct is declared on this line. + let declaredHere (scope: Structure.ScopeRange) = + scope.CollapseRange.StartLine = declarationLine + && scope.Range.EndLine >= item.Range.EndLine + && scope.Scope <> Structure.Scope.Comment + && scope.Scope <> Structure.Scope.XmlDocComment + + let mutable widest = ValueNone + + for scope in scopes do + if declaredHere scope then + match widest with + | ValueSome(previous: Structure.ScopeRange) when previous.Range.EndLine >= scope.Range.EndLine -> () + | _ -> widest <- ValueSome scope + + // A one-line member declares no scope of its own; it stands for itself rather than for the type around it. + let firstLine, lastLine = + match widest with + | ValueSome scope -> scope.Range.StartLine, scope.Range.EndLine + | ValueNone -> declarationLine, item.Range.EndLine + + // Outlining reports a doc comment only once it spans several lines, so a one-line "///" in front of + // a declaration is invisible to the scopes above. + let isDocComment line = + sourceLines[line - 1].Span.TrimStart().StartsWith("///".AsSpan(), StringComparison.Ordinal) + + let rec docCommentStart line = + if line > 1 && isDocComment (line - 1) then + docCommentStart (line - 1) + else + line + + let firstLine = docCommentStart firstLine + + struct (firstLine, min lastLine (firstLine + MaxSnippetLines - 1)) diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj index 319bdd5a264..3176e1b964c 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj @@ -94,6 +94,9 @@ + + + @@ -179,6 +182,7 @@ + diff --git a/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs b/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs index 7cd53631893..bba3c20de13 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/FSharpProjectOptionsManager.fs @@ -631,7 +631,9 @@ type internal FSharpProjectOptionsManager(checker: FSharpChecker, workspace: Wor match reactor.TryGetCachedOptionsByProjectId(documentId.ProjectId) with | Some(_, parsingOptions, _) -> parsingOptions | _ -> + // ParseFile takes the last entry of SourceFiles as the last compiland; with none it throws. { FSharpParsingOptions.Default with + SourceFiles = [| path |] IsInteractive = CompilerEnvironment.IsScriptFile path } diff --git a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs index 427baf0c6ab..96c4041db06 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs @@ -6,27 +6,31 @@ open System open System.ComponentModel.Design open System.Runtime.InteropServices open System.Threading +open System.Threading.Tasks open System.IO open System.Collections.Immutable open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.ExternalAccess.FSharp +open Microsoft.CodeAnalysis.Host.Mef open Microsoft.CodeAnalysis.Options -open FSharp.Compiler -open FSharp.Compiler.CodeAnalysis -open FSharp.NativeInterop +open Microsoft.ServiceHub.Framework open Microsoft.VisualStudio +open Microsoft.VisualStudio.Copilot open Microsoft.VisualStudio.FSharp.Editor open Microsoft.VisualStudio.LanguageServices open Microsoft.VisualStudio.LanguageServices.Implementation.LanguageService open Microsoft.VisualStudio.LanguageServices.Implementation.ProjectSystem open Microsoft.VisualStudio.Shell open Microsoft.VisualStudio.Shell.Interop +open Microsoft.VisualStudio.Shell.ServiceBroker open Microsoft.VisualStudio.Text.Outlining -open Microsoft.CodeAnalysis.ExternalAccess.FSharp -open Microsoft.CodeAnalysis.Host.Mef +open Microsoft.VisualStudio.Editor open Microsoft.VisualStudio.FSharp.Editor.Telemetry -open CancellableTasks +open FSharp.Compiler +open FSharp.Compiler.CodeAnalysis +open FSharp.NativeInterop open FSharp.Compiler.Text -open Microsoft.VisualStudio.Editor +open CancellableTasks #nowarn "9" // NativePtr.toNativeInt #nowarn "57" // Experimental stuff @@ -408,8 +412,10 @@ type internal FSharpPackage() as this = |> CancellableTask.startAsTask cancellationToken) ) + override this.RegisterOnAfterPackageLoadedAsyncWork(afterPackageLoadedTasks: PackageLoadTasks) = + base.RegisterOnAfterPackageLoadedAsyncWork(afterPackageLoadedTasks) + #if DEBUG - override _.RegisterOnAfterPackageLoadedAsyncWork(afterPackageLoadedTasks: PackageLoadTasks) = afterPackageLoadedTasks.AddTask( false, fun _ _ -> @@ -421,6 +427,62 @@ type internal FSharpPackage() as this = ) #endif + /// Copilot's registration service is an exported brokered service whose MEF part constructor blocks waiting + /// for the main thread. Asking for the proxy from a background thread therefore deadlocks against anyone + /// asking for it from the main thread - the Git provider does, while creating its services at solution open - + /// so take the main thread dependency deliberately, the way Roslyn does for a proxy that has one. + member private this.RegisterCopilotContextProviderAsync(cancellationToken: CancellationToken) : Task = + task { + try + DebugHelpers.FSharpOutputPane.logInfo "Copilot: registering context provider (switching to main thread)…" + do! this.JoinableTaskFactory.SwitchToMainThreadAsync(alwaysYield = true, cancellationToken = cancellationToken) + + DebugHelpers.FSharpOutputPane.logInfo "Copilot: getting brokered service container…" + let! container = this.GetServiceAsync(typeof) + + match container with + | :? IBrokeredServiceContainer as container -> + // The Interactions service also serves the registration interface. It is absent when + // GitHub Copilot is not installed, in which case the proxy is null and F# stays out of the picker. + DebugHelpers.FSharpOutputPane.logInfo "Copilot: getting registration service proxy…" + + let! registration = + container + .GetFullAccessServiceBroker() + .GetProxyAsync(CopilotDescriptors.InteractionService, cancellationToken) + + use registration = registration + + match registration with + | null -> DebugHelpers.FSharpOutputPane.logInfo "Copilot: service proxy is null (Copilot not installed)" + | registration -> + DebugHelpers.FSharpOutputPane.logInfo "Copilot: registering F# context provider…" + + let moniker = + ServiceMoniker( + FSharpConstants.copilotSymbolProviderName, + Version CopilotDescriptors.CurrentContextProviderVersion + ) + + do! registration.RegisterContextProviderAsync(moniker, cancellationToken) + DebugHelpers.FSharpOutputPane.logInfo "Copilot: registration complete" + | _ -> DebugHelpers.FSharpOutputPane.logInfo "Copilot: container is not IBrokeredServiceContainer" + // A Copilot failure - a contract version the installed build does not serve, say - must not take the + // rest of the post-load work down with it. + with ex when not (ex :? OperationCanceledException) -> + DebugHelpers.FSharpOutputPane.logExceptionWithContext (ex, "Registering the Copilot context provider") + } + + override this.LoadComponentsInBackgroundAfterSolutionFullyLoadedAsync(cancellationToken) : Task = + // 'base' cannot be captured by the state machine, so start the base work before entering it. + let baseComponents = + base.LoadComponentsInBackgroundAfterSolutionFullyLoadedAsync(cancellationToken) + + task { + do! baseComponents + do! this.RegisterCopilotContextProviderAsync(cancellationToken) + } + override _.RoslynLanguageName = FSharpConstants.FSharpLanguageName (*override this.CreateWorkspace() = this.ComponentModel.GetService() *) override this.CreateLanguageService() = FSharpLanguageService(this) diff --git a/vsintegration/src/FSharp.Editor/LanguageService/WorkspaceExtensions.fs b/vsintegration/src/FSharp.Editor/LanguageService/WorkspaceExtensions.fs index 2406f3a6e32..0d11a4e6b8d 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/WorkspaceExtensions.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/WorkspaceExtensions.fs @@ -581,6 +581,12 @@ type Document with return! checker.ParseDocument(this, parsingOptions, userOpName) } + /// Parses the given F# document with the parsing options its project has already produced, or with defaults when + /// it has none yet: the only parse available while the project system is still loading. The defines can be the + /// wrong ones, so the tree describes a compilation that may never happen. + member this.GetFSharpQuickParseResultsAsync(userOpName) = + this.GetFSharpChecker().ParseDocument(this, this.GetFSharpQuickParsingOptions(), userOpName) + /// Parses and checks the given F# document. member this.GetFSharpParseAndCheckResultsAsync(userOpName) = cancellableTask { diff --git a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs index 546b00e1b16..642ef91e60e 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs @@ -7,8 +7,10 @@ open System.IO open System.Composition open System.Collections.Immutable open System.Collections.Concurrent -open System.Threading.Tasks open System.Globalization +open System.Linq +open System.Threading +open System.Threading.Tasks open Microsoft.CodeAnalysis open Microsoft.CodeAnalysis.ExternalAccess.FSharp.Navigation @@ -19,34 +21,107 @@ open Microsoft.VisualStudio.Text.PatternMatching open FSharp.Compiler.EditorServices open CancellableTasks -[); Shared>] -type internal FSharpNavigateToSearchService +/// The navigable items of one parse of a document, and the text version it was taken from. +[] +type private NavigableItemsEntry = + { + Version: VersionStamp + /// Parsed without the project's compilation options, while the solution was still loading. + Approximate: bool + Items: NavigableItem array + } + +/// Parse-tree navigable items per document, cached on the document's text version. +/// Shared by NavigateTo and by the Copilot chat mention provider. +[] +type internal FSharpNavigableItemsCache [] (patternMatcherFactory: IPatternMatcherFactory, [] workspace: VisualStudioWorkspace) = - let cache = ConcurrentDictionary() + let cache = ConcurrentDictionary() do - if workspace <> null then - workspace.WorkspaceChanged.Add - <| fun e -> + match workspace with + | null -> () + | workspace -> + workspace.WorkspaceChanged.Add(fun e -> if e.NewSolution.Id <> e.OldSolution.Id then - cache.Clear() + cache.Clear()) + + let store (document: Document) version approximate parseTree = + let items = NavigateTo.GetNavigableItems parseTree + + cache[document.Id] <- + { + Version = version + Approximate = approximate + Items = items + } + + items + + member _.GetNavigableItems(document: Document) = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + let! currentVersion = document.GetTextVersionAsync(ct) - let getNavigableItems (document: Document) = + match cache.TryGetValue document.Id with + | true, entry when entry.Version = currentVersion && not entry.Approximate -> return entry.Items + | _ -> + let! parseResults = document.GetFSharpParseResultsAsync(nameof (FSharpNavigableItemsCache)) + return store document currentVersion false parseResults.ParseTree + } + + /// The items of a parse that does not wait for the project's compilation options, for the search that runs while + /// the solution is still loading. A file behind `#if` can be read under the wrong defines, so the entry it leaves + /// behind never answers `GetNavigableItems`. + member _.GetNavigableItemsWhileLoading(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 + | true, entry when entry.Version = currentVersion -> return entry.Items | _ -> - let! parseResults = document.GetFSharpParseResultsAsync(nameof (FSharpNavigateToSearchService)) - let items = NavigateTo.GetNavigableItems parseResults.ParseTree - cache[document.Id] <- currentVersion, items - return items + let! parseResults = document.GetFSharpQuickParseResultsAsync(nameof (FSharpNavigableItemsCache)) + return store document currentVersion true parseResults.ParseTree } + member _.CreateMatcherFor(searchPattern: string) = + let patternMatcher = + patternMatcherFactory.CreatePatternMatcher( + searchPattern, + PatternMatcherCreationOptions( + cultureInfo = CultureInfo.CurrentUICulture, + flags = PatternMatcherCreationFlags.AllowFuzzyMatching, + containerSplitCharacters = [ '.' ] + ) + ) + + fun (item: NavigableItem) -> + // PatternMatcher will not match operators and some backtick escaped identifiers. + // To handle them, we fall back to simple substring match. + let name = item.Name + + if item.NeedsBackticks then + match name.IndexOf(searchPattern, StringComparison.CurrentCultureIgnoreCase) with + | i when i > 0 -> ValueSome(PatternMatch(PatternMatchKind.Substring, false, false)) + | 0 when name.Length = searchPattern.Length -> ValueSome(PatternMatch(PatternMatchKind.Exact, false, false)) + | 0 -> ValueSome(PatternMatch(PatternMatchKind.Prefix, false, false)) + | _ -> ValueNone + else + // full name with dots allows for path matching, e.g. + // "f.c.so.elseif" will match "Fantomas.Core.SyntaxOak.ElseIfNode" + patternMatcher.TryMatch $"{item.Container.FullName}.{name}" + |> ValueOption.ofNullable + +[); Shared>] +type internal FSharpNavigateToSearchService [] (itemsCache: FSharpNavigableItemsCache) = + + /// The parses of the search that runs while the solution loads take turns across all its projects, and leave a + /// core to the load itself. + let loadingThrottle = new SemaphoreSlim(max 1 (Environment.ProcessorCount - 1)) + let kindsProvided = ImmutableHashSet.Create( FSharpNavigateToItemKind.Module, @@ -115,44 +190,24 @@ type internal FSharpNavigateToSearchService | PatternMatchKind.Fuzzy -> FSharpNavigateToMatchKind.Fuzzy | _ -> FSharpNavigateToMatchKind.None - let createMatcherFor searchPattern = - let patternMatcher = - patternMatcherFactory.CreatePatternMatcher( - searchPattern, - PatternMatcherCreationOptions( - cultureInfo = CultureInfo.CurrentUICulture, - flags = PatternMatcherCreationFlags.AllowFuzzyMatching, - containerSplitCharacters = [ '.' ] - ) - ) - - fun (item: NavigableItem) -> - // PatternMatcher will not match operators and some backtick escaped identifiers. - // To handle them, we fall back to simple substring match. - let name = item.Name + let createMatcherFor (searchPattern: string) = + itemsCache.CreateMatcherFor searchPattern - if item.NeedsBackticks then - match name.IndexOf(searchPattern, StringComparison.CurrentCultureIgnoreCase) with - | i when i > 0 -> ValueSome(PatternMatch(PatternMatchKind.Substring, false, false)) - | 0 when name.Length = searchPattern.Length -> ValueSome(PatternMatch(PatternMatchKind.Exact, false, false)) - | 0 -> ValueSome(PatternMatch(PatternMatchKind.Prefix, false, false)) - | _ -> ValueNone - else - // full name with dots allows for path matching, e.g. - // "f.c.so.elseif" will match "Fantomas.Core.SyntaxOak.ElseIfNode" - patternMatcher.TryMatch $"{item.Container.FullName}.{name}" - |> ValueOption.ofNullable - - let processDocument (tryMatch: NavigableItem -> PatternMatch voption) (kinds: IImmutableSet) (document: Document) = + let processDocument + (getItems: Document -> CancellableTask) + (tryMatch: NavigableItem -> PatternMatch voption) + (kinds: IImmutableSet) + (document: Document) + = cancellableTask { let! ct = CancellableTask.getCancellationToken () let! sourceText = document.GetTextAsync ct - let! items = getNavigableItems document + let! items = getItems document let processed = - [| + seq { for item in items do let contains = kinds.Contains(navigateToItemKindToRoslynKind item.Kind) let patternMatch = tryMatch item @@ -182,9 +237,25 @@ type internal FSharpNavigateToSearchService ) ) | _ -> () - |] + } - return processed + return processed |> Seq.toImmutableArray + } + + /// Priority items first, each half in its original order, as NavigateTo's own service orders its work. + let prioritize isPriority items = + let priority, rest = items |> Seq.toArray |> Array.partition isPriority + [| yield! priority; yield! rest |] + + let throttled (work: CancellableTask<'a>) = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + do! loadingThrottle.WaitAsync ct + + try + return! work + finally + loadingThrottle.Release() |> ignore } interface IFSharpNavigateToSearchService with @@ -194,32 +265,62 @@ type internal FSharpNavigateToSearchService 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! results = + project.Documents + |> Seq.map (processDocument itemsCache.GetNavigableItems tryMatch kinds) + |> CancellableTask.whenAll + return results |> Seq.collect _.AsEnumerable() |> Seq.toImmutableArray } |> CancellableTask.start cancellationToken member _.SearchDocumentAsync(document: Document, searchPattern, kinds, cancellationToken) = - cancellableTask { - let! result = processDocument (createMatcherFor searchPattern) kinds document - return Array.toImmutableArray result - } - |> CancellableTask.start cancellationToken + processDocument itemsCache.GetNavigableItems (createMatcherFor searchPattern) kinds document cancellationToken member _.KindsProvided = kindsProvided member _.CanFilter = true + + interface IFSharpAdvancedNavigateToSearchService with + member _.SearchCachedDocumentsAsync + ( + _solution, + projects, + priorityDocuments, + searchPattern, + kinds, + _activeDocument, + onResultsFound, + onProjectCompleted, + cancellationToken + ) : Task = + let tryMatch = createMatcherFor searchPattern + let priorityIds = ImmutableHashSet.CreateRange(priorityDocuments |> Seq.map _.Id) + let isPriority (document: Document) = priorityIds.Contains document.Id + + let searchDocumentWhileLoading document = + cancellableTask { + let! results = throttled (processDocument itemsCache.GetNavigableItemsWhileLoading tryMatch kinds document) + + if results.Length > 0 then + do! onResultsFound.Invoke results + } + + // Every document waits on the throttle in the order it is started, so priority documents, and the + // projects that hold them, are parsed first. + let searchProjectWhileLoading (project: Project) = + cancellableTask { + let! _ = + project.Documents + |> prioritize isPriority + |> Seq.map searchDocumentWhileLoading + |> CancellableTask.whenAll + + do! onProjectCompleted.Invoke() + } + + projects + |> prioritize (fun project -> project.Documents |> Seq.exists isPriority) + |> Seq.map searchProjectWhileLoading + |> CancellableTask.whenAll + |> CancellableTask.startAsTask cancellationToken diff --git a/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs b/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs index d0326d84311..b087cb56782 100644 --- a/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs +++ b/vsintegration/src/FSharp.Editor/Structure/BlockStructureService.fs @@ -119,7 +119,7 @@ module internal BlockStructure = let ellipsis = "..." let createBlockSpans isBlockStructureEnabled (sourceText: SourceText) (parsedInput: ParsedInput) = - let linetext = sourceText.Lines |> Seq.map (fun x -> x.ToString()) |> Seq.toArray + let linetext = sourceText.GetLinesAsMemory() Structure.getOutliningRanges linetext parsedInput |> Seq.distinctBy (fun x -> x.Range.StartLine) diff --git a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs new file mode 100644 index 00000000000..c7ad810ab4a --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace FSharp.Editor.Tests + +open System.Threading + +open Xunit + +open Microsoft.VisualStudio.Copilot +open Microsoft.VisualStudio.FSharp.Editor + +open FSharp.Editor.Tests.Helpers +open CancellableTasks + +module CopilotContextProviderTests = + + let fileContents = + """ +module Widgets + +/// Counts things that matter. +type Counter(start: int) = + let mutable value = start + + member _.Value = value + + member _.Bump() = + value <- value + 1 + value + +type Shape = + | Circle of radius: float + | Square of side: float + +let describeShape shape = + match shape with + | Circle r -> $"circle {r}" + | Square s -> $"square {s}" + +/// Twice the value. +let twice x = x * 2 +""" + + let solution = RoslynTestHelpers.CreateSolution fileContents + + let private cache = + MefHelpers.createExportProvider().GetExportedValue() + + let private run computation = + computation |> CancellableTask.start CancellationToken.None |> _.Result + + let private search pattern = + CopilotSymbolQuery.search cache solution pattern + |> run + |> Array.map (fun (struct (item, _)) -> CopilotSymbolMapping.fullyQualifiedName item) + + let private symbolContext name = + CopilotSymbolQuery.symbolContext cache solution name |> run + + let private contextOf name = + match symbolContext name with + | ValueSome context -> context + | ValueNone -> failwith $"expected a symbol context for {name}" + + [] + [] + [] + [] + [] + let ``search finds a declaration by its fully qualified name`` (pattern: string, expected: string) = + Assert.Contains(expected, search pattern) + + [] + [] + [] + [] + [] + [] + [] + let ``a name matches only the declaration it spells out`` (candidate: string, expected: bool) = + let item = + CopilotSymbolQuery.search cache solution "Counter" + |> run + |> Array.pick (fun (struct (item, _)) -> + if CopilotSymbolMapping.fullyQualifiedName item = "Widgets.Counter" then + Some item + else + None) + + Assert.Equal(expected, CopilotSymbolMapping.hasFullyQualifiedName candidate item) + + [] + let ``search reports each declaration once`` () = + let names = search "Counter" + Assert.Equal((Array.distinct names).Length, names.Length) + + [] + let ``an unknown name has no context`` () = + Assert.True((symbolContext "Widgets.NoSuchThing").IsNone) + + [] + let ``a type context carries the whole declaration and its doc comment`` () = + let context = contextOf "Widgets.Counter" + + Assert.Equal("Widgets.Counter", context.FullyQualifiedName) + Assert.Equal("Counter", context.UnqualifiedName) + Assert.Contains("Counts things that matter.", context.Snippet) + Assert.Contains("member _.Bump()", context.Snippet) + + [] + let ``a member context carries the member body alone`` () = + let context = contextOf "Widgets.Counter.Bump" + + Assert.Contains("value <- value + 1", context.Snippet) + Assert.DoesNotContain("type Counter", context.Snippet) + + [] + let ``a one-line declaration keeps its doc comment`` () = + let context = contextOf "Widgets.twice" + + Assert.Contains("Twice the value.", context.Snippet) + Assert.Contains("let twice x", context.Snippet) + Assert.DoesNotContain("describeShape", context.Snippet) + + [] + [] + [] + [] + [] + [] + let ``declaration kinds map onto Copilot symbol types`` (name: string, expected: CopilotSymbolContextType) = + Assert.Equal(expected, (contextOf name).SymbolType) + + [] + let ``a context points back at the source it was taken from`` () = + let context = contextOf "Widgets.Counter" + let location = Assert.Single context.SnippetLocations + let document = solution.Projects |> Seq.exactlyOne |> _.Documents |> Seq.exactlyOne + + Assert.Equal(document.FilePath, location.FilePath) + Assert.Equal(context.Snippet.Length, location.Span.Length) diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj index ecce1205b8c..8c45911e6f9 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj +++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj @@ -32,6 +32,8 @@ + + diff --git a/vsintegration/tests/FSharp.Editor.Tests/NavigateToSearchWhileLoadingTests.fs b/vsintegration/tests/FSharp.Editor.Tests/NavigateToSearchWhileLoadingTests.fs new file mode 100644 index 00000000000..0f171ded8ca --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/NavigateToSearchWhileLoadingTests.fs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +/// Navigate To runs a search of its own while the solution is still loading, and nothing it searches +/// then has its compilation options yet. +module FSharp.Editor.Tests.NavigateToSearchWhileLoadingTests + +open System +open System.Collections.Immutable +open System.Threading +open System.Threading.Tasks + +open Xunit + +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.ExternalAccess.FSharp.NavigateTo + +open FSharp.Editor.Tests.Helpers + +/// A project the project system has not yet handed its command line options: the state every project +/// of the solution is in until it has loaded. +let private loadingProject name source = + let projectId = ProjectId.CreateNewId() + + [ RoslynTestHelpers.CreateDocumentInfo projectId $"C:\\{name}.fs" source ] + |> RoslynTestHelpers.CreateProjectInfo projectId $"C:\\{name}.fsproj" + +let private loadingSolution source = + let project = loadingProject "test" source + let solution = RoslynTestHelpers.CreateSolution [ project ] + project.Id, solution, solution.Projects |> Seq.exactlyOne + +/// One export provider per test: the navigable items are cached in a shared one. +let private searchServices () = + let service: IFSharpNavigateToSearchService = + MefHelpers.createExportProvider().GetExportedValue() + + service, service :?> IFSharpAdvancedNavigateToSearchService + +let private namesFound (results: ImmutableArray) = results |> Seq.map _.Name |> Seq.toList + +/// The loading search as the searcher drives it: results and project completions arrive through callbacks. +/// Returns the names found and how many times a project was reported complete. +let private searchWhileLoading + (service: IFSharpNavigateToSearchService, advanced: IFSharpAdvancedNavigateToSearchService) + (projects: Project list) + pattern + = + task { + let found = ResizeArray() + let completed = ref 0 + + do! + advanced.SearchCachedDocumentsAsync( + (List.head projects).Solution, + ImmutableArray.CreateRange projects, + ImmutableArray.Empty, + pattern, + service.KindsProvided, + null, + (fun results -> + lock found (fun () -> found.AddRange results) + Task.CompletedTask), + (fun () -> + Interlocked.Increment &completed.contents |> ignore + Task.CompletedTask), + CancellationToken.None + ) + + return found |> Seq.map _.Name |> Seq.toList, completed.Value + } + +[] +let ``the loading search finds what the search that waits for the options cannot`` () : Task = + task { + let _, _, project = + loadingSolution "module Sample =\n let declaredWhileLoading = 1\n" + + let (service, _) as services = searchServices () + + let searchAccurately () = + service.SearchProjectAsync(project, ImmutableArray.Empty, "declaredWhileLoading", service.KindsProvided, CancellationToken.None) + :> Task + + let! _ = Assert.ThrowsAnyAsync(fun () -> searchAccurately ()) + + let! names, completed = searchWhileLoading services [ project ] "declaredWhileLoading" + + Assert.Equal([ "declaredWhileLoading" ], names) + Assert.Equal(1, completed) + + // What the loading search left in the cache must not be handed to the accurate search: it was + // read without the project's defines. + let! _ = Assert.ThrowsAnyAsync(fun () -> searchAccurately ()) + () + } + +[] +let ``the loading search reads a file under the wrong defines and does not keep the answer`` () : Task = + task { + let projectId, solution, project = + loadingSolution "#if FOO\nlet fooOnly = 1\n#endif\n" + + let (service, _) as services = searchServices () + + let! namesWhileLoading, _ = searchWhileLoading services [ project ] "fooOnly" + Assert.Equal([], namesWhileLoading) + + { RoslynTestHelpers.DefaultProjectOptions with + OtherOptions = [| "--define:FOO" |] + } + |> RoslynTestHelpers.SetProjectOptions projectId solution + + let! found = service.SearchProjectAsync(project, ImmutableArray.Empty, "fooOnly", service.KindsProvided, CancellationToken.None) + + Assert.Equal([ "fooOnly" ], namesFound found) + } + +[] +let ``every project is reported complete once, whether or not anything is found in it`` () : Task = + task { + let solution = + RoslynTestHelpers.CreateSolution + [ + loadingProject "first" "let found = 1\n" + loadingProject "second" "let other = 2\n" + ] + + let! names, completed = searchWhileLoading (searchServices ()) (solution.Projects |> Seq.toList) "found" + + Assert.Equal([ "found" ], names) + Assert.Equal(2, completed) + }