diff --git a/src/PowerShellEditorServices/Server/PsesServiceCollectionExtensions.cs b/src/PowerShellEditorServices/Server/PsesServiceCollectionExtensions.cs index 7ad24f36b..acf5a299a 100644 --- a/src/PowerShellEditorServices/Server/PsesServiceCollectionExtensions.cs +++ b/src/PowerShellEditorServices/Server/PsesServiceCollectionExtensions.cs @@ -64,6 +64,7 @@ public static IServiceCollection AddPsesDebugServices( .AddSingleton(languageServiceProvider.GetService()) .AddSingleton(psesDebugServer) .AddSingleton() + .AddSingleton() .AddSingleton(new DebugStateService { OwnsEditorSession = useTempSession diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs new file mode 100644 index 000000000..6ce6f6ef0 --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/BreakpointService.cs @@ -0,0 +1,312 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Logging; +using Microsoft.PowerShell.EditorServices.Services.DebugAdapter; + +namespace Microsoft.PowerShell.EditorServices.Services +{ + internal class BreakpointService + { + private readonly ILogger _logger; + private readonly PowerShellContextService _powerShellContextService; + private readonly DebugStateService _debugStateService; + + // TODO: This needs to be managed per nested session + internal readonly Dictionary> BreakpointsPerFile = + new Dictionary>(); + + internal readonly HashSet CommandBreakpoints = + new HashSet(); + + public BreakpointService( + ILoggerFactory factory, + PowerShellContextService powerShellContextService, + DebugStateService debugStateService) + { + _logger = factory.CreateLogger(); + _powerShellContextService = powerShellContextService; + _debugStateService = debugStateService; + } + + public async Task> GetBreakpointsAsync() + { + if (BreakpointApiUtils.SupportsBreakpointApis) + { + return BreakpointApiUtils.GetBreakpoints( + _powerShellContextService.CurrentRunspace.Runspace.Debugger, + _debugStateService.RunspaceId); + } + + // Legacy behavior + PSCommand psCommand = new PSCommand(); + psCommand.AddCommand(@"Microsoft.PowerShell.Utility\Get-PSBreakpoint"); + IEnumerable breakpoints = await _powerShellContextService.ExecuteCommandAsync(psCommand); + return breakpoints.ToList(); + } + + public async Task> SetBreakpointsAsync(string escapedScriptPath, IEnumerable breakpoints) + { + if (BreakpointApiUtils.SupportsBreakpointApis) + { + foreach (BreakpointDetails breakpointDetails in breakpoints) + { + try + { + BreakpointApiUtils.SetBreakpoint(_powerShellContextService.CurrentRunspace.Runspace.Debugger, breakpointDetails, _debugStateService.RunspaceId); + } + catch(InvalidOperationException e) + { + breakpointDetails.Message = e.Message; + breakpointDetails.Verified = false; + } + } + + return breakpoints; + } + + // Legacy behavior + PSCommand psCommand = null; + List configuredBreakpoints = new List(); + foreach (BreakpointDetails breakpoint in breakpoints) + { + ScriptBlock actionScriptBlock = null; + + // Check if this is a "conditional" line breakpoint. + if (!string.IsNullOrWhiteSpace(breakpoint.Condition) || + !string.IsNullOrWhiteSpace(breakpoint.HitCondition) || + !string.IsNullOrWhiteSpace(breakpoint.LogMessage)) + { + actionScriptBlock = BreakpointApiUtils.GetBreakpointActionScriptBlock( + breakpoint.Condition, + breakpoint.HitCondition, + breakpoint.LogMessage, + out string errorMessage); + + if (!string.IsNullOrEmpty(errorMessage)) + { + breakpoint.Verified = false; + breakpoint.Message = errorMessage; + configuredBreakpoints.Add(breakpoint); + continue; + } + } + + // On first iteration psCommand will be null, every subsequent + // iteration will need to start a new statement. + if (psCommand == null) + { + psCommand = new PSCommand(); + } + else + { + psCommand.AddStatement(); + } + + psCommand + .AddCommand(@"Microsoft.PowerShell.Utility\Set-PSBreakpoint") + .AddParameter("Script", escapedScriptPath) + .AddParameter("Line", breakpoint.LineNumber); + + // Check if the user has specified the column number for the breakpoint. + if (breakpoint.ColumnNumber.HasValue && breakpoint.ColumnNumber.Value > 0) + { + // It bums me out that PowerShell will silently ignore a breakpoint + // where either the line or the column is invalid. I'd rather have an + // error or warning message I could relay back to the client. + psCommand.AddParameter("Column", breakpoint.ColumnNumber.Value); + } + + if (actionScriptBlock != null) + { + psCommand.AddParameter("Action", actionScriptBlock); + } + } + + // If no PSCommand was created then there are no breakpoints to set. + if (psCommand != null) + { + IEnumerable setBreakpoints = + await _powerShellContextService.ExecuteCommandAsync(psCommand); + configuredBreakpoints.AddRange( + setBreakpoints.Select(BreakpointDetails.Create)); + } + + return configuredBreakpoints; + } + + public async Task> SetCommandBreakpoints(IEnumerable breakpoints) + { + if (BreakpointApiUtils.SupportsBreakpointApis) + { + foreach (CommandBreakpointDetails commandBreakpointDetails in breakpoints) + { + try + { + BreakpointApiUtils.SetBreakpoint(_powerShellContextService.CurrentRunspace.Runspace.Debugger, commandBreakpointDetails, _debugStateService.RunspaceId); + } + catch(InvalidOperationException e) + { + commandBreakpointDetails.Message = e.Message; + commandBreakpointDetails.Verified = false; + } + } + + return breakpoints; + } + + // Legacy behavior + PSCommand psCommand = null; + List configuredBreakpoints = new List(); + foreach (CommandBreakpointDetails breakpoint in breakpoints) + { + // On first iteration psCommand will be null, every subsequent + // iteration will need to start a new statement. + if (psCommand == null) + { + psCommand = new PSCommand(); + } + else + { + psCommand.AddStatement(); + } + + psCommand + .AddCommand(@"Microsoft.PowerShell.Utility\Set-PSBreakpoint") + .AddParameter("Command", breakpoint.Name); + + // Check if this is a "conditional" line breakpoint. + if (!string.IsNullOrWhiteSpace(breakpoint.Condition) || + !string.IsNullOrWhiteSpace(breakpoint.HitCondition)) + { + ScriptBlock actionScriptBlock = + BreakpointApiUtils.GetBreakpointActionScriptBlock( + breakpoint.Condition, + breakpoint.HitCondition, + logMessage: null, + out string errorMessage); + + // If there was a problem with the condition string, + // move onto the next breakpoint. + if (!string.IsNullOrEmpty(errorMessage)) + { + breakpoint.Verified = false; + breakpoint.Message = errorMessage; + configuredBreakpoints.Add(breakpoint); + continue; + } + + psCommand.AddParameter("Action", actionScriptBlock); + } + } + + // If no PSCommand was created then there are no breakpoints to set. + if (psCommand != null) + { + IEnumerable setBreakpoints = + await _powerShellContextService.ExecuteCommandAsync(psCommand); + configuredBreakpoints.AddRange( + setBreakpoints.Select(CommandBreakpointDetails.Create)); + } + + return configuredBreakpoints; + } + + /// + /// Clears all breakpoints in the current session. + /// + public async Task RemoveAllBreakpointsAsync(string scriptPath = null) + { + try + { + if (BreakpointApiUtils.SupportsBreakpointApis) + { + foreach (Breakpoint breakpoint in BreakpointApiUtils.GetBreakpoints( + _powerShellContextService.CurrentRunspace.Runspace.Debugger, + _debugStateService.RunspaceId)) + { + if (scriptPath == null || scriptPath == breakpoint.Script) + { + BreakpointApiUtils.RemoveBreakpoint( + _powerShellContextService.CurrentRunspace.Runspace.Debugger, + breakpoint, + _debugStateService.RunspaceId); + } + } + + return; + } + + // Legacy behavior + + PSCommand psCommand = new PSCommand(); + psCommand.AddCommand(@"Microsoft.PowerShell.Utility\Get-PSBreakpoint"); + + if (!string.IsNullOrEmpty(scriptPath)) + { + psCommand.AddParameter("Script", scriptPath); + } + + psCommand.AddCommand(@"Microsoft.PowerShell.Utility\Remove-PSBreakpoint"); + + await _powerShellContextService.ExecuteCommandAsync(psCommand).ConfigureAwait(false); + } + catch (Exception e) + { + _logger.LogException("Caught exception while clearing breakpoints from session", e); + } + } + + public async Task RemoveBreakpointsAsync(IEnumerable breakpoints) + { + if (BreakpointApiUtils.SupportsBreakpointApis) + { + foreach (Breakpoint breakpoint in breakpoints) + { + BreakpointApiUtils.RemoveBreakpoint( + _powerShellContextService.CurrentRunspace.Runspace.Debugger, + breakpoint, + _debugStateService.RunspaceId); + + switch (breakpoint) + { + case CommandBreakpoint commandBreakpoint: + CommandBreakpoints.Remove(commandBreakpoint); + break; + case LineBreakpoint lineBreakpoint: + if (BreakpointsPerFile.TryGetValue(lineBreakpoint.Script, out HashSet bps)) + { + bps.Remove(lineBreakpoint); + } + break; + default: + throw new ArgumentException("Unsupported breakpoint type."); + } + } + + return; + } + + // Legacy behavior + var breakpointIds = breakpoints.Select(b => b.Id).ToArray(); + if(breakpointIds.Length > 0) + { + PSCommand psCommand = new PSCommand(); + psCommand.AddCommand(@"Microsoft.PowerShell.Utility\Remove-PSBreakpoint"); + psCommand.AddParameter("Id", breakpoints.Select(b => b.Id).ToArray()); + + await _powerShellContextService.ExecuteCommandAsync(psCommand); + } + } + + + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs index d2f5a7a7a..b6460a99a 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs @@ -14,7 +14,6 @@ using Microsoft.PowerShell.EditorServices.Utility; using System.Threading; using Microsoft.Extensions.Logging; -using Microsoft.PowerShell.EditorServices.Logging; using Microsoft.PowerShell.EditorServices.Services.TextDocument; using Microsoft.PowerShell.EditorServices.Services.PowerShellContext; using Microsoft.PowerShell.EditorServices.Services.DebugAdapter; @@ -31,15 +30,13 @@ internal class DebugService private const string PsesGlobalVariableNamePrefix = "__psEditorServices_"; private const string TemporaryScriptFileName = "Script Listing.ps1"; + private readonly BreakpointDetails[] s_emptyBreakpointDetailsArray = new BreakpointDetails[0]; private readonly ILogger logger; private readonly PowerShellContextService powerShellContext; + private readonly BreakpointService _breakpointService; private RemoteFileManagerService remoteFileManager; - // TODO: This needs to be managed per nested session - private readonly Dictionary> breakpointsPerFile = - new Dictionary>(); - private int nextVariableId; private string temporaryScriptListingPath; private List variables; @@ -104,12 +101,14 @@ internal class DebugService public DebugService( PowerShellContextService powerShellContext, RemoteFileManagerService remoteFileManager, + BreakpointService breakpointService, ILoggerFactory factory) { Validate.IsNotNull(nameof(powerShellContext), powerShellContext); this.logger = factory.CreateLogger(); this.powerShellContext = powerShellContext; + _breakpointService = breakpointService; this.powerShellContext.DebuggerStop += this.OnDebuggerStopAsync; this.powerShellContext.DebuggerResumed += this.OnDebuggerResumed; @@ -140,8 +139,6 @@ public async Task SetLineBreakpointsAsync( BreakpointDetails[] breakpoints, bool clearExisting = true) { - var resultBreakpointDetails = new List(); - var dscBreakpoints = this.powerShellContext .CurrentRunspace @@ -157,7 +154,7 @@ public async Task SetLineBreakpointsAsync( this.logger.LogTrace( $"Could not set breakpoints for local path '{scriptPath}' in a remote session."); - return resultBreakpointDetails.ToArray(); + return s_emptyBreakpointDetailsArray; } string mappedPath = @@ -174,7 +171,7 @@ public async Task SetLineBreakpointsAsync( this.logger.LogTrace( $"Could not set breakpoint on temporary script listing path '{scriptPath}'."); - return resultBreakpointDetails.ToArray(); + return s_emptyBreakpointDetailsArray; } // Fix for issue #123 - file paths that contain wildcard chars [ and ] need to @@ -186,78 +183,16 @@ public async Task SetLineBreakpointsAsync( { if (clearExisting) { - await this.ClearBreakpointsInFileAsync(scriptFile).ConfigureAwait(false); + await _breakpointService.RemoveAllBreakpointsAsync(scriptFile.FilePath).ConfigureAwait(false); } - PSCommand psCommand = null; - foreach (BreakpointDetails breakpoint in breakpoints) - { - // On first iteration psCommand will be null, every subsequent - // iteration will need to start a new statement. - if (psCommand == null) - { - psCommand = new PSCommand(); - } - else - { - psCommand.AddStatement(); - } - - psCommand - .AddCommand(@"Microsoft.PowerShell.Utility\Set-PSBreakpoint") - .AddParameter("Script", escapedScriptPath) - .AddParameter("Line", breakpoint.LineNumber); - - // Check if the user has specified the column number for the breakpoint. - if (breakpoint.ColumnNumber.HasValue && breakpoint.ColumnNumber.Value > 0) - { - // It bums me out that PowerShell will silently ignore a breakpoint - // where either the line or the column is invalid. I'd rather have an - // error or warning message I could relay back to the client. - psCommand.AddParameter("Column", breakpoint.ColumnNumber.Value); - } - - // Check if this is a "conditional" line breakpoint. - if (!String.IsNullOrWhiteSpace(breakpoint.Condition) || - !String.IsNullOrWhiteSpace(breakpoint.HitCondition)) - { - ScriptBlock actionScriptBlock = - GetBreakpointActionScriptBlock(breakpoint); - - // If there was a problem with the condition string, - // move onto the next breakpoint. - if (actionScriptBlock == null) - { - resultBreakpointDetails.Add(breakpoint); - continue; - } - - psCommand.AddParameter("Action", actionScriptBlock); - } - } - - // If no PSCommand was created then there are no breakpoints to set. - if (psCommand != null) - { - IEnumerable configuredBreakpoints = - await this.powerShellContext.ExecuteCommandAsync(psCommand).ConfigureAwait(false); - - // The order in which the breakpoints are returned is significant to the - // VSCode client and should match the order in which they are passed in. - resultBreakpointDetails.AddRange( - configuredBreakpoints.Select(BreakpointDetails.Create)); - } - } - else - { - resultBreakpointDetails = - await dscBreakpoints.SetLineBreakpointsAsync( - powerShellContext, - escapedScriptPath, - breakpoints).ConfigureAwait(false); + return (await _breakpointService.SetBreakpointsAsync(escapedScriptPath, breakpoints).ConfigureAwait(false)).ToArray(); } - return resultBreakpointDetails.ToArray(); + return await dscBreakpoints.SetLineBreakpointsAsync( + this.powerShellContext, + escapedScriptPath, + breakpoints); } /// @@ -270,49 +205,20 @@ public async Task SetCommandBreakpointsAsync( CommandBreakpointDetails[] breakpoints, bool clearExisting = true) { - var resultBreakpointDetails = new List(); + CommandBreakpointDetails[] resultBreakpointDetails = null; if (clearExisting) { - await this.ClearCommandBreakpointsAsync().ConfigureAwait(false); + // Flatten dictionary values into one list and remove them all. + await _breakpointService.RemoveBreakpointsAsync((await _breakpointService.GetBreakpointsAsync()).Where( i => i is CommandBreakpoint)).ConfigureAwait(false); } if (breakpoints.Length > 0) { - foreach (CommandBreakpointDetails breakpoint in breakpoints) - { - PSCommand psCommand = new PSCommand(); - psCommand.AddCommand(@"Microsoft.PowerShell.Utility\Set-PSBreakpoint"); - psCommand.AddParameter("Command", breakpoint.Name); - - // Check if this is a "conditional" command breakpoint. - if (!String.IsNullOrWhiteSpace(breakpoint.Condition) || - !String.IsNullOrWhiteSpace(breakpoint.HitCondition)) - { - ScriptBlock actionScriptBlock = GetBreakpointActionScriptBlock(breakpoint); - - // If there was a problem with the condition string, - // move onto the next breakpoint. - if (actionScriptBlock == null) - { - resultBreakpointDetails.Add(breakpoint); - continue; - } - - psCommand.AddParameter("Action", actionScriptBlock); - } - - IEnumerable configuredBreakpoints = - await this.powerShellContext.ExecuteCommandAsync(psCommand).ConfigureAwait(false); - - // The order in which the breakpoints are returned is significant to the - // VSCode client and should match the order in which they are passed in. - resultBreakpointDetails.AddRange( - configuredBreakpoints.Select(CommandBreakpointDetails.Create)); - } + resultBreakpointDetails = (await _breakpointService.SetCommandBreakpoints(breakpoints).ConfigureAwait(false)).ToArray(); } - return resultBreakpointDetails.ToArray(); + return resultBreakpointDetails ?? new CommandBreakpointDetails[0]; } /// @@ -753,58 +659,10 @@ public VariableScope[] GetVariableScopes(int stackFrameId) }; } - /// - /// Clears all breakpoints in the current session. - /// - public async Task ClearAllBreakpointsAsync() - { - try - { - PSCommand psCommand = new PSCommand(); - psCommand.AddCommand(@"Microsoft.PowerShell.Utility\Get-PSBreakpoint"); - psCommand.AddCommand(@"Microsoft.PowerShell.Utility\Remove-PSBreakpoint"); - - await this.powerShellContext.ExecuteCommandAsync(psCommand).ConfigureAwait(false); - } - catch (Exception e) - { - logger.LogException("Caught exception while clearing breakpoints from session", e); - } - } - #endregion #region Private Methods - private async Task ClearBreakpointsInFileAsync(ScriptFile scriptFile) - { - // Get the list of breakpoints for this file - if (this.breakpointsPerFile.TryGetValue(scriptFile.Id, out List breakpoints)) - { - if (breakpoints.Count > 0) - { - PSCommand psCommand = new PSCommand(); - psCommand.AddCommand(@"Microsoft.PowerShell.Utility\Remove-PSBreakpoint"); - psCommand.AddParameter("Id", breakpoints.Select(b => b.Id).ToArray()); - - await this.powerShellContext.ExecuteCommandAsync(psCommand).ConfigureAwait(false); - - // Clear the existing breakpoints list for the file - breakpoints.Clear(); - } - } - } - - private async Task ClearCommandBreakpointsAsync() - { - PSCommand psCommand = new PSCommand(); - psCommand.AddCommand(@"Microsoft.PowerShell.Utility\Get-PSBreakpoint"); - psCommand.AddParameter("Type", "Command"); - psCommand.AddCommand(@"Microsoft.PowerShell.Utility\Remove-PSBreakpoint"); - - await this.powerShellContext.ExecuteCommandAsync(psCommand).ConfigureAwait(false); - } - private async Task FetchStackFramesAndVariablesAsync(string scriptNameOverride) { await this.debugInfoHandle.WaitAsync().ConfigureAwait(false); @@ -998,187 +856,6 @@ private async Task FetchStackFramesAsync(string scriptNameOverride) } } - /// - /// Inspects the condition, putting in the appropriate scriptblock template - /// "if (expression) { break }". If errors are found in the condition, the - /// breakpoint passed in is updated to set Verified to false and an error - /// message is put into the breakpoint.Message property. - /// - /// - /// - private ScriptBlock GetBreakpointActionScriptBlock( - BreakpointDetailsBase breakpoint) - { - try - { - ScriptBlock actionScriptBlock; - int? hitCount = null; - - // If HitCondition specified, parse and verify it. - if (!(String.IsNullOrWhiteSpace(breakpoint.HitCondition))) - { - if (Int32.TryParse(breakpoint.HitCondition, out int parsedHitCount)) - { - hitCount = parsedHitCount; - } - else - { - breakpoint.Verified = false; - breakpoint.Message = $"The specified HitCount '{breakpoint.HitCondition}' is not valid. " + - "The HitCount must be an integer number."; - return null; - } - } - - // Create an Action scriptblock based on condition and/or hit count passed in. - if (hitCount.HasValue && string.IsNullOrWhiteSpace(breakpoint.Condition)) - { - // In the HitCount only case, this is simple as we can just use the HitCount - // property on the breakpoint object which is represented by $_. - string action = $"if ($_.HitCount -eq {hitCount}) {{ break }}"; - actionScriptBlock = ScriptBlock.Create(action); - } - else if (!string.IsNullOrWhiteSpace(breakpoint.Condition)) - { - // Must be either condition only OR condition and hit count. - actionScriptBlock = ScriptBlock.Create(breakpoint.Condition); - - // Check for simple, common errors that ScriptBlock parsing will not catch - // e.g. $i == 3 and $i > 3 - if (!ValidateBreakpointConditionAst(actionScriptBlock.Ast, out string message)) - { - breakpoint.Verified = false; - breakpoint.Message = message; - return null; - } - - // Check for "advanced" condition syntax i.e. if the user has specified - // a "break" or "continue" statement anywhere in their scriptblock, - // pass their scriptblock through to the Action parameter as-is. - Ast breakOrContinueStatementAst = - actionScriptBlock.Ast.Find( - ast => (ast is BreakStatementAst || ast is ContinueStatementAst), true); - - // If this isn't advanced syntax then the conditions string should be a simple - // expression that needs to be wrapped in a "if" test that conditionally executes - // a break statement. - if (breakOrContinueStatementAst == null) - { - string wrappedCondition; - - if (hitCount.HasValue) - { - string globalHitCountVarName = - $"$global:{PsesGlobalVariableNamePrefix}BreakHitCounter_{breakpointHitCounter++}"; - - wrappedCondition = - $"if ({breakpoint.Condition}) {{ if (++{globalHitCountVarName} -eq {hitCount}) {{ break }} }}"; - } - else - { - wrappedCondition = $"if ({breakpoint.Condition}) {{ break }}"; - } - - actionScriptBlock = ScriptBlock.Create(wrappedCondition); - } - } - else - { - // Shouldn't get here unless someone called this with no condition and no hit count. - actionScriptBlock = ScriptBlock.Create("break"); - this.logger.LogWarning("No condition and no hit count specified by caller."); - } - - return actionScriptBlock; - } - catch (ParseException ex) - { - // Failed to create conditional breakpoint likely because the user provided an - // invalid PowerShell expression. Let the user know why. - breakpoint.Verified = false; - breakpoint.Message = ExtractAndScrubParseExceptionMessage(ex, breakpoint.Condition); - return null; - } - } - - private bool ValidateBreakpointConditionAst(Ast conditionAst, out string message) - { - message = string.Empty; - - // We are only inspecting a few simple scenarios in the EndBlock only. - if (conditionAst is ScriptBlockAst scriptBlockAst && - scriptBlockAst.BeginBlock == null && - scriptBlockAst.ProcessBlock == null && - scriptBlockAst.EndBlock != null && - scriptBlockAst.EndBlock.Statements.Count == 1) - { - StatementAst statementAst = scriptBlockAst.EndBlock.Statements[0]; - string condition = statementAst.Extent.Text; - - if (statementAst is AssignmentStatementAst) - { - message = FormatInvalidBreakpointConditionMessage(condition, "Use '-eq' instead of '=='."); - return false; - } - - if (statementAst is PipelineAst pipelineAst - && pipelineAst.PipelineElements.Count == 1 - && pipelineAst.PipelineElements[0].Redirections.Count > 0) - { - message = FormatInvalidBreakpointConditionMessage(condition, "Use '-gt' instead of '>'."); - return false; - } - } - - return true; - } - - private string ExtractAndScrubParseExceptionMessage(ParseException parseException, string condition) - { - string[] messageLines = parseException.Message.Split('\n'); - - // Skip first line - it is a location indicator "At line:1 char: 4" - for (int i = 1; i < messageLines.Length; i++) - { - string line = messageLines[i]; - if (line.StartsWith("+")) - { - continue; - } - - if (!string.IsNullOrWhiteSpace(line)) - { - // Note '==' and '>" do not generate parse errors - if (line.Contains("'!='")) - { - line += " Use operator '-ne' instead of '!='."; - } - else if (line.Contains("'<'") && condition.Contains("<=")) - { - line += " Use operator '-le' instead of '<='."; - } - else if (line.Contains("'<'")) - { - line += " Use operator '-lt' instead of '<'."; - } - else if (condition.Contains(">=")) - { - line += " Use operator '-ge' instead of '>='."; - } - - return FormatInvalidBreakpointConditionMessage(condition, line); - } - } - - // If the message format isn't in a form we expect, just return the whole message. - return FormatInvalidBreakpointConditionMessage(condition, parseException.Message); - } - - private string FormatInvalidBreakpointConditionMessage(string condition, string message) - { - return $"'{condition}' is not a valid PowerShell expression. {message}"; - } - private string TrimScriptListingLine(PSObject scriptLineObj, ref int prefixLength) { string scriptLine = scriptLineObj.ToString(); @@ -1337,10 +1014,10 @@ private void OnBreakpointUpdated(object sender, BreakpointUpdatedEventArgs e) string normalizedScriptName = scriptPath.ToLower(); // Get the list of breakpoints for this file - if (!this.breakpointsPerFile.TryGetValue(normalizedScriptName, out List breakpoints)) + if (!_breakpointService.BreakpointsPerFile.TryGetValue(normalizedScriptName, out HashSet breakpoints)) { - breakpoints = new List(); - this.breakpointsPerFile.Add( + breakpoints = new HashSet(); + _breakpointService.BreakpointsPerFile.Add( normalizedScriptName, breakpoints); } diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/DebugStateService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/DebugStateService.cs index 0dd5be42e..f60d945a6 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/DebugStateService.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/DebugStateService.cs @@ -19,6 +19,8 @@ internal class DebugStateService internal bool IsRemoteAttach { get; set; } + internal int? RunspaceId { get; set; } + internal bool IsAttachSession { get; set; } internal bool WaitingForAttach { get; set; } diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointApiUtils.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointApiUtils.cs new file mode 100644 index 000000000..353ebf561 --- /dev/null +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointApiUtils.cs @@ -0,0 +1,325 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Collections.Generic; +using System.Management.Automation; +using System.Management.Automation.Language; +using System.Reflection; +using System.Text; +using System.Threading; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Services.DebugAdapter + +{ + internal static class BreakpointApiUtils + { + #region Private Static Fields + + private const string s_psesGlobalVariableNamePrefix = "__psEditorServices_"; + + private static readonly Lazy> s_setLineBreakpointLazy; + + private static readonly Lazy> s_setCommandBreakpointLazy; + + private static readonly Lazy>> s_getBreakpointsLazy; + + private static readonly Lazy> s_removeBreakpointLazy; + + private static int breakpointHitCounter; + + #endregion + + #region Static Constructor + + static BreakpointApiUtils() + { + // If this version of PowerShell does not support the new Breakpoint APIs introduced in PowerShell 7.0.0, + // do nothing as this class will not get used. + if (!SupportsBreakpointApis) + { + return; + } + + s_setLineBreakpointLazy = new Lazy>(() => + { + Type[] setLineBreakpointParameters = new[] { typeof(string), typeof(int), typeof(int), typeof(ScriptBlock), typeof(int?) }; + MethodInfo setLineBreakpointMethod = typeof(Debugger).GetMethod("SetLineBreakpoint", setLineBreakpointParameters); + + return (Func)Delegate.CreateDelegate( + typeof(Func), + firstArgument: null, + setLineBreakpointMethod); + }); + + s_setCommandBreakpointLazy = new Lazy>(() => + { + Type[] setCommandBreakpointParameters = new[] { typeof(string), typeof(ScriptBlock), typeof(string), typeof(int?) }; + MethodInfo setCommandBreakpointMethod = typeof(Debugger).GetMethod("SetCommandBreakpoint", setCommandBreakpointParameters); + + return (Func)Delegate.CreateDelegate( + typeof(Func), + firstArgument: null, + setCommandBreakpointMethod); + }); + + s_getBreakpointsLazy = new Lazy>>(() => + { + Type[] getBreakpointsParameters = new[] { typeof(int?) }; + MethodInfo getBreakpointsMethod = typeof(Debugger).GetMethod("GetBreakpoints", getBreakpointsParameters); + + return (Func>)Delegate.CreateDelegate( + typeof(Func>), + firstArgument: null, + getBreakpointsMethod); + }); + + s_removeBreakpointLazy = new Lazy>(() => + { + Type[] removeBreakpointParameters = new[] { typeof(Breakpoint), typeof(int?) }; + MethodInfo removeBreakpointMethod = typeof(Debugger).GetMethod("RemoveBreakpoint", removeBreakpointParameters); + + return (Func)Delegate.CreateDelegate( + typeof(Func), + firstArgument: null, + removeBreakpointMethod); + }); + } + + #endregion + + #region Public Static Properties + + private static Func SetLineBreakpointDelegate => s_setLineBreakpointLazy.Value; + + private static Func SetCommandBreakpointDelegate => s_setCommandBreakpointLazy.Value; + + private static Func> GetBreakpointsDelegate => s_getBreakpointsLazy.Value; + + private static Func RemoveBreakpointDelegate => s_removeBreakpointLazy.Value; + + #endregion + + #region Public Static Properties + + // TODO: Try to compute this more dynamically. If we're launching a script in the PSIC, there are APIs are available in PS 5.1 and up. + // For now, only PS7 or greater gets this feature. + public static bool SupportsBreakpointApis => VersionUtils.IsPS7OrGreater; + + #endregion + + #region Public Static Methods + + public static Breakpoint SetBreakpoint(Debugger debugger, BreakpointDetailsBase breakpoint, int? runspaceId = null) + { + ScriptBlock actionScriptBlock = null; + string logMessage = breakpoint is BreakpointDetails bd ? bd.LogMessage : null; + // Check if this is a "conditional" line breakpoint. + if (!string.IsNullOrWhiteSpace(breakpoint.Condition) || + !string.IsNullOrWhiteSpace(breakpoint.HitCondition) || + !string.IsNullOrWhiteSpace(logMessage)) + { + actionScriptBlock = GetBreakpointActionScriptBlock( + breakpoint.Condition, + breakpoint.HitCondition, + logMessage, + out string errorMessage); + + if (!string.IsNullOrEmpty(errorMessage)) + { + // This is handled by the caller where it will set the 'Message' and 'Verified' on the BreakpointDetails + throw new InvalidOperationException(errorMessage); + } + } + + switch (breakpoint) + { + case BreakpointDetails lineBreakpoint: + return SetLineBreakpointDelegate(debugger, lineBreakpoint.Source, lineBreakpoint.LineNumber, lineBreakpoint.ColumnNumber ?? 0, actionScriptBlock, runspaceId); + + case CommandBreakpointDetails commandBreakpoint: + return SetCommandBreakpointDelegate(debugger, commandBreakpoint.Name, null, null, runspaceId); + + default: + throw new NotImplementedException("Other breakpoints not supported yet"); + } + } + + public static List GetBreakpoints(Debugger debugger, int? runspaceId = null) + { + return GetBreakpointsDelegate(debugger, runspaceId); + } + + public static bool RemoveBreakpoint(Debugger debugger, Breakpoint breakpoint, int? runspaceId = null) + { + return RemoveBreakpointDelegate(debugger, breakpoint, runspaceId); + } + + /// + /// Inspects the condition, putting in the appropriate scriptblock template + /// "if (expression) { break }". If errors are found in the condition, the + /// breakpoint passed in is updated to set Verified to false and an error + /// message is put into the breakpoint.Message property. + /// + /// The expression that needs to be true for the breakpoint to be triggered. + /// The amount of times this line should be hit til the breakpoint is triggered. + /// The log message to write instead of calling 'break'. In VS Code, this is called a 'logPoint'. + /// ScriptBlock + public static ScriptBlock GetBreakpointActionScriptBlock(string condition, string hitCondition, string logMessage, out string errorMessage) + { + errorMessage = null; + + try + { + StringBuilder builder = new StringBuilder( + string.IsNullOrEmpty(logMessage) + ? "break" + : $"Microsoft.PowerShell.Utility\\Write-Host '{logMessage}'"); + + // If HitCondition specified, parse and verify it. + if (!(string.IsNullOrWhiteSpace(hitCondition))) + { + if (!int.TryParse(hitCondition, out int parsedHitCount)) + { + throw new InvalidOperationException("Hit Count was not a valid integer."); + } + + if(string.IsNullOrWhiteSpace(condition)) + { + // In the HitCount only case, this is simple as we can just use the HitCount + // property on the breakpoint object which is represented by $_. + builder.Insert(0, $"if ($_.HitCount -eq {parsedHitCount}) {{ ") + .Append(" }"); + } + + int incrementResult = Interlocked.Increment(ref breakpointHitCounter); + + string globalHitCountVarName = + $"$global:{s_psesGlobalVariableNamePrefix}BreakHitCounter_{incrementResult}"; + + builder.Insert(0, $"if (++{globalHitCountVarName} -eq {parsedHitCount}) {{ ") + .Append(" }"); + } + + if (!string.IsNullOrWhiteSpace(condition)) + { + ScriptBlock parsed = ScriptBlock.Create(condition); + + // Check for simple, common errors that ScriptBlock parsing will not catch + // e.g. $i == 3 and $i > 3 + if (!ValidateBreakpointConditionAst(parsed.Ast, out string message)) + { + throw new InvalidOperationException(message); + } + + // Check for "advanced" condition syntax i.e. if the user has specified + // a "break" or "continue" statement anywhere in their scriptblock, + // pass their scriptblock through to the Action parameter as-is. + if (parsed.Ast.Find(ast => + (ast is BreakStatementAst || ast is ContinueStatementAst), true) != null) + { + return parsed; + } + + builder.Insert(0, $"if ({condition}) {{ ") + .Append(" }"); + } + + return ScriptBlock.Create(builder.ToString()); + } + catch (ParseException e) + { + errorMessage = ExtractAndScrubParseExceptionMessage(e, condition); + return null; + } + catch (InvalidOperationException e) + { + errorMessage = e.Message; + return null; + } + } + + private static bool ValidateBreakpointConditionAst(Ast conditionAst, out string message) + { + message = string.Empty; + + // We are only inspecting a few simple scenarios in the EndBlock only. + if (conditionAst is ScriptBlockAst scriptBlockAst && + scriptBlockAst.BeginBlock == null && + scriptBlockAst.ProcessBlock == null && + scriptBlockAst.EndBlock != null && + scriptBlockAst.EndBlock.Statements.Count == 1) + { + StatementAst statementAst = scriptBlockAst.EndBlock.Statements[0]; + string condition = statementAst.Extent.Text; + + if (statementAst is AssignmentStatementAst) + { + message = FormatInvalidBreakpointConditionMessage(condition, "Use '-eq' instead of '=='."); + return false; + } + + if (statementAst is PipelineAst pipelineAst + && pipelineAst.PipelineElements.Count == 1 + && pipelineAst.PipelineElements[0].Redirections.Count > 0) + { + message = FormatInvalidBreakpointConditionMessage(condition, "Use '-gt' instead of '>'."); + return false; + } + } + + return true; + } + + private static string ExtractAndScrubParseExceptionMessage(ParseException parseException, string condition) + { + string[] messageLines = parseException.Message.Split('\n'); + + // Skip first line - it is a location indicator "At line:1 char: 4" + for (int i = 1; i < messageLines.Length; i++) + { + string line = messageLines[i]; + if (line.StartsWith("+")) + { + continue; + } + + if (!string.IsNullOrWhiteSpace(line)) + { + // Note '==' and '>" do not generate parse errors + if (line.Contains("'!='")) + { + line += " Use operator '-ne' instead of '!='."; + } + else if (line.Contains("'<'") && condition.Contains("<=")) + { + line += " Use operator '-le' instead of '<='."; + } + else if (line.Contains("'<'")) + { + line += " Use operator '-lt' instead of '<'."; + } + else if (condition.Contains(">=")) + { + line += " Use operator '-ge' instead of '>='."; + } + + return FormatInvalidBreakpointConditionMessage(condition, line); + } + } + + // If the message format isn't in a form we expect, just return the whole message. + return FormatInvalidBreakpointConditionMessage(condition, parseException.Message); + } + + private static string FormatInvalidBreakpointConditionMessage(string condition, string message) + { + return $"'{condition}' is not a valid PowerShell expression. {message}"; + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointDetails.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointDetails.cs index 4e4ee6340..74bcd9ce4 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointDetails.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/BreakpointDetails.cs @@ -13,7 +13,7 @@ namespace Microsoft.PowerShell.EditorServices.Services.DebugAdapter /// Provides details about a breakpoint that is set in the /// PowerShell debugger. /// - public class BreakpointDetails : BreakpointDetailsBase + internal class BreakpointDetails : BreakpointDetailsBase { /// /// Gets the unique ID of the breakpoint. @@ -36,6 +36,8 @@ public class BreakpointDetails : BreakpointDetailsBase /// public int? ColumnNumber { get; private set; } + public string LogMessage { get; private set; } + private BreakpointDetails() { } @@ -50,12 +52,13 @@ private BreakpointDetails() /// /// /// - public static BreakpointDetails Create( + internal static BreakpointDetails Create( string source, int line, int? column = null, string condition = null, - string hitCondition = null) + string hitCondition = null, + string logMessage = null) { Validate.IsNotNull("source", source); @@ -66,7 +69,8 @@ public static BreakpointDetails Create( LineNumber = line, ColumnNumber = column, Condition = condition, - HitCondition = hitCondition + HitCondition = hitCondition, + LogMessage = logMessage }; } @@ -76,7 +80,7 @@ public static BreakpointDetails Create( /// /// The Breakpoint instance from which details will be taken. /// A new instance of the BreakpointDetails class. - public static BreakpointDetails Create(Breakpoint breakpoint) + internal static BreakpointDetails Create(Breakpoint breakpoint) { Validate.IsNotNull("breakpoint", breakpoint); diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/CommandBreakpointDetails.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/CommandBreakpointDetails.cs index 2b7c65def..c16928186 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/CommandBreakpointDetails.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Debugging/CommandBreakpointDetails.cs @@ -12,7 +12,7 @@ namespace Microsoft.PowerShell.EditorServices.Services.DebugAdapter /// /// Provides details about a command breakpoint that is set in the PowerShell debugger. /// - public class CommandBreakpointDetails : BreakpointDetailsBase + internal class CommandBreakpointDetails : BreakpointDetailsBase { /// /// Gets the name of the command on which the command breakpoint has been set. @@ -31,7 +31,7 @@ private CommandBreakpointDetails() /// Condition string that would be applied to the breakpoint Action parameter. /// Hit condition string that would be applied to the breakpoint Action parameter. /// - public static CommandBreakpointDetails Create( + internal static CommandBreakpointDetails Create( string name, string condition = null, string hitCondition = null) @@ -50,7 +50,7 @@ public static CommandBreakpointDetails Create( /// /// The Breakpoint instance from which details will be taken. /// A new instance of the BreakpointDetails class. - public static CommandBreakpointDetails Create(Breakpoint breakpoint) + internal static CommandBreakpointDetails Create(Breakpoint breakpoint) { Validate.IsNotNull("breakpoint", breakpoint); diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs index 45db430fe..02c6e9080 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/BreakpointHandlers.cs @@ -141,14 +141,7 @@ public SetBreakpointsHandler( public async Task Handle(SetBreakpointsArguments request, CancellationToken cancellationToken) { - ScriptFile scriptFile = null; - - // When you set a breakpoint in the right pane of a Git diff window on a PS1 file, - // the Source.Path comes through as Untitled-X. That's why we check for IsUntitledPath. - if (!ScriptFile.IsUntitledPath(request.Source.Path) && - !_workspaceService.TryGetFile( - request.Source.Path, - out scriptFile)) + if (!_workspaceService.TryGetFile(request.Source.Path, out ScriptFile scriptFile)) { string message = _debugStateService.NoDebug ? string.Empty : "Source file could not be accessed, breakpoint not set."; var srcBreakpoints = request.Breakpoints @@ -164,7 +157,9 @@ public async Task Handle(SetBreakpointsArguments request // Verify source file is a PowerShell script file. string fileExtension = Path.GetExtension(scriptFile?.FilePath ?? "")?.ToLower(); - if (string.IsNullOrEmpty(fileExtension) || ((fileExtension != ".ps1") && (fileExtension != ".psm1"))) + bool isUntitledPath = ScriptFile.IsUntitledPath(request.Source.Path); + if ((!isUntitledPath && fileExtension != ".ps1" && fileExtension != ".psm1") || + (!BreakpointApiUtils.SupportsBreakpointApis && isUntitledPath)) { _logger.LogWarning( $"Attempted to set breakpoints on a non-PowerShell file: {request.Source.Path}"); @@ -189,7 +184,8 @@ public async Task Handle(SetBreakpointsArguments request (int)srcBreakpoint.Line, (int?)srcBreakpoint.Column, srcBreakpoint.Condition, - srcBreakpoint.HitCondition)) + srcBreakpoint.HitCondition, + srcBreakpoint.LogMessage)) .ToArray(); // If this is a "run without debugging (Ctrl+F5)" session ignore requests to set breakpoints. diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs index 8a1f00c7e..f566b2c53 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs @@ -3,10 +3,13 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. // +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.PowerShellContext; using Microsoft.PowerShell.EditorServices.Services.TextDocument; using OmniSharp.Extensions.DebugAdapter.Protocol.Events; @@ -97,8 +100,26 @@ private async Task LaunchScriptAsync(string scriptToLaunch) { ScriptFile untitledScript = _workspaceService.GetFile(scriptToLaunch); - await _powerShellContextService - .ExecuteScriptStringAsync(untitledScript.Contents, true, true).ConfigureAwait(false); + if (BreakpointApiUtils.SupportsBreakpointApis) + { + // Parse untitled files with their `Untitled:` URI as the file name which will cache the URI & contents within the PowerShell parser. + // By doing this, we light up the ability to debug Untitled files with breakpoints. + // This is only possible via the direct usage of the breakpoint APIs in PowerShell because + // Set-PSBreakpoint validates that paths are actually on the filesystem. + ScriptBlockAst ast = Parser.ParseInput(untitledScript.Contents, untitledScript.DocumentUri, out Token[] tokens, out ParseError[] errors); + + // This seems to be the simplest way to invoke a script block (which contains breakpoint information) via the PowerShell API. + var cmd = new PSCommand().AddScript(". $args[0]").AddArgument(ast.GetScriptBlock()); + await _powerShellContextService + .ExecuteCommandAsync(cmd, sendOutputToHost: true, sendErrorToHost:true) + .ConfigureAwait(false); + } + else + { + await _powerShellContextService + .ExecuteScriptStringAsync(untitledScript.Contents, writeInputToHost: true, writeOutputToHost: true) + .ConfigureAwait(false); + } } else { diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/InitializeHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/InitializeHandler.cs index fda53620f..4fb305090 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/InitializeHandler.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/InitializeHandler.cs @@ -15,27 +15,31 @@ internal class InitializeHandler : IInitializeHandler { private readonly ILogger _logger; private readonly DebugService _debugService; + private readonly BreakpointService _breakpointService; public InitializeHandler( ILoggerFactory factory, - DebugService debugService) + DebugService debugService, + BreakpointService breakpointService) { _logger = factory.CreateLogger(); _debugService = debugService; + _breakpointService = breakpointService; } public async Task Handle(InitializeRequestArguments request, CancellationToken cancellationToken) { // Clear any existing breakpoints before proceeding - await _debugService.ClearAllBreakpointsAsync().ConfigureAwait(false); + await _breakpointService.RemoveAllBreakpointsAsync().ConfigureAwait(false); // Now send the Initialize response to continue setup return new InitializeResponse { + SupportsConditionalBreakpoints = true, SupportsConfigurationDoneRequest = true, SupportsFunctionBreakpoints = true, - SupportsConditionalBreakpoints = true, SupportsHitConditionalBreakpoints = true, + SupportsLogPoints = true, SupportsSetVariable = true }; } diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs index fa99f0e2f..223aa995e 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs @@ -6,15 +6,18 @@ using System; using System.Collections.Generic; using System.IO; +using System.Management.Automation; using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Logging; using Microsoft.PowerShell.EditorServices.Services; using Microsoft.PowerShell.EditorServices.Services.PowerShellContext; using OmniSharp.Extensions.DebugAdapter.Protocol.Events; using MediatR; using OmniSharp.Extensions.JsonRpc; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; namespace Microsoft.PowerShell.EditorServices.Handlers { @@ -182,7 +185,13 @@ public async Task Handle(PsesLaunchRequestArguments request, CancellationT _debugStateService.Arguments = arguments; _debugStateService.IsUsingTempIntegratedConsole = request.CreateTemporaryIntegratedConsole; - // TODO: Bring this back + if (request.CreateTemporaryIntegratedConsole + && !string.IsNullOrEmpty(request.Script) + && ScriptFile.IsUntitledPath(request.Script)) + { + throw new RpcErrorException(0, "Running an Untitled file in a temporary integrated console is currently not supported."); + } + // If the current session is remote, map the script path to the remote // machine if necessary if (_debugStateService.ScriptToLaunch != null && @@ -212,6 +221,7 @@ internal class AttachHandler : IPsesAttachHandler private readonly ILogger _logger; private readonly DebugService _debugService; + private readonly BreakpointService _breakpointService; private readonly PowerShellContextService _powerShellContextService; private readonly DebugStateService _debugStateService; private readonly DebugEventHandlerService _debugEventHandlerService; @@ -223,11 +233,13 @@ public AttachHandler( DebugService debugService, PowerShellContextService powerShellContextService, DebugStateService debugStateService, + BreakpointService breakpointService, DebugEventHandlerService debugEventHandlerService) { _logger = factory.CreateLogger(); _jsonRpcServer = jsonRpcServer; _debugService = debugService; + _breakpointService = breakpointService; _powerShellContextService = powerShellContextService; _debugStateService = debugStateService; _debugEventHandlerService = debugEventHandlerService; @@ -322,9 +334,6 @@ await _powerShellContextService.ExecuteScriptStringAsync( throw new RpcErrorException(0, "A positive integer must be specified for the processId field."); } - // Clear any existing breakpoints before proceeding - await _debugService.ClearAllBreakpointsAsync().ConfigureAwait(continueOnCapturedContext: false); - // Execute the Debug-Runspace command but don't await it because it // will block the debug adapter initialization process. The // InitializedEvent will be sent as soon as the RunspaceChanged @@ -333,6 +342,16 @@ await _powerShellContextService.ExecuteScriptStringAsync( string debugRunspaceCmd; if (request.RunspaceName != null) { + IEnumerable ids = await _powerShellContextService.ExecuteCommandAsync(new PSCommand() + .AddCommand("Microsoft.PowerShell.Utility\\Get-Runspace") + .AddParameter("Name", request.RunspaceName) + .AddCommand("Microsoft.PowerShell.Utility\\Select-Object") + .AddParameter("ExpandProperty", "Id")); + foreach (var id in ids) + { + _debugStateService.RunspaceId = id; + break; + } debugRunspaceCmd = $"\nDebug-Runspace -Name '{request.RunspaceName}'"; } else if (request.RunspaceId != null) @@ -345,18 +364,29 @@ await _powerShellContextService.ExecuteScriptStringAsync( throw new RpcErrorException(0, "A positive integer must be specified for the RunspaceId field."); } + _debugStateService.RunspaceId = runspaceId; + debugRunspaceCmd = $"\nDebug-Runspace -Id {runspaceId}"; } else { + _debugStateService.RunspaceId = 1; + debugRunspaceCmd = "\nDebug-Runspace -Id 1"; } + // Clear any existing breakpoints before proceeding + await _breakpointService.RemoveAllBreakpointsAsync().ConfigureAwait(continueOnCapturedContext: false); + _debugStateService.WaitingForAttach = true; Task nonAwaitedTask = _powerShellContextService .ExecuteScriptStringAsync(debugRunspaceCmd) .ContinueWith(OnExecutionCompletedAsync); + if (runspaceVersion.Version.Major >= 7) + { + _jsonRpcServer.SendNotification(EventNames.Initialized); + } return Unit.Value; } @@ -374,33 +404,33 @@ private async Task OnExecutionCompletedAsync(Task executeTask) _logger.LogTrace("Execution completed, terminating..."); - //_debugStateService.ExecutionCompleted = true; - - //_debugEventHandlerService.UnregisterEventHandlers(); - - //if (_debugStateService.IsAttachSession) - //{ - // // Pop the sessions - // if (_powerShellContextService.CurrentRunspace.Context == RunspaceContext.EnteredProcess) - // { - // try - // { - // await _powerShellContextService.ExecuteScriptStringAsync("Exit-PSHostProcess"); - - // if (_debugStateService.IsRemoteAttach && - // _powerShellContextService.CurrentRunspace.Location == RunspaceLocation.Remote) - // { - // await _powerShellContextService.ExecuteScriptStringAsync("Exit-PSSession"); - // } - // } - // catch (Exception e) - // { - // _logger.LogException("Caught exception while popping attached process after debugging", e); - // } - // } - //} - - //_debugService.IsClientAttached = false; + _debugStateService.ExecutionCompleted = true; + + _debugEventHandlerService.UnregisterEventHandlers(); + + if (_debugStateService.IsAttachSession) + { + // Pop the sessions + if (_powerShellContextService.CurrentRunspace.Context == RunspaceContext.EnteredProcess) + { + try + { + await _powerShellContextService.ExecuteScriptStringAsync("Exit-PSHostProcess"); + + if (_debugStateService.IsRemoteAttach && + _powerShellContextService.CurrentRunspace.Location == RunspaceLocation.Remote) + { + await _powerShellContextService.ExecuteScriptStringAsync("Exit-PSSession"); + } + } + catch (Exception e) + { + _logger.LogException("Caught exception while popping attached process after debugging", e); + } + } + } + + _debugService.IsClientAttached = false; _jsonRpcServer.SendNotification(EventNames.Terminated); } } diff --git a/src/PowerShellEditorServices/Services/PowerShellContext/PowerShellContextService.cs b/src/PowerShellEditorServices/Services/PowerShellContext/PowerShellContextService.cs index 599453546..3c6413325 100644 --- a/src/PowerShellEditorServices/Services/PowerShellContext/PowerShellContextService.cs +++ b/src/PowerShellEditorServices/Services/PowerShellContext/PowerShellContextService.cs @@ -46,7 +46,7 @@ public class PowerShellContextService : IDisposable, IHostSupportsInteractiveSes static PowerShellContextService() { // PowerShell ApartmentState APIs aren't available in PSStandard, so we need to use reflection - if (!VersionUtils.IsNetCore || VersionUtils.IsPS7) + if (!VersionUtils.IsNetCore || VersionUtils.IsPS7OrGreater) { MethodInfo setterInfo = typeof(Runspace).GetProperty("ApartmentState").GetSetMethod(); Delegate setter = Delegate.CreateDelegate(typeof(Action), firstArgument: null, method: setterInfo); diff --git a/src/PowerShellEditorServices/Services/PowerShellContext/Session/Capabilities/DscBreakpointCapability.cs b/src/PowerShellEditorServices/Services/PowerShellContext/Session/Capabilities/DscBreakpointCapability.cs index 220801dfa..247c1d13e 100644 --- a/src/PowerShellEditorServices/Services/PowerShellContext/Session/Capabilities/DscBreakpointCapability.cs +++ b/src/PowerShellEditorServices/Services/PowerShellContext/Session/Capabilities/DscBreakpointCapability.cs @@ -24,7 +24,7 @@ internal class DscBreakpointCapability : IRunspaceCapability private Dictionary breakpointsPerFile = new Dictionary(); - public async Task> SetLineBreakpointsAsync( + public async Task SetLineBreakpointsAsync( PowerShellContextService powerShellContext, string scriptPath, BreakpointDetails[] breakpoints) @@ -68,7 +68,7 @@ await powerShellContext.ExecuteScriptStringAsync( breakpoint.Verified = true; } - return breakpoints.ToList(); + return breakpoints.ToArray(); } public bool IsDscResourcePath(string scriptPath) diff --git a/src/PowerShellEditorServices/Utility/LspDebugUtils.cs b/src/PowerShellEditorServices/Utility/LspDebugUtils.cs index 4423252ac..abaeb4546 100644 --- a/src/PowerShellEditorServices/Utility/LspDebugUtils.cs +++ b/src/PowerShellEditorServices/Utility/LspDebugUtils.cs @@ -4,9 +4,9 @@ namespace Microsoft.PowerShell.EditorServices.Utility { - public static class LspDebugUtils + internal static class LspDebugUtils { - public static Breakpoint CreateBreakpoint( + internal static Breakpoint CreateBreakpoint( BreakpointDetails breakpointDetails) { Validate.IsNotNull(nameof(breakpointDetails), breakpointDetails); @@ -22,7 +22,7 @@ public static Breakpoint CreateBreakpoint( }; } - public static Breakpoint CreateBreakpoint( + internal static Breakpoint CreateBreakpoint( CommandBreakpointDetails breakpointDetails) { Validate.IsNotNull(nameof(breakpointDetails), breakpointDetails); diff --git a/src/PowerShellEditorServices/Utility/VersionUtils.cs b/src/PowerShellEditorServices/Utility/VersionUtils.cs index 159ceb97a..3dddd7914 100644 --- a/src/PowerShellEditorServices/Utility/VersionUtils.cs +++ b/src/PowerShellEditorServices/Utility/VersionUtils.cs @@ -47,7 +47,7 @@ internal static class VersionUtils /// /// True if we are running in PowerShell 7, false otherwise. /// - public static bool IsPS7 { get; } = PSVersion.Major == 7; + public static bool IsPS7OrGreater { get; } = PSVersion.Major >= 7; /// /// True if we are running in on Windows, false otherwise.