Private/Errors.ps1
|
# Context, error collection, message format, and small shared helpers. $script:PsdIsWindows = [System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT $script:PsdNextId = 0 $script:PsdInvariant = [System.Globalization.CultureInfo]::InvariantCulture $script:PsdDefaultLimits = [ordered]@{ MaxDepth = 64 MaxFiles = 256 MaxFileSize = 100MB MaxStringLength = 1MB MaxNodes = 100000 RegexTimeout = 1 } function New-PsdContext { param( [System.Collections.IDictionary] $Limits, [string] $Unresolved = 'Error' ) $l = Copy-PsdPlain $script:PsdDefaultLimits if ($Limits) { foreach ($k in $Limits.PSBase.Keys) { $name = [string]$k if (-not $l.Contains($name)) { throw "'$name' isn't a limit. Limits: $(@($script:PsdDefaultLimits.PSBase.Keys) -join ', ')." } $value = $Limits[$k] if ($name -eq 'RegexTimeout' -and $value -is [timespan]) { $value = $value.TotalSeconds } if (-not (Test-PsdNumber $value) -or $value -le 0) { throw "The limit '$name' has to be a number above 0." } $l[$name] = $value } } @{ Errors = New-PsdList Warnings = New-PsdList # where a value came from when it could have come from more than one place; written with -Verbose Notes = New-PsdList NoteSeen = @{} # -Loader: reads files that aren't .psd1, like .ps1 scripts; PsdUtil itself runs no code Loader = $null Limits = $l Files = @{} Docs = @{} DocStack = New-PsdList Unresolved = $Unresolved Nodes = 0 SchemaMode = $false Trace = $null LimitHit = $false Seen = @{} } } function New-PsdId { $script:PsdNextId++ $script:PsdNextId } function Add-PsdError { param( $Ctx, [string] $Code, [string] $Message, $File = $null, [int] $Line = 0, [int] $Column = 0, [int] $Length = 0, [string] $Key = $null, [string[]] $Hint = @(), [string[]] $Extra = @() ) # The same problem can be reached twice (a shared file, a cached value). Report it once. $fileText = if ($File) { $File.Path } else { '' } $sig = '{0}|{1}|{2}|{3}|{4}' -f $Code, $fileText, $Line, $Column, $Message if ($Ctx.Seen.ContainsKey($sig)) { return } $Ctx.Seen[$sig] = $true $Ctx.Errors.Add(@{ Code = $Code Message = $Message File = $File Line = $Line Column = $Column Length = $Length Key = $Key Hint = $Hint Extra = $Extra }) } function Add-PsdLimitError { param($Ctx, [string] $Limit, [string] $Where, $Node = $null) $Ctx.LimitHit = $true $loc = Get-PsdNodeLoc $Node Add-PsdError $Ctx 'PSD700' "the limit $Limit ($($Ctx.Limits[$Limit])) was reached $Where." -File $loc.File -Line $loc.Line -Column $loc.Col ` -Hint @("If this is on purpose, raise it: -Limits @{ $Limit = ... }") } function Format-PsdErrorText { param($Info) $lines = New-Object 'System.Collections.Generic.List[string]' $head = $Info.Message if ($Info.File) { $leaf = $Info.File.Name + $Info.File.Ext if ($Info.File.Synthetic) { $leaf = $Info.File.Label } if ($Info.Line -gt 0) { $head = "$leaf, line $($Info.Line): $head" } else { $head = "${leaf}: $head" } } elseif ($head.Length -gt 0) { $head = $head.Substring(0, 1).ToUpperInvariant() + $head.Substring(1) } $lines.Add($head) $body = New-Object 'System.Collections.Generic.List[string]' if ($Info.File -and $Info.Line -gt 0 -and $Info.File.Lines -and $Info.Line -le $Info.File.Lines.Count) { $src = $Info.File.Lines[$Info.Line - 1].TrimEnd() $trimmed = $src.TrimStart() $lead = $src.Length - $trimmed.Length $body.Add(' ' + $trimmed) if ($Info.Column -gt 0) { $offset = [Math]::Max(0, $Info.Column - 1 - $lead) $len = [Math]::Max(1, $Info.Length) if ($offset + $len -gt $trimmed.Length) { $len = [Math]::Max(1, $trimmed.Length - $offset) } $body.Add(' ' + (' ' * $offset) + ('^' * $len)) } } foreach ($x in $Info.Extra) { $body.Add(' ' + $x) } foreach ($h in $Info.Hint) { if ($h) { $body.Add(' ' + $h) } } if ($body.Count -gt 0) { $lines.Add('') foreach ($b in $body) { $lines.Add($b) } } $code = "[$($Info.Code)]" $last = $lines[$lines.Count - 1] if ($last.Length + 2 + $code.Length -le 76) { $last = $last.PadRight(76 - $code.Length) + $code } else { $last = $last + ' ' + $code } $lines[$lines.Count - 1] = $last $lines -join [Environment]::NewLine } function ConvertTo-PsdErrorObject { param($Info) [pscustomobject]@{ PSTypeName = 'PsdUtil.Error' Code = $Info.Code File = $(if ($Info.File) { $Info.File.Path }) Line = $Info.Line Column = $Info.Column Key = $Info.Key Message = Format-PsdErrorText $Info } | Add-Member -MemberType ScriptMethod -Name ToString -Value { $this.Message } -Force -PassThru } function New-PsdErrorRecord { param($Info) $ex = New-Object System.Exception (Format-PsdErrorText $Info) New-Object System.Management.Automation.ErrorRecord $ex, $Info.Code, ([System.Management.Automation.ErrorCategory]::InvalidData), (ConvertTo-PsdErrorObject $Info) } # Writes every collected error and warning through the calling command. function Write-PsdResult { param($Cmdlet, $Ctx, [string] $What) foreach ($n in $Ctx.Notes) { $Cmdlet.WriteVerbose($n) } foreach ($w in $Ctx.Warnings) { $Cmdlet.WriteWarning($w) } foreach ($e in $Ctx.Errors) { $Cmdlet.WriteError((New-PsdErrorRecord $e)) } if ($Ctx.Errors.Count -gt 1 -and $What) { $Cmdlet.WriteWarning("Found $($Ctx.Errors.Count) problems in $What. Nothing was loaded.") } } # --- Suggestions -------------------------------------------------------------- function Get-PsdDistance { param([string] $First, [string] $Second) # Edit distance where swapping two neighbouring letters counts as one edit. $s = $First.ToLowerInvariant(); $t = $Second.ToLowerInvariant() $n = $s.Length; $m = $t.Length $w = $m + 1 $d = New-Object 'int[]' (($n + 1) * $w) for ($i = 0; $i -le $n; $i++) { $d[$i * $w] = $i } for ($j = 0; $j -le $m; $j++) { $d[$j] = $j } for ($i = 1; $i -le $n; $i++) { for ($j = 1; $j -le $m; $j++) { $cost = 1 if ($s[$i - 1] -eq $t[$j - 1]) { $cost = 0 } $up = $d[($i - 1) * $w + $j] + 1 $left = $d[$i * $w + $j - 1] + 1 $diag = $d[($i - 1) * $w + $j - 1] + $cost $v = [Math]::Min([Math]::Min($up, $left), $diag) if ($i -gt 1 -and $j -gt 1 -and $s[$i - 1] -eq $t[$j - 2] -and $s[$i - 2] -eq $t[$j - 1]) { $swap = $d[($i - 2) * $w + $j - 2] + 1 $v = [Math]::Min($v, $swap) } $d[$i * $w + $j] = $v } } $d[$n * $w + $m] } function Get-PsdDidYouMean { param([string] $Word, $Candidates) $best = $null; $bestD = [int]::MaxValue $limit = [Math]::Max(2, [int][Math]::Floor($Word.Length / 3)) foreach ($c in $Candidates) { $s = [string]$c if ($s -eq $Word) { continue } $dist = Get-PsdDistance $Word $s if ($dist -lt $bestD) { $bestD = $dist; $best = $s } } if ($best -and $bestD -le $limit) { "Did you mean '$best'?" } } # --- Values ------------------------------------------------------------------- function Test-PsdNumber { param($Value) $Value -is [int] -or $Value -is [long] -or $Value -is [double] -or $Value -is [decimal] -or $Value -is [single] -or $Value -is [byte] -or $Value -is [sbyte] -or $Value -is [int16] -or $Value -is [uint16] -or $Value -is [uint32] -or $Value -is [uint64] } function Test-PsdList { param($Value) ($Value -is [System.Collections.IList]) -and ($Value -isnot [string]) } function Test-PsdContainer { param($Value) ($Value -is [System.Collections.IDictionary]) -or (Test-PsdList $Value) } function Get-PsdKindText { param($Value) if ($null -eq $Value) { return '$null' } if ($Value -is [string]) { return 'text' } if ($Value -is [bool]) { return 'true or false' } if (Test-PsdNumber $Value) { return 'a number' } if ($Value -is [System.Collections.IDictionary]) { return 'a map' } if (Test-PsdList $Value) { return 'a list' } 'a ' + $Value.GetType().Name } function Format-PsdScalarText { param($Value) if ($null -eq $Value) { return '' } if ($Value -is [string]) { return $Value } if ($Value -is [double]) { return $Value.ToString('R', $script:PsdInvariant) } if ($Value -is [System.IFormattable]) { return $Value.ToString($null, $script:PsdInvariant) } [string]$Value } function Format-PsdValueShort { param($Value) if ($null -eq $Value) { return '$null' } if ($Value -is [string]) { return "'$Value'" } if ($Value -is [bool]) { if ($Value) { return '$true' } return '$false' } if (Test-PsdNumber $Value) { return (Format-PsdScalarText $Value) } Get-PsdKindText $Value } # Equality for list operators, unique, -in, Enum and Compare-Psd. Text ignores case unless told otherwise. function Test-PsdEqual { param($A, $B, [System.StringComparison] $Text = 'OrdinalIgnoreCase') if ($null -eq $A -or $null -eq $B) { return ($null -eq $A -and $null -eq $B) } if ($A -is [System.Collections.IDictionary]) { if ($B -isnot [System.Collections.IDictionary] -or $A.PSBase.Count -ne $B.PSBase.Count) { return $false } foreach ($k in $A.PSBase.Keys) { if (-not $B.Contains($k) -or -not (Test-PsdEqual $A[$k] $B[$k] $Text)) { return $false } } return $true } if (Test-PsdList $A) { if (-not (Test-PsdList $B) -or $A.Count -ne $B.Count) { return $false } for ($i = 0; $i -lt $A.Count; $i++) { if (-not (Test-PsdEqual $A[$i] $B[$i] $Text)) { return $false } } return $true } if ($A -is [string] -and $B -is [string]) { return [string]::Equals($A, $B, $Text) } if ((Test-PsdNumber $A) -and (Test-PsdNumber $B)) { return ([double]$A -eq [double]$B) } if ($A.GetType() -ne $B.GetType()) { return $false } $A.Equals($B) } function Test-PsdListHas { param($List, $Item) foreach ($x in $List) { if (Test-PsdEqual $x $Item) { return $true } } $false } function New-PsdList { New-Object 'System.Collections.Generic.List[object]' } # A new list: the items of List ($null is fine), then Item. function Join-PsdList { param($List, $Item) $out = New-PsdList foreach ($x in $List) { $out.Add($x) } $out.Add($Item) , $out } # What can stand for a whole file or map: a map, a PSCustomObject or a generated class made from a map. function Test-PsdMapLike { param($Value) $Value -is [System.Collections.IDictionary] -or $Value -is [System.Management.Automation.PSCustomObject] -or $null -ne (Get-PsdKeptMap $Value) } function New-PsdRegex { param([string] $Pattern, $Ctx, [switch] $CaseSensitive) $opt = [System.Text.RegularExpressions.RegexOptions]'IgnoreCase, CultureInvariant' if ($CaseSensitive) { $opt = [System.Text.RegularExpressions.RegexOptions]::CultureInvariant } New-Object System.Text.RegularExpressions.Regex $Pattern, $opt, ([timespan]::FromSeconds($Ctx.Limits.RegexTimeout)) } function Get-PsdPathKey { param([string] $Full) if ($script:PsdIsWindows) { return $Full.ToLowerInvariant() } $Full } function ConvertTo-PsdSlashPath { param([string] $Path) $Path.Replace('\', '/') } function Get-PsdOsName { if ($script:PsdIsWindows) { return 'windows' } $mac = Get-Variable -Name IsMacOS -ValueOnly -ErrorAction Ignore if ($mac) { return 'macos' } 'linux' } # The items of a list as a new list, or a one-item list. @($list) fails on generic lists. function Get-PsdItems { param($Value) $out = New-PsdList if (Test-PsdList $Value) { foreach ($x in $Value) { $out.Add($x) } } else { $out.Add($Value) } , $out } |