diff --git a/src/PowerShellEditorServices.Protocol/LanguageServer/Completion.cs b/src/PowerShellEditorServices.Protocol/LanguageServer/Completion.cs index 38fb9e431..2e0cbeeca 100644 --- a/src/PowerShellEditorServices.Protocol/LanguageServer/Completion.cs +++ b/src/PowerShellEditorServices.Protocol/LanguageServer/Completion.cs @@ -58,6 +58,12 @@ public enum CompletionItemKind Folder = 19 } + public enum InsertTextFormat + { + PlainText = 1, + Snippet = 2, + } + [DebuggerDisplay("NewText = {NewText}, Range = {Range.Start.Line}:{Range.Start.Character} - {Range.End.Line}:{Range.End.Character}")] public class TextEdit { @@ -86,6 +92,8 @@ public class CompletionItem public string InsertText { get; set; } + public InsertTextFormat InsertTextFormat { get; set; } = InsertTextFormat.PlainText; + public Range Range { get; set; } public string[] CommitCharacters { get; set; } diff --git a/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs b/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs index 7c278eb2c..41dfb4181 100644 --- a/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs +++ b/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs @@ -1922,6 +1922,8 @@ private static CompletionItem CreateCompletionItem( { string detailString = null; string documentationString = null; + string completionText = completionDetails.CompletionText; + InsertTextFormat insertTextFormat = InsertTextFormat.PlainText; if ((completionDetails.CompletionType == CompletionType.Variable) || (completionDetails.CompletionType == CompletionType.ParameterName)) @@ -1963,6 +1965,19 @@ private static CompletionItem CreateCompletionItem( } } } + 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 @@ -1973,7 +1988,8 @@ private static CompletionItem CreateCompletionItem( return new CompletionItem { - InsertText = completionDetails.CompletionText, + InsertText = completionText, + InsertTextFormat = insertTextFormat, Label = completionDetails.ListItemText, Kind = MapCompletionKind(completionDetails.CompletionType), Detail = detailString, @@ -1982,7 +1998,7 @@ private static CompletionItem CreateCompletionItem( FilterText = completionDetails.CompletionText, TextEdit = new TextEdit { - NewText = completionDetails.CompletionText, + NewText = completionText, Range = new Range { Start = new Position diff --git a/test/PowerShellEditorServices.Test.Host/LanguageServerTests.cs b/test/PowerShellEditorServices.Test.Host/LanguageServerTests.cs index 27daf6fae..bdfa9de4b 100644 --- a/test/PowerShellEditorServices.Test.Host/LanguageServerTests.cs +++ b/test/PowerShellEditorServices.Test.Host/LanguageServerTests.cs @@ -15,6 +15,7 @@ using System; using System.IO; using System.Linq; +using System.Runtime.InteropServices; using System.Threading.Tasks; using Xunit; @@ -260,6 +261,97 @@ await this.SendRequest( Assert.True(updatedCompletionItem.Documentation.Length > 0); } + [Fact] + public async Task CompletesDetailOnFilePathSuggestion() + { + string expectedPathSnippet; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + expectedPathSnippet = @".\TestFiles\CompleteFunctionName.ps1"; + } + else + { + expectedPathSnippet = "./TestFiles/CompleteFunctionName.ps1"; + } + + // Change dir to root of this test project's folder + await this.SetLocationForServerTest(this.TestRootDir); + + await this.SendOpenFileEvent(TestUtilities.NormalizePath("TestFiles/CompleteFunctionName.ps1")); + + CompletionItem[] completions = + await this.SendRequest( + CompletionRequest.Type, + new TextDocumentPositionParams + { + TextDocument = new TextDocumentIdentifier + { + Uri = TestUtilities.NormalizePath("TestFiles/CompleteFunctionName.ps1") + }, + Position = new Position + { + Line = 8, + Character = 35 + } + }); + + CompletionItem completionItem = + completions + .FirstOrDefault( + c => c.InsertText == expectedPathSnippet); + + Assert.NotNull(completionItem); + Assert.Equal(InsertTextFormat.PlainText, completionItem.InsertTextFormat); + } + + [Fact] + public async Task CompletesDetailOnFolderPathSuggestion() + { + string expectedPathSnippet; + InsertTextFormat insertTextFormat; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + expectedPathSnippet = @"'.\TestFiles\Folder With Spaces$0'"; + insertTextFormat = InsertTextFormat.Snippet; + } + else + { + expectedPathSnippet = @"'./TestFiles/Folder With Spaces$0'"; + insertTextFormat = InsertTextFormat.Snippet; + } + + // Change dir to root of this test project's folder + await this.SetLocationForServerTest(this.TestRootDir); + + await this.SendOpenFileEvent(TestUtilities.NormalizePath("TestFiles/CompleteFunctionName.ps1")); + + CompletionItem[] completions = + await this.SendRequest( + CompletionRequest.Type, + new TextDocumentPositionParams + { + TextDocument = new TextDocumentIdentifier + { + Uri = TestUtilities.NormalizePath("TestFiles/CompleteFunctionName.ps1") + }, + Position = new Position + { + Line = 7, + Character = 32 + } + }); + + CompletionItem completionItem = + completions + .FirstOrDefault( + c => c.InsertText == expectedPathSnippet); + + Assert.NotNull(completionItem); + Assert.Equal(insertTextFormat, completionItem.InsertTextFormat); + } + [Fact] public async Task FindsReferencesOfVariable() { @@ -829,6 +921,27 @@ await this.SendRequest( Assert.Equal(expectedArchitecture, versionDetails.Architecture); } + private string TestRootDir + { + get + { + string assemblyDir = Path.GetDirectoryName(this.GetType().Assembly.Location); + return Path.Combine(assemblyDir, @"..\..\.."); + } + } + + private async Task SetLocationForServerTest(string path) + { + // Change dir to root of this test project's folder + await this.SendRequest( + EvaluateRequest.Type, + new EvaluateRequestArguments + { + Expression = $"Set-Location {path}", + Context = "repl" + }); + } + private async Task SendOpenFileEvent(string filePath, bool waitForDiagnostics = true) { string fileContents = string.Join(Environment.NewLine, File.ReadAllLines(filePath)); diff --git a/test/PowerShellEditorServices.Test.Host/TestFiles/CompleteFunctionName.ps1 b/test/PowerShellEditorServices.Test.Host/TestFiles/CompleteFunctionName.ps1 index a049b2845..d7f9b9aab 100644 --- a/test/PowerShellEditorServices.Test.Host/TestFiles/CompleteFunctionName.ps1 +++ b/test/PowerShellEditorServices.Test.Host/TestFiles/CompleteFunctionName.ps1 @@ -4,4 +4,6 @@ function My-Function $Cons My- Get-Proc -$HKC \ No newline at end of file +$HKC +Get-ChildItem ./TestFiles/Folder +Get-ChildItem ./TestFiles/CompleteF diff --git a/test/PowerShellEditorServices.Test.Host/TestFiles/Folder With Spaces/.gitkeep b/test/PowerShellEditorServices.Test.Host/TestFiles/Folder With Spaces/.gitkeep new file mode 100644 index 000000000..e69de29bb