diff --git a/README.md b/README.md index 7b2456d..9b1b453 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ The module exports: | `ConvertFrom-Yaml` | Parse one or more YAML documents into PowerShell values. | | `ConvertTo-Yaml` | Serialize supported PowerShell values as YAML 1.2-compatible text. | | `Export-Yaml` | Serialize values and atomically write one YAML file. | +| `Format-Yaml` | Normalize YAML streams without projecting representation nodes to PowerShell values. | | `Import-Yaml` | Strictly decode and parse YAML files. | | `Test-Yaml` | Test YAML syntax, tags, duplicate keys, and configured resource limits. | @@ -143,6 +144,36 @@ Repeated acyclic collection references are emitted with anchors and aliases. Cyclic graphs and unsupported runtime objects fail specifically; values are never silently truncated or converted with `ToString()`. +## Format YAML streams + +`Format-Yaml` normalizes existing YAML without converting it through +`PSCustomObject` or dictionary values. It retains document order and empty +documents, node kinds, scalar content, effective tags, anchors and aliases, +recursive graphs, complex keys, collection structure, and mapping order. + +```powershell +$normalized = Get-Content -Path '.\config.yaml' | Format-Yaml -Indent 4 +``` + +Pipeline records are joined with LF and parsed as one stream. The output is one +string with LF line endings and no final newline. Every document starts with +`---`; document-end markers, comments, directives, flow presentation, scalar +styles, and original anchor names are normalized. Effective standard tags use +`!!` shorthand where possible, while local and global tags use a deterministic +verbatim form. + +Formatting is byte-idempotent at the same options: + +```powershell +$normalized -ceq ($normalized | Format-Yaml -Indent 4) +``` + +`-Indent` accepts 2 through 9 spaces. The `-Depth`, `-MaxNodes`, `-MaxAliases`, +`-MaxScalarLength`, `-MaxTagLength`, `-MaxTotalTagLength`, and +`-MaxNumericLength` defaults and ranges match `ConvertFrom-Yaml`. Invalid YAML, +duplicate representation keys, undefined aliases, malformed tags, and resource +limit violations terminate with the same classified YAML errors as parsing. + ## Export YAML files `Export-Yaml` aggregates pipeline records like `ConvertTo-Yaml`, serializes the @@ -217,6 +248,7 @@ The archive contains 402 inputs: | `out.yaml` projection | 241 | 1 | 0 | 160 | | Official `emit.yaml` fixtures | 55 | 0 | 0 | 347 | | Module self-round-trip | 305 | 3 | 0 | 94 | +| `Format-Yaml` representation and idempotence | 400 | 0 | 0 | 2 | All 94 fixtures marked invalid are rejected. The valid `2JQS` and `X38W` inputs are syntactically recognized and produce matching representation @@ -225,6 +257,13 @@ mapping keys must be unique. They are not unsupported grammar. Both are reported as policy differences for module self-round-trip; `X38W`, the one case with an `out.yaml` fixture, is also reported that way on that surface. +The formatter surface directly compares representation events and graph +identity before and after formatting, validates the emitted stream, and +requires byte-identical second formatting. It passes all 306 loadable valid +inputs and confirms that all 94 invalid inputs remain rejected. The two valid +duplicate-key policy cases are not applicable because `Format-Yaml` applies +the same representation-key uniqueness policy as `ConvertFrom-Yaml`. + The two JSON projection differences are `565N`, where `!!binary` intentionally becomes `byte[]` instead of a Base64 string, and `J7PZ`, where legacy `!!omap` intentionally becomes `System.Collections.Specialized.OrderedDictionary` diff --git a/examples/General.ps1 b/examples/General.ps1 index 2931f45..050afc6 100644 --- a/examples/General.ps1 +++ b/examples/General.ps1 @@ -40,6 +40,13 @@ $outputYaml = [ordered]@{ $outputYaml $outputYaml | ConvertFrom-Yaml +# Normalize YAML presentation without projecting its representation graph. +$normalizedYaml = @' +# Presentation differences are removed. +{ name: example, ports: [80, 443] } +'@ | Format-Yaml -Indent 4 +$normalizedYaml + # Atomically export one file, then import it with strict decoding. $configPath = Join-Path $env:TEMP 'yaml-example.yaml' $config | Export-Yaml -Path $configPath -PassThru diff --git a/src/functions/private/ConvertFrom-YamlTagUriEscape.ps1 b/src/functions/private/ConvertFrom-YamlTagUriEscape.ps1 index 75b3cdd..02008c8 100644 --- a/src/functions/private/ConvertFrom-YamlTagUriEscape.ps1 +++ b/src/functions/private/ConvertFrom-YamlTagUriEscape.ps1 @@ -13,7 +13,11 @@ function ConvertFrom-YamlTagUriEscape { [pscustomobject] $Mark, [Parameter(Mandatory)] - [string] $Token + [string] $Token, + + [Parameter(Mandatory)] + [ValidateRange(1, 1048576)] + [int] $MaxLength ) $builder = [System.Text.StringBuilder]::new() @@ -25,14 +29,27 @@ function ConvertFrom-YamlTagUriEscape { if ($character -ne '%') { if ($escapedBytes.Count -gt 0) { try { - [void] $builder.Append($utf8.GetString($escapedBytes.ToArray())) + $decoded = $utf8.GetString($escapedBytes.ToArray()) } catch [System.Text.DecoderFallbackException] { throw (New-YamlException -Start $Mark -End $Mark -ErrorId 'YamlInvalidTag' -Message ( "The tag token '$Token' contains an invalid UTF-8 escape sequence." )) } + if ($builder.Length + $decoded.Length -gt $MaxLength) { + throw (New-YamlException -Start $Mark -End $Mark ` + -ErrorId 'YamlTagLimitExceeded' -Message ( + "A YAML tag exceeds the configured limit of $MaxLength characters." + )) + } + [void] $builder.Append($decoded) $escapedBytes.Clear() } + if ($builder.Length -ge $MaxLength) { + throw (New-YamlException -Start $Mark -End $Mark ` + -ErrorId 'YamlTagLimitExceeded' -Message ( + "A YAML tag exceeds the configured limit of $MaxLength characters." + )) + } [void] $builder.Append($character) continue } @@ -54,12 +71,19 @@ function ConvertFrom-YamlTagUriEscape { if ($escapedBytes.Count -gt 0) { try { - [void] $builder.Append($utf8.GetString($escapedBytes.ToArray())) + $decoded = $utf8.GetString($escapedBytes.ToArray()) } catch [System.Text.DecoderFallbackException] { throw (New-YamlException -Start $Mark -End $Mark -ErrorId 'YamlInvalidTag' -Message ( "The tag token '$Token' contains an invalid UTF-8 escape sequence." )) } + if ($builder.Length + $decoded.Length -gt $MaxLength) { + throw (New-YamlException -Start $Mark -End $Mark ` + -ErrorId 'YamlTagLimitExceeded' -Message ( + "A YAML tag exceeds the configured limit of $MaxLength characters." + )) + } + [void] $builder.Append($decoded) } $builder.ToString() diff --git a/src/functions/private/ConvertTo-YamlRepresentationNode.ps1 b/src/functions/private/ConvertTo-YamlRepresentationNode.ps1 new file mode 100644 index 0000000..745e537 --- /dev/null +++ b/src/functions/private/ConvertTo-YamlRepresentationNode.ps1 @@ -0,0 +1,114 @@ +function ConvertTo-YamlRepresentationNode { + <# + .SYNOPSIS + Converts one representation graph to a lossless emission graph. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node, + + [Parameter(Mandatory)] + [pscustomobject] $State + ) + + $orderedNodes = [System.Collections.Generic.List[object]]::new() + $visited = [System.Collections.Generic.HashSet[int]]::new() + $aliasTargets = [System.Collections.Generic.HashSet[int]]::new() + $pending = [System.Collections.Generic.Stack[object]]::new() + $pending.Push($Node) + + while ($pending.Count -gt 0) { + $current = $pending.Pop() + if ($current.Kind -eq 'Alias') { + [void] $aliasTargets.Add($current.Target.Id) + if (-not $visited.Contains($current.Target.Id)) { + $pending.Push($current.Target) + } + continue + } + if (-not $visited.Add($current.Id)) { + continue + } + + $orderedNodes.Add($current) + if ($current.Kind -eq 'Sequence') { + for ($index = $current.Items.Count - 1; $index -ge 0; $index--) { + $pending.Push($current.Items[$index]) + } + } elseif ($current.Kind -eq 'Mapping') { + for ($index = $current.Entries.Count - 1; $index -ge 0; $index--) { + $pending.Push($current.Entries[$index].Value) + $pending.Push($current.Entries[$index].Key) + } + } + } + + $anchorNames = [System.Collections.Generic.Dictionary[int, string]]::new() + foreach ($source in $orderedNodes) { + if (-not [string]::IsNullOrEmpty($source.Anchor) -or + $aliasTargets.Contains($source.Id)) { + $anchorNames[$source.Id] = 'id{0:d3}' -f $State.NextAnchor + $State.NextAnchor++ + } + } + + $nodes = [System.Collections.Generic.Dictionary[int, object]]::new() + foreach ($source in $orderedNodes) { + $target = New-YamlEmissionNode -Kind $source.Kind + $target.Tag = [string] $source.Tag + $target.HasUnknownTag = [bool] $source.HasUnknownTag + if ($anchorNames.ContainsKey($source.Id)) { + $target.Anchor = $anchorNames[$source.Id] + $target.ReferenceId = [long] $source.Id + } + + if ($source.Kind -eq 'Scalar') { + $target.Value = [string] $source.Value + $target.Style = 'DoubleQuoted' + if ([string]::IsNullOrEmpty($source.Tag) -and + -not $source.HasUnknownTag -and $source.IsPlainImplicit) { + $resolved = (Resolve-YamlScalar -Node $source).Value + $effectiveTag = Get-YamlEffectiveTag -Node $source -Value $resolved + if ($effectiveTag -cne 'tag:yaml.org,2002:str') { + $target.Style = 'Plain' + } + } + } + $nodes[$source.Id] = $target + } + + foreach ($source in $orderedNodes) { + $target = $nodes[$source.Id] + if ($source.Kind -eq 'Sequence') { + foreach ($item in $source.Items) { + $itemId = if ($item.Kind -eq 'Alias') { + $item.Target.Id + } else { + $item.Id + } + $target.Items.Add($nodes[$itemId]) + } + } elseif ($source.Kind -eq 'Mapping') { + foreach ($entry in $source.Entries) { + $keyId = if ($entry.Key.Kind -eq 'Alias') { + $entry.Key.Target.Id + } else { + $entry.Key.Id + } + $valueId = if ($entry.Value.Kind -eq 'Alias') { + $entry.Value.Target.Id + } else { + $entry.Value.Id + } + $target.Entries.Add([pscustomobject]@{ + Key = $nodes[$keyId] + Value = $nodes[$valueId] + }) + } + } + } + + Write-Output -InputObject $nodes[$Node.Id] -NoEnumerate +} diff --git a/src/functions/private/ConvertTo-YamlRepresentationText.ps1 b/src/functions/private/ConvertTo-YamlRepresentationText.ps1 new file mode 100644 index 0000000..9627812 --- /dev/null +++ b/src/functions/private/ConvertTo-YamlRepresentationText.ps1 @@ -0,0 +1,31 @@ +function ConvertTo-YamlRepresentationText { + <# + .SYNOPSIS + Emits representation documents as deterministic LF-normalized YAML. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [object[]] $Documents, + + [Parameter(Mandatory)] + [ValidateRange(2, 9)] + [int] $Indent + ) + + if ($Documents.Count -eq 0) { + return '' + } + + $state = [pscustomobject]@{ NextAnchor = 1 } + $renderedDocuments = [System.Collections.Generic.List[string]]::new() + foreach ($document in $Documents) { + $emissionNode = ConvertTo-YamlRepresentationNode -Node $document -State $state + $text = ConvertTo-YamlText -Node $emissionNode -Indent $Indent -ExplicitDocumentStart + $text = $text -replace '[ \t]+(?=\n|$)', '' + $renderedDocuments.Add($text.TrimEnd("`n")) + } + return $renderedDocuments.ToArray() -join "`n" +} diff --git a/src/functions/private/ConvertTo-YamlTagText.ps1 b/src/functions/private/ConvertTo-YamlTagText.ps1 new file mode 100644 index 0000000..690aa5c --- /dev/null +++ b/src/functions/private/ConvertTo-YamlTagText.ps1 @@ -0,0 +1,60 @@ +function ConvertTo-YamlTagText { + <# + .SYNOPSIS + Encodes an effective tag as canonical YAML tag text. + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [string] $Tag + ) + + $uriPunctuation = '#;/?:@&=+$,_.!~*''()[]' + $utf8 = [System.Text.UTF8Encoding]::new($false, $true) + $builder = [System.Text.StringBuilder]::new() + for ($index = 0; $index -lt $Tag.Length; $index++) { + $character = $Tag[$index] + $code = [int] $character + $isAsciiWord = ( + ($code -ge 0x30 -and $code -le 0x39) -or + ($code -ge 0x41 -and $code -le 0x5A) -or + ($code -ge 0x61 -and $code -le 0x7A) -or + $character -eq '-' + ) + if ($isAsciiWord -or $uriPunctuation.IndexOf($character) -ge 0) { + [void] $builder.Append($character) + continue + } + + if ([char]::IsHighSurrogate($character)) { + if ($index + 1 -ge $Tag.Length -or -not [char]::IsLowSurrogate($Tag[$index + 1])) { + throw [System.ArgumentException]::new( + 'A YAML tag cannot contain an unpaired UTF-16 high surrogate.' + ) + } + $scalarText = $Tag.Substring($index, 2) + $index++ + } elseif ([char]::IsLowSurrogate($character)) { + throw [System.ArgumentException]::new( + 'A YAML tag cannot contain an unpaired UTF-16 low surrogate.' + ) + } else { + $scalarText = [string] $character + } + + foreach ($byte in $utf8.GetBytes($scalarText)) { + [void] $builder.Append(('%{0:X2}' -f $byte)) + } + } + + $escapedTag = $builder.ToString() + $standardPrefix = 'tag:yaml.org,2002:' + if ($escapedTag.StartsWith($standardPrefix, [System.StringComparison]::Ordinal)) { + $suffix = $escapedTag.Substring($standardPrefix.Length) + if (Test-YamlTagUriText -Text $suffix -Shorthand) { + return "!!$suffix" + } + } + return "!<$escapedTag>" +} diff --git a/src/functions/private/Get-YamlEmissionImplicitKeyLength.ps1 b/src/functions/private/Get-YamlEmissionImplicitKeyLength.ps1 index af52971..d39e71c 100644 --- a/src/functions/private/Get-YamlEmissionImplicitKeyLength.ps1 +++ b/src/functions/private/Get-YamlEmissionImplicitKeyLength.ps1 @@ -7,6 +7,7 @@ function Get-YamlEmissionImplicitKeyLength { [OutputType([int])] param ( [Parameter(Mandatory)] + [AllowEmptyString()] [string] $RenderedText ) diff --git a/src/functions/private/Get-YamlEmissionPrefix.ps1 b/src/functions/private/Get-YamlEmissionPrefix.ps1 index d674e29..92d21b9 100644 --- a/src/functions/private/Get-YamlEmissionPrefix.ps1 +++ b/src/functions/private/Get-YamlEmissionPrefix.ps1 @@ -15,12 +15,9 @@ function Get-YamlEmissionPrefix { $parts.Add("&$($Node.Anchor)") } if (-not [string]::IsNullOrEmpty($Node.Tag)) { - $prefix = 'tag:yaml.org,2002:' - if ($Node.Tag.StartsWith($prefix, [System.StringComparison]::Ordinal)) { - $parts.Add("!!$($Node.Tag.Substring($prefix.Length))") - } else { - $parts.Add("!<$($Node.Tag)>") - } + $parts.Add((ConvertTo-YamlTagText -Tag $Node.Tag)) + } elseif ($Node.HasUnknownTag) { + $parts.Add('!') } return $parts -join ' ' } diff --git a/src/functions/private/Get-YamlTagPresentationLimit.ps1 b/src/functions/private/Get-YamlTagPresentationLimit.ps1 new file mode 100644 index 0000000..62c3847 --- /dev/null +++ b/src/functions/private/Get-YamlTagPresentationLimit.ps1 @@ -0,0 +1,26 @@ +function Get-YamlTagPresentationLimit { + <# + .SYNOPSIS + Gets an allocation-safe presentation bound for a decoded tag limit. + #> + [CmdletBinding()] + [OutputType([int])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Context, + + [switch] $Token + ) + + # One UTF-16 code unit can require three UTF-8 bytes, each written as %XX. + $limit = 9L * $Context.MaxTagLength + if ($Token) { + $maximumHandleLength = 0 + foreach ($handle in $Context.TagHandles.Keys) { + $maximumHandleLength = [Math]::Max($maximumHandleLength, $handle.Length) + } + $limit += $maximumHandleLength + 3L + } + + [int] [Math]::Min($limit, [int]::MaxValue) +} diff --git a/src/functions/private/New-YamlEmissionNode.ps1 b/src/functions/private/New-YamlEmissionNode.ps1 index 6f05dc3..277b770 100644 --- a/src/functions/private/New-YamlEmissionNode.ps1 +++ b/src/functions/private/New-YamlEmissionNode.ps1 @@ -16,15 +16,16 @@ function New-YamlEmissionNode { ) $node = [pscustomobject]@{ - PSTypeName = 'PSModule.Yaml.EmissionNode' - Kind = $Kind - Tag = '' - Value = '' - Style = 'Plain' - Items = [System.Collections.Generic.List[object]]::new() - Entries = [System.Collections.Generic.List[object]]::new() - ReferenceId = [long] 0 - Anchor = '' + PSTypeName = 'PSModule.Yaml.EmissionNode' + Kind = $Kind + Tag = '' + HasUnknownTag = $false + Value = '' + Style = 'Plain' + Items = [System.Collections.Generic.List[object]]::new() + Entries = [System.Collections.Generic.List[object]]::new() + ReferenceId = [long] 0 + Anchor = '' } Write-Output -InputObject $node -NoEnumerate } diff --git a/src/functions/private/Read-YamlDirectiveBlock.ps1 b/src/functions/private/Read-YamlDirectiveBlock.ps1 index 5031772..aad4309 100644 --- a/src/functions/private/Read-YamlDirectiveBlock.ps1 +++ b/src/functions/private/Read-YamlDirectiveBlock.ps1 @@ -58,10 +58,10 @@ function Read-YamlDirectiveBlock { "The tag handle '$handle' is declared more than once." )) } - if ($prefix.Length -gt $Context.MaxTagLength) { + if ($prefix.Length -gt (Get-YamlTagPresentationLimit -Context $Context)) { throw (New-YamlException -Start $mark -End $mark ` -ErrorId 'YamlTagLimitExceeded' -Message ( - "A YAML tag prefix exceeds the configured limit of $($Context.MaxTagLength) characters." + "A YAML tag prefix cannot fit the configured limit of $($Context.MaxTagLength) decoded characters." )) } if (-not $prefix.Equals('!', [System.StringComparison]::Ordinal) -and @@ -71,6 +71,8 @@ function Read-YamlDirectiveBlock { "The TAG directive prefix '$prefix' contains invalid URI text." )) } + $null = ConvertFrom-YamlTagUriEscape -Text $prefix -Mark $mark -Token $prefix ` + -MaxLength $Context.MaxTagLength $tagHandles[$handle] = $prefix } elseif (-not (Test-YamlReservedDirective -Directive $directive)) { throw (New-YamlException -Start $mark -End $mark ` diff --git a/src/functions/private/Read-YamlFlowNode.ps1 b/src/functions/private/Read-YamlFlowNode.ps1 index 4aa6e4b..99aa69d 100644 --- a/src/functions/private/Read-YamlFlowNode.ps1 +++ b/src/functions/private/Read-YamlFlowNode.ps1 @@ -62,9 +62,11 @@ function Read-YamlFlowNode { Move-YamlCursor -Cursor $Cursor -Context $Context } } - if ($Cursor.Index - $tokenStart -gt $Context.MaxTagLength) { + if ($Cursor.Index - $tokenStart -gt ( + Get-YamlTagPresentationLimit -Context $Context -Token + )) { throw (New-YamlException -Start $start -End $start -ErrorId 'YamlTagLimitExceeded' -Message ( - "A YAML tag token exceeds the configured limit of $($Context.MaxTagLength) characters." + "A YAML tag token cannot fit the configured limit of $($Context.MaxTagLength) decoded characters." )) } $resolved = Resolve-YamlTag -Token $Context.Text.Substring( diff --git a/src/functions/private/Read-YamlNodeProperty.ps1 b/src/functions/private/Read-YamlNodeProperty.ps1 index e1b46d4..210c1da 100644 --- a/src/functions/private/Read-YamlNodeProperty.ps1 +++ b/src/functions/private/Read-YamlNodeProperty.ps1 @@ -54,11 +54,13 @@ function Read-YamlNodeProperty { $position++ } } - if ($position - $start -gt $Context.MaxTagLength) { + if ($position - $start -gt ( + Get-YamlTagPresentationLimit -Context $Context -Token + )) { $mark = New-YamlMark -Index ($Context.LineStarts[$Line] + $Column + $start) -Line $Line ` -Column ($Column + $start) throw (New-YamlException -Start $mark -End $mark -ErrorId 'YamlTagLimitExceeded' -Message ( - "A YAML tag token exceeds the configured limit of $($Context.MaxTagLength) characters." + "A YAML tag token cannot fit the configured limit of $($Context.MaxTagLength) decoded characters." )) } $token = $Text.Substring($start, $position - $start) diff --git a/src/functions/private/Resolve-YamlTag.ps1 b/src/functions/private/Resolve-YamlTag.ps1 index b98ba5b..8bdb330 100644 --- a/src/functions/private/Resolve-YamlTag.ps1 +++ b/src/functions/private/Resolve-YamlTag.ps1 @@ -71,7 +71,8 @@ function Resolve-YamlTag { $expanded = if ($isNonSpecific) { '' } else { - ConvertFrom-YamlTagUriEscape -Text ($prefix + $suffix) -Mark $Mark -Token $Token + ConvertFrom-YamlTagUriEscape -Text ($prefix + $suffix) -Mark $Mark -Token $Token ` + -MaxLength $Context.MaxTagLength } $expandedLength = $expanded.Length if ($expandedLength -gt $Context.MaxTagLength) { diff --git a/src/functions/public/Format-Yaml.ps1 b/src/functions/public/Format-Yaml.ps1 new file mode 100644 index 0000000..21e4783 --- /dev/null +++ b/src/functions/public/Format-Yaml.ps1 @@ -0,0 +1,123 @@ +function Format-Yaml { + <# + .SYNOPSIS + Normalizes a YAML stream without projecting it to PowerShell values. + + .DESCRIPTION + Parses YAML into the module's representation graph and emits one + deterministic YAML string directly from those nodes. Document order, + empty documents, effective tags, anchors, aliases, complex keys, node + kinds, scalar content, and mapping order are preserved. + + Comments, directives, flow styles, scalar presentation styles, document + end markers, and source anchor names are normalized. Every document has + an explicit start marker. Output uses LF and has no final newline. + + Pipeline strings are joined with a line feed and parsed as one stream, + matching ConvertFrom-Yaml. + + .PARAMETER InputObject + YAML text. Multiple pipeline records are joined with a line feed and + parsed as one YAML stream. + + .PARAMETER Indent + Block indentation from 2 through 9 spaces. The default is 2. + + .PARAMETER Depth + Maximum YAML node nesting depth. The default is 100. + + .PARAMETER MaxNodes + Maximum number of YAML nodes in the stream. The default is 100000. + + .PARAMETER MaxAliases + Maximum number of alias nodes in the stream. The default is 1000. + + .PARAMETER MaxScalarLength + Maximum decoded character count for one scalar. The default is + 1048576. + + .PARAMETER MaxTagLength + Maximum expanded character count for one tag. The default is 1024. + + .PARAMETER MaxTotalTagLength + Maximum cumulative expanded tag characters. The default is 65536. + + .PARAMETER MaxNumericLength + Maximum digits in an implicitly or explicitly typed number. The + default is 4096. + + .EXAMPLE + Get-Content -Path '.\config.yaml' | Format-Yaml + + Joins the input lines and emits one normalized YAML stream. + + .EXAMPLE + $normalized = Format-Yaml -InputObject '{name: Ada, active: true}' -Indent 4 + + Converts flow presentation to deterministic block presentation with + four-space indentation. + + .INPUTS + System.String[] + + .OUTPUTS + System.String + #> + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory, Position = 0, ValueFromPipeline)] + [AllowEmptyString()] + [string[]] $InputObject, + + [ValidateRange(2, 9)] + [int] $Indent = 2, + + [ValidateRange(1, 128)] + [int] $Depth = 100, + + [ValidateRange(1, 2147483647)] + [int] $MaxNodes = 100000, + + [ValidateRange(0, 2147483647)] + [int] $MaxAliases = 1000, + + [ValidateRange(1, 2147483647)] + [int] $MaxScalarLength = 1048576, + + [ValidateRange(1, 1048576)] + [int] $MaxTagLength = 1024, + + [ValidateRange(1, 2147483647)] + [int] $MaxTotalTagLength = 65536, + + [ValidateRange(1, 1048576)] + [int] $MaxNumericLength = 4096 + ) + + begin { + $lines = [System.Collections.Generic.List[string]]::new() + } + process { + foreach ($line in $InputObject) { + $lines.Add($line) + } + } + end { + $yamlText = $lines -join "`n" + try { + $documentBox = Read-YamlStream -Yaml $yamlText -Depth $Depth -MaxNodes $MaxNodes ` + -MaxAliases $MaxAliases -MaxScalarLength $MaxScalarLength -MaxTagLength $MaxTagLength ` + -MaxTotalTagLength $MaxTotalTagLength -MaxNumericLength $MaxNumericLength + $formatted = ConvertTo-YamlRepresentationText -Documents $documentBox.Value -Indent $Indent + $PSCmdlet.WriteObject($formatted, $false) + } catch { + if (-not $_.Exception.Data.Contains('IsYamlException')) { + throw + } + $record = New-YamlErrorRecord -Exception $_.Exception -DefaultErrorId 'YamlInvalidInput' ` + -Category InvalidData -TargetObject $yamlText + $PSCmdlet.ThrowTerminatingError($record) + } + } +} diff --git a/tests/Conformance.Tests.ps1 b/tests/Conformance.Tests.ps1 index bda0a57..24f0515 100644 --- a/tests/Conformance.Tests.ps1 +++ b/tests/Conformance.Tests.ps1 @@ -34,7 +34,8 @@ BeforeAll { -CompareEvents ` -CompareOutYaml ` -CompareEmitYaml ` - -CompareSelfRoundTrip + -CompareSelfRoundTrip ` + -CompareFormatter ) $emptySuitePath = Join-Path $TestDrive 'empty-suite' $null = New-Item -Path $emptySuitePath -ItemType Directory @@ -47,6 +48,7 @@ Describe 'Released yaml-test-suite corpus accounting' { $conformanceYamlModule | Should -Not -BeNullOrEmpty $command = Get-Command -Name ConvertFrom-Yaml $command.Module | Should -Be $conformanceYamlModule + (Get-Command -Name Format-Yaml).Module | Should -Be $conformanceYamlModule $conformanceYamlModule.ModuleBase | Should -Not -Be (Join-Path $repositoryRoot 'src') $conformanceYamlModule.PowerShellVersion | Should -Be '7.6' @@ -226,6 +228,88 @@ Describe 'Released yaml-test-suite corpus accounting' { Should -Not -Be (ConvertTo-YamlSuiteReferenceSignature -Value $distinctKeyGraph) } + It 'distinguishes non-specific tags in canonical representation events' { + $plain = Invoke-InYamlModule -ScriptBlock $readYamlSuiteRepresentation ` + -Arguments @('"value"') + $nonSpecific = Invoke-InYamlModule -ScriptBlock $readYamlSuiteRepresentation ` + -Arguments @('! value') + $plainEvents = ConvertTo-YamlSuiteActualEvent -Documents $plain.Value + $nonSpecificEvents = ConvertTo-YamlSuiteActualEvent -Documents $nonSpecific.Value + $oracleEvents = ConvertFrom-YamlSuiteEventText -Text @' ++STR ++DOC +=VAL :value +-DOC +-STR +'@ + + (Compare-YamlSuiteCanonicalList -Left $plainEvents -Right $nonSpecificEvents) | + Should -BeFalse + $nonSpecificEvents | Should -Be $oracleEvents + $nonSpecificEvents | Should -Contain '=VAL|nonSpecificTag=true|value=value' + } + + It 'distinguishes implicit scalar tags without treating style as semantic' { + foreach ($mutation in @( + @{ Plain = 'true'; Quoted = '"true"'; Tag = 'tag:yaml.org,2002:bool' } + @{ Plain = '42'; Quoted = '"42"'; Tag = 'tag:yaml.org,2002:int' } + @{ Plain = '1.5'; Quoted = '"1.5"'; Tag = 'tag:yaml.org,2002:float' } + @{ Plain = '.inf'; Quoted = '".inf"'; Tag = 'tag:yaml.org,2002:float' } + @{ Plain = 'null'; Quoted = '"null"'; Tag = 'tag:yaml.org,2002:null' } + )) { + $plain = Invoke-InYamlModule -ScriptBlock $readYamlSuiteRepresentation ` + -Arguments @($mutation.Plain) + $quoted = Invoke-InYamlModule -ScriptBlock $readYamlSuiteRepresentation ` + -Arguments @($mutation.Quoted) + $plainEvents = ConvertTo-YamlSuiteActualEvent -Documents $plain.Value ` + -IncludeEffectiveScalarTags + $quotedEvents = ConvertTo-YamlSuiteActualEvent -Documents $quoted.Value ` + -IncludeEffectiveScalarTags + + (Compare-YamlSuiteCanonicalList -Left $plainEvents -Right $quotedEvents) | + Should -BeFalse + $plainEvents | Should -Contain ( + '=VAL|tag={0}|value={1}' -f $mutation.Tag, $mutation.Plain + ) + $quotedEvents | Should -Contain ( + '=VAL|tag=tag:yaml.org,2002:str|value={0}' -f $mutation.Plain + ) + } + + $singleQuoted = Invoke-InYamlModule -ScriptBlock $readYamlSuiteRepresentation ` + -Arguments @("'value'") + $doubleQuoted = Invoke-InYamlModule -ScriptBlock $readYamlSuiteRepresentation ` + -Arguments @('"value"') + $singleEvents = ConvertTo-YamlSuiteActualEvent -Documents $singleQuoted.Value ` + -IncludeEffectiveScalarTags + $doubleEvents = ConvertTo-YamlSuiteActualEvent -Documents $doubleQuoted.Value ` + -IncludeEffectiveScalarTags + + (Compare-YamlSuiteCanonicalList -Left $singleEvents -Right $doubleEvents) | + Should -BeTrue + + $explicit = Invoke-InYamlModule -ScriptBlock $readYamlSuiteRepresentation ` + -Arguments @('!!str value') + $unknown = Invoke-InYamlModule -ScriptBlock $readYamlSuiteRepresentation ` + -Arguments @('!local value') + $nonSpecific = Invoke-InYamlModule -ScriptBlock $readYamlSuiteRepresentation ` + -Arguments @('! value') + $explicitEvents = ConvertTo-YamlSuiteActualEvent -Documents $explicit.Value ` + -IncludeEffectiveScalarTags + $unknownEvents = ConvertTo-YamlSuiteActualEvent -Documents $unknown.Value ` + -IncludeEffectiveScalarTags + $nonSpecificEvents = ConvertTo-YamlSuiteActualEvent -Documents $nonSpecific.Value ` + -IncludeEffectiveScalarTags + + $explicitEvents | + Should -Contain '=VAL|tag=tag:yaml.org,2002:str|explicitTag=true|value=value' + $unknownEvents | Should -Contain '=VAL|tag=!local|explicitTag=true|value=value' + $nonSpecificEvents | + Should -Contain '=VAL|tag=tag:yaml.org,2002:str|nonSpecificTag=true|value=value' + (Compare-YamlSuiteCanonicalList -Left $explicitEvents -Right $doubleEvents) | + Should -BeFalse + } + It 'detects altered ordered-map and alias semantics in out.yaml' { $mutatedSuitePath = Join-Path $TestDrive 'mutated-out-cases' $null = New-Item -Path $mutatedSuitePath -ItemType Directory -Force @@ -405,6 +489,46 @@ ship-to: ) } + It 'formats every loadable case with representation and idempotence preserved' { + @($suiteResults | Where-Object FormatterResult -EQ 'Pass').Count | + Should -Be 400 + @($suiteResults | Where-Object FormatterResult -EQ 'PolicyDifference').Count | + Should -Be 0 + @($suiteResults | Where-Object FormatterResult -EQ 'Fail').Count | + Should -Be 0 + @($suiteResults | Where-Object FormatterResult -EQ 'NotApplicable').Count | + Should -Be 2 + + $excluded = @( + $suiteResults | + Where-Object FormatterResult -EQ 'NotApplicable' | + Sort-Object Case + ) + @($excluded.Case) | Should -Be @('2JQS', 'X38W') + @($excluded.FormatterReason | Select-Object -Unique) | + Should -Be @('RepresentationMappingKeyUniqueness') + + $formatted = @( + $suiteResults | + Where-Object { -not $_.ExpectsError -and $_.FormatterResult -eq 'Pass' } + ) + $formatted.Count | Should -Be 306 + @($formatted | Where-Object FormatterReason).Count | Should -Be 0 + @($formatted | Where-Object { $_.FormatterText -cne $_.FormatterSecond }).Count | + Should -Be 0 + @($formatted | Where-Object { $_.FormatterExpected -cne $_.FormatterActual }).Count | + Should -Be 0 + } + + It 'keeps every invalid corpus input rejected by the formatter' { + $invalid = @($suiteResults | Where-Object ExpectsError) + + $invalid.Count | Should -Be 94 + @($invalid | Where-Object FormatterResult -NE 'Pass').Count | Should -Be 0 + @($invalid.FormatterReason | Select-Object -Unique) | + Should -Be @('InvalidInputRejected') + } + It 'keeps the previously failing multi-document JSON cases green' { $jsonRegressions = @( $suiteResults | diff --git a/tests/Format-Yaml.Tests.ps1 b/tests/Format-Yaml.Tests.ps1 new file mode 100644 index 0000000..63c0561 --- /dev/null +++ b/tests/Format-Yaml.Tests.ps1 @@ -0,0 +1,387 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Required for Pester tests' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester tests' +)] +[CmdletBinding()] +param() + +BeforeAll { + . (Join-Path $PSScriptRoot 'TestBootstrap.ps1') + + function Get-TestYamlFailure { + <# + .SYNOPSIS + Captures one expected terminating error for classification checks. + #> + param ( + [Parameter(Mandatory)] + [scriptblock] $Action + ) + + try { + $null = & $Action + } catch { + return $_ + } + throw 'The YAML operation unexpectedly succeeded.' + } +} + +Describe 'Format-Yaml' { + Context 'Public contract' { + It 'exposes the advanced string formatter contract' { + $command = Get-Command -Name Format-Yaml + $inputParameter = $command.Parameters['InputObject'] + $parameterAttribute = $inputParameter.Attributes | + Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] } + $allowEmptyString = $inputParameter.Attributes | + Where-Object { $_ -is [System.Management.Automation.AllowEmptyStringAttribute] } + $indentRange = $command.Parameters['Indent'].Attributes | + Where-Object { $_ -is [System.Management.Automation.ValidateRangeAttribute] } + + $command.CmdletBinding | Should -BeTrue + $inputParameter.ParameterType | Should -Be ([string[]]) + $parameterAttribute.Mandatory | Should -BeTrue + $parameterAttribute.Position | Should -Be 0 + $parameterAttribute.ValueFromPipeline | Should -BeTrue + $allowEmptyString | Should -Not -BeNullOrEmpty + @($command.OutputType.Type) | Should -Contain ([string]) + $indentRange.MinRange | Should -Be 2 + $indentRange.MaxRange | Should -Be 9 + } + + It 'emits exactly one string without CR or a final newline' { + $result = @('one: 1', 'two: 2' | Format-Yaml) + + $result.Count | Should -Be 1 + $result[0] | Should -BeOfType [string] + $result[0] | Should -Not -Match "`r" + $result[0].EndsWith("`n", [System.StringComparison]::Ordinal) | + Should -BeFalse + } + + It 'returns one empty string for empty and comment-only streams' { + $empty = @('' | Format-Yaml) + $commentOnly = @("# heading`n# trailing" | Format-Yaml) + + $empty.Count | Should -Be 1 + $empty[0] | Should -BeExactly '' + $commentOnly.Count | Should -Be 1 + $commentOnly[0] | Should -BeExactly '' + ('---' | Format-Yaml) | Should -BeExactly '---' + } + } + + Context 'Deterministic normalization' { + It 'removes comments and normalizes flow collections to block form' { + $inputYaml = @' +# heading +root: { z: 1, a: "true", nested: [one, two] } # trailing +'@ + $expected = @' +--- +"root": + "z": 1 + "a": "true" + "nested": + - "one" + - "two" +'@.Replace("`r`n", "`n").TrimEnd() + + ($inputYaml | Format-Yaml) | Should -BeExactly $expected + } + + It 'uses the requested indentation' { + $formatted = "outer:`n inner:`n value: one" | Format-Yaml -Indent 4 + + $formatted | Should -Match '(?m)^ "inner":$' + $formatted | Should -Match '(?m)^ "value": "one"$' + } + + It 'joins pipeline records with LF into one stream' { + $formatted = 'root:', ' child: value' | Format-Yaml + + $formatted | Should -BeExactly "---`n`"root`":`n `"child`": `"value`"" + } + + It 'preserves empty documents and document order' { + $inputYaml = "---`n...`n---`n# empty`n---`nvalue`n..." + $formatted = $inputYaml | Format-Yaml + $documents = @($formatted | ConvertFrom-Yaml) + + $formatted | Should -BeExactly "---`n---`n---`n`"value`"" + $documents.Count | Should -Be 3 + $documents[0] | Should -BeNullOrEmpty + $documents[1] | Should -BeNullOrEmpty + $documents[2] | Should -Be 'value' + } + + It 'normalizes literal, folded, and chomping styles without changing content' { + $inputYaml = @' +literal: |- + first + second +folded: >+ + alpha + beta + +'@ + $expectedValues = $inputYaml | ConvertFrom-Yaml -AsHashtable + $formatted = $inputYaml | Format-Yaml + $actualValues = $formatted | ConvertFrom-Yaml -AsHashtable + + $formatted | Should -Not -Match '(?m)^[|>][+-]?$' + $formatted | Should -Match '\\n' + $actualValues['literal'] | Should -BeExactly $expectedValues['literal'] + $actualValues['folded'] | Should -BeExactly $expectedValues['folded'] + } + + It 'quotes Unicode and control content safely' { + $inputYaml = '"\0\a\b\t\n\r\e\x85\u263A\U0001F600"' + $expected = $inputYaml | ConvertFrom-Yaml + $formatted = $inputYaml | Format-Yaml + + ($formatted | ConvertFrom-Yaml) | Should -BeExactly $expected + $formatted | Should -Match '\\0\\a\\b\\t\\n\\r\\e\\u0085' + $formatted | Should -Not -Match "`r" + } + } + + Context 'Representation preservation' { + It 'resolves tag directives and emits effective tags without directives' { + $inputYaml = @' +%TAG !e! tag:example.com,2026: +--- +!e!widget { value: !!str 42 } +'@ + $formatted = $inputYaml | Format-Yaml + $before = Get-TestYamlRepresentationRoot -Yaml $inputYaml + $after = Get-TestYamlRepresentationRoot -Yaml $formatted + + $formatted | Should -Not -Match '(?m)^%TAG' + $formatted | Should -Match '!' + $formatted | Should -Match '!!str "42"' + $after.Kind | Should -Be $before.Kind + $after.Tag | Should -BeExactly $before.Tag + $after.HasUnknownTag | Should -Be $before.HasUnknownTag + } + + It 'preserves standard, local, global, escaped, and non-specific tags' -ForEach @( + @{ Yaml = '!!binary SGVsbG8=' } + @{ Yaml = '!local value' } + @{ Yaml = '! value' } + @{ Yaml = '! value' } + @{ Yaml = '! value' } + ) { + $formatted = $Yaml | Format-Yaml + $before = Get-TestYamlRepresentationRoot -Yaml $Yaml + $after = Get-TestYamlRepresentationRoot -Yaml $formatted + + $after.Kind | Should -Be $before.Kind + $after.Tag | Should -BeExactly $before.Tag + $after.HasUnknownTag | Should -Be $before.HasUnknownTag + $after.Value | Should -BeExactly $before.Value + } + + It 'preserves aliases and recursive graph identity with deterministic anchors' { + $inputYaml = @' +root: &source + self: *source +copy: *source +'@ + $formatted = $inputYaml | Format-Yaml + $result = $formatted | ConvertFrom-Yaml -AsHashtable + + $formatted | Should -Match '&id001' + @([regex]::Matches($formatted, '\*id001')).Count | Should -Be 2 + [object]::ReferenceEquals($result['root'], $result['copy']) | + Should -BeTrue + [object]::ReferenceEquals($result['root'], $result['root']['self']) | + Should -BeTrue + } + + It 'preserves complex keys and mapping order' { + $inputYaml = @' +? [region, { port: 443 }] +: first +simple: second +'@ + $formatted = $inputYaml | Format-Yaml + $result = $formatted | ConvertFrom-Yaml -AsHashtable + $entry = $result.GetEnumerator() | Select-Object -First 1 + + @($result.Values) | Should -Be @('first', 'second') + , $entry.Key | Should -BeOfType [object[]] + $entry.Key[0] | Should -Be 'region' + $entry.Key[1] | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + $entry.Key[1]['port'] | Should -Be 443 + } + + It 'preserves explicit standard collection and scalar tags' -ForEach @( + @{ Yaml = "!!set`n? one`n? two"; Kind = 'Mapping'; Tag = 'tag:yaml.org,2002:set' } + @{ Yaml = "!!omap`n- one: 1`n- two: 2"; Kind = 'Sequence'; Tag = 'tag:yaml.org,2002:omap' } + @{ Yaml = "!!pairs`n- one: 1`n- one: 2"; Kind = 'Sequence'; Tag = 'tag:yaml.org,2002:pairs' } + @{ Yaml = '!!timestamp 2001-12-15T02:59:43.1Z'; Kind = 'Scalar'; Tag = 'tag:yaml.org,2002:timestamp' } + ) { + $formatted = $Yaml | Format-Yaml + $node = Get-TestYamlRepresentationRoot -Yaml $formatted + + $node.Kind | Should -Be $Kind + $node.Tag | Should -BeExactly $Tag + } + + It 'keeps core-looking strings distinct from implicit core scalars' { + $inputYaml = @' +quoted: "true" +tagged: !!str null +boolean: true +nullValue: +float: .nan +'@ + $formatted = $inputYaml | Format-Yaml + $result = $formatted | ConvertFrom-Yaml -AsHashtable + + $result['quoted'] | Should -BeOfType [string] + $result['quoted'] | Should -Be 'true' + $result['tagged'] | Should -BeOfType [string] + $result['tagged'] | Should -Be 'null' + $result['boolean'] | Should -BeTrue + $result['nullValue'] | Should -BeNullOrEmpty + [double]::IsNaN($result['float']) | Should -BeTrue + } + + It 'is byte-idempotent at the same options' -ForEach @( + @{ Yaml = "# comment`n{ b: [2, 3], a: one }"; Indent = 2 } + @{ Yaml = "&root [*root]"; Indent = 4 } + @{ Yaml = "---`n`n---`n!local value"; Indent = 3 } + @{ Yaml = "text: |+`n one`n two`n"; Indent = 2 } + ) { + $first = $Yaml | Format-Yaml -Indent $Indent + $second = $first | Format-Yaml -Indent $Indent + + $second | Should -BeExactly $first + } + } + + Context 'Validation and parser limits' { + It 'keeps exact effective tag-length boundaries idempotent' -ForEach @( + @{ + Name = 'local' + Yaml = '!abc value' + TooLong = '!abcd value' + Limit = 4 + } + @{ + Name = 'verbatim' + Yaml = '! value' + TooLong = '! value' + Limit = 3 + } + @{ + Name = 'expanded handle' + Yaml = "%TAG !e! tag:x%2C`n---`n!e!a value" + TooLong = "%TAG !e! tag:x%2C`n---`n!e!ab value" + Limit = 7 + } + ) { + $first = $Yaml | Format-Yaml -MaxTagLength $Limit + $second = $first | Format-Yaml -MaxTagLength $Limit + $failure = Get-TestYamlFailure { + $TooLong | Format-Yaml -MaxTagLength $Limit + } + + $second | Should -BeExactly $first + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlTagLimitExceeded' + } + + It 'keeps the default tag-length boundary idempotent' { + $accepted = '!' + ('a' * 1023) + ' value' + $rejected = '!' + ('a' * 1024) + ' value' + + $first = $accepted | Format-Yaml + ($first | Format-Yaml) | Should -BeExactly $first + $failure = Get-TestYamlFailure { + $rejected | Format-Yaml + } + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlTagLimitExceeded' + } + + It 'keeps cumulative effective tag budgets idempotent' { + $yaml = "!abc one`n---`n!def two" + $first = $yaml | Format-Yaml -MaxTagLength 4 -MaxTotalTagLength 8 + + ($first | Format-Yaml -MaxTagLength 4 -MaxTotalTagLength 8) | + Should -BeExactly $first + $failure = Get-TestYamlFailure { + $yaml | Format-Yaml -MaxTagLength 4 -MaxTotalTagLength 7 + } + $failure.Exception.Data['YamlErrorId'] | + Should -BeExactly 'YamlTagLimitExceeded' + } + + It 'classifies every parser resource limit like ConvertFrom-Yaml' -ForEach @( + @{ Name = 'depth'; Yaml = "a:`n b:`n c: value"; Parameters = @{ Depth = 2 } } + @{ Name = 'nodes'; Yaml = '[one, two]'; Parameters = @{ MaxNodes = 2 } } + @{ Name = 'aliases'; Yaml = "a: &a value`nb: *a"; Parameters = @{ MaxAliases = 0 } } + @{ Name = 'scalar length'; Yaml = 'value: long'; Parameters = @{ MaxScalarLength = 4 } } + @{ Name = 'tag length'; Yaml = '!long value'; Parameters = @{ MaxTagLength = 2 } } + @{ + Name = 'total tag length' + Yaml = "!a one`n---`n!b two" + Parameters = @{ MaxTotalTagLength = 3 } + } + @{ Name = 'numeric length'; Yaml = '123'; Parameters = @{ MaxNumericLength = 2 } } + ) { + $parseFailure = Get-TestYamlFailure { + $Yaml | ConvertFrom-Yaml @Parameters + } + $formatFailure = Get-TestYamlFailure { + $Yaml | Format-Yaml @Parameters + } + + $formatFailure.Exception.Data['YamlErrorId'] | + Should -BeExactly $parseFailure.Exception.Data['YamlErrorId'] + $formatFailure.FullyQualifiedErrorId | + Should -Be "$($parseFailure.Exception.Data['YamlErrorId']),Format-Yaml" + } + + It 'classifies invalid YAML like ConvertFrom-Yaml' -ForEach @( + @{ Name = 'malformed flow'; Yaml = '[one, two' } + @{ Name = 'duplicate key'; Yaml = "key: one`nkey: two" } + @{ Name = 'undefined alias'; Yaml = '*missing' } + @{ Name = 'malformed tag'; Yaml = '!foo%GG value' } + ) { + $parseFailure = Get-TestYamlFailure { $Yaml | ConvertFrom-Yaml } + $formatFailure = Get-TestYamlFailure { $Yaml | Format-Yaml } + + $formatFailure.Exception.Data['YamlErrorId'] | + Should -BeExactly $parseFailure.Exception.Data['YamlErrorId'] + $formatFailure.FullyQualifiedErrorId | + Should -Be "$($parseFailure.Exception.Data['YamlErrorId']),Format-Yaml" + } + + It 'does not swallow unexpected runtime failures' { + $loadedModule = Get-Module -Name Yaml | Select-Object -First 1 + if ($null -eq $loadedModule) { + Mock Read-YamlStream { + throw [System.InvalidOperationException]::new('unexpected runtime failure') + } + } else { + Mock Read-YamlStream -ModuleName $loadedModule.Name { + throw [System.InvalidOperationException]::new('unexpected runtime failure') + } + } + + { 'name: Ada' | Format-Yaml } | + Should -Throw -ExpectedMessage '*unexpected runtime failure*' + } + } +} diff --git a/tests/Packaging.Tests.ps1 b/tests/Packaging.Tests.ps1 index 411e5fd..7e30f94 100644 --- a/tests/Packaging.Tests.ps1 +++ b/tests/Packaging.Tests.ps1 @@ -79,6 +79,7 @@ Describe 'Dependency-free package source' { 'ConvertFrom-YamlNode.ps1', 'Get-YamlSerializationShape.ps1', 'ConvertTo-YamlNode.ps1', + 'ConvertTo-YamlRepresentationNode.ps1', 'Write-YamlNodeText.ps1' ) | ForEach-Object { $isPresent = Test-Path -LiteralPath (Join-Path $privatePath $_) @@ -131,6 +132,7 @@ Describe 'Generated artifact package' { 'ConvertFrom-Yaml', 'ConvertTo-Yaml', 'Export-Yaml', + 'Format-Yaml', 'Import-Yaml', 'Test-Yaml' ) @@ -170,6 +172,10 @@ if ($value['v'] -isnot [object[]] -or $value['v'].Count -ne 0) { if (-not ('name: Ada' | Test-Yaml)) { throw 'The imported parser did not validate YAML.' } +$formatted = '{name: Ada, active: true}' | Format-Yaml +if ($formatted -cne "---`n`"name`": `"Ada`"`n`"active`": true") { + throw 'The imported formatter did not normalize YAML.' +} $shared = [ordered]@{ value = 1 } $roundTrip = [ordered]@{ first = $shared; second = $shared } | ConvertTo-Yaml | diff --git a/tests/tools/Invoke-YamlTestSuite.ps1 b/tests/tools/Invoke-YamlTestSuite.ps1 index 77c1b63..05c3a0e 100644 --- a/tests/tools/Invoke-YamlTestSuite.ps1 +++ b/tests/tools/Invoke-YamlTestSuite.ps1 @@ -12,7 +12,8 @@ param ( [switch] $CompareEvents, [switch] $CompareOutYaml, [switch] $CompareEmitYaml, - [switch] $CompareSelfRoundTrip + [switch] $CompareSelfRoundTrip, + [switch] $CompareFormatter ) . (Join-Path $PSScriptRoot '..\TestBootstrap.ps1') @@ -21,12 +22,14 @@ if (-not $PSBoundParameters.ContainsKey('CompareJson') -and -not $PSBoundParameters.ContainsKey('CompareEvents') -and -not $PSBoundParameters.ContainsKey('CompareOutYaml') -and -not $PSBoundParameters.ContainsKey('CompareEmitYaml') -and - -not $PSBoundParameters.ContainsKey('CompareSelfRoundTrip')) { + -not $PSBoundParameters.ContainsKey('CompareSelfRoundTrip') -and + -not $PSBoundParameters.ContainsKey('CompareFormatter')) { $CompareJson = $true $CompareEvents = $true $CompareOutYaml = $true $CompareEmitYaml = $true $CompareSelfRoundTrip = $true + $CompareFormatter = $true } function Invoke-InYamlModule { @@ -591,18 +594,19 @@ function ConvertFrom-YamlSuiteEventText { ConvertFrom-YamlSuiteEventEscape -Value $value ) } - $anchorToken = '' if ($anchor) { - if (-not $anchorMap.ContainsKey($anchor)) { - $anchorCounter++ - $anchorMap[$anchor] = 'a{0:d3}' -f $anchorCounter - } + $anchorCounter++ + $anchorMap[$anchor] = 'a{0:d3}' -f $anchorCounter $anchorToken = $anchorMap[$anchor] } $parts = [System.Collections.Generic.List[string]]::new() $parts.Add($prefix) - if ($tag -and $tag -ne '!') { $parts.Add("tag=$tag") } + if ($tag -eq '!') { + $parts.Add('nonSpecificTag=true') + } elseif ($tag) { + $parts.Add("tag=$tag") + } if ($anchorToken) { $parts.Add("anchor=$anchorToken") } if ($prefix -eq '=VAL') { $parts.Add("value=$value") } $canonical.Add(($parts -join '|')) @@ -628,12 +632,29 @@ function ConvertFrom-YamlSuiteEventText { [string[]] $canonical.ToArray() } +function Get-YamlSuiteScalarEffectiveTag { + [OutputType([string])] + param ( + [Parameter(Mandatory)] + [pscustomobject] $Node + ) + + Invoke-InYamlModule -ScriptBlock { + param ([pscustomobject] $ScalarNode) + + $resolved = Resolve-YamlScalar -Node $ScalarNode + Get-YamlEffectiveTag -Node $ScalarNode -Value $resolved.Value + } -Arguments @($Node) +} + function ConvertTo-YamlSuiteActualEvent { [OutputType([string[]])] param ( [Parameter(Mandatory)] [AllowEmptyCollection()] - [object[]] $Documents + [object[]] $Documents, + + [switch] $IncludeEffectiveScalarTags ) function ConvertTo-YamlSuiteEventEscapedText { @@ -675,11 +696,7 @@ function ConvertTo-YamlSuiteActualEvent { $node = $frame.Node if ($node.Kind -eq 'Alias') { - $targetAnchorKey = if ([string]::IsNullOrEmpty($node.Target.Anchor)) { - 'id:{0}' -f $node.Target.Id - } else { - $node.Target.Anchor - } + $targetAnchorKey = 'id:{0}' -f $node.Target.Id $targetAnchor = Get-YamlSuiteAnchorToken -Key $targetAnchorKey ` -AnchorMap $anchorMap -AnchorCounter ([ref] $anchorCounter) $events.Add("=ALI|target=$targetAnchor") @@ -688,10 +705,23 @@ function ConvertTo-YamlSuiteActualEvent { if ($node.Kind -eq 'Scalar') { $parts = [System.Collections.Generic.List[string]]::new() $parts.Add('=VAL') - if ($node.Tag -and $node.Tag -ne '!') { $parts.Add("tag=$($node.Tag)") } + if ($IncludeEffectiveScalarTags) { + $parts.Add("tag=$(Get-YamlSuiteScalarEffectiveTag -Node $node)") + if (-not [string]::IsNullOrEmpty($node.Tag)) { + $parts.Add('explicitTag=true') + } elseif ($node.HasUnknownTag) { + $parts.Add('nonSpecificTag=true') + } + } else { + if ([string]::IsNullOrEmpty($node.Tag) -and $node.HasUnknownTag) { + $parts.Add('nonSpecificTag=true') + } elseif ($node.Tag) { + $parts.Add("tag=$($node.Tag)") + } + } if ($node.Anchor) { $parts.Add("anchor=$( - Get-YamlSuiteAnchorToken -Key $node.Anchor -AnchorMap $anchorMap ` + Get-YamlSuiteAnchorToken -Key ('id:{0}' -f $node.Id) -AnchorMap $anchorMap ` -AnchorCounter ([ref] $anchorCounter) )") } @@ -705,10 +735,14 @@ function ConvertTo-YamlSuiteActualEvent { $startParts = [System.Collections.Generic.List[string]]::new() $startToken = if ($node.Kind -eq 'Sequence') { '+SEQ' } else { '+MAP' } $startParts.Add($startToken) - if ($node.Tag -and $node.Tag -ne '!') { $startParts.Add("tag=$($node.Tag)") } + if ([string]::IsNullOrEmpty($node.Tag) -and $node.HasUnknownTag) { + $startParts.Add('nonSpecificTag=true') + } elseif ($node.Tag) { + $startParts.Add("tag=$($node.Tag)") + } if ($node.Anchor) { $startParts.Add("anchor=$( - Get-YamlSuiteAnchorToken -Key $node.Anchor -AnchorMap $anchorMap ` + Get-YamlSuiteAnchorToken -Key ('id:{0}' -f $node.Id) -AnchorMap $anchorMap ` -AnchorCounter ([ref] $anchorCounter) )") } @@ -831,6 +865,12 @@ $testYamlSuiteText = { -MaxScalarLength 1048576 -MaxTagLength 1024 -MaxTotalTagLength 65536 ` -MaxNumericLength 4096 } +$formatYamlSuiteText = { + param ([string] $YamlText) + Format-Yaml -InputObject $YamlText -Depth 128 -MaxNodes 100000 -MaxAliases 1000 ` + -MaxScalarLength 1048576 -MaxTagLength 1024 -MaxTotalTagLength 65536 ` + -MaxNumericLength 4096 +} $suiteRoot = (Resolve-Path -LiteralPath $Path).Path $inputFiles = @( @@ -872,6 +912,8 @@ foreach ($inputFile in $inputFiles) { $emitYamlReason = '' $selfRoundTripResult = 'NotApplicable' $selfRoundTripReason = '' + $formatterResult = 'NotApplicable' + $formatterReason = '' $representation = $null $stream = $null @@ -881,6 +923,7 @@ foreach ($inputFile in $inputFiles) { $projectedJsonCanonical = $null $projectedReference = '' $projectionError = '' + $streamError = '' $jsonOracleValues = $null $jsonOracleCanonical = $null $eventExpected = $null @@ -895,6 +938,10 @@ foreach ($inputFile in $inputFiles) { $emitYamlActualReference = $null $selfRoundTripCanonical = $null $selfRoundTripReference = $null + $formatterExpected = $null + $formatterActual = $null + $formatterText = $null + $formatterSecond = $null try { $representation = Invoke-InYamlModule -ScriptBlock $readYamlSuiteRepresentation -Arguments @($yaml) @@ -923,7 +970,7 @@ foreach ($inputFile in $inputFiles) { } if ($expectsError) { $syntaxResult = 'Pass' - } elseif ($casePath -cin @('2JQS', 'X38W') -and + } elseif ($null -ne $representation -and $_.Exception.Data['YamlErrorId'] -eq 'YamlDuplicateKey') { $syntaxResult = 'PolicyDifference' $syntaxReason = 'RepresentationMappingKeyUniqueness' @@ -931,6 +978,7 @@ foreach ($inputFile in $inputFiles) { $syntaxResult = 'Fail' $syntaxReason = [string] $_.Exception.Data['YamlErrorId'] } + $streamError = [string] $_.Exception.Data['YamlErrorId'] } } @@ -1201,6 +1249,7 @@ foreach ($inputFile in $inputFiles) { $selfRoundTripReason = 'SelfRoundTripMismatch' } } + } } catch [System.NotSupportedException] { $selfRoundTripResult = 'Fail' @@ -1216,6 +1265,77 @@ foreach ($inputFile in $inputFiles) { } } + if ($CompareFormatter) { + if ($expectsError) { + try { + $formatterText = Invoke-InYamlModule -ScriptBlock $formatYamlSuiteText ` + -Arguments @($yaml) + $formatterResult = 'Fail' + $formatterReason = 'InvalidInputAccepted' + } catch { + if (-not $_.Exception.Data.Contains('IsYamlException')) { + throw + } + $formatterResult = 'Pass' + $formatterReason = 'InvalidInputRejected' + } + } elseif ($null -eq $stream) { + if ($null -ne $representation -and $streamError -ceq 'YamlDuplicateKey') { + $formatterResult = 'NotApplicable' + $formatterReason = 'RepresentationMappingKeyUniqueness' + } else { + $formatterResult = 'Fail' + $formatterReason = if ($streamError) { $streamError } else { 'RepresentationUnavailable' } + } + } else { + try { + $formatterText = [string] ( + Invoke-InYamlModule -ScriptBlock $formatYamlSuiteText -Arguments @($yaml) + ) + $isValidFormat = Invoke-InYamlModule -ScriptBlock $testYamlSuiteText ` + -Arguments @($formatterText) + if (-not $isValidFormat) { + $formatterResult = 'Fail' + $formatterReason = 'FormattedYamlInvalid' + } else { + $formattedRepresentation = Invoke-InYamlModule ` + -ScriptBlock $readYamlSuiteRepresentation -Arguments @($formatterText) + $originalEvents = ConvertTo-YamlSuiteActualEvent -Documents $representation.Value ` + -IncludeEffectiveScalarTags + $formattedEvents = ConvertTo-YamlSuiteActualEvent ` + -Documents $formattedRepresentation.Value -IncludeEffectiveScalarTags + $formatterExpected = $originalEvents -join "`n" + $formatterActual = $formattedEvents -join "`n" + if (-not ( + Compare-YamlSuiteCanonicalList ` + -Left $formattedEvents -Right $originalEvents + )) { + $formatterResult = 'Fail' + $formatterReason = 'RepresentationMismatch' + } else { + $formatterSecond = [string] ( + Invoke-InYamlModule -ScriptBlock $formatYamlSuiteText ` + -Arguments @($formatterText) + ) + if ($formatterSecond -cne $formatterText) { + $formatterResult = 'Fail' + $formatterReason = 'FormatterNotIdempotent' + } else { + $formatterResult = 'Pass' + } + } + } + } catch { + if ($_.Exception.Data.Contains('IsYamlException')) { + $formatterResult = 'Fail' + $formatterReason = [string] $_.Exception.Data['YamlErrorId'] + } else { + throw + } + } + } + } + [pscustomobject]@{ Case = $casePath ExpectsError = $expectsError @@ -1235,6 +1355,8 @@ foreach ($inputFile in $inputFiles) { EmitYamlReason = $emitYamlReason SelfRoundTripResult = $selfRoundTripResult SelfRoundTripReason = $selfRoundTripReason + FormatterResult = $formatterResult + FormatterReason = $formatterReason EventExpected = $eventExpected EventActual = $eventActual JsonExpected = $jsonExpected @@ -1247,6 +1369,10 @@ foreach ($inputFile in $inputFiles) { EmitYamlActualRefs = $emitYamlActualReference SelfRoundTripActual = $selfRoundTripCanonical SelfRoundTripRefs = $selfRoundTripReference + FormatterExpected = $formatterExpected + FormatterActual = $formatterActual + FormatterText = $formatterText + FormatterSecond = $formatterSecond ProjectedActual = $projectedCanonical ProjectedRefs = $projectedReference }