Skip to content

Commit 48c996c

Browse files
authored
Support breakpoints in untitled files in WinPS (#2248)
* Support breakpoints in untitled files in WinPS Adds support for setting breakpoints in untitled/unsaved files for Windows PowerShell 5.1. This aligns the breakpoint validation behaviour with the PowerShell 7.x API so that a breakpoint can be set for any ScriptBlock with a filename if it aligns with the client's filename. * Add fallback to Set-PSBreakpoint in case reflection can't find ctor * Fix up wildcard handling * Fix up command breakpoints for WinPS
1 parent 6bb322e commit 48c996c

File tree

5 files changed

+108
-31
lines changed

5 files changed

+108
-31
lines changed

src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,83 @@ namespace Microsoft.PowerShell.EditorServices.Services
1818
{
1919
internal class BreakpointService
2020
{
21+
/// <summary>
22+
/// Code used on WinPS 5.1 to set breakpoints without Script path validation.
23+
/// It uses reflection because the APIs were not public until 7.0 but just in
24+
/// case something changes it has a fallback to Set-PSBreakpoint.
25+
/// </summary>
26+
private const string _setPSBreakpointLegacy = @"
27+
[CmdletBinding(DefaultParameterSetName = 'Line')]
28+
param (
29+
[Parameter()]
30+
[ScriptBlock]
31+
$Action,
32+
33+
[Parameter(ParameterSetName = 'Command')]
34+
[Parameter(ParameterSetName = 'Line', Mandatory = $true)]
35+
[string]
36+
$Script,
37+
38+
[Parameter(ParameterSetName = 'Line')]
39+
[int]
40+
$Line,
41+
42+
[Parameter(ParameterSetName = 'Line')]
43+
[int]
44+
$Column,
45+
46+
[Parameter(ParameterSetName = 'Command', Mandatory = $true)]
47+
[string]
48+
$Command
49+
)
50+
51+
if ($Script) {
52+
# If using Set-PSBreakpoint we need to escape any wildcard patterns.
53+
$PSBoundParameters['Script'] = [WildcardPattern]::Escape($Script)
54+
}
55+
else {
56+
# WinPS must use null for the Script if unset.
57+
$Script = [NullString]::Value
58+
}
59+
60+
if ($PSCmdlet.ParameterSetName -eq 'Command') {
61+
$cmdCtor = [System.Management.Automation.CommandBreakpoint].GetConstructor(
62+
[System.Reflection.BindingFlags]'NonPublic, Public, Instance',
63+
$null,
64+
[type[]]@([string], [System.Management.Automation.WildcardPattern], [string], [ScriptBlock]),
65+
$null)
66+
67+
if (-not $cmdCtor) {
68+
Microsoft.PowerShell.Utility\Set-PSBreakpoint @PSBoundParameters
69+
return
70+
}
71+
72+
$pattern = [System.Management.Automation.WildcardPattern]::Get(
73+
$Command,
74+
[System.Management.Automation.WildcardOptions]'Compiled, IgnoreCase')
75+
$b = $cmdCtor.Invoke(@($Script, $pattern, $Command, $Action))
76+
}
77+
else {
78+
$lineCtor = [System.Management.Automation.LineBreakpoint].GetConstructor(
79+
[System.Reflection.BindingFlags]'NonPublic, Public, Instance',
80+
$null,
81+
[type[]]@([string], [int], [int], [ScriptBlock]),
82+
$null)
83+
84+
if (-not $lineCtor) {
85+
Microsoft.PowerShell.Utility\Set-PSBreakpoint @PSBoundParameters
86+
return
87+
}
88+
89+
$b = $lineCtor.Invoke(@($Script, $Line, $Column, $Action))
90+
}
91+
92+
[Runspace]::DefaultRunspace.Debugger.SetBreakpoints(
93+
[System.Management.Automation.Breakpoint[]]@($b))
94+
95+
$b
96+
";
97+
2198
private readonly ILogger<BreakpointService> _logger;
2299
private readonly IInternalPowerShellExecutionService _executionService;
23100
private readonly PsesInternalHost _editorServicesHost;
@@ -57,7 +134,7 @@ public async Task<IReadOnlyList<Breakpoint>> GetBreakpointsAsync()
57134
.ConfigureAwait(false);
58135
}
59136

60-
public async Task<IReadOnlyList<BreakpointDetails>> SetBreakpointsAsync(string escapedScriptPath, IReadOnlyList<BreakpointDetails> breakpoints)
137+
public async Task<IReadOnlyList<BreakpointDetails>> SetBreakpointsAsync(IReadOnlyList<BreakpointDetails> breakpoints)
61138
{
62139
if (BreakpointApiUtils.SupportsBreakpointApis(_editorServicesHost.CurrentRunspace))
63140
{
@@ -114,9 +191,11 @@ public async Task<IReadOnlyList<BreakpointDetails>> SetBreakpointsAsync(string e
114191
psCommand.AddStatement();
115192
}
116193

194+
// Don't use Set-PSBreakpoint as that will try and validate the Script
195+
// path which may or may not exist.
117196
psCommand
118-
.AddCommand(@"Microsoft.PowerShell.Utility\Set-PSBreakpoint")
119-
.AddParameter("Script", escapedScriptPath)
197+
.AddScript(_setPSBreakpointLegacy, useLocalScope: true)
198+
.AddParameter("Script", breakpoint.Source)
120199
.AddParameter("Line", breakpoint.LineNumber);
121200

122201
// Check if the user has specified the column number for the breakpoint.
@@ -184,7 +263,7 @@ public async Task<IReadOnlyList<CommandBreakpointDetails>> SetCommandBreakpoints
184263
}
185264

186265
psCommand
187-
.AddCommand(@"Microsoft.PowerShell.Utility\Set-PSBreakpoint")
266+
.AddScript(_setPSBreakpointLegacy, useLocalScope: true)
188267
.AddParameter("Command", breakpoint.Name);
189268

190269
// Check if this is a "conditional" line breakpoint.

src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ public async Task<IReadOnlyList<BreakpointDetails>> SetLineBreakpointsAsync(
164164
await _breakpointService.RemoveAllBreakpointsAsync(scriptFile.FilePath).ConfigureAwait(false);
165165
}
166166

167-
return await _breakpointService.SetBreakpointsAsync(escapedScriptPath, breakpoints).ConfigureAwait(false);
167+
return await _breakpointService.SetBreakpointsAsync(breakpoints).ConfigureAwait(false);
168168
}
169169

170170
return await dscBreakpoints

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

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
using Microsoft.PowerShell.EditorServices.Logging;
1212
using Microsoft.PowerShell.EditorServices.Services;
1313
using Microsoft.PowerShell.EditorServices.Services.DebugAdapter;
14-
using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace;
1514
using Microsoft.PowerShell.EditorServices.Services.TextDocument;
1615
using Microsoft.PowerShell.EditorServices.Utility;
1716
using OmniSharp.Extensions.DebugAdapter.Protocol.Models;
@@ -31,20 +30,17 @@ internal class BreakpointHandlers : ISetFunctionBreakpointsHandler, ISetBreakpoi
3130
private readonly DebugService _debugService;
3231
private readonly DebugStateService _debugStateService;
3332
private readonly WorkspaceService _workspaceService;
34-
private readonly IRunspaceContext _runspaceContext;
3533

3634
public BreakpointHandlers(
3735
ILoggerFactory loggerFactory,
3836
DebugService debugService,
3937
DebugStateService debugStateService,
40-
WorkspaceService workspaceService,
41-
IRunspaceContext runspaceContext)
38+
WorkspaceService workspaceService)
4239
{
4340
_logger = loggerFactory.CreateLogger<BreakpointHandlers>();
4441
_debugService = debugService;
4542
_debugStateService = debugStateService;
4643
_workspaceService = workspaceService;
47-
_runspaceContext = runspaceContext;
4844
}
4945

5046
public async Task<SetBreakpointsResponse> Handle(SetBreakpointsArguments request, CancellationToken cancellationToken)
@@ -182,12 +178,11 @@ public Task<SetExceptionBreakpointsResponse> Handle(SetExceptionBreakpointsArgum
182178

183179
Task.FromResult(new SetExceptionBreakpointsResponse());
184180

185-
private bool IsFileSupportedForBreakpoints(string requestedPath, ScriptFile resolvedScriptFile)
181+
private static bool IsFileSupportedForBreakpoints(string requestedPath, ScriptFile resolvedScriptFile)
186182
{
187-
// PowerShell 7 and above support breakpoints in untitled files
188183
if (ScriptFile.IsUntitledPath(requestedPath))
189184
{
190-
return BreakpointApiUtils.SupportsBreakpointApis(_runspaceContext.CurrentRunspace);
185+
return true;
191186
}
192187

193188
if (string.IsNullOrEmpty(resolvedScriptFile?.FilePath))

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

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,9 @@
77
using System.Threading.Tasks;
88
using Microsoft.Extensions.Logging;
99
using Microsoft.PowerShell.EditorServices.Services;
10-
using Microsoft.PowerShell.EditorServices.Services.DebugAdapter;
1110
using Microsoft.PowerShell.EditorServices.Services.PowerShell;
1211
using Microsoft.PowerShell.EditorServices.Services.PowerShell.Debugging;
1312
using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution;
14-
using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace;
1513
using Microsoft.PowerShell.EditorServices.Services.TextDocument;
1614
using Microsoft.PowerShell.EditorServices.Utility;
1715
using OmniSharp.Extensions.DebugAdapter.Protocol.Events;
@@ -44,7 +42,6 @@ internal class ConfigurationDoneHandler : IConfigurationDoneHandler
4442
private readonly IInternalPowerShellExecutionService _executionService;
4543
private readonly WorkspaceService _workspaceService;
4644
private readonly IPowerShellDebugContext _debugContext;
47-
private readonly IRunspaceContext _runspaceContext;
4845

4946
// TODO: Decrease these arguments since they're a bunch of interfaces that can be simplified
5047
// (i.e., `IRunspaceContext` should just be available on `IPowerShellExecutionService`).
@@ -56,8 +53,7 @@ public ConfigurationDoneHandler(
5653
DebugEventHandlerService debugEventHandlerService,
5754
IInternalPowerShellExecutionService executionService,
5855
WorkspaceService workspaceService,
59-
IPowerShellDebugContext debugContext,
60-
IRunspaceContext runspaceContext)
56+
IPowerShellDebugContext debugContext)
6157
{
6258
_logger = loggerFactory.CreateLogger<ConfigurationDoneHandler>();
6359
_debugAdapterServer = debugAdapterServer;
@@ -67,7 +63,6 @@ public ConfigurationDoneHandler(
6763
_executionService = executionService;
6864
_workspaceService = workspaceService;
6965
_debugContext = debugContext;
70-
_runspaceContext = runspaceContext;
7166
}
7267

7368
public Task<ConfigurationDoneResponse> Handle(ConfigurationDoneArguments request, CancellationToken cancellationToken)
@@ -119,13 +114,11 @@ internal async Task LaunchScriptAsync(string scriptToLaunch)
119114
else // It's a URI to an untitled script, or a raw script.
120115
{
121116
bool isScriptFile = _workspaceService.TryGetFile(scriptToLaunch, out ScriptFile untitledScript);
122-
if (isScriptFile && BreakpointApiUtils.SupportsBreakpointApis(_runspaceContext.CurrentRunspace))
117+
if (isScriptFile)
123118
{
124119
// Parse untitled files with their `Untitled:` URI as the filename which will
125120
// cache the URI and contents within the PowerShell parser. By doing this, we
126-
// light up the ability to debug untitled files with line breakpoints. This is
127-
// only possible with PowerShell 7's new breakpoint APIs since the old API,
128-
// Set-PSBreakpoint, validates that the given path points to a real file.
121+
// light up the ability to debug untitled files with line breakpoints.
129122
ScriptBlockAst ast = Parser.ParseInput(
130123
untitledScript.Contents,
131124
untitledScript.DocumentUri.ToString(),

test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -527,10 +527,11 @@ await debugService.SetCommandBreakpointsAsync(
527527
Assert.Equal("True > ", prompt.ValueString);
528528
}
529529

530-
[SkippableFact]
531-
public async Task DebuggerBreaksInUntitledScript()
530+
[Theory]
531+
[InlineData("Command")]
532+
[InlineData("Line")]
533+
public async Task DebuggerBreaksInUntitledScript(string breakpointType)
532534
{
533-
Skip.IfNot(VersionUtils.PSEdition == "Core", "Untitled script breakpoints only supported in PowerShell Core");
534535
const string contents = "Write-Output $($MyInvocation.Line)";
535536
const string scriptPath = "untitled:Untitled-1";
536537
Assert.True(ScriptFile.IsUntitledPath(scriptPath));
@@ -539,11 +540,20 @@ public async Task DebuggerBreaksInUntitledScript()
539540
Assert.Equal(contents, scriptFile.Contents);
540541
Assert.True(workspace.TryGetFile(scriptPath, out ScriptFile _));
541542

542-
await debugService.SetCommandBreakpointsAsync(
543-
new[] { CommandBreakpointDetails.Create("Write-Output") });
543+
if (breakpointType == "Command")
544+
{
545+
await debugService.SetCommandBreakpointsAsync(
546+
new[] { CommandBreakpointDetails.Create("Write-Output") });
547+
}
548+
else
549+
{
550+
await debugService.SetLineBreakpointsAsync(
551+
scriptFile,
552+
new[] { BreakpointDetails.Create(scriptPath, 1) });
553+
}
544554

545555
ConfigurationDoneHandler configurationDoneHandler = new(
546-
NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null, psesHost);
556+
NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null);
547557

548558
Task _ = configurationDoneHandler.LaunchScriptAsync(scriptPath);
549559
await AssertDebuggerStopped(scriptPath, 1);
@@ -565,7 +575,7 @@ await debugService.SetCommandBreakpointsAsync(
565575
public async Task RecordsF5CommandInPowerShellHistory()
566576
{
567577
ConfigurationDoneHandler configurationDoneHandler = new(
568-
NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null, psesHost);
578+
NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null);
569579
await configurationDoneHandler.LaunchScriptAsync(debugScriptFile.FilePath);
570580

571581
IReadOnlyList<string> historyResult = await psesHost.ExecutePSCommandAsync<string>(
@@ -605,7 +615,7 @@ public async Task RecordsF8CommandInHistory()
605615
public async Task OddFilePathsLaunchCorrectly()
606616
{
607617
ConfigurationDoneHandler configurationDoneHandler = new(
608-
NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null, psesHost);
618+
NullLoggerFactory.Instance, null, debugService, null, null, psesHost, workspace, null);
609619
await configurationDoneHandler.LaunchScriptAsync(oddPathScriptFile.FilePath);
610620

611621
IReadOnlyList<string> historyResult = await psesHost.ExecutePSCommandAsync<string>(

0 commit comments

Comments
 (0)