diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 2d032095814..6f9d34e2f62 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -4,6 +4,7 @@ * 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)) * Added a **Tools > Options > F# Tools > Compiler** option, **"Use the .NET SDK F# compiler for builds"**, on by default. Visual Studio uses the .NET SDK F# compiler when the project supplies SDK paths, matching `dotnet build`. Otherwise, it uses the bundled .NET Framework compiler. Turn the option off to use the bundled compiler for all projects. Changes are saved on Apply or OK; Cancel discards unsaved changes. Command-line builds are unaffected. ([Issue #20484](https://github.com/dotnet/fsharp/issues/20484), [PR #20485](https://github.com/dotnet/fsharp/pull/20485)) +* 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 diff --git a/eng/Packages.props b/eng/Packages.props index fb6753942db..5a15e374dda 100644 --- a/eng/Packages.props +++ b/eng/Packages.props @@ -65,6 +65,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/vsintegration/src/FSharp.Editor/Common/Constants.fs b/vsintegration/src/FSharp.Editor/Common/Constants.fs index ead451467cf..6065aca2c67 100644 --- a/vsintegration/src/FSharp.Editor/Common/Constants.fs +++ b/vsintegration/src/FSharp.Editor/Common/Constants.fs @@ -43,6 +43,10 @@ module internal FSharpConstants = /// "F# Language Service" let FSharpLanguageServiceCallbackName = "F# Language Service" + [] + let copilotSymbolProviderName = + "Microsoft.VisualStudio.FSharp.CopilotSymbolContextProvider" + [] /// "FSharp" let FSharpLanguageLongName = "FSharp" diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs new file mode 100644 index 00000000000..be21e47256c --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotContextProvider.fs @@ -0,0 +1,725 @@ +// 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.Diagnostics +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 Microsoft.VisualStudio.Text.PatternMatching + +open FSharp.Compiler.EditorServices +open CancellableTasks + +[] +type internal PickedDeclaration = + { + File: string + ProjectName: string + Line: int + } + +[] +type internal DocumentFocus = + | Selected + | Focused + | Open + | Elsewhere + +module internal CopilotSymbolQuery = + + [] + let private MaxMentions = 20 + + [] + let private MaxDeclarations = 4 + + [] + let private UserOpName = "CopilotSymbolContext" + + /// Copilot drops the partial answer of a cancelled query, so a long cold scan stops itself instead. + [] + let private ColdSearchBudgetMs = 1500L + + [] + let private MinSolutionWideSearchLength = 3 + + let private parallelism = max 1 (Environment.ProcessorCount - 1) + + let private matcherFor (cache: FSharpNavigableItemsCache) (searchText: string) = + match searchText with + | "" -> fun (_: NavigableItem) -> ValueSome(PatternMatch(PatternMatchKind.Exact, false, false)) + | searchText -> cache.CreateMatcherFor searchText + + let private fsharpDocuments (solution: Solution) = + solution.Projects |> Seq.where _.IsFSharp |> Seq.collect _.Documents + + let private isFocused (focus: EditorFocus) (document: Document) = + String.Equals(focus.FilePath, document.FilePath, StringComparison.OrdinalIgnoreCase) + + let private documentFocusOf + (focus: EditorFocus voption) + (openIds: HashSet) + (isSelected: NavigableItem -> bool) + (document: Document) + (item: NavigableItem) + = + if not (openIds.Contains document.Id) then + DocumentFocus.Elsewhere + else + match focus with + | ValueSome focus when isFocused focus document -> + if isSelected item then + DocumentFocus.Selected + else + DocumentFocus.Focused + | _ -> DocumentFocus.Open + + let private rankOf focus = + match focus with + | DocumentFocus.Selected -> 0 + | DocumentFocus.Focused -> 1 + | DocumentFocus.Open -> 2 + | DocumentFocus.Elsewhere -> 3 + + [] + type private Outline = + { + Text: SourceText + Lines: string array + Declared: Dictionary + } + + let private outlineOf (document: Document) = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + let! sourceText = document.GetTextAsync ct + let! parseResults = document.GetFSharpParseResultsAsync UserOpName + + let sourceLines = + Array.init sourceText.Lines.Count (fun line -> sourceText.Lines[line].ToString()) + + return + { + Text = sourceText + Lines = sourceLines + Declared = + Structure.getOutliningRanges sourceLines parseResults.ParseTree + |> CopilotSymbolSnippets.declaredByLine + } + } + + let private notSelected (_: NavigableItem) = false + + let private isSelectedIn (focus: EditorFocus) (document: Document) = + cancellableTask { + let! outline = outlineOf document + + return + fun (item: NavigableItem) -> + let struct (firstLine, lastLine) = + CopilotSymbolSnippets.declarationLines outline.Lines outline.Declared item + + firstLine <= focus.LastLine && focus.FirstLine <= lastLine + } + + let tiers (cache: FSharpNavigableItemsCache) (openIds: HashSet) (solution: Solution) = + let opened = + openIds + |> Seq.chooseV (fun id -> + match solution.GetDocument id with + | null -> ValueNone + | document when document.Project.IsFSharp -> ValueSome document + | _ -> ValueNone) + |> ResizeArray + + let unopened = + seq { + for document in fsharpDocuments solution do + if not (openIds.Contains document.Id) then + document + } + + // Navigate To fills the same cache between the two walks: cold is what the cached walk did not answer. + let answered = HashSet() + + let cached = + seq { + for document in unopened do + match cache.TryGetCachedNavigableItems document with + | ValueSome items -> + answered.Add document.Id |> ignore + struct (document, items) + | ValueNone -> () + } + + let cold = + seq { + for document in unopened do + if not (answered.Contains document.Id) then + document + } + + struct (opened, cached, cold) + + let private scanTiers + (cache: FSharpNavigableItemsCache) + (openIds: HashSet) + (solution: Solution) + (budgetMs: int64) + (enough: unit -> bool) + (collect: Document -> NavigableItem array -> unit) + = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + let struct (opened, cached, cold) = tiers cache openIds solution + + let parseAndCollect (document: Document) = + cancellableTask { + let! items = cache.GetNavigableItems document + collect document items + } + + do! Task.parallelDoLimit parallelism ct (opened |> Seq.map parseAndCollect) + + if not (enough ()) then + for struct (document, items) in cached do + ct.ThrowIfCancellationRequested() + collect document items + + if not (enough ()) then + let budget = Stopwatch.StartNew() + + let scan (document: Document) = + cancellableTask { + // Gates the next parse rather than cancelling one: a session's first query must survive its first parse. + if budget.ElapsedMilliseconds < budgetMs && not (enough ()) then + do! parseAndCollect document + } + + do! Task.parallelDoLimit parallelism ct (cold |> Seq.map scan) + } + + [] + type private Hit = + { + Kind: PatternMatchKind + Item: NavigableItem + Document: Document + Name: string + } + + let private compareRanked (struct (rank1: int, hit1: Hit)) (struct (rank2: int, hit2: Hit)) = + match compare rank1 rank2 with + | 0 -> + match compare (int hit1.Kind) (int hit2.Kind) with + | 0 -> compare hit1.Item.Name.Length hit2.Item.Name.Length + | order -> order + | order -> order + + let search + (cache: FSharpNavigableItemsCache) + (openDocumentIds: DocumentId seq) + (focus: EditorFocus voption) + (solution: Solution) + (searchTexts: string[]) + = + cancellableTask { + let openIds = HashSet openDocumentIds + + let queries = + searchTexts + |> Array.map (fun text -> + struct {| + TryMatch = matcherFor cache text + SolutionWide = text.Length >= MinSolutionWideSearchLength + Hits = ResizeArray() + Names = HashSet StringComparer.Ordinal + |}) + + let solutionWide = queries |> Array.filter _.SolutionWide + let gate = obj () + let mutable focusedDocument = ValueNone + + let collect (document: Document) (items: NavigableItem array) = + let queries = + if openIds.Contains document.Id then + queries + else + solutionWide + + let matched = + queries + |> Array.map (fun query -> + let hits = ResizeArray() + + for item in items do + match query.TryMatch item with + | ValueSome patternMatch -> + hits.Add + { + Kind = patternMatch.Kind + Item = item + Document = document + Name = CopilotSymbolMapping.fullyQualifiedName item + } + | ValueNone -> () + + struct (query, hits)) + + lock gate (fun () -> + for struct (query, hits) in matched do + query.Hits.AddRange hits + + if query.SolutionWide then + for hit in hits do + if query.Names.Count < MaxMentions then + query.Names.Add hit.Name |> ignore + + match focus with + | ValueSome focus when + isFocused focus document + && matched |> Array.exists (fun (struct (_, hits)) -> hits.Count > 0) + -> + focusedDocument <- ValueSome document + | _ -> ()) + + let enough () = + lock gate (fun () -> solutionWide |> Array.forall (fun query -> query.Names.Count >= MaxMentions)) + + do! scanTiers cache openIds solution ColdSearchBudgetMs enough collect + + let! isSelected = + match focus, focusedDocument with + | ValueSome focus, ValueSome document -> isSelectedIn focus document + | _ -> CancellableTask.singleton notSelected + + return + queries + |> Array.map (fun query -> + let seen = HashSet StringComparer.Ordinal + let mentions = ResizeArray MaxMentions + + let ranked = + query.Hits + |> Seq.map (fun hit -> + let focus = documentFocusOf focus openIds isSelected hit.Document hit.Item + struct (rankOf focus * 2 + (if hit.Document.IsFSharpSignatureFile then 1 else 0), hit), focus) + |> Seq.sortWith (fun (ranked1, _) (ranked2, _) -> compareRanked ranked1 ranked2) + + for struct (_, hit), focus in ranked do + if mentions.Count < MaxMentions && seen.Add hit.Name then + mentions.Add(struct (hit.Item, hit.Document, focus)) + + mentions.ToArray()) + } + + let private declarationsFound + (cache: FSharpNavigableItemsCache) + (openDocumentIds: DocumentId seq) + (solution: Solution) + (fullyQualifiedName: string) + = + cancellableTask { + let hits = ResizeArray() + let mutable declaredInImplementation = false + + let collect (document: Document) (items: NavigableItem array) = + let declared = + items + |> Array.filter (CopilotSymbolMapping.hasFullyQualifiedName fullyQualifiedName) + + if declared.Length > 0 then + lock hits (fun () -> + for item in declared do + hits.Add(struct (item, document)) + + if not document.IsFSharpSignatureFile then + declaredInImplementation <- true) + + let enough () = + lock hits (fun () -> declaredInImplementation) + + do! scanTiers cache (HashSet openDocumentIds) solution Int64.MaxValue enough collect + + let implementations = + hits + |> Seq.filter (fun (struct (_, document: Document)) -> not document.IsFSharpSignatureFile) + |> Seq.toArray + + return + match implementations with + | [||] -> hits.ToArray() + | implementations -> implementations + } + + /// The picked project's copy of a linked file answers first: its defines decide what is declared where. + let private declarationsPickedAt + (cache: FSharpNavigableItemsCache) + (solution: Solution) + (fullyQualifiedName: string) + (picked: PickedDeclaration) + = + cancellableTask { + let inFile = + solution.GetDocumentIdsWithFilePath picked.File + |> Seq.map (fun documentId -> solution.GetDocument documentId) + |> Seq.filter (fun document -> document.Project.IsFSharp && not document.IsFSharpSignatureFile) + |> Seq.toArray + + let isPickedProject (document: Document) = + String.Equals(document.Project.Name, picked.ProjectName, StringComparison.Ordinal) + + let documents = + match Array.filter isPickedProject inFile with + | [||] -> inFile + | inPickedProject -> inPickedProject + + let found = ResizeArray() + + for document in documents do + let! items = cache.GetNavigableItems document + + for item in items do + if CopilotSymbolMapping.hasFullyQualifiedName fullyQualifiedName item then + found.Add(struct (item, document)) + + return + found + |> Seq.sortBy (fun (struct (item, _)) -> item.Range.StartLine <> picked.Line) + |> Seq.toArray + } + + let declarationsOf + (cache: FSharpNavigableItemsCache) + (openDocumentIds: DocumentId seq) + (solution: Solution) + (fullyQualifiedName: string) + (picked: PickedDeclaration voption) + = + cancellableTask { + let! pickedDeclarations = + match picked with + | ValueSome picked -> declarationsPickedAt cache solution fullyQualifiedName picked + | ValueNone -> CancellableTask.singleton Array.empty + + match pickedDeclarations with + | [||] -> return! declarationsFound cache openDocumentIds solution fullyQualifiedName + | declarations -> return declarations + } + + let private snippetOf (outline: Outline) (item: NavigableItem) = + let struct (firstLine, lastLine) = + CopilotSymbolSnippets.definitionLines outline.Lines outline.Declared item + + let text = outline.Text + let firstLine = max 1 firstLine + let lastLine = min text.Lines.Count lastLine + + let span = + TextSpan.FromBounds(text.Lines[firstLine - 1].Start, text.Lines[lastLine - 1].End) + + struct (text.ToString span, span) + + let symbolContext + (cache: FSharpNavigableItemsCache) + (openDocumentIds: DocumentId seq) + (solution: Solution) + (fullyQualifiedName: string) + (picked: PickedDeclaration voption) + = + cancellableTask { + let! declarations = declarationsOf cache openDocumentIds solution fullyQualifiedName picked + let declarations = Array.truncate MaxDeclarations declarations + + match Array.tryHeadV declarations with + | ValueNone -> return ValueNone + | ValueSome(struct (first, _)) -> + let snippets = ResizeArray() + let locations = ResizeArray() + let mutable outlined: struct (DocumentId * Outline) voption = ValueNone + + for struct (item, document: Document) in declarations do + let! outline = + match outlined with + | ValueSome(struct (id, outline)) when id = document.Id -> CancellableTask.singleton outline + | _ -> outlineOf document + + outlined <- ValueSome(struct (document.Id, outline)) + let struct (text, span) = snippetOf outline item + 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() + ) + ) + } + +[; typeof |], + Audience = (ServiceAudience.PublicSdk ||| ServiceAudience.Local))>] +type internal FSharpCopilotContextProvider + [] + ( + cache: FSharpNavigableItemsCache, + [)>] serviceBroker: IServiceBroker, + [] workspace: VisualStudioWorkspace | null + ) = + + // Never disposed: disposing it would dispose the broker it wraps. + let documentContexts = + new ServiceBrokerClient(serviceBroker, ThreadHelper.JoinableTaskFactory) + + let editorFocus () = + cancellableTask { + let! ct = CancellableTask.getCancellationToken () + use! rental = documentContexts.GetProxyAsync(CopilotDescriptors.Context.Document, ct).AsTask() + + match rental.Proxy with + | null -> return ValueNone + | proxy -> + let! context = proxy.GetActiveDocumentAsync(CopilotCorrelationId.New(), ct) + + match context with + | null -> return ValueNone + | context -> + match context.TryGetValue() with + | true, document -> return CopilotSymbolMapping.editorFocusOf document + | _ -> return ValueNone + } + + 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 + ) + CopilotInputDescriptor( + CopilotSymbolMapping.DeclarationFileInput, + "File the picked declaration is in.", + CopilotDefaultTypes.StringName, + IsRequired = false + ) + CopilotInputDescriptor( + CopilotSymbolMapping.DeclarationProjectInput, + "Project the picked declaration is compiled in.", + CopilotDefaultTypes.StringName, + IsRequired = false + ) + CopilotInputDescriptor( + CopilotSymbolMapping.DeclarationLineInput, + "Line the picked overload or partial definition is declared on.", + CopilotDefaultTypes.IntegerName, + IsRequired = false + ) + |] + ) + + static let members = [| descriptor |] :> IReadOnlyList + + static let memberNames = [| CopilotSymbolMapping.SymbolMember |] :> IReadOnlyList + + static let noMentions = + Array.empty :> IReadOnlyCollection + + let priorityOf focus = + match focus with + | DocumentFocus.Selected -> CopilotQueriedMentionPriority.Selection + | DocumentFocus.Focused -> CopilotQueriedMentionPriority.High + | DocumentFocus.Open -> CopilotQueriedMentionPriority.Low + | DocumentFocus.Elsewhere -> CopilotQueriedMentionPriority.None + + let mentionFor (item: NavigableItem) (document: Document) focus = + let inputs = Dictionary(4, StringComparer.Ordinal) + + inputs[CopilotSymbolMapping.FullyQualifiedNameInput] <- + CopilotValue(CopilotDefaultTypes.StringName, CopilotSymbolMapping.fullyQualifiedName item) + + match document.FilePath with + | null -> () + | filePath -> + inputs[CopilotSymbolMapping.DeclarationFileInput] <- CopilotValue(CopilotDefaultTypes.StringName, filePath) + inputs[CopilotSymbolMapping.DeclarationProjectInput] <- CopilotValue(CopilotDefaultTypes.StringName, document.Project.Name) + + inputs[CopilotSymbolMapping.DeclarationLineInput] <- CopilotValue(CopilotDefaultTypes.IntegerName, item.Range.StartLine) + + let fileName = Path.GetFileName document.FilePath + + let tooltip = + String.Format( + SR.CopilotSymbolTooltip(), + CopilotSymbolMapping.symbolContextType item.Kind, + fileName, + CopilotSymbolMapping.tooltipName item + ) + + CopilotQueriedContextMention( + moniker, + descriptor, + inputs, + item.Name, + Description = fileName, + Tooltip = tooltip, + Icon = Nullable(CopilotSymbolMapping.icon item.Kind), + IsNavigable = true, + Priority = priorityOf focus + ) + :> CopilotQueriedMention + + let mentionsFor (searchTexts: string voption[]) = + cancellableTask { + match workspace, searchTexts |> Seq.chooseV id |> Seq.distinct |> Seq.toArray with + | null, _ + | _, [||] -> return Array.create searchTexts.Length noMentions + | workspace, distinct -> + let! focus = editorFocus () + + let! hits = CopilotSymbolQuery.search cache (workspace.GetOpenDocumentIds()) focus workspace.CurrentSolution distinct + + let byText = Dictionary(StringComparer.Ordinal) + + for index in 0 .. distinct.Length - 1 do + byText[distinct[index]] <- + hits[index] + |> Array.map (fun (struct (item, document, focus)) -> mentionFor item document focus) + :> IReadOnlyCollection + + return + searchTexts + |> Array.map (function + | ValueSome text -> byText[text] + | ValueNone -> noMentions) + } + + let fullyQualifiedNameOf inputs = + CopilotSymbolMapping.textOf CopilotSymbolMapping.FullyQualifiedNameInput inputs + + let pickedAt inputs = + match + CopilotSymbolMapping.textOf CopilotSymbolMapping.DeclarationFileInput inputs, + CopilotSymbolMapping.textOf CopilotSymbolMapping.DeclarationProjectInput inputs, + CopilotSymbolMapping.inputOf CopilotSymbolMapping.DeclarationLineInput inputs + with + | ValueSome file, ValueSome project, ValueSome line -> + ValueSome + { + File = file + ProjectName = project + Line = line + } + | _ -> 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.GetOpenDocumentIds()) + workspace.CurrentSolution + fullyQualifiedName + (pickedAt inputs) + + 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 [| CopilotSymbolMapping.searchTextOf query |] + |> CancellableTask.map Array.head + |> 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 + (workspace.GetOpenDocumentIds()) + solution + fullyQualifiedName + (pickedAt mention.Inputs) + + 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 + + interface ICopilotMentionBatchQueryable with + member _.QueryMentionBatchAsync(queries, cancellationToken) : Task>> = + mentionsFor (queries |> Seq.map CopilotSymbolMapping.searchTextOf |> Seq.toArray) + |> CancellableTask.map (fun mentions -> mentions :> 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..cf2d3cd7dca --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolMapping.fs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +module internal Microsoft.VisualStudio.FSharp.Editor.CopilotSymbolMapping + +open System +open System.Collections.Generic + +open Microsoft.VisualStudio.Copilot +open Microsoft.VisualStudio.Imaging + +open FSharp.Compiler.EditorServices +open FSharp.Compiler.Syntax + +/// Also the mention prefix the user types and sees: "#fsharpSymbol:Namespace.Type.Member". +[] +let SymbolMember = "fsharpSymbol" + +[] +let FullyQualifiedNameInput = "fullyQualifiedName" + +[] +let DeclarationLineInput = "declarationLine" + +[] +let DeclarationFileInput = "declarationFile" + +[] +let DeclarationProjectInput = "declarationProject" + +let inputOf<'T> name (inputs: IReadOnlyDictionary | null) = + match inputs with + | null -> ValueNone + | inputs -> + match inputs.TryGetValue name with + | true, value -> + match value.TryGetValue<'T>() with + | true, input -> ValueSome input + | _ -> ValueNone + | _ -> ValueNone + +let textOf name inputs = + inputOf name inputs + |> ValueOption.filter (fun text -> not (String.IsNullOrWhiteSpace text)) + +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) + +/// FCS joins dotted paths: only a container's last segment can be quoted, and a file's top-level module not at all. +let private isQuoted (item: NavigableItem) = + item.NeedsBackticks && not (item.Kind.IsModule && item.Container.Type.IsFile) + +let private isContainerQuoted (container: NavigableContainer) = + container.Name.Length > 0 + && not container.Type.IsFile + && PrettyNaming.DoesIdentifierNeedBackticks container.Name + +let private containerPath (container: NavigableContainer) = + let path = container.FullName + + if isContainerQuoted container then + String.Concat(path.Substring(0, path.Length - container.Name.Length), "``", container.Name, "``") + else + path + +let fullyQualifiedName (item: NavigableItem) = + let name = + if isQuoted item then + String.Concat("``", item.Name, "``") + else + item.Name + + match containerPath item.Container with + | "" -> name + | container -> String.Concat(container, ".", name) + +let tooltipName (item: NavigableItem) = + match symbolContextType item.Kind, item.Container.Type, item.Container.Name with + | CopilotSymbolContextType.Class, _, _ + | _, NavigableContainerType.File, _ + | _, _, "" -> item.Name + | _, _, container -> $"{container}.{item.Name}" + +/// The suffix test spares a solution-wide scan from building the name of every declaration it passes. +let hasFullyQualifiedName (candidate: string) (item: NavigableItem) = + (candidate.EndsWith(item.Name, StringComparison.Ordinal) + || candidate.EndsWith("``", StringComparison.Ordinal)) + && String.Equals(candidate, fullyQualifiedName item, StringComparison.Ordinal) + +/// The typed text split on ':' ("#fsharpSymbol:Ns.Type:15" arrives as three inputs), asked as Unknown before the picker resolves it. +let searchTextOf (query: CopilotMentionQuery) = + let searchTextAt index (inputs: IReadOnlyList) = + if index < inputs.Count then + ValueSome( + match inputs[index] with + | null -> "" + | text -> text.Trim() + ) + else + ValueNone + + match query.Type, query.Inputs with + | (CopilotMentionType.Context | CopilotMentionType.Unknown), inputs when inputs.Count > 0 -> + if String.Equals(inputs[0], SymbolMember, StringComparison.Ordinal) then + searchTextAt 1 inputs + else + searchTextAt 0 inputs + | _ -> ValueNone + +let private lineOf (content: string) offset = + let mutable line = 1 + + for i in 0 .. offset - 1 do + if content[i] = '\n' then + line <- line + 1 + + line + +/// Copilot widens an empty selection to the whole file for F#, so a whole-file range means no line is known. +let editorFocusOf (document: DocumentContext) = + match document.FilePath with + | null -> ValueNone + | filePath -> + let focusOn firstLine lastLine = + ValueSome + { + FilePath = filePath + FirstLine = firstLine + LastLine = lastLine + } + + match document.Selections |> Seq.tryHeadV with + | ValueSome selection when selection.Caret.HasValue -> + let line = lineOf document.Content selection.Caret.Value + focusOn line line + | ValueSome selection when selection.LineRange.HasValue -> + let range = selection.LineRange.Value + + let totalLines = + document.TotalLinesInFile + |> ValueOption.ofNullable + |> ValueOption.defaultWith (fun () -> lineOf document.Content document.Content.Length) + + if range.StartLine <= 1 && range.EndLine >= totalLines then + focusOn 0 0 + else + focusOn range.StartLine range.EndLine + | _ -> focusOn 0 0 diff --git a/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs new file mode 100644 index 00000000000..525adab479f --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Copilot/CopilotSymbolSnippets.fs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +module internal Microsoft.VisualStudio.FSharp.Editor.CopilotSymbolSnippets + +open System +open System.Collections.Generic + +open FSharp.Compiler.EditorServices + +[] +let MaxSnippetLines = 200 + +/// A scope's header runs from its Range start, over its doc comment, to its CollapseRange start. +let declaredByLine (scopes: Structure.ScopeRange seq) = + let declared = Dictionary() + + for scope in scopes do + if + scope.Scope <> Structure.Scope.Comment + && scope.Scope <> Structure.Scope.XmlDocComment + then + for line in scope.Range.StartLine .. scope.CollapseRange.StartLine do + match declared.TryGetValue line with + | true, widest when widest.Range.EndLine >= scope.Range.EndLine -> () + | _ -> declared[line] <- scope + + declared + +let declarationLines (sourceLines: string array) (declared: Dictionary) (item: NavigableItem) = + let declarationLine = item.Range.StartLine + + let firstLine, lastLine = + match declared.TryGetValue declarationLine with + | true, scope when scope.Range.EndLine >= item.Range.EndLine -> scope.Range.StartLine, scope.Range.EndLine + | _ -> declarationLine, item.Range.EndLine + + // Outlining reports no scope for a one-line "///", so walk back over it by text. + let isDocComment line = + sourceLines[line - 1].AsSpan().TrimStart().StartsWith("///".AsSpan(), StringComparison.Ordinal) + + let rec docCommentStart line = + if line > 1 && isDocComment (line - 1) then + docCommentStart (line - 1) + else + line + + struct (docCommentStart firstLine, lastLine) + +let definitionLines (sourceLines: string array) (declared: Dictionary) (item: NavigableItem) = + let struct (firstLine, lastLine) = declarationLines sourceLines declared item + struct (firstLine, min lastLine (firstLine + MaxSnippetLines - 1)) diff --git a/vsintegration/src/FSharp.Editor/Copilot/EditorFocus.fs b/vsintegration/src/FSharp.Editor/Copilot/EditorFocus.fs new file mode 100644 index 00000000000..d5d80548c94 --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Copilot/EditorFocus.fs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Editor + +/// Lines are 1-based and inclusive; 0 and 0 when the file is known but no line is. +[] +type internal EditorFocus = + { + FilePath: string + FirstLine: int + LastLine: int + } diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj index 319bdd5a264..f5d6a8e0b04 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj @@ -94,6 +94,10 @@ + + + + @@ -179,6 +183,7 @@ + diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.resx b/vsintegration/src/FSharp.Editor/FSharp.Editor.resx index 1f1f632d770..8d1a5854269 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.resx +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.resx @@ -368,4 +368,9 @@ Use live (unsaved) buffers for analysis Returns: + + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs index ac8ceffd4f9..4a00ac8ff16 100644 --- a/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs +++ b/vsintegration/src/FSharp.Editor/LanguageService/LanguageService.fs @@ -6,10 +6,13 @@ 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.Options +open Microsoft.ServiceHub.Framework +open Microsoft.VisualStudio.Copilot open FSharp.Compiler open FSharp.Compiler.CodeAnalysis open FSharp.NativeInterop @@ -20,6 +23,7 @@ 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 @@ -411,6 +415,9 @@ type internal FSharpPackage() as this = #if DEBUG override _.RegisterOnAfterPackageLoadedAsyncWork(afterPackageLoadedTasks: PackageLoadTasks) = + // The base schedules LoadComponentsInBackgroundAfterSolutionFullyLoadedAsync, which registers Copilot. + base.RegisterOnAfterPackageLoadedAsyncWork(afterPackageLoadedTasks) + afterPackageLoadedTasks.AddTask( false, fun _ _ -> @@ -422,6 +429,49 @@ type internal FSharpPackage() as this = ) #endif + member private this.RegisterCopilotContextProviderAsync(cancellationToken: CancellationToken) : Task = + task { + try + // Copilot's registration part blocks on the main thread while MEF constructs it, so a background + // request deadlocks against the Git provider's request for it on the main thread at solution open. + do! this.JoinableTaskFactory.SwitchToMainThreadAsync(alwaysYield = true, cancellationToken = cancellationToken) + + let! container = this.GetServiceAsync(typeof) + + match container with + | :? IBrokeredServiceContainer as container -> + // The Interactions service also serves ICopilotRegistrationService. + let! registration = + container + .GetFullAccessServiceBroker() + .GetProxyAsync(CopilotDescriptors.InteractionService, cancellationToken) + + use registration = registration + + match registration with + | null -> () + | registration -> + let moniker = + ServiceMoniker( + FSharpConstants.copilotSymbolProviderName, + Version CopilotDescriptors.CurrentContextProviderVersion + ) + + do! registration.RegisterContextProviderAsync(moniker, cancellationToken) + | _ -> () + with ex when not (ex :? OperationCanceledException) -> + DebugHelpers.FSharpOutputPane.logExceptionWithContext (ex, "Registering the Copilot context provider") + } + + override this.LoadComponentsInBackgroundAfterSolutionFullyLoadedAsync(cancellationToken) : Task = + 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/Navigation/NavigateToSearchService.fs b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs index 546b00e1b16..ced67f2412e 100644 --- a/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs +++ b/vsintegration/src/FSharp.Editor/Navigation/NavigateToSearchService.fs @@ -19,8 +19,8 @@ open Microsoft.VisualStudio.Text.PatternMatching open FSharp.Compiler.EditorServices open CancellableTasks -[); Shared>] -type internal FSharpNavigateToSearchService +[] +type internal FSharpNavigableItemsCache [] (patternMatcherFactory: IPatternMatcherFactory, [] workspace: VisualStudioWorkspace) = @@ -33,7 +33,7 @@ type internal FSharpNavigateToSearchService if e.NewSolution.Id <> e.OldSolution.Id then cache.Clear() - let getNavigableItems (document: Document) = + member _.GetNavigableItems(document: Document) = cancellableTask { let! ct = CancellableTask.getCancellationToken () let! currentVersion = document.GetTextVersionAsync(ct) @@ -41,12 +41,51 @@ type internal FSharpNavigateToSearchService match cache.TryGetValue document.Id with | true, (version, items) when version = currentVersion -> return items | _ -> - let! parseResults = document.GetFSharpParseResultsAsync(nameof (FSharpNavigateToSearchService)) + let! parseResults = document.GetFSharpParseResultsAsync(nameof (FSharpNavigableItemsCache)) let items = NavigateTo.GetNavigableItems parseResults.ParseTree cache[document.Id] <- currentVersion, items return items } + /// Uses the version the document already knows: asking for it could read a closed file from disk. + member _.TryGetCachedNavigableItems(document: Document) = + match cache.TryGetValue document.Id, document.TryGetTextVersion() with + | (true, (version, items)), (true, currentVersion) when version = currentVersion -> ValueSome items + | _ -> ValueNone + + 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) = + + let getNavigableItems (document: Document) = itemsCache.GetNavigableItems document + let kindsProvided = ImmutableHashSet.Create( FSharpNavigateToItemKind.Module, @@ -115,33 +154,8 @@ 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 - - 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 createMatcherFor (searchPattern: string) = + itemsCache.CreateMatcherFor searchPattern let processDocument (tryMatch: NavigableItem -> PatternMatch voption) (kinds: IImmutableSet) (document: Document) = cancellableTask { diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf index cd8c46bf705..5af8dfaab66 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf @@ -105,6 +105,13 @@ Navrhnout názvy pro nerozpoznané identifikátory; Použít místo negace odčítání + + {0} in {1} +{2} + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + F# Disposable Values (locals) Uvolnitelné hodnoty jazyka F# (místní) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf index bce1941f0b1..e7cf1e34711 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf @@ -105,6 +105,13 @@ Namen für nicht aufgelöste Bezeichner vorschlagen; Subtraktion anstelle von Negation verwenden + + {0} in {1} +{2} + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + F# Disposable Values (locals) Disposable-Werte in F# (lokal) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf index fa8cb62c422..a763e64aca8 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf @@ -105,6 +105,13 @@ Sugerir nombres para identificadores sin resolver; Usar la resta en lugar de la negación + + {0} in {1} +{2} + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + F# Disposable Values (locals) Valores de F# descartables (locales) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf index e7ec71e839e..6fd805187e5 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf @@ -105,6 +105,13 @@ Suggérer des noms pour les identificateurs non résolus ; Utiliser la soustraction à la place de la négation + + {0} in {1} +{2} + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + F# Disposable Values (locals) Valeurs F# pouvant être supprimées (variables locales) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf index 327a7ca362f..766483153f0 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf @@ -105,6 +105,13 @@ Suggerisci i nomi per gli identificatori non risolti; Usare la sottrazione invece della negazione + + {0} in {1} +{2} + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + F# Disposable Values (locals) Valori eliminabili F# (variabili locali) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf index d45234c011a..30834f89e84 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf @@ -105,6 +105,13 @@ Suggest names for unresolved identifiers; 否定の代わりに減算を使用する + + {0} in {1} +{2} + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + F# Disposable Values (locals) F# の破棄可能な値 (ローカル) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf index 3248e0641ea..1ef22e3047d 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf @@ -105,6 +105,13 @@ Suggest names for unresolved identifiers; 부정 대신 빼기 사용 + + {0} in {1} +{2} + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + F# Disposable Values (locals) F# 삭제 가능한 값(로컬) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf index abc39f15da5..1091913daa2 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf @@ -105,6 +105,13 @@ Sugeruj nazwy dla nierozpoznanych identyfikatorów; Użyj odejmowania zamiast negacji + + {0} in {1} +{2} + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + F# Disposable Values (locals) Wartości możliwe do likwidacji języka F# (lokalne) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf index dfde43120f5..28e5ff703d2 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf @@ -105,6 +105,13 @@ Sugerir nomes para identificadores não resolvidos; Use a subtração em vez da negação + + {0} in {1} +{2} + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + F# Disposable Values (locals) Valores F# Descartáveis (locais) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf index 47cda215312..5787d9818f3 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf @@ -105,6 +105,13 @@ Suggest names for unresolved identifiers; Используйте вычитание вместо отрицания. + + {0} in {1} +{2} + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + F# Disposable Values (locals) Освобождаемые значения F# (локальные) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf index 58aa5d54c43..933e42779d4 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf @@ -105,6 +105,13 @@ Kullanılmayan değerleri analiz et ve bunlara düzeltmeler öner; Negatif yapma yerine çıkarmayı kullanın + + {0} in {1} +{2} + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + F# Disposable Values (locals) F# Atılabilir Değerleri (yereller) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf index 4fa703776fb..69381cc377d 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf @@ -105,6 +105,13 @@ Suggest names for unresolved identifiers; 使用减法代替求反 + + {0} in {1} +{2} + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + F# Disposable Values (locals) F# 可释放值(局部值) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf index fd46ef9919a..4bdafbf0121 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf @@ -105,6 +105,13 @@ Suggest names for unresolved identifiers; 使用減號代替否定 + + {0} in {1} +{2} + {0} in {1} +{2} + Tooltip of an F# declaration in the Copilot Chat "#" picker, as Copilot shows a C# one. {0} is the kind of declaration (Class, Method, Field...), {1} the file name, {2} the declaration's name. + F# Disposable Values (locals) F# 可處置的值 (區域) diff --git a/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs new file mode 100644 index 00000000000..daa5bfd4ace --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/CopilotContextProviderTests.fs @@ -0,0 +1,615 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace FSharp.Editor.Tests + +open System +open System.Collections.Generic + +open Xunit + +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.Text +open Microsoft.VisualStudio.Copilot +open Microsoft.VisualStudio.FSharp.Editor + +open FSharp.Compiler.EditorServices +open FSharp.Editor.Tests.Helpers +open CancellableTasks + +module CopilotContextProviderTests = + + let private widgets = + """ +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 private solution = RoslynTestHelpers.CreateSolution widgets + + let private cache = + MefHelpers.createExportProvider().GetExportedValue() + + let private run computation = + CancellableTask.runSynchronouslyWithoutCancellation computation + + let private manyProject = "C:\\many.fsproj" + + let private solutionOfProjects projects = + let projects = + [ + for projectPath, defines, files in projects -> + let projectId = ProjectId.CreateNewId() + + let documents = + files + |> List.map (fun (path, source) -> RoslynTestHelpers.CreateDocumentInfo projectId path source) + + struct (projectId, defines, files, RoslynTestHelpers.CreateProjectInfo projectId projectPath documents) + ] + + let solution = + RoslynTestHelpers.CreateSolution [ for struct (_, _, _, info) in projects -> info ] + + for struct (projectId, defines, files, _) in projects do + { RoslynTestHelpers.DefaultProjectOptions with + SourceFiles = files |> List.map fst |> Array.ofList + OtherOptions = defines + } + |> RoslynTestHelpers.SetProjectOptions projectId solution + + solution + + let private solutionOf files = + solutionOfProjects [ manyProject, [||], files ] + + let private documentAt (filePath: string) (solution: Solution) = + solution.Projects + |> Seq.collect _.Documents + |> Seq.find (fun document -> document.FilePath = filePath) + + let private parse (document: Document) = + cache.GetNavigableItems document |> run |> ignore + + let private isParsed (document: Document) = + (cache.TryGetCachedNavigableItems document).IsSome + + let private hitsIn openDocumentIds focus solution pattern = + CopilotSymbolQuery.search cache openDocumentIds focus solution [| pattern |] + |> run + |> Array.head + + let private namesOf hits = + hits + |> Array.map (fun (struct (item, _, _)) -> CopilotSymbolMapping.fullyQualifiedName item) + + let private namesIn openDocumentIds focus solution pattern = + hitsIn openDocumentIds focus solution pattern |> namesOf + + let private itemNamed fullyQualifiedName = + hitsIn Seq.empty ValueNone solution fullyQualifiedName + |> Array.pick (fun (struct (item, _, _)) -> + if CopilotSymbolMapping.fullyQualifiedName item = fullyQualifiedName then + Some item + else + None) + + let private contextIn solution (name: string) picked = + CopilotSymbolQuery.symbolContext cache Seq.empty solution name picked + |> run + |> ValueOption.defaultWith (fun () -> failwith $"expected a symbol context for {name}") + + let private declarationsPickedIn solution name picked = + CopilotSymbolQuery.declarationsOf cache Seq.empty solution name picked |> run + + let private lineOf (struct (item: NavigableItem, _: Document)) = item.Range.StartLine + + let private fileOf (struct (_: NavigableItem, document: Document)) = document.FilePath + + let private pickedIn file line = + ValueSome + { + File = file + ProjectName = manyProject + Line = line + } + + let private focusOn filePath firstLine lastLine = + ValueSome + { + FilePath = filePath + FirstLine = firstLine + LastLine = lastLine + } + + let private caretOn filePath line = focusOn filePath line line + + let private manyDeclarations name count = + let members = + [ for i in 1..count -> $" member _.{name}{i} = {i}" ] |> String.concat "\n" + + $"module {name}Module\n\ntype {name}Holder() =\n{members}\n" + + let private coldPath = "C:\\cold.fs" + + let private coldFile = coldPath, "module Cold\n\nlet widgetCounter = 1\n" + + let private openPath = "C:\\open.fs" + + let private multilinePath = "C:\\multiline.fs" + + let private multilineSolution = + solutionOf + [ + multilinePath, + "module Multiline\n\n/// Adds.\nlet add\n (x: int)\n (y: int) =\n x + y\n\ntype Calculator() =\n member _.Sum\n (\n x: int,\n y: int\n ) =\n x * y\n" + ] + + let private overloadsPath = "C:\\overloads.fs" + + let private overloadsSolution = + let overloads = + [ "int"; "string"; "float"; "bool"; "char"; "byte" ] + |> List.map (fun parameterType -> + $" member _.Bump(step: {parameterType}) =\n ignore step\n \"{parameterType}\"\n") + |> String.concat "\n" + + solutionOf [ overloadsPath, $"module Overloads\n\ntype Counter() =\n{overloads}" ] + + let private dottedSolution = + solutionOf + [ + "C:\\dotted.fs", + "module M\n\nlet ``a.b`` = 1\n\nmodule a =\n let b = 2\n\nmodule ``x.y`` =\n let z = 3\n\nmodule x =\n module y =\n let z = 4\n" + ] + + [] + [] + [] + [] + [] + let ``search finds a declaration by its fully qualified name`` (pattern: string, expected: string) = + Assert.Contains(expected, namesIn Seq.empty ValueNone solution pattern) + + [] + [] + [] + [] + [] + [] + [] + let ``a name matches only the declaration it spells out`` (candidate: string, expected: bool) = + Assert.Equal(expected, CopilotSymbolMapping.hasFullyQualifiedName candidate (itemNamed "Widgets.Counter")) + + [] + [] + [] + [] + [] + let ``a tooltip names a member by its container and a type by itself`` (fullyQualifiedName: string, expected: string) = + Assert.Equal(expected, CopilotSymbolMapping.tooltipName (itemNamed fullyQualifiedName)) + + [] + [] + [] + [] + [] + [] + [] + let ``the search text is the input after the member name`` (inputs: string, expected: string) = + let query = CopilotMentionQuery(CopilotMentionType.Unknown, inputs.Split ':') + + Assert.Equal(ValueOption.ofObj expected, CopilotSymbolMapping.searchTextOf query) + + [] + let ``only context mentions are searched for`` () = + let query = CopilotMentionQuery(CopilotMentionType.Command, [| "Widget" |]) + + Assert.True((CopilotSymbolMapping.searchTextOf query).IsNone) + + [] + let ``search reports each declaration once`` () = + let names = namesIn Seq.empty ValueNone solution "Counter" + Assert.Equal((Array.distinct names).Length, names.Length) + + [] + let ``an unknown name has no context`` () = + Assert.True( + (CopilotSymbolQuery.symbolContext cache Seq.empty solution "Widgets.NoSuchThing" ValueNone + |> run) + .IsNone + ) + + [] + [] + [] + let ``a known file answers without parsing the rest of the solution`` (isOpen: bool) = + let solution = solutionOf [ "C:\\known.fs", manyDeclarations "Widget" 25; coldFile ] + let known = documentAt "C:\\known.fs" solution + + let openDocumentIds = + if isOpen then + [ known.Id ] + else + parse known + [] + + let names = namesIn openDocumentIds ValueNone solution "Widget" + + Assert.Equal(20, names.Length) + Assert.All(names, fun name -> Assert.StartsWith("WidgetModule", name, StringComparison.Ordinal)) + Assert.False(isParsed (documentAt coldPath solution)) + + [] + let ``the search stops once it has enough declarations`` () = + let solution = + solutionOf + [ + for i in 1..150 -> $"C:\\cold{i}.fs", $"module Cold{i}\n\ntype Counter{i}() =\n member _.Value = {i}\n" + ] + + Assert.Equal(20, (namesIn Seq.empty ValueNone solution "Counter").Length) + Assert.NotEmpty(solution.Projects |> Seq.collect _.Documents |> Seq.filter (isParsed >> not)) + + [] + [] + [] + [] + let ``an unopened document is visited once whenever the cache learns of it`` (cachedBefore: bool, cachedBetween: bool) = + let solution = solutionOf [ coldFile ] + let document = documentAt coldPath solution + + if cachedBefore then + parse document + + let struct (_, cached, cold) = CopilotSymbolQuery.tiers cache (HashSet()) solution + let answeredFromCache = Seq.length cached + + if cachedBetween then + parse document + + Assert.Equal(1, answeredFromCache + Seq.length cold) + + [] + let ``a parse of an earlier text does not answer for a document`` () = + let solution = + solutionOf + [ + "C:\\edited.fs", "module Edited\n\nlet before = 1\n\n\n\n\nlet removedLater = 2\n" + ] + + let document = documentAt "C:\\edited.fs" solution + parse document + + let edited = document.WithText(SourceText.From("module Edited\n\nlet after = 1\n")) + let solution = edited.Project.Solution + + Assert.False(isParsed edited) + Assert.Equal([| "Edited.after" |], namesIn Seq.empty ValueNone solution "after") + Assert.Empty(namesIn Seq.empty ValueNone solution "removedLater") + + Assert.True( + (CopilotSymbolQuery.symbolContext cache Seq.empty solution "Edited.removedLater" ValueNone + |> run) + .IsNone + ) + + /// WidgetHolder loses to Widget on name length, so it can only come first through where its file sits. + [] + [] + [] + [] + [] + let ``the file the user works in answers first`` (holderIsOpen: bool, holderIsFocused: bool, expected: string) = + let holderPath = "C:\\holder.fs" + + let solution = + solutionOf + [ + "C:\\elsewhere.fs", "module Elsewhere\n\ntype Widget() =\n member _.Value = 1\n" + holderPath, "module Holder\n\ntype WidgetHolder() =\n member _.Value = 2\n" + ] + + let openDocumentIds = + [ + if holderIsOpen then + (documentAt holderPath solution).Id + ] + + let focus = if holderIsFocused then caretOn holderPath 1 else ValueNone + let names = namesIn openDocumentIds focus solution "Widget" + + Assert.Equal(expected, Array.head names) + Assert.Equal(2, names.Length) + + [] + let ``no text at all answers with the declarations of the open files`` () = + let solution = solutionOf [ openPath, manyDeclarations "Widget" 3; coldFile ] + + let names = + namesIn [ (documentAt openPath solution).Id ] (caretOn openPath 1) solution "" + + Assert.Contains("WidgetModule.WidgetHolder", names) + Assert.All(names, fun name -> Assert.StartsWith("WidgetModule", name, StringComparison.Ordinal)) + Assert.False(isParsed (documentAt coldPath solution)) + + [] + [] + [] + let ``a short text is looked up in the open files alone`` (pattern: string) = + let solution = solutionOf [ openPath, "module Open\n\nlet other = 1\n"; coldFile ] + let names = namesIn [ (documentAt openPath solution).Id ] ValueNone solution pattern + + Assert.Equal(pattern.Length >= 3, Array.contains "Cold.widgetCounter" names) + + /// AroundTheCaret loses to Short on name length, so it can only come first by holding the caret in its member's body. + [] + [] + [] + let ``the declaration around the caret answers before the rest of the focused file`` (caretLine: int, expected: string) = + let path = "C:\\selection.fs" + + let source = + "module Selection\n\ntype Short() =\n member _.Value = 1\n\ntype AroundTheCaret() =\n member _.Compute() =\n 2\n" + + let solution = solutionOf [ path, source ] + + let names = + namesIn [ (documentAt path solution).Id ] (caretOn path caretLine) solution "" + |> Array.filter (fun name -> name = "Selection.Short" || name = "Selection.AroundTheCaret") + + Assert.Equal(expected, Array.head names) + + let private overloadsPickedAt picked = + declarationsPickedIn overloadsSolution "Overloads.Counter.Bump" picked + |> Array.map lineOf + + [] + let ``navigating a mention goes to the overload it was picked for`` () = + let lines = overloadsPickedAt ValueNone + Assert.Equal(6, (Array.distinct lines).Length) + + for line in lines do + let picked = overloadsPickedAt (pickedIn overloadsPath line) + Assert.Equal(line, Array.head picked) + Assert.Equal(6, picked.Length) + + // A stale line leaves the file's own order; a renamed file falls back to the solution-wide answer. + Assert.Equal(lines, overloadsPickedAt (pickedIn overloadsPath -1)) + Assert.Equal(lines, overloadsPickedAt (pickedIn "C:\\renamed.fs" lines[5])) + + [] + let ``a context starts from the overload its mention was picked for`` () = + let lastOverload = Array.last (overloadsPickedAt ValueNone) + + let context = + contextIn overloadsSolution "Overloads.Counter.Bump" (pickedIn overloadsPath lastOverload) + + Assert.StartsWith("member _.Bump(step: byte)", context.Snippet.TrimStart(), StringComparison.Ordinal) + Assert.Equal(4, Seq.length context.SnippetLocations) + + [] + [] + [] + [] + let ``a mention goes to the file it was picked in when two files declare its name on one line`` (filePath: string, line: int) = + let twin = "namespace Twins\n\ntype Twin() =\n member _.Value = 1\n" + let solution = solutionOf [ "C:\\first.fs", twin; "C:\\second.fs", twin ] + + let declarations = + declarationsPickedIn solution "Twins.Twin.Value" (pickedIn filePath line) + + Assert.Equal([| filePath |], declarations |> Array.map fileOf) + + [] + [] + [] + [] + [] + [] + let ``a mention goes to the project it was picked in when a file is linked into two`` + (project: string, line: int, expectedProject: string, expectedLine: int) + = + let linked = + "C:\\shared.fs", "module M\n#if A\nlet shared = 1\n#else\nlet shared = \"B\"\n#endif\n" + + let solution = + solutionOfProjects + [ + "C:\\a.fsproj", [| "--define:A" |], [ linked ] + "C:\\b.fsproj", [||], [ linked ] + ] + + let picked = + ValueSome + { + File = fst linked + ProjectName = project + Line = line + } + + let struct (item, document) = + declarationsPickedIn solution "M.shared" picked |> Array.head + + Assert.Equal(expectedProject, document.Project.Name) + Assert.Equal(expectedLine, item.Range.StartLine) + + [] + let ``a mention picked in a signature file goes to the implementation`` () = + let solution = + solutionOf + [ + "C:\\lib.fsi", "module Lib\n\nval twice: int -> int\n" + "C:\\lib.fs", "module Lib\n\n\nlet twice x = x * 2\n" + ] + + let declarations = + declarationsPickedIn solution "Lib.twice" (pickedIn "C:\\lib.fsi" 3) + + Assert.Equal([| "C:\\lib.fs" |], declarations |> Array.map fileOf) + + [] + [] + [] + let ``a header over several lines keeps its declaration whole`` (name: string, firstLine: string, lastLine: string) = + let snippet = (contextIn multilineSolution name ValueNone).Snippet.Trim() + + Assert.StartsWith(firstLine, snippet, StringComparison.Ordinal) + Assert.EndsWith(lastLine, snippet, StringComparison.Ordinal) + + [] + [] + [] + let ``the caret in a body under a header over several lines selects its declaration`` (caretLine: int, around: string, apart: string) = + let focused = documentAt multilinePath multilineSolution + + let hits = + hitsIn [ focused.Id ] (caretOn multilinePath caretLine) multilineSolution "" + + let focusOf name = + hits + |> Array.pick (fun (struct (item, _, focus)) -> + if CopilotSymbolMapping.fullyQualifiedName item = name then + Some focus + else + None) + + Assert.Equal(DocumentFocus.Selected, focusOf around) + Assert.Equal(DocumentFocus.Focused, focusOf apart) + + [] + let ``a batch of texts answers like the same texts one by one`` () = + let solution = + solutionOf + [ + "C:\\a.fs", "module A\n\nlet alpha = 1\n" + "C:\\b.fs", "module B\n\nlet beta = 2\n" + ] + + let batched = + CopilotSymbolQuery.search cache Seq.empty ValueNone solution [| "alpha"; "beta" |] + |> run + |> Array.map namesOf + + Assert.Equal(namesIn Seq.empty ValueNone solution "alpha", batched[0]) + Assert.Equal(namesIn Seq.empty ValueNone solution "beta", batched[1]) + + [] + let ``a type context carries the whole declaration and its doc comment`` () = + let context = contextIn solution "Widgets.Counter" ValueNone + + 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 = contextIn solution "Widgets.Counter.Bump" ValueNone + + Assert.Contains("value <- value + 1", context.Snippet) + Assert.DoesNotContain("type Counter", context.Snippet) + + [] + let ``a one-line declaration keeps its doc comment`` () = + let context = contextIn solution "Widgets.twice" ValueNone + + 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, (contextIn solution name ValueNone).SymbolType) + + [] + let ``a context points back at the source it was taken from`` () = + let context = contextIn solution "Widgets.Counter" ValueNone + let location = Assert.Single context.SnippetLocations + let document = RoslynTestHelpers.GetSingleDocument solution + + Assert.Equal(document.FilePath, location.FilePath) + Assert.Equal(context.Snippet.Length, location.Span.Length) + + [] + let ``names that differ only in double backticks answer as two mentions`` () = + let names = namesIn Seq.empty ValueNone dottedSolution "a.b" + + Assert.Contains("M.``a.b``", names) + Assert.Contains("M.a.b", names) + + [] + [] + [] + [] + [] + let ``a name holding a dot resolves to its own declaration`` (fullyQualifiedName: string, declaration: string) = + Assert.Equal(declaration, (contextIn dottedSolution fullyQualifiedName ValueNone).Snippet.Trim()) + + let private focusContent = "module M\n\nlet a = 1\n\nlet b = 2\n" + + let private focusFilePath = "C:\\focus.fs" + + let private documentFocus caretOffset startLine endLine = + let caret = if caretOffset < 0 then Nullable() else Nullable caretOffset + + let lineRange = + if startLine < 0 then + Nullable() + else + Nullable(Microsoft.VisualStudio.RpcContracts.Utilities.Range(startLine, -1, endLine, -1)) + + let selection = + DocumentSelection(0, focusContent.Length, Caret = caret, LineRange = lineRange) + + DocumentContext(focusContent, FilePath = focusFilePath, TotalLinesInFile = 6, Selections = [| selection |]) + |> CopilotSymbolMapping.editorFocusOf + + /// Offset 10 starts "let a = 1"; a range over all six lines of the file means "focused, no line known". + [] + [] + [] + [] + let ``the caret or selection maps onto a focused line range`` + (caretOffset: int) + (startLine: int) + (endLine: int) + (expectedFirst: int) + (expectedLast: int) + = + Assert.Equal(focusOn focusFilePath expectedFirst expectedLast, documentFocus caretOffset startLine endLine) + + [] + let ``no selection at all is focused with no line known`` () = + let document = DocumentContext(focusContent, FilePath = focusFilePath) + + Assert.Equal(focusOn focusFilePath 0 0, CopilotSymbolMapping.editorFocusOf document) + + [] + let ``a document with no file path is not focused`` () = + Assert.True((CopilotSymbolMapping.editorFocusOf (DocumentContext(focusContent))).IsNone) diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj index a62b25af1cf..87f071ddbfd 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj +++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj @@ -33,6 +33,7 @@ +