From a474f1df1ecf33eabbb0f7446c15bb92f878cf90 Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Mon, 19 Aug 2019 17:17:18 -0700 Subject: [PATCH 1/8] completion support --- .../LanguageServer/OmnisharpLanguageServer.cs | 1 + .../Services/Symbols/SymbolsService.cs | 2 - .../Services/Symbols/Vistors/AstOperations.cs | 180 +++++----- .../TextDocument/CompletionResults.cs | 340 ++++++++++++++++++ .../Handlers/CompletionHandler.cs | 295 +++++++++++++++ .../LanguageServerProtocolMessageTests.cs | 15 + .../TestsFixture.cs | 12 + 7 files changed, 756 insertions(+), 89 deletions(-) create mode 100644 src/PowerShellEditorServices.Engine/Services/TextDocument/CompletionResults.cs create mode 100644 src/PowerShellEditorServices.Engine/Services/TextDocument/Handlers/CompletionHandler.cs diff --git a/src/PowerShellEditorServices.Engine/LanguageServer/OmnisharpLanguageServer.cs b/src/PowerShellEditorServices.Engine/LanguageServer/OmnisharpLanguageServer.cs index e40b1e60d..9f1152270 100644 --- a/src/PowerShellEditorServices.Engine/LanguageServer/OmnisharpLanguageServer.cs +++ b/src/PowerShellEditorServices.Engine/LanguageServer/OmnisharpLanguageServer.cs @@ -110,6 +110,7 @@ public async Task StartAsync() .WithHandler() .WithHandler() .WithHandler() + .WithHandler() .OnInitialize( async (languageServer, request) => { diff --git a/src/PowerShellEditorServices.Engine/Services/Symbols/SymbolsService.cs b/src/PowerShellEditorServices.Engine/Services/Symbols/SymbolsService.cs index 468bfb62e..c9ab22463 100644 --- a/src/PowerShellEditorServices.Engine/Services/Symbols/SymbolsService.cs +++ b/src/PowerShellEditorServices.Engine/Services/Symbols/SymbolsService.cs @@ -21,8 +21,6 @@ public class SymbolsService { #region Private Fields - const int DefaultWaitTimeoutMilliseconds = 5000; - private readonly ILogger _logger; private readonly IDocumentSymbolProvider[] _documentSymbolProviders; diff --git a/src/PowerShellEditorServices.Engine/Services/Symbols/Vistors/AstOperations.cs b/src/PowerShellEditorServices.Engine/Services/Symbols/Vistors/AstOperations.cs index 9e003c0d4..ede463fb3 100644 --- a/src/PowerShellEditorServices.Engine/Services/Symbols/Vistors/AstOperations.cs +++ b/src/PowerShellEditorServices.Engine/Services/Symbols/Vistors/AstOperations.cs @@ -5,8 +5,15 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using System.Management.Automation; using System.Management.Automation.Language; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Utility; namespace Microsoft.PowerShell.EditorServices.Symbols { @@ -17,11 +24,11 @@ internal static class AstOperations { // TODO: When netstandard is upgraded to 2.0, see if // Delegate.CreateDelegate can be used here instead - //private static readonly MethodInfo s_extentCloneWithNewOffset = typeof(PSObject).GetTypeInfo().Assembly - // .GetType("System.Management.Automation.Language.InternalScriptPosition") - // .GetMethod("CloneWithNewOffset", BindingFlags.Instance | BindingFlags.NonPublic); + private static readonly MethodInfo s_extentCloneWithNewOffset = typeof(PSObject).GetTypeInfo().Assembly + .GetType("System.Management.Automation.Language.InternalScriptPosition") + .GetMethod("CloneWithNewOffset", BindingFlags.Instance | BindingFlags.NonPublic); - //private static readonly SemaphoreSlim s_completionHandle = AsyncUtils.CreateSimpleLockingSemaphore(); + private static readonly SemaphoreSlim s_completionHandle = AsyncUtils.CreateSimpleLockingSemaphore(); // TODO: BRING THIS BACK /// @@ -48,89 +55,88 @@ internal static class AstOperations /// A CommandCompletion instance that contains completions for the /// symbol at the given offset. /// - // static public async Task GetCompletionsAsync( - // Ast scriptAst, - // Token[] currentTokens, - // int fileOffset, - // PowerShellContext powerShellContext, - // ILogger logger, - // CancellationToken cancellationToken) - // { - // if (!s_completionHandle.Wait(0)) - // { - // return null; - // } - - // try - // { - // IScriptPosition cursorPosition = (IScriptPosition)s_extentCloneWithNewOffset.Invoke( - // scriptAst.Extent.StartScriptPosition, - // new object[] { fileOffset }); - - // logger.Write( - // LogLevel.Verbose, - // string.Format( - // "Getting completions at offset {0} (line: {1}, column: {2})", - // fileOffset, - // cursorPosition.LineNumber, - // cursorPosition.ColumnNumber)); - - // if (!powerShellContext.IsAvailable) - // { - // return null; - // } - - // var stopwatch = new Stopwatch(); - - // // If the current runspace is out of process we can use - // // CommandCompletion.CompleteInput because PSReadLine won't be taking up the - // // main runspace. - // if (powerShellContext.IsCurrentRunspaceOutOfProcess()) - // { - // using (RunspaceHandle runspaceHandle = await powerShellContext.GetRunspaceHandleAsync(cancellationToken)) - // using (PowerShell powerShell = PowerShell.Create()) - // { - // powerShell.Runspace = runspaceHandle.Runspace; - // stopwatch.Start(); - // try - // { - // return CommandCompletion.CompleteInput( - // scriptAst, - // currentTokens, - // cursorPosition, - // options: null, - // powershell: powerShell); - // } - // finally - // { - // stopwatch.Stop(); - // logger.Write(LogLevel.Verbose, $"IntelliSense completed in {stopwatch.ElapsedMilliseconds}ms."); - // } - // } - // } - - // CommandCompletion commandCompletion = null; - // await powerShellContext.InvokeOnPipelineThreadAsync( - // pwsh => - // { - // stopwatch.Start(); - // commandCompletion = CommandCompletion.CompleteInput( - // scriptAst, - // currentTokens, - // cursorPosition, - // options: null, - // powershell: pwsh); - // }); - // stopwatch.Stop(); - // logger.Write(LogLevel.Verbose, $"IntelliSense completed in {stopwatch.ElapsedMilliseconds}ms."); - - // return commandCompletion; - // } - // finally - // { - // s_completionHandle.Release(); - // } - // } + static public async Task GetCompletionsAsync( + Ast scriptAst, + Token[] currentTokens, + int fileOffset, + PowerShellContextService powerShellContext, + ILogger logger, + CancellationToken cancellationToken) + { + if (!s_completionHandle.Wait(0)) + { + return null; + } + + try + { + IScriptPosition cursorPosition = (IScriptPosition)s_extentCloneWithNewOffset.Invoke( + scriptAst.Extent.StartScriptPosition, + new object[] { fileOffset }); + + logger.LogTrace( + string.Format( + "Getting completions at offset {0} (line: {1}, column: {2})", + fileOffset, + cursorPosition.LineNumber, + cursorPosition.ColumnNumber)); + + if (!powerShellContext.IsAvailable) + { + return null; + } + + var stopwatch = new Stopwatch(); + + // If the current runspace is out of process we can use + // CommandCompletion.CompleteInput because PSReadLine won't be taking up the + // main runspace. + if (powerShellContext.IsCurrentRunspaceOutOfProcess()) + { + using (RunspaceHandle runspaceHandle = await powerShellContext.GetRunspaceHandleAsync(cancellationToken)) + using (System.Management.Automation.PowerShell powerShell = System.Management.Automation.PowerShell.Create()) + { + powerShell.Runspace = runspaceHandle.Runspace; + stopwatch.Start(); + try + { + return CommandCompletion.CompleteInput( + scriptAst, + currentTokens, + cursorPosition, + options: null, + powershell: powerShell); + } + finally + { + stopwatch.Stop(); + logger.LogTrace($"IntelliSense completed in {stopwatch.ElapsedMilliseconds}ms."); + } + } + } + + CommandCompletion commandCompletion = null; + await powerShellContext.InvokeOnPipelineThreadAsync( + pwsh => + { + stopwatch.Start(); + commandCompletion = CommandCompletion.CompleteInput( + scriptAst, + currentTokens, + cursorPosition, + options: null, + powershell: pwsh); + }); + stopwatch.Stop(); + logger.LogTrace($"IntelliSense completed in {stopwatch.ElapsedMilliseconds}ms."); + + return commandCompletion; + } + finally + { + s_completionHandle.Release(); + } + } /// /// Finds the symbol at a given file location diff --git a/src/PowerShellEditorServices.Engine/Services/TextDocument/CompletionResults.cs b/src/PowerShellEditorServices.Engine/Services/TextDocument/CompletionResults.cs new file mode 100644 index 000000000..fc8d2eb00 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/TextDocument/CompletionResults.cs @@ -0,0 +1,340 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +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 +{ + /// + /// Provides the results of a single code completion request. + /// + public 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 = new CompletionDetails[0]; + 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. + /// + public 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}")] + public 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) + { + CompletionDetails otherDetails = obj as CompletionDetails; + if (otherDetails == null) + { + 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: + 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.Engine/Services/TextDocument/Handlers/CompletionHandler.cs b/src/PowerShellEditorServices.Engine/Services/TextDocument/Handlers/CompletionHandler.cs new file mode 100644 index 000000000..3f4e8657a --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/TextDocument/Handlers/CompletionHandler.cs @@ -0,0 +1,295 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Management.Automation; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Symbols; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; + +namespace Microsoft.PowerShell.EditorServices.TextDocument +{ + internal class CompletionHandler : ICompletionHandler + { + const int DefaultWaitTimeoutMilliseconds = 5000; + private readonly CompletionItem[] s_emptyCompletionResult = new CompletionItem[0]; + + private readonly ILogger _logger; + private readonly PowerShellContextService _powerShellContextService; + private readonly WorkspaceService _workspaceService; + + private CompletionResults _mostRecentCompletions; + + private int _mostRecentRequestLine; + + private int _mostRecentRequestOffest; + + private string _mostRecentRequestFile; + + private CompletionCapability _capability; + + public CompletionHandler( + ILoggerFactory factory, + PowerShellContextService powerShellContextService, + WorkspaceService workspaceService) + { + _logger = factory.CreateLogger(); + _powerShellContextService = powerShellContextService; + _workspaceService = workspaceService; + } + + public CompletionRegistrationOptions GetRegistrationOptions() + { + return new CompletionRegistrationOptions() + { + DocumentSelector = new DocumentSelector(new DocumentFilter() { Pattern = "**/*.ps*1" }), + ResolveProvider = true, + TriggerCharacters = new string[] { ".", "-", ":", "\\" } + }; + } + + public async Task Handle(CompletionParams request, CancellationToken cancellationToken) + { + int cursorLine = (int) request.Position.Line + 1; + int cursorColumn = (int) request.Position.Character + 1; + + ScriptFile scriptFile = + _workspaceService.GetFile( + request.TextDocument.Uri.ToString()); + + CompletionResults completionResults = + await GetCompletionsInFileAsync( + scriptFile, + cursorLine, + cursorColumn); + + CompletionItem[] completionItems = s_emptyCompletionResult; + + if (completionResults != null) + { + 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 new CompletionList(completionItems); + } + + public void SetCapability(CompletionCapability capability) + { + _capability = capability; + } + + /// + /// Gets completions for a statement contained in the given + /// script file at the specified line and column position. + /// + /// + /// The script file in which completions will be gathered. + /// + /// + /// The 1-based line number at which completions will be gathered. + /// + /// + /// The 1-based column number at which completions will be gathered. + /// + /// + /// A CommandCompletion instance completions for the identified statement. + /// + public async Task GetCompletionsInFileAsync( + ScriptFile scriptFile, + int lineNumber, + int columnNumber) + { + 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, + _powerShellContextService, + _logger, + cts.Token); + } + + if (commandCompletion == null) + { + return new CompletionResults(); + } + + try + { + 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(); + } + } + + private static CompletionItem CreateCompletionItem( + CompletionDetails completionDetails, + BufferRange completionRange, + int sortIndex) + { + string detailString = null; + string documentationString = null; + string completionText = completionDetails.CompletionText; + InsertTextFormat insertTextFormat = InsertTextFormat.PlainText; + + if ((completionDetails.CompletionType == CompletionType.Variable) || + (completionDetails.CompletionType == 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 now 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; + } + } + else if ((completionDetails.CompletionType == CompletionType.Method) || + (completionDetails.CompletionType == CompletionType.Property)) + { + // We have a raw signature for .NET members, heck let's display it. It's + // better than nothing. + documentationString = completionDetails.ToolTipText; + } + else if (completionDetails.CompletionType == 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) + { + // 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; + } + } + } + else if ((completionDetails.CompletionType == CompletionType.Folder) && + (completionText.EndsWith("\"") || completionText.EndsWith("'"))) + { + // 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 + int len = completionDetails.CompletionText.Length; + completionText = completionDetails.CompletionText.Insert(len - 1, "$0"); + insertTextFormat = InsertTextFormat.Snippet; + } + + // 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}"; + + return new CompletionItem + { + 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 + { + 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 + } + } + } + }; + } + + private static CompletionItemKind MapCompletionKind(CompletionType completionType) + { + switch (completionType) + { + 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; + } + } + } +} diff --git a/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs b/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs index 6c169c1bf..889841736 100644 --- a/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs +++ b/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs @@ -607,5 +607,20 @@ await LanguageClient.SendRequest( Assert.Single(commandOrCodeActions, command => command.Command.Name == "PowerShell.ApplyCodeActionEdits"); } + + [Fact] + public async Task CanSendCompletionRequest() + { + string filePath = NewTestFile("Write-H"); + + CompletionList completionItems = await LanguageClient.TextDocument.Completions( + filePath, line: 0, column: 7); + + Assert.Collection(completionItems, + completionItem1 => { + Assert.Equal("Write-Host", completionItem1.Label); + } + ); + } } } diff --git a/test/PowerShellEditorServices.Test.E2E/TestsFixture.cs b/test/PowerShellEditorServices.Test.E2E/TestsFixture.cs index a9bd12195..4599881a9 100644 --- a/test/PowerShellEditorServices.Test.E2E/TestsFixture.cs +++ b/test/PowerShellEditorServices.Test.E2E/TestsFixture.cs @@ -5,6 +5,7 @@ using System.Reflection; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; using OmniSharp.Extensions.LanguageServer.Client; using OmniSharp.Extensions.LanguageServer.Client.Processes; using OmniSharp.Extensions.LanguageServer.Protocol.Models; @@ -83,6 +84,17 @@ public async Task InitializeAsync() Directory.CreateDirectory(Path.Combine(s_binDir, Path.GetRandomFileName())); await LanguageClient.Initialize(testdir.FullName); + // Make sure Script Analysis is enabled because we'll need it in the tests. + LanguageClient.Workspace.DidChangeConfiguration(JObject.Parse(@" +{ + ""PowerShell"": { + ""ScriptAnalysis"": { + ""Enable"": true + } + } +} +")); + Diagnostics = new List(); LanguageClient.TextDocument.OnPublishDiagnostics((uri, diagnostics) => { From 15c99189d9da15ecd0be73041d06628bc21eddbf Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Mon, 19 Aug 2019 17:23:29 -0700 Subject: [PATCH 2/8] misc codacy fixes --- .../Services/TextDocument/Handlers/CompletionHandler.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PowerShellEditorServices.Engine/Services/TextDocument/Handlers/CompletionHandler.cs b/src/PowerShellEditorServices.Engine/Services/TextDocument/Handlers/CompletionHandler.cs index 3f4e8657a..e66a5d709 100644 --- a/src/PowerShellEditorServices.Engine/Services/TextDocument/Handlers/CompletionHandler.cs +++ b/src/PowerShellEditorServices.Engine/Services/TextDocument/Handlers/CompletionHandler.cs @@ -47,11 +47,11 @@ public CompletionHandler( public CompletionRegistrationOptions GetRegistrationOptions() { - return new CompletionRegistrationOptions() + return new CompletionRegistrationOptions { - DocumentSelector = new DocumentSelector(new DocumentFilter() { Pattern = "**/*.ps*1" }), + DocumentSelector = new DocumentSelector(new DocumentFilter { Pattern = "**/*.ps*1" }), ResolveProvider = true, - TriggerCharacters = new string[] { ".", "-", ":", "\\" } + TriggerCharacters = new[] { ".", "-", ":", "\\" } }; } From 7b9d5f3a1a468cdd8f0296ba17164375218479b2 Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Mon, 19 Aug 2019 17:51:38 -0700 Subject: [PATCH 3/8] use BUILD_ARTIFACTSTAGINGDIRECTORY so logs can be uploaded --- test/PowerShellEditorServices.Test.E2E/TestsFixture.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/PowerShellEditorServices.Test.E2E/TestsFixture.cs b/test/PowerShellEditorServices.Test.E2E/TestsFixture.cs index 4599881a9..84d125732 100644 --- a/test/PowerShellEditorServices.Test.E2E/TestsFixture.cs +++ b/test/PowerShellEditorServices.Test.E2E/TestsFixture.cs @@ -28,7 +28,7 @@ public class TestsFixture : IAsyncLifetime $"pses_test_sessiondetails_{Path.GetRandomFileName()}"); private readonly static string s_logPath = Path.Combine( - s_binDir, + Environment.GetEnvironmentVariable("BUILD_ARTIFACTSTAGINGDIRECTORY") ?? s_binDir, $"pses_test_logs_{Path.GetRandomFileName()}"); const string s_logLevel = "Diagnostic"; From 97a8cb1438e2cddeec62fc2ce92c10032e8fbe3f Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Mon, 19 Aug 2019 18:00:21 -0700 Subject: [PATCH 4/8] publish artifacts even if build fails --- .vsts-ci/templates/ci-general.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.vsts-ci/templates/ci-general.yml b/.vsts-ci/templates/ci-general.yml index 664d41d9e..af4fb8fb5 100644 --- a/.vsts-ci/templates/ci-general.yml +++ b/.vsts-ci/templates/ci-general.yml @@ -23,3 +23,4 @@ steps: inputs: ArtifactName: PowerShellEditorServices PathtoPublish: '$(Build.ArtifactStagingDirectory)' + condition: succeededOrFailed() From 254bc0654566116b373000a9b1b349f97f7dd21e Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Mon, 19 Aug 2019 18:22:25 -0700 Subject: [PATCH 5/8] handle log messages --- test/PowerShellEditorServices.Test.E2E/TestsFixture.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/PowerShellEditorServices.Test.E2E/TestsFixture.cs b/test/PowerShellEditorServices.Test.E2E/TestsFixture.cs index 84d125732..14a35079f 100644 --- a/test/PowerShellEditorServices.Test.E2E/TestsFixture.cs +++ b/test/PowerShellEditorServices.Test.E2E/TestsFixture.cs @@ -82,6 +82,12 @@ public async Task InitializeAsync() DirectoryInfo testdir = Directory.CreateDirectory(Path.Combine(s_binDir, Path.GetRandomFileName())); + + LanguageClient.Window.OnLogMessage((message, messageType) => + { + Console.WriteLine($"{messageType.ToString()}: {message}"); + }); + await LanguageClient.Initialize(testdir.FullName); // Make sure Script Analysis is enabled because we'll need it in the tests. From 28253733e24eca036e2a7009d621f13b5d3d27ce Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Tue, 20 Aug 2019 09:43:23 -0700 Subject: [PATCH 6/8] give PSES a chance to run what it needs to run --- .../LanguageServerProtocolMessageTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs b/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs index 889841736..9e7593599 100644 --- a/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs +++ b/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs @@ -55,6 +55,9 @@ private string NewTestFile(string script, bool isPester = false) } }); + // Give PSES a chance to run what it needs to run. + Thread.Sleep(1000); + return filePath; } From 29b84b31a632f431cfd3dbb41322a5c840699148 Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Tue, 20 Aug 2019 10:30:03 -0700 Subject: [PATCH 7/8] switch to using xUnit output helper --- .../LanguageServerProtocolMessageTests.cs | 18 +++++++++++++++++- .../TestsFixture.cs | 5 ----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs b/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs index 9e7593599..7c5bd52b9 100644 --- a/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs +++ b/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs @@ -11,6 +11,7 @@ using OmniSharp.Extensions.LanguageServer.Protocol.Models; using PowerShellEditorServices.Engine.Services.Handlers; using Xunit; +using Xunit.Abstractions; using Range = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; namespace PowerShellEditorServices.Test.E2E @@ -20,17 +21,32 @@ public class LanguageServerProtocolMessageTests : IClassFixture, I private readonly static string s_binDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + private static bool s_registeredOnLogMessage; + private readonly LanguageClient LanguageClient; private readonly List Diagnostics; private readonly string PwshExe; + private readonly ITestOutputHelper _output; - public LanguageServerProtocolMessageTests(TestsFixture data) + public LanguageServerProtocolMessageTests(ITestOutputHelper output, TestsFixture data) { Diagnostics = new List(); LanguageClient = data.LanguageClient; Diagnostics = data.Diagnostics; PwshExe = TestsFixture.PwshExe; Diagnostics.Clear(); + + _output = output; + + if (!s_registeredOnLogMessage) + { + LanguageClient.Window.OnLogMessage((message, messageType) => + { + _output.WriteLine($"{messageType.ToString()}: {message}"); + }); + + s_registeredOnLogMessage = true; + } } public void Dispose() diff --git a/test/PowerShellEditorServices.Test.E2E/TestsFixture.cs b/test/PowerShellEditorServices.Test.E2E/TestsFixture.cs index 14a35079f..092a8799a 100644 --- a/test/PowerShellEditorServices.Test.E2E/TestsFixture.cs +++ b/test/PowerShellEditorServices.Test.E2E/TestsFixture.cs @@ -83,11 +83,6 @@ public async Task InitializeAsync() DirectoryInfo testdir = Directory.CreateDirectory(Path.Combine(s_binDir, Path.GetRandomFileName())); - LanguageClient.Window.OnLogMessage((message, messageType) => - { - Console.WriteLine($"{messageType.ToString()}: {message}"); - }); - await LanguageClient.Initialize(testdir.FullName); // Make sure Script Analysis is enabled because we'll need it in the tests. From 4b23fa00df9e5bbc9730d4c9e09d831afc6f222a Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Tue, 20 Aug 2019 10:43:15 -0700 Subject: [PATCH 8/8] treat DynamicKeywords as Keyword --- .../Services/TextDocument/CompletionResults.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/PowerShellEditorServices.Engine/Services/TextDocument/CompletionResults.cs b/src/PowerShellEditorServices.Engine/Services/TextDocument/CompletionResults.cs index fc8d2eb00..3f59eccfe 100644 --- a/src/PowerShellEditorServices.Engine/Services/TextDocument/CompletionResults.cs +++ b/src/PowerShellEditorServices.Engine/Services/TextDocument/CompletionResults.cs @@ -306,6 +306,7 @@ private static CompletionType ConvertCompletionResultType( return CompletionType.Type; case CompletionResultType.Keyword: + case CompletionResultType.DynamicKeyword: return CompletionType.Keyword; case CompletionResultType.ProviderContainer: