diff --git a/src/PowerShellEditorServices.Host/CodeLens/CodeLensFeature.cs b/src/PowerShellEditorServices.Host/CodeLens/CodeLensFeature.cs index e7e2f6910..d90d77333 100644 --- a/src/PowerShellEditorServices.Host/CodeLens/CodeLensFeature.cs +++ b/src/PowerShellEditorServices.Host/CodeLens/CodeLensFeature.cs @@ -126,7 +126,7 @@ private async Task HandleCodeLensRequestAsync( 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/PesterCodeLensProvider.cs b/src/PowerShellEditorServices.Host/CodeLens/PesterCodeLensProvider.cs index e814918ab..cc4a706b0 100644 --- a/src/PowerShellEditorServices.Host/CodeLens/PesterCodeLensProvider.cs +++ b/src/PowerShellEditorServices.Host/CodeLens/PesterCodeLensProvider.cs @@ -3,13 +3,11 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. // -using Microsoft.PowerShell.EditorServices.Commands; -using Microsoft.PowerShell.EditorServices.Symbols; -using System; using System.Collections.Generic; -using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Commands; +using Microsoft.PowerShell.EditorServices.Symbols; namespace Microsoft.PowerShell.EditorServices.CodeLenses { @@ -53,7 +51,7 @@ private CodeLens[] GetPesterLens(PesterSymbolReference pesterSymbol, ScriptFile "PowerShell.RunPesterTests", "Run tests", new object[] { - scriptFile.ClientFilePath, + scriptFile.DocumentUri, false /* No debug */, pesterSymbol.TestName, pesterSymbol.ScriptRegion?.StartLineNumber })), @@ -66,7 +64,7 @@ private CodeLens[] GetPesterLens(PesterSymbolReference pesterSymbol, ScriptFile "PowerShell.RunPesterTests", "Debug tests", new object[] { - scriptFile.ClientFilePath, + scriptFile.DocumentUri, true /* Run in the debugger */, pesterSymbol.TestName, pesterSymbol.ScriptRegion?.StartLineNumber })), diff --git a/src/PowerShellEditorServices.Host/CodeLens/ReferencesCodeLensProvider.cs b/src/PowerShellEditorServices.Host/CodeLens/ReferencesCodeLensProvider.cs index 6a5312a93..3aff19990 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, } diff --git a/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs b/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs index d325b2d65..20a32c3d0 100644 --- a/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs +++ b/src/PowerShellEditorServices.Protocol/Server/LanguageServer.cs @@ -1757,7 +1757,7 @@ private static async Task PublishScriptDiagnosticsAsync( 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. @@ -1765,7 +1765,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 f4dd3de26..c2806a7fb 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 d81f9f5e7..571ea5c0e 100644 --- a/src/PowerShellEditorServices/Workspace/Workspace.cs +++ b/src/PowerShellEditorServices/Workspace/Workspace.cs @@ -351,7 +351,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; } @@ -400,7 +400,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; } @@ -625,6 +625,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. + internal static string ConvertPathToDocumentUri(string path) + { + const string fileUriPrefix = "file:///"; + + if (path.StartsWith("untitled:", StringComparison.Ordinal)) + { + return path; + } + + if (path.StartsWith(fileUriPrefix, StringComparison.Ordinal)) + { + return path; + } + + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // On a Linux filesystem, you can have multiple colons in a filename e.g. foo:bar:baz.txt + string absoluteUri = new Uri(path).AbsoluteUri; + + // First colon is part of the protocol scheme, see if there are other colons in the path + int firstColonIndex = absoluteUri.IndexOf(':'); + if (absoluteUri.IndexOf(':', firstColonIndex + 1) > firstColonIndex) + { + absoluteUri = new StringBuilder(absoluteUri) + .Replace( + oldValue: ":", + newValue: "%3A", + startIndex: firstColonIndex + 1, + count: absoluteUri.Length - firstColonIndex - 1) + .ToString(); + } + + return absoluteUri; + } + + // VSCode file URIs on Windows need the drive letter lowercase, and the colon + // URI encoded. System.Uri won't do that, so we manually create the URI. + var newUri = new StringBuilder(System.Web.HttpUtility.UrlPathEncode(path)); + int colonIndex = path.IndexOf(':'); + if (colonIndex > 0) + { + int driveLetterIndex = colonIndex - 1; + char driveLetter = char.ToLowerInvariant(path[driveLetterIndex]); + newUri + .Replace(path[driveLetterIndex], driveLetter, driveLetterIndex, 1) + .Replace(":", "%3A", colonIndex, 1); + } + + return newUri.Replace('\\', '/').Insert(0, fileUriPrefix).ToString(); + } + #endregion } } diff --git a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs index 0d6e0b30e..9b430cd1a 100644 --- a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs +++ b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs @@ -101,7 +101,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 2a664f2f3..24ca67104 100644 --- a/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs +++ b/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs @@ -572,6 +572,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); @@ -580,5 +581,35 @@ 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); + } + 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/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); + } + } } }