Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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`
Expand Down
7 changes: 7 additions & 0 deletions examples/General.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 27 additions & 3 deletions src/functions/private/ConvertFrom-YamlTagUriEscape.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
}
Expand All @@ -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()
Expand Down
114 changes: 114 additions & 0 deletions src/functions/private/ConvertTo-YamlRepresentationNode.ps1
Original file line number Diff line number Diff line change
@@ -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
}
31 changes: 31 additions & 0 deletions src/functions/private/ConvertTo-YamlRepresentationText.ps1
Original file line number Diff line number Diff line change
@@ -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"
}
60 changes: 60 additions & 0 deletions src/functions/private/ConvertTo-YamlTagText.ps1
Original file line number Diff line number Diff line change
@@ -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>"
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ function Get-YamlEmissionImplicitKeyLength {
[OutputType([int])]
param (
[Parameter(Mandatory)]
[AllowEmptyString()]
[string] $RenderedText
)

Expand Down
9 changes: 3 additions & 6 deletions src/functions/private/Get-YamlEmissionPrefix.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 ' '
}
Loading
Loading