diff --git a/PowerShellEditorServices.build.ps1 b/PowerShellEditorServices.build.ps1 index 36ade043e..0ac9ff998 100644 --- a/PowerShellEditorServices.build.ps1 +++ b/PowerShellEditorServices.build.ps1 @@ -64,6 +64,8 @@ $script:RequiredBuildAssets = @{ 'publish/OmniSharp.Extensions.JsonRpc.dll', 'publish/OmniSharp.Extensions.LanguageProtocol.dll', 'publish/OmniSharp.Extensions.LanguageServer.dll', + 'publish/OmniSharp.Extensions.DebugAdapter.dll', + 'publish/OmniSharp.Extensions.DebugAdapter.Server.dll', 'publish/runtimes/linux-64/native/libdisablekeyecho.so', 'publish/runtimes/osx-64/native/libdisablekeyecho.dylib', 'publish/Serilog.dll', diff --git a/docs/api/index.md b/docs/api/index.md index a845c1d66..94c6a5d4a 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -20,9 +20,9 @@ the PowerShell debugger. Use the @Microsoft.PowerShell.EditorServices.Console.ConsoleService to provide interactive console support in the user's editor. -Use the @Microsoft.PowerShell.EditorServices.Extensions.ExtensionService to allow +Use the @Microsoft.PowerShell.EditorServices.Engine.Services.ExtensionService to allow the user to extend the host editor with new capabilities using PowerShell code. The core of all the services is the @Microsoft.PowerShell.EditorServices.PowerShellContext class. This class manages a session's runspace and handles script and command -execution no matter what state the runspace is in. \ No newline at end of file +execution no matter what state the runspace is in. diff --git a/docs/guide/extensions.md b/docs/guide/extensions.md index 6e964416c..66953aa68 100644 --- a/docs/guide/extensions.md +++ b/docs/guide/extensions.md @@ -9,7 +9,7 @@ uses PowerShell Editor Services. ### Introducing `$psEditor` The entry point for the PowerShell Editor Services extensibility model is the `$psEditor` -object of the type @Microsoft.PowerShell.EditorServices.Extensions.EditorObject. For +object of the type @Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext.EditorObject. For those familiar with the PowerShell ISE's `$psISE` object, the `$psEditor` object is very similar. The primary difference is that this model has been generalized to work against any editor which leverages PowerShell Editor Services for its PowerShell editing experience. @@ -19,7 +19,7 @@ any editor which leverages PowerShell Editor Services for its PowerShell editing > please file an issue on our GitHub page. This object gives access to all of the high-level services in the current -editing session. For example, the @Microsoft.PowerShell.EditorServices.Extensions.EditorObject.Workspace +editing session. For example, the @Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext.EditorObject.Workspace property gives access to the editor's workspace, allowing you to create or open files in the editor. @@ -79,17 +79,17 @@ Register-EditorCommand ` -ScriptBlock { Write-Output "My command's script block was invoked!" } ``` -### The @Microsoft.PowerShell.EditorServices.Extensions.EditorContext parameter +### The @Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext.EditorContext parameter Your function, cmdlet, or ScriptBlock can optionally accept a single parameter -of type @Microsoft.PowerShell.EditorServices.Extensions.EditorContext which provides +of type @Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext.EditorContext which provides information about the state of the host editor at the time your command was invoked. With this object you can easily perform operations like manipulatin the state of the user's active editor buffer or changing the current selection. The usual convention is that a `$context` parameter is added to your editor command's function. For now it is recommended that you fully specify the -type of the @Microsoft.PowerShell.EditorServices.Extensions.EditorContext object +type of the @Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext.EditorContext object so that you get full IntelliSense on your context parameter. Here is an example of using the `$context` parameter: @@ -99,7 +99,7 @@ Register-EditorCommand ` -Name "MyModule.MyEditorCommandWithContext" ` -DisplayName "My command with context usage" ` -ScriptBlock { - param([Microsoft.PowerShell.EditorServices.Extensions.EditorContext]$context) + param([Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext.EditorContext]$context) Write-Output "The user's cursor is on line $($context.CursorPosition.Line)!" } ``` @@ -165,4 +165,4 @@ in that editor starts up. > NOTE: In the future we plan to provide an easy way for the user to opt-in > to the automatic loading of any editor command modules that they've installed > from the PowerShell Gallery. If this interests you, please let us know on -> [this GitHub issue](https://github.com/PowerShell/PowerShellEditorServices/issues/215). \ No newline at end of file +> [this GitHub issue](https://github.com/PowerShell/PowerShellEditorServices/issues/215). diff --git a/global.json b/global.json index 80bff6046..9847f02c9 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "2.1.602" + "version": "2.1.801" } } diff --git a/module/PowerShellEditorServices.VSCode/PowerShellEditorServices.VSCode.psm1 b/module/PowerShellEditorServices.VSCode/PowerShellEditorServices.VSCode.psm1 index e7b34e076..12b205c98 100644 --- a/module/PowerShellEditorServices.VSCode/PowerShellEditorServices.VSCode.psm1 +++ b/module/PowerShellEditorServices.VSCode/PowerShellEditorServices.VSCode.psm1 @@ -5,7 +5,7 @@ Microsoft.PowerShell.Utility\Add-Type -Path "$PSScriptRoot/bin/Microsoft.PowerShell.EditorServices.VSCode.dll" -if ($psEditor -is [Microsoft.PowerShell.EditorServices.Extensions.EditorObject]) { +if ($psEditor -is [Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext.EditorObject]) { [Microsoft.PowerShell.EditorServices.VSCode.ComponentRegistration]::Register($psEditor.Components) } else { diff --git a/module/PowerShellEditorServices.VSCode/Public/HtmlContentView/New-VSCodeHtmlContentView.ps1 b/module/PowerShellEditorServices.VSCode/Public/HtmlContentView/New-VSCodeHtmlContentView.ps1 index 0e9088bd3..d76c69fd4 100644 --- a/module/PowerShellEditorServices.VSCode/Public/HtmlContentView/New-VSCodeHtmlContentView.ps1 +++ b/module/PowerShellEditorServices.VSCode/Public/HtmlContentView/New-VSCodeHtmlContentView.ps1 @@ -41,7 +41,7 @@ function New-VSCodeHtmlContentView { ) process { - if ($psEditor -is [Microsoft.PowerShell.EditorServices.Extensions.EditorObject]) { + if ($psEditor -is [Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext.EditorObject]) { $viewFeature = $psEditor.Components.Get([Microsoft.PowerShell.EditorServices.VSCode.CustomViews.IHtmlContentViews]) $view = $viewFeature.CreateHtmlContentViewAsync($Title).Result diff --git a/module/PowerShellEditorServices/Commands/Private/BuiltInCommands.ps1 b/module/PowerShellEditorServices/Commands/Private/BuiltInCommands.ps1 index 7c73e8358..e808cc16a 100644 --- a/module/PowerShellEditorServices/Commands/Private/BuiltInCommands.ps1 +++ b/module/PowerShellEditorServices/Commands/Private/BuiltInCommands.ps1 @@ -3,7 +3,7 @@ Register-EditorCommand ` -DisplayName 'Open Editor Profile' ` -SuppressOutput ` -ScriptBlock { - param([Microsoft.PowerShell.EditorServices.Extensions.EditorContext]$context) + param([Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext.EditorContext]$context) If (!(Test-Path -Path $Profile)) { New-Item -Path $Profile -ItemType File } $psEditor.Workspace.OpenFile($Profile) } @@ -13,18 +13,18 @@ Register-EditorCommand ` -DisplayName 'Open Profile from List (Current User)' ` -SuppressOutput ` -ScriptBlock { - param([Microsoft.PowerShell.EditorServices.Extensions.EditorContext]$context) - - $Current = Split-Path -Path $profile -Leaf + param([Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext.EditorContext]$context) + + $Current = Split-Path -Path $profile -Leaf $List = @($Current,'Microsoft.VSCode_profile.ps1','Microsoft.PowerShell_profile.ps1','Microsoft.PowerShellISE_profile.ps1','Profile.ps1') | Select-Object -Unique $Choices = [System.Management.Automation.Host.ChoiceDescription[]] @($List) $Selection = $host.ui.PromptForChoice('Please Select a Profile', '(Current User)', $choices,'0') $Name = $List[$Selection] - + $ProfileDir = Split-Path $Profile -Parent $ProfileName = Join-Path -Path $ProfileDir -ChildPath $Name - + If (!(Test-Path -Path $ProfileName)) { New-Item -Path $ProfileName -ItemType File } - + $psEditor.Workspace.OpenFile($ProfileName) - } \ No newline at end of file + } diff --git a/module/PowerShellEditorServices/Commands/Public/CmdletInterface.ps1 b/module/PowerShellEditorServices/Commands/Public/CmdletInterface.ps1 index 47623296a..efd5b481e 100644 --- a/module/PowerShellEditorServices/Commands/Public/CmdletInterface.ps1 +++ b/module/PowerShellEditorServices/Commands/Public/CmdletInterface.ps1 @@ -47,7 +47,7 @@ function Register-EditorCommand { $commandArgs += $Function } - $editorCommand = New-Object Microsoft.PowerShell.EditorServices.Extensions.EditorCommand -ArgumentList $commandArgs + $editorCommand = New-Object Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext.EditorCommand -ArgumentList $commandArgs if ($psEditor.RegisterCommand($editorCommand)) { Write-Verbose "Registered new command '$Name'" diff --git a/module/PowerShellEditorServices/Commands/Public/Import-EditorCommand.ps1 b/module/PowerShellEditorServices/Commands/Public/Import-EditorCommand.ps1 index 9ddec5021..466d9368e 100644 --- a/module/PowerShellEditorServices/Commands/Public/Import-EditorCommand.ps1 +++ b/module/PowerShellEditorServices/Commands/Public/Import-EditorCommand.ps1 @@ -7,7 +7,7 @@ function Import-EditorCommand { <# .EXTERNALHELP ..\PowerShellEditorServices.Commands-help.xml #> - [OutputType([Microsoft.PowerShell.EditorServices.Extensions.EditorCommand])] + [OutputType([Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext.EditorCommand])] [CmdletBinding(DefaultParameterSetName='ByCommand')] param( [Parameter(Position=0, @@ -75,7 +75,7 @@ function Import-EditorCommand { $commands = $Command | Get-Command -ErrorAction SilentlyContinue } } - $attributeType = [Microsoft.PowerShell.EditorServices.Extensions.EditorCommandAttribute] + $attributeType = [Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext.EditorCommandAttribute] foreach ($aCommand in $commands) { # Get the attribute from our command to get name info. $details = $aCommand.ScriptBlock.Attributes | Where-Object TypeId -eq $attributeType @@ -99,7 +99,7 @@ function Import-EditorCommand { } # Check for a context parameter. $contextParameter = $aCommand.Parameters.Values | - Where-Object ParameterType -eq ([Microsoft.PowerShell.EditorServices.Extensions.EditorContext]) + Where-Object ParameterType -eq ([Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext.EditorContext]) # If one is found then add a named argument. Otherwise call the command directly. if ($contextParameter) { @@ -109,7 +109,7 @@ function Import-EditorCommand { $scriptBlock = [scriptblock]::Create($aCommand.Name) } - $editorCommand = New-Object Microsoft.PowerShell.EditorServices.Extensions.EditorCommand @( + $editorCommand = New-Object Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext.EditorCommand @( <# commandName: #> $details.Name, <# displayName: #> $details.DisplayName, <# suppressOutput: #> $details.SuppressOutput, diff --git a/module/docs/Import-EditorCommand.md b/module/docs/Import-EditorCommand.md index d84c66d53..b04487595 100644 --- a/module/docs/Import-EditorCommand.md +++ b/module/docs/Import-EditorCommand.md @@ -30,7 +30,7 @@ The Import-EditorCommand function will search the specified module for functions Alternatively, you can specify command info objects (like those from the Get-Command cmdlet) to be processed directly. -To tag a command as an editor command, attach the attribute 'Microsoft.PowerShell.EditorServices.Extensions.EditorCommandAttribute' to the function like you would with 'CmdletBindingAttribute'. The attribute accepts the named parameters 'Name', 'DisplayName', and 'SuppressOutput'. +To tag a command as an editor command, attach the attribute 'Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext.EditorCommandAttribute' to the function like you would with 'CmdletBindingAttribute'. The attribute accepts the named parameters 'Name', 'DisplayName', and 'SuppressOutput'. ## EXAMPLES @@ -55,7 +55,7 @@ Registers all editor commands that contain "Editor" in the name and return all s ```powershell function Invoke-MyEditorCommand { [CmdletBinding()] - [Microsoft.PowerShell.EditorServices.Extensions.EditorCommand(DisplayName='My Command', SuppressOutput)] + [Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext.EditorCommand(DisplayName='My Command', SuppressOutput)] param() end { ConvertTo-ScriptExtent -Offset 0 | Set-ScriptExtent -Text 'My Command!' @@ -145,7 +145,7 @@ You can pass commands to register as editor commands. ## OUTPUTS -### Microsoft.PowerShell.EditorServices.Extensions.EditorCommand +### Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext.EditorCommand If the "PassThru" parameter is specified editor commands that were successfully registered will be returned. This function does not output to the pipeline otherwise. diff --git a/src/PowerShellEditorServices.Engine/Hosting/EditorServicesHost.cs b/src/PowerShellEditorServices.Engine/Hosting/EditorServicesHost.cs index 83f61a729..b92d9d487 100644 --- a/src/PowerShellEditorServices.Engine/Hosting/EditorServicesHost.cs +++ b/src/PowerShellEditorServices.Engine/Hosting/EditorServicesHost.cs @@ -6,15 +6,20 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.IO.Pipes; using System.Linq; using System.Management.Automation; using System.Management.Automation.Host; using System.Reflection; using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Principal; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.PowerShell.EditorServices.Engine.Server; +using Microsoft.PowerShell.EditorServices.Engine.Services; using Microsoft.PowerShell.EditorServices.Utility; using Serilog; @@ -58,6 +63,15 @@ public class EditorServicesHost { #region Private Fields + // This int will be casted to a PipeOptions enum that only exists in .NET Core 2.1 and up which is why it's not available to us in .NET Standard. + private const int CurrentUserOnly = 0x20000000; + + // In .NET Framework, NamedPipeServerStream has a constructor that takes in a PipeSecurity object. We will use reflection to call the constructor, + // since .NET Framework doesn't have the `CurrentUserOnly` PipeOption. + // doc: https://docs.microsoft.com/en-us/dotnet/api/system.io.pipes.namedpipeserverstream.-ctor?view=netframework-4.7.2#System_IO_Pipes_NamedPipeServerStream__ctor_System_String_System_IO_Pipes_PipeDirection_System_Int32_System_IO_Pipes_PipeTransmissionMode_System_IO_Pipes_PipeOptions_System_Int32_System_Int32_System_IO_Pipes_PipeSecurity_ + private static readonly ConstructorInfo s_netFrameworkPipeServerConstructor = + typeof(NamedPipeServerStream).GetConstructor(new[] { typeof(string), typeof(PipeDirection), typeof(int), typeof(PipeTransmissionMode), typeof(PipeOptions), typeof(int), typeof(int), typeof(PipeSecurity) }); + private readonly HostDetails _hostDetails; private readonly PSHost _internalHost; @@ -69,6 +83,7 @@ public class EditorServicesHost private readonly string[] _additionalModules; private PsesLanguageServer _languageServer; + private PsesDebugServer _debugServer; private Microsoft.Extensions.Logging.ILogger _logger; @@ -221,16 +236,15 @@ public void StartLanguageService( EditorServiceTransportConfig config, ProfilePaths profilePaths) { - while (System.Diagnostics.Debugger.IsAttached) - { - System.Console.WriteLine($"{Process.GetCurrentProcess().Id}"); - Thread.Sleep(2000); - } + // Uncomment to debug language service + // while (!System.Diagnostics.Debugger.IsAttached) + // { + // System.Console.WriteLine($"{Process.GetCurrentProcess().Id}"); + // Thread.Sleep(2000); + // } _logger.LogInformation($"LSP NamedPipe: {config.InOutPipeName}\nLSP OutPipe: {config.OutPipeName}"); - - switch (config.TransportType) { case EditorServiceTransportType.NamedPipe: @@ -269,6 +283,8 @@ public void StartLanguageService( config.TransportType, config.Endpoint)); } + + private bool alreadySubscribedDebug; /// /// Starts the debug service with the specified config. /// @@ -280,17 +296,85 @@ public void StartDebugService( ProfilePaths profilePaths, bool useExistingSession) { - /* - this.debugServiceListener = CreateServiceListener(MessageProtocolType.DebugAdapter, config); - this.debugServiceListener.ClientConnect += OnDebugServiceClientConnect; - this.debugServiceListener.Start(); + //while (System.Diagnostics.Debugger.IsAttached) + //{ + // System.Console.WriteLine($"{Process.GetCurrentProcess().Id}"); + // Thread.Sleep(2000); + //} - this.logger.Write( - LogLevel.Normal, - string.Format( - "Debug service started, type = {0}, endpoint = {1}", - config.TransportType, config.Endpoint)); - */ + _logger.LogInformation($"Debug NamedPipe: {config.InOutPipeName}\nDebug OutPipe: {config.OutPipeName}"); + + switch (config.TransportType) + { + case EditorServiceTransportType.NamedPipe: + NamedPipeServerStream inNamedPipe = CreateNamedPipe( + config.InOutPipeName ?? config.InPipeName, + config.OutPipeName, + out NamedPipeServerStream outNamedPipe); + + _debugServer = new PsesDebugServer( + _factory, + inNamedPipe, + outNamedPipe ?? inNamedPipe); + + Task[] tasks = outNamedPipe != null + ? new[] { inNamedPipe.WaitForConnectionAsync(), outNamedPipe.WaitForConnectionAsync() } + : new[] { inNamedPipe.WaitForConnectionAsync() }; + Task.WhenAll(tasks) + .ContinueWith(async task => + { + _logger.LogInformation("Starting debug server"); + await _debugServer.StartAsync(_languageServer.LanguageServer.Services); + _logger.LogInformation( + $"Debug service started, type = {config.TransportType}, endpoint = {config.Endpoint}"); + }); + + break; + + case EditorServiceTransportType.Stdio: + _debugServer = new PsesDebugServer( + _factory, + Console.OpenStandardInput(), + Console.OpenStandardOutput()); + + Task.Run(async () => + { + _logger.LogInformation("Starting debug server"); + + IServiceProvider serviceProvider = useExistingSession + ? _languageServer.LanguageServer.Services + : new ServiceCollection().AddSingleton( + (provider) => PowerShellContextService.Create( + _factory, + provider.GetService(), + profilePaths, + _featureFlags, + _enableConsoleRepl, + _internalHost, + _hostDetails, + _additionalModules)) + .BuildServiceProvider(); + + await _debugServer.StartAsync(serviceProvider); + _logger.LogInformation( + $"Debug service started, type = {config.TransportType}, endpoint = {config.Endpoint}"); + }); + break; + + default: + throw new NotSupportedException($"The transport {config.TransportType} is not supported"); + } + + if(!alreadySubscribedDebug) + { + alreadySubscribedDebug = true; + _debugServer.SessionEnded += (sender, eventArgs) => + { + _debugServer.Dispose(); + alreadySubscribedDebug = false; + StartDebugService(config, profilePaths, useExistingSession); + }; + } } /// @@ -350,6 +434,81 @@ private void CurrentDomain_UnhandledException( _logger.LogError($"FATAL UNHANDLED EXCEPTION: {e.ExceptionObject}"); } + private static NamedPipeServerStream CreateNamedPipe( + string inOutPipeName, + string outPipeName, + out NamedPipeServerStream outPipe) + { + // .NET Core implementation is simplest so try that first + if (VersionUtils.IsNetCore) + { + outPipe = outPipeName == null + ? null + : new NamedPipeServerStream( + pipeName: outPipeName, + direction: PipeDirection.Out, + maxNumberOfServerInstances: 1, + transmissionMode: PipeTransmissionMode.Byte, + options: (PipeOptions)CurrentUserOnly); + + return new NamedPipeServerStream( + pipeName: inOutPipeName, + direction: PipeDirection.InOut, + maxNumberOfServerInstances: 1, + transmissionMode: PipeTransmissionMode.Byte, + options: PipeOptions.Asynchronous | (PipeOptions)CurrentUserOnly); + } + + // Now deal with Windows PowerShell + // We need to use reflection to get a nice constructor + + var pipeSecurity = new PipeSecurity(); + + WindowsIdentity identity = WindowsIdentity.GetCurrent(); + WindowsPrincipal principal = new WindowsPrincipal(identity); + + if (principal.IsInRole(WindowsBuiltInRole.Administrator)) + { + // Allow the Administrators group full access to the pipe. + pipeSecurity.AddAccessRule(new PipeAccessRule( + new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null).Translate(typeof(NTAccount)), + PipeAccessRights.FullControl, AccessControlType.Allow)); + } + else + { + // Allow the current user read/write access to the pipe. + pipeSecurity.AddAccessRule(new PipeAccessRule( + WindowsIdentity.GetCurrent().User, + PipeAccessRights.ReadWrite, AccessControlType.Allow)); + } + + outPipe = outPipeName == null + ? null + : (NamedPipeServerStream)s_netFrameworkPipeServerConstructor.Invoke( + new object[] { + outPipeName, + PipeDirection.InOut, + 1, // maxNumberOfServerInstances + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous, + 1024, // inBufferSize + 1024, // outBufferSize + pipeSecurity + }); + + return (NamedPipeServerStream)s_netFrameworkPipeServerConstructor.Invoke( + new object[] { + inOutPipeName, + PipeDirection.InOut, + 1, // maxNumberOfServerInstances + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous, + 1024, // inBufferSize + 1024, // outBufferSize + pipeSecurity + }); + } + #endregion } } diff --git a/src/PowerShellEditorServices.Engine/PowerShellEditorServices.Engine.csproj b/src/PowerShellEditorServices.Engine/PowerShellEditorServices.Engine.csproj index 3c219d986..3a51e5b13 100644 --- a/src/PowerShellEditorServices.Engine/PowerShellEditorServices.Engine.csproj +++ b/src/PowerShellEditorServices.Engine/PowerShellEditorServices.Engine.csproj @@ -21,7 +21,7 @@ - + @@ -29,6 +29,6 @@ + - 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 new file mode 100644 index 000000000..1088cd145 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Server/PsesDebugServer.cs @@ -0,0 +1,102 @@ +// +// 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.IO; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Engine.Handlers; +using Microsoft.PowerShell.EditorServices.Engine.Services; +using OmniSharp.Extensions.DebugAdapter.Protocol.Serialization; +using OmniSharp.Extensions.JsonRpc; +using OmniSharp.Extensions.LanguageServer.Server; + +namespace Microsoft.PowerShell.EditorServices.Engine.Server +{ + public class PsesDebugServer : IDisposable + { + protected readonly ILoggerFactory _loggerFactory; + private readonly Stream _inputStream; + private readonly Stream _outputStream; + + private IJsonRpcServer _jsonRpcServer; + + public PsesDebugServer( + ILoggerFactory factory, + Stream inputStream, + Stream outputStream) + { + _loggerFactory = factory; + _inputStream = inputStream; + _outputStream = outputStream; + } + + public async Task StartAsync(IServiceProvider languageServerServiceProvider) + { + _jsonRpcServer = await JsonRpcServer.From(options => + { + options.Serializer = new DapProtocolSerializer(); + options.Reciever = new DapReciever(); + options.LoggerFactory = _loggerFactory; + ILogger logger = options.LoggerFactory.CreateLogger("DebugOptionsStartup"); + options.Services = new ServiceCollection() + .AddSingleton(languageServerServiceProvider.GetService()) + .AddSingleton(languageServerServiceProvider.GetService()) + .AddSingleton(languageServerServiceProvider.GetService()) + .AddSingleton(this) + .AddSingleton() + .AddSingleton() + .AddSingleton(); + + options + .WithInput(_inputStream) + .WithOutput(_outputStream); + + logger.LogInformation("Adding handlers"); + + options + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler() + .WithHandler(); + + logger.LogInformation("Handlers added"); + }); + } + + public void Dispose() + { + _jsonRpcServer.Dispose(); + } + + #region Events + + public event EventHandler SessionEnded; + + internal void OnSessionEnded() + { + SessionEnded?.Invoke(this, null); + } + + #endregion + } +} diff --git a/src/PowerShellEditorServices.Engine/Server/PsesLanguageServer.cs b/src/PowerShellEditorServices.Engine/Server/PsesLanguageServer.cs index f0ce7f920..7da6fc1e3 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() @@ -68,11 +68,18 @@ public async Task StartAsync() .AddSingleton() .AddSingleton( (provider) => - GetFullyInitializedPowerShellContext( + PowerShellContextService.Create( + LoggerFactory, provider.GetService(), - _profilePaths)) + _profilePaths, + _featureFlags, + _enableConsoleRepl, + _internalHost, + _hostDetails, + _additionalModules)) .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton( (provider) => { @@ -155,58 +162,7 @@ await serviceProvider.GetService().SetWorkingDirectory public async Task WaitForShutdown() { await _serverStart.Task; - await _languageServer.WaitForExit; - } - - private PowerShellContextService GetFullyInitializedPowerShellContext( - OmniSharp.Extensions.LanguageServer.Protocol.Server.ILanguageServer languageServer, - ProfilePaths profilePaths) - { - var logger = _loggerFactory.CreateLogger(); - - // PSReadLine can only be used when -EnableConsoleRepl is specified otherwise - // issues arise when redirecting stdio. - var powerShellContext = new PowerShellContextService( - logger, - languageServer, - _featureFlags.Contains("PSReadLine") && _enableConsoleRepl); - - EditorServicesPSHostUserInterface hostUserInterface = - _enableConsoleRepl - ? (EditorServicesPSHostUserInterface)new TerminalPSHostUserInterface(powerShellContext, logger, _internalHost) - : new ProtocolPSHostUserInterface(languageServer, powerShellContext, logger); - - EditorServicesPSHost psHost = - new EditorServicesPSHost( - powerShellContext, - _hostDetails, - hostUserInterface, - logger); - - Runspace initialRunspace = PowerShellContextService.CreateRunspace(psHost); - powerShellContext.Initialize(profilePaths, initialRunspace, true, hostUserInterface); - - powerShellContext.ImportCommandsModuleAsync( - Path.Combine( - Path.GetDirectoryName(this.GetType().GetTypeInfo().Assembly.Location), - @"..\Commands")); - - // TODO: This can be moved to the point after the $psEditor object - // gets initialized when that is done earlier than LanguageServer.Initialize - foreach (string module in this._additionalModules) - { - var command = - new PSCommand() - .AddCommand("Microsoft.PowerShell.Core\\Import-Module") - .AddParameter("Name", module); - - powerShellContext.ExecuteCommandAsync( - command, - sendOutputToHost: false, - sendErrorToHost: true); - } - - return powerShellContext; + await LanguageServer.WaitForExit; } protected abstract (Stream input, Stream output) GetInputOutputStreams(); diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/DebugEventHandlerService.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/DebugEventHandlerService.cs new file mode 100644 index 000000000..e4410f439 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/DebugEventHandlerService.cs @@ -0,0 +1,177 @@ +// +// 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 +{ + internal 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_RunspaceChanged; + _debugService.BreakpointUpdated += DebugService_BreakpointUpdated; + _debugService.DebuggerStopped += DebugService_DebuggerStopped; + _powerShellContextService.DebuggerResumed += PowerShellContext_DebuggerResumed; + } + + internal void UnregisterEventHandlers() + { + _powerShellContextService.RunspaceChanged -= PowerShellContext_RunspaceChanged; + _debugService.BreakpointUpdated -= DebugService_BreakpointUpdated; + _debugService.DebuggerStopped -= DebugService_DebuggerStopped; + _powerShellContextService.DebuggerResumed -= PowerShellContext_DebuggerResumed; + } + + #region Public methods + + internal void TriggerDebuggerStopped(DebuggerStoppedEventArgs e) + { + DebugService_DebuggerStopped(null, e); + } + + #endregion + + #region Event Handlers + + private void DebugService_DebuggerStopped(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_RunspaceChanged(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_DebuggerResumed(object sender, DebuggerResumeAction e) + { + _jsonRpcServer.SendNotification(EventNames.Continued, + new ContinuedEvent + { + AllThreadsContinued = true, + ThreadId = 1 + }); + } + + private void DebugService_BreakpointUpdated(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 = LspDebugUtils.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..64889ae5c --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/DebugService.cs @@ -0,0 +1,1352 @@ +// +// 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. + /// + internal 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 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 RemoteFileManagerService instance to use for accessing files in remote sessions. + //// + /// An ILogger implementation used for writing log messages. + public DebugService( + PowerShellContextService powerShellContext, + RemoteFileManagerService 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; + // 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.LogTrace( + $"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; + } + 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; + + // 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); + + // 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; + 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..fd2ec00cb --- /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 +{ + internal 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/BreakpointHandlers.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/BreakpointHandlers.cs new file mode 100644 index 000000000..8b0d0962c --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/BreakpointHandlers.cs @@ -0,0 +1,226 @@ +// +// 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.IO; +using System.Linq; +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.DebugAdapter; +using Microsoft.PowerShell.EditorServices.Engine.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.DebugAdapter.Protocol.Models; +using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; + +namespace Microsoft.PowerShell.EditorServices.Engine.Handlers +{ + internal class SetFunctionBreakpointsHandler : ISetFunctionBreakpointsHandler + { + private readonly ILogger _logger; + private readonly DebugService _debugService; + private readonly DebugStateService _debugStateService; + + public SetFunctionBreakpointsHandler( + ILoggerFactory loggerFactory, + DebugService debugService, + DebugStateService debugStateService) + { + _logger = loggerFactory.CreateLogger(); + _debugService = debugService; + _debugStateService = debugStateService; + } + + public async Task Handle(SetFunctionBreakpointsArguments request, CancellationToken cancellationToken) + { + CommandBreakpointDetails[] breakpointDetails = request.Breakpoints + .Select((funcBreakpoint) => CommandBreakpointDetails.Create( + funcBreakpoint.Name, + funcBreakpoint.Condition, + funcBreakpoint.HitCondition)) + .ToArray(); + + // If this is a "run without debugging (Ctrl+F5)" session ignore requests to set breakpoints. + CommandBreakpointDetails[] updatedBreakpointDetails = breakpointDetails; + if (!_debugStateService.NoDebug) + { + _debugStateService.SetBreakpointInProgress = true; + + try + { + updatedBreakpointDetails = + await _debugService.SetCommandBreakpointsAsync( + breakpointDetails); + } + catch (Exception e) + { + // Log whatever the error is + _logger.LogException($"Caught error while setting command breakpoints", e); + } + finally + { + _debugStateService.SetBreakpointInProgress = false; + } + } + + return new SetFunctionBreakpointsResponse + { + Breakpoints = updatedBreakpointDetails + .Select(LspDebugUtils.CreateBreakpoint) + .ToArray() + }; + } + } + + internal class SetExceptionBreakpointsHandler : ISetExceptionBreakpointsHandler + { + private readonly ILogger _logger; + private readonly DebugService _debugService; + private readonly DebugStateService _debugStateService; + + public SetExceptionBreakpointsHandler( + ILoggerFactory loggerFactory, + DebugService debugService, + DebugStateService debugStateService) + { + _logger = loggerFactory.CreateLogger(); + _debugService = debugService; + _debugStateService = debugStateService; + } + + public Task Handle(SetExceptionBreakpointsArguments request, CancellationToken cancellationToken) + { + // TODO: When support for exception breakpoints (unhandled and/or first chance) + // are added to the PowerShell engine, wire up the VSCode exception + // breakpoints here using the pattern below to prevent bug regressions. + //if (!noDebug) + //{ + // setBreakpointInProgress = true; + + // try + // { + // // Set exception breakpoints in DebugService + // } + // catch (Exception e) + // { + // // Log whatever the error is + // Logger.WriteException($"Caught error while setting exception breakpoints", e); + // } + // finally + // { + // setBreakpointInProgress = false; + // } + //} + + return Task.FromResult(new SetExceptionBreakpointsResponse()); + } + } + + internal class SetBreakpointsHandler : ISetBreakpointsHandler + { + private readonly ILogger _logger; + private readonly DebugService _debugService; + private readonly DebugStateService _debugStateService; + private readonly WorkspaceService _workspaceService; + + public SetBreakpointsHandler( + ILoggerFactory loggerFactory, + DebugService debugService, + DebugStateService debugStateService, + WorkspaceService workspaceService) + { + _logger = loggerFactory.CreateLogger(); + _debugService = debugService; + _debugStateService = debugStateService; + _workspaceService = workspaceService; + } + + public async Task Handle(SetBreakpointsArguments request, CancellationToken cancellationToken) + { + ScriptFile scriptFile = null; + + // When you set a breakpoint in the right pane of a Git diff window on a PS1 file, + // the Source.Path comes through as Untitled-X. That's why we check for IsUntitledPath. + if (!ScriptFile.IsUntitledPath(request.Source.Path) && + !_workspaceService.TryGetFile( + request.Source.Path, + out scriptFile)) + { + string message = _debugStateService.NoDebug ? string.Empty : "Source file could not be accessed, breakpoint not set."; + var srcBreakpoints = request.Breakpoints + .Select(srcBkpt => LspDebugUtils.CreateBreakpoint( + srcBkpt, request.Source.Path, message, verified: _debugStateService.NoDebug)); + + // Return non-verified breakpoint message. + return new SetBreakpointsResponse + { + Breakpoints = new Container(srcBreakpoints) + }; + } + + // Verify source file is a PowerShell script file. + string fileExtension = Path.GetExtension(scriptFile?.FilePath ?? "")?.ToLower(); + if (string.IsNullOrEmpty(fileExtension) || ((fileExtension != ".ps1") && (fileExtension != ".psm1"))) + { + _logger.LogWarning( + $"Attempted to set breakpoints on a non-PowerShell file: {request.Source.Path}"); + + string message = _debugStateService.NoDebug ? string.Empty : "Source is not a PowerShell script, breakpoint not set."; + + var srcBreakpoints = request.Breakpoints + .Select(srcBkpt => LspDebugUtils.CreateBreakpoint( + srcBkpt, request.Source.Path, message, verified: _debugStateService.NoDebug)); + + // Return non-verified breakpoint message. + return new SetBreakpointsResponse + { + Breakpoints = new Container(srcBreakpoints) + }; + } + + // At this point, the source file has been verified as a PowerShell script. + BreakpointDetails[] breakpointDetails = request.Breakpoints + .Select((srcBreakpoint) => BreakpointDetails.Create( + scriptFile.FilePath, + (int)srcBreakpoint.Line, + (int?)srcBreakpoint.Column, + srcBreakpoint.Condition, + srcBreakpoint.HitCondition)) + .ToArray(); + + // If this is a "run without debugging (Ctrl+F5)" session ignore requests to set breakpoints. + BreakpointDetails[] updatedBreakpointDetails = breakpointDetails; + if (!_debugStateService.NoDebug) + { + _debugStateService.SetBreakpointInProgress = true; + + try + { + updatedBreakpointDetails = + await _debugService.SetLineBreakpointsAsync( + scriptFile, + breakpointDetails); + } + catch (Exception e) + { + // Log whatever the error is + _logger.LogException($"Caught error while setting breakpoints in SetBreakpoints handler for file {scriptFile?.FilePath}", e); + } + finally + { + _debugStateService.SetBreakpointInProgress = false; + } + } + + return new SetBreakpointsResponse + { + Breakpoints = new Container(updatedBreakpointDetails + .Select(LspDebugUtils.CreateBreakpoint)) + }; + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs new file mode 100644 index 000000000..354d723f4 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/ConfigurationDoneHandler.cs @@ -0,0 +1,104 @@ +// +// 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 Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext; +using Microsoft.PowerShell.EditorServices.Engine.Services.TextDocument; +using OmniSharp.Extensions.DebugAdapter.Protocol.Events; +using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; +using OmniSharp.Extensions.JsonRpc; + +namespace Microsoft.PowerShell.EditorServices.Engine.Handlers +{ + internal class ConfigurationDoneHandler : IConfigurationDoneHandler + { + private readonly ILogger _logger; + private readonly IJsonRpcServer _jsonRpcServer; + private readonly DebugService _debugService; + private readonly DebugStateService _debugStateService; + private readonly DebugEventHandlerService _debugEventHandlerService; + private readonly PowerShellContextService _powerShellContextService; + private readonly WorkspaceService _workspaceService; + + public ConfigurationDoneHandler( + ILoggerFactory loggerFactory, + IJsonRpcServer jsonRpcServer, + DebugService debugService, + DebugStateService debugStateService, + DebugEventHandlerService debugEventHandlerService, + PowerShellContextService powerShellContextService, + WorkspaceService workspaceService) + { + _logger = loggerFactory.CreateLogger(); + _jsonRpcServer = jsonRpcServer; + _debugService = debugService; + _debugStateService = debugStateService; + _debugEventHandlerService = debugEventHandlerService; + _powerShellContextService = powerShellContextService; + _workspaceService = workspaceService; + } + + public Task Handle(ConfigurationDoneArguments request, CancellationToken cancellationToken) + { + _debugService.IsClientAttached = true; + + if (!string.IsNullOrEmpty(_debugStateService.ScriptToLaunch)) + { + if (_powerShellContextService.SessionState == PowerShellContextState.Ready) + { + // Configuration is done, launch the script + var nonAwaitedTask = LaunchScriptAsync(_debugStateService.ScriptToLaunch) + .ConfigureAwait(continueOnCapturedContext: false); + } + else + { + _logger.LogTrace("configurationDone request called after script was already launched, skipping it."); + } + } + + if (_debugStateService.IsInteractiveDebugSession) + { + if (_debugStateService.OwnsEditorSession) + { + // If this is a debug-only session, we need to start + // the command loop manually + // TODO: Bring this back + //_editorSession.HostInput.StartCommandLoop(); + } + + if (_debugService.IsDebuggerStopped) + { + // If this is an interactive session and there's a pending breakpoint, + // send that information along to the debugger client + _debugEventHandlerService.TriggerDebuggerStopped(_debugService.CurrentDebuggerStoppedEventArgs); + } + } + + return Task.FromResult(new ConfigurationDoneResponse()); + } + + private async Task LaunchScriptAsync(string scriptToLaunch) + { + // Is this an untitled script? + if (ScriptFile.IsUntitledPath(scriptToLaunch)) + { + ScriptFile untitledScript = _workspaceService.GetFile(scriptToLaunch); + + await _powerShellContextService + .ExecuteScriptStringAsync(untitledScript.Contents, true, true); + } + else + { + await _powerShellContextService + .ExecuteScriptWithArgsAsync(scriptToLaunch, _debugStateService.Arguments, writeInputToHost: true); + } + + _jsonRpcServer.SendNotification(EventNames.Terminated); + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/DebugEvaluateHandler.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/DebugEvaluateHandler.cs new file mode 100644 index 000000000..a35133eb9 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/DebugEvaluateHandler.cs @@ -0,0 +1,88 @@ +// +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Engine.Services; +using Microsoft.PowerShell.EditorServices.Engine.Services.DebugAdapter; + +namespace Microsoft.PowerShell.EditorServices.Engine.Handlers +{ + internal class DebugEvaluateHandler : IEvaluateHandler + { + private readonly ILogger _logger; + private readonly PowerShellContextService _powerShellContextService; + private readonly DebugService _debugService; + + public DebugEvaluateHandler( + ILoggerFactory factory, + PowerShellContextService powerShellContextService, + DebugService debugService) + { + _logger = factory.CreateLogger(); + _powerShellContextService = powerShellContextService; + _debugService = debugService; + } + + public async Task Handle(EvaluateRequestArguments request, CancellationToken cancellationToken) + { + string valueString = ""; + int variableId = 0; + + bool isFromRepl = + string.Equals( + request.Context, + "repl", + StringComparison.CurrentCultureIgnoreCase); + + if (isFromRepl) + { + var notAwaited = + _powerShellContextService + .ExecuteScriptStringAsync(request.Expression, false, true) + .ConfigureAwait(false); + } + else + { + VariableDetailsBase result = null; + + // VS Code might send this request after the debugger + // has been resumed, return an empty result in this case. + if (_powerShellContextService.IsDebuggerStopped) + { + // First check to see if the watch expression refers to a naked variable reference. + result = + _debugService.GetVariableFromExpression(request.Expression, request.FrameId); + + // If the expression is not a naked variable reference, then evaluate the expression. + if (result == null) + { + result = + await _debugService.EvaluateExpressionAsync( + request.Expression, + request.FrameId, + isFromRepl); + } + } + + if (result != null) + { + valueString = result.ValueString; + variableId = + result.IsExpandable ? + result.Id : 0; + } + } + + return new EvaluateResponseBody + { + Result = valueString, + VariablesReference = variableId + }; + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/DebuggerActionHandlers.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/DebuggerActionHandlers.cs new file mode 100644 index 000000000..2ae5c932c --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/DebuggerActionHandlers.cs @@ -0,0 +1,123 @@ +// +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Engine.Services; +using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; +using OmniSharp.Extensions.JsonRpc; + +namespace Microsoft.PowerShell.EditorServices.Engine.Handlers +{ + internal class ContinueHandler : IContinueHandler + { + private readonly ILogger _logger; + private readonly DebugService _debugService; + + public ContinueHandler( + ILoggerFactory loggerFactory, + DebugService debugService) + { + _logger = loggerFactory.CreateLogger(); + _debugService = debugService; + } + + public Task Handle(ContinueArguments request, CancellationToken cancellationToken) + { + _debugService.Continue(); + return Task.FromResult(new ContinueResponse()); + } + } + + internal class NextHandler : INextHandler + { + private readonly ILogger _logger; + private readonly DebugService _debugService; + + public NextHandler( + ILoggerFactory loggerFactory, + DebugService debugService) + { + _logger = loggerFactory.CreateLogger(); + _debugService = debugService; + } + + public Task Handle(NextArguments request, CancellationToken cancellationToken) + { + _debugService.StepOver(); + return Task.FromResult(new NextResponse()); + } + } + + internal class PauseHandler : IPauseHandler + { + private readonly ILogger _logger; + private readonly DebugService _debugService; + + public PauseHandler( + ILoggerFactory loggerFactory, + DebugService debugService) + { + _logger = loggerFactory.CreateLogger(); + _debugService = debugService; + } + + public Task Handle(PauseArguments request, CancellationToken cancellationToken) + { + try + { + _debugService.Break(); + return Task.FromResult(new PauseResponse()); + } + catch(NotSupportedException e) + { + throw new RpcErrorException(0, e.Message); + } + } + } + + internal class StepInHandler : IStepInHandler + { + private readonly ILogger _logger; + private readonly DebugService _debugService; + + public StepInHandler( + ILoggerFactory loggerFactory, + DebugService debugService) + { + _logger = loggerFactory.CreateLogger(); + _debugService = debugService; + } + + public Task Handle(StepInArguments request, CancellationToken cancellationToken) + { + _debugService.StepIn(); + return Task.FromResult(new StepInResponse()); + } + } + + internal class StepOutHandler : IStepOutHandler + { + private readonly ILogger _logger; + private readonly DebugService _debugService; + + public StepOutHandler( + ILoggerFactory loggerFactory, + DebugService debugService) + { + _logger = loggerFactory.CreateLogger(); + _debugService = debugService; + } + + public Task Handle(StepOutArguments request, CancellationToken cancellationToken) + { + _debugService.StepOut(); + return Task.FromResult(new StepOutResponse()); + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/DisconnectHandler.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/DisconnectHandler.cs new file mode 100644 index 000000000..7c5711e3f --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/DisconnectHandler.cs @@ -0,0 +1,84 @@ +// +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Engine.Logging; +using Microsoft.PowerShell.EditorServices.Engine.Server; +using Microsoft.PowerShell.EditorServices.Engine.Services; +using Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext; +using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; + +namespace Microsoft.PowerShell.EditorServices.Engine.Handlers +{ + internal class DisconnectHandler : IDisconnectHandler + { + private readonly ILogger _logger; + private readonly PowerShellContextService _powerShellContextService; + private readonly DebugService _debugService; + private readonly DebugStateService _debugStateService; + private readonly DebugEventHandlerService _debugEventHandlerService; + private readonly PsesDebugServer _psesDebugServer; + + public DisconnectHandler( + ILoggerFactory factory, + PsesDebugServer psesDebugServer, + PowerShellContextService powerShellContextService, + DebugService debugService, + DebugStateService debugStateService, + DebugEventHandlerService debugEventHandlerService) + { + _logger = factory.CreateLogger(); + _psesDebugServer = psesDebugServer; + _powerShellContextService = powerShellContextService; + _debugService = debugService; + _debugStateService = debugStateService; + _debugEventHandlerService = debugEventHandlerService; + } + + public async Task Handle(DisconnectArguments request, CancellationToken cancellationToken) + { + _debugEventHandlerService.UnregisterEventHandlers(); + if (_debugStateService.ExecutionCompleted == false) + { + _debugStateService.ExecutionCompleted = true; + _powerShellContextService.AbortExecution(shouldAbortDebugSession: true); + + if (_debugStateService.IsInteractiveDebugSession && _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; + } + + _logger.LogInformation("Debug adapter is shutting down..."); + + // Trigger the clean up of the debugger. + Task.Run(_psesDebugServer.OnSessionEnded); + + return new DisconnectResponse(); + } + } +} 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..0876fdff1 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/InitializeHandler.cs @@ -0,0 +1,43 @@ +// +// 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.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..77e62588a --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/LaunchAndAttachHandler.cs @@ -0,0 +1,407 @@ +// +// 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.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; } + } + + internal 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; + private readonly RemoteFileManagerService _remoteFileManagerService; + + public LaunchHandler( + ILoggerFactory factory, + IJsonRpcServer jsonRpcServer, + DebugService debugService, + PowerShellContextService powerShellContextService, + DebugStateService debugStateService, + DebugEventHandlerService debugEventHandlerService, + RemoteFileManagerService remoteFileManagerService) + { + _logger = factory.CreateLogger(); + _jsonRpcServer = jsonRpcServer; + _debugService = debugService; + _powerShellContextService = powerShellContextService; + _debugStateService = debugStateService; + _debugEventHandlerService = debugEventHandlerService; + _remoteFileManagerService = remoteFileManagerService; + } + + 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 (_debugStateService.ScriptToLaunch != null && + _powerShellContextService.CurrentRunspace.Location == RunspaceLocation.Remote) + { + _debugStateService.ScriptToLaunch = + _remoteFileManagerService.GetMappedPath( + _debugStateService.ScriptToLaunch, + _powerShellContextService.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; + } + } + + internal 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 RpcErrorException(0, "User aborted attach to PowerShell host process."); + } + + StringBuilder errorMessages = new StringBuilder(); + + if (request.ComputerName != null) + { + if (runspaceVersion.Version.Major < 4) + { + throw new RpcErrorException(0, $"Remote sessions are only available with PowerShell 4 and higher (current session is {runspaceVersion.Version})."); + } + else if (_powerShellContextService.CurrentRunspace.Location == RunspaceLocation.Remote) + { + throw new RpcErrorException(0, $"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 RpcErrorException(0, $"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 RpcErrorException(0, $"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 RpcErrorException(0, $"Could not attach to process '{processId}'"); + } + } + else if (customPipeNameIsSet) + { + if (runspaceVersion.Version < s_minVersionForCustomPipeName) + { + throw new RpcErrorException(0, $"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 RpcErrorException(0, $"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 RpcErrorException(0, "A positive integer must be specified for the processId field."); + } + + // Clear any existing breakpoints before proceeding + await _debugService.ClearAllBreakpointsAsync().ConfigureAwait(continueOnCapturedContext: false); + + // Execute the Debug-Runspace command but don't await it because it + // will block the debug adapter initialization process. The + // InitializedEvent will be sent as soon as the RunspaceChanged + // 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 RpcErrorException(0, "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; + _jsonRpcServer.SendNotification(EventNames.Terminated); + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/ScopesHandler.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/ScopesHandler.cs new file mode 100644 index 000000000..22a33154c --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/ScopesHandler.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.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Engine.Services; +using Microsoft.PowerShell.EditorServices.Engine.Services.DebugAdapter; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.DebugAdapter.Protocol.Models; +using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; + +namespace Microsoft.PowerShell.EditorServices.Engine.Handlers +{ + internal class ScopesHandler : IScopesHandler + { + private readonly ILogger _logger; + private readonly DebugService _debugService; + + public ScopesHandler( + ILoggerFactory loggerFactory, + DebugService debugService) + { + _logger = loggerFactory.CreateLogger(); + _debugService = debugService; + } + + public Task Handle(ScopesArguments request, CancellationToken cancellationToken) + { + VariableScope[] variableScopes = + _debugService.GetVariableScopes( + (int) request.FrameId); + + return Task.FromResult(new ScopesResponse + { + Scopes = new Container(variableScopes + .Select(LspDebugUtils.CreateScope)) + }); + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/SetVariableHandler.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/SetVariableHandler.cs new file mode 100644 index 000000000..542b3f36e --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/SetVariableHandler.cs @@ -0,0 +1,67 @@ +// +// 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.Management.Automation; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Engine.Services; +using Microsoft.PowerShell.EditorServices.Engine.Services.DebugAdapter; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.DebugAdapter.Protocol.Models; +using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; +using OmniSharp.Extensions.JsonRpc; + +namespace Microsoft.PowerShell.EditorServices.Engine.Handlers +{ + internal class SetVariableHandler : ISetVariableHandler + { + private readonly ILogger _logger; + private readonly DebugService _debugService; + + public SetVariableHandler( + ILoggerFactory loggerFactory, + DebugService debugService) + { + _logger = loggerFactory.CreateLogger(); + _debugService = debugService; + } + + public async Task Handle(SetVariableArguments request, CancellationToken cancellationToken) + { + try + { + string updatedValue = + await _debugService.SetVariableAsync( + (int) request.VariablesReference, + request.Name, + request.Value); + + return new SetVariableResponse + { + Value = updatedValue + }; + + } + catch (Exception ex) when(ex is ArgumentTransformationMetadataException || + ex is InvalidPowerShellExpressionException || + ex is SessionStateUnauthorizedAccessException) + { + // Catch common, innocuous errors caused by the user supplying a value that can't be converted or the variable is not settable. + _logger.LogTrace($"Failed to set variable: {ex.Message}"); + throw new RpcErrorException(0, ex.Message); + } + catch (Exception ex) + { + _logger.LogError($"Unexpected error setting variable: {ex.Message}"); + string msg = + $"Unexpected error: {ex.GetType().Name} - {ex.Message} Please report this error to the PowerShellEditorServices project on GitHub."; + throw new RpcErrorException(0, msg); + } + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/SourceHandler.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/SourceHandler.cs new file mode 100644 index 000000000..168682ca0 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/SourceHandler.cs @@ -0,0 +1,21 @@ +// +// 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 OmniSharp.Extensions.DebugAdapter.Protocol.Requests; + +namespace Microsoft.PowerShell.EditorServices.Engine.Handlers +{ + internal class SourceHandler : ISourceHandler + { + public Task Handle(SourceArguments request, CancellationToken cancellationToken) + { + // TODO: Implement this message. For now, doesn't seem to + // be a problem that it's missing. + return Task.FromResult(new SourceResponse()); + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/StackTraceHandler.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/StackTraceHandler.cs new file mode 100644 index 000000000..9b2263b24 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/StackTraceHandler.cs @@ -0,0 +1,80 @@ +// +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Engine.Services; +using Microsoft.PowerShell.EditorServices.Engine.Services.DebugAdapter; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.DebugAdapter.Protocol.Models; +using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; + +namespace Microsoft.PowerShell.EditorServices.Engine.Handlers +{ + internal class StackTraceHandler : IStackTraceHandler + { + private readonly ILogger _logger; + private readonly DebugService _debugService; + + public StackTraceHandler( + ILoggerFactory loggerFactory, + DebugService debugService) + { + _logger = loggerFactory.CreateLogger(); + _debugService = debugService; + } + + public Task Handle(StackTraceArguments request, CancellationToken cancellationToken) + { + StackFrameDetails[] stackFrameDetails = + _debugService.GetStackFrames(); + + // Handle a rare race condition where the adapter requests stack frames before they've + // begun building. + if (stackFrameDetails == null) + { + return Task.FromResult(new StackTraceResponse + { + StackFrames = new StackFrame[0], + TotalFrames = 0 + }); + } + + List newStackFrames = new List(); + + long startFrameIndex = request.StartFrame ?? 0; + long maxFrameCount = stackFrameDetails.Length; + + // If the number of requested levels == 0 (or null), that means get all stack frames + // after the specified startFrame index. Otherwise get all the stack frames. + long requestedFrameCount = (request.Levels ?? 0); + if (requestedFrameCount > 0) + { + maxFrameCount = Math.Min(maxFrameCount, startFrameIndex + requestedFrameCount); + } + + for (long i = startFrameIndex; i < maxFrameCount; i++) + { + // Create the new StackFrame object with an ID that can + // be referenced back to the current list of stack frames + //newStackFrames.Add( + // StackFrame.Create( + // stackFrameDetails[i], + // i)); + newStackFrames.Add( + LspDebugUtils.CreateStackFrame(stackFrameDetails[i], id: i)); + } + + return Task.FromResult(new StackTraceResponse + { + StackFrames = newStackFrames, + TotalFrames = newStackFrames.Count + }); + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/ThreadsHandler.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/ThreadsHandler.cs new file mode 100644 index 000000000..06c14c44c --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/ThreadsHandler.cs @@ -0,0 +1,29 @@ +// +// 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 OmniSharp.Extensions.DebugAdapter.Protocol.Models; +using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; + +namespace Microsoft.PowerShell.EditorServices.Engine.Handlers +{ + internal class ThreadsHandler : IThreadsHandler + { + public Task Handle(ThreadsArguments request, CancellationToken cancellationToken) + { + return Task.FromResult(new ThreadsResponse + { + // TODO: What do I do with these? + Threads = new Container( + new OmniSharp.Extensions.DebugAdapter.Protocol.Models.Thread + { + Id = 1, + Name = "Main Thread" + }) + }); + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/VariablesHandler.cs b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/VariablesHandler.cs new file mode 100644 index 000000000..cf8003302 --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Services/DebugAdapter/Handlers/VariablesHandler.cs @@ -0,0 +1,58 @@ +// +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.PowerShell.EditorServices.Engine.Services; +using Microsoft.PowerShell.EditorServices.Engine.Services.DebugAdapter; +using Microsoft.PowerShell.EditorServices.Utility; +using OmniSharp.Extensions.DebugAdapter.Protocol.Models; +using OmniSharp.Extensions.DebugAdapter.Protocol.Requests; + +namespace Microsoft.PowerShell.EditorServices.Engine.Handlers +{ + internal class VariablesHandler : IVariablesHandler + { + private readonly ILogger _logger; + private readonly DebugService _debugService; + + public VariablesHandler( + ILoggerFactory loggerFactory, + DebugService debugService) + { + _logger = loggerFactory.CreateLogger(); + _debugService = debugService; + } + + public Task Handle(VariablesArguments request, CancellationToken cancellationToken) + { + VariableDetailsBase[] variables = + _debugService.GetVariables( + (int)request.VariablesReference); + + VariablesResponse variablesResponse = null; + + try + { + variablesResponse = new VariablesResponse + { + Variables = + variables + .Select(LspDebugUtils.CreateVariable) + .ToArray() + }; + } + catch (Exception) + { + // TODO: This shouldn't be so broad + } + + return Task.FromResult(variablesResponse); + } + } +} diff --git a/src/PowerShellEditorServices.Engine/Services/PowerShellContext/PowerShellContextService.cs b/src/PowerShellEditorServices.Engine/Services/PowerShellContext/PowerShellContextService.cs index b46eff1e8..3afac2843 100644 --- a/src/PowerShellEditorServices.Engine/Services/PowerShellContext/PowerShellContextService.cs +++ b/src/PowerShellEditorServices.Engine/Services/PowerShellContext/PowerShellContextService.cs @@ -161,6 +161,64 @@ public PowerShellContextService( ExecutionStatusChanged += PowerShellContext_ExecutionStatusChangedAsync; } + public static PowerShellContextService Create( + ILoggerFactory factory, + OmniSharp.Extensions.LanguageServer.Protocol.Server.ILanguageServer languageServer, + ProfilePaths profilePaths, + HashSet featureFlags, + bool enableConsoleRepl, + PSHost internalHost, + HostDetails hostDetails, + string[] additionalModules + ) + { + var logger = factory.CreateLogger(); + + // PSReadLine can only be used when -EnableConsoleRepl is specified otherwise + // issues arise when redirecting stdio. + var powerShellContext = new PowerShellContextService( + logger, + languageServer, + featureFlags.Contains("PSReadLine") && enableConsoleRepl); + + EditorServicesPSHostUserInterface hostUserInterface = + enableConsoleRepl + ? (EditorServicesPSHostUserInterface)new TerminalPSHostUserInterface(powerShellContext, logger, internalHost) + : new ProtocolPSHostUserInterface(languageServer, powerShellContext, logger); + + EditorServicesPSHost psHost = + new EditorServicesPSHost( + powerShellContext, + hostDetails, + hostUserInterface, + logger); + + Runspace initialRunspace = PowerShellContextService.CreateRunspace(psHost); + powerShellContext.Initialize(profilePaths, initialRunspace, true, hostUserInterface); + + powerShellContext.ImportCommandsModuleAsync( + Path.Combine( + Path.GetDirectoryName(typeof(PowerShellContextService).GetTypeInfo().Assembly.Location), + @"..\Commands")); + + // TODO: This can be moved to the point after the $psEditor object + // gets initialized when that is done earlier than LanguageServer.Initialize + foreach (string module in additionalModules) + { + var command = + new PSCommand() + .AddCommand("Microsoft.PowerShell.Core\\Import-Module") + .AddParameter("Name", module); + + powerShellContext.ExecuteCommandAsync( + command, + sendOutputToHost: false, + sendErrorToHost: true); + } + + return powerShellContext; + } + /// /// /// diff --git a/src/PowerShellEditorServices.Engine/Services/PowerShellContext/Session/RemoteFileManager.cs b/src/PowerShellEditorServices.Engine/Services/PowerShellContext/RemoteFileManagerService.cs similarity index 97% rename from src/PowerShellEditorServices.Engine/Services/PowerShellContext/Session/RemoteFileManager.cs rename to src/PowerShellEditorServices.Engine/Services/PowerShellContext/RemoteFileManagerService.cs index 1fe3d4086..cc32755cc 100644 --- a/src/PowerShellEditorServices.Engine/Services/PowerShellContext/Session/RemoteFileManager.cs +++ b/src/PowerShellEditorServices.Engine/Services/PowerShellContext/RemoteFileManagerService.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using Microsoft.PowerShell.EditorServices.Engine.Logging; +using Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext; using Microsoft.PowerShell.EditorServices.Utility; using System; using System.Collections.Generic; @@ -16,13 +17,13 @@ using System.Text; using System.Threading.Tasks; -namespace Microsoft.PowerShell.EditorServices.Engine.Services.PowerShellContext +namespace Microsoft.PowerShell.EditorServices.Engine.Services { /// /// Manages files that are accessed from a remote PowerShell session. /// Also manages the registration and handling of the 'psedit' function. /// - public class RemoteFileManager + internal class RemoteFileManagerService { #region Fields @@ -236,23 +237,23 @@ function New-EditorFile { #region Constructors /// - /// Creates a new instance of the RemoteFileManager class. + /// Creates a new instance of the RemoteFileManagerService class. /// + /// An ILoggerFactory implementation used for writing log messages. /// /// The PowerShellContext to use for file loading operations. /// /// /// The IEditorOperations instance to use for opening/closing files in the editor. /// - /// An ILogger implementation used for writing log messages. - public RemoteFileManager( + public RemoteFileManagerService( + ILoggerFactory factory, PowerShellContextService powerShellContext, - IEditorOperations editorOperations, - ILogger logger) + EditorOperationsService editorOperations) { Validate.IsNotNull(nameof(powerShellContext), powerShellContext); - this.logger = logger; + this.logger = factory.CreateLogger(); this.powerShellContext = powerShellContext; this.powerShellContext.RunspaceChanged += HandleRunspaceChangedAsync; @@ -701,7 +702,7 @@ private void TryDeleteTemporaryPath() private class RemotePathMappings { private RunspaceDetails runspaceDetails; - private RemoteFileManager remoteFileManager; + private RemoteFileManagerService remoteFileManager; private HashSet openedPaths = new HashSet(); private Dictionary pathMappings = new Dictionary(); @@ -712,7 +713,7 @@ public IEnumerable OpenedPaths public RemotePathMappings( RunspaceDetails runspaceDetails, - RemoteFileManager remoteFileManager) + RemoteFileManagerService remoteFileManager) { this.runspaceDetails = runspaceDetails; this.remoteFileManager = remoteFileManager; 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/Services/TextDocument/Handlers/TextDocumentHandler.cs b/src/PowerShellEditorServices.Engine/Services/TextDocument/Handlers/TextDocumentHandler.cs index 52538d2d7..d3b70c43a 100644 --- a/src/PowerShellEditorServices.Engine/Services/TextDocument/Handlers/TextDocumentHandler.cs +++ b/src/PowerShellEditorServices.Engine/Services/TextDocument/Handlers/TextDocumentHandler.cs @@ -25,6 +25,7 @@ class TextDocumentHandler : ITextDocumentSyncHandler private readonly ILogger _logger; private readonly AnalysisService _analysisService; private readonly WorkspaceService _workspaceService; + private readonly RemoteFileManagerService _remoteFileManagerService; private readonly DocumentSelector _documentSelector = new DocumentSelector( new DocumentFilter() @@ -37,11 +38,16 @@ class TextDocumentHandler : ITextDocumentSyncHandler public TextDocumentSyncKind Change => TextDocumentSyncKind.Incremental; - public TextDocumentHandler(ILoggerFactory factory, AnalysisService analysisService, WorkspaceService workspaceService) + public TextDocumentHandler( + ILoggerFactory factory, + AnalysisService analysisService, + WorkspaceService workspaceService, + RemoteFileManagerService remoteFileManagerService) { _logger = factory.CreateLogger(); _analysisService = analysisService; _workspaceService = workspaceService; + _remoteFileManagerService = remoteFileManagerService; } public Task Handle(DidChangeTextDocumentParams notification, CancellationToken token) @@ -117,21 +123,20 @@ public Task Handle(DidCloseTextDocumentParams notification, CancellationTo return Unit.Task; } - public Task Handle(DidSaveTextDocumentParams notification, CancellationToken token) + public async Task Handle(DidSaveTextDocumentParams notification, CancellationToken token) { ScriptFile savedFile = _workspaceService.GetFile( notification.TextDocument.Uri.ToString()); - // TODO bring back - // if (savedFile != null) - // { - // if (this.editorSession.RemoteFileManager.IsUnderRemoteTempPath(savedFile.FilePath)) - // { - // await this.editorSession.RemoteFileManager.SaveRemoteFileAsync( - // savedFile.FilePath); - // } - // } - return Unit.Task; + + if (savedFile != null) + { + if (_remoteFileManagerService.IsUnderRemoteTempPath(savedFile.FilePath)) + { + await _remoteFileManagerService.SaveRemoteFileAsync(savedFile.FilePath); + } + } + return Unit.Value; } TextDocumentSaveRegistrationOptions IRegistration.GetRegistrationOptions() 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/LspDebugUtils.cs b/src/PowerShellEditorServices.Engine/Utility/LspDebugUtils.cs new file mode 100644 index 000000000..914b7e2da --- /dev/null +++ b/src/PowerShellEditorServices.Engine/Utility/LspDebugUtils.cs @@ -0,0 +1,115 @@ +using System; +using Microsoft.PowerShell.EditorServices.Engine.Services.DebugAdapter; +using OmniSharp.Extensions.DebugAdapter.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Utility +{ + public static class LspDebugUtils + { + 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 + }; + } + + public static StackFrame CreateStackFrame( + StackFrameDetails stackFrame, + long id) + { + var sourcePresentationHint = + stackFrame.IsExternalCode ? SourcePresentationHint.Deemphasize : SourcePresentationHint.Normal; + + // When debugging an interactive session, the ScriptPath is which is not a valid source file. + // We need to make sure the user can't open the file associated with this stack frame. + // It will generate a VSCode error in this case. + Source source = null; + if (!stackFrame.ScriptPath.Contains("<")) + { + source = new Source + { + Path = stackFrame.ScriptPath, + PresentationHint = sourcePresentationHint + }; + } + + return new StackFrame + { + Id = id, + Name = (source != null) ? stackFrame.FunctionName : "Interactive Session", + Line = (source != null) ? stackFrame.StartLineNumber : 0, + EndLine = stackFrame.EndLineNumber, + Column = (source != null) ? stackFrame.StartColumnNumber : 0, + EndColumn = stackFrame.EndColumnNumber, + Source = source + }; + } + + public static Scope CreateScope(VariableScope scope) + { + return new Scope + { + Name = scope.Name, + VariablesReference = scope.Id, + // Temporary fix for #95 to get debug hover tips to work well at least for the local scope. + Expensive = ((scope.Name != VariableContainerDetails.LocalScopeName) && + (scope.Name != VariableContainerDetails.AutoVariablesName)) + }; + } + + public static Variable CreateVariable(VariableDetailsBase variable) + { + return new Variable + { + Name = variable.Name, + Value = variable.ValueString ?? string.Empty, + Type = variable.Type, + EvaluateName = variable.Name, + VariablesReference = + variable.IsExpandable ? + variable.Id : 0 + }; + } + } +} diff --git a/test/PowerShellEditorServices.Test.E2E/LSPTestsFixures.cs b/test/PowerShellEditorServices.Test.E2E/LSPTestsFixures.cs new file mode 100644 index 000000000..fcf10bee3 --- /dev/null +++ b/test/PowerShellEditorServices.Test.E2E/LSPTestsFixures.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; +using OmniSharp.Extensions.LanguageServer.Client; +using OmniSharp.Extensions.LanguageServer.Client.Processes; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace PowerShellEditorServices.Test.E2E +{ + public class LSPTestsFixture : TestsFixture + { + public override bool IsDebugAdapterTests => false; + + public LanguageClient LanguageClient { get; private set; } + public List Diagnostics { get; set; } + + public async override Task CustomInitializeAsync( + ILoggerFactory factory, + StdioServerProcess process) + { + LanguageClient = new LanguageClient(factory, process); + + DirectoryInfo testdir = + Directory.CreateDirectory(Path.Combine(s_binDir, Path.GetRandomFileName())); + + await LanguageClient.Initialize(testdir.FullName); + + // Make sure Script Analysis is enabled because we'll need it in the tests. + LanguageClient.Workspace.DidChangeConfiguration(JObject.Parse(@" +{ + ""PowerShell"": { + ""ScriptAnalysis"": { + ""Enable"": true + } + } +} +")); + + Diagnostics = new List(); + LanguageClient.TextDocument.OnPublishDiagnostics((uri, diagnostics) => + { + Diagnostics.AddRange(diagnostics); + }); + } + + public override async Task DisposeAsync() + { + await LanguageClient.Shutdown(); + await _psesProcess.Stop(); + LanguageClient?.Dispose(); + } + } +} diff --git a/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs b/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs index 22f37a00b..8654832a6 100644 --- a/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs +++ b/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs @@ -21,7 +21,7 @@ namespace PowerShellEditorServices.Test.E2E { - public class LanguageServerProtocolMessageTests : IClassFixture, IDisposable + public class LanguageServerProtocolMessageTests : IClassFixture, IDisposable { private readonly static string s_binDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); @@ -33,7 +33,7 @@ public class LanguageServerProtocolMessageTests : IClassFixture, I private readonly string PwshExe; private readonly ITestOutputHelper _output; - public LanguageServerProtocolMessageTests(ITestOutputHelper output, TestsFixture data) + public LanguageServerProtocolMessageTests(ITestOutputHelper output, LSPTestsFixture data) { Diagnostics = new List(); LanguageClient = data.LanguageClient; diff --git a/test/PowerShellEditorServices.Test.E2E/PowerShellEditorServices.Test.E2E.csproj b/test/PowerShellEditorServices.Test.E2E/PowerShellEditorServices.Test.E2E.csproj index 8b7c085b4..a2b3878f8 100644 --- a/test/PowerShellEditorServices.Test.E2E/PowerShellEditorServices.Test.E2E.csproj +++ b/test/PowerShellEditorServices.Test.E2E/PowerShellEditorServices.Test.E2E.csproj @@ -10,7 +10,7 @@ - + diff --git a/test/PowerShellEditorServices.Test.E2E/TestsFixture.cs b/test/PowerShellEditorServices.Test.E2E/TestsFixture.cs index 092a8799a..7b51226f3 100644 --- a/test/PowerShellEditorServices.Test.E2E/TestsFixture.cs +++ b/test/PowerShellEditorServices.Test.E2E/TestsFixture.cs @@ -13,9 +13,9 @@ namespace PowerShellEditorServices.Test.E2E { - public class TestsFixture : IAsyncLifetime + public abstract class TestsFixture : IAsyncLifetime { - private readonly static string s_binDir = + protected readonly static string s_binDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); private readonly static string s_bundledModulePath = new FileInfo(Path.Combine( @@ -38,11 +38,11 @@ public class TestsFixture : IAsyncLifetime const string s_hostVersion = "1.0.0"; readonly static string[] s_additionalModules = { "PowerShellEditorServices.VSCode" }; - private StdioServerProcess _psesProcess; + protected StdioServerProcess _psesProcess; public static string PwshExe { get; } = Environment.GetEnvironmentVariable("PWSH_EXE_NAME") ?? "pwsh"; - public LanguageClient LanguageClient { get; private set; } - public List Diagnostics { get; set; } + + public virtual bool IsDebugAdapterTests { get; set; } public async Task InitializeAsync() { @@ -56,7 +56,8 @@ public async Task InitializeAsync() processStartInfo.ArgumentList.Add("-NoProfile"); processStartInfo.ArgumentList.Add("-EncodedCommand"); - string[] args = { + List args = new List + { Path.Combine(s_bundledModulePath, "PowerShellEditorServices", "Start-EditorServices.ps1"), "-LogPath", s_logPath, "-LogLevel", s_logLevel, @@ -70,6 +71,11 @@ public async Task InitializeAsync() "-Stdio" }; + if (IsDebugAdapterTests) + { + args.Add("-DebugServiceOnly"); + } + string base64Str = Convert.ToBase64String( System.Text.Encoding.Unicode.GetBytes(string.Join(' ', args))); @@ -78,36 +84,16 @@ public async Task InitializeAsync() _psesProcess = new StdioServerProcess(factory, processStartInfo); await _psesProcess.Start(); - LanguageClient = new LanguageClient(factory, _psesProcess); - - DirectoryInfo testdir = - Directory.CreateDirectory(Path.Combine(s_binDir, Path.GetRandomFileName())); - - await LanguageClient.Initialize(testdir.FullName); - - // Make sure Script Analysis is enabled because we'll need it in the tests. - LanguageClient.Workspace.DidChangeConfiguration(JObject.Parse(@" -{ - ""PowerShell"": { - ""ScriptAnalysis"": { - ""Enable"": true + await CustomInitializeAsync(factory, _psesProcess); } - } -} -")); - Diagnostics = new List(); - LanguageClient.TextDocument.OnPublishDiagnostics((uri, diagnostics) => - { - Diagnostics.AddRange(diagnostics); - }); - } - - public async Task DisposeAsync() + public virtual async Task DisposeAsync() { - await LanguageClient.Shutdown(); await _psesProcess.Stop(); - LanguageClient?.Dispose(); } + + public abstract Task CustomInitializeAsync( + ILoggerFactory factory, + StdioServerProcess process); } }