Add-Debugger.ps1
<#PSScriptInfo
.DESCRIPTION Debugger for hosts with no own debugger. .VERSION 1.0.0 .AUTHOR Roman Kuzmin .COPYRIGHT (c) Roman Kuzmin .TAGS Debug .GUID e187060e-ad39-425c-a6e3-b1e1e92ab59d .LICENSEURI http://www.apache.org/licenses/LICENSE-2.0 .PROJECTURI https://github.com/nightroman/PowerShelf #> <# .Synopsis Adds a script debugger to PowerShell. Author: Roman Kuzmin .Description This script is designed for PowerShell runspaces which do not have their own debuggers, e.g. Visual Studio NuGet console ("Package Manager Host"), the default runspace host ("Default Host"), and etc. But it can be used instead of build-in debuggers as well. The script should be called once at any moment when debugging is needed. In order to restore the original debugger invoke Restore-Debugger. This function is defined by Add-Debugger. For output of debug commands the script uses Out-Host or a file with a separate console started for watching its tail. For input the GUI input box is used for typing PowerShell and debugger commands. Use the switch ReadHost in order to use Read-Host instead. .Parameter Path Specifies the output file used instead of Out-Host. A separate console is opened for watching its tail. .Parameter ReadHost Tells to use Read-Host for input instead of the GUI input box. .Inputs None .Outputs None .Example > How to debug terminating errors in a bare runspace. Use cases: - In .NET the similar code is typical for invoking PowerShell. - In PowerShell BeginInvoke() makes sense for background jobs. $ps = [PowerShell]::Create() $null = $ps.AddScript({ # add debugger with file output Add-Debugger.ps1 $env:TEMP\debug.log # enable debugging on terminating errors $null = Set-PSBreakpoint -Variable StackTrace -Mode Write # from now on the debugger dialog is shown on failures # and a separate PowerShell output console is started ... }) $ps.Invoke() # or BeginInvoke() .Link https://github.com/nightroman/PowerShelf #> param( [Parameter()] [string]$Path, [switch]$ReadHost, [int]$XPos = -1, [int]$YPos = -1 ) # Restore another debugger by its Restore-Debugger. if ((Test-Path Variable:\_Debugger) -and (Get-Variable _Debugger).Description -ne 'Add-Debugger.ps1') { Restore-Debugger } # Removes and gets debugger handlers. function global:Remove-Debugger { $instance = [System.Management.Automation.Runspaces.Runspace]::DefaultRunspace.Debugger $type = [System.Management.Automation.Debugger] $e = $type.GetEvent('DebuggerStop') $v = $type.GetField('DebuggerStop', ([System.Reflection.BindingFlags]'NonPublic, Instance')).GetValue($instance) if ($v) { $handlers = $v.GetInvocationList() foreach($handler in $handlers) { $e.RemoveEventHandler($instance, $handler) } $handlers } } # Restores original debugger handlers. function global:Restore-Debugger { if (!(Test-Path Variable:\_Debugger)) {return} $null = Remove-Debugger if ($_Debugger.Handlers) { foreach($handler in $_Debugger.Handlers) { [System.Management.Automation.Runspaces.Runspace]::DefaultRunspace.Debugger.add_DebuggerStop($handler) } } Remove-Variable _Debugger -Scope Global -Force } # Add the debugger and data once if (!(Test-Path Variable:\_Debugger)) { $null = New-Variable -Name _Debugger -Scope Global -Description Add-Debugger.ps1 -Option ReadOnly -Value @{ History = [System.Collections.ArrayList]@() Handlers = Remove-Debugger DefaultContext = 0 Context = [ref]0 Action = '?' XPos = $XPos YPos = $YPos } [System.Management.Automation.Runspaces.Runspace]::DefaultRunspace.Debugger.add_DebuggerStop({. Invoke-DebuggerStop}) } # Writes debugger output. if ($Path) { $_Debugger.Path = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($Path) $_Debugger.Watch = $true function global:Write-Debugger($Data) { [System.IO.File]::AppendAllText($_Debugger.Path, ($Data | Out-String), [System.Text.Encoding]::Unicode) if ($_Debugger.Watch) { $_Debugger.Watch = $false Watch-Debugger $_Debugger.Path } } } else { $_Debugger.Path = $null function global:Write-Debugger($Data) { $Data | Out-Host } } # Reads debugger input. if ($ReadHost) { function global:Read-Debugger( $Prompt, $Title, $Default ) { Read-Host $Prompt } } else { Add-Type -AssemblyName Microsoft.VisualBasic function global:Read-Debugger( $Prompt, $Title, $Default ) { [Microsoft.VisualBasic.Interaction]::InputBox($Prompt, $Title, $Default, $_Debugger.XPos, $_Debugger.YPos) } } # Starts an external file viewer. function global:Watch-Debugger($Path) { $Path = $Path.Replace("'", "''") $me = Get-Process -Id $PID $app = if ($me.Name -eq 'pwsh') {$me.Path} else {'powershell.exe'} Start-Process $app "-NoProfile -Command Get-Content -LiteralPath '$Path' -Wait -ErrorAction 0" } # Writes the current invocation info. function global:Write-DebuggerInfo( $InvocationInfo, $Context ) { # write position message if ($_ = $InvocationInfo.PositionMessage) { Write-Debugger ($_.Trim()) } # done? $file = $InvocationInfo.ScriptName if ($Context -le 0 -or !$file -or !(Test-Path -LiteralPath $file)) {return} # write file lines $markIndex = $InvocationInfo.ScriptLineNumber - 1 Write-DebuggerFile $file ($markIndex - $Context) (2 * $Context + 1) $markIndex } # Writes the specified file lines. function global:Write-DebuggerFile( $Path, $LineIndex, $LineCount, $MarkIndex ) { # amend negative start if ($LineIndex -lt 0) { $LineCount += $LineIndex $LineIndex = 0 } # content lines $lines = @(Get-Content -LiteralPath $Path -TotalCount ($LineIndex + $LineCount) -Force -ErrorAction 0) # leading spaces $space = ($lines[$LineIndex .. -1] | .{process{ if ($_ -match '^(\s*)\S') { ($matches[1] -replace "`t", ' ').Length } }} | Measure-Object -Minimum).Minimum # write lines with a mark Write-Debugger '' do { if (($line = $lines[$LineIndex]) -match '^(\s*)(\S.*)') { $line = ($matches[1] -replace "`t", ' ').Substring($space) + $matches[2] } $mark = if ($LineIndex -eq $MarkIndex) {'=>'} else {' '} Write-Debugger ('{0,4} {1} {2}' -f ($LineIndex + 1), $mark, $line) } while(++$LineIndex -lt $lines.Length) Write-Debugger '' } # Processes DebuggerStop events. function global:Invoke-DebuggerStop { # write breakpoints if ($_.Breakpoints) {&{ Write-Debugger '' foreach($b in $_.Breakpoints) { if ($b -is [System.Management.Automation.VariableBreakpoint] -and $b.Variable -eq 'StackTrace') { Write-Debugger 'TERMINATING ERROR BREAKPOINT' } else { Write-Debugger "Hit $b" } } }} # write debug location Write-DebuggerInfo $_.InvocationInfo $_Debugger.DefaultContext Write-Debugger '' # REPL $_Debugger.e = $_ for() { ### prompt $_Debugger.Action = Read-Debugger "Enter PowerShell and debug commands.`nUse h or ? for help" Debugger $_Debugger.Action $_Debugger.Action = if ($null -eq $_Debugger.Action) {''} else {$_Debugger.Action.Trim()} Write-Debugger "DBG> $($_Debugger.Action)" ### Continue if (!$_Debugger.Action -or $_Debugger.Action -eq 'c' -or $_Debugger.Action -eq 'Continue') { $_Debugger.e.ResumeAction = 'Continue' return } ### StepInto if ($_Debugger.Action -eq 's' -or $_Debugger.Action -eq 'StepInto') { $_Debugger.e.ResumeAction = 'StepInto' return } ### StepOver if ($_Debugger.Action -eq 'v' -or $_Debugger.Action -eq 'StepOver') { $_Debugger.e.ResumeAction = 'StepOver' return } ### StepOut if ($_Debugger.Action -eq 'o' -or $_Debugger.Action -eq 'StepOut') { $_Debugger.e.ResumeAction = 'StepOut' return } ### Quit if ($_Debugger.Action -eq 'q' -or $_Debugger.Action -eq 'Quit') { $_Debugger.e.ResumeAction = 'Stop' return } ### Detach if ($_Debugger.Action -eq 'd' -or $_Debugger.Action -eq 'Detach') { if ($_Debugger.Handlers) { Write-Debugger 'd, Detach - not supported in this environment.' continue } $_Debugger.e.ResumeAction = 'Continue' Restore-Debugger return } ### history if ($_Debugger.Action -eq 'r') { Write-Debugger $_Debugger.History continue } ### stack if ($_Debugger.Action -ceq 'k') { Write-Debugger (Get-PSCallStack | Format-Table Command, Location, Arguments -AutoSize) continue } if ($_Debugger.Action -ceq 'K') { Write-Debugger (Get-PSCallStack | Format-List) continue } ### <number> if ([int]::TryParse($_Debugger.Action, $_Debugger.Context)) { Write-DebuggerInfo $_Debugger.e.InvocationInfo $_Debugger.Context.Value if ($_Debugger.Action[0] -eq "+") { $_Debugger.DefaultContext = $_Debugger.Context.Value } continue } ### watch if ($_Debugger.Action -eq 'w') { if ($_Debugger.Path) { Watch-Debugger $_Debugger.Path } else { Write-Debugger 'Debugger output file is not used.' } continue } ### help if ($_Debugger.Action -eq '?' -or $_Debugger.Action -eq 'h') { Write-Debugger (@( '' ' s, StepInto Step to the next statement into functions, scripts, etc.' ' v, StepOver Step to the next statement over functions, scripts, etc.' ' o, StepOut Step out of the current function, script, etc.' ' c, Continue Continue operation (also on empty input).' if (!$_Debugger.Handlers) { ' d, Detach Continue operation and detach the debugger.' } ' q, Quit Stop operation and exit the debugger.' ' ?, h Write this help message.' ' k Write call stack (Get-PSCallStack).' ' K Write detailed call stack using Format-List.' '' ' <n> Write debug location in context of <n> lines.' ' +<n> Set location context preference to <n> lines.' ' k <s> <n> Write source at stack <s> in context of <n> lines.' '' ' w Restart watching the debugger output file.' ' r Write last PowerShell commands invoked on debugging.' ' <command> Invoke any PowerShell <command> and write its output.' '' ) -join [System.Environment]::NewLine) continue } ### stack <s> <n> Set-Alias k debug_stack function debug_stack([Parameter()][int]$s, [int]$n) { $stack = @(Get-PSCallStack) if ($s -ge $stack.Count) { Write-Debugger 'Out of range of the call stack.' return } $1 = $stack[$s] if (!($file = $1.ScriptName)) { Write-Debugger 'The caller has no script file.' return } if ($n -le 0) {$n = 5} $markIndex = $1.ScriptLineNumber - 1 Write-Debugger $file Write-DebuggerFile $file ($markIndex - $n) (2 * $n + 1) $markIndex } ### invoke command try { $_Debugger.History.Remove($_Debugger.Action) $null = $_Debugger.History.Add($_Debugger.Action) Write-Debugger (.([scriptblock]::Create($_Debugger.Action))) } catch { Write-Debugger $(if ($_.InvocationInfo.ScriptName -like '*\Add-Debugger.ps1') {$_.ToString()} else {$_}) } } } |