diff --git a/src/PowerShellEditorServices/Services/PowerShell/Utility/CommandHelpers.cs b/src/PowerShellEditorServices/Services/PowerShell/Utility/CommandHelpers.cs index fffb0c1c9..d65503167 100644 --- a/src/PowerShellEditorServices/Services/PowerShell/Utility/CommandHelpers.cs +++ b/src/PowerShellEditorServices/Services/PowerShell/Utility/CommandHelpers.cs @@ -64,7 +64,8 @@ internal static class CommandHelpers public static async Task GetCommandInfoAsync( string commandName, IRunspaceInfo currentRunspace, - IInternalPowerShellExecutionService executionService) + IInternalPowerShellExecutionService executionService, + CancellationToken cancellationToken = default) { // This mechanism only works in-process if (currentRunspace.RunspaceOrigin != RunspaceOrigin.Local) @@ -98,7 +99,7 @@ public static async Task GetCommandInfoAsync( .AddParameter("ErrorAction", "Ignore"); IReadOnlyList results = await executionService - .ExecutePSCommandAsync(command, CancellationToken.None) + .ExecutePSCommandAsync(command, cancellationToken) .ConfigureAwait(false); CommandInfo commandInfo = results.Count > 0 ? results[0] : null; @@ -120,7 +121,8 @@ public static async Task GetCommandInfoAsync( /// The synopsis. public static async Task GetCommandSynopsisAsync( CommandInfo commandInfo, - IInternalPowerShellExecutionService executionService) + IInternalPowerShellExecutionService executionService, + CancellationToken cancellationToken = default) { Validate.IsNotNull(nameof(commandInfo), commandInfo); Validate.IsNotNull(nameof(executionService), executionService); @@ -151,7 +153,7 @@ public static async Task GetCommandSynopsisAsync( .AddParameter("ErrorAction", "Ignore"); IReadOnlyList results = await executionService - .ExecutePSCommandAsync(command, CancellationToken.None) + .ExecutePSCommandAsync(command, cancellationToken) .ConfigureAwait(false); // Extract the synopsis string from the object diff --git a/src/PowerShellEditorServices/Services/TextDocument/CompletionResults.cs b/src/PowerShellEditorServices/Services/TextDocument/CompletionResults.cs deleted file mode 100644 index 8f35af586..000000000 --- a/src/PowerShellEditorServices/Services/TextDocument/CompletionResults.cs +++ /dev/null @@ -1,339 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Management.Automation; -using System.Text.RegularExpressions; -using Microsoft.PowerShell.EditorServices.Utility; - -namespace Microsoft.PowerShell.EditorServices.Services.TextDocument -{ - /// - /// Provides the results of a single code completion request. - /// - internal sealed class CompletionResults - { - #region Properties - - /// - /// Gets the completions that were found during the - /// completion request. - /// - public CompletionDetails[] Completions { get; private set; } - - /// - /// Gets the range in the buffer that should be replaced by this - /// completion result. - /// - public BufferRange ReplacedRange { get; private set; } - - #endregion - - #region Constructors - - /// - /// Creates an empty CompletionResults instance. - /// - public CompletionResults() - { - this.Completions = Array.Empty(); - this.ReplacedRange = new BufferRange(0, 0, 0, 0); - } - - internal static CompletionResults Create( - ScriptFile scriptFile, - CommandCompletion commandCompletion) - { - BufferRange replacedRange = null; - - // Only calculate the replacement range if there are completion results - if (commandCompletion.CompletionMatches.Count > 0) - { - replacedRange = - scriptFile.GetRangeBetweenOffsets( - commandCompletion.ReplacementIndex, - commandCompletion.ReplacementIndex + commandCompletion.ReplacementLength); - } - - return new CompletionResults - { - Completions = GetCompletionsArray(commandCompletion), - ReplacedRange = replacedRange - }; - } - - #endregion - - #region Private Methods - - private static CompletionDetails[] GetCompletionsArray( - CommandCompletion commandCompletion) - { - IEnumerable completionList = - commandCompletion.CompletionMatches.Select( - CompletionDetails.Create); - - return completionList.ToArray(); - } - - #endregion - } - - /// - /// Enumerates the completion types that may be returned. - /// - internal enum CompletionType - { - /// - /// Completion type is unknown, either through being uninitialized or - /// having been created from an unsupported CompletionResult that was - /// returned by the PowerShell engine. - /// - Unknown = 0, - - /// - /// Identifies a completion for a command. - /// - Command, - - /// - /// Identifies a completion for a .NET method. - /// - Method, - - /// - /// Identifies a completion for a command parameter name. - /// - ParameterName, - - /// - /// Identifies a completion for a command parameter value. - /// - ParameterValue, - - /// - /// Identifies a completion for a .NET property. - /// - Property, - - /// - /// Identifies a completion for a variable name. - /// - Variable, - - /// - /// Identifies a completion for a namespace. - /// - Namespace, - - /// - /// Identifies a completion for a .NET type name. - /// - Type, - - /// - /// Identifies a completion for a PowerShell language keyword. - /// - Keyword, - - /// - /// Identifies a completion for a provider path (like a file system path) to a leaf item. - /// - File, - - /// - /// Identifies a completion for a provider path (like a file system path) to a container. - /// - Folder - } - - /// - /// Provides the details about a single completion result. - /// - [DebuggerDisplay("CompletionType = {CompletionType.ToString()}, CompletionText = {CompletionText}")] - internal sealed class CompletionDetails - { - #region Properties - - /// - /// Gets the text that will be used to complete the statement - /// at the requested file offset. - /// - public string CompletionText { get; private set; } - - /// - /// Gets the text that should be dispayed in a drop-down completion list. - /// - public string ListItemText { get; private set; } - - /// - /// Gets the text that can be used to display a tooltip for - /// the statement at the requested file offset. - /// - public string ToolTipText { get; private set; } - - /// - /// Gets the name of the type which this symbol represents. - /// If the symbol doesn't have an inherent type, null will - /// be returned. - /// - public string SymbolTypeName { get; private set; } - - /// - /// Gets the CompletionType which identifies the type of this completion. - /// - public CompletionType CompletionType { get; private set; } - - #endregion - - #region Constructors - - internal static CompletionDetails Create(CompletionResult completionResult) - { - Validate.IsNotNull("completionResult", completionResult); - - // Some tooltips may have newlines or whitespace for unknown reasons - string toolTipText = completionResult.ToolTip; - if (toolTipText != null) - { - toolTipText = toolTipText.Trim(); - } - - return new CompletionDetails - { - CompletionText = completionResult.CompletionText, - ListItemText = completionResult.ListItemText, - ToolTipText = toolTipText, - SymbolTypeName = ExtractSymbolTypeNameFromToolTip(completionResult.ToolTip), - CompletionType = - ConvertCompletionResultType( - completionResult.ResultType) - }; - } - - internal static CompletionDetails Create( - string completionText, - CompletionType completionType, - string toolTipText = null, - string symbolTypeName = null, - string listItemText = null) - { - return new CompletionDetails - { - CompletionText = completionText, - CompletionType = completionType, - ListItemText = listItemText, - ToolTipText = toolTipText, - SymbolTypeName = symbolTypeName - }; - } - - #endregion - - #region Public Methods - - /// - /// Compares two CompletionResults instances for equality. - /// - /// The potential CompletionResults instance to compare. - /// True if the CompletionResults instances have the same details. - public override bool Equals(object obj) - { - if (!(obj is CompletionDetails otherDetails)) - { - return false; - } - - return - string.Equals(this.CompletionText, otherDetails.CompletionText) && - this.CompletionType == otherDetails.CompletionType && - string.Equals(this.ToolTipText, otherDetails.ToolTipText) && - string.Equals(this.SymbolTypeName, otherDetails.SymbolTypeName); - } - - /// - /// Returns the hash code for this CompletionResults instance. - /// - /// The hash code for this CompletionResults instance. - public override int GetHashCode() - { - return - string.Format( - "{0}{1}{2}{3}{4}", - this.CompletionText, - this.CompletionType, - this.ListItemText, - this.ToolTipText, - this.SymbolTypeName).GetHashCode(); - } - - #endregion - - #region Private Methods - - private static CompletionType ConvertCompletionResultType( - CompletionResultType completionResultType) - { - switch (completionResultType) - { - case CompletionResultType.Command: - return CompletionType.Command; - - case CompletionResultType.Method: - return CompletionType.Method; - - case CompletionResultType.ParameterName: - return CompletionType.ParameterName; - - case CompletionResultType.ParameterValue: - return CompletionType.ParameterValue; - - case CompletionResultType.Property: - return CompletionType.Property; - - case CompletionResultType.Variable: - return CompletionType.Variable; - - case CompletionResultType.Namespace: - return CompletionType.Namespace; - - case CompletionResultType.Type: - return CompletionType.Type; - - case CompletionResultType.Keyword: - case CompletionResultType.DynamicKeyword: - return CompletionType.Keyword; - - case CompletionResultType.ProviderContainer: - return CompletionType.Folder; - - case CompletionResultType.ProviderItem: - return CompletionType.File; - - default: - // TODO: Trace the unsupported CompletionResultType - return CompletionType.Unknown; - } - } - - private static string ExtractSymbolTypeNameFromToolTip(string toolTipText) - { - // Tooltips returned from PowerShell contain the symbol type in - // brackets. Attempt to extract such strings for further processing. - var matches = Regex.Matches(toolTipText, @"^\[(.+)\]"); - - if (matches.Count > 0 && matches[0].Groups.Count > 1) - { - // Return the symbol type name - return matches[0].Groups[1].Value; - } - - return null; - } - - #endregion - } -} diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/CompletionHandler.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/CompletionHandler.cs index 2581e2a6e..bd3a45713 100644 --- a/src/PowerShellEditorServices/Services/TextDocument/Handlers/CompletionHandler.cs +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/CompletionHandler.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.Collections.Generic; using System.Management.Automation; using System.Text; using System.Text.RegularExpressions; @@ -24,17 +25,13 @@ namespace Microsoft.PowerShell.EditorServices.Handlers // TODO: Use ABCs. internal class PsesCompletionHandler : ICompletionHandler, ICompletionResolveHandler { - const int DefaultWaitTimeoutMilliseconds = 5000; private readonly ILogger _logger; private readonly IRunspaceContext _runspaceContext; private readonly IInternalPowerShellExecutionService _executionService; private readonly WorkspaceService _workspaceService; - private CompletionResults _mostRecentCompletions; - private int _mostRecentRequestLine; - private int _mostRecentRequestOffest; - private string _mostRecentRequestFile; private CompletionCapability _capability; private readonly Guid _id = Guid.NewGuid(); + Guid ICanBeIdentifiedHandler.Id => _id; public PsesCompletionHandler( @@ -49,7 +46,7 @@ public PsesCompletionHandler( _workspaceService = workspaceService; } - public CompletionRegistrationOptions GetRegistrationOptions(CompletionCapability capability, ClientCapabilities clientCapabilities) => new CompletionRegistrationOptions + public CompletionRegistrationOptions GetRegistrationOptions(CompletionCapability capability, ClientCapabilities clientCapabilities) => new() { DocumentSelector = LspUtils.PowerShellDocumentSelector, ResolveProvider = true, @@ -62,31 +59,13 @@ public async Task Handle(CompletionParams request, CancellationT int cursorColumn = request.Position.Character + 1; ScriptFile scriptFile = _workspaceService.GetFile(request.TextDocument.Uri); + IEnumerable completionResults = await GetCompletionsInFileAsync( + scriptFile, + cursorLine, + cursorColumn, + cancellationToken).ConfigureAwait(false); - if (cancellationToken.IsCancellationRequested) - { - _logger.LogDebug("Completion request canceled for file: {0}", request.TextDocument.Uri); - return Array.Empty(); - } - - CompletionResults completionResults = - await GetCompletionsInFileAsync( - scriptFile, - cursorLine, - cursorColumn).ConfigureAwait(false); - - if (completionResults == null) - { - return Array.Empty(); - } - - CompletionItem[] completionItems = new CompletionItem[completionResults.Completions.Length]; - for (int i = 0; i < completionItems.Length; i++) - { - completionItems[i] = CreateCompletionItem(completionResults.Completions[i], completionResults.ReplacedRange, i + 1); - } - - return completionItems; + return new CompletionList(completionResults); } public static bool CanResolve(CompletionItem value) @@ -97,34 +76,32 @@ public static bool CanResolve(CompletionItem value) // Handler for "completionItem/resolve". In VSCode this is fired when a completion item is highlighted in the completion list. public async Task Handle(CompletionItem request, CancellationToken cancellationToken) { - // We currently only support this request for anything that returns a CommandInfo: functions, cmdlets, aliases. - if (request.Kind != CompletionItemKind.Function) - { - return request; - } - - // No details means the module hasn't been imported yet and Intellisense shouldn't import the module to get this info. - if (request.Detail is null) + // We currently only support this request for anything that returns a CommandInfo: + // functions, cmdlets, aliases. No detail means the module hasn't been imported yet and + // IntelliSense shouldn't import the module to get this info. + if (request.Kind is not CompletionItemKind.Function || request.Detail is null) { return request; } // Get the documentation for the function - CommandInfo commandInfo = - await CommandHelpers.GetCommandInfoAsync( - request.Label, - _runspaceContext.CurrentRunspace, - _executionService).ConfigureAwait(false); + CommandInfo commandInfo = await CommandHelpers.GetCommandInfoAsync( + request.Label, + _runspaceContext.CurrentRunspace, + _executionService, + cancellationToken).ConfigureAwait(false); - if (commandInfo != null) + if (commandInfo is not null) { - request = request with + return request with { - Documentation = await CommandHelpers.GetCommandSynopsisAsync(commandInfo, _executionService).ConfigureAwait(false) + Documentation = await CommandHelpers.GetCommandSynopsisAsync( + commandInfo, + _executionService, + cancellationToken).ConfigureAwait(false) }; } - // Send back the updated CompletionItem return request; } @@ -149,217 +126,168 @@ public void SetCapability(CompletionCapability capability, ClientCapabilities cl /// /// A CommandCompletion instance completions for the identified statement. /// - public async Task GetCompletionsInFileAsync( + public async Task> GetCompletionsInFileAsync( ScriptFile scriptFile, int lineNumber, - int columnNumber) + int columnNumber, + CancellationToken cancellationToken) { Validate.IsNotNull(nameof(scriptFile), scriptFile); - // Get the offset at the specified position. This method - // will also validate the given position. - int fileOffset = - scriptFile.GetOffsetAtPosition( - lineNumber, - columnNumber); - - CommandCompletion commandCompletion = null; - using (var cts = new CancellationTokenSource(DefaultWaitTimeoutMilliseconds)) - { - commandCompletion = - await AstOperations.GetCompletionsAsync( - scriptFile.ScriptAst, - scriptFile.ScriptTokens, - fileOffset, - _executionService, - _logger, - cts.Token).ConfigureAwait(false); - } - - if (commandCompletion == null) + CommandCompletion result = await AstOperations.GetCompletionsAsync( + scriptFile.ScriptAst, + scriptFile.ScriptTokens, + scriptFile.GetOffsetAtPosition(lineNumber, columnNumber), + _executionService, + _logger, + cancellationToken).ConfigureAwait(false); + + // Only calculate the replacement range if there are completions. + BufferRange replacedRange = new(0, 0, 0, 0); + if (result.CompletionMatches.Count > 0) { - return new CompletionResults(); + replacedRange = scriptFile.GetRangeBetweenOffsets( + result.ReplacementIndex, + result.ReplacementIndex + result.ReplacementLength); } - try + // Create OmniSharp CompletionItems from PowerShell CompletionResults. We use a for loop + // because the index is used for sorting. + CompletionItem[] completionItems = new CompletionItem[result.CompletionMatches.Count]; + for (int i = 0; i < result.CompletionMatches.Count; i++) { - CompletionResults completionResults = - CompletionResults.Create( - scriptFile, - commandCompletion); - - // save state of most recent completion - _mostRecentCompletions = completionResults; - _mostRecentRequestFile = scriptFile.Id; - _mostRecentRequestLine = lineNumber; - _mostRecentRequestOffest = columnNumber; - - return completionResults; - } - catch (ArgumentException e) - { - // Bad completion results could return an invalid - // replacement range, catch that here - _logger.LogError( - $"Caught exception while trying to create CompletionResults:\n\n{e.ToString()}"); - - return new CompletionResults(); + completionItems[i] = CreateCompletionItem(result.CompletionMatches[i], replacedRange, i + 1); } + return completionItems; } - private static CompletionItem CreateCompletionItem( - CompletionDetails completionDetails, + internal static CompletionItem CreateCompletionItem( + CompletionResult result, BufferRange completionRange, int sortIndex) { - string detailString = null; - string documentationString = null; - string completionText = completionDetails.CompletionText; - InsertTextFormat insertTextFormat = InsertTextFormat.PlainText; + Validate.IsNotNull(nameof(result), result); - switch (completionDetails.CompletionType) + TextEdit textEdit = new() { - case CompletionType.Type: - case CompletionType.Namespace: - case CompletionType.ParameterValue: - case CompletionType.Method: - case CompletionType.Property: - detailString = completionDetails.ToolTipText; - break; - case CompletionType.Variable: - case CompletionType.ParameterName: - // Look for type encoded in the tooltip for parameters and variables. - // Display PowerShell type names in [] to be consistent with PowerShell syntax - // and how the debugger displays type names. - var matches = Regex.Matches(completionDetails.ToolTipText, @"^(\[.+\])"); - if ((matches.Count > 0) && (matches[0].Groups.Count > 1)) - { - detailString = matches[0].Groups[1].Value; - } - // The comparison operators (-eq, -not, -gt, etc) are unfortunately fall into ParameterName - // but they don't have a type associated to them. This allows those tooltips to show up. - else if (!string.IsNullOrEmpty(completionDetails.ToolTipText)) - { - detailString = completionDetails.ToolTipText; - } - break; - case CompletionType.Command: - // For Commands, let's extract the resolved command or the path for an exe - // from the ToolTipText - if there is any ToolTipText. - if (completionDetails.ToolTipText != null) + NewText = result.CompletionText, + Range = new Range + { + Start = new Position { - // Fix for #240 - notepad++.exe in tooltip text caused regex parser to throw. - string escapedToolTipText = Regex.Escape(completionDetails.ToolTipText); - - // Don't display ToolTipText if it is the same as the ListItemText. - // Reject command syntax ToolTipText - it's too much to display as a detailString. - if (!completionDetails.ListItemText.Equals( - completionDetails.ToolTipText, - StringComparison.OrdinalIgnoreCase) && - !Regex.IsMatch(completionDetails.ToolTipText, - @"^\s*" + escapedToolTipText + @"\s+\[")) - { - detailString = completionDetails.ToolTipText; - } - } - - break; - case CompletionType.Folder: - // Insert a final "tab stop" as identified by $0 in the snippet provided for completion. - // For folder paths, we take the path returned by PowerShell e.g. 'C:\Program Files' and insert - // the tab stop marker before the closing quote char e.g. 'C:\Program Files$0'. - // This causes the editing cursor to be placed *before* the final quote after completion, - // which makes subsequent path completions work. See this part of the LSP spec for details: - // https://microsoft.github.io/language-server-protocol/specification#textDocument_completion - - // Since we want to use a "tab stop" we need to escape a few things for Textmate to render properly. - if (EndsWithQuote(completionText)) + Line = completionRange.Start.Line - 1, + Character = completionRange.Start.Column - 1 + }, + End = new Position { - var sb = new StringBuilder(completionDetails.CompletionText) - .Replace(@"\", @"\\") - .Replace(@"}", @"\}") - .Replace(@"$", @"\$"); - completionText = sb.Insert(sb.Length - 1, "$0").ToString(); - insertTextFormat = InsertTextFormat.Snippet; + Line = completionRange.End.Line - 1, + Character = completionRange.End.Column - 1 } + } + }; - break; - } + // Some tooltips may have newlines or whitespace for unknown reasons. + string detail = result.ToolTip?.Trim(); - // Force the client to maintain the sort order in which the - // original completion results were returned. We just need to - // make sure the default order also be the lexicographical order - // which we do by prefixing the ListItemText with a leading 0's - // four digit index. - var sortText = $"{sortIndex:D4}{completionDetails.ListItemText}"; + CompletionItem item = new() + { + Label = result.ListItemText, + Detail = result.ListItemText.Equals(detail, StringComparison.CurrentCulture) + ? string.Empty : detail, // Don't repeat label. + // Retain PowerShell's sort order with the given index. + SortText = $"{sortIndex:D4}{result.ListItemText}", + FilterText = result.CompletionText, + TextEdit = textEdit // Used instead of InsertText. + }; - return new CompletionItem + return result.ResultType switch { - InsertText = completionText, - InsertTextFormat = insertTextFormat, - Label = completionDetails.ListItemText, - Kind = MapCompletionKind(completionDetails.CompletionType), - Detail = detailString, - Documentation = documentationString, - SortText = sortText, - FilterText = completionDetails.CompletionText, - TextEdit = new TextEdit - { - NewText = completionText, - Range = new Range + CompletionResultType.Text => item with { Kind = CompletionItemKind.Text }, + CompletionResultType.History => item with { Kind = CompletionItemKind.Reference }, + CompletionResultType.Command => item with { Kind = CompletionItemKind.Function }, + CompletionResultType.ProviderItem => item with { Kind = CompletionItemKind.File }, + CompletionResultType.ProviderContainer => TryBuildSnippet(result.CompletionText, out string snippet) + ? item with { - Start = new Position - { - Line = completionRange.Start.Line - 1, - Character = completionRange.Start.Column - 1 - }, - End = new Position - { - Line = completionRange.End.Line - 1, - Character = completionRange.End.Column - 1 - } + Kind = CompletionItemKind.Folder, + InsertTextFormat = InsertTextFormat.Snippet, + TextEdit = textEdit with { NewText = snippet } } - } + : item with { Kind = CompletionItemKind.Folder }, + CompletionResultType.Property => item with { Kind = CompletionItemKind.Property }, + CompletionResultType.Method => item with { Kind = CompletionItemKind.Method }, + CompletionResultType.ParameterName => TryExtractType(detail, out string type) + ? item with { Kind = CompletionItemKind.Variable, Detail = type } + // The comparison operators (-eq, -not, -gt, etc) unfortunately come across as + // ParameterName types but they don't have a type associated to them, so we can + // deduce it is an operator. + : item with { Kind = CompletionItemKind.Operator }, + CompletionResultType.ParameterValue => item with { Kind = CompletionItemKind.Value }, + CompletionResultType.Variable => TryExtractType(detail, out string type) + ? item with { Kind = CompletionItemKind.Variable, Detail = type } + : item with { Kind = CompletionItemKind.Variable }, + CompletionResultType.Namespace => item with { Kind = CompletionItemKind.Module }, + CompletionResultType.Type => detail.StartsWith("Class ", StringComparison.CurrentCulture) + // Custom classes come through as types but the PowerShell completion tooltip + // will start with "Class ", so we can more accurately display its icon. + ? item with { Kind = CompletionItemKind.Class } + : item with { Kind = CompletionItemKind.TypeParameter }, + CompletionResultType.Keyword or CompletionResultType.DynamicKeyword => + item with { Kind = CompletionItemKind.Keyword }, + _ => throw new ArgumentOutOfRangeException(nameof(result)) }; } - private static CompletionItemKind MapCompletionKind(CompletionType completionType) + private static readonly Regex s_typeRegex = new(@"^(\[.+\])", RegexOptions.Compiled); + + /// + /// Look for type encoded in the tooltip for parameters and variables. Display PowerShell + /// type names in [] to be consistent with PowerShell syntax and how the debugger displays + /// type names. + /// + /// + /// + /// Whether or not the type was found. + private static bool TryExtractType(string toolTipText, out string type) { - switch (completionType) + MatchCollection matches = s_typeRegex.Matches(toolTipText); + type = string.Empty; + if ((matches.Count > 0) && (matches[0].Groups.Count > 1)) { - case CompletionType.Command: - return CompletionItemKind.Function; - - case CompletionType.Property: - return CompletionItemKind.Property; - - case CompletionType.Method: - return CompletionItemKind.Method; - - case CompletionType.Variable: - case CompletionType.ParameterName: - return CompletionItemKind.Variable; - - case CompletionType.File: - return CompletionItemKind.File; - - case CompletionType.Folder: - return CompletionItemKind.Folder; - - default: - return CompletionItemKind.Text; + type = matches[0].Groups[1].Value; + return true; } + return false; } - private static bool EndsWithQuote(string text) + /// + /// Insert a final "tab stop" as identified by $0 in the snippet provided for completion. + /// For folder paths, we take the path returned by PowerShell e.g. 'C:\Program Files' and + /// insert the tab stop marker before the closing quote char e.g. 'C:\Program Files$0'. This + /// causes the editing cursor to be placed *before* the final quote after completion, which + /// makes subsequent path completions work. See this part of the LSP spec for details: + /// https://microsoft.github.io/language-server-protocol/specification#textDocument_completion + /// + /// + /// + /// + /// Whether or not the completion ended with a quote and so was a snippet. + /// + private static bool TryBuildSnippet(string completionText, out string snippet) { - if (string.IsNullOrEmpty(text)) + snippet = string.Empty; + if (!string.IsNullOrEmpty(completionText) + && completionText[completionText.Length - 1] is '"' or '\'') { - return false; + // Since we want to use a "tab stop" we need to escape a few things. + StringBuilder sb = new StringBuilder(completionText) + .Replace(@"\", @"\\") + .Replace(@"}", @"\}") + .Replace(@"$", @"\$"); + snippet = sb.Insert(sb.Length - 1, "$0").ToString(); + return true; } - - char lastChar = text[text.Length - 1]; - return lastChar == '"' || lastChar == '\''; + return false; } } } diff --git a/test/PowerShellEditorServices.Test.Shared/Completion/CompleteAttributeValue.cs b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteAttributeValue.cs index 63cf69911..317cc6277 100644 --- a/test/PowerShellEditorServices.Test.Shared/Completion/CompleteAttributeValue.cs +++ b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteAttributeValue.cs @@ -2,26 +2,74 @@ // Licensed under the MIT License. using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; namespace Microsoft.PowerShell.EditorServices.Test.Shared.Completion { - internal class CompleteAttributeValue + internal static class CompleteAttributeValue { - public static readonly ScriptRegion SourceDetails = - new ScriptRegion( - file: TestUtilities.NormalizePath("Completion/CompletionExamples.psm1"), - text: string.Empty, - startLineNumber: 16, - startColumnNumber: 38, - startOffset: 0, - endLineNumber: 0, - endColumnNumber: 0, - endOffset: 0); + public static readonly ScriptRegion SourceDetails = new( + file: TestUtilities.NormalizePath("Completion/CompletionExamples.psm1"), + text: string.Empty, + startLineNumber: 16, + startColumnNumber: 38, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); - public static readonly BufferRange ExpectedRange = - new BufferRange( - new BufferPosition(16, 33), - new BufferPosition(16, 38)); + public static readonly CompletionItem ExpectedCompletion1 = new() + { + Kind = CompletionItemKind.Property, + Detail = "System.Boolean ValueFromPipeline", + FilterText = "ValueFromPipeline", + Label = "ValueFromPipeline", + SortText = "0001ValueFromPipeline", + TextEdit = new TextEdit + { + NewText = "ValueFromPipeline", + Range = new Range + { + Start = new Position { Line = 15, Character = 32 }, + End = new Position { Line = 15, Character = 37 } + } + } + }; + + public static readonly CompletionItem ExpectedCompletion2 = new() + { + Kind = CompletionItemKind.Property, + Detail = "System.Boolean ValueFromPipelineByPropertyName", + FilterText = "ValueFromPipelineByPropertyName", + Label = "ValueFromPipelineByPropertyName", + SortText = "0002ValueFromPipelineByPropertyName", + TextEdit = new TextEdit + { + NewText = "ValueFromPipelineByPropertyName", + Range = new Range + { + Start = new Position { Line = 15, Character = 32 }, + End = new Position { Line = 15, Character = 37 } + } + } + }; + + public static readonly CompletionItem ExpectedCompletion3 = new() + { + Kind = CompletionItemKind.Property, + Detail = "System.Boolean ValueFromRemainingArguments", + FilterText = "ValueFromRemainingArguments", + Label = "ValueFromRemainingArguments", + SortText = "0003ValueFromRemainingArguments", + TextEdit = new TextEdit + { + NewText = "ValueFromRemainingArguments", + Range = new Range + { + Start = new Position { Line = 15, Character = 32 }, + End = new Position { Line = 15, Character = 37 } + } + } + }; } } - diff --git a/test/PowerShellEditorServices.Test.Shared/Completion/CompleteCommandFromModule.cs b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteCommandFromModule.cs index e7b07ddb3..184e1699a 100644 --- a/test/PowerShellEditorServices.Test.Shared/Completion/CompleteCommandFromModule.cs +++ b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteCommandFromModule.cs @@ -3,33 +3,41 @@ using System; using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; namespace Microsoft.PowerShell.EditorServices.Test.Shared.Completion { - internal class CompleteCommandFromModule + internal static class CompleteCommandFromModule { - private static readonly string[] s_getRandomParamSets = { - "Get-Random [[-Maximum] ] [-SetSeed ] [-Minimum ] []", - "Get-Random [-InputObject] [-SetSeed ] [-Count ] []" - }; + public static readonly string GetRandomDetail = + "Get-Random [[-Maximum] ] [-SetSeed ] [-Minimum ]"; - public static readonly ScriptRegion SourceDetails = - new ScriptRegion( - file: TestUtilities.NormalizePath("Completion/CompletionExamples.psm1"), - text: string.Empty, - startLineNumber: 13, - startColumnNumber: 8, - startOffset: 0, - endLineNumber: 0, - endColumnNumber: 0, - endOffset: 0); + public static readonly ScriptRegion SourceDetails = new( + file: TestUtilities.NormalizePath("Completion/CompletionExamples.psm1"), + text: string.Empty, + startLineNumber: 13, + startColumnNumber: 8, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); - public static readonly CompletionDetails ExpectedCompletion = - CompletionDetails.Create( - "Get-Random", - CompletionType.Command, - string.Join(Environment.NewLine + Environment.NewLine, s_getRandomParamSets), - listItemText: "Get-Random" - ); + public static readonly CompletionItem ExpectedCompletion = new() + { + Kind = CompletionItemKind.Function, + Detail = "", // OS-dependent, checked separately. + FilterText = "Get-Random", + Label = "Get-Random", + SortText = "0001Get-Random", + TextEdit = new TextEdit + { + NewText = "Get-Random", + Range = new Range + { + Start = new Position { Line = 12, Character = 0 }, + End = new Position { Line = 12, Character = 8 } + } + } + }; } } diff --git a/test/PowerShellEditorServices.Test.Shared/Completion/CompleteCommandInFile.cs b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteCommandInFile.cs index dbd06fc10..cf8bb6855 100644 --- a/test/PowerShellEditorServices.Test.Shared/Completion/CompleteCommandInFile.cs +++ b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteCommandInFile.cs @@ -2,26 +2,38 @@ // Licensed under the MIT License. using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; namespace Microsoft.PowerShell.EditorServices.Test.Shared.Completion { - internal class CompleteCommandInFile + internal static class CompleteCommandInFile { - public static readonly ScriptRegion SourceDetails = - new ScriptRegion( - file: TestUtilities.NormalizePath("Completion/CompletionExamples.psm1"), - text: string.Empty, - startLineNumber: 8, - startColumnNumber: 7, - startOffset: 0, - endLineNumber: 0, - endColumnNumber: 0, - endOffset: 0); + public static readonly ScriptRegion SourceDetails = new( + file: TestUtilities.NormalizePath("Completion/CompletionExamples.psm1"), + text: string.Empty, + startLineNumber: 8, + startColumnNumber: 7, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); - public static readonly CompletionDetails ExpectedCompletion = - CompletionDetails.Create( - "Get-Something", - CompletionType.Command, - "Get-Something"); + public static readonly CompletionItem ExpectedCompletion = new() + { + Kind = CompletionItemKind.Function, + Detail = "", + FilterText = "Get-Something", + Label = "Get-Something", + SortText = "0001Get-Something", + TextEdit = new TextEdit + { + NewText = "Get-Something", + Range = new Range + { + Start = new Position { Line = 7, Character = 0 }, + End = new Position { Line = 7, Character = 6 } + } + } + }; } } diff --git a/test/PowerShellEditorServices.Test.Shared/Completion/CompleteFilePath.cs b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteFilePath.cs index d821e3b6e..a3a899f7e 100644 --- a/test/PowerShellEditorServices.Test.Shared/Completion/CompleteFilePath.cs +++ b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteFilePath.cs @@ -2,26 +2,30 @@ // Licensed under the MIT License. using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; namespace Microsoft.PowerShell.EditorServices.Test.Shared.Completion { - internal class CompleteFilePath + internal static class CompleteFilePath { - public static readonly ScriptRegion SourceDetails = - new ScriptRegion( - file: TestUtilities.NormalizePath("Completion/CompletionExamples.psm1"), - text: string.Empty, - startLineNumber: 19, - startColumnNumber: 15, - startOffset: 0, - endLineNumber: 0, - endColumnNumber: 0, - endOffset: 0); + public static readonly ScriptRegion SourceDetails = new( + file: TestUtilities.NormalizePath("Completion/CompletionExamples.psm1"), + text: string.Empty, + startLineNumber: 19, + startColumnNumber: 15, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); - public static readonly BufferRange ExpectedRange = - new BufferRange( - new BufferPosition(19, 15), - new BufferPosition(19, 25)); + public static readonly TextEdit ExpectedEdit = new() + { + NewText = "", + Range = new Range + { + Start = new Position { Line = 18, Character = 14 }, + End = new Position { Line = 18, Character = 14 } + } + }; } } - diff --git a/test/PowerShellEditorServices.Test.Shared/Completion/CompleteNamespace.cs b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteNamespace.cs index d1356898e..66c111871 100644 --- a/test/PowerShellEditorServices.Test.Shared/Completion/CompleteNamespace.cs +++ b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteNamespace.cs @@ -2,27 +2,38 @@ // Licensed under the MIT License. using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; namespace Microsoft.PowerShell.EditorServices.Test.Shared.Completion { - internal class CompleteNamespace + internal static class CompleteNamespace { - public static readonly ScriptRegion SourceDetails = - new ScriptRegion( - file: TestUtilities.NormalizePath("Completion/CompletionExamples.psm1"), - text: string.Empty, - startLineNumber: 22, - startColumnNumber: 15, - startOffset: 0, - endLineNumber: 0, - endColumnNumber: 0, - endOffset: 0); + public static readonly ScriptRegion SourceDetails = new( + file: TestUtilities.NormalizePath("Completion/CompletionExamples.psm1"), + text: string.Empty, + startLineNumber: 22, + startColumnNumber: 15, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); - public static readonly CompletionDetails ExpectedCompletion = - CompletionDetails.Create( - "System.Collections", - CompletionType.Namespace, - "System.Collections" - ); + public static readonly CompletionItem ExpectedCompletion = new() + { + Kind = CompletionItemKind.Module, + Detail = "Namespace System.Collections", + FilterText = "System.Collections", + Label = "Collections", + SortText = "0001Collections", + TextEdit = new TextEdit + { + NewText = "System.Collections", + Range = new Range + { + Start = new Position { Line = 21, Character = 1 }, + End = new Position { Line = 21, Character = 15 } + } + } + }; } } diff --git a/test/PowerShellEditorServices.Test.Shared/Completion/CompleteTypeName.cs b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteTypeName.cs index 084411a76..867f067b4 100644 --- a/test/PowerShellEditorServices.Test.Shared/Completion/CompleteTypeName.cs +++ b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteTypeName.cs @@ -2,27 +2,38 @@ // Licensed under the MIT License. using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; namespace Microsoft.PowerShell.EditorServices.Test.Shared.Completion { - internal class CompleteTypeName + internal static class CompleteTypeName { - public static readonly ScriptRegion SourceDetails = - new ScriptRegion( - file: TestUtilities.NormalizePath("Completion/CompletionExamples.psm1"), - text: string.Empty, - startLineNumber: 21, - startColumnNumber: 25, - startOffset: 0, - endLineNumber: 0, - endColumnNumber: 0, - endOffset: 0); + public static readonly ScriptRegion SourceDetails = new( + file: TestUtilities.NormalizePath("Completion/CompletionExamples.psm1"), + text: string.Empty, + startLineNumber: 21, + startColumnNumber: 25, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); - public static readonly CompletionDetails ExpectedCompletion = - CompletionDetails.Create( - "System.Collections.ArrayList", - CompletionType.Type, - "System.Collections.ArrayList" - ); + public static readonly CompletionItem ExpectedCompletion = new() + { + Kind = CompletionItemKind.TypeParameter, + Detail = "System.Collections.ArrayList", + FilterText = "System.Collections.ArrayList", + Label = "ArrayList", + SortText = "0001ArrayList", + TextEdit = new TextEdit + { + NewText = "System.Collections.ArrayList", + Range = new Range + { + Start = new Position { Line = 20, Character = 1 }, + End = new Position { Line = 20, Character = 29 } + } + } + }; } } diff --git a/test/PowerShellEditorServices.Test.Shared/Completion/CompleteVariableInFile.cs b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteVariableInFile.cs index 2654cd31e..394962379 100644 --- a/test/PowerShellEditorServices.Test.Shared/Completion/CompleteVariableInFile.cs +++ b/test/PowerShellEditorServices.Test.Shared/Completion/CompleteVariableInFile.cs @@ -2,26 +2,38 @@ // Licensed under the MIT License. using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; namespace Microsoft.PowerShell.EditorServices.Test.Shared.Completion { - internal class CompleteVariableInFile + internal static class CompleteVariableInFile { - public static readonly ScriptRegion SourceDetails = - new ScriptRegion( - file: TestUtilities.NormalizePath("Completion/CompletionExamples.psm1"), - text: string.Empty, - startLineNumber: 10, - startColumnNumber: 9, - startOffset: 0, - endLineNumber: 0, - endColumnNumber: 0, - endOffset: 0); + public static readonly ScriptRegion SourceDetails = new( + file: TestUtilities.NormalizePath("Completion/CompletionExamples.psm1"), + text: string.Empty, + startLineNumber: 10, + startColumnNumber: 9, + startOffset: 0, + endLineNumber: 0, + endColumnNumber: 0, + endOffset: 0); - public static readonly CompletionDetails ExpectedCompletion = - CompletionDetails.Create( - "$testVar1", - CompletionType.Variable, - "testVar1"); + public static readonly CompletionItem ExpectedCompletion = new() + { + Kind = CompletionItemKind.Variable, + Detail = "", // Same as label, so not shown. + FilterText = "$testVar1", + Label = "testVar1", + SortText = "0001testVar1", + TextEdit = new TextEdit + { + NewText = "$testVar1", + Range = new Range + { + Start = new Position { Line = 9, Character = 0 }, + End = new Position { Line = 9, Character = 8 } + } + } + }; } } diff --git a/test/PowerShellEditorServices.Test/Language/CompletionHandlerTests.cs b/test/PowerShellEditorServices.Test/Language/CompletionHandlerTests.cs index b83689cb3..9681b7dc6 100644 --- a/test/PowerShellEditorServices.Test/Language/CompletionHandlerTests.cs +++ b/test/PowerShellEditorServices.Test/Language/CompletionHandlerTests.cs @@ -2,6 +2,9 @@ // Licensed under the MIT License. using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.PowerShell.EditorServices.Handlers; @@ -11,6 +14,7 @@ using Microsoft.PowerShell.EditorServices.Test.Shared; using Microsoft.PowerShell.EditorServices.Test.Shared.Completion; using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; using Xunit; namespace Microsoft.PowerShell.EditorServices.Test.Language @@ -31,130 +35,95 @@ public CompletionHandlerTests() public void Dispose() { - psesHost.StopAsync().GetAwaiter().GetResult(); + psesHost.StopAsync().Wait(); GC.SuppressFinalize(this); } private ScriptFile GetScriptFile(ScriptRegion scriptRegion) => workspace.GetFile(TestUtilities.GetSharedPath(scriptRegion.File)); - private async Task GetCompletionResults(ScriptRegion scriptRegion) + private Task> GetCompletionResultsAsync(ScriptRegion scriptRegion) { - return await completionHandler.GetCompletionsInFileAsync( + return completionHandler.GetCompletionsInFileAsync( GetScriptFile(scriptRegion), scriptRegion.StartLineNumber, - scriptRegion.StartColumnNumber).ConfigureAwait(true); + scriptRegion.StartColumnNumber, + CancellationToken.None); } [Fact] public async Task CompletesCommandInFile() { - CompletionResults completionResults = await GetCompletionResults(CompleteCommandInFile.SourceDetails).ConfigureAwait(true); - Assert.NotEmpty(completionResults.Completions); - Assert.Equal(CompleteCommandInFile.ExpectedCompletion, completionResults.Completions[0]); + IEnumerable results = await GetCompletionResultsAsync(CompleteCommandInFile.SourceDetails).ConfigureAwait(true); + CompletionItem actual = Assert.Single(results); + Assert.Equal(CompleteCommandInFile.ExpectedCompletion, actual); } [Fact] public async Task CompletesCommandFromModule() { - CompletionResults completionResults = await GetCompletionResults(CompleteCommandFromModule.SourceDetails).ConfigureAwait(true); - - Assert.NotEmpty(completionResults.Completions); - - Assert.Equal( - CompleteCommandFromModule.ExpectedCompletion.CompletionText, - completionResults.Completions[0].CompletionText); - - Assert.Equal( - CompleteCommandFromModule.ExpectedCompletion.CompletionType, - completionResults.Completions[0].CompletionType); - - Assert.NotNull(completionResults.Completions[0].ToolTipText); + IEnumerable results = await GetCompletionResultsAsync(CompleteCommandFromModule.SourceDetails).ConfigureAwait(true); + CompletionItem actual = Assert.Single(results); + // NOTE: The tooltip varies across PowerShell and OS versions, so we ignore it. + Assert.Equal(CompleteCommandFromModule.ExpectedCompletion, actual with { Detail = "" }); + Assert.StartsWith(CompleteCommandFromModule.GetRandomDetail, actual.Detail); } - [SkippableFact] + [Fact] public async Task CompletesTypeName() { - Skip.If( - !VersionUtils.IsNetCore, - "In Windows PowerShell the CommandCompletion fails in the test harness, but works manually."); - - CompletionResults completionResults = await GetCompletionResults(CompleteTypeName.SourceDetails).ConfigureAwait(true); - - Assert.NotEmpty(completionResults.Completions); - - Assert.Equal( - CompleteTypeName.ExpectedCompletion.CompletionText, - completionResults.Completions[0].CompletionText); - - Assert.Equal( - CompleteTypeName.ExpectedCompletion.CompletionType, - completionResults.Completions[0].CompletionType); - - Assert.NotNull(completionResults.Completions[0].ToolTipText); + IEnumerable results = await GetCompletionResultsAsync(CompleteTypeName.SourceDetails).ConfigureAwait(true); + CompletionItem actual = Assert.Single(results); + if (VersionUtils.IsNetCore) + { + Assert.Equal(CompleteTypeName.ExpectedCompletion, actual); + } + else + { + // Windows PowerShell shows ArrayList as a Class. + Assert.Equal(CompleteTypeName.ExpectedCompletion with + { + Kind = CompletionItemKind.Class, + Detail = "Class System.Collections.ArrayList" + }, actual); + } } [Trait("Category", "Completions")] - [SkippableFact] + [Fact] public async Task CompletesNamespace() { - Skip.If( - !VersionUtils.IsNetCore, - "In Windows PowerShell the CommandCompletion fails in the test harness, but works manually."); - - CompletionResults completionResults = await GetCompletionResults(CompleteNamespace.SourceDetails).ConfigureAwait(true); - - Assert.NotEmpty(completionResults.Completions); - - Assert.Equal( - CompleteNamespace.ExpectedCompletion.CompletionText, - completionResults.Completions[0].CompletionText); - - Assert.Equal( - CompleteNamespace.ExpectedCompletion.CompletionType, - completionResults.Completions[0].CompletionType); - - Assert.NotNull(completionResults.Completions[0].ToolTipText); + IEnumerable results = await GetCompletionResultsAsync(CompleteNamespace.SourceDetails).ConfigureAwait(true); + CompletionItem actual = Assert.Single(results); + Assert.Equal(CompleteNamespace.ExpectedCompletion, actual); } [Fact] public async Task CompletesVariableInFile() { - CompletionResults completionResults = await GetCompletionResults(CompleteVariableInFile.SourceDetails).ConfigureAwait(true); - - Assert.Single(completionResults.Completions); - - Assert.Equal( - CompleteVariableInFile.ExpectedCompletion, - completionResults.Completions[0]); + IEnumerable results = await GetCompletionResultsAsync(CompleteVariableInFile.SourceDetails).ConfigureAwait(true); + CompletionItem actual = Assert.Single(results); + Assert.Equal(CompleteVariableInFile.ExpectedCompletion, actual); } [Fact] public async Task CompletesAttributeValue() { - CompletionResults completionResults = await GetCompletionResults(CompleteAttributeValue.SourceDetails).ConfigureAwait(true); - - Assert.NotEmpty(completionResults.Completions); - - Assert.Equal( - CompleteAttributeValue.ExpectedRange, - completionResults.ReplacedRange); + IEnumerable results = await GetCompletionResultsAsync(CompleteAttributeValue.SourceDetails).ConfigureAwait(true); + Assert.Collection(results, + actual => Assert.Equal(actual, CompleteAttributeValue.ExpectedCompletion1), + acutal => Assert.Equal(acutal, CompleteAttributeValue.ExpectedCompletion2), + actual => Assert.Equal(actual, CompleteAttributeValue.ExpectedCompletion3)); } [Fact] public async Task CompletesFilePath() { - CompletionResults completionResults = await GetCompletionResults(CompleteFilePath.SourceDetails).ConfigureAwait(true); - - Assert.NotEmpty(completionResults.Completions); - - // TODO: Since this is a path completion, this test will need to be - // platform specific. Probably something like: - // - Windows: C:\Program - // - macOS: /User - // - Linux: /hom - //Assert.Equal( - // CompleteFilePath.ExpectedRange, - // completionResults.ReplacedRange); + IEnumerable results = await GetCompletionResultsAsync(CompleteFilePath.SourceDetails).ConfigureAwait(true); + Assert.NotEmpty(results); + CompletionItem actual = results.First(); + // Paths are system dependent so we ignore the text and just check the type and range. + Assert.Equal(actual.TextEdit.TextEdit with { NewText = "" }, CompleteFilePath.ExpectedEdit); + Assert.All(results, r => Assert.True(r.Kind is CompletionItemKind.File or CompletionItemKind.Folder)); } } }