Last active
November 30, 2018 13:52
-
-
Save SteveL-MSFT/edf969ee676206a47d520a9032dcf6e3 to your computer and use it in GitHub Desktop.
ErrorRecord Format
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Define the PS code to embed in the XML below here. | |
$sb = { | |
# Make sure we control the specific strict mode in effect. | |
# (The strict-mode setting is inherited from the parent scope.) | |
Set-StrictMode -Version 1 | |
# Save the error record at hand as well as its invocation info. | |
$err = $_ | |
$myInv = $err.InvocationInfo | |
function append($s, $new, $sep = ': ') { $s + $(if ($s) { $sep } else { '' }) + $new } | |
switch ($ErrorView) { | |
'CategoryView' { $err.CategoryInfo.GetMessage(); break } | |
Default { # 'NormalView' | |
if ($err.FullyQualifiedErrorId -like 'NativeCommandError*') { # External-utility STDERR output - covers both 'NativeCommandError' and 'NativeCommandErrorMessage' | |
# NOTE: | |
# By default, stderr lines output by external utilities are NOT processed here, because they now go straight to the host. | |
# Only if you redirect stder output to the success stream, using 2>&1, are stderr lines processed here - one by one. | |
# The *first* stderr line is reported as 'NativeCommandError', and all subsequent lines as 'NativeCommandErrorMessage' | |
# Generally, external utilities prefix their error message with their executable name, such as 'FINDSTR: ...', so no addtional | |
# context is needed. | |
# However, invoking `cmd.exe` builtins with `cmd /c` does NOT (e.g., `cmd /c 'dir /z'`), so, if the error message doesn't start with "<someIdentifier>:", | |
# prepend the invoking command's name. | |
$msg = $err.Exception.Message | |
if ($err.FullyQualifiedErrorId -eq 'NativeCommandError' -and $msg -notmatch '^\w+:') { | |
$msg = $myInv.MyCommand.Name + ': ' + $msg | |
} | |
# Output the message. | |
$msg | |
} else { # PS error | |
# See if the error is a parsing error. | |
# !! Apparently, a parsing error that occurs *directly on the command line* results in an $err.Exception | |
# !! of type [System.Management.Automation.ParentContainsErrorRecordException] | |
# !! By contrast, if you inspect $Error[0] after provoking a parsing error directly on the command line | |
# !! you get a [System.Management.Automation.ParseException] *directly*, which *itself* also implements the | |
# !! [System.Management.Automation.IContainsErrorRecord] *interface*. | |
$isParseError = $err.Exception -is [System.Management.Automation.ParseException] -or $err.Exception -is [System.Management.Automation.ParentContainsErrorRecordException] | |
$isRemotingError = $err -is [System.Management.Automation.Runspaces.RemotingErrorRecord] # remoting error, including from jobs | |
$isScriptTerminatingError = [bool] $err.Exception.WasThrownFromThrowStatement # $err.CategoryInfo.Category -eq 'OperationStopped' | |
# Identify the *immediate* source of the error: | |
$source = '' | |
if ($isParseError) { | |
# Clearly mark parsing errors as such. | |
# NOTE: ?? This string will have to be localized. | |
$source = 'PARSE ERROR' | |
# If $myInv.MyCommand.Name has a value, the implication is that the exception | |
# is an *indirectly* triggered parse error, via Invoke-Expression, so we indicate that. | |
# Otherwise, we rely on the exception message to provide sufficient information. | |
if ($myInv.MyCommand.Name) { | |
$source = append $source $myInv.MyCommand.Name | |
} | |
} else { | |
if ($isScriptTerminatingError) { | |
# Clearly mark a script-terminating error (triggered with Throw) as such. | |
# NOTE: ?? This string will have to be localized. | |
$source = 'STOPPED' | |
} | |
if ($isRemotingError) { # remoting error (including from jobs) | |
# Prepend the name of the originating computer. | |
$source = append $source ("[{0}]" -f $err.OriginInfo.PSComputerName) | |
# Add the command name, if a command caused the error. | |
if ($err.CategoryInfo.Activity) { # Contains the command name, if a *command* caused the error. | |
$source = $source + ' ' + $err.CategoryInfo.Activity | |
} | |
} elseif ($myInv.MyCommand.Name) { | |
# If a *command* is the error source: this generally evaluates to the resolved underlying cmdlet/function name, | |
# even if the invocation used an alias or & or . | |
$source = append $source $myInv.MyCommand.Name | |
# If the invocation name was different - i.e., if an alias was used - append the alias name (e.g., 'Get-Item (gi)') | |
# ?? This doesn't always work, notably not with . and & (in which case $myInv.InvocationName contains '.' and '&', | |
# !! and with statement-terminating errors such as `Get-Item -NoSuchParam`. | |
# !! Examining .Line is not an option, because unrelated statements may preceded the offending one on the same line. | |
if ($myInv.InvocationName -and $myInv.InvocationName -notmatch '[.&]' -and $myInv.InvocationName -ne $myInv.MyCommand.Name) { | |
$source += ' ({0})' -f $myInv.InvocationName | |
} | |
} elseif ($myInv) { # expression such as `1/0` or `& { 1/0 }` or Throw statements | |
$source = "ERROR" | |
} | |
} | |
$context = '' | |
# Transform the message to a single-line string. | |
if ($isParseError) { | |
# Parse errors have multi-line messages in the following format: | |
# At ....:<line> char:<col> | |
# + <broken-part-of-statement> | |
# + ~ | |
# where the "~" indicates the precise location of the error. | |
# We reformat this message to a single line | |
# $contextPart, $offendingLinePart, $indicatorLine, $reason, $rest = $err.ToString() -split '\r?\n' | |
# We omit the "~" line and directly append '<<<' to the offending part of the | |
# line and also strip the '+ ' prefix, then append the specific parsing error message. | |
# !! There could be MULTIPLE parsing errors, however, we only report the FIRST. | |
# ?? Is this a reason to *always* present parsing errors as multi-line errors? | |
# $msg = '{0}<<< {1}' -f ($offendingLinePart -replace '^[+]\s+'), $reason | |
$msg = $err.Exception.Errors[0].Message | |
$extent = $err.Exception.Errors[0].Extent.Text | |
$context = $myInv.Line.Replace($extent, "`e[35m$extent`e[33m") | |
} else { | |
# All other errors: replace newlines with spaces to output a single line. | |
$msg = $err.ToString() -replace '\r?\n', ' ' | |
if ($myInv.PositionMessage) { | |
$errorLength = $myInv.PositionMessage.Split("+")[2].Trim().Length | |
for ($i = 0; $i -lt $myInv.Line.Length; $i++ ) | |
{ | |
if ($i -eq $myInv.OffsetInLine - 1) | |
{ | |
$context += "`e[35m" | |
} | |
elseif ($i -eq ($myInv.OffsetInLine + $errorLength - 1)) | |
{ | |
$context += "`e[33m" | |
} | |
$context += $myInv.Line[$i]; | |
} | |
} | |
} | |
# ?? When is $err.PSMessageDetails ever filled and does it matter? | |
# Output the synthesized message as a *single-line* string. | |
# Note that no effort is made to word-wrap overly long lines: | |
# * Hopefully, most terminals are wide enough to fit most errors on 1 line | |
# * When redirecting to a file / transcribing, processing is easier if every | |
# error can be assumed to occupy just 1 line. | |
$combinedMsg = "{0}: `e[35m{1}" -f $source, $msg | |
if ($context) { | |
$combinedMsg += "`nLine: `e[33m{0}" -f $context.Trim() | |
} | |
$combinedMsg | |
} | |
} | |
} | |
} | |
# Create a *.ps1xml file on the fly, using a fixed-per-session file path | |
# based on the process ID. This is helpful, because PS remembers previously | |
# loaded *.ps1xml files and tries to reload them, so if we used a different | |
# path every time (and deleted the file after), every subsequent | |
# Update-FormatData call would complain about missing files. | |
@" | |
<Configuration> | |
<ViewDefinitions> | |
<View> | |
<Name>ErrorInstance</Name> | |
<OutOfBand /> | |
<ViewSelectedBy> | |
<TypeName>System.Management.Automation.ErrorRecord</TypeName> | |
</ViewSelectedBy> | |
<CustomControl> | |
<CustomEntries> | |
<CustomEntry> | |
<CustomItem> | |
<ExpressionBinding> | |
<ScriptBlock> | |
<![CDATA[ | |
$sb | |
]]> | |
</ScriptBlock> | |
</ExpressionBinding> | |
</CustomItem> | |
</CustomEntry> | |
</CustomEntries> | |
</CustomControl> | |
</View> | |
</ViewDefinitions> | |
</Configuration> | |
"@ > ($tmpFile = [io.path]::GetTempPath() + "$PID.ps1xml") | |
# Load the format data via -PrependPath, which *preempts* preloaded definitions. | |
Update-FormatData -PrependPath $tmpFile | |
# Clean up. | |
Remove-Item $tmpFile | |
# === Create a test script that provokes various errors | |
$sbs = { | |
# Expression-statement-terminating error (from an all-PS expression) | |
1 / 0 | |
}, | |
{ | |
# Expression-statement-terminating error (from a .NET method call) | |
[int]::Parse('not-a-number') | |
}, | |
{ | |
# Non-terminating error | |
Get-Item /NoSuchItem | |
}, | |
{ | |
# Pipeline-statement-terminating error | |
Get-Item -NoSuchParam | |
}, | |
{ | |
# Indirect parse error, via Invoke-Expression. (If we tried ``1 * 2)`` directly in a script, the whole script would fail to parse.) | |
Invoke-Expression '1 * 2)' | |
}, | |
{ | |
# Remoting (job) error | |
Receive-Job -Wait -AutoRemoveJob (Start-Job { Get-Item /NoSuchItem }) | |
}, | |
{ | |
# Script-terminating error, via Throw | |
Remove-Item $PSCommandPath # Since the Throw statement below terminates this temp. script, we self-delete it first here. | |
Throw "Not at $HOME" | |
} | |
# !! Even the current 'NormalView' view makes no attempt to identify | |
# !! the *function* inside a script in which the error occurred. | |
# { | |
# # Error inside a function | |
# Function Foo { Get-Item /NoSuchItem }; Foo | |
# } | |
$tmpScript = [io.path]::GetTempPath() + "$PID.ps1" | |
$sbs | % { | |
@" | |
'$(($_.ToString() -replace "'", "''").Trim())' | |
$($_.Tostring()) | |
"" | |
"@ | |
} > $tmpScript | |
# Write-Verbose -Verbose $tmpScript | |
# Invoke the temp. script to show how the errors are formatted. | |
# !! Do not use try / finally here, because the first terminating error | |
# !! would trigger the finally block. | |
& $tmpScript | |
# Since the Throw statement inside the temp. script aborts this script too, | |
# we never get here. | |
# Remove-Item $tmpScript |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment