Skip to content

Support breakpoints in untitled files in WinPS #2248

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,83 @@ namespace Microsoft.PowerShell.EditorServices.Services
{
internal class BreakpointService
{
/// <summary>
/// Code used on WinPS 5.1 to set breakpoints without Script path validation.
/// It uses reflection because the APIs were not public until 7.0 but just in
/// case something changes it has a fallback to Set-PSBreakpoint.
/// </summary>
private const string _setPSBreakpointLegacy = @"
[CmdletBinding(DefaultParameterSetName = 'Line')]
param (
[Parameter()]
[ScriptBlock]
$Action,

[Parameter(ParameterSetName = 'Command')]
[Parameter(ParameterSetName = 'Line', Mandatory = $true)]
[string]
$Script,

[Parameter(ParameterSetName = 'Line')]
[int]
$Line,

[Parameter(ParameterSetName = 'Line')]
[int]
$Column,

[Parameter(ParameterSetName = 'Command', Mandatory = $true)]
[string]
$Command
)

if ($Script) {
# If using Set-PSBreakpoint we need to escape any wildcard patterns.
$PSBoundParameters['Script'] = [WildcardPattern]::Escape($Script)
}
else {
# WinPS must use null for the Script if unset.
$Script = [NullString]::Value
}

if ($PSCmdlet.ParameterSetName -eq 'Command') {
$cmdCtor = [System.Management.Automation.CommandBreakpoint].GetConstructor(
[System.Reflection.BindingFlags]'NonPublic, Public, Instance',
$null,
[type[]]@([string], [System.Management.Automation.WildcardPattern], [string], [ScriptBlock]),
$null)

if (-not $cmdCtor) {
Microsoft.PowerShell.Utility\Set-PSBreakpoint @PSBoundParameters
return
}

$pattern = [System.Management.Automation.WildcardPattern]::Get(
$Command,
[System.Management.Automation.WildcardOptions]'Compiled, IgnoreCase')
$b = $cmdCtor.Invoke(@($Script, $pattern, $Command, $Action))
}
else {
$lineCtor = [System.Management.Automation.LineBreakpoint].GetConstructor(
[System.Reflection.BindingFlags]'NonPublic, Public, Instance',
$null,
[type[]]@([string], [int], [int], [ScriptBlock]),
$null)

if (-not $lineCtor) {
Microsoft.PowerShell.Utility\Set-PSBreakpoint @PSBoundParameters
return
}

$b = $lineCtor.Invoke(@($Script, $Line, $Column, $Action))
}

[Runspace]::DefaultRunspace.Debugger.SetBreakpoints(
[System.Management.Automation.Breakpoint[]]@($b))

$b
";

private readonly ILogger<BreakpointService> _logger;
private readonly IInternalPowerShellExecutionService _executionService;
private readonly PsesInternalHost _editorServicesHost;
Expand Down Expand Up @@ -57,7 +134,7 @@ public async Task<IReadOnlyList<Breakpoint>> GetBreakpointsAsync()
.ConfigureAwait(false);
}

public async Task<IReadOnlyList<BreakpointDetails>> SetBreakpointsAsync(string escapedScriptPath, IReadOnlyList<BreakpointDetails> breakpoints)
public async Task<IReadOnlyList<BreakpointDetails>> SetBreakpointsAsync(IReadOnlyList<BreakpointDetails> breakpoints)
{
if (BreakpointApiUtils.SupportsBreakpointApis(_editorServicesHost.CurrentRunspace))
{
Expand Down Expand Up @@ -114,9 +191,11 @@ public async Task<IReadOnlyList<BreakpointDetails>> SetBreakpointsAsync(string e
psCommand.AddStatement();
}

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

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

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

// Check if this is a "conditional" line breakpoint.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ public async Task<IReadOnlyList<BreakpointDetails>> SetLineBreakpointsAsync(
await _breakpointService.RemoveAllBreakpointsAsync(scriptFile.FilePath).ConfigureAwait(false);
}

return await _breakpointService.SetBreakpointsAsync(escapedScriptPath, breakpoints).ConfigureAwait(false);
return await _breakpointService.SetBreakpointsAsync(breakpoints).ConfigureAwait(false);
}

return await dscBreakpoints
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
using Microsoft.PowerShell.EditorServices.Logging;
using Microsoft.PowerShell.EditorServices.Services;
using Microsoft.PowerShell.EditorServices.Services.DebugAdapter;
using Microsoft.PowerShell.EditorServices.Services.PowerShell.Runspace;
using Microsoft.PowerShell.EditorServices.Services.TextDocument;
using Microsoft.PowerShell.EditorServices.Utility;
using OmniSharp.Extensions.DebugAdapter.Protocol.Models;
Expand All @@ -31,20 +30,17 @@ internal class BreakpointHandlers : ISetFunctionBreakpointsHandler, ISetBreakpoi
private readonly DebugService _debugService;
private readonly DebugStateService _debugStateService;
private readonly WorkspaceService _workspaceService;
private readonly IRunspaceContext _runspaceContext;

public BreakpointHandlers(
ILoggerFactory loggerFactory,
DebugService debugService,
DebugStateService debugStateService,
WorkspaceService workspaceService,
IRunspaceContext runspaceContext)
WorkspaceService workspaceService)
{
_logger = loggerFactory.CreateLogger<BreakpointHandlers>();
_debugService = debugService;
_debugStateService = debugStateService;
_workspaceService = workspaceService;
_runspaceContext = runspaceContext;
}

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

