diff --git a/src/PowerShellEditorServices.Host/CodeLens/CodeLensFeature.cs b/src/PowerShellEditorServices.Host/CodeLens/CodeLensFeature.cs index ac5f388a3..9a0174cdc 100644 --- a/src/PowerShellEditorServices.Host/CodeLens/CodeLensFeature.cs +++ b/src/PowerShellEditorServices.Host/CodeLens/CodeLensFeature.cs @@ -126,7 +126,7 @@ private async Task HandleCodeLensRequest( codeLensResponse[i] = codeLensResults[i].ToProtocolCodeLens( new CodeLensData { - Uri = codeLensResults[i].File.ClientFilePath, + Uri = codeLensResults[i].File.DocumentUri, ProviderId = codeLensResults[i].Provider.ProviderId }, _jsonSerializer); diff --git a/src/PowerShellEditorServices.Host/CodeLens/ReferencesCodeLensProvider.cs b/src/PowerShellEditorServices.Host/CodeLens/ReferencesCodeLensProvider.cs index c833aec27..3c961742d 100644 --- a/src/PowerShellEditorServices.Host/CodeLens/ReferencesCodeLensProvider.cs +++ b/src/PowerShellEditorServices.Host/CodeLens/ReferencesCodeLensProvider.cs @@ -118,7 +118,7 @@ public async Task ResolveCodeLensAsync( GetReferenceCountHeader(referenceLocations.Length), new object[] { - codeLens.File.ClientFilePath, + codeLens.File.DocumentUri, codeLens.ScriptExtent.ToRange().Start, referenceLocations, } @@ -151,7 +151,7 @@ private static string GetFileUri(string filePath) // If the file isn't untitled, return a URI-style path return !filePath.StartsWith("untitled") && !filePath.StartsWith("inmemory") - ? new Uri("file://" + filePath).AbsoluteUri + ? Workspace.ConvertPathToDocumentUri(filePath) : filePath; } diff --git a/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs b/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs index 0432fa2ee..af228029d 100644 --- a/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs +++ b/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs @@ -1738,7 +1738,7 @@ private static async Task PublishScriptDiagnostics( diagnostics.Add(markerDiagnostic); } - correctionIndex[scriptFile.ClientFilePath] = fileCorrections; + correctionIndex[scriptFile.DocumentUri] = fileCorrections; // Always send syntax and semantic errors. We want to // make sure no out-of-date markers are being displayed. @@ -1746,7 +1746,7 @@ await eventSender( PublishDiagnosticsNotification.Type, new PublishDiagnosticsNotification { - Uri = scriptFile.ClientFilePath, + Uri = scriptFile.DocumentUri, Diagnostics = diagnostics.ToArray() }); } diff --git a/src/PowerShellEditorServices/Workspace/ScriptFile.cs b/src/PowerShellEditorServices/Workspace/ScriptFile.cs index 0e0d4d6e7..08e9c6a00 100644 --- a/src/PowerShellEditorServices/Workspace/ScriptFile.cs +++ b/src/PowerShellEditorServices/Workspace/ScriptFile.cs @@ -3,13 +3,14 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. // -using Microsoft.PowerShell.EditorServices.Utility; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Management.Automation; using System.Management.Automation.Language; +using System.Runtime.InteropServices; +using Microsoft.PowerShell.EditorServices.Utility; namespace Microsoft.PowerShell.EditorServices { @@ -52,6 +53,19 @@ public string Id /// public string ClientFilePath { get; private set; } + /// + /// Gets the file path in LSP DocumentUri form. The ClientPath property must not be null. + /// + public string DocumentUri + { + get + { + return this.ClientFilePath == null + ? string.Empty + : Workspace.ConvertPathToDocumentUri(this.ClientFilePath); + } + } + /// /// Gets or sets a boolean that determines whether /// semantic analysis should be enabled for this file. diff --git a/src/PowerShellEditorServices/Workspace/Workspace.cs b/src/PowerShellEditorServices/Workspace/Workspace.cs index 6f9cb8beb..f2fb07b98 100644 --- a/src/PowerShellEditorServices/Workspace/Workspace.cs +++ b/src/PowerShellEditorServices/Workspace/Workspace.cs @@ -350,7 +350,7 @@ private void RecursivelyEnumerateFiles(string folderPath, ref List found this.logger.WriteHandledException( $"Could not enumerate files in the path '{folderPath}' due to an exception", e); - + continue; } @@ -399,7 +399,7 @@ private void RecursivelyEnumerateFiles(string folderPath, ref List found this.logger.WriteHandledException( $"Could not enumerate directories in the path '{folderPath}' due to an exception", e); - + return; } @@ -624,6 +624,69 @@ private static string UnescapeDriveColon(string fileUri) return sb.ToString(); } + /// + /// Converts a file system path into a DocumentUri required by Language Server Protocol. + /// + /// + /// When sending a document path to a LSP client, the path must be provided as a + /// DocumentUri in order to features like the Problems window or peek definition + /// to be able to open the specified file. + /// + /// + /// A file system path. Note: if the path is already a DocumentUri, it will be returned unmodified. + /// + /// The file system path encoded as a DocumentUri. + public static string ConvertPathToDocumentUri(string path) + { + const string fileUriPrefix = "file:///"; + const string untitledUriPrefix = "untitled:"; + + // If path is already in document uri form, there is nothing to convert. + if (path.StartsWith(untitledUriPrefix, StringComparison.Ordinal) || + path.StartsWith(fileUriPrefix, StringComparison.Ordinal)) + { + return path; + } + + string escapedPath = Uri.EscapeDataString(path); + var docUriStrBld = new StringBuilder(escapedPath); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // VSCode file URIs on Windows need the drive letter lowercase. + // Search original path for colon since a char search (no string culture involved) + // is faster than a string search. + if (path.Contains(':')) + { + // Start at index 1 to avoid an index out of range check when accessing index - 1. + // Also, if the colon is at index 0 there is no drive letter before it to lower case. + for (int i = 1; i < docUriStrBld.Length - 2; i++) + { + if ((docUriStrBld[i] == '%') && (docUriStrBld[i + 1] == '3') && (docUriStrBld[i + 2] == 'A')) + { + int driveLetterIndex = i - 1; + char driveLetter = char.ToLowerInvariant(docUriStrBld[driveLetterIndex]); + docUriStrBld.Replace(path[driveLetterIndex], driveLetter, driveLetterIndex, 1); + break; + } + } + } + + // Uri.EscapeDataString goes a bit far, encoding \ chars. Also, VSCode wants / instead of \. + docUriStrBld.Replace("%5C", "/"); + } + else + { + // Because we will prefix later with file:///, remove the initial encoded / if this is an absolute path. + // See https://docs.microsoft.com/en-us/dotnet/api/system.uri?view=netframework-4.7.2#implicit-file-path-support + // Uri.EscapeDataString goes a bit far, encoding / chars. + docUriStrBld.Replace("%2F", string.Empty, 0, 3).Replace("%2F", "/"); + } + + // ' is not always encoded. I've seen this in Windows PowerShell. + return docUriStrBld.Replace("'", "%27").Insert(0, fileUriPrefix).ToString(); + } + #endregion } } diff --git a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs index 18ce3cf30..c259b44be 100644 --- a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs +++ b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs @@ -97,7 +97,7 @@ public static IEnumerable DebuggerAcceptsScriptArgsTestData } [Theory] - [MemberData("DebuggerAcceptsScriptArgsTestData")] + [MemberData(nameof(DebuggerAcceptsScriptArgsTestData))] public async Task DebuggerAcceptsScriptArgs(string[] args) { // The path is intentionally odd (some escaped chars but not all) because we are testing diff --git a/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs b/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs index 993e56999..f9555c40a 100644 --- a/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs +++ b/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs @@ -570,6 +570,7 @@ public void PropertiesInitializedCorrectlyForUntitled() Assert.Equal(path, scriptFile.FilePath); Assert.Equal(path, scriptFile.ClientFilePath); + Assert.Equal(path, scriptFile.DocumentUri); Assert.True(scriptFile.IsAnalysisEnabled); Assert.True(scriptFile.IsInMemory); Assert.Empty(scriptFile.ReferencedFiles); @@ -578,5 +579,43 @@ public void PropertiesInitializedCorrectlyForUntitled() Assert.Equal(3, scriptFile.FileLines.Count); } } + + [Fact] + public void DocumentUriRetunsCorrectStringForAbsolutePath() + { + string path; + ScriptFile scriptFile; + var emptyStringReader = new StringReader(""); + + if (Environment.OSVersion.Platform == PlatformID.Win32NT) + { + path = @"C:\Users\AmosBurton\projects\Rocinate\ProtoMolecule.ps1"; + scriptFile = new ScriptFile(path, path, emptyStringReader, PowerShellVersion); + Assert.Equal("file:///c%3A/Users/AmosBurton/projects/Rocinate/ProtoMolecule.ps1", scriptFile.DocumentUri); + + path = @"c:\Users\BobbyDraper\projects\Rocinate\foo's_~#-[@] +,;=%.ps1"; + scriptFile = new ScriptFile(path, path, emptyStringReader, PowerShellVersion); + Assert.Equal("file:///c%3A/Users/BobbyDraper/projects/Rocinate/foo%27s_~%23-%5B%40%5D%20%2B%2C%3B%3D%25.ps1", scriptFile.DocumentUri); + } + else + { + // Test the following only on Linux and macOS. + path = "/home/AlexKamal/projects/Rocinate/ProtoMolecule.ps1"; + scriptFile = new ScriptFile(path, path, emptyStringReader, PowerShellVersion); + Assert.Equal("file:///home/AlexKamal/projects/Rocinate/ProtoMolecule.ps1", scriptFile.DocumentUri); + + path = "/home/BobbyDraper/projects/Rocinate/foo's_~#-[@] +,;=%.ps1"; + scriptFile = new ScriptFile(path, path, emptyStringReader, PowerShellVersion); + Assert.Equal("file:///home/BobbyDraper/projects/Rocinate/foo%27s_~%23-%5B%40%5D%20%2B%2C%3B%3D%25.ps1", scriptFile.DocumentUri); + + path = "/home/NaomiNagata/projects/Rocinate/Proto:Mole:cule.ps1"; + scriptFile = new ScriptFile(path, path, emptyStringReader, PowerShellVersion); + Assert.Equal("file:///home/NaomiNagata/projects/Rocinate/Proto%3AMole%3Acule.ps1", scriptFile.DocumentUri); + + path = "/home/JamesHolden/projects/Rocinate/Proto:Mole\\cule.ps1"; + scriptFile = new ScriptFile(path, path, emptyStringReader, PowerShellVersion); + Assert.Equal("file:///home/JamesHolden/projects/Rocinate/Proto%3AMole%5Ccule.ps1", scriptFile.DocumentUri); + } + } } }