diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/DidChangeWatchedFilesHandler.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/DidChangeWatchedFilesHandler.cs index aa4c0a969..d018f7c6e 100644 --- a/src/PowerShellEditorServices/Services/TextDocument/Handlers/DidChangeWatchedFilesHandler.cs +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/DidChangeWatchedFilesHandler.cs @@ -68,7 +68,11 @@ public Task Handle(DidChangeWatchedFilesParams request, CancellationToken matcher.AddExcludePatterns(_workspaceService.ExcludeFilesGlob); foreach (FileEvent change in request.Changes) { - if (matcher.Match(change.Uri.GetFileSystemPath()).HasMatches) + string changePath = change.Uri.ToUri().IsFile + ? change.Uri.GetFileSystemPath() + : change.Uri.ToUri().AbsolutePath; + + if (matcher.Match(changePath).HasMatches) { continue; } @@ -102,7 +106,7 @@ public Task Handle(DidChangeWatchedFilesParams request, CancellationToken string fileContents; try { - fileContents = WorkspaceService.ReadFileContents(change.Uri); + fileContents = _workspaceService.ReadFileContents(change.Uri); } catch { diff --git a/src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs b/src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs index 9cff31d41..288dafd65 100644 --- a/src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs +++ b/src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs @@ -196,12 +196,31 @@ internal static List GetLines(string text) /// True if the path is an untitled file, false otherwise. internal static bool IsUntitledPath(string path) { - Validate.IsNotNull(nameof(path), path); - // This may not have been given a URI, so return false instead of throwing. - return Uri.IsWellFormedUriString(path, UriKind.RelativeOrAbsolute) && - !string.Equals(DocumentUri.From(path).Scheme, Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase); + if (!Uri.IsWellFormedUriString(path, UriKind.RelativeOrAbsolute)) + { + return false; + } + + DocumentUri documentUri = DocumentUri.From(path); + string scheme = documentUri.Scheme?.ToLowerInvariant(); + if (!IsSupportedScheme(scheme)) + { + return false; + } + + return scheme switch + { + "inmemory" or "untitled" or "vscode-notebook-cell" => true, + _ => false, + }; } + internal static bool IsSupportedScheme(string scheme) => scheme?.ToLowerInvariant() switch + { + "file" or "inmemory" or "untitled" or "vscode-notebook-cell" or "pspath" => true, + _ => false, + }; + /// /// Gets a line from the file's contents. /// diff --git a/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs index 9b721387a..fd9c68e44 100644 --- a/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs +++ b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs @@ -6,10 +6,14 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Management.Automation; using System.Security; using System.Text; +using System.Threading; using Microsoft.Extensions.FileSystemGlobbing; using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host; using Microsoft.PowerShell.EditorServices.Services.TextDocument; using Microsoft.PowerShell.EditorServices.Services.Workspace; using Microsoft.PowerShell.EditorServices.Utility; @@ -51,9 +55,12 @@ internal class WorkspaceService "**/*" }; + private const string s_psPathScheme = "pspath"; + private readonly ILogger logger; private readonly Version powerShellVersion; private readonly ConcurrentDictionary workspaceFiles = new(); + private readonly PsesInternalHost psesInternalHost; #endregion @@ -100,11 +107,18 @@ public WorkspaceService(ILoggerFactory factory) FollowSymlinks = true; } + /// + /// Creates a new instance of the Workspace class backed by a PowerShell host. + /// + public WorkspaceService(ILoggerFactory factory, PsesInternalHost psesInternalHost) + : this(factory) => this.psesInternalHost = psesInternalHost; + #endregion #region Public Methods - public IEnumerable WorkspacePaths => WorkspaceFolders.Select(i => i.Uri.GetFileSystemPath()); + public IEnumerable WorkspacePaths => WorkspaceFolders.Select( + folder => folder.Uri.ToUri().IsFile ? folder.Uri.GetFileSystemPath() : GetPowerShellPath(folder.Uri)); /// /// Gets an open file in the workspace. If the file isn't open but exists on the filesystem, load and return it. @@ -139,18 +153,8 @@ public ScriptFile GetFile(DocumentUri documentUri) // Make sure the file isn't already loaded into the workspace if (!workspaceFiles.TryGetValue(keyName, out ScriptFile scriptFile)) { - // This method allows FileNotFoundException to bubble up - // if the file isn't found. - using (StreamReader streamReader = OpenStreamReader(documentUri)) - { - scriptFile = - new ScriptFile( - documentUri, - streamReader, - powerShellVersion); - - workspaceFiles[keyName] = scriptFile; - } + scriptFile = ScriptFile.Create(documentUri, ReadFileContents(documentUri), powerShellVersion); + workspaceFiles[keyName] = scriptFile; logger.LogDebug("Opened file on disk: " + documentUri.ToString()); } @@ -192,18 +196,10 @@ public bool TryGetFile(Uri fileUri, out ScriptFile scriptFile) => /// The out parameter that will contain the ScriptFile object. public bool TryGetFile(DocumentUri documentUri, out ScriptFile scriptFile) { - switch (documentUri.Scheme) + if (!ScriptFile.IsSupportedScheme(documentUri.Scheme)) { - // List supported schemes here - case "file": - case "inmemory": - case "untitled": - case "vscode-notebook-cell": - break; - - default: - scriptFile = null; - return false; + scriptFile = null; + return false; } try @@ -396,11 +392,58 @@ public IEnumerable EnumeratePSFiles( int maxDepth, bool ignoreReparsePoints) { + string[] powerShellWorkspacePaths = GetPowerShellWorkspacePaths() + .Where(path => !string.IsNullOrEmpty(path)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + string[] fileSystemWorkspacePaths = GetFileSystemWorkspacePaths() + .Where(path => !string.IsNullOrEmpty(path)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (powerShellWorkspacePaths.Length == 0 && fileSystemWorkspacePaths.Length == 0) + { + yield break; + } + + if (psesInternalHost is not null && powerShellWorkspacePaths.Length > 0) + { + PSCommand psCommand = new PSCommand() + .AddCommand(@"Microsoft.PowerShell.Management\Get-ChildItem") + .AddParameter("LiteralPath", powerShellWorkspacePaths) + .AddParameter("Recurse") + .AddParameter("ErrorAction", ActionPreference.SilentlyContinue) + .AddParameter("Force") + .AddParameter("Include", includeGlobs.Concat(VersionUtils.IsNetCore ? s_psFileExtensionsCoreFramework : s_psFileExtensionsFullFramework).ToArray()) + .AddParameter("Exclude", excludeGlobs) + .AddParameter("Depth", maxDepth); + + if (VersionUtils.IsNetCore) + { + psCommand.AddParameter("FollowSymlink", !ignoreReparsePoints); + } + + psCommand + .AddCommand("Where-Object") + .AddParameter("Property", "PSIsContainer") + .AddParameter("EQ") + .AddParameter("Value", false); + + IReadOnlyList results = psesInternalHost.InvokePSCommand(psCommand, null, CancellationToken.None); + foreach (string path in results.Select(ConvertWorkspaceItemPath).Where(path => !string.IsNullOrEmpty(path))) + { + yield return path; + } + + yield break; + } + Matcher matcher = new(); foreach (string pattern in includeGlobs) { matcher.AddInclude(pattern); } foreach (string pattern in excludeGlobs) { matcher.AddExclude(pattern); } - foreach (string rootPath in WorkspacePaths) + foreach (string rootPath in fileSystemWorkspacePaths) { if (!Directory.Exists(rootPath)) { @@ -439,10 +482,133 @@ internal static StreamReader OpenStreamReader(DocumentUri uri) return new StreamReader(fileStream, new UTF8Encoding(), detectEncodingFromByteOrderMarks: true); } - internal static string ReadFileContents(DocumentUri uri) + internal string ReadFileContents(DocumentUri uri) { - using StreamReader reader = OpenStreamReader(uri); - return reader.ReadToEnd(); + if (uri.ToUri().IsFile || psesInternalHost is null) + { + using StreamReader reader = OpenStreamReader(uri); + return reader.ReadToEnd(); + } + + string psPath = GetPowerShellPath(uri); + try + { + IReadOnlyList result = psesInternalHost.InvokePSCommand( + new PSCommand() + .AddCommand(@"Microsoft.PowerShell.Management\Get-Content") + .AddParameter("LiteralPath", psPath) + .AddParameter("ErrorAction", ActionPreference.Stop), + new PowerShellExecutionOptions { ThrowOnError = true }, + CancellationToken.None); + + return string.Join(Environment.NewLine, result); + } + catch (ActionPreferenceStopException ex) + when (ex.ErrorRecord.CategoryInfo.Category == ErrorCategory.ObjectNotFound + && ex.ErrorRecord.TargetObject is string[] missingFiles + && missingFiles.Length == 1) + { + throw new FileNotFoundException(ex.ErrorRecord.ToString(), missingFiles[0], ex.ErrorRecord.Exception); + } + } + + // Return only file-backed workspace roots as filesystem paths. + // Example: + // file:///repo -> /repo + // pspath://FileSystem/C%3A/repo -> excluded + private IEnumerable GetFileSystemWorkspacePaths() + { + if (WorkspaceFolders.Count > 0) + { + return WorkspaceFolders + .Select(folder => folder.Uri) + .Where(uri => uri.ToUri().IsFile) + .Select(uri => uri.GetFileSystemPath()); + } + + return string.IsNullOrEmpty(InitialWorkingDirectory) + ? Array.Empty() + : new[] { InitialWorkingDirectory }; + } + + // Return only provider-backed workspace roots as PowerShell literal paths. + // Example: + // pspath://FileSystem/C%3A/repo -> FileSystem::C:/repo + // file:///repo -> excluded + private IEnumerable GetPowerShellWorkspacePaths() + { + if (WorkspaceFolders.Count > 0) + { + return WorkspaceFolders + .Select(folder => folder.Uri) + .Where(uri => !uri.ToUri().IsFile) + .Select(GetPowerShellPath); + } + + return Array.Empty(); + } + + // Normalize Get-ChildItem output to a workspace path string. + // Example: + // FullName=/repo/a.ps1 -> /repo/a.ps1 + // PSPath=Registry::HKEY_CURRENT_USER\\Software\\Foo -> pspath://Registry/HKEY_CURRENT_USER/Software/Foo + private static string ConvertWorkspaceItemPath(PSObject item) + { + if (item.Properties["FullName"]?.Value is string fullName && !string.IsNullOrEmpty(fullName)) + { + return fullName; + } + + return item.Properties["PSPath"]?.Value is string psPath && !string.IsNullOrEmpty(psPath) + ? CreatePowerShellPathUri(psPath) + : null; + } + + // Convert a document URI to the literal path PowerShell commands should use. + // Example: + // file:///repo/a.ps1 -> /repo/a.ps1 + // pspath://FileSystem/C%3A/repo/a.ps1 -> FileSystem::C:/repo/a.ps1 + private static string GetPowerShellPath(DocumentUri uri) + { + Uri parsedUri = uri.ToUri(); + if (parsedUri.IsFile) + { + return parsedUri.LocalPath; + } + + if (string.Equals(uri.Scheme, s_psPathScheme, StringComparison.OrdinalIgnoreCase)) + { + string provider = parsedUri.GetComponents(UriComponents.Host, UriFormat.Unescaped); + string path = Uri.UnescapeDataString(parsedUri.AbsolutePath); + if (path.Length >= 3 && path[0] == '/' && char.IsLetter(path[1]) && path[2] == ':') + { + path = path.TrimStart('/'); + } + + return string.IsNullOrEmpty(provider) + ? path.TrimStart('/') + : $"{provider}::{path}"; + } + + throw new NotSupportedException($"Unsupported URI scheme '{uri.Scheme}'."); + } + + // Convert a PowerShell provider path to the pspath:// document form used by the workspace. + // Example: + // FileSystem::C:\\repo\\a.ps1 -> pspath://FileSystem/C%3A/repo/a.ps1 + // Registry::HKEY_CURRENT_USER\\Software\\Foo -> pspath://Registry/HKEY_CURRENT_USER/Software/Foo + private static string CreatePowerShellPathUri(string psPath) + { + string[] parts = psPath.Split(new[] { "::" }, 2, StringSplitOptions.None); + if (parts.Length != 2) + { + return $"{s_psPathScheme}:///{Uri.EscapeDataString(psPath)}"; + } + + string provider = parts[0].Split('\\').Last(); + string normalizedPath = parts[1].Replace('\\', '/'); + string encodedPath = string.Join("/", normalizedPath.Split('/').Select(Uri.EscapeDataString)); + return $"{s_psPathScheme}://{Uri.EscapeDataString(provider)}/{encodedPath}"; } /// diff --git a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs index 3ba16008d..5142bdbde 100644 --- a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs +++ b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs @@ -42,6 +42,7 @@ public class DebugServiceTests : IAsyncLifetime private WorkspaceService workspace; private ScriptFile debugScriptFile; private ScriptFile oddPathScriptFile; + private ScriptFile psProviderPathScriptFile; private ScriptFile variableScriptFile; private readonly TestReadLine testReadLine = new(); @@ -70,10 +71,19 @@ public async Task InitializeAsync() debugService.DebuggerStopped += OnDebuggerStopped; // Load the test debug files. - workspace = new WorkspaceService(NullLoggerFactory.Instance); + workspace = new WorkspaceService(NullLoggerFactory.Instance, psesHost); debugScriptFile = GetDebugScript("DebugTest.ps1"); oddPathScriptFile = GetDebugScript("Debug' W&ith $Params [Test].ps1"); variableScriptFile = GetDebugScript("VariableTest.ps1"); + + string variableScriptFilePath = TestUtilities.GetSharedPath(Path.Combine("Debugging", "VariableTest.ps1")); + dynamic psItem = (await psesHost.ExecutePSCommandAsync( + new PSCommand() + .AddCommand("Get-Item") + .AddParameter("LiteralPath", variableScriptFilePath), + CancellationToken.None)).First(); + + psProviderPathScriptFile = workspace.GetFile(ConvertPSPathToUri((string)psItem.PSPath.ToString())); } public async Task DisposeAsync() @@ -94,6 +104,19 @@ public async Task DisposeAsync() [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD110:Observe result of async calls", Justification = "This intentionally fires and forgets on another thread.")] private void OnDebuggerStopped(object sender, DebuggerStoppedEventArgs e) => Task.Run(() => debuggerStoppedQueue.Add(e)); + // Convert a PowerShell provider path into the pspath:// URI form used by workspace tests. + // Example: + // FileSystem::C:\\repo\\a.ps1 -> pspath://FileSystem/C%3A/repo/a.ps1 + // Registry::HKEY_CURRENT_USER\\Software\\Foo -> pspath://Registry/HKEY_CURRENT_USER/Software/Foo + private static string ConvertPSPathToUri(string psPath) + { + string[] parts = psPath.Split(new[] { "::" }, 2, StringSplitOptions.None); + string provider = parts[0].Split('\\').Last(); + string normalizedPath = parts[1].Replace('\\', '/'); + string encodedPath = string.Join("/", normalizedPath.Split('/').Select(Uri.EscapeDataString)); + return $"pspath://{Uri.EscapeDataString(provider)}/{encodedPath}"; + } + private ScriptFile GetDebugScript(string fileName) => workspace.GetFile(TestUtilities.GetSharedPath(Path.Combine("Debugging", fileName))); private Task GetVariables(string scopeName) @@ -626,6 +649,20 @@ public async Task OddFilePathsLaunchCorrectly() Assert.Equal(". " + PSCommandHelpers.EscapeScriptFilePath(oddPathScriptFile.FilePath), Assert.Single(historyResult)); } + [Fact] + public async Task PSProviderPathsLaunchCorrectly() + { + ConfigurationDoneHandler configurationDoneHandler = new( + NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null); + await configurationDoneHandler.LaunchScriptAsync(psProviderPathScriptFile.FilePath); + + IReadOnlyList historyResult = await psesHost.ExecutePSCommandAsync( + new PSCommand().AddScript("(Get-History).CommandLine"), + CancellationToken.None); + + Assert.Equal(". $args[0]", Assert.Single(historyResult)); + } + [Fact] public async Task DebuggerVariableStringDisplaysCorrectly() { diff --git a/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs b/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs index 11c27c4cf..7a1239ea8 100644 --- a/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs +++ b/test/PowerShellEditorServices.Test/Session/ScriptFileTests.cs @@ -665,8 +665,9 @@ public void DocumentUriReturnsCorrectStringForAbsolutePath() [InlineData(@"C:\Users\me\Documents\test.ps1", false)] [InlineData("/Users/me/Documents/test.ps1", false)] [InlineData("vscode-notebook-cell:/Users/me/Documents/test.ps1#0001", true)] - [InlineData("https://microsoft.com", true)] + [InlineData("https://microsoft.com", false)] [InlineData("Untitled:Untitled-1", true)] + [InlineData("pspath://filesystem/C%3A/Users/me/Documents/test.ps1", false)] [InlineData(@"'a log statement' > 'c:\Users\me\Documents\test.txt' ", false)] public void IsUntitledFileIsCorrect(string path, bool expected) => Assert.Equal(expected, ScriptFile.IsUntitledPath(path));