Skip to content

Commit 4b542ba

Browse files
Add representation-preserving YAML formatter
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent ccf9a01 commit 4b542ba

8 files changed

Lines changed: 677 additions & 15 deletions
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
function ConvertTo-YamlRepresentationNode {
2+
<#
3+
.SYNOPSIS
4+
Converts one representation graph to a lossless emission graph.
5+
#>
6+
[CmdletBinding()]
7+
[OutputType([pscustomobject])]
8+
param (
9+
[Parameter(Mandatory)]
10+
[pscustomobject] $Node,
11+
12+
[Parameter(Mandatory)]
13+
[pscustomobject] $State
14+
)
15+
16+
$orderedNodes = [System.Collections.Generic.List[object]]::new()
17+
$visited = [System.Collections.Generic.HashSet[int]]::new()
18+
$aliasTargets = [System.Collections.Generic.HashSet[int]]::new()
19+
$pending = [System.Collections.Generic.Stack[object]]::new()
20+
$pending.Push($Node)
21+
22+
while ($pending.Count -gt 0) {
23+
$current = $pending.Pop()
24+
if ($current.Kind -eq 'Alias') {
25+
[void] $aliasTargets.Add($current.Target.Id)
26+
if (-not $visited.Contains($current.Target.Id)) {
27+
$pending.Push($current.Target)
28+
}
29+
continue
30+
}
31+
if (-not $visited.Add($current.Id)) {
32+
continue
33+
}
34+
35+
$orderedNodes.Add($current)
36+
if ($current.Kind -eq 'Sequence') {
37+
for ($index = $current.Items.Count - 1; $index -ge 0; $index--) {
38+
$pending.Push($current.Items[$index])
39+
}
40+
} elseif ($current.Kind -eq 'Mapping') {
41+
for ($index = $current.Entries.Count - 1; $index -ge 0; $index--) {
42+
$pending.Push($current.Entries[$index].Value)
43+
$pending.Push($current.Entries[$index].Key)
44+
}
45+
}
46+
}
47+
48+
$anchorNames = [System.Collections.Generic.Dictionary[int, string]]::new()
49+
foreach ($source in $orderedNodes) {
50+
if (-not [string]::IsNullOrEmpty($source.Anchor) -or
51+
$aliasTargets.Contains($source.Id)) {
52+
$anchorNames[$source.Id] = 'id{0:d3}' -f $State.NextAnchor
53+
$State.NextAnchor++
54+
}
55+
}
56+
57+
$nodes = [System.Collections.Generic.Dictionary[int, object]]::new()
58+
foreach ($source in $orderedNodes) {
59+
$target = New-YamlEmissionNode -Kind $source.Kind
60+
$target.Tag = [string] $source.Tag
61+
$target.HasUnknownTag = [bool] $source.HasUnknownTag
62+
if ($anchorNames.ContainsKey($source.Id)) {
63+
$target.Anchor = $anchorNames[$source.Id]
64+
$target.ReferenceId = [long] $source.Id
65+
}
66+
67+
if ($source.Kind -eq 'Scalar') {
68+
$target.Value = [string] $source.Value
69+
$target.Style = 'DoubleQuoted'
70+
if ([string]::IsNullOrEmpty($source.Tag) -and
71+
-not $source.HasUnknownTag -and $source.IsPlainImplicit) {
72+
$resolved = (Resolve-YamlScalar -Node $source).Value
73+
$effectiveTag = Get-YamlEffectiveTag -Node $source -Value $resolved
74+
if ($effectiveTag -cne 'tag:yaml.org,2002:str') {
75+
$target.Style = 'Plain'
76+
}
77+
}
78+
}
79+
$nodes[$source.Id] = $target
80+
}
81+
82+
foreach ($source in $orderedNodes) {
83+
$target = $nodes[$source.Id]
84+
if ($source.Kind -eq 'Sequence') {
85+
foreach ($item in $source.Items) {
86+
$itemId = if ($item.Kind -eq 'Alias') {
87+
$item.Target.Id
88+
} else {
89+
$item.Id
90+
}
91+
$target.Items.Add($nodes[$itemId])
92+
}
93+
} elseif ($source.Kind -eq 'Mapping') {
94+
foreach ($entry in $source.Entries) {
95+
$keyId = if ($entry.Key.Kind -eq 'Alias') {
96+
$entry.Key.Target.Id
97+
} else {
98+
$entry.Key.Id
99+
}
100+
$valueId = if ($entry.Value.Kind -eq 'Alias') {
101+
$entry.Value.Target.Id
102+
} else {
103+
$entry.Value.Id
104+
}
105+
$target.Entries.Add([pscustomobject]@{
106+
Key = $nodes[$keyId]
107+
Value = $nodes[$valueId]
108+
})
109+
}
110+
}
111+
}
112+
113+
Write-Output -InputObject $nodes[$Node.Id] -NoEnumerate
114+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
function ConvertTo-YamlRepresentationText {
2+
<#
3+
.SYNOPSIS
4+
Emits representation documents as deterministic LF-normalized YAML.
5+
#>
6+
[CmdletBinding()]
7+
[OutputType([string])]
8+
param (
9+
[Parameter(Mandatory)]
10+
[AllowEmptyCollection()]
11+
[object[]] $Documents,
12+
13+
[Parameter(Mandatory)]
14+
[ValidateRange(2, 9)]
15+
[int] $Indent
16+
)
17+
18+
if ($Documents.Count -eq 0) {
19+
return ''
20+
}
21+
22+
$state = [pscustomobject]@{ NextAnchor = 1 }
23+
$renderedDocuments = [System.Collections.Generic.List[string]]::new()
24+
foreach ($document in $Documents) {
25+
$emissionNode = ConvertTo-YamlRepresentationNode -Node $document -State $state
26+
$text = ConvertTo-YamlText -Node $emissionNode -Indent $Indent -ExplicitDocumentStart
27+
$text = $text -replace '[ \t]+(?=\n|$)', ''
28+
$renderedDocuments.Add($text.TrimEnd("`n"))
29+
}
30+
return $renderedDocuments.ToArray() -join "`n"
31+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
function ConvertTo-YamlTagText {
2+
<#
3+
.SYNOPSIS
4+
Encodes an effective tag as canonical YAML tag text.
5+
#>
6+
[CmdletBinding()]
7+
[OutputType([string])]
8+
param (
9+
[Parameter(Mandatory)]
10+
[string] $Tag
11+
)
12+
13+
$uriPunctuation = '#;/?:@&=+$,_.!~*''()[]'
14+
$utf8 = [System.Text.UTF8Encoding]::new($false, $true)
15+
$builder = [System.Text.StringBuilder]::new()
16+
for ($index = 0; $index -lt $Tag.Length; $index++) {
17+
$character = $Tag[$index]
18+
$code = [int] $character
19+
$isAsciiWord = (
20+
($code -ge 0x30 -and $code -le 0x39) -or
21+
($code -ge 0x41 -and $code -le 0x5A) -or
22+
($code -ge 0x61 -and $code -le 0x7A) -or
23+
$character -eq '-'
24+
)
25+
if ($isAsciiWord -or $uriPunctuation.IndexOf($character) -ge 0) {
26+
[void] $builder.Append($character)
27+
continue
28+
}
29+
30+
if ([char]::IsHighSurrogate($character)) {
31+
if ($index + 1 -ge $Tag.Length -or -not [char]::IsLowSurrogate($Tag[$index + 1])) {
32+
throw [System.ArgumentException]::new(
33+
'A YAML tag cannot contain an unpaired UTF-16 high surrogate.'
34+
)
35+
}
36+
$scalarText = $Tag.Substring($index, 2)
37+
$index++
38+
} elseif ([char]::IsLowSurrogate($character)) {
39+
throw [System.ArgumentException]::new(
40+
'A YAML tag cannot contain an unpaired UTF-16 low surrogate.'
41+
)
42+
} else {
43+
$scalarText = [string] $character
44+
}
45+
46+
foreach ($byte in $utf8.GetBytes($scalarText)) {
47+
[void] $builder.Append(('%{0:X2}' -f $byte))
48+
}
49+
}
50+
51+
$escapedTag = $builder.ToString()
52+
$standardPrefix = 'tag:yaml.org,2002:'
53+
if ($escapedTag.StartsWith($standardPrefix, [System.StringComparison]::Ordinal)) {
54+
$suffix = $escapedTag.Substring($standardPrefix.Length)
55+
if (Test-YamlTagUriText -Text $suffix -Shorthand) {
56+
return "!!$suffix"
57+
}
58+
}
59+
return "!<$escapedTag>"
60+
}

src/functions/private/Get-YamlEmissionPrefix.ps1

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,9 @@ function Get-YamlEmissionPrefix {
1515
$parts.Add("&$($Node.Anchor)")
1616
}
1717
if (-not [string]::IsNullOrEmpty($Node.Tag)) {
18-
$prefix = 'tag:yaml.org,2002:'
19-
if ($Node.Tag.StartsWith($prefix, [System.StringComparison]::Ordinal)) {
20-
$parts.Add("!!$($Node.Tag.Substring($prefix.Length))")
21-
} else {
22-
$parts.Add("!<$($Node.Tag)>")
23-
}
18+
$parts.Add((ConvertTo-YamlTagText -Tag $Node.Tag))
19+
} elseif ($Node.HasUnknownTag) {
20+
$parts.Add('!')
2421
}
2522
return $parts -join ' '
2623
}

src/functions/private/New-YamlEmissionNode.ps1

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,16 @@ function New-YamlEmissionNode {
1616
)
1717

1818
$node = [pscustomobject]@{
19-
PSTypeName = 'PSModule.Yaml.EmissionNode'
20-
Kind = $Kind
21-
Tag = ''
22-
Value = ''
23-
Style = 'Plain'
24-
Items = [System.Collections.Generic.List[object]]::new()
25-
Entries = [System.Collections.Generic.List[object]]::new()
26-
ReferenceId = [long] 0
27-
Anchor = ''
19+
PSTypeName = 'PSModule.Yaml.EmissionNode'
20+
Kind = $Kind
21+
Tag = ''
22+
HasUnknownTag = $false
23+
Value = ''
24+
Style = 'Plain'
25+
Items = [System.Collections.Generic.List[object]]::new()
26+
Entries = [System.Collections.Generic.List[object]]::new()
27+
ReferenceId = [long] 0
28+
Anchor = ''
2829
}
2930
Write-Output -InputObject $node -NoEnumerate
3031
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
function Format-Yaml {
2+
<#
3+
.SYNOPSIS
4+
Normalizes a YAML stream without projecting it to PowerShell values.
5+
6+
.DESCRIPTION
7+
Parses YAML into the module's representation graph and emits one
8+
deterministic YAML string directly from those nodes. Document order,
9+
empty documents, effective tags, anchors, aliases, complex keys, node
10+
kinds, scalar content, and mapping order are preserved.
11+
12+
Comments, directives, flow styles, scalar presentation styles, document
13+
end markers, and source anchor names are normalized. Every document has
14+
an explicit start marker. Output uses LF and has no final newline.
15+
16+
Pipeline strings are joined with a line feed and parsed as one stream,
17+
matching ConvertFrom-Yaml.
18+
19+
.PARAMETER InputObject
20+
YAML text. Multiple pipeline records are joined with a line feed and
21+
parsed as one YAML stream.
22+
23+
.PARAMETER Indent
24+
Block indentation from 2 through 9 spaces. The default is 2.
25+
26+
.PARAMETER Depth
27+
Maximum YAML node nesting depth. The default is 100.
28+
29+
.PARAMETER MaxNodes
30+
Maximum number of YAML nodes in the stream. The default is 100000.
31+
32+
.PARAMETER MaxAliases
33+
Maximum number of alias nodes in the stream. The default is 1000.
34+
35+
.PARAMETER MaxScalarLength
36+
Maximum decoded character count for one scalar. The default is
37+
1048576.
38+
39+
.PARAMETER MaxTagLength
40+
Maximum expanded character count for one tag. The default is 1024.
41+
42+
.PARAMETER MaxTotalTagLength
43+
Maximum cumulative expanded tag characters. The default is 65536.
44+
45+
.PARAMETER MaxNumericLength
46+
Maximum digits in an implicitly or explicitly typed number. The
47+
default is 4096.
48+
49+
.EXAMPLE
50+
Get-Content -Path '.\config.yaml' | Format-Yaml
51+
52+
Joins the input lines and emits one normalized YAML stream.
53+
54+
.EXAMPLE
55+
$normalized = Format-Yaml -InputObject '{name: Ada, active: true}' -Indent 4
56+
57+
Converts flow presentation to deterministic block presentation with
58+
four-space indentation.
59+
60+
.INPUTS
61+
System.String[]
62+
63+
.OUTPUTS
64+
System.String
65+
#>
66+
[CmdletBinding()]
67+
[OutputType([string])]
68+
param (
69+
[Parameter(Mandatory, Position = 0, ValueFromPipeline)]
70+
[AllowEmptyString()]
71+
[string[]] $InputObject,
72+
73+
[ValidateRange(2, 9)]
74+
[int] $Indent = 2,
75+
76+
[ValidateRange(1, 128)]
77+
[int] $Depth = 100,
78+
79+
[ValidateRange(1, 2147483647)]
80+
[int] $MaxNodes = 100000,
81+
82+
[ValidateRange(0, 2147483647)]
83+
[int] $MaxAliases = 1000,
84+
85+
[ValidateRange(1, 2147483647)]
86+
[int] $MaxScalarLength = 1048576,
87+
88+
[ValidateRange(1, 1048576)]
89+
[int] $MaxTagLength = 1024,
90+
91+
[ValidateRange(1, 2147483647)]
92+
[int] $MaxTotalTagLength = 65536,
93+
94+
[ValidateRange(1, 1048576)]
95+
[int] $MaxNumericLength = 4096
96+
)
97+
98+
begin {
99+
$lines = [System.Collections.Generic.List[string]]::new()
100+
}
101+
process {
102+
foreach ($line in $InputObject) {
103+
$lines.Add($line)
104+
}
105+
}
106+
end {
107+
$yamlText = $lines -join "`n"
108+
try {
109+
$documentBox = Read-YamlStream -Yaml $yamlText -Depth $Depth -MaxNodes $MaxNodes `
110+
-MaxAliases $MaxAliases -MaxScalarLength $MaxScalarLength -MaxTagLength $MaxTagLength `
111+
-MaxTotalTagLength $MaxTotalTagLength -MaxNumericLength $MaxNumericLength
112+
$formatted = ConvertTo-YamlRepresentationText -Documents $documentBox.Value -Indent $Indent
113+
$PSCmdlet.WriteObject($formatted, $false)
114+
} catch {
115+
if (-not $_.Exception.Data.Contains('IsYamlException')) {
116+
throw
117+
}
118+
$record = New-YamlErrorRecord -Exception $_.Exception -DefaultErrorId 'YamlInvalidInput' `
119+
-Category InvalidData -TargetObject $yamlText
120+
$PSCmdlet.ThrowTerminatingError($record)
121+
}
122+
}
123+
}

0 commit comments

Comments
 (0)