From 6114c38808439235d5390ed5965e4a1893448b3e Mon Sep 17 00:00:00 2001 From: Patrick Meinecke Date: Fri, 20 May 2022 13:44:56 -0400 Subject: [PATCH 1/5] Update intellisense logging Now shows word to complete and match count instead of the literal type name for `SMA.CommandCompletion`. --- .../Services/Symbols/Vistors/AstOperations.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/PowerShellEditorServices/Services/Symbols/Vistors/AstOperations.cs b/src/PowerShellEditorServices/Services/Symbols/Vistors/AstOperations.cs index cec9223b2..2a3e1256a 100644 --- a/src/PowerShellEditorServices/Services/Symbols/Vistors/AstOperations.cs +++ b/src/PowerShellEditorServices/Services/Symbols/Vistors/AstOperations.cs @@ -127,7 +127,15 @@ await executionService.ExecuteDelegateAsync( .ConfigureAwait(false); stopwatch.Stop(); - logger.LogTrace($"IntelliSense completed in {stopwatch.ElapsedMilliseconds}ms: {commandCompletion}"); + logger.LogTrace( + "IntelliSense completed in {elapsed}ms - WordToComplete: \"{word}\" MatchCount: {count}", + stopwatch.ElapsedMilliseconds, + commandCompletion.ReplacementLength > 0 + ? scriptAst.Extent.StartScriptPosition.GetFullScript()?.Substring( + commandCompletion.ReplacementIndex, + commandCompletion.ReplacementLength) + : null, + commandCompletion.CompletionMatches.Count); return commandCompletion; } From d3d5aea61ff49731f3889543703f257e00a86832 Mon Sep 17 00:00:00 2001 From: Patrick Meinecke Date: Fri, 20 May 2022 13:55:23 -0400 Subject: [PATCH 2/5] Fix completion for types when using namespaces Type completion when utilizing `using namespace` statements was really hit or miss due to replacement text not matching the filter text. Also add support for completing and retaining `$PSScriptRoot`. Requires a change in PowerShell that is not yet merged to work, but does not break without the changes needed. --- .../Handlers/CompletionHandler.cs | 137 +++++++++++++++++- 1 file changed, 129 insertions(+), 8 deletions(-) diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/CompletionHandler.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/CompletionHandler.cs index fad0fa696..7d77088d0 100644 --- a/src/PowerShellEditorServices/Services/TextDocument/Handlers/CompletionHandler.cs +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/CompletionHandler.cs @@ -30,6 +30,7 @@ internal class PsesCompletionHandler : CompletionHandlerBase private readonly IRunspaceContext _runspaceContext; private readonly IInternalPowerShellExecutionService _executionService; private readonly WorkspaceService _workspaceService; + private CompletionCapability _completionCapability; public PsesCompletionHandler( ILoggerFactory factory, @@ -43,13 +44,21 @@ public PsesCompletionHandler( _workspaceService = workspaceService; } - protected override CompletionRegistrationOptions CreateRegistrationOptions(CompletionCapability capability, ClientCapabilities clientCapabilities) => new() + protected override CompletionRegistrationOptions CreateRegistrationOptions(CompletionCapability capability, ClientCapabilities clientCapabilities) + { + _completionCapability = capability; + return new CompletionRegistrationOptions() { // TODO: What do we do with the arguments? DocumentSelector = LspUtils.PowerShellDocumentSelector, ResolveProvider = true, - TriggerCharacters = new[] { ".", "-", ":", "\\", "$", " " } + TriggerCharacters = new[] { ".", "-", ":", "\\", "$", " " }, }; + } + + public bool SupportsSnippets => _completionCapability?.CompletionItem?.SnippetSupport is true; + + public bool SupportsCommitCharacters => _completionCapability?.CompletionItem?.CommitCharactersSupport is true; public override async Task Handle(CompletionParams request, CancellationToken cancellationToken) { @@ -143,6 +152,14 @@ internal async Task GetCompletionsInFileAsync( result.ReplacementIndex, result.ReplacementIndex + result.ReplacementLength); + string textToBeReplaced = string.Empty; + if (result.ReplacementLength is not 0) + { + textToBeReplaced = scriptFile.Contents.Substring( + result.ReplacementIndex, + result.ReplacementLength); + } + bool isIncomplete = false; // Create OmniSharp CompletionItems from PowerShell CompletionResults. We use a for loop // because the index is used for sorting. @@ -159,16 +176,25 @@ internal async Task GetCompletionsInFileAsync( isIncomplete = true; } - completionItems[i] = CreateCompletionItem(result.CompletionMatches[i], replacedRange, i + 1); + completionItems[i] = CreateCompletionItem( + result.CompletionMatches[i], + replacedRange, + i + 1, + textToBeReplaced, + scriptFile); + _logger.LogTrace("Created completion item: " + completionItems[i] + " with " + completionItems[i].TextEdit); } + return new CompletionResults(isIncomplete, completionItems); } - internal static CompletionItem CreateCompletionItem( + internal CompletionItem CreateCompletionItem( CompletionResult result, BufferRange completionRange, - int sortIndex) + int sortIndex, + string textToBeReplaced, + ScriptFile scriptFile) { Validate.IsNotNull(nameof(result), result); @@ -200,7 +226,9 @@ internal static CompletionItem CreateCompletionItem( ? string.Empty : detail, // Don't repeat label. // Retain PowerShell's sort order with the given index. SortText = $"{sortIndex:D4}{result.ListItemText}", - FilterText = result.CompletionText, + FilterText = result.ResultType is CompletionResultType.Type + ? GetTypeFilterText(textToBeReplaced, result.CompletionText) + : result.CompletionText, // Used instead of Label when TextEdit is unsupported InsertText = result.CompletionText, // Used instead of InsertText when possible @@ -212,8 +240,8 @@ internal static CompletionItem CreateCompletionItem( 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) + CompletionResultType.ProviderItem or CompletionResultType.ProviderContainer + => CreateProviderItemCompletion(item, result, scriptFile, textToBeReplaced), ? item with { Kind = CompletionItemKind.Folder, @@ -245,6 +273,99 @@ internal static CompletionItem CreateCompletionItem( }; } + private CompletionItem CreateProviderItemCompletion( + CompletionItem item, + CompletionResult result, + ScriptFile scriptFile, + string textToBeReplaced) + { + // TODO: Work out a way to do this generally instead of special casing PSScriptRoot. + // + // This code relies on PowerShell/PowerShell#17376. Until that makes it into a release + // no matches will be returned anyway. + const string PSScriptRootVariable = "$PSScriptRoot"; + string completionText = result.CompletionText; + if (textToBeReplaced.IndexOf(PSScriptRootVariable, StringComparison.OrdinalIgnoreCase) is int variableIndex and not -1 + && System.IO.Path.GetDirectoryName(scriptFile.FilePath) is string scriptFolder and not "" + && completionText.IndexOf(scriptFolder, StringComparison.OrdinalIgnoreCase) is int pathIndex and not -1 + && !scriptFile.IsInMemory) + { + completionText = completionText + .Remove(pathIndex, scriptFolder.Length) + .Insert(variableIndex, textToBeReplaced.Substring(variableIndex, PSScriptRootVariable.Length)); + } + + InsertTextFormat insertFormat; + TextEdit edit; + CompletionItemKind itemKind; + if (result.ResultType is CompletionResultType.ProviderContainer + && SupportsSnippets + && TryBuildSnippet(completionText, out string snippet)) + { + edit = item.TextEdit.TextEdit with { NewText = snippet }; + insertFormat = InsertTextFormat.Snippet; + itemKind = CompletionItemKind.Folder; + } + else + { + edit = item.TextEdit.TextEdit with { NewText = completionText }; + insertFormat = default; + itemKind = CompletionItemKind.File; + } + + return item with + { + Kind = itemKind, + TextEdit = edit, + InsertText = completionText, + FilterText = completionText, + InsertTextFormat = insertFormat, + CommitCharacters = MaybeAddCommitCharacters("\\", "/", "'", "\""), + }; + } + + private Container MaybeAddCommitCharacters(params string[] characters) + => SupportsCommitCharacters ? new Container(characters) : null; + + private static string GetTypeFilterText(string textToBeReplaced, string completionText) + { + // FilterText for a type name with using statements gets a little complicated. Consider + // this script: + // + // using namespace System.Management.Automation + // [System.Management.Automation.Tracing.] + // + // Since we're emitting an edit that replaces `System.Management.Automation.Tracing.` with + // `Tracing.NullWriter` (for example), we can't use CompletionText as the filter. If we + // do, we won't find any matches because it's trying to filter `Tracing.NullWriter` with + // `System.Management.Automation.Tracing.` which is too different. So we prepend each + // namespace that exists in our original text but does not in our completion text. + if (!textToBeReplaced.Contains('.')) + { + return completionText; + } + + string[] oldTypeParts = textToBeReplaced.Split('.'); + string[] newTypeParts = completionText.Split('.'); + + StringBuilder newFilterText = new(completionText); + + int newPartsIndex = newTypeParts.Length - 2; + for (int i = oldTypeParts.Length - 2; i >= 0; i--) + { + if (newPartsIndex is >= 0 + && newTypeParts[newPartsIndex].Equals(oldTypeParts[i], StringComparison.OrdinalIgnoreCase)) + { + newPartsIndex--; + continue; + } + + newFilterText.Insert(0, '.').Insert(0, oldTypeParts[i]); + } + + return newFilterText.ToString(); + } + private static readonly Regex s_typeRegex = new(@"^(\[.+\])", RegexOptions.Compiled); /// From 4ae9a07ed69d1bc2706bd8e9c341d994655ecdf4 Mon Sep 17 00:00:00 2001 From: Patrick Meinecke Date: Fri, 20 May 2022 14:05:36 -0400 Subject: [PATCH 3/5] Parse tooltips for members to create markdown view --- .../Handlers/CompletionHandler.cs | 96 +++- .../Utility/Extensions.cs | 17 + .../Utility/FormatUtils.cs | 439 ++++++++++++++++++ 3 files changed, 536 insertions(+), 16 deletions(-) create mode 100644 src/PowerShellEditorServices/Utility/FormatUtils.cs diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/CompletionHandler.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/CompletionHandler.cs index 7d77088d0..323bd08f9 100644 --- a/src/PowerShellEditorServices/Services/TextDocument/Handlers/CompletionHandler.cs +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/CompletionHandler.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Management.Automation; using System.Text; using System.Text.RegularExpressions; @@ -48,18 +49,20 @@ protected override CompletionRegistrationOptions CreateRegistrationOptions(Compl { _completionCapability = capability; return new CompletionRegistrationOptions() - { - // TODO: What do we do with the arguments? - DocumentSelector = LspUtils.PowerShellDocumentSelector, - ResolveProvider = true, + { + // TODO: What do we do with the arguments? + DocumentSelector = LspUtils.PowerShellDocumentSelector, + ResolveProvider = true, TriggerCharacters = new[] { ".", "-", ":", "\\", "$", " " }, - }; + }; } public bool SupportsSnippets => _completionCapability?.CompletionItem?.SnippetSupport is true; public bool SupportsCommitCharacters => _completionCapability?.CompletionItem?.CommitCharactersSupport is true; + public bool SupportsMarkdown => _completionCapability?.CompletionItem?.DocumentationFormat?.Contains(MarkupKind.Markdown) is true; + public override async Task Handle(CompletionParams request, CancellationToken cancellationToken) { int cursorLine = request.Position.Line + 1; @@ -81,6 +84,61 @@ public override async Task Handle(CompletionParams request, Canc // Handler for "completionItem/resolve". In VSCode this is fired when a completion item is highlighted in the completion list. public override async Task Handle(CompletionItem request, CancellationToken cancellationToken) { + if (SupportsMarkdown) + { + if (request.Kind is CompletionItemKind.Method) + { + string documentation = FormatUtils.GetMethodDocumentation( + _logger, + request.Data.ToString(), + out MarkupKind kind); + + return request with + { + Documentation = new MarkupContent() + { + Kind = kind, + Value = documentation, + }, + }; + } + + if (request.Kind is CompletionItemKind.Class or CompletionItemKind.TypeParameter or CompletionItemKind.Enum) + { + string documentation = FormatUtils.GetTypeDocumentation( + _logger, + request.Detail, + out MarkupKind kind); + + return request with + { + Detail = null, + Documentation = new MarkupContent() + { + Kind = kind, + Value = documentation, + }, + }; + } + + if (request.Kind is CompletionItemKind.EnumMember or CompletionItemKind.Property or CompletionItemKind.Field) + { + string documentation = FormatUtils.GetPropertyDocumentation( + _logger, + request.Data.ToString(), + out MarkupKind kind); + + return request with + { + Documentation = new MarkupContent() + { + Kind = kind, + Value = documentation, + }, + }; + } + } + // 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. @@ -242,15 +300,19 @@ internal CompletionItem CreateCompletionItem( CompletionResultType.Command => item with { Kind = CompletionItemKind.Function }, CompletionResultType.ProviderItem or CompletionResultType.ProviderContainer => CreateProviderItemCompletion(item, result, scriptFile, textToBeReplaced), - ? item with - { - 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.Property => item with + { + Kind = CompletionItemKind.Property, + Detail = SupportsMarkdown ? null : detail, + Data = SupportsMarkdown ? detail : null, + CommitCharacters = MaybeAddCommitCharacters("."), + }, + CompletionResultType.Method => item with + { + Kind = CompletionItemKind.Method, + Data = item.Detail, + Detail = SupportsMarkdown ? null : item.Detail, + }, 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 @@ -265,8 +327,10 @@ CompletionResultType.ProviderItem or CompletionResultType.ProviderContainer 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 }, + ? item with { Kind = CompletionItemKind.Class, Detail = detail.Substring("Class ".Length) } + : detail.StartsWith("Enum ", StringComparison.CurrentCulture) + ? item with { Kind = CompletionItemKind.Enum, Detail = detail.Substring("Enum ".Length) } + : item with { Kind = CompletionItemKind.TypeParameter }, CompletionResultType.Keyword or CompletionResultType.DynamicKeyword => item with { Kind = CompletionItemKind.Keyword }, _ => throw new ArgumentOutOfRangeException(nameof(result)) diff --git a/src/PowerShellEditorServices/Utility/Extensions.cs b/src/PowerShellEditorServices/Utility/Extensions.cs index f2e248514..c280f1b14 100644 --- a/src/PowerShellEditorServices/Utility/Extensions.cs +++ b/src/PowerShellEditorServices/Utility/Extensions.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Collections.Generic; using System.Management.Automation.Language; +using System.Text; namespace Microsoft.PowerShell.EditorServices.Utility { @@ -144,5 +145,21 @@ public static bool Contains(this IScriptExtent scriptExtent, int line, int colum return true; } + + /// + /// Same as but never CRLF. Use this when building + /// formatting for clients that may not render CRLF correctly. + /// + /// + public static StringBuilder AppendLineLF(this StringBuilder self) => self.Append('\n'); + + /// + /// Same as but never CRLF. Use this when building + /// formatting for clients that may not render CRLF correctly. + /// + /// + /// + public static StringBuilder AppendLineLF(this StringBuilder self, string value) + => self.Append(value).Append('\n'); } } diff --git a/src/PowerShellEditorServices/Utility/FormatUtils.cs b/src/PowerShellEditorServices/Utility/FormatUtils.cs new file mode 100644 index 000000000..9863d7b78 --- /dev/null +++ b/src/PowerShellEditorServices/Utility/FormatUtils.cs @@ -0,0 +1,439 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Logging; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Utility +{ + internal static class FormatUtils + { + private const char GenericOpen = '['; + + private const char GenericClose = ']'; + + private const string Static = "static "; + + /// + /// Space, new line, carriage return and tab. + /// + private static readonly ReadOnlyMemory s_whiteSpace = new[] { '\n', '\r', '\t', ' ' }; + + /// + /// A period, comma, and both open and square brackets. + /// + private static readonly ReadOnlyMemory s_commaSquareBracketOrDot = new[] { '.', ',', '[', ']' }; + + internal static string? GetTypeDocumentation(ILogger logger, string? toolTip, out MarkupKind kind) + { + if (toolTip is null) + { + kind = default; + return null; + } + + try + { + kind = MarkupKind.Markdown; + StringBuilder text = new(); + HashSet? usingNamespaces = null; + + text.Append('['); + ProcessType(toolTip.AsSpan(), text, ref usingNamespaces); + text.AppendLineLF("]").Append("```"); + return PrependUsingStatements(text, usingNamespaces) + .Insert(0, "```powershell\n") + .ToString(); + } + catch (Exception e) + { + logger.LogHandledException($"Failed to type property tool tip \"{toolTip}\".", e); + kind = MarkupKind.PlainText; + return toolTip.Replace("\r\n", "\n\n"); + } + } + + internal static string? GetPropertyDocumentation(ILogger logger, string? toolTip, out MarkupKind kind) + { + if (toolTip is null) + { + kind = default; + return null; + } + + try + { + return GetPropertyDocumentation( + StripAssemblyQualifications(toolTip).AsSpan(), + out kind); + } + catch (Exception e) + { + logger.LogHandledException($"Failed to parse property tool tip \"{toolTip}\".", e); + kind = MarkupKind.PlainText; + return toolTip.Replace("\r\n", "\n\n"); + } + } + + internal static string? GetMethodDocumentation(ILogger logger, string? toolTip, out MarkupKind kind) + { + if (toolTip is null) + { + kind = default; + return null; + } + + try + { + return GetMethodDocumentation( + StripAssemblyQualifications(toolTip).AsSpan(), + out kind); + } + catch (Exception e) + { + logger.LogHandledException($"Failed to parse method tool tip \"{toolTip}\".", e); + kind = MarkupKind.PlainText; + return toolTip.Replace("\r\n", "\n\n"); + } + } + + private static string GetPropertyDocumentation(ReadOnlySpan toolTip, out MarkupKind kind) + { + kind = MarkupKind.Markdown; + ReadOnlySpan originalToolTip = toolTip; + HashSet? usingNamespaces = null; + StringBuilder text = new(); + + if (toolTip.IndexOf(Static.AsSpan(), StringComparison.Ordinal) is 0) + { + text.Append(Static); + toolTip = toolTip.Slice(Static.Length); + } + + int endOfTypeIndex = toolTip.IndexOf(' '); + + // Abort trying to process if we come across something we don't understand. + if (endOfTypeIndex is -1) + { + kind = MarkupKind.PlainText; + // Replace CRLF with LF as some clients like vim render the CR as a printable + // character. Also double up on new lines as VSCode ignores single new lines. + return originalToolTip.ToString().Replace("\r\n", "\n\n"); + } + + text.Append('['); + ProcessType(toolTip.Slice(0, endOfTypeIndex), text, ref usingNamespaces); + text.Append("] "); + + toolTip = toolTip.Slice(endOfTypeIndex + 1); + + string nameAndAccessors = toolTip.ToString(); + + // Turn `{get;set;}` into `{ get; set; }` because it looks pretty. Also with namespaces + // separated we don't need to worry as much about space. This only needs to be done + // sometimes as for some reason instance properties already have spaces. + if (toolTip.IndexOf("{ ".AsSpan()) is -1) + { + nameAndAccessors = nameAndAccessors + .Replace("get;", " get;") + .Replace("set;", " set;") + .Replace("}", " }"); + } + + // Add a $ so it looks like a PowerShell class property. Though we don't have the accessor + // syntax used here, it still parses fine in the markdown. + text.Append('$') + .AppendLineLF(nameAndAccessors) + .Append("```"); + + return PrependUsingStatements(text, usingNamespaces) + .Insert(0, "```powershell\n") + .ToString(); + } + + private static string GetMethodDocumentation(ReadOnlySpan toolTip, out MarkupKind kind) + { + kind = MarkupKind.Markdown; + StringBuilder text = new(); + HashSet? usingNamespaces = null; + while (true) + { + toolTip = toolTip.TrimStart(s_whiteSpace.Span); + toolTip = ProcessMethod(toolTip, text, ref usingNamespaces); + if (toolTip.IsEmpty) + { + return PrependUsingStatements(text.AppendLineLF().AppendLineLF("```"), usingNamespaces) + .Insert(0, "```powershell\n") + .ToString(); + } + + text.AppendLineLF().AppendLineLF(); + } + } + + private static StringBuilder PrependUsingStatements(StringBuilder text, HashSet? usingNamespaces) + { + if (usingNamespaces is null or { Count: 0 } || (usingNamespaces.Count is 1 && usingNamespaces.First() is "System")) + { + return text; + } + + string[] namespaces = usingNamespaces.ToArray(); + Array.Sort(namespaces); + text.Insert(0, "\n"); + for (int i = namespaces.Length - 1; i >= 0; i--) + { + if (namespaces[i] is "System") + { + continue; + } + + text.Insert(0, "using namespace " + namespaces[i] + "\n"); + } + + return text; + } + + private static string StripAssemblyQualifications(string value) + { + // Sometimes tooltip will have fully assembly qualified names, typically when a pointer + // is involved. This strips out the assembly qualification. + return Regex.Replace( + value, + ", [a-zA-Z.]+, Version=[0-9.]+, Culture=[a-zA-Z]*, PublicKeyToken=[0-9a-fnul]* ", + " "); + } + + private static ReadOnlySpan ProcessMethod( + ReadOnlySpan toolTip, + StringBuilder text, + ref HashSet? usingNamespaces) + { + if (toolTip.IsEmpty) + { + return default; + } + + if (toolTip.IndexOf(Static.AsSpan(), StringComparison.Ordinal) is 0) + { + text.Append(Static); + toolTip = toolTip.Slice(Static.Length); + } + + int endReturnTypeIndex = toolTip.IndexOf(' '); + if (endReturnTypeIndex is -1) + { + text.Append(toolTip.ToString()); + return default; + } + + text.Append('['); + ProcessType(toolTip.Slice(0, endReturnTypeIndex), text, ref usingNamespaces); + toolTip = toolTip.Slice(endReturnTypeIndex + 1); + text.Append("] "); + int endMethodNameIndex = toolTip.IndexOf('('); + if (endMethodNameIndex is -1) + { + text.Append(toolTip.ToString()); + return default; + } + + text.Append(toolTip.Slice(0, endMethodNameIndex + 1).ToString()); + toolTip = toolTip.Slice(endMethodNameIndex + 1); + if (!toolTip.IsEmpty && toolTip[0] is ')') + { + text.Append(')'); + return toolTip.Slice(1); + } + + const string indent = " "; + text.AppendLineLF().Append(indent); + while (true) + { + // ref/out/in parameters come through the tooltip with the literal text `[ref] ` + // prepended to the type. Unsure why it's the only instance where the square + // brackets are included, but without special handling it breaks the parser. + const string RefText = "[ref] "; + if (toolTip.IndexOf(RefText.AsSpan()) is 0) + { + text.Append(RefText); + toolTip = toolTip.Slice(RefText.Length); + } + + // PowerShell doesn't have a params keyword, though the binder does honor params + // methods. For lack of a better option that parses well, we'll use the decoration + // that is added in C# when the keyword is used. + const string ParamsText = "Params "; + if (toolTip.IndexOf(ParamsText.AsSpan()) is 0) + { + text.Append("[ParamArray()] "); + toolTip = toolTip.Slice(ParamsText.Length); + } + + // Generics aren't displayed with spaces in the tooltip so this is a safe end of + // type marker. + int spaceIndex = toolTip.IndexOf(' '); + if (spaceIndex is -1) + { + text.Append(toolTip.ToString()); + return default; + } + + text.Append('['); + ProcessType(toolTip.Slice(0, spaceIndex), text, ref usingNamespaces); + text.Append("] "); + toolTip = toolTip.Slice(spaceIndex + 1); + + // TODO: Add extra handling if PowerShell/PowerShell#13799 gets merged. This code + // should mostly handle it fine but a default string value with `,` or `)` would + // break. That's not the worst if it happens, but extra parsing to handle that might + // be nice. + int paramNameEndIndex = toolTip.IndexOfAny(',', ')'); + if (paramNameEndIndex is -1) + { + text.Append(toolTip.ToString()); + return default; + } + + text.Append('$').Append(toolTip.Slice(0, paramNameEndIndex).ToString()); + toolTip = toolTip.Slice(paramNameEndIndex); + if (toolTip[0] is ')') + { + text.Append(')'); + return toolTip.Slice(1); + } + + // Skip comma *and* space. + toolTip = toolTip.Slice(2); + + text.AppendLineLF(",") + .Append(indent); + } + } + + private static void ProcessType(ReadOnlySpan type, StringBuilder text, ref HashSet? usingNamespaces) + { + if (type.IndexOf('[') is int bracketIndex and not -1) + { + ProcessType(type.Slice(0, bracketIndex), text, ref usingNamespaces); + type = type.Slice(bracketIndex); + + // This is an array rather than a generic type. + if (type.IndexOfAny(',', ']') is 1) + { + text.Append(type.ToString()); + return; + } + + text.Append(GenericOpen); + type = type.Slice(1); + while (true) + { + if (type.IndexOfAny(',', '[', ']') is int nextDelimIndex and not -1) + { + ProcessType(type.Slice(0, nextDelimIndex), text, ref usingNamespaces); + type = type.Slice(nextDelimIndex); + + if (type[0] is '[' && type.IndexOfAny(',', ']') is 1) + { + type = ProcessArray(type, text); + continue; + } + + char delimChar = type[0] switch + { + '[' => GenericOpen, + ']' => GenericClose, + char c => c, + }; + + text.Append(delimChar); + type = type.Slice(1); + continue; + } + + if (!type.IsEmpty) + { + text.Append(type.ToString()); + } + + return; + } + } + + ReadOnlySpan namespaceStart = default; + int lastDot = 0; + while (true) + { + if (type.IndexOfAny(s_commaSquareBracketOrDot.Span) is int nextDelimIndex and not -1) + { + // Strip namespaces. + if (type[nextDelimIndex] is '.') + { + if (namespaceStart.IsEmpty) + { + namespaceStart = type; + } + + lastDot += nextDelimIndex + 1; + type = type.Slice(nextDelimIndex + 1); + continue; + } + + if (!namespaceStart.IsEmpty) + { + usingNamespaces ??= new(StringComparer.OrdinalIgnoreCase); + usingNamespaces.Add(namespaceStart.Slice(0, lastDot - 1).ToString()); + } + + text.Append(type.Slice(0, nextDelimIndex).ToString()); + return; + } + + if (!namespaceStart.IsEmpty) + { + usingNamespaces ??= new(StringComparer.OrdinalIgnoreCase); + usingNamespaces.Add(namespaceStart.Slice(0, lastDot - 1).ToString()); + } + + text.Append(type.ToString()); + return; + } + } + + private static ReadOnlySpan ProcessArray(ReadOnlySpan type, StringBuilder text) + { + for (int i = 0; i < type.Length; i++) + { + char c = type[i]; + if (c is ']') + { + text.Append(']'); + // Check for types like int[][] + if (type.Length - 1 > i && type[i + 1] is '[') + { + text.Append('['); + i++; + continue; + } + + return type.Slice(i + 1); + } + + text.Append(c); + } + + Debug.Fail("Span passed to ProcessArray should have contained a ']' char."); + return default; + } + } +} From ffccc899eb61e2b1464f2c79cc6542c0e333cf76 Mon Sep 17 00:00:00 2001 From: Patrick Meinecke Date: Fri, 20 May 2022 14:31:17 -0400 Subject: [PATCH 4/5] Fix attribute value test --- .../Language/CompletionHandlerTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/PowerShellEditorServices.Test/Language/CompletionHandlerTests.cs b/test/PowerShellEditorServices.Test/Language/CompletionHandlerTests.cs index d8094799c..125a8349e 100644 --- a/test/PowerShellEditorServices.Test/Language/CompletionHandlerTests.cs +++ b/test/PowerShellEditorServices.Test/Language/CompletionHandlerTests.cs @@ -110,9 +110,9 @@ public async Task CompletesAttributeValue() { (_, IEnumerable results) = await GetCompletionResultsAsync(CompleteAttributeValue.SourceDetails).ConfigureAwait(true); Assert.Collection(results.OrderBy(c => c.SortText), - actual => Assert.Equal(actual, CompleteAttributeValue.ExpectedCompletion1), - actual => Assert.Equal(actual, CompleteAttributeValue.ExpectedCompletion2), - actual => Assert.Equal(actual, CompleteAttributeValue.ExpectedCompletion3)); + actual => Assert.Equal(actual with { Data = null }, CompleteAttributeValue.ExpectedCompletion1), + actual => Assert.Equal(actual with { Data = null }, CompleteAttributeValue.ExpectedCompletion2), + actual => Assert.Equal(actual with { Data = null }, CompleteAttributeValue.ExpectedCompletion3)); } [Fact] From f4548aa4fa4ca964007bfd428e353b7a8e4cb839 Mon Sep 17 00:00:00 2001 From: Patrick Meinecke Date: Fri, 20 May 2022 15:05:30 -0400 Subject: [PATCH 5/5] Fix type name completion test for winps --- .../Language/CompletionHandlerTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/PowerShellEditorServices.Test/Language/CompletionHandlerTests.cs b/test/PowerShellEditorServices.Test/Language/CompletionHandlerTests.cs index 125a8349e..4ba77bf1f 100644 --- a/test/PowerShellEditorServices.Test/Language/CompletionHandlerTests.cs +++ b/test/PowerShellEditorServices.Test/Language/CompletionHandlerTests.cs @@ -83,7 +83,7 @@ public async Task CompletesTypeName() Assert.Equal(CompleteTypeName.ExpectedCompletion with { Kind = CompletionItemKind.Class, - Detail = "Class System.Collections.ArrayList" + Detail = "System.Collections.ArrayList" }, actual); } }