Task.FromResult(new SetExceptionBreakpointsResponse());

private bool IsFileSupportedForBreakpoints(string requestedPath, ScriptFile resolvedScriptFile)
private static bool IsFileSupportedForBreakpoints(string requestedPath, ScriptFile resolvedScriptFile)
{
// PowerShell 7 and above support breakpoints in untitled files
if (ScriptFile.IsUntitledPath(requestedPath))
{
return BreakpointApiUtils.SupportsBreakpointApis(_runspaceContext.CurrentRunspace);
return true;
}

if (string.IsNullOrEmpty(resolvedScriptFile?.FilePath))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,9 @@
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;
Expand Down Expand Up @@ -44,7 +42,6 @@ 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`).
Expand All @@ -56,8 +53,7 @@ public ConfigurationDoneHandler(
DebugEventHandlerService debugEventHandlerService,
IInternalPowerShellExecutionService executionService,
WorkspaceService workspaceService,
IPowerShellDebugContext debugContext,
IRunspaceContext runspaceContext)
IPowerShellDebugContext debugContext)
{
_logger = loggerFactory.CreateLogger<ConfigurationDoneHandler>();
_debugAdapterServer = debugAdapterServer;
Expand All @@ -67,7 +63,6 @@ public ConfigurationDoneHandler(
_executionService = executionService;
_workspaceService = workspaceService;
_debugContext = debugContext;
_runspaceContext = runspaceContext;
}

public Task<ConfigurationDoneResponse> Handle(ConfigurationDoneArguments request, CancellationToken cancellationToken)
Expand Down Expand Up @@ -119,13 +114,11 @@ internal async Task LaunchScriptAsync(string scriptToLaunch)
else // It's a URI to an untitled script, or a raw script.
{
bool isScriptFile = _workspaceService.TryGetFile(scriptToLaunch, out ScriptFile untitledScript);
if (isScriptFile && BreakpointApiUtils.SupportsBreakpointApis(_runspaceContext.CurrentRunspace))
if (isScriptFile)
{
// 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.
// light up the ability to debug untitled files with line breakpoints.
ScriptBlockAst ast = Parser.ParseInput(
untitledScript.Contents,
untitledScript.DocumentUri.ToString(),
Expand Down
26 changes: 18 additions & 8 deletions test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -527,10 +527,11 @@ await debugService.SetCommandBreakpointsAsync(
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));
Expand All @@ -539,11 +540,20 @@ 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,
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);
await AssertDebuggerStopped(scriptPath, 1);
Expand All @@ -565,7 +575,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<string> historyResult = await psesHost.ExecutePSCommandAsync<string>(
Expand Down Expand Up @@ -605,7 +615,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<string> historyResult = await psesHost.ExecutePSCommandAsync<string>(
Expand Down
Loading