diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/DidChangeWatchedFilesHandler.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/DidChangeWatchedFilesHandler.cs index edbe9b0ad..70cdc067b 100644 --- a/src/PowerShellEditorServices/Services/TextDocument/Handlers/DidChangeWatchedFilesHandler.cs +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/DidChangeWatchedFilesHandler.cs @@ -66,7 +66,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; } @@ -100,7 +104,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 fb6772d2b..a1eb7a4fb 100644 --- a/src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs +++ b/src/PowerShellEditorServices/Services/TextDocument/ScriptFile.cs @@ -183,12 +183,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 ad9b04552..2b022f757 100644 --- a/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs +++ b/src/PowerShellEditorServices/Services/Workspace/WorkspaceService.cs @@ -6,10 +6,16 @@ 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; using OmniSharp.Extensions.LanguageServer.Protocol; using OmniSharp.Extensions.LanguageServer.Protocol.Models; @@ -24,15 +30,37 @@ internal class WorkspaceService { #region Private Fields + // List of all file extensions considered PowerShell files in the .Net Core Framework. + private static readonly string[] s_psFileExtensionsCoreFramework = + { + ".ps1", + ".psm1", + ".psd1" + }; + + // .Net Core doesn't appear to use the same three letter pattern matching rule although the docs + // suggest it should be find the '.ps1xml' files because we search for the pattern '*.ps1'. + // ref https://docs.microsoft.com/en-us/dotnet/api/system.io.directory.getfiles?view=netcore-2.1#System_IO_Directory_GetFiles_System_String_System_String_System_IO_EnumerationOptions_ + private static readonly string[] s_psFileExtensionsFullFramework = + { + ".ps1", + ".psm1", + ".psd1", + ".ps1xml" + }; + // An array of globs which includes everything. private static readonly string[] s_psIncludeAllGlob = new[] { "**/*" }; + private const string s_psPathScheme = "pspath"; + private readonly ILogger logger; private readonly Version powerShellVersion; private readonly ConcurrentDictionary workspaceFiles = new(); + private readonly PsesInternalHost psesInternalHost; #endregion @@ -79,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. @@ -118,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()); } @@ -171,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 @@ -281,7 +298,14 @@ public void CloseFile(ScriptFile scriptFile) Validate.IsNotNull(nameof(scriptFile), scriptFile); string keyName = GetFileKey(scriptFile.DocumentUri); - workspaceFiles.TryRemove(keyName, out ScriptFile _); + if (workspaceFiles.TryRemove(keyName, out ScriptFile _)) + { + logger.LogDebug("Closed file: " + scriptFile.DocumentUri); + } + else + { + logger.LogWarning("Tried to close file that was not open: " + scriptFile.DocumentUri); + } } /// @@ -314,6 +338,35 @@ public string GetRelativePath(ScriptFile scriptFile) return fileUri.ToString(); } + /// + /// Finds a file in the first workspace folder where it exists, if possible. + /// Used as a backwards-compatible way to find files in the workspace. + /// + /// + /// Best possible path. + public string FindFileInWorkspace(string filePath) + { + // If the file path is already an absolute path, just return it. + if (Path.IsPathRooted(filePath)) + { + return filePath; + } + + // If the file path is relative, try to find it in the workspace folders. + foreach (WorkspaceFolder workspaceFolder in WorkspaceFolders) + { + string folderPath = workspaceFolder.Uri.GetFileSystemPath(); + string combinedPath = Path.Combine(folderPath, filePath); + if (File.Exists(combinedPath)) + { + return combinedPath; + } + } + + // If the file path is not found in the workspace folders, return the original path. + return filePath; + } + /// /// Enumerate all the PowerShell (ps1, psm1, psd1) files in the workspace in a recursive manner, using default values. /// @@ -337,7 +390,83 @@ public IEnumerable EnumeratePSFiles( string[] excludeGlobs, string[] includeGlobs, int maxDepth, - bool ignoreReparsePoints) => []; + 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 fileSystemWorkspacePaths) + { + if (!Directory.Exists(rootPath)) + { + continue; + } + + WorkspaceFileSystemWrapperFactory fsFactory = new( + rootPath, + maxDepth, + VersionUtils.IsNetCore ? s_psFileExtensionsCoreFramework : s_psFileExtensionsFullFramework, + ignoreReparsePoints, + logger); + + PatternMatchingResult fileMatchResult = matcher.Execute(fsFactory.RootDirectory); + foreach (FilePatternMatch item in fileMatchResult.Files) + { + // item.Path always contains forward slashes in paths when it should be backslashes on Windows. + // Since we're returning strings here, it's important to use the correct directory separator. + string path = VersionUtils.IsWindows ? item.Path.Replace('/', Path.DirectorySeparatorChar) : item.Path; + yield return Path.Combine(rootPath, path); + } + } + } #endregion @@ -353,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) + { + 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() { - using StreamReader reader = OpenStreamReader(uri); - return reader.ReadToEnd(); + 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}"; } internal string ResolveWorkspacePath(string path) => ResolveRelativeScriptPath(InitialWorkingDirectory, path); diff --git a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs index 30b020c30..5142bdbde 100644 --- a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs +++ b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs @@ -33,21 +33,22 @@ internal class TestReadLine : IReadLine } [Trait("Category", "DebugService")] - public class DebugServiceTests : IDisposable + public class DebugServiceTests : IAsyncLifetime { - private readonly PsesInternalHost psesHost; - private readonly BreakpointService breakpointService; - private readonly DebugService debugService; + private PsesInternalHost psesHost; + private BreakpointService breakpointService; + private DebugService debugService; private readonly BlockingCollection debuggerStoppedQueue = new(); - private readonly WorkspaceService workspace; - private readonly ScriptFile debugScriptFile; - private readonly ScriptFile oddPathScriptFile; - private readonly ScriptFile variableScriptFile; + private WorkspaceService workspace; + private ScriptFile debugScriptFile; + private ScriptFile oddPathScriptFile; + private ScriptFile psProviderPathScriptFile; + private ScriptFile variableScriptFile; private readonly TestReadLine testReadLine = new(); - public DebugServiceTests() + public async Task InitializeAsync() { - psesHost = PsesHostFactory.Create(NullLoggerFactory.Instance); + psesHost = await PsesHostFactory.Create(NullLoggerFactory.Instance); // This is required for remote debugging, but we call it here to end up in the same // state as the usual startup path. psesHost.DebugContext.EnableDebugMode(); @@ -70,32 +71,51 @@ public DebugServiceTests() 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 void Dispose() + public async Task DisposeAsync() { debugService.Abort(); + await Task.Run(psesHost.StopAsync); debuggerStoppedQueue.Dispose(); -#pragma warning disable VSTHRD002 - psesHost.StopAsync().Wait(); -#pragma warning restore VSTHRD002 - GC.SuppressFinalize(this); } /// - /// This event handler lets us test that the debugger stopped or paused as expected. It will - /// deadlock if called in the PSES Pipeline Thread, which can easily happen in this test - /// code when methods on are called. Hence we treat this test - /// code like UI code and use 'ConfigureAwait(true)' or 'Task.Run(...)' to ensure we stay - /// OFF the pipeline thread. + /// This event handler lets us test that the debugger stopped or paused + /// as expected. It will deadlock if called in the PSES Pipeline Thread. + /// Hence we use 'Task.Run(...)' when accessing the queue to ensure we + /// stay OFF the pipeline thread. /// /// /// - private void OnDebuggerStopped(object sender, DebuggerStoppedEventArgs e) => debuggerStoppedQueue.Add(e); + [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))); @@ -118,20 +138,20 @@ private Task ExecuteScriptFileAsync(string scriptFilePath, params string[] args) private Task ExecuteVariableScriptFileAsync() => ExecuteScriptFileAsync(variableScriptFile.FilePath); - private void AssertDebuggerPaused() + private async Task AssertDebuggerPaused() { using CancellationTokenSource cts = new(60000); - DebuggerStoppedEventArgs eventArgs = debuggerStoppedQueue.Take(cts.Token); + DebuggerStoppedEventArgs eventArgs = await Task.Run(() => debuggerStoppedQueue.Take(cts.Token)); Assert.Empty(eventArgs.OriginalEvent.Breakpoints); } - private void AssertDebuggerStopped( + private async Task AssertDebuggerStopped( string scriptPath = "", int lineNumber = -1, CommandBreakpointDetails commandBreakpointDetails = default) { - using CancellationTokenSource cts = new(60000); - DebuggerStoppedEventArgs eventArgs = debuggerStoppedQueue.Take(cts.Token); + using CancellationTokenSource cts = new(30000); + DebuggerStoppedEventArgs eventArgs = await Task.Run(() => debuggerStoppedQueue.Take(cts.Token)); Assert.True(psesHost.DebugContext.IsStopped); @@ -176,8 +196,8 @@ await debugService.SetCommandBreakpointsAsync( Task> executeTask = psesHost.ExecutePSCommandAsync( new PSCommand().AddScript("Get-Random -SetSeed 42 -Maximum 100"), CancellationToken.None); - AssertDebuggerStopped("", 1); - await Task.Run(debugService.Continue); + await AssertDebuggerStopped("", 1); + debugService.Continue(); Assert.Equal(17, (await executeTask)[0]); StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); @@ -204,7 +224,7 @@ await debugService.SetCommandBreakpointsAsync( public async Task DebuggerAcceptsScriptArgs(string[] args) { IReadOnlyList breakpoints = await debugService.SetLineBreakpointsAsync( - oddPathScriptFile, + oddPathScriptFile.FilePath, new[] { BreakpointDetails.Create(oddPathScriptFile.FilePath, 3) }); Assert.Single(breakpoints); @@ -218,7 +238,7 @@ public async Task DebuggerAcceptsScriptArgs(string[] args) Task _ = ExecuteScriptFileAsync(oddPathScriptFile.FilePath, args); - AssertDebuggerStopped(oddPathScriptFile.FilePath, 3); + await AssertDebuggerStopped(oddPathScriptFile.FilePath, 3); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -285,7 +305,7 @@ public async Task DebuggerStopsOnFunctionBreakpoints() new[] { CommandBreakpointDetails.Create("Write-Host") }); Task _ = ExecuteDebugFileAsync(); - AssertDebuggerStopped(debugScriptFile.FilePath, 6); + await AssertDebuggerStopped(debugScriptFile.FilePath, 6); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -296,8 +316,8 @@ public async Task DebuggerStopsOnFunctionBreakpoints() Assert.Equal("1", i.ValueString); // The function breakpoint should fire the next time through the loop. - await Task.Run(debugService.Continue); - AssertDebuggerStopped(debugScriptFile.FilePath, 6); + debugService.Continue(); + await AssertDebuggerStopped(debugScriptFile.FilePath, 6); variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -313,7 +333,7 @@ public async Task DebuggerSetsAndClearsLineBreakpoints() { IReadOnlyList breakpoints = await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 5), BreakpointDetails.Create(debugScriptFile.FilePath, 10) @@ -326,7 +346,7 @@ await debugService.SetLineBreakpointsAsync( Assert.Equal(10, breakpoints[1].LineNumber); breakpoints = await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 2) }); confirmedBreakpoints = await GetConfirmedBreakpoints(debugScriptFile); @@ -334,7 +354,7 @@ await debugService.SetLineBreakpointsAsync( Assert.Equal(2, breakpoints[0].LineNumber); await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, Array.Empty()); IReadOnlyList remainingBreakpoints = await GetConfirmedBreakpoints(debugScriptFile); @@ -345,16 +365,16 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerStopsOnLineBreakpoints() { await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 5), BreakpointDetails.Create(debugScriptFile.FilePath, 7) }); Task _ = ExecuteDebugFileAsync(); - AssertDebuggerStopped(debugScriptFile.FilePath, 5); - await Task.Run(debugService.Continue); - AssertDebuggerStopped(debugScriptFile.FilePath, 7); + await AssertDebuggerStopped(debugScriptFile.FilePath, 5); + debugService.Continue(); + await AssertDebuggerStopped(debugScriptFile.FilePath, 7); } [Fact] @@ -364,13 +384,13 @@ public async Task DebuggerStopsOnConditionalBreakpoints() const int breakpointValue2 = 20; await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 7, null, $"$i -eq {breakpointValue1} -or $i -eq {breakpointValue2}"), }); Task _ = ExecuteDebugFileAsync(); - AssertDebuggerStopped(debugScriptFile.FilePath, 7); + await AssertDebuggerStopped(debugScriptFile.FilePath, 7); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -382,8 +402,8 @@ await debugService.SetLineBreakpointsAsync( // The conditional breakpoint should not fire again, until the value of // i reaches breakpointValue2. - await Task.Run(debugService.Continue); - AssertDebuggerStopped(debugScriptFile.FilePath, 7); + debugService.Continue(); + await AssertDebuggerStopped(debugScriptFile.FilePath, 7); variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -400,13 +420,13 @@ public async Task DebuggerStopsOnHitConditionBreakpoint() const int hitCount = 5; await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 6, null, null, $"{hitCount}"), }); Task _ = ExecuteDebugFileAsync(); - AssertDebuggerStopped(debugScriptFile.FilePath, 6); + await AssertDebuggerStopped(debugScriptFile.FilePath, 6); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -423,11 +443,11 @@ public async Task DebuggerStopsOnConditionalAndHitConditionBreakpoint() const int hitCount = 5; await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 6, null, "$i % 2 -eq 0", $"{hitCount}") }); Task _ = ExecuteDebugFileAsync(); - AssertDebuggerStopped(debugScriptFile.FilePath, 6); + await AssertDebuggerStopped(debugScriptFile.FilePath, 6); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -444,7 +464,7 @@ public async Task DebuggerProvidesMessageForInvalidConditionalBreakpoint() { IReadOnlyList breakpoints = await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { // TODO: Add this breakpoint back when it stops moving around?! The ordering // of these two breakpoints seems to do with which framework executes the @@ -472,7 +492,7 @@ public async Task DebuggerFindsParsableButInvalidSimpleBreakpointConditions() { IReadOnlyList breakpoints = await debugService.SetLineBreakpointsAsync( - debugScriptFile, + debugScriptFile.FilePath, new[] { BreakpointDetails.Create(debugScriptFile.FilePath, 5, column: null, condition: "$i == 100"), BreakpointDetails.Create(debugScriptFile.FilePath, 7, column: null, condition: "$i > 100") @@ -495,18 +515,16 @@ public async Task DebuggerBreaksWhenRequested() IReadOnlyList confirmedBreakpoints = await GetConfirmedBreakpoints(debugScriptFile); Assert.Empty(confirmedBreakpoints); Task _ = ExecuteDebugFileAsync(); - // NOTE: This must be run on a separate thread so the async event handlers can fire. - await Task.Run(debugService.Break); - AssertDebuggerPaused(); + debugService.Break(); + await AssertDebuggerPaused(); } [Fact] public async Task DebuggerRunsCommandsWhileStopped() { Task _ = ExecuteDebugFileAsync(); - // NOTE: This must be run on a separate thread so the async event handlers can fire. - await Task.Run(debugService.Break); - AssertDebuggerPaused(); + debugService.Break(); + await AssertDebuggerPaused(); // Try running a command from outside the pipeline thread Task> executeTask = psesHost.ExecutePSCommandAsync( @@ -526,16 +544,17 @@ await debugService.SetCommandBreakpointsAsync( ScriptFile testScript = GetDebugScript("PSDebugContextTest.ps1"); Task _ = ExecuteScriptFileAsync(testScript.FilePath); - AssertDebuggerStopped(testScript.FilePath, 11); + await AssertDebuggerStopped(testScript.FilePath, 11); VariableDetails prompt = await debugService.EvaluateExpressionAsync("prompt", false, CancellationToken.None); Assert.Equal("True > ", prompt.ValueString); } - [SkippableFact] - public async Task DebuggerBreaksInUntitledScript() + [Theory] + [InlineData("Command")] + [InlineData("Line")] + public async Task DebuggerBreaksInUntitledScript(string breakpointType) { - Skip.IfNot(VersionUtils.PSEdition == "Core", "Untitled script breakpoints only supported in PowerShell Core"); const string contents = "Write-Output $($MyInvocation.Line)"; const string scriptPath = "untitled:Untitled-1"; Assert.True(ScriptFile.IsUntitledPath(scriptPath)); @@ -544,14 +563,23 @@ public async Task DebuggerBreaksInUntitledScript() Assert.Equal(contents, scriptFile.Contents); Assert.True(workspace.TryGetFile(scriptPath, out ScriptFile _)); - await debugService.SetCommandBreakpointsAsync( - new[] { CommandBreakpointDetails.Create("Write-Output") }); + if (breakpointType == "Command") + { + await debugService.SetCommandBreakpointsAsync( + new[] { CommandBreakpointDetails.Create("Write-Output") }); + } + else + { + await debugService.SetLineBreakpointsAsync( + scriptFile.FilePath, + new[] { BreakpointDetails.Create(scriptPath, 1) }); + } ConfigurationDoneHandler configurationDoneHandler = new( - NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null, psesHost); + NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null); Task _ = configurationDoneHandler.LaunchScriptAsync(scriptPath); - AssertDebuggerStopped(scriptPath, 1); + await AssertDebuggerStopped(scriptPath, 1); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.CommandVariablesName); VariableDetailsBase myInvocation = Array.Find(variables, v => v.Name == "$MyInvocation"); @@ -570,7 +598,7 @@ await debugService.SetCommandBreakpointsAsync( public async Task RecordsF5CommandInPowerShellHistory() { ConfigurationDoneHandler configurationDoneHandler = new( - NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null, psesHost); + NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null); await configurationDoneHandler.LaunchScriptAsync(debugScriptFile.FilePath); IReadOnlyList historyResult = await psesHost.ExecutePSCommandAsync( @@ -610,7 +638,7 @@ public async Task RecordsF8CommandInHistory() public async Task OddFilePathsLaunchCorrectly() { ConfigurationDoneHandler configurationDoneHandler = new( - NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null, psesHost); + NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null); await configurationDoneHandler.LaunchScriptAsync(oddPathScriptFile.FilePath); IReadOnlyList historyResult = await psesHost.ExecutePSCommandAsync( @@ -621,15 +649,29 @@ 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() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 8) }); Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -643,11 +685,11 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerGetsVariables() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 21) }); Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -693,11 +735,11 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerSetsVariablesNoConversion() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 14) }); Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); VariableScope[] scopes = debugService.GetVariableScopes(0); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -723,8 +765,8 @@ await debugService.SetLineBreakpointsAsync( // The above just tests that the debug service returns the correct new value string. // Let's step the debugger and make sure the values got set to the new values. - await Task.Run(debugService.StepOver); - AssertDebuggerStopped(variableScriptFile.FilePath); + debugService.StepOver(); + await AssertDebuggerStopped(variableScriptFile.FilePath); // Test set of a local string variable (not strongly typed) variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -746,12 +788,12 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerSetsVariablesWithConversion() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 14) }); // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); VariableScope[] scopes = debugService.GetVariableScopes(0); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -779,8 +821,8 @@ await debugService.SetLineBreakpointsAsync( // The above just tests that the debug service returns the correct new value string. // Let's step the debugger and make sure the values got set to the new values. - await Task.Run(debugService.StepOver); - AssertDebuggerStopped(variableScriptFile.FilePath); + debugService.StepOver(); + await AssertDebuggerStopped(variableScriptFile.FilePath); // Test set of a local string variable (not strongly typed but force conversion) variables = await GetVariables(VariableContainerDetails.LocalScopeName); @@ -802,12 +844,12 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerVariableEnumDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 15) }); // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); VariableDetailsBase[] variables = await debugService.GetVariables(stackFrames[0].AutoVariables.Id, CancellationToken.None); @@ -822,12 +864,12 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerVariableHashtableDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 11) }); // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); VariableDetailsBase[] variables = await debugService.GetVariables(stackFrames[0].AutoVariables.Id, CancellationToken.None); @@ -855,12 +897,12 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerVariableNullStringDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 16) }); // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); VariableDetailsBase[] variables = await debugService.GetVariables(stackFrames[0].AutoVariables.Id, CancellationToken.None); @@ -875,12 +917,12 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerVariablePSObjectDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 17) }); // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); VariableDetailsBase[] variables = await debugService.GetVariables(stackFrames[0].AutoVariables.Id, CancellationToken.None); @@ -907,7 +949,7 @@ public async Task DebuggerEnumerableShowsRawView() // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(commandBreakpointDetails: breakpoint); + await AssertDebuggerStopped(commandBreakpointDetails: breakpoint); VariableDetailsBase simpleArrayVar = Array.Find( await GetVariables(VariableContainerDetails.ScriptScopeName), @@ -964,7 +1006,7 @@ public async Task DebuggerDictionaryShowsRawView() // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(commandBreakpointDetails: breakpoint); + await AssertDebuggerStopped(commandBreakpointDetails: breakpoint); VariableDetailsBase simpleDictionaryVar = Array.Find( await GetVariables(VariableContainerDetails.ScriptScopeName), @@ -1027,7 +1069,7 @@ public async Task DebuggerDerivedDictionaryPropertyInRawView() // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(commandBreakpointDetails: breakpoint); + await AssertDebuggerStopped(commandBreakpointDetails: breakpoint); VariableDetailsBase sortedDictionaryVar = Array.Find( await GetVariables(VariableContainerDetails.ScriptScopeName), @@ -1071,12 +1113,12 @@ await GetVariables(VariableContainerDetails.ScriptScopeName), public async Task DebuggerVariablePSCustomObjectDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 18) }); // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); VariableDetailsBase[] variables = await debugService.GetVariables(stackFrames[0].AutoVariables.Id, CancellationToken.None); @@ -1100,12 +1142,12 @@ await debugService.SetLineBreakpointsAsync( public async Task DebuggerVariableProcessObjectDisplaysCorrectly() { await debugService.SetLineBreakpointsAsync( - variableScriptFile, + variableScriptFile.FilePath, new[] { BreakpointDetails.Create(variableScriptFile.FilePath, 19) }); // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(variableScriptFile.FilePath); + await AssertDebuggerStopped(variableScriptFile.FilePath); StackFrameDetails[] stackFrames = await debugService.GetStackFramesAsync(); VariableDetailsBase[] variables = await debugService.GetVariables(stackFrames[0].AutoVariables.Id, CancellationToken.None); @@ -1134,7 +1176,7 @@ await debugService.SetCommandBreakpointsAsync( ScriptFile testScript = GetDebugScript("GetChildItemTest.ps1"); Task _ = ExecuteScriptFileAsync(testScript.FilePath); - AssertDebuggerStopped(testScript.FilePath, 2); + await AssertDebuggerStopped(testScript.FilePath, 2); VariableDetailsBase[] variables = await GetVariables(VariableContainerDetails.LocalScopeName); VariableDetailsBase var = Array.Find(variables, v => v.Name == "$file"); @@ -1154,7 +1196,7 @@ public async Task DebuggerToStringShouldMarshallToPipeline() // Execute the script and wait for the breakpoint to be hit Task _ = ExecuteVariableScriptFileAsync(); - AssertDebuggerStopped(commandBreakpointDetails: breakpoint); + await AssertDebuggerStopped(commandBreakpointDetails: breakpoint); VariableDetailsBase[] vars = await GetVariables(VariableContainerDetails.ScriptScopeName); VariableDetailsBase customToStrings = Array.Find(vars, i => i.Name is "$CustomToStrings"); 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));