From 30080f3d353fdda272a0aeff70d4dc2ee7096b87 Mon Sep 17 00:00:00 2001 From: Andrew Schwartzmeyer Date: Wed, 23 Feb 2022 11:00:52 -0800 Subject: [PATCH] Re-enable line breakpoints for untitled scripts We managed to make the previous hack work while continuing to support passing the users' arguments. As there was demand for this feature to continue working, despite being a hack, we're keeping it. --- .../Handlers/ConfigurationDoneHandler.cs | 65 ++++++++++++++++--- .../Utility/PSCommandExtensions.cs | 5 +- .../Debugging/DebugServiceTests.cs | 2 +- 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs index 01bf70190..74595831e 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs @@ -1,13 +1,17 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Management.Automation; +using System.Management.Automation.Language; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.DebugAdapter; using Microsoft.PowerShell.EditorServices.Services.PowerShell; using Microsoft.PowerShell.EditorServices.Services.PowerShell.Debugging; using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution; +using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace; using Microsoft.PowerShell.EditorServices.Services.TextDocument; using Microsoft.PowerShell.EditorServices.Utility; using OmniSharp.Extensions.DebugAdapter.Protocol.Events; @@ -18,6 +22,9 @@ namespace Microsoft.PowerShell.EditorServices.Handlers { internal class ConfigurationDoneHandler : IConfigurationDoneHandler { + // TODO: We currently set `WriteInputToHost` as true, which writes our debugged commands' + // `GetInvocationText` and that reveals some obscure implementation details we should + // instead hide from the user with pretty strings (or perhaps not write out at all). private static readonly PowerShellExecutionOptions s_debuggerExecutionOptions = new() { MustRunInForeground = true, @@ -35,7 +42,10 @@ internal class ConfigurationDoneHandler : IConfigurationDoneHandler private readonly IInternalPowerShellExecutionService _executionService; private readonly WorkspaceService _workspaceService; private readonly IPowerShellDebugContext _debugContext; + private readonly IRunspaceContext _runspaceContext; + // TODO: Decrease these arguments since they're a bunch of interfaces that can be simplified + // (i.e., `IRunspaceContext` should just be available on `IPowerShellExecutionService`). public ConfigurationDoneHandler( ILoggerFactory loggerFactory, IDebugAdapterServerFacade debugAdapterServer, @@ -44,7 +54,8 @@ public ConfigurationDoneHandler( DebugEventHandlerService debugEventHandlerService, IInternalPowerShellExecutionService executionService, WorkspaceService workspaceService, - IPowerShellDebugContext debugContext) + IPowerShellDebugContext debugContext, + IRunspaceContext runspaceContext) { _logger = loggerFactory.CreateLogger(); _debugAdapterServer = debugAdapterServer; @@ -54,6 +65,7 @@ public ConfigurationDoneHandler( _executionService = executionService; _workspaceService = workspaceService; _debugContext = debugContext; + _runspaceContext = runspaceContext; } public Task Handle(ConfigurationDoneArguments request, CancellationToken cancellationToken) @@ -90,16 +102,51 @@ public Task Handle(ConfigurationDoneArguments request private async Task LaunchScriptAsync(string scriptToLaunch) { - // TODO: Theoretically we can make PowerShell respect line breakpoints in untitled - // files, but the previous method was a hack that conflicted with correct passing of - // arguments to the debugged script. We are prioritizing the latter over the former, as - // command breakpoints and `Wait-Debugger` work fine. - string command = ScriptFile.IsUntitledPath(scriptToLaunch) - ? string.Concat("{ ", _workspaceService.GetFile(scriptToLaunch).Contents, " }") - : string.Concat('"', scriptToLaunch, '"'); + PSCommand command; + if (ScriptFile.IsUntitledPath(scriptToLaunch)) + { + ScriptFile untitledScript = _workspaceService.GetFile(scriptToLaunch); + if (BreakpointApiUtils.SupportsBreakpointApis(_runspaceContext.CurrentRunspace)) + { + // Parse untitled files with their `Untitled:` URI as the filename which will + // cache the URI and contents within the PowerShell parser. By doing this, we + // light up the ability to debug untitled files with line breakpoints. This is + // only possible with PowerShell 7's new breakpoint APIs since the old API, + // Set-PSBreakpoint, validates that the given path points to a real file. + ScriptBlockAst ast = Parser.ParseInput( + untitledScript.Contents, + untitledScript.DocumentUri.ToString(), + out Token[] _, + out ParseError[] _); + + // In order to use utilize the parser's cache (and therefore hit line + // breakpoints) we need to use the AST's `ScriptBlock` object. Due to + // limitations in PowerShell's public API, this means we must use the + // `PSCommand.AddArgument(object)` method, hence this hack where we dot-source + // `$args[0]. Fortunately the dot-source operator maintains a stack of arguments + // on each invocation, so passing the user's arguments directly in the initial + // `AddScript` surprisingly works. + command = PSCommandHelpers + .BuildDotSourceCommandWithArguments("$args[0]", _debugStateService.Arguments) + .AddArgument(ast.GetScriptBlock()); + } + else + { + // Without the new APIs we can only execute the untitled script's contents. + // Command breakpoints and `Wait-Debugger` will work. + command = PSCommandHelpers.BuildDotSourceCommandWithArguments( + string.Concat("{ ", untitledScript.Contents, " }"), _debugStateService.Arguments); + } + } + else + { + // For a saved file we just execute its path (after escaping it). + command = PSCommandHelpers.BuildDotSourceCommandWithArguments( + string.Concat('"', scriptToLaunch, '"'), _debugStateService.Arguments); + } await _executionService.ExecutePSCommandAsync( - PSCommandHelpers.BuildCommandFromArguments(command, _debugStateService.Arguments), + command, CancellationToken.None, s_debuggerExecutionOptions).ConfigureAwait(false); _debugAdapterServer.SendNotification(EventNames.Terminated); diff --git a/src/PowerShellEditorServices/Utility/PSCommandExtensions.cs b/src/PowerShellEditorServices/Utility/PSCommandExtensions.cs index 1d5186184..0cd08927e 100644 --- a/src/PowerShellEditorServices/Utility/PSCommandExtensions.cs +++ b/src/PowerShellEditorServices/Utility/PSCommandExtensions.cs @@ -129,10 +129,11 @@ private static StringBuilder AddCommandText(this StringBuilder sb, Command comma return sb; } - public static PSCommand BuildCommandFromArguments(string command, IEnumerable arguments) + public static PSCommand BuildDotSourceCommandWithArguments(string command, IEnumerable arguments) { + string args = string.Join(" ", arguments ?? Array.Empty()); + string script = string.Concat(". ", command, string.IsNullOrEmpty(args) ? "" : " ", args); // HACK: We use AddScript instead of AddArgument/AddParameter to reuse Powershell parameter binding logic. - string script = string.Concat(". ", command, " ", string.Join(" ", arguments ?? Array.Empty())); return new PSCommand().AddScript(script); } } diff --git a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs index 34be3a890..538ff489f 100644 --- a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs +++ b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs @@ -96,7 +96,7 @@ private VariableDetailsBase[] GetVariables(string scopeName) private Task ExecutePowerShellCommand(string command, params string[] args) { return psesHost.ExecutePSCommandAsync( - PSCommandHelpers.BuildCommandFromArguments(string.Concat('"', command, '"'), args), + PSCommandHelpers.BuildDotSourceCommandWithArguments(string.Concat('"', command, '"'), args), CancellationToken.None); }