diff --git a/src/PowerShellEditorServices.Engine/Hosting/EditorServicesHost.cs b/src/PowerShellEditorServices.Engine/Hosting/EditorServicesHost.cs index da21892e9..710c99913 100644 --- a/src/PowerShellEditorServices.Engine/Hosting/EditorServicesHost.cs +++ b/src/PowerShellEditorServices.Engine/Hosting/EditorServicesHost.cs @@ -318,7 +318,7 @@ public void StartDebugService( Task.WhenAll(tasks) .ContinueWith(async task => { _logger.LogInformation("Starting debug server"); - await _debugServer.StartAsync(); + await _debugServer.StartAsync(_languageServer.LanguageServer.Services); _logger.LogInformation( $"Debug service started, type = {config.TransportType}, endpoint = {config.Endpoint}"); }); @@ -327,6 +327,12 @@ public void StartDebugService( default: throw new NotSupportedException("not supported"); } + + _debugServer.SessionEnded += (sender, eventArgs) => + { + _debugServer.Dispose(); + StartDebugService(config, profilePaths, useExistingSession); + }; } /// diff --git a/src/PowerShellEditorServices.Engine/PowerShellEditorServices.Engine.csproj b/src/PowerShellEditorServices.Engine/PowerShellEditorServices.Engine.csproj index d2f3256c7..17607082a 100644 --- a/src/PowerShellEditorServices.Engine/PowerShellEditorServices.Engine.csproj +++ b/src/PowerShellEditorServices.Engine/PowerShellEditorServices.Engine.csproj @@ -32,4 +32,8 @@ + + + + diff --git a/src/PowerShellEditorServices.Engine/Server/NamedPipePsesLanguageServer.cs b/src/PowerShellEditorServices.Engine/Server/NamedPipePsesLanguageServer.cs index 08086d5d8..4742bbe09 100644 --- a/src/PowerShellEditorServices.Engine/Server/NamedPipePsesLanguageServer.cs +++ b/src/PowerShellEditorServices.Engine/Server/NamedPipePsesLanguageServer.cs @@ -61,7 +61,7 @@ protected override (Stream input, Stream output) GetInputOutputStreams() _outNamedPipeName, out NamedPipeServerStream outNamedPipe); - var logger = _loggerFactory.CreateLogger("NamedPipeConnection"); + var logger = LoggerFactory.CreateLogger("NamedPipeConnection"); logger.LogInformation("Waiting for connection"); namedPipe.WaitForConnection(); diff --git a/src/PowerShellEditorServices.Engine/Server/PsesDebugServer.cs b/src/PowerShellEditorServices.Engine/Server/PsesDebugServer.cs index 1972e459b..67e040ab9 100644 --- a/src/PowerShellEditorServices.Engine/Server/PsesDebugServer.cs +++ b/src/PowerShellEditorServices.Engine/Server/PsesDebugServer.cs @@ -3,32 +3,24 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. // -using System.Collections.Generic; +using System; using System.IO; -using System.Management.Automation; -using System.Management.Automation.Host; -using System.Management.Automation.Runspaces; -using System.Reflection; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.PowerShell.EditorServices.Engine.Handlers; -using Microsoft.PowerShell.EditorServices.Engine.Hosting; using Microsoft.PowerShell.EditorServices.Engine.Services; -using Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext; using OmniSharp.Extensions.DebugAdapter.Protocol.Serialization; using OmniSharp.Extensions.JsonRpc; using OmniSharp.Extensions.LanguageServer.Server; namespace Microsoft.PowerShell.EditorServices.Engine.Server { - internal class PsesDebugServer + internal class PsesDebugServer : IDisposable { protected readonly ILoggerFactory _loggerFactory; private readonly Stream _inputStream; private readonly Stream _outputStream; - private readonly TaskCompletionSource _serverStart; - private IJsonRpcServer _jsonRpcServer; @@ -40,11 +32,9 @@ internal PsesDebugServer( _loggerFactory = factory; _inputStream = inputStream; _outputStream = outputStream; - _serverStart = new TaskCompletionSource(); - } - public async Task StartAsync() + public async Task StartAsync(IServiceProvider languageServerServiceProvider) { _jsonRpcServer = await JsonRpcServer.From(options => { @@ -52,53 +42,41 @@ public async Task StartAsync() options.Reciever = new DapReciever(); options.LoggerFactory = _loggerFactory; ILogger logger = options.LoggerFactory.CreateLogger("DebugOptionsStartup"); - options.AddHandler(); - // options.Services = new ServiceCollection() - // .AddSingleton() - // .AddSingleton() - // .AddSingleton() - // .AddSingleton( - // (provider) => - // GetFullyInitializedPowerShellContext( - // provider.GetService(), - // _profilePaths)) - // .AddSingleton() - // .AddSingleton() - // .AddSingleton( - // (provider) => - // { - // var extensionService = new ExtensionService( - // provider.GetService(), - // provider.GetService()); - // extensionService.InitializeAsync( - // serviceProvider: provider, - // editorOperations: provider.GetService()) - // .Wait(); - // return extensionService; - // }) - // .AddSingleton( - // (provider) => - // { - // return AnalysisService.Create( - // provider.GetService(), - // provider.GetService(), - // options.LoggerFactory.CreateLogger()); - // }); - + options.Services = new ServiceCollection() + .AddSingleton(languageServerServiceProvider.GetService()) + .AddSingleton() + .AddSingleton() + .AddSingleton(); + options .WithInput(_inputStream) .WithOutput(_outputStream); logger.LogInformation("Adding handlers"); + options + .WithHandler() + .WithHandler() + .WithHandler(); + logger.LogInformation("Handlers added"); }); } - public async Task WaitForShutdown() + public void Dispose() + { + _jsonRpcServer.Dispose(); + } + + #region Events + + public event EventHandler SessionEnded; + + internal void OnSessionEnded() { - await _serverStart.Task; - //await _languageServer.; + SessionEnded?.Invoke(this, null); } + + #endregion } } diff --git a/src/PowerShellEditorServices.Engine/Server/PsesLanguageServer.cs b/src/PowerShellEditorServices.Engine/Server/PsesLanguageServer.cs index f0ce7f920..c1b611d03 100644 --- a/src/PowerShellEditorServices.Engine/Server/PsesLanguageServer.cs +++ b/src/PowerShellEditorServices.Engine/Server/PsesLanguageServer.cs @@ -22,7 +22,9 @@ namespace Microsoft.PowerShell.EditorServices.Engine.Server { internal abstract class PsesLanguageServer { - protected readonly ILoggerFactory _loggerFactory; + internal ILoggerFactory LoggerFactory { get; private set; } + internal ILanguageServer LanguageServer { get; private set; } + private readonly LogLevel _minimumLogLevel; private readonly bool _enableConsoleRepl; private readonly HashSet _featureFlags; @@ -32,8 +34,6 @@ internal abstract class PsesLanguageServer private readonly ProfilePaths _profilePaths; private readonly TaskCompletionSource _serverStart; - private ILanguageServer _languageServer; - internal PsesLanguageServer( ILoggerFactory factory, LogLevel minimumLogLevel, @@ -44,7 +44,7 @@ internal PsesLanguageServer( PSHost internalHost, ProfilePaths profilePaths) { - _loggerFactory = factory; + LoggerFactory = factory; _minimumLogLevel = minimumLogLevel; _enableConsoleRepl = enableConsoleRepl; _featureFlags = featureFlags; @@ -57,10 +57,10 @@ internal PsesLanguageServer( public async Task StartAsync() { - _languageServer = await LanguageServer.From(options => + LanguageServer = await OmniSharp.Extensions.LanguageServer.Server.LanguageServer.From(options => { options.AddDefaultLoggingProvider(); - options.LoggerFactory = _loggerFactory; + options.LoggerFactory = LoggerFactory; ILogger logger = options.LoggerFactory.CreateLogger("OptionsStartup"); options.Services = new ServiceCollection() .AddSingleton() @@ -155,14 +155,14 @@ await serviceProvider.GetService().SetWorkingDirectory public async Task WaitForShutdown() { await _serverStart.Task; - await _languageServer.WaitForExit; + await LanguageServer.WaitForExit; } private PowerShellContextService GetFullyInitializedPowerShellContext( OmniSharp.Extensions.LanguageServer.Protocol.Server.ILanguageServer languageServer, ProfilePaths profilePaths) { - var logger = _loggerFactory.CreateLogger(); + var logger = LoggerFactory.CreateLogger(); // PSReadLine can only be used when -EnableConsoleRepl is specified otherwise // issues arise when redirecting stdio. diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/DebugEventHandlerService.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/DebugEventHandlerService.cs new file mode 100644 index 000000000..313715a11 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/DebugEventHandlerService.cs @@ -0,0 +1,168 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Management.Automation; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Engine.Services.DebugAdapter; +using Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.DebugAdapter.Protocol.Events; +using OmniSharp.Extensions.JsonRpc; + +namespace Microsoft.PowerShell.EditorServices.Engine.Services +{ + public class DebugEventHandlerService + { + private readonly ILogger _logger; + private readonly PowerShellContextService _powerShellContextService; + private readonly DebugService _debugService; + private readonly DebugStateService _debugStateService; + private readonly IJsonRpcServer _jsonRpcServer; + + public DebugEventHandlerService( + ILoggerFactory factory, + PowerShellContextService powerShellContextService, + DebugService debugService, + DebugStateService debugStateService, + IJsonRpcServer jsonRpcServer) + { + _logger = factory.CreateLogger(); + _powerShellContextService = powerShellContextService; + _debugService = debugService; + _debugStateService = debugStateService; + _jsonRpcServer = jsonRpcServer; + } + + internal void RegisterEventHandlers() + { + _powerShellContextService.RunspaceChanged += PowerShellContext_RunspaceChangedAsync; + _debugService.BreakpointUpdated += DebugService_BreakpointUpdatedAsync; + _debugService.DebuggerStopped += DebugService_DebuggerStoppedAsync; + _powerShellContextService.DebuggerResumed += PowerShellContext_DebuggerResumedAsync; + } + + internal void UnregisterEventHandlers() + { + _powerShellContextService.RunspaceChanged -= PowerShellContext_RunspaceChangedAsync; + _debugService.BreakpointUpdated -= DebugService_BreakpointUpdatedAsync; + _debugService.DebuggerStopped -= DebugService_DebuggerStoppedAsync; + _powerShellContextService.DebuggerResumed -= PowerShellContext_DebuggerResumedAsync; + } + + #region Event Handlers + + private void DebugService_DebuggerStoppedAsync(object sender, DebuggerStoppedEventArgs e) + { + // Provide the reason for why the debugger has stopped script execution. + // See https://github.com/Microsoft/vscode/issues/3648 + // The reason is displayed in the breakpoints viewlet. Some recommended reasons are: + // "step", "breakpoint", "function breakpoint", "exception" and "pause". + // We don't support exception breakpoints and for "pause", we can't distinguish + // between stepping and the user pressing the pause/break button in the debug toolbar. + string debuggerStoppedReason = "step"; + if (e.OriginalEvent.Breakpoints.Count > 0) + { + debuggerStoppedReason = + e.OriginalEvent.Breakpoints[0] is CommandBreakpoint + ? "function breakpoint" + : "breakpoint"; + } + + _jsonRpcServer.SendNotification(EventNames.Stopped, + new StoppedEvent + { + ThreadId = 1, + Reason = debuggerStoppedReason + }); + } + + private void PowerShellContext_RunspaceChangedAsync(object sender, RunspaceChangedEventArgs e) + { + if (_debugStateService.WaitingForAttach && + e.ChangeAction == RunspaceChangeAction.Enter && + e.NewRunspace.Context == RunspaceContext.DebuggedRunspace) + { + // Send the InitializedEvent so that the debugger will continue + // sending configuration requests + _debugStateService.WaitingForAttach = false; + _jsonRpcServer.SendNotification(EventNames.Initialized); + } + else if ( + e.ChangeAction == RunspaceChangeAction.Exit && + (_powerShellContextService.IsDebuggerStopped)) + { + // Exited the session while the debugger is stopped, + // send a ContinuedEvent so that the client changes the + // UI to appear to be running again + _jsonRpcServer.SendNotification(EventNames.Continued, + new ContinuedEvent + { + ThreadId = 1, + AllThreadsContinued = true + }); + } + } + + private void PowerShellContext_DebuggerResumedAsync(object sender, DebuggerResumeAction e) + { + _jsonRpcServer.SendNotification(EventNames.Continued, + new ContinuedEvent + { + AllThreadsContinued = true, + ThreadId = 1 + }); + } + + private void DebugService_BreakpointUpdatedAsync(object sender, BreakpointUpdatedEventArgs e) + { + string reason = "changed"; + + if (_debugStateService.SetBreakpointInProgress) + { + // Don't send breakpoint update notifications when setting + // breakpoints on behalf of the client. + return; + } + + switch (e.UpdateType) + { + case BreakpointUpdateType.Set: + reason = "new"; + break; + + case BreakpointUpdateType.Removed: + reason = "removed"; + break; + } + + OmniSharp.Extensions.DebugAdapter.Protocol.Models.Breakpoint breakpoint; + if (e.Breakpoint is LineBreakpoint) + { + breakpoint = LspBreakpointUtils.CreateBreakpoint(BreakpointDetails.Create(e.Breakpoint)); + } + else if (e.Breakpoint is CommandBreakpoint) + { + _logger.LogTrace("Function breakpoint updated event is not supported yet"); + return; + } + else + { + _logger.LogError($"Unrecognized breakpoint type {e.Breakpoint.GetType().FullName}"); + return; + } + + breakpoint.Verified = e.UpdateType != BreakpointUpdateType.Disabled; + + _jsonRpcServer.SendNotification(EventNames.Breakpoint, + new BreakpointEvent + { + Reason = reason, + Breakpoint = breakpoint + }); + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/DebugService.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/DebugService.cs new file mode 100644 index 000000000..182dd8ae9 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/DebugService.cs @@ -0,0 +1,1360 @@ +// +// 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.Management.Automation.Language; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Utility; +using System.Threading; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Engine.Logging; +using Microsoft.PowerShell.EditorServices.Engine.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext; +using Microsoft.PowerShell.EditorServices.Engine.Services.DebugAdapter; + +namespace Microsoft.PowerShell.EditorServices.Engine.Services +{ + /// + /// Provides a high-level service for interacting with the + /// PowerShell debugger in the runspace managed by a PowerShellContext. + /// + public class DebugService + { + #region Fields + + private const string PsesGlobalVariableNamePrefix = "__psEditorServices_"; + private const string TemporaryScriptFileName = "Script Listing.ps1"; + + private readonly ILogger logger; + private readonly PowerShellContextService powerShellContext; + //private RemoteFileManagerService remoteFileManager; + + // TODO: This needs to be managed per nested session + private readonly Dictionary> breakpointsPerFile = + new Dictionary>(); + + private int nextVariableId; + private readonly string temporaryScriptListingPath; + private List variables; + private VariableContainerDetails globalScopeVariables; + private VariableContainerDetails scriptScopeVariables; + private StackFrameDetails[] stackFrameDetails; + private readonly PropertyInfo invocationTypeScriptPositionProperty; + + private static int breakpointHitCounter; + + private readonly SemaphoreSlim debugInfoHandle = AsyncUtils.CreateSimpleLockingSemaphore(); + #endregion + + #region Properties + + /// + /// Gets or sets a boolean that indicates whether a debugger client is + /// currently attached to the debugger. + /// + public bool IsClientAttached { get; set; } + + /// + /// Gets a boolean that indicates whether the debugger is currently + /// stopped at a breakpoint. + /// + public bool IsDebuggerStopped => this.powerShellContext.IsDebuggerStopped; + + /// + /// Gets the current DebuggerStoppedEventArgs when the debugger + /// is stopped. + /// + public DebuggerStoppedEventArgs CurrentDebuggerStoppedEventArgs { get; private set; } + + #endregion + + #region Constructors + + /// + /// Initializes a new instance of the DebugService class and uses + /// the given PowerShellContext for all future operations. + /// + /// + /// The PowerShellContext to use for all debugging operations. + /// + /// An ILogger implementation used for writing log messages. + //public DebugService(PowerShellContextService powerShellContext, ILogger logger) + // : this(powerShellContext, null, logger) + //{ + //} + + /// + /// Initializes a new instance of the DebugService class and uses + /// the given PowerShellContext for all future operations. + /// + /// + /// The PowerShellContext to use for all debugging operations. + /// + //// + //// A RemoteFileManager instance to use for accessing files in remote sessions. + //// + /// An ILogger implementation used for writing log messages. + public DebugService( + PowerShellContextService powerShellContext, + //RemoteFileManager remoteFileManager, + ILoggerFactory factory) + { + Validate.IsNotNull(nameof(powerShellContext), powerShellContext); + + this.logger = factory.CreateLogger(); + this.powerShellContext = powerShellContext; + this.powerShellContext.DebuggerStop += this.OnDebuggerStopAsync; + this.powerShellContext.DebuggerResumed += this.OnDebuggerResumed; + + this.powerShellContext.BreakpointUpdated += this.OnBreakpointUpdated; + + //this.remoteFileManager = remoteFileManager; + + this.invocationTypeScriptPositionProperty = + typeof(InvocationInfo) + .GetProperty( + "ScriptPosition", + BindingFlags.NonPublic | BindingFlags.Instance); + } + + #endregion + + #region Public Methods + + /// + /// Sets the list of line breakpoints for the current debugging session. + /// + /// The ScriptFile in which breakpoints will be set. + /// BreakpointDetails for each breakpoint that will be set. + /// If true, causes all existing breakpoints to be cleared before setting new ones. + /// An awaitable Task that will provide details about the breakpoints that were set. + public async Task SetLineBreakpointsAsync( + ScriptFile scriptFile, + BreakpointDetails[] breakpoints, + bool clearExisting = true) + { + var resultBreakpointDetails = new List(); + + var dscBreakpoints = + this.powerShellContext + .CurrentRunspace + .GetCapability(); + + string scriptPath = scriptFile.FilePath; + //TODO: BRING THIS BACK + // Make sure we're using the remote script path + /* + if (this.powerShellContext.CurrentRunspace.Location == RunspaceLocation.Remote && + this.remoteFileManager != null) + { + if (!this.remoteFileManager.IsUnderRemoteTempPath(scriptPath)) + { + this.logger.Write( + LogLevel.Verbose, + $"Could not set breakpoints for local path '{scriptPath}' in a remote session."); + + return resultBreakpointDetails.ToArray(); + } + + string mappedPath = + this.remoteFileManager.GetMappedPath( + scriptPath, + this.powerShellContext.CurrentRunspace); + + scriptPath = mappedPath; + } + else */ + if ( + this.temporaryScriptListingPath != null && + this.temporaryScriptListingPath.Equals(scriptPath, StringComparison.CurrentCultureIgnoreCase)) + { + this.logger.LogTrace( + $"Could not set breakpoint on temporary script listing path '{scriptPath}'."); + + return resultBreakpointDetails.ToArray(); + } + + // Fix for issue #123 - file paths that contain wildcard chars [ and ] need to + // quoted and have those wildcard chars escaped. + string escapedScriptPath = + PowerShellContextService.WildcardEscapePath(scriptPath); + + if (dscBreakpoints == null || !dscBreakpoints.IsDscResourcePath(escapedScriptPath)) + { + if (clearExisting) + { + await this.ClearBreakpointsInFileAsync(scriptFile); + } + + foreach (BreakpointDetails breakpoint in breakpoints) + { + PSCommand psCommand = new PSCommand(); + psCommand.AddCommand(@"Microsoft.PowerShell.Utility\Set-PSBreakpoint"); + psCommand.AddParameter("Script", escapedScriptPath); + psCommand.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); + } + + IEnumerable configuredBreakpoints = + await this.powerShellContext.ExecuteCommandAsync(psCommand); + + // 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( + this.powerShellContext, + escapedScriptPath, + breakpoints); + } + + return resultBreakpointDetails.ToArray(); + } + + /// + /// Sets the list of command breakpoints for the current debugging session. + /// + /// CommandBreakpointDetails for each command breakpoint that will be set. + /// If true, causes all existing function breakpoints to be cleared before setting new ones. + /// An awaitable Task that will provide details about the breakpoints that were set. + public async Task SetCommandBreakpointsAsync( + CommandBreakpointDetails[] breakpoints, + bool clearExisting = true) + { + var resultBreakpointDetails = new List(); + + if (clearExisting) + { + await this.ClearCommandBreakpointsAsync(); + } + + 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); + + // 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)); + } + } + + return resultBreakpointDetails.ToArray(); + } + + /// + /// Sends a "continue" action to the debugger when stopped. + /// + public void Continue() + { + this.powerShellContext.ResumeDebugger( + DebuggerResumeAction.Continue); + } + + /// + /// Sends a "step over" action to the debugger when stopped. + /// + public void StepOver() + { + this.powerShellContext.ResumeDebugger( + DebuggerResumeAction.StepOver); + } + + /// + /// Sends a "step in" action to the debugger when stopped. + /// + public void StepIn() + { + this.powerShellContext.ResumeDebugger( + DebuggerResumeAction.StepInto); + } + + /// + /// Sends a "step out" action to the debugger when stopped. + /// + public void StepOut() + { + this.powerShellContext.ResumeDebugger( + DebuggerResumeAction.StepOut); + } + + /// + /// Causes the debugger to break execution wherever it currently + /// is at the time. This is equivalent to clicking "Pause" in a + /// debugger UI. + /// + public void Break() + { + // Break execution in the debugger + this.powerShellContext.BreakExecution(); + } + + /// + /// Aborts execution of the debugger while it is running, even while + /// it is stopped. Equivalent to calling PowerShellContext.AbortExecution. + /// + public void Abort() + { + this.powerShellContext.AbortExecution(shouldAbortDebugSession: true); + } + + /// + /// Gets the list of variables that are children of the scope or variable + /// that is identified by the given referenced ID. + /// + /// + /// An array of VariableDetails instances which describe the requested variables. + public VariableDetailsBase[] GetVariables(int variableReferenceId) + { + VariableDetailsBase[] childVariables; + this.debugInfoHandle.Wait(); + try + { + if ((variableReferenceId < 0) || (variableReferenceId >= this.variables.Count)) + { + logger.LogWarning($"Received request for variableReferenceId {variableReferenceId} that is out of range of valid indices."); + return new VariableDetailsBase[0]; + } + + VariableDetailsBase parentVariable = this.variables[variableReferenceId]; + if (parentVariable.IsExpandable) + { + childVariables = parentVariable.GetChildren(this.logger); + foreach (var child in childVariables) + { + // Only add child if it hasn't already been added. + if (child.Id < 0) + { + child.Id = this.nextVariableId++; + this.variables.Add(child); + } + } + } + else + { + childVariables = new VariableDetailsBase[0]; + } + + return childVariables; + } + finally + { + this.debugInfoHandle.Release(); + } + } + + /// + /// Evaluates a variable expression in the context of the stopped + /// debugger. This method decomposes the variable expression to + /// walk the cached variable data for the specified stack frame. + /// + /// The variable expression string to evaluate. + /// The ID of the stack frame in which the expression should be evaluated. + /// A VariableDetailsBase object containing the result. + public VariableDetailsBase GetVariableFromExpression(string variableExpression, int stackFrameId) + { + // NOTE: From a watch we will get passed expressions that are not naked variables references. + // Probably the right way to do this woudld be to examine the AST of the expr before calling + // this method to make sure it is a VariableReference. But for the most part, non-naked variable + // references are very unlikely to find a matching variable e.g. "$i+5.2" will find no var matching "$i+5". + + // Break up the variable path + string[] variablePathParts = variableExpression.Split('.'); + + VariableDetailsBase resolvedVariable = null; + IEnumerable variableList; + + // Ensure debug info isn't currently being built. + this.debugInfoHandle.Wait(); + try + { + variableList = this.variables; + } + finally + { + this.debugInfoHandle.Release(); + } + + foreach (var variableName in variablePathParts) + { + if (variableList == null) + { + // If there are no children left to search, break out early + return null; + } + + resolvedVariable = + variableList.FirstOrDefault( + v => + string.Equals( + v.Name, + variableName, + StringComparison.CurrentCultureIgnoreCase)); + + if (resolvedVariable != null && + resolvedVariable.IsExpandable) + { + // Continue by searching in this variable's children + variableList = this.GetVariables(resolvedVariable.Id); + } + } + + return resolvedVariable; + } + + /// + /// Sets the specified variable by container variableReferenceId and variable name to the + /// specified new value. If the variable cannot be set or converted to that value this + /// method will throw InvalidPowerShellExpressionException, ArgumentTransformationMetadataException, or + /// SessionStateUnauthorizedAccessException. + /// + /// The container (Autos, Local, Script, Global) that holds the variable. + /// The name of the variable prefixed with $. + /// The new string value. This value must not be null. If you want to set the variable to $null + /// pass in the string "$null". + /// The string representation of the value the variable was set to. + public async Task SetVariableAsync(int variableContainerReferenceId, string name, string value) + { + Validate.IsNotNull(nameof(name), name); + Validate.IsNotNull(nameof(value), value); + + this.logger.LogTrace($"SetVariableRequest for '{name}' to value string (pre-quote processing): '{value}'"); + + // An empty or whitespace only value is not a valid expression for SetVariable. + if (value.Trim().Length == 0) + { + throw new InvalidPowerShellExpressionException("Expected an expression."); + } + + // Evaluate the expression to get back a PowerShell object from the expression string. + PSCommand psCommand = new PSCommand(); + psCommand.AddScript(value); + var errorMessages = new StringBuilder(); + var results = + await this.powerShellContext.ExecuteCommandAsync( + psCommand, + errorMessages, + false, + false); + + // Check if PowerShell's evaluation of the expression resulted in an error. + object psobject = results.FirstOrDefault(); + if ((psobject == null) && (errorMessages.Length > 0)) + { + throw new InvalidPowerShellExpressionException(errorMessages.ToString()); + } + + // If PowerShellContext.ExecuteCommand returns an ErrorRecord as output, the expression failed evaluation. + // Ideally we would have a separate means from communicating error records apart from normal output. + if (psobject is ErrorRecord errorRecord) + { + throw new InvalidPowerShellExpressionException(errorRecord.ToString()); + } + + // OK, now we have a PS object from the supplied value string (expression) to assign to a variable. + // Get the variable referenced by variableContainerReferenceId and variable name. + VariableContainerDetails variableContainer = null; + await this.debugInfoHandle.WaitAsync(); + try + { + variableContainer = (VariableContainerDetails)this.variables[variableContainerReferenceId]; + } + finally + { + this.debugInfoHandle.Release(); + } + + VariableDetailsBase variable = variableContainer.Children[name]; + // Determine scope in which the variable lives. This is required later for the call to Get-Variable -Scope. + string scope = null; + if (variableContainerReferenceId == this.scriptScopeVariables.Id) + { + scope = "Script"; + } + else if (variableContainerReferenceId == this.globalScopeVariables.Id) + { + scope = "Global"; + } + else + { + // Determine which stackframe's local scope the variable is in. + StackFrameDetails[] stackFrames = await this.GetStackFramesAsync(); + for (int i = 0; i < stackFrames.Length; i++) + { + var stackFrame = stackFrames[i]; + if (stackFrame.LocalVariables.ContainsVariable(variable.Id)) + { + scope = i.ToString(); + break; + } + } + } + + if (scope == null) + { + // Hmm, this would be unexpected. No scope means do not pass GO, do not collect $200. + throw new Exception("Could not find the scope for this variable."); + } + + // Now that we have the scope, get the associated PSVariable object for the variable to be set. + psCommand.Commands.Clear(); + psCommand = new PSCommand(); + psCommand.AddCommand(@"Microsoft.PowerShell.Utility\Get-Variable"); + psCommand.AddParameter("Name", name.TrimStart('$')); + psCommand.AddParameter("Scope", scope); + + IEnumerable result = await this.powerShellContext.ExecuteCommandAsync(psCommand, sendErrorToHost: false); + PSVariable psVariable = result.FirstOrDefault(); + if (psVariable == null) + { + throw new Exception($"Failed to retrieve PSVariable object for '{name}' from scope '{scope}'."); + } + + // We have the PSVariable object for the variable the user wants to set and an object to assign to that variable. + // The last step is to determine whether the PSVariable is "strongly typed" which may require a conversion. + // If it is not strongly typed, we simply assign the object directly to the PSVariable potentially changing its type. + // Turns out ArgumentTypeConverterAttribute is not public. So we call the attribute through it's base class - + // ArgumentTransformationAttribute. + var argTypeConverterAttr = + psVariable.Attributes + .OfType() + .FirstOrDefault(a => a.GetType().Name.Equals("ArgumentTypeConverterAttribute")); + + if (argTypeConverterAttr != null) + { + // PSVariable is strongly typed. Need to apply the conversion/transform to the new value. + psCommand.Commands.Clear(); + psCommand = new PSCommand(); + psCommand.AddCommand(@"Microsoft.PowerShell.Utility\Get-Variable"); + psCommand.AddParameter("Name", "ExecutionContext"); + psCommand.AddParameter("ValueOnly"); + + errorMessages.Clear(); + + var getExecContextResults = + await this.powerShellContext.ExecuteCommandAsync( + psCommand, + errorMessages, + sendErrorToHost: false); + + EngineIntrinsics executionContext = getExecContextResults.OfType().FirstOrDefault(); + + var msg = $"Setting variable '{name}' using conversion to value: {psobject ?? ""}"; + this.logger.LogTrace(msg); + + psVariable.Value = argTypeConverterAttr.Transform(executionContext, psobject); + } + else + { + // PSVariable is *not* strongly typed. In this case, whack the old value with the new value. + var msg = $"Setting variable '{name}' directly to value: {psobject ?? ""} - previous type was {psVariable.Value?.GetType().Name ?? ""}"; + this.logger.LogTrace(msg); + psVariable.Value = psobject; + } + + // Use the VariableDetails.ValueString functionality to get the string representation for client debugger. + // This makes the returned string consistent with the strings normally displayed for variables in the debugger. + var tempVariable = new VariableDetails(psVariable); + this.logger.LogTrace($"Set variable '{name}' to: {tempVariable.ValueString ?? ""}"); + return tempVariable.ValueString; + } + + /// + /// Evaluates an expression in the context of the stopped + /// debugger. This method will execute the specified expression + /// PowerShellContext. + /// + /// The expression string to execute. + /// The ID of the stack frame in which the expression should be executed. + /// + /// If true, writes the expression result as host output rather than returning the results. + /// In this case, the return value of this function will be null. + /// A VariableDetails object containing the result. + public async Task EvaluateExpressionAsync( + string expressionString, + int stackFrameId, + bool writeResultAsOutput) + { + var results = + await this.powerShellContext.ExecuteScriptStringAsync( + expressionString, + false, + writeResultAsOutput); + + // Since this method should only be getting invoked in the debugger, + // we can assume that Out-String will be getting used to format results + // of command executions into string output. However, if null is returned + // then return null so that no output gets displayed. + string outputString = + results != null && results.Any() ? + string.Join(Environment.NewLine, results) : + null; + + // If we've written the result as output, don't return a + // VariableDetails instance. + return + writeResultAsOutput ? + null : + new VariableDetails( + expressionString, + outputString); + } + + /// + /// Gets the list of stack frames at the point where the + /// debugger sf stopped. + /// + /// + /// An array of StackFrameDetails instances that contain the stack trace. + /// + public StackFrameDetails[] GetStackFrames() + { + this.debugInfoHandle.Wait(); + try + { + return this.stackFrameDetails; + } + finally + { + this.debugInfoHandle.Release(); + } + } + + internal StackFrameDetails[] GetStackFrames(CancellationToken cancellationToken) + { + this.debugInfoHandle.Wait(cancellationToken); + try + { + return this.stackFrameDetails; + } + finally + { + this.debugInfoHandle.Release(); + } + } + + internal async Task GetStackFramesAsync() + { + await this.debugInfoHandle.WaitAsync(); + try + { + return this.stackFrameDetails; + } + finally + { + this.debugInfoHandle.Release(); + } + } + + internal async Task GetStackFramesAsync(CancellationToken cancellationToken) + { + await this.debugInfoHandle.WaitAsync(cancellationToken); + try + { + return this.stackFrameDetails; + } + finally + { + this.debugInfoHandle.Release(); + } + } + + /// + /// Gets the list of variable scopes for the stack frame that + /// is identified by the given ID. + /// + /// The ID of the stack frame at which variable scopes should be retrieved. + /// The list of VariableScope instances which describe the available variable scopes. + public VariableScope[] GetVariableScopes(int stackFrameId) + { + var stackFrames = this.GetStackFrames(); + int localStackFrameVariableId = stackFrames[stackFrameId].LocalVariables.Id; + int autoVariablesId = stackFrames[stackFrameId].AutoVariables.Id; + + return new VariableScope[] + { + new VariableScope(autoVariablesId, VariableContainerDetails.AutoVariablesName), + new VariableScope(localStackFrameVariableId, VariableContainerDetails.LocalScopeName), + new VariableScope(this.scriptScopeVariables.Id, VariableContainerDetails.ScriptScopeName), + new VariableScope(this.globalScopeVariables.Id, VariableContainerDetails.GlobalScopeName), + }; + } + + /// + /// 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); + } + 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); + + // 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); + } + + private async Task FetchStackFramesAndVariablesAsync(string scriptNameOverride) + { + await this.debugInfoHandle.WaitAsync(); + try + { + this.nextVariableId = VariableDetailsBase.FirstVariableId; + this.variables = new List + { + + // Create a dummy variable for index 0, should never see this. + new VariableDetails("Dummy", null) + }; + + // Must retrieve global/script variales before stack frame variables + // as we check stack frame variables against globals. + await FetchGlobalAndScriptVariablesAsync(); + await FetchStackFramesAsync(scriptNameOverride); + } + finally + { + this.debugInfoHandle.Release(); + } + } + + private async Task FetchGlobalAndScriptVariablesAsync() + { + // Retrieve globals first as script variable retrieval needs to search globals. + this.globalScopeVariables = + await FetchVariableContainerAsync(VariableContainerDetails.GlobalScopeName, null); + + this.scriptScopeVariables = + await FetchVariableContainerAsync(VariableContainerDetails.ScriptScopeName, null); + } + + private async Task FetchVariableContainerAsync( + string scope, + VariableContainerDetails autoVariables) + { + PSCommand psCommand = new PSCommand(); + psCommand.AddCommand("Get-Variable"); + psCommand.AddParameter("Scope", scope); + + var scopeVariableContainer = + new VariableContainerDetails(this.nextVariableId++, "Scope: " + scope); + this.variables.Add(scopeVariableContainer); + + var results = await this.powerShellContext.ExecuteCommandAsync(psCommand, sendErrorToHost: false); + if (results != null) + { + foreach (PSObject psVariableObject in results) + { + var variableDetails = new VariableDetails(psVariableObject) { Id = this.nextVariableId++ }; + this.variables.Add(variableDetails); + scopeVariableContainer.Children.Add(variableDetails.Name, variableDetails); + + if ((autoVariables != null) && AddToAutoVariables(psVariableObject, scope)) + { + autoVariables.Children.Add(variableDetails.Name, variableDetails); + } + } + } + + return scopeVariableContainer; + } + + private bool AddToAutoVariables(PSObject psvariable, string scope) + { + if ((scope == VariableContainerDetails.GlobalScopeName) || + (scope == VariableContainerDetails.ScriptScopeName)) + { + // We don't A) have a good way of distinguishing built-in from user created variables + // and B) globalScopeVariables.Children.ContainsKey() doesn't work for built-in variables + // stored in a child variable container within the globals variable container. + return false; + } + + string variableName = psvariable.Properties["Name"].Value as string; + object variableValue = psvariable.Properties["Value"].Value; + + // Don't put any variables created by PSES in the Auto variable container. + if (variableName.StartsWith(PsesGlobalVariableNamePrefix) || + variableName.Equals("PSDebugContext")) + { + return false; + } + + ScopedItemOptions variableScope = ScopedItemOptions.None; + PSPropertyInfo optionsProperty = psvariable.Properties["Options"]; + if (string.Equals(optionsProperty.TypeNameOfValue, "System.String")) + { + if (!Enum.TryParse( + optionsProperty.Value as string, + out variableScope)) + { + this.logger.LogWarning( + $"Could not parse a variable's ScopedItemOptions value of '{optionsProperty.Value}'"); + } + } + else if (optionsProperty.Value is ScopedItemOptions) + { + variableScope = (ScopedItemOptions)optionsProperty.Value; + } + + // Some local variables, if they exist, should be displayed by default + if (psvariable.TypeNames[0].EndsWith("LocalVariable")) + { + if (variableName.Equals("_")) + { + return true; + } + else if (variableName.Equals("args", StringComparison.OrdinalIgnoreCase)) + { + return variableValue is Array array + && array.Length > 0; + } + + return false; + } + else if (!psvariable.TypeNames[0].EndsWith(nameof(PSVariable))) + { + return false; + } + + var constantAllScope = ScopedItemOptions.AllScope | ScopedItemOptions.Constant; + var readonlyAllScope = ScopedItemOptions.AllScope | ScopedItemOptions.ReadOnly; + + if (((variableScope & constantAllScope) == constantAllScope) || + ((variableScope & readonlyAllScope) == readonlyAllScope)) + { + string prefixedVariableName = VariableDetails.DollarPrefix + variableName; + if (this.globalScopeVariables.Children.ContainsKey(prefixedVariableName)) + { + return false; + } + } + + return true; + } + + private async Task FetchStackFramesAsync(string scriptNameOverride) + { + PSCommand psCommand = new PSCommand(); + + // This glorious hack ensures that Get-PSCallStack returns a list of CallStackFrame + // objects (or "deserialized" CallStackFrames) when attached to a runspace in another + // process. Without the intermediate variable Get-PSCallStack inexplicably returns + // an array of strings containing the formatted output of the CallStackFrame list. + var callStackVarName = $"$global:{PsesGlobalVariableNamePrefix}CallStack"; + psCommand.AddScript($"{callStackVarName} = Get-PSCallStack; {callStackVarName}"); + + var results = await this.powerShellContext.ExecuteCommandAsync(psCommand); + + var callStackFrames = results.ToArray(); + + this.stackFrameDetails = new StackFrameDetails[callStackFrames.Length]; + + for (int i = 0; i < callStackFrames.Length; i++) + { + VariableContainerDetails autoVariables = + new VariableContainerDetails( + this.nextVariableId++, + VariableContainerDetails.AutoVariablesName); + + this.variables.Add(autoVariables); + + VariableContainerDetails localVariables = + await FetchVariableContainerAsync(i.ToString(), autoVariables); + + // When debugging, this is the best way I can find to get what is likely the workspace root. + // This is controlled by the "cwd:" setting in the launch config. + string workspaceRootPath = this.powerShellContext.InitialWorkingDirectory; + + this.stackFrameDetails[i] = + StackFrameDetails.Create(callStackFrames[i], autoVariables, localVariables, workspaceRootPath); + + string stackFrameScriptPath = this.stackFrameDetails[i].ScriptPath; + if (scriptNameOverride != null && + string.Equals(stackFrameScriptPath, StackFrameDetails.NoFileScriptPath)) + { + this.stackFrameDetails[i].ScriptPath = scriptNameOverride; + } + // TODO: BRING THIS BACK + //else if (this.powerShellContext.CurrentRunspace.Location == RunspaceLocation.Remote && + // this.remoteFileManager != null && + // !string.Equals(stackFrameScriptPath, StackFrameDetails.NoFileScriptPath)) + //{ + // this.stackFrameDetails[i].ScriptPath = + // this.remoteFileManager.GetMappedPath( + // stackFrameScriptPath, + // this.powerShellContext.CurrentRunspace); + //} + } + } + + /// + /// 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(); + + if (!string.IsNullOrWhiteSpace(scriptLine)) + { + if (prefixLength == 0) + { + // The prefix is a padded integer ending with ':', an asterisk '*' + // if this is the current line, and one character of padding + prefixLength = scriptLine.IndexOf(':') + 2; + } + + return scriptLine.Substring(prefixLength); + } + + return null; + } + + #endregion + + #region Events + + /// + /// Raised when the debugger stops execution at a breakpoint or when paused. + /// + public event EventHandler DebuggerStopped; + + private async void OnDebuggerStopAsync(object sender, DebuggerStopEventArgs e) + { + bool noScriptName = false; + string localScriptPath = e.InvocationInfo.ScriptName; + + // TODO: BRING THIS BACK + // If there's no ScriptName, get the "list" of the current source + //if (this.remoteFileManager != null && string.IsNullOrEmpty(localScriptPath)) + //{ + // // Get the current script listing and create the buffer + // PSCommand command = new PSCommand(); + // command.AddScript($"list 1 {int.MaxValue}"); + + // IEnumerable scriptListingLines = + // await this.powerShellContext.ExecuteCommandAsync( + // command, false, false); + + // if (scriptListingLines != null) + // { + // int linePrefixLength = 0; + + // string scriptListing = + // string.Join( + // Environment.NewLine, + // scriptListingLines + // .Select(o => this.TrimScriptListingLine(o, ref linePrefixLength)) + // .Where(s => s != null)); + + // this.temporaryScriptListingPath = + // this.remoteFileManager.CreateTemporaryFile( + // $"[{this.powerShellContext.CurrentRunspace.SessionDetails.ComputerName}] {TemporaryScriptFileName}", + // scriptListing, + // this.powerShellContext.CurrentRunspace); + + // localScriptPath = + // this.temporaryScriptListingPath + // ?? StackFrameDetails.NoFileScriptPath; + + // noScriptName = localScriptPath != null; + // } + // else + // { + // this.logger.LogWarning($"Could not load script context"); + // } + //} + + // Get call stack and variables. + await this.FetchStackFramesAndVariablesAsync( + noScriptName ? localScriptPath : null); + + // TODO: BRING THIS BACK + // If this is a remote connection and the debugger stopped at a line + // in a script file, get the file contents + //if (this.powerShellContext.CurrentRunspace.Location == RunspaceLocation.Remote && + // this.remoteFileManager != null && + // !noScriptName) + //{ + // localScriptPath = + // await this.remoteFileManager.FetchRemoteFileAsync( + // e.InvocationInfo.ScriptName, + // this.powerShellContext.CurrentRunspace); + //} + + if (this.stackFrameDetails.Length > 0) + { + // Augment the top stack frame with details from the stop event + + if (this.invocationTypeScriptPositionProperty + .GetValue(e.InvocationInfo) is IScriptExtent scriptExtent) + { + this.stackFrameDetails[0].StartLineNumber = scriptExtent.StartLineNumber; + this.stackFrameDetails[0].EndLineNumber = scriptExtent.EndLineNumber; + this.stackFrameDetails[0].StartColumnNumber = scriptExtent.StartColumnNumber; + this.stackFrameDetails[0].EndColumnNumber = scriptExtent.EndColumnNumber; + } + } + + this.CurrentDebuggerStoppedEventArgs = + new DebuggerStoppedEventArgs( + e, + this.powerShellContext.CurrentRunspace, + localScriptPath); + + // Notify the host that the debugger is stopped + this.DebuggerStopped?.Invoke( + sender, + this.CurrentDebuggerStoppedEventArgs); + } + + private void OnDebuggerResumed(object sender, DebuggerResumeAction e) + { + this.CurrentDebuggerStoppedEventArgs = null; + } + + /// + /// Raised when a breakpoint is added/removed/updated in the debugger. + /// + public event EventHandler BreakpointUpdated; + + private void OnBreakpointUpdated(object sender, BreakpointUpdatedEventArgs e) + { + // This event callback also gets called when a CommandBreakpoint is modified. + // Only execute the following code for LineBreakpoint so we can keep track + // of which line breakpoints exist per script file. We use this later when + // we need to clear all breakpoints in a script file. We do not need to do + // this for CommandBreakpoint, as those span all script files. + if (e.Breakpoint is LineBreakpoint lineBreakpoint) + { + string scriptPath = lineBreakpoint.Script; + // TODO: BRING THIS BACK + //if (this.powerShellContext.CurrentRunspace.Location == RunspaceLocation.Remote && + // this.remoteFileManager != null) + //{ + // string mappedPath = + // this.remoteFileManager.GetMappedPath( + // scriptPath, + // this.powerShellContext.CurrentRunspace); + + // if (mappedPath == null) + // { + // this.logger.LogError( + // $"Could not map remote path '{scriptPath}' to a local path."); + + // return; + // } + + // scriptPath = mappedPath; + //} + + // Normalize the script filename for proper indexing + string normalizedScriptName = scriptPath.ToLower(); + + // Get the list of breakpoints for this file + if (!this.breakpointsPerFile.TryGetValue(normalizedScriptName, out List breakpoints)) + { + breakpoints = new List(); + this.breakpointsPerFile.Add( + normalizedScriptName, + breakpoints); + } + + // Add or remove the breakpoint based on the update type + if (e.UpdateType == BreakpointUpdateType.Set) + { + breakpoints.Add(e.Breakpoint); + } + else if (e.UpdateType == BreakpointUpdateType.Removed) + { + breakpoints.Remove(e.Breakpoint); + } + else + { + // TODO: Do I need to switch out instances for updated breakpoints? + } + } + + this.BreakpointUpdated?.Invoke(sender, e); + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/DebugStateService.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/DebugStateService.cs new file mode 100644 index 000000000..d12a1f5e2 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/DebugStateService.cs @@ -0,0 +1,32 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.PowerShell.EditorServices.Engine.Services +{ + public class DebugStateService + { + internal bool NoDebug { get; set; } + + internal string Arguments { get; set; } + + internal bool IsRemoteAttach { get; set; } + + internal bool IsAttachSession { get; set; } + + internal bool WaitingForAttach { get; set; } + + internal string ScriptToLaunch { get; set; } + + internal bool OwnsEditorSession { get; set; } + + internal bool ExecutionCompleted { get; set; } + + internal bool IsInteractiveDebugSession { get; set; } + + internal bool SetBreakpointInProgress { get; set; } + + internal bool IsUsingTempIntegratedConsole { get; set; } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/BreakpointDetails.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/BreakpointDetails.cs new file mode 100644 index 000000000..fb479dfb7 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/BreakpointDetails.cs @@ -0,0 +1,107 @@ +// +// 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.Management.Automation; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Engine.Services.DebugAdapter +{ + /// + /// Provides details about a breakpoint that is set in the + /// PowerShell debugger. + /// + public class BreakpointDetails : BreakpointDetailsBase + { + /// + /// Gets the unique ID of the breakpoint. + /// + /// + public int Id { get; private set; } + + /// + /// Gets the source where the breakpoint is located. Used only for debug purposes. + /// + public string Source { get; private set; } + + /// + /// Gets the line number at which the breakpoint is set. + /// + public int LineNumber { get; private set; } + + /// + /// Gets the column number at which the breakpoint is set. If null, the default of 1 is used. + /// + public int? ColumnNumber { get; private set; } + + private BreakpointDetails() + { + } + + /// + /// Creates an instance of the BreakpointDetails class from the individual + /// pieces of breakpoint information provided by the client. + /// + /// + /// + /// + /// + /// + /// + public static BreakpointDetails Create( + string source, + int line, + int? column = null, + string condition = null, + string hitCondition = null) + { + Validate.IsNotNull("source", source); + + return new BreakpointDetails + { + Verified = true, + Source = source, + LineNumber = line, + ColumnNumber = column, + Condition = condition, + HitCondition = hitCondition + }; + } + + /// + /// Creates an instance of the BreakpointDetails class from a + /// PowerShell Breakpoint object. + /// + /// The Breakpoint instance from which details will be taken. + /// A new instance of the BreakpointDetails class. + public static BreakpointDetails Create(Breakpoint breakpoint) + { + Validate.IsNotNull("breakpoint", breakpoint); + + if (!(breakpoint is LineBreakpoint lineBreakpoint)) + { + throw new ArgumentException( + "Unexpected breakpoint type: " + breakpoint.GetType().Name); + } + + var breakpointDetails = new BreakpointDetails + { + Id = breakpoint.Id, + Verified = true, + Source = lineBreakpoint.Script, + LineNumber = lineBreakpoint.Line, + ColumnNumber = lineBreakpoint.Column, + Condition = lineBreakpoint.Action?.ToString() + }; + + if (lineBreakpoint.Column > 0) + { + breakpointDetails.ColumnNumber = lineBreakpoint.Column; + } + + return breakpointDetails; + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/BreakpointDetailsBase.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/BreakpointDetailsBase.cs new file mode 100644 index 000000000..3393bd007 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/BreakpointDetailsBase.cs @@ -0,0 +1,36 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.PowerShell.EditorServices.Engine.Services.DebugAdapter +{ + /// + /// Provides details about a breakpoint that is set in the + /// PowerShell debugger. + /// + public abstract class BreakpointDetailsBase + { + /// + /// Gets or sets a boolean indicator that if true, breakpoint could be set + /// (but not necessarily at the desired location). + /// + public bool Verified { get; set; } + + /// + /// Gets or set an optional message about the state of the breakpoint. This is shown to the user + /// and can be used to explain why a breakpoint could not be verified. + /// + public string Message { get; set; } + + /// + /// Gets the breakpoint condition string. + /// + public string Condition { get; protected set; } + + /// + /// Gets the breakpoint hit condition string. + /// + public string HitCondition { get; protected set; } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/CommandBreakpointDetails.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/CommandBreakpointDetails.cs new file mode 100644 index 000000000..d181f3c92 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/CommandBreakpointDetails.cs @@ -0,0 +1,72 @@ +// +// 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.Management.Automation; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Engine.Services.DebugAdapter +{ + /// + /// Provides details about a command breakpoint that is set in the PowerShell debugger. + /// + public class CommandBreakpointDetails : BreakpointDetailsBase + { + /// + /// Gets the name of the command on which the command breakpoint has been set. + /// + public string Name { get; private set; } + + private CommandBreakpointDetails() + { + } + + /// + /// Creates an instance of the class from the individual + /// pieces of breakpoint information provided by the client. + /// + /// The name of the command to break on. + /// 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( + string name, + string condition = null, + string hitCondition = null) + { + Validate.IsNotNull(nameof(name), name); + + return new CommandBreakpointDetails { + Name = name, + Condition = condition + }; + } + + /// + /// Creates an instance of the class from a + /// PowerShell CommandBreakpoint object. + /// + /// The Breakpoint instance from which details will be taken. + /// A new instance of the BreakpointDetails class. + public static CommandBreakpointDetails Create(Breakpoint breakpoint) + { + Validate.IsNotNull("breakpoint", breakpoint); + + if (!(breakpoint is CommandBreakpoint commandBreakpoint)) + { + throw new ArgumentException( + "Unexpected breakpoint type: " + breakpoint.GetType().Name); + } + + var breakpointDetails = new CommandBreakpointDetails { + Verified = true, + Name = commandBreakpoint.Command, + Condition = commandBreakpoint.Action?.ToString() + }; + + return breakpointDetails; + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/DebuggerStoppedEventArgs.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/DebuggerStoppedEventArgs.cs new file mode 100644 index 000000000..9b478afb0 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/DebuggerStoppedEventArgs.cs @@ -0,0 +1,117 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext; +using Microsoft.PowerShell.EditorServices.Utility; +using System.Management.Automation; + +namespace Microsoft.PowerShell.EditorServices.Engine.Services.DebugAdapter +{ + /// + /// Provides event arguments for the DebugService.DebuggerStopped event. + /// + public class DebuggerStoppedEventArgs + { + #region Properties + + /// + /// Gets the path of the script where the debugger has stopped execution. + /// If 'IsRemoteSession' returns true, this path will be a local filesystem + /// path containing the contents of the script that is executing remotely. + /// + public string ScriptPath { get; private set; } + + /// + /// Returns true if the breakpoint was raised from a remote debugging session. + /// + public bool IsRemoteSession + { + get { return this.RunspaceDetails.Location == RunspaceLocation.Remote; } + } + + /// + /// Gets the original script path if 'IsRemoteSession' returns true. + /// + public string RemoteScriptPath { get; private set; } + + /// + /// Gets the RunspaceDetails for the current runspace. + /// + public RunspaceDetails RunspaceDetails { get; private set; } + + /// + /// Gets the line number at which the debugger stopped execution. + /// + public int LineNumber + { + get + { + return this.OriginalEvent.InvocationInfo.ScriptLineNumber; + } + } + + /// + /// Gets the column number at which the debugger stopped execution. + /// + public int ColumnNumber + { + get + { + return this.OriginalEvent.InvocationInfo.OffsetInLine; + } + } + + /// + /// Gets the original DebuggerStopEventArgs from the PowerShell engine. + /// + public DebuggerStopEventArgs OriginalEvent { get; private set; } + + #endregion + + #region Constructors + + /// + /// Creates a new instance of the DebuggerStoppedEventArgs class. + /// + /// The original DebuggerStopEventArgs instance from which this instance is based. + /// The RunspaceDetails of the runspace which raised this event. + public DebuggerStoppedEventArgs( + DebuggerStopEventArgs originalEvent, + RunspaceDetails runspaceDetails) + : this(originalEvent, runspaceDetails, null) + { + } + + /// + /// Creates a new instance of the DebuggerStoppedEventArgs class. + /// + /// The original DebuggerStopEventArgs instance from which this instance is based. + /// The RunspaceDetails of the runspace which raised this event. + /// The local path of the remote script being debugged. + public DebuggerStoppedEventArgs( + DebuggerStopEventArgs originalEvent, + RunspaceDetails runspaceDetails, + string localScriptPath) + { + Validate.IsNotNull(nameof(originalEvent), originalEvent); + Validate.IsNotNull(nameof(runspaceDetails), runspaceDetails); + + if (!string.IsNullOrEmpty(localScriptPath)) + { + this.ScriptPath = localScriptPath; + this.RemoteScriptPath = originalEvent.InvocationInfo.ScriptName; + } + else + { + this.ScriptPath = originalEvent.InvocationInfo.ScriptName; + } + + this.OriginalEvent = originalEvent; + this.RunspaceDetails = runspaceDetails; + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/InvalidPowerShellExpressionException.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/InvalidPowerShellExpressionException.cs new file mode 100644 index 000000000..a708778f9 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/InvalidPowerShellExpressionException.cs @@ -0,0 +1,24 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; + +namespace Microsoft.PowerShell.EditorServices.Engine.Services.DebugAdapter +{ + /// + /// Represents the exception that is thrown when an invalid expression is provided to the DebugService's SetVariable method. + /// + public class InvalidPowerShellExpressionException : Exception + { + /// + /// Initializes a new instance of the SetVariableExpressionException class. + /// + /// Message indicating why the expression is invalid. + public InvalidPowerShellExpressionException(string message) + : base(message) + { + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/StackFrameDetails.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/StackFrameDetails.cs new file mode 100644 index 000000000..c58f53623 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/StackFrameDetails.cs @@ -0,0 +1,134 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Management.Automation; + +namespace Microsoft.PowerShell.EditorServices.Engine.Services.DebugAdapter +{ + /// + /// Contains details pertaining to a single stack frame in + /// the current debugging session. + /// + public class StackFrameDetails + { + #region Fields + + /// + /// A constant string used in the ScriptPath field to represent a + /// stack frame with no associated script file. + /// + public const string NoFileScriptPath = ""; + + #endregion + + #region Properties + + /// + /// Gets the path to the script where the stack frame occurred. + /// + public string ScriptPath { get; internal set; } + + /// + /// Gets the name of the function where the stack frame occurred. + /// + public string FunctionName { get; private set; } + + /// + /// Gets the start line number of the script where the stack frame occurred. + /// + public int StartLineNumber { get; internal set; } + + /// + /// Gets the line number of the script where the stack frame occurred. + /// + public int? EndLineNumber { get; internal set; } + + /// + /// Gets the start column number of the line where the stack frame occurred. + /// + public int StartColumnNumber { get; internal set; } + + /// + /// Gets the end column number of the line where the stack frame occurred. + /// + public int? EndColumnNumber { get; internal set; } + + /// + /// Gets a boolean value indicating whether or not the stack frame is executing + /// in script external to the current workspace root. + /// + public bool IsExternalCode { get; internal set; } + + /// + /// Gets or sets the VariableContainerDetails that contains the auto variables. + /// + public VariableContainerDetails AutoVariables { get; private set; } + + /// + /// Gets or sets the VariableContainerDetails that contains the local variables. + /// + public VariableContainerDetails LocalVariables { get; private set; } + + #endregion + + #region Constructors + + /// + /// Creates an instance of the StackFrameDetails class from a + /// CallStackFrame instance provided by the PowerShell engine. + /// + /// + /// A PSObject representing the CallStackFrame instance from which details will be obtained. + /// + /// + /// A variable container with all the filtered, auto variables for this stack frame. + /// + /// + /// A variable container with all the local variables for this stack frame. + /// + /// + /// Specifies the path to the root of an open workspace, if one is open. This path is used to + /// determine whether individua stack frames are external to the workspace. + /// + /// A new instance of the StackFrameDetails class. + static internal StackFrameDetails Create( + PSObject callStackFrameObject, + VariableContainerDetails autoVariables, + VariableContainerDetails localVariables, + string workspaceRootPath = null) + { + string moduleId = string.Empty; + var isExternal = false; + + var invocationInfo = callStackFrameObject.Properties["InvocationInfo"]?.Value as InvocationInfo; + string scriptPath = (callStackFrameObject.Properties["ScriptName"].Value as string) ?? NoFileScriptPath; + int startLineNumber = (int)(callStackFrameObject.Properties["ScriptLineNumber"].Value ?? 0); + + // TODO: RKH 2019-03-07 Temporarily disable "external" code until I have a chance to add + // settings to control this feature. + //if (workspaceRootPath != null && + // invocationInfo != null && + // !scriptPath.StartsWith(workspaceRootPath, StringComparison.OrdinalIgnoreCase)) + //{ + // isExternal = true; + //} + + return new StackFrameDetails + { + ScriptPath = scriptPath, + FunctionName = callStackFrameObject.Properties["FunctionName"].Value as string, + StartLineNumber = startLineNumber, + EndLineNumber = startLineNumber, // End line number isn't given in PowerShell stack frames + StartColumnNumber = 0, // Column number isn't given in PowerShell stack frames + EndColumnNumber = 0, + AutoVariables = autoVariables, + LocalVariables = localVariables, + IsExternalCode = isExternal + }; + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/VariableContainerDetails.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/VariableContainerDetails.cs new file mode 100644 index 000000000..28d2df551 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/VariableContainerDetails.cs @@ -0,0 +1,100 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Collections.Generic; +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Engine.Services.DebugAdapter +{ + /// + /// Container for variables that is not itself a variable per se. However given how + /// VSCode uses an integer variable reference id for every node under the "Variables" tool + /// window, it is useful to treat containers, typically scope containers, as a variable. + /// Note that these containers are not necessarily always a scope container. Consider a + /// container such as "Auto" or "My". These aren't scope related but serve as just another + /// way to organize variables into a useful UI structure. + /// + [DebuggerDisplay("Name = {Name}, Id = {Id}, Count = {Children.Count}")] + public class VariableContainerDetails : VariableDetailsBase + { + /// + /// Provides a constant for the name of the Global scope. + /// + public const string AutoVariablesName = "Auto"; + + /// + /// Provides a constant for the name of the Global scope. + /// + public const string GlobalScopeName = "Global"; + + /// + /// Provides a constant for the name of the Local scope. + /// + public const string LocalScopeName = "Local"; + + /// + /// Provides a constant for the name of the Script scope. + /// + public const string ScriptScopeName = "Script"; + + private readonly Dictionary children; + + /// + /// Instantiates an instance of VariableScopeDetails. + /// + /// The variable reference id for this scope. + /// The name of the variable scope. + public VariableContainerDetails(int id, string name) + { + Validate.IsNotNull(name, "name"); + + this.Id = id; + this.Name = name; + this.IsExpandable = true; + this.ValueString = " "; // An empty string isn't enough due to a temporary bug in VS Code. + + this.children = new Dictionary(); + } + + /// + /// Gets the collection of child variables. + /// + public IDictionary Children + { + get { return this.children; } + } + + /// + /// Returns the details of the variable container's children. If empty, returns an empty array. + /// + /// + public override VariableDetailsBase[] GetChildren(ILogger logger) + { + var variablesArray = new VariableDetailsBase[this.children.Count]; + this.children.Values.CopyTo(variablesArray, 0); + return variablesArray; + } + + /// + /// Determines whether this variable container contains the specified variable by its referenceId. + /// + /// The variableReferenceId to search for. + /// Returns true if this variable container directly contains the specified variableReferenceId, false otherwise. + public bool ContainsVariable(int variableReferenceId) + { + foreach (VariableDetailsBase value in this.children.Values) + { + if (value.Id == variableReferenceId) + { + return true; + } + } + + return false; + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/VariableDetails.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/VariableDetails.cs new file mode 100644 index 000000000..9d3e375b0 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/VariableDetails.cs @@ -0,0 +1,416 @@ +// +// 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; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Management.Automation; +using System.Reflection; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Engine.Services.DebugAdapter +{ + /// + /// Contains details pertaining to a variable in the current + /// debugging session. + /// + [DebuggerDisplay("Name = {Name}, Id = {Id}, Value = {ValueString}")] + public class VariableDetails : VariableDetailsBase + { + #region Fields + + /// + /// Provides a constant for the dollar sign variable prefix string. + /// + public const string DollarPrefix = "$"; + + private object valueObject; + private VariableDetails[] cachedChildren; + + #endregion + + #region Constructors + + /// + /// Initializes an instance of the VariableDetails class from + /// the details contained in a PSVariable instance. + /// + /// + /// The PSVariable instance from which variable details will be obtained. + /// + public VariableDetails(PSVariable psVariable) + : this(DollarPrefix + psVariable.Name, psVariable.Value) + { + } + + /// + /// Initializes an instance of the VariableDetails class from + /// the name and value pair stored inside of a PSObject which + /// represents a PSVariable. + /// + /// + /// The PSObject which represents a PSVariable. + /// + public VariableDetails(PSObject psVariableObject) + : this( + DollarPrefix + psVariableObject.Properties["Name"].Value as string, + psVariableObject.Properties["Value"].Value) + { + } + + /// + /// Initializes an instance of the VariableDetails class from + /// the details contained in a PSPropertyInfo instance. + /// + /// + /// The PSPropertyInfo instance from which variable details will be obtained. + /// + public VariableDetails(PSPropertyInfo psProperty) + : this(psProperty.Name, psProperty.Value) + { + } + + /// + /// Initializes an instance of the VariableDetails class from + /// a given name/value pair. + /// + /// The variable's name. + /// The variable's value. + public VariableDetails(string name, object value) + { + this.valueObject = value; + + this.Id = -1; // Not been assigned a variable reference id yet + this.Name = name; + this.IsExpandable = GetIsExpandable(value); + + string typeName; + this.ValueString = GetValueStringAndType(value, this.IsExpandable, out typeName); + this.Type = typeName; + } + + #endregion + + #region Public Methods + + /// + /// If this variable instance is expandable, this method returns the + /// details of its children. Otherwise it returns an empty array. + /// + /// + public override VariableDetailsBase[] GetChildren(ILogger logger) + { + VariableDetails[] childVariables = null; + + if (this.IsExpandable) + { + if (this.cachedChildren == null) + { + this.cachedChildren = GetChildren(this.valueObject, logger); + } + + return this.cachedChildren; + } + else + { + childVariables = new VariableDetails[0]; + } + + return childVariables; + } + + #endregion + + #region Private Methods + + private static bool GetIsExpandable(object valueObject) + { + if (valueObject == null) + { + return false; + } + + // If a PSObject, unwrap it + var psobject = valueObject as PSObject; + if (psobject != null) + { + valueObject = psobject.BaseObject; + } + + Type valueType = + valueObject != null ? + valueObject.GetType() : + null; + + TypeInfo valueTypeInfo = valueType.GetTypeInfo(); + + return + valueObject != null && + !valueTypeInfo.IsPrimitive && + !valueTypeInfo.IsEnum && // Enums don't have any properties + !(valueObject is string) && // Strings get treated as IEnumerables + !(valueObject is decimal) && + !(valueObject is UnableToRetrievePropertyMessage); + } + + private static string GetValueStringAndType(object value, bool isExpandable, out string typeName) + { + string valueString = null; + typeName = null; + + if (value == null) + { + // Set to identifier recognized by PowerShell to make setVariable from the debug UI more natural. + return "$null"; + } + + Type objType = value.GetType(); + typeName = $"[{objType.FullName}]"; + + if (value is bool) + { + // Set to identifier recognized by PowerShell to make setVariable from the debug UI more natural. + valueString = (bool) value ? "$true" : "$false"; + } + else if (isExpandable) + { + + // Get the "value" for an expandable object. + if (value is DictionaryEntry) + { + // For DictionaryEntry - display the key/value as the value. + var entry = (DictionaryEntry)value; + valueString = + string.Format( + "[{0}, {1}]", + entry.Key, + GetValueStringAndType(entry.Value, GetIsExpandable(entry.Value), out typeName)); + } + else + { + string valueToString = value.SafeToString(); + if (valueToString.Equals(objType.ToString())) + { + // If the ToString() matches the type name, then display the type + // name in PowerShell format. + string shortTypeName = objType.Name; + + // For arrays and ICollection, display the number of contained items. + if (value is Array) + { + var arr = value as Array; + if (arr.Rank == 1) + { + shortTypeName = InsertDimensionSize(shortTypeName, arr.Length); + } + } + else if (value is ICollection) + { + var collection = (ICollection)value; + shortTypeName = InsertDimensionSize(shortTypeName, collection.Count); + } + + valueString = $"[{shortTypeName}]"; + } + else + { + valueString = valueToString; + } + } + } + else + { + // Value is a scalar (not expandable). If it's a string, display it directly otherwise use SafeToString() + if (value is string) + { + valueString = "\"" + value + "\""; + } + else + { + valueString = value.SafeToString(); + } + } + + return valueString; + } + + private static string InsertDimensionSize(string value, int dimensionSize) + { + string result = value; + + int indexLastRBracket = value.LastIndexOf("]"); + if (indexLastRBracket > 0) + { + result = + value.Substring(0, indexLastRBracket) + + dimensionSize + + value.Substring(indexLastRBracket); + } + else + { + // Types like ArrayList don't use [] in type name so + // display value like so - [ArrayList: 5] + result = value + ": " + dimensionSize; + } + + return result; + } + + private VariableDetails[] GetChildren(object obj, ILogger logger) + { + List childVariables = new List(); + + if (obj == null) + { + return childVariables.ToArray(); + } + + try + { + PSObject psObject = obj as PSObject; + + if ((psObject != null) && + (psObject.TypeNames[0] == typeof(PSCustomObject).ToString())) + { + // PowerShell PSCustomObject's properties are completely defined by the ETS type system. + childVariables.AddRange( + psObject + .Properties + .Select(p => new VariableDetails(p))); + } + else + { + // If a PSObject other than a PSCustomObject, unwrap it. + if (psObject != null) + { + // First add the PSObject's ETS propeties + childVariables.AddRange( + psObject + .Properties + .Where(p => p.MemberType == PSMemberTypes.NoteProperty) + .Select(p => new VariableDetails(p))); + + obj = psObject.BaseObject; + } + + IDictionary dictionary = obj as IDictionary; + IEnumerable enumerable = obj as IEnumerable; + + // We're in the realm of regular, unwrapped .NET objects + if (dictionary != null) + { + // Buckle up kids, this is a bit weird. We could not use the LINQ + // operator OfType. Even though R# will squiggle the + // "foreach" keyword below and offer to convert to a LINQ-expression - DON'T DO IT! + // The reason is that LINQ extension methods work with objects of type + // IEnumerable. Objects of type Dictionary<,>, respond to iteration via + // IEnumerable by returning KeyValuePair<,> objects. Unfortunately non-generic + // dictionaries like HashTable return DictionaryEntry objects. + // It turns out that iteration via C#'s foreach loop, operates on the variable's + // type which in this case is IDictionary. IDictionary was designed to always + // return DictionaryEntry objects upon iteration and the Dictionary<,> implementation + // honors that when the object is reintepreted as an IDictionary object. + // FYI, a test case for this is to open $PSBoundParameters when debugging a + // function that defines parameters and has been passed parameters. + // If you open the $PSBoundParameters variable node in this scenario and see nothing, + // this code is broken. + int i = 0; + foreach (DictionaryEntry entry in dictionary) + { + childVariables.Add( + new VariableDetails( + "[" + i++ + "]", + entry)); + } + } + else if (enumerable != null && !(obj is string)) + { + int i = 0; + foreach (var item in enumerable) + { + childVariables.Add( + new VariableDetails( + "[" + i++ + "]", + item)); + } + } + + AddDotNetProperties(obj, childVariables); + } + } + catch (GetValueInvocationException ex) + { + // This exception occurs when accessing the value of a + // variable causes a script to be executed. Right now + // we aren't loading children on the pipeline thread so + // this causes an exception to be raised. In this case, + // just return an empty list of children. + logger.LogWarning($"Failed to get properties of variable {this.Name}, value invocation was attempted: {ex.Message}"); + } + + return childVariables.ToArray(); + } + + private static void AddDotNetProperties(object obj, List childVariables) + { + Type objectType = obj.GetType(); + var properties = + objectType.GetProperties( + BindingFlags.Public | BindingFlags.Instance); + + foreach (var property in properties) + { + // Don't display indexer properties, it causes an exception anyway. + if (property.GetIndexParameters().Length > 0) + { + continue; + } + + try + { + childVariables.Add( + new VariableDetails( + property.Name, + property.GetValue(obj))); + } + catch (Exception ex) + { + // Some properties can throw exceptions, add the property + // name and info about the error. + if (ex is TargetInvocationException) + { + ex = ex.InnerException; + } + + childVariables.Add( + new VariableDetails( + property.Name, + new UnableToRetrievePropertyMessage( + "Error retrieving property - " + ex.GetType().Name))); + } + } + } + + #endregion + + private struct UnableToRetrievePropertyMessage + { + public UnableToRetrievePropertyMessage(string message) + { + this.Message = message; + } + + public string Message { get; } + + public override string ToString() + { + return "<" + Message + ">"; + } + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/VariableDetailsBase.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/VariableDetailsBase.cs new file mode 100644 index 000000000..0eb8c32ab --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/VariableDetailsBase.cs @@ -0,0 +1,60 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Utility; + +namespace Microsoft.PowerShell.EditorServices.Engine.Services.DebugAdapter +{ + /// + /// Defines the common details between a variable and a variable container such as a scope + /// in the current debugging session. + /// + public abstract class VariableDetailsBase + { + /// + /// Provides a constant that is used as the starting variable ID for all. + /// Avoid 0 as it indicates a variable node with no children. + /// variables. + /// + public const int FirstVariableId = 1; + + /// + /// Gets the numeric ID of the variable which can be used to refer + /// to it in future requests. + /// + public int Id { get; set; } + + /// + /// Gets the variable's name. + /// + public string Name { get; protected set; } + + /// + /// Gets the string representation of the variable's value. + /// If the variable is an expandable object, this string + /// will be empty. + /// + public string ValueString { get; protected set; } + + /// + /// Gets the type of the variable's value. + /// + public string Type { get; protected set; } + + /// + /// Returns true if the variable's value is expandable, meaning + /// that it has child properties or its contents can be enumerated. + /// + public bool IsExpandable { get; protected set; } + + /// + /// If this variable instance is expandable, this method returns the + /// details of its children. Otherwise it returns an empty array. + /// + /// + public abstract VariableDetailsBase[] GetChildren(ILogger logger); + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/VariableScope.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/VariableScope.cs new file mode 100644 index 000000000..411388951 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Debugging/VariableScope.cs @@ -0,0 +1,37 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +namespace Microsoft.PowerShell.EditorServices.Engine.Services.DebugAdapter +{ + /// + /// Contains details pertaining to a variable scope in the current + /// debugging session. + /// + public class VariableScope + { + /// + /// Gets a numeric ID that can be used in future operations + /// relating to this scope. + /// + public int Id { get; private set; } + + /// + /// Gets a name that describes the variable scope. + /// + public string Name { get; private set; } + + /// + /// Initializes a new instance of the VariableScope class with + /// the given ID and name. + /// + /// The variable scope's ID. + /// The variable scope's name. + public VariableScope(int id, string name) + { + this.Id = id; + this.Name = name; + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/InitializeHandler.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/InitializeHandler.cs new file mode 100644 index 000000000..1d68d008f --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/InitializeHandler.cs @@ -0,0 +1,44 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Engine.Services; +using OmniSharp.Extensions.DebugAdapter.Protocol.Models; +using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; + +namespace Microsoft.PowerShell.EditorServices.Engine.Handlers +{ + internal class InitializeHandler : IInitializeHandler + { + private readonly ILogger _logger; + private readonly DebugService _debugService; + + public InitializeHandler( + ILoggerFactory factory, + DebugService debugService) + { + _logger = factory.CreateLogger(); + _debugService = debugService; + } + + public async Task Handle(InitializeRequestArguments request, CancellationToken cancellationToken) + { + // Clear any existing breakpoints before proceeding + await _debugService.ClearAllBreakpointsAsync(); + + // Now send the Initialize response to continue setup + return new InitializeResponse + { + SupportsConfigurationDoneRequest = true, + SupportsFunctionBreakpoints = true, + SupportsConditionalBreakpoints = true, + SupportsHitConditionalBreakpoints = true, + SupportsSetVariable = true + }; + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs new file mode 100644 index 000000000..845579e44 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs @@ -0,0 +1,414 @@ +// +// 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.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Engine.Logging; +using Microsoft.PowerShell.EditorServices.Engine.Services; +using Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext; +using OmniSharp.Extensions.DebugAdapter.Protocol.Events; +using OmniSharp.Extensions.Embedded.MediatR; +using OmniSharp.Extensions.JsonRpc; + +namespace Microsoft.PowerShell.EditorServices.Engine.Handlers +{ + [Serial, Method("launch")] + interface IPsesLaunchHandler : IJsonRpcRequestHandler { } + + [Serial, Method("attach")] + interface IPsesAttachHandler : IJsonRpcRequestHandler { } + + public class PsesLaunchRequestArguments : IRequest + { + /// + /// Gets or sets the absolute path to the script to debug. + /// + public string Script { get; set; } + + /// + /// Gets or sets a boolean value that indicates whether the script should be + /// run with (false) or without (true) debugging support. + /// + public bool NoDebug { get; set; } + + /// + /// Gets or sets a boolean value that determines whether to automatically stop + /// target after launch. If not specified, target does not stop. + /// + public bool StopOnEntry { get; set; } + + /// + /// Gets or sets optional arguments passed to the debuggee. + /// + public string[] Args { get; set; } + + /// + /// Gets or sets the working directory of the launched debuggee (specified as an absolute path). + /// If omitted the debuggee is lauched in its own directory. + /// + public string Cwd { get; set; } + + /// + /// Gets or sets a boolean value that determines whether to create a temporary + /// integrated console for the debug session. Default is false. + /// + public bool CreateTemporaryIntegratedConsole { get; set; } + + /// + /// Gets or sets the absolute path to the runtime executable to be used. + /// Default is the runtime executable on the PATH. + /// + public string RuntimeExecutable { get; set; } + + /// + /// Gets or sets the optional arguments passed to the runtime executable. + /// + public string[] RuntimeArgs { get; set; } + + /// + /// Gets or sets optional environment variables to pass to the debuggee. The string valued + /// properties of the 'environmentVariables' are used as key/value pairs. + /// + public Dictionary Env { get; set; } + } + + public class PsesAttachRequestArguments : IRequest + { + public string ComputerName { get; set; } + + public string ProcessId { get; set; } + + public string RunspaceId { get; set; } + + public string RunspaceName { get; set; } + + public string CustomPipeName { get; set; } + } + + public class LaunchHandler : IPsesLaunchHandler + { + private readonly ILogger _logger; + private readonly DebugService _debugService; + private readonly PowerShellContextService _powerShellContextService; + private readonly DebugStateService _debugStateService; + private readonly DebugEventHandlerService _debugEventHandlerService; + private readonly IJsonRpcServer _jsonRpcServer; + + public LaunchHandler( + ILoggerFactory factory, + IJsonRpcServer jsonRpcServer, + DebugService debugService, + PowerShellContextService powerShellContextService, + DebugStateService debugStateService, + DebugEventHandlerService debugEventHandlerService) + { + _logger = factory.CreateLogger(); + _jsonRpcServer = jsonRpcServer; + _debugService = debugService; + _powerShellContextService = powerShellContextService; + _debugStateService = debugStateService; + _debugEventHandlerService = debugEventHandlerService; + } + + public async Task Handle(PsesLaunchRequestArguments request, CancellationToken cancellationToken) + { + _debugEventHandlerService.RegisterEventHandlers(); + + // Determine whether or not the working directory should be set in the PowerShellContext. + if ((_powerShellContextService.CurrentRunspace.Location == RunspaceLocation.Local) && + !_debugService.IsDebuggerStopped) + { + // Get the working directory that was passed via the debug config + // (either via launch.json or generated via no-config debug). + string workingDir = request.Cwd; + + // Assuming we have a non-empty/null working dir, unescape the path and verify + // the path exists and is a directory. + if (!string.IsNullOrEmpty(workingDir)) + { + try + { + if ((File.GetAttributes(workingDir) & FileAttributes.Directory) != FileAttributes.Directory) + { + workingDir = Path.GetDirectoryName(workingDir); + } + } + catch (Exception ex) + { + workingDir = null; + _logger.LogError( + $"The specified 'cwd' path is invalid: '{request.Cwd}'. Error: {ex.Message}"); + } + } + + // If we have no working dir by this point and we are running in a temp console, + // pick some reasonable default. + if (string.IsNullOrEmpty(workingDir) && request.CreateTemporaryIntegratedConsole) + { + workingDir = Environment.CurrentDirectory; + } + + // At this point, we will either have a working dir that should be set to cwd in + // the PowerShellContext or the user has requested (via an empty/null cwd) that + // the working dir should not be changed. + if (!string.IsNullOrEmpty(workingDir)) + { + await _powerShellContextService.SetWorkingDirectoryAsync(workingDir, isPathAlreadyEscaped: false); + } + + _logger.LogTrace($"Working dir " + (string.IsNullOrEmpty(workingDir) ? "not set." : $"set to '{workingDir}'")); + } + + // Prepare arguments to the script - if specified + string arguments = null; + if ((request.Args != null) && (request.Args.Length > 0)) + { + arguments = string.Join(" ", request.Args); + _logger.LogTrace("Script arguments are: " + arguments); + } + + // Store the launch parameters so that they can be used later + _debugStateService.NoDebug = request.NoDebug; + _debugStateService.ScriptToLaunch = request.Script; + _debugStateService.Arguments = arguments; + _debugStateService.IsUsingTempIntegratedConsole = request.CreateTemporaryIntegratedConsole; + + // TODO: Bring this back + // If the current session is remote, map the script path to the remote + // machine if necessary + //if (_scriptToLaunch != null && + // _powerShellContextService.CurrentRunspace.Location == RunspaceLocation.Remote) + //{ + // _scriptToLaunch = + // _editorSession.RemoteFileManager.GetMappedPath( + // _scriptToLaunch, + // _editorSession.PowerShellContext.CurrentRunspace); + //} + + // If no script is being launched, mark this as an interactive + // debugging session + _debugStateService.IsInteractiveDebugSession = string.IsNullOrEmpty(_debugStateService.ScriptToLaunch); + + // Send the InitializedEvent so that the debugger will continue + // sending configuration requests + _jsonRpcServer.SendNotification(EventNames.Initialized); + + return Unit.Value; + } + } + + public class AttachHandler : IPsesAttachHandler + { + private static readonly Version s_minVersionForCustomPipeName = new Version(6, 2); + + private readonly ILogger _logger; + private readonly DebugService _debugService; + private readonly PowerShellContextService _powerShellContextService; + private readonly DebugStateService _debugStateService; + private readonly DebugEventHandlerService _debugEventHandlerService; + private readonly IJsonRpcServer _jsonRpcServer; + + public AttachHandler( + ILoggerFactory factory, + IJsonRpcServer jsonRpcServer, + DebugService debugService, + PowerShellContextService powerShellContextService, + DebugStateService debugStateService, + DebugEventHandlerService debugEventHandlerService) + { + _logger = factory.CreateLogger(); + _jsonRpcServer = jsonRpcServer; + _debugService = debugService; + _powerShellContextService = powerShellContextService; + _debugStateService = debugStateService; + _debugEventHandlerService = debugEventHandlerService; + } + + public async Task Handle(PsesAttachRequestArguments request, CancellationToken cancellationToken) + { + _debugStateService.IsAttachSession = true; + + _debugEventHandlerService.RegisterEventHandlers(); + + bool processIdIsSet = !string.IsNullOrEmpty(request.ProcessId) && request.ProcessId != "undefined"; + bool customPipeNameIsSet = !string.IsNullOrEmpty(request.CustomPipeName) && request.CustomPipeName != "undefined"; + + PowerShellVersionDetails runspaceVersion = + _powerShellContextService.CurrentRunspace.PowerShellVersion; + + // If there are no host processes to attach to or the user cancels selection, we get a null for the process id. + // This is not an error, just a request to stop the original "attach to" request. + // Testing against "undefined" is a HACK because I don't know how to make "Cancel" on quick pick loading + // to cancel on the VSCode side without sending an attachRequest with processId set to "undefined". + if (!processIdIsSet && !customPipeNameIsSet) + { + _logger.LogInformation( + $"Attach request aborted, received {request.ProcessId} for processId."); + + throw new Exception("User aborted attach to PowerShell host process."); + } + + StringBuilder errorMessages = new StringBuilder(); + + if (request.ComputerName != null) + { + if (runspaceVersion.Version.Major < 4) + { + throw new Exception($"Remote sessions are only available with PowerShell 4 and higher (current session is {runspaceVersion.Version})."); + } + else if (_powerShellContextService.CurrentRunspace.Location == RunspaceLocation.Remote) + { + throw new Exception($"Cannot attach to a process in a remote session when already in a remote session."); + } + + await _powerShellContextService.ExecuteScriptStringAsync( + $"Enter-PSSession -ComputerName \"{request.ComputerName}\"", + errorMessages); + + if (errorMessages.Length > 0) + { + throw new Exception($"Could not establish remote session to computer '{request.ComputerName}'"); + } + + _debugStateService.IsRemoteAttach = true; + } + + if (processIdIsSet && int.TryParse(request.ProcessId, out int processId) && (processId > 0)) + { + if (runspaceVersion.Version.Major < 5) + { + throw new Exception($"Attaching to a process is only available with PowerShell 5 and higher (current session is {runspaceVersion.Version})."); + } + + await _powerShellContextService.ExecuteScriptStringAsync( + $"Enter-PSHostProcess -Id {processId}", + errorMessages); + + if (errorMessages.Length > 0) + { + throw new Exception($"Could not attach to process '{processId}'"); + } + } + else if (customPipeNameIsSet) + { + if (runspaceVersion.Version < s_minVersionForCustomPipeName) + { + throw new Exception($"Attaching to a process with CustomPipeName is only available with PowerShell 6.2 and higher (current session is {runspaceVersion.Version})."); + } + + await _powerShellContextService.ExecuteScriptStringAsync( + $"Enter-PSHostProcess -CustomPipeName {request.CustomPipeName}", + errorMessages); + + if (errorMessages.Length > 0) + { + throw new Exception($"Could not attach to process with CustomPipeName: '{request.CustomPipeName}'"); + } + } + else if (request.ProcessId != "current") + { + _logger.LogError( + $"Attach request failed, '{request.ProcessId}' is an invalid value for the processId."); + + throw new Exception("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 + // event gets fired with the attached runspace. + + string debugRunspaceCmd; + if (request.RunspaceName != null) + { + debugRunspaceCmd = $"\nDebug-Runspace -Name '{request.RunspaceName}'"; + } + else if (request.RunspaceId != null) + { + if (!int.TryParse(request.RunspaceId, out int runspaceId) || runspaceId <= 0) + { + _logger.LogError( + $"Attach request failed, '{request.RunspaceId}' is an invalid value for the processId."); + + throw new Exception("A positive integer must be specified for the RunspaceId field."); + } + + debugRunspaceCmd = $"\nDebug-Runspace -Id {runspaceId}"; + } + else + { + debugRunspaceCmd = "\nDebug-Runspace -Id 1"; + } + + _debugStateService.WaitingForAttach = true; + Task nonAwaitedTask = _powerShellContextService + .ExecuteScriptStringAsync(debugRunspaceCmd) + .ContinueWith(OnExecutionCompletedAsync); + + return Unit.Value; + } + + private async Task OnExecutionCompletedAsync(Task executeTask) + { + try + { + await executeTask; + } + catch (Exception e) + { + _logger.LogError( + "Exception occurred while awaiting debug launch task.\n\n" + e.ToString()); + } + + _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; + + //if (_disconnectRequestContext != null) + //{ + // // Respond to the disconnect request and stop the server + // await _disconnectRequestContext.SendResultAsync(null); + // Stop(); + // return; + //} + + _jsonRpcServer.SendNotification(EventNames.Terminated); + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/InitializeHandler.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/InitializeHandler.cs deleted file mode 100644 index 0bca571f6..000000000 --- a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/InitializeHandler.cs +++ /dev/null @@ -1,28 +0,0 @@ -// -// Copyright (c) Microsoft. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. -// - -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; - -namespace Microsoft.PowerShell.EditorServices.Engine.Handlers -{ - internal class PowershellInitializeHandler : InitializeHandler - { - private readonly ILogger _logger; - - public PowershellInitializeHandler(ILoggerFactory factory) - { - _logger = factory.CreateLogger(); - } - - public override Task Handle(InitializeRequestArguments request, CancellationToken cancellationToken) - { - _logger.LogTrace("We did it."); - return Task.FromResult(new InitializeResponse()); - } - } -} diff --git a/src/PowerShellEditorServices.Engine/Services/PowerShellContext/Session/Capabilities/DscBreakpointCapability.cs b/src/PowerShellEditorServices.Engine/Services/PowerShellContext/Session/Capabilities/DscBreakpointCapability.cs index e9f1b45e0..76a5c4d80 100644 --- a/src/PowerShellEditorServices.Engine/Services/PowerShellContext/Session/Capabilities/DscBreakpointCapability.cs +++ b/src/PowerShellEditorServices.Engine/Services/PowerShellContext/Session/Capabilities/DscBreakpointCapability.cs @@ -1,166 +1,168 @@ -//// -//// Copyright (c) Microsoft. All rights reserved. -//// Licensed under the MIT license. See LICENSE file in the project root for full license information. -//// - -//using System.Linq; -//using System.Threading.Tasks; - -//namespace Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext -//{ -// using Microsoft.Extensions.Logging; -// using Microsoft.PowerShell.EditorServices.Utility; -// using System; -// using System.Collections.Generic; -// using System.Collections.ObjectModel; -// using System.Management.Automation; - -// internal class DscBreakpointCapability : IRunspaceCapability -// { -// private string[] dscResourceRootPaths = new string[0]; - -// private Dictionary breakpointsPerFile = -// new Dictionary(); - -// public async Task> SetLineBreakpointsAsync( -// PowerShellContextService powerShellContext, -// string scriptPath, -// BreakpointDetails[] breakpoints) -// { -// List resultBreakpointDetails = -// new List(); - -// // We always get the latest array of breakpoint line numbers -// // so store that for future use -// if (breakpoints.Length > 0) -// { -// // Set the breakpoints for this scriptPath -// this.breakpointsPerFile[scriptPath] = -// breakpoints.Select(b => b.LineNumber).ToArray(); -// } -// else -// { -// // No more breakpoints for this scriptPath, remove it -// this.breakpointsPerFile.Remove(scriptPath); -// } - -// string hashtableString = -// string.Join( -// ", ", -// this.breakpointsPerFile -// .Select(file => $"@{{Path=\"{file.Key}\";Line=@({string.Join(",", file.Value)})}}")); - -// // Run Enable-DscDebug as a script because running it as a PSCommand -// // causes an error which states that the Breakpoint parameter has not -// // been passed. -// await powerShellContext.ExecuteScriptStringAsync( -// hashtableString.Length > 0 -// ? $"Enable-DscDebug -Breakpoint {hashtableString}" -// : "Disable-DscDebug", -// false, -// false); - -// // Verify all the breakpoints and return them -// foreach (var breakpoint in breakpoints) -// { -// breakpoint.Verified = true; -// } - -// return breakpoints.ToList(); -// } - -// public bool IsDscResourcePath(string scriptPath) -// { -// return dscResourceRootPaths.Any( -// dscResourceRootPath => -// scriptPath.StartsWith( -// dscResourceRootPath, -// StringComparison.CurrentCultureIgnoreCase)); -// } - -// public static DscBreakpointCapability CheckForCapability( -// RunspaceDetails runspaceDetails, -// PowerShellContextService powerShellContext, -// ILogger logger) -// { -// DscBreakpointCapability capability = null; - -// // DSC support is enabled only for Windows PowerShell. -// if ((runspaceDetails.PowerShellVersion.Version.Major < 6) && -// (runspaceDetails.Context != RunspaceContext.DebuggedRunspace)) -// { -// using (PowerShell powerShell = PowerShell.Create()) -// { -// powerShell.Runspace = runspaceDetails.Runspace; - -// // Attempt to import the updated DSC module -// powerShell.AddCommand("Import-Module"); -// powerShell.AddArgument(@"C:\Program Files\DesiredStateConfiguration\1.0.0.0\Modules\PSDesiredStateConfiguration\PSDesiredStateConfiguration.psd1"); -// powerShell.AddParameter("PassThru"); -// powerShell.AddParameter("ErrorAction", "Ignore"); - -// PSObject moduleInfo = null; - -// try -// { -// moduleInfo = powerShell.Invoke().FirstOrDefault(); -// } -// catch (RuntimeException e) -// { -// logger.LogException("Could not load the DSC module!", e); -// } - -// if (moduleInfo != null) -// { -// logger.LogTrace("Side-by-side DSC module found, gathering DSC resource paths..."); - -// // The module was loaded, add the breakpoint capability -// capability = new DscBreakpointCapability(); -// runspaceDetails.AddCapability(capability); - -// powerShell.Commands.Clear(); -// powerShell.AddScript("Write-Host \"Gathering DSC resource paths, this may take a while...\""); -// powerShell.Invoke(); - -// // Get the list of DSC resource paths -// powerShell.Commands.Clear(); -// powerShell.AddCommand("Get-DscResource"); -// powerShell.AddCommand("Select-Object"); -// powerShell.AddParameter("ExpandProperty", "ParentPath"); - -// Collection resourcePaths = null; - -// try -// { -// resourcePaths = powerShell.Invoke(); -// } -// catch (CmdletInvocationException e) -// { -// logger.LogException("Get-DscResource failed!", e); -// } - -// if (resourcePaths != null) -// { -// capability.dscResourceRootPaths = -// resourcePaths -// .Select(o => (string)o.BaseObject) -// .ToArray(); - -// logger.LogTrace($"DSC resources found: {resourcePaths.Count}"); -// } -// else -// { -// logger.LogTrace($"No DSC resources found."); -// } -// } -// else -// { -// logger.LogTrace($"Side-by-side DSC module was not found."); -// } -// } -// } - -// return capability; -// } -// } -//} +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System.Linq; +using System.Threading.Tasks; + +namespace Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext +{ + using Microsoft.Extensions.Logging; + using Microsoft.PowerShell.EditorServices.Engine.Logging; + using Microsoft.PowerShell.EditorServices.Engine.Services.DebugAdapter; + using Microsoft.PowerShell.EditorServices.Utility; + using System; + using System.Collections.Generic; + using System.Collections.ObjectModel; + using System.Management.Automation; + + internal class DscBreakpointCapability : IRunspaceCapability + { + private string[] dscResourceRootPaths = new string[0]; + + private Dictionary breakpointsPerFile = + new Dictionary(); + + public async Task> SetLineBreakpointsAsync( + PowerShellContextService powerShellContext, + string scriptPath, + BreakpointDetails[] breakpoints) + { + List resultBreakpointDetails = + new List(); + + // We always get the latest array of breakpoint line numbers + // so store that for future use + if (breakpoints.Length > 0) + { + // Set the breakpoints for this scriptPath + this.breakpointsPerFile[scriptPath] = + breakpoints.Select(b => b.LineNumber).ToArray(); + } + else + { + // No more breakpoints for this scriptPath, remove it + this.breakpointsPerFile.Remove(scriptPath); + } + + string hashtableString = + string.Join( + ", ", + this.breakpointsPerFile + .Select(file => $"@{{Path=\"{file.Key}\";Line=@({string.Join(",", file.Value)})}}")); + + // Run Enable-DscDebug as a script because running it as a PSCommand + // causes an error which states that the Breakpoint parameter has not + // been passed. + await powerShellContext.ExecuteScriptStringAsync( + hashtableString.Length > 0 + ? $"Enable-DscDebug -Breakpoint {hashtableString}" + : "Disable-DscDebug", + false, + false); + + // Verify all the breakpoints and return them + foreach (var breakpoint in breakpoints) + { + breakpoint.Verified = true; + } + + return breakpoints.ToList(); + } + + public bool IsDscResourcePath(string scriptPath) + { + return dscResourceRootPaths.Any( + dscResourceRootPath => + scriptPath.StartsWith( + dscResourceRootPath, + StringComparison.CurrentCultureIgnoreCase)); + } + + public static DscBreakpointCapability CheckForCapability( + RunspaceDetails runspaceDetails, + PowerShellContextService powerShellContext, + ILogger logger) + { + DscBreakpointCapability capability = null; + + // DSC support is enabled only for Windows PowerShell. + if ((runspaceDetails.PowerShellVersion.Version.Major < 6) && + (runspaceDetails.Context != RunspaceContext.DebuggedRunspace)) + { + using (PowerShell powerShell = PowerShell.Create()) + { + powerShell.Runspace = runspaceDetails.Runspace; + + // Attempt to import the updated DSC module + powerShell.AddCommand("Import-Module"); + powerShell.AddArgument(@"C:\Program Files\DesiredStateConfiguration\1.0.0.0\Modules\PSDesiredStateConfiguration\PSDesiredStateConfiguration.psd1"); + powerShell.AddParameter("PassThru"); + powerShell.AddParameter("ErrorAction", "Ignore"); + + PSObject moduleInfo = null; + + try + { + moduleInfo = powerShell.Invoke().FirstOrDefault(); + } + catch (RuntimeException e) + { + logger.LogException("Could not load the DSC module!", e); + } + + if (moduleInfo != null) + { + logger.LogTrace("Side-by-side DSC module found, gathering DSC resource paths..."); + + // The module was loaded, add the breakpoint capability + capability = new DscBreakpointCapability(); + runspaceDetails.AddCapability(capability); + + powerShell.Commands.Clear(); + powerShell.AddScript("Write-Host \"Gathering DSC resource paths, this may take a while...\""); + powerShell.Invoke(); + + // Get the list of DSC resource paths + powerShell.Commands.Clear(); + powerShell.AddCommand("Get-DscResource"); + powerShell.AddCommand("Select-Object"); + powerShell.AddParameter("ExpandProperty", "ParentPath"); + + Collection resourcePaths = null; + + try + { + resourcePaths = powerShell.Invoke(); + } + catch (CmdletInvocationException e) + { + logger.LogException("Get-DscResource failed!", e); + } + + if (resourcePaths != null) + { + capability.dscResourceRootPaths = + resourcePaths + .Select(o => (string)o.BaseObject) + .ToArray(); + + logger.LogTrace($"DSC resources found: {resourcePaths.Count}"); + } + else + { + logger.LogTrace($"No DSC resources found."); + } + } + else + { + logger.LogTrace($"Side-by-side DSC module was not found."); + } + } + } + + return capability; + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Utility/Extensions.cs b/src/PowerShellEditorServices.Engine/Utility/Extensions.cs new file mode 100644 index 000000000..205332d83 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Utility/Extensions.cs @@ -0,0 +1,154 @@ +// +// 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.Linq; +using System.Collections.Generic; +using System.Management.Automation.Language; + +namespace Microsoft.PowerShell.EditorServices.Utility +{ + internal static class ObjectExtensions + { + /// + /// Extension to evaluate an object's ToString() method in an exception safe way. This will + /// extension method will not throw. + /// + /// The object on which to call ToString() + /// The ToString() return value or a suitable error message is that throws. + public static string SafeToString(this object obj) + { + string str; + + try + { + str = obj.ToString(); + } + catch (Exception ex) + { + str = $""; + } + + return str; + } + + /// + /// Get the maximum of the elements from the given enumerable. + /// + /// Type of object for which the enumerable is defined. + /// An enumerable object of type T + /// A comparer for ordering elements of type T. The comparer should handle null values. + /// An object of type T. If the enumerable is empty or has all null elements, then the method returns null. + public static T MaxElement(this IEnumerable elements, Func comparer) where T:class + { + if (elements == null) + { + throw new ArgumentNullException(nameof(elements)); + } + + if (comparer == null) + { + throw new ArgumentNullException(nameof(comparer)); + } + + if (!elements.Any()) + { + return null; + } + + var maxElement = elements.First(); + foreach(var element in elements.Skip(1)) + { + if (element != null && comparer(element, maxElement) > 0) + { + maxElement = element; + } + } + + return maxElement; + } + + /// + /// Get the minimum of the elements from the given enumerable. + /// + /// Type of object for which the enumerable is defined. + /// An enumerable object of type T + /// A comparer for ordering elements of type T. The comparer should handle null values. + /// An object of type T. If the enumerable is empty or has all null elements, then the method returns null. + public static T MinElement(this IEnumerable elements, Func comparer) where T : class + { + return MaxElement(elements, (elementX, elementY) => -1 * comparer(elementX, elementY)); + } + + /// + /// Compare extents with respect to their widths. + /// + /// Width of an extent is defined as the difference between its EndOffset and StartOffest properties. + /// + /// Extent of type IScriptExtent. + /// Extent of type IScriptExtent. + /// 0 if extentX and extentY are equal in width. 1 if width of extent X is greater than that of extent Y. Otherwise, -1. + public static int ExtentWidthComparer(this IScriptExtent extentX, IScriptExtent extentY) + { + + if (extentX == null && extentY == null) + { + return 0; + } + + if (extentX != null && extentY == null) + { + return 1; + } + + if (extentX == null) + { + return -1; + } + + var extentWidthX = extentX.EndOffset - extentX.StartOffset; + var extentWidthY = extentY.EndOffset - extentY.StartOffset; + if (extentWidthX > extentWidthY) + { + return 1; + } + else if (extentWidthX < extentWidthY) + { + return -1; + } + else + { + return 0; + } + } + + /// + /// Check if the given coordinates are wholly contained in the instance's extent. + /// + /// Extent of type IScriptExtent. + /// 1-based line number. + /// 1-based column number + /// True if the coordinates are wholly contained in the instance's extent, otherwise, false. + public static bool Contains(this IScriptExtent scriptExtent, int line, int column) + { + if (scriptExtent.StartLineNumber > line || scriptExtent.EndLineNumber < line) + { + return false; + } + + if (scriptExtent.StartLineNumber == line) + { + return scriptExtent.StartColumnNumber <= column; + } + + if (scriptExtent.EndLineNumber == line) + { + return scriptExtent.EndColumnNumber >= column; + } + + return true; + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Utility/LspBreakpointUtils.cs b/src/PowerShellEditorServices.Engine/Utility/LspBreakpointUtils.cs new file mode 100644 index 000000000..3ad153ef3 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Utility/LspBreakpointUtils.cs @@ -0,0 +1,57 @@ +using System; +using Microsoft.PowerShell.EditorServices.Engine.Services.DebugAdapter; +using OmniSharp.Extensions.DebugAdapter.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Utility +{ + public static class LspBreakpointUtils + { + public static Breakpoint CreateBreakpoint( + BreakpointDetails breakpointDetails) + { + Validate.IsNotNull(nameof(breakpointDetails), breakpointDetails); + + return new Breakpoint + { + Id = breakpointDetails.Id, + Verified = breakpointDetails.Verified, + Message = breakpointDetails.Message, + Source = new Source { Path = breakpointDetails.Source }, + Line = breakpointDetails.LineNumber, + Column = breakpointDetails.ColumnNumber + }; + } + + public static Breakpoint CreateBreakpoint( + CommandBreakpointDetails breakpointDetails) + { + Validate.IsNotNull(nameof(breakpointDetails), breakpointDetails); + + return new Breakpoint + { + Verified = breakpointDetails.Verified, + Message = breakpointDetails.Message + }; + } + + public static Breakpoint CreateBreakpoint( + SourceBreakpoint sourceBreakpoint, + string source, + string message, + bool verified = false) + { + Validate.IsNotNull(nameof(sourceBreakpoint), sourceBreakpoint); + Validate.IsNotNull(nameof(source), source); + Validate.IsNotNull(nameof(message), message); + + return new Breakpoint + { + Verified = verified, + Message = message, + Source = new Source { Path = source }, + Line = sourceBreakpoint.Line, + Column = sourceBreakpoint.Column + }; + } + } +}