Skip to content

Commit e2827a1

Browse files
Re-enable line breakpoints for untitled scripts (#1724)
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.
1 parent 3fa3443 commit e2827a1

File tree

3 files changed

+60
-12
lines changed

3 files changed

+60
-12
lines changed

src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs

+56-9
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4+
using System.Management.Automation;
5+
using System.Management.Automation.Language;
46
using System.Threading;
57
using System.Threading.Tasks;
68
using Microsoft.Extensions.Logging;
79
using Microsoft.PowerShell.EditorServices.Services;
10+
using Microsoft.PowerShell.EditorServices.Services.DebugAdapter;
811
using Microsoft.PowerShell.EditorServices.Services.PowerShell;
912
using Microsoft.PowerShell.EditorServices.Services.PowerShell.Debugging;
1013
using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution;
14+
using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace;
1115
using Microsoft.PowerShell.EditorServices.Services.TextDocument;
1216
using Microsoft.PowerShell.EditorServices.Utility;
1317
using OmniSharp.Extensions.DebugAdapter.Protocol.Events;
@@ -18,6 +22,9 @@ namespace Microsoft.PowerShell.EditorServices.Handlers
1822
{
1923
internal class ConfigurationDoneHandler : IConfigurationDoneHandler
2024
{
25+
// TODO: We currently set `WriteInputToHost` as true, which writes our debugged commands'
26+
// `GetInvocationText` and that reveals some obscure implementation details we should
27+
// instead hide from the user with pretty strings (or perhaps not write out at all).
2128
private static readonly PowerShellExecutionOptions s_debuggerExecutionOptions = new()
2229
{
2330
MustRunInForeground = true,
@@ -35,7 +42,10 @@ internal class ConfigurationDoneHandler : IConfigurationDoneHandler
3542
private readonly IInternalPowerShellExecutionService _executionService;
3643
private readonly WorkspaceService _workspaceService;
3744
private readonly IPowerShellDebugContext _debugContext;
45+
private readonly IRunspaceContext _runspaceContext;
3846

47+
// TODO: Decrease these arguments since they're a bunch of interfaces that can be simplified
48+
// (i.e., `IRunspaceContext` should just be available on `IPowerShellExecutionService`).
3949
public ConfigurationDoneHandler(
4050
ILoggerFactory loggerFactory,
4151
IDebugAdapterServerFacade debugAdapterServer,
@@ -44,7 +54,8 @@ public ConfigurationDoneHandler(
4454
DebugEventHandlerService debugEventHandlerService,
4555
IInternalPowerShellExecutionService executionService,
4656
WorkspaceService workspaceService,
47-
IPowerShellDebugContext debugContext)
57+
IPowerShellDebugContext debugContext,
58+
IRunspaceContext runspaceContext)
4859
{
4960
_logger = loggerFactory.CreateLogger<ConfigurationDoneHandler>();
5061
_debugAdapterServer = debugAdapterServer;
@@ -54,6 +65,7 @@ public ConfigurationDoneHandler(
5465
_executionService = executionService;
5566
_workspaceService = workspaceService;
5667
_debugContext = debugContext;
68+
_runspaceContext = runspaceContext;
5769
}
5870

5971
public Task<ConfigurationDoneResponse> Handle(ConfigurationDoneArguments request, CancellationToken cancellationToken)
@@ -90,16 +102,51 @@ public Task<ConfigurationDoneResponse> Handle(ConfigurationDoneArguments request
90102

91103
private async Task LaunchScriptAsync(string scriptToLaunch)
92104
{
93-
// TODO: Theoretically we can make PowerShell respect line breakpoints in untitled
94-
// files, but the previous method was a hack that conflicted with correct passing of
95-
// arguments to the debugged script. We are prioritizing the latter over the former, as
96-
// command breakpoints and `Wait-Debugger` work fine.
97-
string command = ScriptFile.IsUntitledPath(scriptToLaunch)
98-
? string.Concat("{ ", _workspaceService.GetFile(scriptToLaunch).Contents, " }")
99-
: string.Concat('"', scriptToLaunch, '"');
105+
PSCommand command;
106+
if (ScriptFile.IsUntitledPath(scriptToLaunch))
107+
{
108+
ScriptFile untitledScript = _workspaceService.GetFile(scriptToLaunch);
109+
if (BreakpointApiUtils.SupportsBreakpointApis(_runspaceContext.CurrentRunspace))
110+
{
111+
// Parse untitled files with their `Untitled:` URI as the filename which will
112+
// cache the URI and contents within the PowerShell parser. By doing this, we
113+
// light up the ability to debug untitled files with line breakpoints. This is
114+
// only possible with PowerShell 7's new breakpoint APIs since the old API,
115+
// Set-PSBreakpoint, validates that the given path points to a real file.
116+
ScriptBlockAst ast = Parser.ParseInput(
117+
untitledScript.Contents,
118+
untitledScript.DocumentUri.ToString(),
119+
out Token[] _,
120+
out ParseError[] _);
121+
122+
// In order to use utilize the parser's cache (and therefore hit line
123+
// breakpoints) we need to use the AST's `ScriptBlock` object. Due to
124+
// limitations in PowerShell's public API, this means we must use the
125+
// `PSCommand.AddArgument(object)` method, hence this hack where we dot-source
126+
// `$args[0]. Fortunately the dot-source operator maintains a stack of arguments
127+
// on each invocation, so passing the user's arguments directly in the initial
128+
// `AddScript` surprisingly works.
129+
command = PSCommandHelpers
130+
.BuildDotSourceCommandWithArguments("$args[0]", _debugStateService.Arguments)
131+
.AddArgument(ast.GetScriptBlock());
132+
}
133+
else
134+
{
135+
// Without the new APIs we can only execute the untitled script's contents.
136+
// Command breakpoints and `Wait-Debugger` will work.
137+
command = PSCommandHelpers.BuildDotSourceCommandWithArguments(
138+
string.Concat("{ ", untitledScript.Contents, " }"), _debugStateService.Arguments);
139+
}
140+
}
141+
else
142+
{
143+
// For a saved file we just execute its path (after escaping it).
144+
command = PSCommandHelpers.BuildDotSourceCommandWithArguments(
145+
string.Concat('"', scriptToLaunch, '"'), _debugStateService.Arguments);
146+
}
100147

101148
await _executionService.ExecutePSCommandAsync(
102-
PSCommandHelpers.BuildCommandFromArguments(command, _debugStateService.Arguments),
149+
command,
103150
CancellationToken.None,
104151
s_debuggerExecutionOptions).ConfigureAwait(false);
105152
_debugAdapterServer.SendNotification(EventNames.Terminated);

src/PowerShellEditorServices/Utility/PSCommandExtensions.cs

+3-2
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,11 @@ private static StringBuilder AddCommandText(this StringBuilder sb, Command comma
129129
return sb;
130130
}
131131

132-
public static PSCommand BuildCommandFromArguments(string command, IEnumerable<string> arguments)
132+
public static PSCommand BuildDotSourceCommandWithArguments(string command, IEnumerable<string> arguments)
133133
{
134+
string args = string.Join(" ", arguments ?? Array.Empty<string>());
135+
string script = string.Concat(". ", command, string.IsNullOrEmpty(args) ? "" : " ", args);
134136
// HACK: We use AddScript instead of AddArgument/AddParameter to reuse Powershell parameter binding logic.
135-
string script = string.Concat(". ", command, " ", string.Join(" ", arguments ?? Array.Empty<string>()));
136137
return new PSCommand().AddScript(script);
137138
}
138139
}

test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ private VariableDetailsBase[] GetVariables(string scopeName)
9696
private Task ExecutePowerShellCommand(string command, params string[] args)
9797
{
9898
return psesHost.ExecutePSCommandAsync(
99-
PSCommandHelpers.BuildCommandFromArguments(string.Concat('"', command, '"'), args),
99+
PSCommandHelpers.BuildDotSourceCommandWithArguments(string.Concat('"', command, '"'), args),
100100
CancellationToken.None);
101101
}
102102

0 commit comments

Comments
 (0)