Private/Schema.ps1
|
using namespace System.Management.Automation.Language # Schemas: .psd1 files loaded like any other file, then turned into rules. $script:PsdKeywords = @('Type', 'Required', 'Description', 'Default', 'Enum', 'Pattern', 'Min', 'Max', 'Properties', 'Items', 'AllowUnknown', 'Merge') $script:PsdBaseTypes = @('string', 'int', 'double', 'bool', 'map', 'array', 'any', 'null') function Find-PsdTreeNode { param($Tree, $Keys) $cur = $Tree foreach ($k in $Keys) { if ($null -eq $cur -or $cur.K -ne 'M' -or -not $cur.V.Contains($k)) { return $cur } $cur = $cur.V[$k] } $cur } function Read-PsdEnumFile { param([string] $Path, $Ctx, $From) $out = New-PsdList if (-not [IO.File]::Exists($Path)) { Add-PsdNodeError $Ctx 'PSD514' "the types file $Path doesn't exist." $From return , $out } $text = [IO.File]::ReadAllText($Path, [Text.Encoding]::UTF8) $tokens = $null; $errors = $null $ast = [Parser]::ParseInput($text, $Path, [ref]$tokens, [ref]$errors) if ($errors.Count -gt 0) { Add-PsdNodeError $Ctx 'PSD514' "the types file $Path can't be read: $($errors[0].Message)" $From return , $out } foreach ($t in $ast.FindAll({ param($a) $a -is [TypeDefinitionAst] -and $a.IsEnum }, $true)) { $members = New-PsdList $defs = New-PsdList foreach ($m in $t.Members) { $members.Add($m.Name) if ($m.InitialValue) { $defs.Add("$($m.Name) = $($m.InitialValue.Extent.Text)") } else { $defs.Add($m.Name) } } $attrs = '' foreach ($a in $t.Attributes) { $attrs += $a.Extent.Text + ' ' } $out.Add(@{ Name = $t.Name; Members = $members; Text = "${attrs}enum $($t.Name) { $($defs -join '; ') }"; Source = $Path; Flags = ($attrs -match 'Flags') }) } , $out } function Import-PsdSchemaRules { param([string] $Path, $Ctx, $From) if (-not [IO.File]::Exists($Path)) { Add-PsdNodeError $Ctx 'PSD511' "the schema file $Path doesn't exist." $From return $null } $sctx = New-PsdContext -Limits $Ctx.Limits $r = Invoke-PsdLoad -Path $Path -Ctx $sctx -SchemaMode foreach ($e in $sctx.Errors) { $Ctx.Errors.Add($e) } if ($sctx.Errors.Count -gt 0) { return $null } $enums = @{} foreach ($f in @($sctx.Files.Values)) { foreach ($t in $f.Types) { $p = [IO.Path]::GetFullPath([IO.Path]::Combine($f.Dir, $t.Path)) foreach ($e in (Read-PsdEnumFile $p $Ctx $t.Node)) { if ($enums.ContainsKey($e.Name) -and $enums[$e.Name].Source -ne $e.Source) { Add-PsdNodeError $Ctx 'PSD515' "the enum $($e.Name) is in two types files: $($enums[$e.Name].Source) and $($e.Source)." $t.Node continue } $enums[$e.Name] = $e } } } $s = @{ Ctx = $Ctx; Doc = $r.Doc; Enums = $enums; File = $r.Entry } $before = $Ctx.Errors.Count $rule = New-PsdRule $r.Value (New-PsdList) $s $true if ($Ctx.Errors.Count -gt $before) { return $null } $rule.SchemaFile = $r.Entry $rule } function Add-PsdSchemaError { param($S, [string] $Code, [string] $Message, $Keys, [string[]] $Hint = @()) $node = Find-PsdTreeNode $S.Doc.Tree $Keys if ($node -and $node.Line -gt 0) { Add-PsdNodeError $S.Ctx $Code $Message $node -Hint $Hint } else { Add-PsdError $S.Ctx $Code $Message -File $S.File -Hint $Hint } } function Get-PsdRuleName { param($Keys) $parts = New-PsdList for ($i = 0; $i -lt $Keys.Count; $i++) { if ($Keys[$i] -eq 'Properties' -and $i + 1 -lt $Keys.Count) { $parts.Add($Keys[$i + 1]); $i++ } elseif ($Keys[$i] -eq 'Items') { $parts.Add('[items]') } } if ($parts.Count -eq 0) { return 'the schema' } ($parts -join '.').Replace('.[items]', '[items]') } function ConvertFrom-PsdTypeText { param([string] $Text, $S, $Keys) $t = $Text.Trim() $list = $false if ($t.EndsWith('[]')) { $list = $true; $t = $t.Substring(0, $t.Length - 2) } $spec = @{ Name = $t; List = $list; Kind = 'base'; Members = $null; Source = $null; Def = $null; DotNet = $null; Text = $Text.Trim() } if ($t -match '^\[(.+)\]$') { $n = $Matches[1] if ($S.Enums.ContainsKey($n)) { $e = $S.Enums[$n] $spec.Kind = 'enum'; $spec.Name = $e.Name; $spec.Members = $e.Members; $spec.Source = $e.Source; $spec.Def = $e.Text return $spec } if ($n -match '^(system\.)?(version|guid|datetime)$') { $spec.Kind = 'conv' $spec.Name = $Matches[2].ToLowerInvariant() return $spec } $type = $n -as [type] if ($type -and $type.IsEnum) { $spec.Kind = 'enum'; $spec.Name = $n; $spec.DotNet = $type.FullName $spec.Members = New-PsdList foreach ($x in [enum]::GetNames($type)) { $spec.Members.Add($x) } return $spec } $known = @($S.Enums.PSBase.Keys) $hint = @((Get-PsdDidYouMean $n $known), "Your own enums need '!types' = './Types.psm1' in the schema.") Add-PsdSchemaError $S 'PSD513' "[$n] isn't a known enum or supported type." $Keys $hint return $null } $lower = $t.ToLowerInvariant() if ($script:PsdBaseTypes -contains $lower) { $spec.Name = $lower; return $spec } Add-PsdSchemaError $S 'PSD509' "'$t' isn't a type." $Keys @((Get-PsdDidYouMean $t $script:PsdBaseTypes), "Types: $($script:PsdBaseTypes -join ', '), or an enum like '[LogLevel]'.") $null } function New-PsdRule { param($Value, $Keys, $S, [bool] $IsRoot) $rule = @{ Types = New-PsdList; Required = $false; Description = $null; Default = $null; HasDefault = $false Enum = $null; Pattern = $null; Regex = $null; Min = $null; Max = $null Properties = $null; Items = $null; AllowUnknown = $false; Name = (Get-PsdRuleName $Keys); SchemaFile = $null Merge = $null } if ($Value -is [string]) { $Value = @{ Type = $Value } } if ($Value -isnot [System.Collections.IDictionary]) { Add-PsdSchemaError $S 'PSD500' "the rule for $($rule.Name) has to be a type like 'int', or a map like @{ Type = 'int' }." $Keys return $rule } $sub = { param($name) Join-PsdList $Keys $name } foreach ($k in $Value.PSBase.Keys) { if ($script:PsdKeywords -inotcontains $k) { Add-PsdSchemaError $S 'PSD500' "'$k' isn't a schema keyword (in $($rule.Name))." (& $sub $k) @((Get-PsdDidYouMean $k $script:PsdKeywords), "Keywords: $($script:PsdKeywords -join ', ').") } } if ($Value.Contains('Type')) { foreach ($x in @($Value['Type'])) { if ($x -isnot [string]) { Add-PsdSchemaError $S 'PSD500' "Type in $($rule.Name) has to be a type name or a list of them." (& $sub 'Type'); continue } $spec = ConvertFrom-PsdTypeText $x $S (& $sub 'Type') if ($spec) { $rule.Types.Add($spec) } } } elseif ($IsRoot) { $rule.Types.Add((ConvertFrom-PsdTypeText 'map' $S $Keys)) } foreach ($flag in 'Required', 'AllowUnknown') { if ($Value.Contains($flag)) { $x = $Value[$flag] if ($x -isnot [bool]) { Add-PsdSchemaError $S 'PSD500' "$flag in $($rule.Name) has to be `$true or `$false." (& $sub $flag) } else { $rule[$flag] = $x } } } if ($Value.Contains('Description')) { $rule.Description = [string]$Value['Description'] } if ($Value.Contains('Enum')) { $rule.Enum = New-PsdList foreach ($x in @($Value['Enum'])) { $rule.Enum.Add($x) } } if ($Value.Contains('Pattern')) { $rule.Pattern = [string]$Value['Pattern'] try { $rule.Regex = New-PsdRegex $rule.Pattern $S.Ctx -CaseSensitive } catch { Add-PsdSchemaError $S 'PSD507' "the Pattern of $($rule.Name) isn't a valid regex: $($_.Exception.InnerException.Message)" (& $sub 'Pattern') } } foreach ($mm in 'Min', 'Max') { if ($Value.Contains($mm)) { $x = $Value[$mm] if (-not (Test-PsdNumber $x)) { Add-PsdSchemaError $S 'PSD500' "$mm in $($rule.Name) has to be a number." (& $sub $mm) } else { $rule[$mm] = $x } } } if ($null -ne $rule.Min -and $null -ne $rule.Max -and $rule.Min -gt $rule.Max) { Add-PsdSchemaError $S 'PSD510' "Min ($($rule.Min)) is bigger than Max ($($rule.Max)) in $($rule.Name), so nothing can fit." (& $sub 'Min') } if ($Value.Contains('Properties')) { $p = $Value['Properties'] if ($p -isnot [System.Collections.IDictionary]) { Add-PsdSchemaError $S 'PSD500' "Properties in $($rule.Name) has to be a map of keys and their rules." (& $sub 'Properties') } else { $rule.Properties = [ordered]@{} foreach ($k in $p.PSBase.Keys) { $rule.Properties[$k] = New-PsdRule $p[$k] (Join-PsdList (& $sub 'Properties') $k) $S $false } } } if ($Value.Contains('Items')) { $rule.Items = New-PsdRule $Value['Items'] (& $sub 'Items') $S $false } if ($Value.Contains('Merge')) { # the default operator of the key, used when the data writes it without one $x = $Value['Merge'] $listOk = $rule.Types.Count -eq 0 foreach ($spec in $rule.Types) { if ($spec.List -or $spec.Name -in 'array', 'any') { $listOk = $true } } if ($x -notin $script:PsdDefaultOperators) { Add-PsdSchemaError $S 'PSD500' "Merge in $($rule.Name) has to be one of $($script:PsdDefaultOperators -join ', '), but it's $(Format-PsdValueShort $x)." (& $sub 'Merge') @('+=, =+ and |= add to a list, := replaces a map instead of merging it.') } elseif ($x -ne ':=' -and -not $listOk) { Add-PsdSchemaError $S 'PSD500' "Merge = '$x' in $($rule.Name) only works on lists, but its Type isn't one." (& $sub 'Merge') @("Give it a list type like 'string[]' or 'array'.") } else { $rule.Merge = [string]$x } } if ($Value.Contains('Default')) { $rule.HasDefault = $true $rule.Default = $Value['Default'] $tctx = New-PsdContext -Limits $S.Ctx.Limits Test-PsdValueRule $rule.Default $null $rule $rule.Name $tctx $null if ($tctx.Errors.Count -gt 0) { Add-PsdSchemaError $S 'PSD508' "the Default of $($rule.Name) doesn't fit its own rules: $($tctx.Errors[0].Message)" (& $sub 'Default') } } $rule } # --- Validation ------------------------------------------------------------------- function Test-PsdTypeSpec { param($Value, $Spec) if ($Spec.List) { if (-not (Test-PsdList $Value)) { return $false } $item = $Spec.Clone() $item.List = $false foreach ($x in $Value) { if (-not (Test-PsdTypeSpec $x $item)) { return $false } } return $true } switch ($Spec.Kind) { 'enum' { if ($Value -is [enum]) { $Value = $Value.ToString() } if ($Value -isnot [string]) { return $false } foreach ($m in $Spec.Members) { if ($m -ieq $Value) { return $true } } return $false } 'conv' { switch ($Spec.Name) { 'version' { if ($Value -is [version]) { return $true }; $o = $null; return ($Value -is [string] -and [version]::TryParse($Value, [ref]$o)) } 'guid' { if ($Value -is [guid]) { return $true }; $o = [guid]::Empty; return ($Value -is [string] -and [guid]::TryParse($Value, [ref]$o)) } 'datetime' { if ($Value -is [datetime]) { return $true } $o = [datetime]::MinValue return ($Value -is [string] -and [datetime]::TryParse($Value, $script:PsdInvariant, [System.Globalization.DateTimeStyles]::None, [ref]$o)) } } } } switch ($Spec.Name) { 'string' { return ($Value -is [string]) } 'int' { return ($Value -is [int] -or $Value -is [long] -or $Value -is [int16] -or $Value -is [byte] -or $Value -is [sbyte] -or $Value -is [uint16] -or $Value -is [uint32]) } 'double' { return (Test-PsdNumber $Value) } 'bool' { return ($Value -is [bool]) } 'map' { return ($Value -is [System.Collections.IDictionary]) } 'array' { return (Test-PsdList $Value) } 'any' { return $true } 'null' { return ($null -eq $Value) } } $false } function Get-PsdTypeSpecText { param($Spec) if ($Spec.List) { return "a list of $($Spec.Text.Substring(0, $Spec.Text.Length - 2))" } if ($Spec.Kind -eq 'enum') { return "one of $($Spec.Members -join ', ')" } if ($Spec.Kind -eq 'conv') { return "a $($Spec.Name)" } @{ string = 'text'; int = 'a whole number'; double = 'a number'; bool = '$true or $false'; map = 'a map'; array = 'a list'; any = 'anything'; null = '$null' }[$Spec.Name] } function Get-PsdRuleHint { param($Rule) if ($Rule.Description) { return @("About this key: $($Rule.Description)") } @() } function Add-PsdValueError { param($Ctx, [string] $Code, [string] $Message, $Node, [string[]] $Hint = @(), [switch] $AtKey) if ($null -eq $Node) { Add-PsdError $Ctx $Code $Message -Hint $Hint; return } if ($AtKey -and $Node.KeyLine -gt 0) { Add-PsdError $Ctx $Code $Message -File $Node.File -Line $Node.KeyLine -Column $Node.KeyCol -Length $Node.KeyLen -Key $Node.Path -Hint $Hint return } Add-PsdNodeError $Ctx $Code $Message $Node -Hint $Hint } function Get-PsdChildNode { param($Node, $Key) if ($null -eq $Node) { return $null } if ($Key -is [int]) { if ($Node.K -eq 'L' -and $Key -lt $Node.V.Count) { return $Node.V[$Key] } return $Node } if ($Node.K -eq 'M' -and $Node.V.Contains($Key)) { return $Node.V[$Key] } $Node } function Test-PsdValueRule { param($Value, $Node, $Rule, [string] $Path, $Ctx, $Doc, [switch] $NoRequired) $shown = $Path if (-not $shown) { $shown = 'The file' } $hint = Get-PsdRuleHint $Rule if ($Rule.Types.Count -gt 0) { $ok = $false foreach ($spec in $Rule.Types) { if (Test-PsdTypeSpec $Value $spec) { $ok = $true; break } } if (-not $ok) { $one = $Rule.Types[0] if ($Rule.Types.Count -eq 1 -and $one.Kind -eq 'enum' -and -not $one.List -and $Value -is [string]) { Add-PsdValueError $Ctx 'PSD504' "$shown can't be '$Value'." $Node (@("Allowed values: $($one.Members -join ', ')") + $hint) return } $want = @($Rule.Types | ForEach-Object { Get-PsdTypeSpecText $_ }) -join ' or ' Add-PsdValueError $Ctx 'PSD501' "$shown has to be $want, but it's $(Format-PsdValueShort $Value)." $Node $hint return } } if ($null -ne $Rule.Enum) { if (-not (Test-PsdListHas $Rule.Enum $Value)) { $vals = @($Rule.Enum | ForEach-Object { Format-PsdScalarText $_ }) -join ', ' Add-PsdValueError $Ctx 'PSD504' "$shown can't be $(Format-PsdValueShort $Value)." $Node (@("Allowed values: $vals") + $hint) return } } if ($Rule.Regex -and $Value -is [string]) { $matched = $true try { $matched = $Rule.Regex.IsMatch($Value) } catch { Add-PsdLimitError $Ctx 'RegexTimeout' "by the Pattern of $shown" return } if (-not $matched) { Add-PsdValueError $Ctx 'PSD505' "$shown is '$Value', but it has to match $($Rule.Pattern)." $Node $hint } } if ($null -ne $Rule.Min -or $null -ne $Rule.Max) { $size = $null; $what = $null if (Test-PsdNumber $Value) { $size = $Value } elseif ($Value -is [string]) { $size = $Value.Length; $what = 'characters long' } elseif (Test-PsdList $Value) { $size = $Value.Count; $what = 'items' } if ($null -ne $size) { $low = ($null -ne $Rule.Min -and $size -lt $Rule.Min) $high = ($null -ne $Rule.Max -and $size -gt $Rule.Max) if ($low -or $high) { $range = if ($null -ne $Rule.Min -and $null -ne $Rule.Max) { "between $($Rule.Min) and $($Rule.Max)" } elseif ($null -ne $Rule.Min) { "at least $($Rule.Min)" } else { "at most $($Rule.Max)" } if ($what) { $msg = "$shown is $size $what, but it has to be $range." } else { $msg = "$shown is $size, but it has to be $range." } Add-PsdValueError $Ctx 'PSD506' $msg $Node $hint } } } if ($Value -is [System.Collections.IDictionary]) { $props = $Rule.Properties foreach ($k in @($Value.PSBase.Keys)) { $child = Get-PsdChildNode $Node ([string]$k) $cp = Join-PsdPath $Path ([string]$k) if ($props -and $props.Contains($k)) { Test-PsdValueRule $Value[$k] $child $props[$k] $cp $Ctx $Doc -NoRequired:$NoRequired } elseif ($Rule.Items) { Test-PsdValueRule $Value[$k] $child $Rule.Items $cp $Ctx $Doc -NoRequired:$NoRequired } elseif ($props -and -not $Rule.AllowUnknown) { $where = if ($Path) { "$Path doesn't have a key called '$k' in the schema." } else { "the schema doesn't have a key called '$k'." } Add-PsdValueError $Ctx 'PSD503' $where $child @((Get-PsdDidYouMean ([string]$k) $props.PSBase.Keys)) -AtKey } } if ($props -and -not $NoRequired) { foreach ($k in $props.PSBase.Keys) { if ($props[$k].Required -and -not $Value.Contains($k)) { $cp = Join-PsdPath $Path $k $files = New-PsdList $file = $null if ($Doc) { $file = $Doc.Entry for ($i = $Doc.Layers.Count - 1; $i -ge 0; $i--) { $f = $Doc.Layers[$i].File; if (-not $f.Synthetic -or $f.Named) { $files.Add($f.Name + $f.Ext) } } } $extra = @() if ($files.Count -gt 0) { $extra = @("Looked in: $($files -join ', ')") } Add-PsdError $Ctx 'PSD502' "$cp is required, but none of these files set it." -File $file -Key $cp -Hint ($extra + (Get-PsdRuleHint $props[$k])) } } } } elseif ((Test-PsdList $Value) -and $Rule.Items) { for ($i = 0; $i -lt $Value.Count; $i++) { Test-PsdValueRule $Value[$i] (Get-PsdChildNode $Node $i) $Rule.Items (Join-PsdPath $Path $i) $Ctx $Doc -NoRequired:$NoRequired } } } # --- Defaults and inference ----------------------------------------------------------- function Add-PsdDefaultValues { param($Value, $Rule) if ($Value -is [System.Collections.IDictionary] -and $Rule.Properties) { foreach ($k in $Rule.Properties.PSBase.Keys) { $r = $Rule.Properties[$k] if (-not $Value.Contains($k)) { if ($r.HasDefault) { $Value[$k] = ConvertTo-PsdOutput (Copy-PsdPlain $r.Default) } } else { Add-PsdDefaultValues $Value[$k] $r } } } if ((Test-PsdList $Value) -and $Rule.Items) { foreach ($x in $Value) { Add-PsdDefaultValues $x $Rule.Items } } } function Copy-PsdPlain { param($Value) if ($Value -is [System.Collections.IDictionary]) { $o = [ordered]@{}; foreach ($k in $Value.PSBase.Keys) { $o[$k] = Copy-PsdPlain $Value[$k] }; return , $o } if (Test-PsdList $Value) { $l = New-PsdList; foreach ($x in $Value) { $l.Add((Copy-PsdPlain $x)) }; return , $l } , $Value } function Get-PsdInferredType { param($Value) if ($null -eq $Value) { return 'null' } if ($Value -is [string]) { return 'string' } if ($Value -is [bool]) { return 'bool' } if ($Value -is [int] -or $Value -is [long]) { return 'int' } if (Test-PsdNumber $Value) { return 'double' } if ($Value -is [System.Collections.IDictionary]) { return 'map' } if (Test-PsdList $Value) { return 'array' } 'any' } function New-PsdInferredRule { param($Value) $t = Get-PsdInferredType $Value if ($t -eq 'map') { $props = [ordered]@{} foreach ($k in $Value.PSBase.Keys) { $props[$k] = New-PsdInferredRule $Value[$k] } return [ordered]@{ Type = 'map'; Properties = $props } } if ($t -eq 'array') { $types = New-PsdList foreach ($x in $Value) { $it = Get-PsdInferredType $x; if (-not $types.Contains($it)) { $types.Add($it) } } if ($types.Count -eq 1 -and $types[0] -in 'string', 'int', 'double', 'bool') { return "$($types[0])[]" } if ($types.Count -eq 1 -and $types[0] -eq 'map') { $merged = [ordered]@{} foreach ($x in $Value) { foreach ($k in $x.PSBase.Keys) { if (-not $merged.Contains($k)) { $merged[$k] = $x[$k] } } } return [ordered]@{ Type = 'array'; Items = (New-PsdInferredRule $merged) } } return 'array' } $t } |