From da33548f31b6058a958d1650af3af88f930d119b Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 2 May 2026 16:38:22 +0200 Subject: [PATCH 01/21] Add ConvertFrom-Yaml and ConvertTo-Yaml Pester tests and remove placeholder --- src/functions/public/Get-PSModuleTest.ps1 | 23 -- tests/ConvertFrom-Yaml.Tests.ps1 | 272 ++++++++++++++++++++++ tests/ConvertTo-Yaml.Tests.ps1 | 247 ++++++++++++++++++++ tests/PSModuleTest.Tests.ps1 | 16 -- 4 files changed, 519 insertions(+), 39 deletions(-) delete mode 100644 src/functions/public/Get-PSModuleTest.ps1 create mode 100644 tests/ConvertFrom-Yaml.Tests.ps1 create mode 100644 tests/ConvertTo-Yaml.Tests.ps1 delete mode 100644 tests/PSModuleTest.Tests.ps1 diff --git a/src/functions/public/Get-PSModuleTest.ps1 b/src/functions/public/Get-PSModuleTest.ps1 deleted file mode 100644 index ffe3483..0000000 --- a/src/functions/public/Get-PSModuleTest.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -#Requires -Modules Utilities - -function Get-PSModuleTest { - <# - .SYNOPSIS - Performs tests on a module. - - .DESCRIPTION - Performs tests on a module. - - .EXAMPLE - Test-PSModule -Name 'World' - - "Hello, World!" - #> - [CmdletBinding()] - param ( - # Name of the person to greet. - [Parameter(Mandatory)] - [string] $Name - ) - Write-Output "Hello, $Name!" -} diff --git a/tests/ConvertFrom-Yaml.Tests.ps1 b/tests/ConvertFrom-Yaml.Tests.ps1 new file mode 100644 index 0000000..876c4ef --- /dev/null +++ b/tests/ConvertFrom-Yaml.Tests.ps1 @@ -0,0 +1,272 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Required for Pester tests' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester tests' +)] +[CmdletBinding()] +param() + +Describe 'ConvertFrom-Yaml' { + + Context 'Scalars' { + It 'Parses a simple string value' { + $result = 'name: World' | ConvertFrom-Yaml + $result.name | Should -Be 'World' + } + + It 'Parses an integer value' { + $result = 'count: 42' | ConvertFrom-Yaml + $result.count | Should -Be 42 + $result.count | Should -BeOfType [int] + } + + It 'Parses a floating-point value' { + $result = 'ratio: 1.5' | ConvertFrom-Yaml + $result.ratio | Should -Be 1.5 + $result.ratio | Should -BeOfType [double] + } + + It 'Parses boolean true variants' { + $result = "a: true`nb: True`nc: yes" | ConvertFrom-Yaml + $result.a | Should -BeTrue + $result.b | Should -BeTrue + $result.c | Should -BeTrue + } + + It 'Parses boolean false variants' { + $result = "a: false`nb: False`nc: no" | ConvertFrom-Yaml + $result.a | Should -BeFalse + $result.b | Should -BeFalse + $result.c | Should -BeFalse + } + + It 'Parses null values' { + $result = "a: null`nb: ~`nc:" | ConvertFrom-Yaml + $result.a | Should -BeNullOrEmpty + $result.b | Should -BeNullOrEmpty + $result.c | Should -BeNullOrEmpty + } + + It 'Parses single-quoted strings preserving content' { + $result = "value: 'true'" | ConvertFrom-Yaml + $result.value | Should -Be 'true' + $result.value | Should -BeOfType [string] + } + + It 'Parses double-quoted strings preserving content' { + $result = 'value: "42"' | ConvertFrom-Yaml + $result.value | Should -Be '42' + $result.value | Should -BeOfType [string] + } + + It 'Handles double-quoted escape sequences' { + $result = 'value: "line1\nline2\ttab"' | ConvertFrom-Yaml + $result.value | Should -Be "line1`nline2`ttab" + } + } + + Context 'Mappings' { + It 'Parses a flat mapping into a PSCustomObject by default' { + $yaml = @" +name: Alice +age: 30 +"@ + $result = $yaml | ConvertFrom-Yaml + $result | Should -BeOfType [PSCustomObject] + $result.name | Should -Be 'Alice' + $result.age | Should -Be 30 + } + + It 'Parses nested mappings' { + $yaml = @" +person: + name: Alice + address: + city: Oslo + country: Norway +"@ + $result = $yaml | ConvertFrom-Yaml + $result.person.name | Should -Be 'Alice' + $result.person.address.city | Should -Be 'Oslo' + $result.person.address.country | Should -Be 'Norway' + } + + It 'Preserves key insertion order' { + $yaml = @" +zebra: 1 +apple: 2 +mango: 3 +"@ + $result = $yaml | ConvertFrom-Yaml + $names = $result.PSObject.Properties.Name + $names[0] | Should -Be 'zebra' + $names[1] | Should -Be 'apple' + $names[2] | Should -Be 'mango' + } + } + + Context 'Sequences' { + It 'Parses a sequence of scalars' { + $yaml = @" +- one +- two +- three +"@ + $result = $yaml | ConvertFrom-Yaml -NoEnumerate + $result.Count | Should -Be 3 + $result[0] | Should -Be 'one' + $result[2] | Should -Be 'three' + } + + It 'Parses a sequence under a key' { + $yaml = @" +items: + - apple + - banana + - cherry +"@ + $result = $yaml | ConvertFrom-Yaml + $result.items.Count | Should -Be 3 + $result.items[1] | Should -Be 'banana' + } + + It 'Parses a sequence of mappings' { + $yaml = @" +people: + - name: Alice + age: 30 + - name: Bob + age: 25 +"@ + $result = $yaml | ConvertFrom-Yaml + $result.people.Count | Should -Be 2 + $result.people[0].name | Should -Be 'Alice' + $result.people[1].age | Should -Be 25 + } + } + + Context '-AsHashtable' { + It 'Returns an ordered dictionary instead of PSCustomObject' { + $result = "a: 1`nb: 2" | ConvertFrom-Yaml -AsHashtable + $result | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + $result['a'] | Should -Be 1 + $result['b'] | Should -Be 2 + } + + It 'Returns nested structures as ordered dictionaries' { + $yaml = @" +outer: + inner: + leaf: value +"@ + $result = $yaml | ConvertFrom-Yaml -AsHashtable + $result['outer'] | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + $result['outer']['inner']['leaf'] | Should -Be 'value' + } + + It 'Preserves key insertion order' { + $result = "zebra: 1`napple: 2" | ConvertFrom-Yaml -AsHashtable + @($result.Keys)[0] | Should -Be 'zebra' + @($result.Keys)[1] | Should -Be 'apple' + } + } + + Context '-NoEnumerate' { + It 'Returns a single-element top-level sequence as an array when -NoEnumerate is set' { + $yaml = "- only" + $result = $yaml | ConvertFrom-Yaml -NoEnumerate + ,$result | Should -BeOfType [System.Object[]] + $result.Count | Should -Be 1 + } + } + + Context 'Frontmatter' { + It 'Parses YAML between --- delimiters' { + $content = @" +--- +title: Hello +draft: false +--- +# Markdown body here + +Some content. +"@ + $result = $content | ConvertFrom-Yaml + $result.title | Should -Be 'Hello' + $result.draft | Should -BeFalse + } + + It 'Parses content that is only frontmatter' { + $content = @" +--- +key: value +--- +"@ + $result = $content | ConvertFrom-Yaml + $result.key | Should -Be 'value' + } + } + + Context 'Comments and blank lines' { + It 'Ignores full-line comments' { + $yaml = @" +# this is a comment +name: Alice +# another comment +age: 30 +"@ + $result = $yaml | ConvertFrom-Yaml + $result.name | Should -Be 'Alice' + $result.age | Should -Be 30 + } + + It 'Ignores inline comments after values' { + $result = 'name: Alice # the user' | ConvertFrom-Yaml + $result.name | Should -Be 'Alice' + } + + It 'Ignores blank lines' { + $yaml = @" +name: Alice + +age: 30 + +"@ + $result = $yaml | ConvertFrom-Yaml + $result.name | Should -Be 'Alice' + $result.age | Should -Be 30 + } + } + + Context '-Depth' { + It 'Throws when nesting exceeds -Depth' { + $yaml = @" +a: + b: + c: + d: value +"@ + { $yaml | ConvertFrom-Yaml -Depth 2 } | Should -Throw + } + + It 'Allows nesting within -Depth' { + $yaml = @" +a: + b: + c: value +"@ + $result = $yaml | ConvertFrom-Yaml -Depth 5 + $result.a.b.c | Should -Be 'value' + } + } + + Context 'Aliases' { + It 'Has ConvertFrom-Yml as an alias' { + (Get-Alias -Name ConvertFrom-Yml -ErrorAction SilentlyContinue).ResolvedCommand.Name | + Should -Be 'ConvertFrom-Yaml' + } + } +} diff --git a/tests/ConvertTo-Yaml.Tests.ps1 b/tests/ConvertTo-Yaml.Tests.ps1 new file mode 100644 index 0000000..d939af8 --- /dev/null +++ b/tests/ConvertTo-Yaml.Tests.ps1 @@ -0,0 +1,247 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Required for Pester tests' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Required for Pester tests' +)] +[CmdletBinding()] +param() + +Describe 'ConvertTo-Yaml' { + + Context 'Scalars' { + It 'Returns a string' { + $yaml = @{ name = 'Alice' } | ConvertTo-Yaml + $yaml | Should -BeOfType [string] + } + + It 'Renders an integer without quotes' { + $yaml = @{ count = 42 } | ConvertTo-Yaml + $yaml.Trim() | Should -Be 'count: 42' + } + + It 'Renders a double without quotes' { + $yaml = @{ ratio = 1.5 } | ConvertTo-Yaml + $yaml.Trim() | Should -Be 'ratio: 1.5' + } + + It 'Renders booleans as lowercase true/false' { + $yaml = ([ordered]@{ a = $true; b = $false }) | ConvertTo-Yaml + $yaml | Should -Match 'a: true' + $yaml | Should -Match 'b: false' + } + + It 'Renders $null as the literal null' { + $yaml = ([ordered]@{ a = $null }) | ConvertTo-Yaml + $yaml.Trim() | Should -Be 'a: null' + } + + It 'Quotes strings that look like booleans to preserve type' { + $yaml = @{ value = 'true' } | ConvertTo-Yaml + $yaml.Trim() | Should -Match "value:\s+(""true""|'true')" + } + + It 'Quotes strings that look like numbers to preserve type' { + $yaml = @{ value = '42' } | ConvertTo-Yaml + $yaml.Trim() | Should -Match "value:\s+(""42""|'42')" + } + + It 'Quotes strings that look like null to preserve type' { + $yaml = @{ value = 'null' } | ConvertTo-Yaml + $yaml.Trim() | Should -Match "value:\s+(""null""|'null')" + } + + It 'Renders strings with special characters using double quotes and escapes' { + $yaml = @{ value = "line1`nline2" } | ConvertTo-Yaml + $yaml | Should -Match '"line1\\nline2"' + } + } + + Context 'Mappings' { + It 'Renders a flat hashtable' { + $yaml = ([ordered]@{ name = 'Alice'; age = 30 }) | ConvertTo-Yaml + $lines = $yaml.TrimEnd("`r","`n") -split "`r?`n" + $lines[0] | Should -Be 'name: Alice' + $lines[1] | Should -Be 'age: 30' + } + + It 'Renders nested mappings with 2-space indent by default' { + $obj = [ordered]@{ + person = [ordered]@{ + name = 'Alice' + address = [ordered]@{ + city = 'Oslo' + } + } + } + $yaml = $obj | ConvertTo-Yaml + $yaml | Should -Match '(?m)^person:' + $yaml | Should -Match '(?m)^ name: Alice' + $yaml | Should -Match '(?m)^ address:' + $yaml | Should -Match '(?m)^ city: Oslo' + } + + It 'Renders a PSCustomObject' { + $obj = [PSCustomObject]@{ name = 'Alice'; age = 30 } + $yaml = $obj | ConvertTo-Yaml + $yaml | Should -Match 'name: Alice' + $yaml | Should -Match 'age: 30' + } + } + + Context 'Sequences' { + It 'Renders a top-level array as a YAML sequence' { + $yaml = ConvertTo-Yaml -InputObject @('one', 'two', 'three') + $yaml | Should -Match '(?m)^- one' + $yaml | Should -Match '(?m)^- two' + $yaml | Should -Match '(?m)^- three' + } + + It 'Renders an array under a key' { + $obj = @{ items = @('a', 'b', 'c') } + $yaml = $obj | ConvertTo-Yaml + $yaml | Should -Match '(?m)^items:' + $yaml | Should -Match '(?m)^ - a' + $yaml | Should -Match '(?m)^ - b' + } + + It 'Renders a sequence of mappings' { + $obj = @{ + people = @( + [ordered]@{ name = 'Alice'; age = 30 } + [ordered]@{ name = 'Bob'; age = 25 } + ) + } + $yaml = $obj | ConvertTo-Yaml + $yaml | Should -Match '(?m)^people:' + $yaml | Should -Match '(?m)^ - name: Alice' + $yaml | Should -Match '(?m)^ age: 30' + $yaml | Should -Match '(?m)^ - name: Bob' + } + } + + Context '-AsArray' { + It 'Wraps a single object in a top-level sequence' { + $obj = [ordered]@{ name = 'Alice' } + $yaml = ConvertTo-Yaml -InputObject $obj -AsArray + $yaml | Should -Match '(?m)^- name: Alice' + } + } + + Context '-Indent' { + It 'Uses 4 spaces when -Indent 4 is specified' { + $obj = [ordered]@{ + outer = [ordered]@{ + inner = 'value' + } + } + $yaml = $obj | ConvertTo-Yaml -Indent 4 + $yaml | Should -Match '(?m)^outer:' + $yaml | Should -Match '(?m)^ inner: value' + } + } + + Context '-EnumsAsStrings' { + It 'Renders enum as string name when -EnumsAsStrings is set' { + $obj = [ordered]@{ day = [System.DayOfWeek]::Monday } + $yaml = $obj | ConvertTo-Yaml -EnumsAsStrings + $yaml.Trim() | Should -Be 'day: Monday' + } + + It 'Renders enum as integer value by default' { + $obj = [ordered]@{ day = [System.DayOfWeek]::Monday } + $yaml = $obj | ConvertTo-Yaml + $yaml.Trim() | Should -Be 'day: 1' + } + } + + Context '-Depth' { + It 'Truncates objects deeper than -Depth via ToString()' { + $obj = [ordered]@{ + a = [ordered]@{ + b = [ordered]@{ + c = 'value' + } + } + } + { $obj | ConvertTo-Yaml -Depth 1 -WarningAction SilentlyContinue } | Should -Not -Throw + } + + It 'Renders within -Depth' { + $obj = [ordered]@{ + a = [ordered]@{ + b = 'value' + } + } + $yaml = $obj | ConvertTo-Yaml -Depth 5 + $yaml | Should -Match 'b: value' + } + } + + Context 'Aliases' { + It 'Has ConvertTo-Yml as an alias' { + (Get-Alias -Name ConvertTo-Yml -ErrorAction SilentlyContinue).ResolvedCommand.Name | + Should -Be 'ConvertTo-Yaml' + } + } +} + +Describe 'Round-trip ConvertTo-Yaml | ConvertFrom-Yaml' { + It 'Preserves a flat mapping' { + $obj = [ordered]@{ name = 'Alice'; age = 30; active = $true } + $result = $obj | ConvertTo-Yaml | ConvertFrom-Yaml -AsHashtable + $result['name'] | Should -Be 'Alice' + $result['age'] | Should -Be 30 + $result['active'] | Should -BeTrue + } + + It 'Preserves a nested mapping' { + $obj = [ordered]@{ + person = [ordered]@{ + name = 'Alice' + address = [ordered]@{ + city = 'Oslo' + country = 'Norway' + } + } + } + $result = $obj | ConvertTo-Yaml | ConvertFrom-Yaml -AsHashtable + $result['person']['name'] | Should -Be 'Alice' + $result['person']['address']['city'] | Should -Be 'Oslo' + $result['person']['address']['country'] | Should -Be 'Norway' + } + + It 'Preserves an array under a key' { + $obj = [ordered]@{ items = @('a', 'b', 'c') } + $result = $obj | ConvertTo-Yaml | ConvertFrom-Yaml -AsHashtable + $result['items'].Count | Should -Be 3 + $result['items'][0] | Should -Be 'a' + $result['items'][2] | Should -Be 'c' + } + + It 'Preserves a sequence of mappings' { + $obj = [ordered]@{ + people = @( + [ordered]@{ name = 'Alice'; age = 30 } + [ordered]@{ name = 'Bob'; age = 25 } + ) + } + $result = $obj | ConvertTo-Yaml | ConvertFrom-Yaml -AsHashtable + $result['people'].Count | Should -Be 2 + $result['people'][0]['name'] | Should -Be 'Alice' + $result['people'][1]['age'] | Should -Be 25 + } + + It 'Preserves quoted strings that look like other types' { + $obj = [ordered]@{ a = 'true'; b = '42'; c = 'null' } + $result = $obj | ConvertTo-Yaml | ConvertFrom-Yaml -AsHashtable + $result['a'] | Should -Be 'true' + $result['a'] | Should -BeOfType [string] + $result['b'] | Should -Be '42' + $result['b'] | Should -BeOfType [string] + $result['c'] | Should -Be 'null' + $result['c'] | Should -BeOfType [string] + } +} diff --git a/tests/PSModuleTest.Tests.ps1 b/tests/PSModuleTest.Tests.ps1 deleted file mode 100644 index 8258bb7..0000000 --- a/tests/PSModuleTest.Tests.ps1 +++ /dev/null @@ -1,16 +0,0 @@ -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSReviewUnusedParameter', '', - Justification = 'Required for Pester tests' -)] -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseDeclaredVarsMoreThanAssignments', '', - Justification = 'Required for Pester tests' -)] -[CmdletBinding()] -param() - -Describe 'Module' { - It 'Function: Get-PSModuleTest' { - Get-PSModuleTest -Name 'World' | Should -Be 'Hello, World!' - } -} From c3e8c110c52c7608364a5de7a34e73e1e332a893 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 2 May 2026 16:42:19 +0200 Subject: [PATCH 02/21] Implement ConvertFrom-Yaml with mappings, sequences, scalars, and frontmatter support --- .../private/ConvertFrom-YamlFrontmatter.ps1 | 56 ++++ .../private/ConvertFrom-YamlLineStream.ps1 | 98 ++++++ .../private/ConvertFrom-YamlNode.ps1 | 303 ++++++++++++++++++ src/functions/public/ConvertFrom-Yaml.ps1 | 119 +++++++ 4 files changed, 576 insertions(+) create mode 100644 src/functions/private/ConvertFrom-YamlFrontmatter.ps1 create mode 100644 src/functions/private/ConvertFrom-YamlLineStream.ps1 create mode 100644 src/functions/private/ConvertFrom-YamlNode.ps1 create mode 100644 src/functions/public/ConvertFrom-Yaml.ps1 diff --git a/src/functions/private/ConvertFrom-YamlFrontmatter.ps1 b/src/functions/private/ConvertFrom-YamlFrontmatter.ps1 new file mode 100644 index 0000000..f4e0ca3 --- /dev/null +++ b/src/functions/private/ConvertFrom-YamlFrontmatter.ps1 @@ -0,0 +1,56 @@ +function ConvertFrom-YamlFrontmatter { + <# + .SYNOPSIS + Extracts YAML frontmatter from a string when present, otherwise returns the string unchanged. + + .DESCRIPTION + If the input begins with a `---` line, returns the content between the opening + `---` and the next `---` or `...` line. Anything after the closing delimiter + (typically markdown body) is discarded. + + If no frontmatter delimiter is detected, the original input is returned. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Text + ) + + # Normalize line endings for matching. + $normalized = $Text -replace "`r`n", "`n" + $lines = $normalized -split "`n" + + # Find first non-empty line. + $firstIdx = -1 + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i].Trim().Length -gt 0) { + $firstIdx = $i + break + } + } + + if ($firstIdx -lt 0) { + return $Text + } + + if ($lines[$firstIdx].Trim() -ne '---') { + return $Text + } + + # Find closing delimiter. + for ($j = $firstIdx + 1; $j -lt $lines.Count; $j++) { + $trim = $lines[$j].Trim() + if ($trim -eq '---' -or $trim -eq '...') { + $body = $lines[($firstIdx + 1)..($j - 1)] -join "`n" + return $body + } + } + + # No closing delimiter — treat everything after opening as frontmatter. + if ($firstIdx + 1 -lt $lines.Count) { + return ($lines[($firstIdx + 1)..($lines.Count - 1)] -join "`n") + } + return '' +} diff --git a/src/functions/private/ConvertFrom-YamlLineStream.ps1 b/src/functions/private/ConvertFrom-YamlLineStream.ps1 new file mode 100644 index 0000000..58fb1c9 --- /dev/null +++ b/src/functions/private/ConvertFrom-YamlLineStream.ps1 @@ -0,0 +1,98 @@ +function ConvertFrom-YamlLineStream { + <# + .SYNOPSIS + Splits YAML text into significant lines, dropping comments and blank lines. + + .DESCRIPTION + Returns an array of `[pscustomobject]` records with `Indent`, `Content`, and `Number` properties. + - Lines that are empty or whitespace-only are skipped. + - Lines whose first non-whitespace character is `#` are skipped. + - Inline comments (` #...` outside quotes) are stripped from the content. + - Tabs in indentation are not allowed (YAML spec); they are treated as one space here. + #> + [CmdletBinding()] + [OutputType([System.Collections.Generic.List[pscustomobject]])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Text + ) + + $result = [System.Collections.Generic.List[pscustomobject]]::new() + $normalized = $Text -replace "`r`n", "`n" + $rawLines = $normalized -split "`n" + + for ($i = 0; $i -lt $rawLines.Count; $i++) { + $raw = $rawLines[$i] + + if ([string]::IsNullOrWhiteSpace($raw)) { + continue + } + + # Compute indent (spaces before first non-space). + $indent = 0 + while ($indent -lt $raw.Length -and ($raw[$indent] -eq ' ' -or $raw[$indent] -eq "`t")) { + $indent++ + } + + $content = $raw.Substring($indent) + + if ($content.StartsWith('#')) { + continue + } + + # Strip inline comments while respecting single/double quotes. + $stripped = Remove-YamlInlineComment -Line $content + if ([string]::IsNullOrWhiteSpace($stripped)) { + continue + } + + $result.Add([pscustomobject]@{ + Indent = $indent + Content = $stripped.TrimEnd() + Number = $i + 1 + }) + } + + return , $result +} + +function Remove-YamlInlineComment { + <# + .SYNOPSIS + Removes an unquoted `# comment` suffix from a YAML content line. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Line + ) + + $inSingle = $false + $inDouble = $false + for ($i = 0; $i -lt $Line.Length; $i++) { + $c = $Line[$i] + if ($c -eq '\' -and $inDouble) { + # Skip escaped char inside double quotes. + $i++ + continue + } + if ($c -eq "'" -and -not $inDouble) { + $inSingle = -not $inSingle + continue + } + if ($c -eq '"' -and -not $inSingle) { + $inDouble = -not $inDouble + continue + } + if ($c -eq '#' -and -not $inSingle -and -not $inDouble) { + # Comment must be preceded by whitespace or be at start of line. + if ($i -eq 0 -or $Line[$i - 1] -eq ' ' -or $Line[$i - 1] -eq "`t") { + return $Line.Substring(0, $i) + } + } + } + return $Line +} diff --git a/src/functions/private/ConvertFrom-YamlNode.ps1 b/src/functions/private/ConvertFrom-YamlNode.ps1 new file mode 100644 index 0000000..91fc951 --- /dev/null +++ b/src/functions/private/ConvertFrom-YamlNode.ps1 @@ -0,0 +1,303 @@ +function ConvertFrom-YamlNode { + <# + .SYNOPSIS + Recursive-descent parser for the YAML line stream produced by ConvertFrom-YamlLineStream. + + .DESCRIPTION + Reads a node starting at the current line index and at the given indentation level. + Returns either a mapping (PSCustomObject or OrderedDictionary), a sequence (array), + or a scalar. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [pscustomobject] $Context, + + [Parameter(Mandatory)] + [int] $Indent, + + [Parameter(Mandatory)] + [int] $Depth + ) + + if ($Depth -gt $Context.MaxDepth) { + throw "ConvertFrom-Yaml: maximum nesting depth ($($Context.MaxDepth)) exceeded." + } + + $lines = $Context.Lines + if ($Context.Index -ge $lines.Count) { + return $null + } + + $current = $lines[$Context.Index] + + # Determine node kind from the first line at this indent. + if ($current.Content.StartsWith('- ') -or $current.Content -eq '-') { + return ConvertFrom-YamlSequence -Context $Context -Indent $Indent -Depth $Depth + } + + return ConvertFrom-YamlMapping -Context $Context -Indent $Indent -Depth $Depth +} + +function ConvertFrom-YamlMapping { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [pscustomobject] $Context, + [Parameter(Mandatory)] [int] $Indent, + [Parameter(Mandatory)] [int] $Depth + ) + + $lines = $Context.Lines + $map = [ordered]@{} + + while ($Context.Index -lt $lines.Count) { + $line = $lines[$Context.Index] + if ($line.Indent -lt $Indent) { break } + if ($line.Indent -gt $Indent) { + throw "ConvertFrom-Yaml: unexpected indentation at line $($line.Number)." + } + if ($line.Content.StartsWith('- ') -or $line.Content -eq '-') { + # A sequence at the same indent as a mapping key is a sibling, not part of mapping. + break + } + + $colonIdx = Find-YamlMappingColon -Content $line.Content + if ($colonIdx -lt 0) { + throw "ConvertFrom-Yaml: expected mapping key at line $($line.Number): '$($line.Content)'." + } + + $key = ConvertFrom-YamlScalar -Raw $line.Content.Substring(0, $colonIdx).Trim() + $rest = $line.Content.Substring($colonIdx + 1).Trim() + + $Context.Index++ + + if ($rest.Length -gt 0) { + $map[[string]$key] = ConvertFrom-YamlScalar -Raw $rest + continue + } + + # Value on subsequent indented lines (mapping or sequence) or null. + if ($Context.Index -ge $lines.Count) { + $map[[string]$key] = $null + continue + } + + $next = $lines[$Context.Index] + if ($next.Indent -le $Indent) { + $map[[string]$key] = $null + continue + } + + # Sequences are allowed to start at the same indent as the parent key in YAML. + # We require the child to be indented strictly greater than the key here for clarity. + $childIndent = $next.Indent + $value = ConvertFrom-YamlNode -Context $Context -Indent $childIndent -Depth ($Depth + 1) + $map[[string]$key] = $value + } + + if ($Context.AsHashtable) { + return $map + } + + $obj = [pscustomobject]@{} + foreach ($k in $map.Keys) { + Add-Member -InputObject $obj -MemberType NoteProperty -Name $k -Value $map[$k] + } + return $obj +} + +function ConvertFrom-YamlSequence { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [pscustomobject] $Context, + [Parameter(Mandatory)] [int] $Indent, + [Parameter(Mandatory)] [int] $Depth + ) + + $lines = $Context.Lines + $list = [System.Collections.Generic.List[object]]::new() + + while ($Context.Index -lt $lines.Count) { + $line = $lines[$Context.Index] + if ($line.Indent -lt $Indent) { break } + if ($line.Indent -gt $Indent) { + throw "ConvertFrom-Yaml: unexpected indentation at line $($line.Number)." + } + if (-not ($line.Content.StartsWith('- ') -or $line.Content -eq '-')) { + break + } + + $afterDash = if ($line.Content.Length -ge 2) { $line.Content.Substring(2).TrimEnd() } else { '' } + + if ($afterDash.Length -eq 0) { + # Value on subsequent indented lines. + $Context.Index++ + if ($Context.Index -ge $lines.Count) { + $list.Add($null) + continue + } + $next = $lines[$Context.Index] + if ($next.Indent -le $Indent) { + $list.Add($null) + continue + } + $list.Add((ConvertFrom-YamlNode -Context $Context -Indent $next.Indent -Depth ($Depth + 1))) + continue + } + + # Inline element: could be a scalar, or a mapping like "- key: value" with possibly more + # mapping keys on following lines indented at "Indent + 2" (under the dash). + $colonIdx = Find-YamlMappingColon -Content $afterDash + if ($colonIdx -ge 0) { + # Treat this as a single-line entry into a mapping. Build a synthetic line stream: + # the current "key: value" line gets re-interpreted at indent (Indent + 2), and any + # continuation lines at indent > (Indent + 2) belong to the same mapping. + $childIndent = $Indent + 2 + $synthetic = [pscustomobject]@{ + Indent = $childIndent + Content = $afterDash + Number = $line.Number + } + # Replace current line with synthetic and recurse as a mapping. + $Context.Lines[$Context.Index] = $synthetic + $value = ConvertFrom-YamlMapping -Context $Context -Indent $childIndent -Depth ($Depth + 1) + $list.Add($value) + continue + } + + # Plain scalar element. + $list.Add((ConvertFrom-YamlScalar -Raw $afterDash)) + $Context.Index++ + } + + return , $list.ToArray() +} + +function Find-YamlMappingColon { + <# + .SYNOPSIS + Returns the index of the unquoted `:` separator in a content line, or -1 if not found. + + .DESCRIPTION + The colon must be followed by whitespace or end-of-line for it to be a YAML mapping + separator. Colons inside quoted strings are ignored. + #> + [CmdletBinding()] + [OutputType([int])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Content + ) + + $inSingle = $false + $inDouble = $false + for ($i = 0; $i -lt $Content.Length; $i++) { + $c = $Content[$i] + if ($c -eq '\' -and $inDouble) { $i++; continue } + if ($c -eq "'" -and -not $inDouble) { $inSingle = -not $inSingle; continue } + if ($c -eq '"' -and -not $inSingle) { $inDouble = -not $inDouble; continue } + if ($c -eq ':' -and -not $inSingle -and -not $inDouble) { + if ($i -eq $Content.Length - 1) { return $i } + $next = $Content[$i + 1] + if ($next -eq ' ' -or $next -eq "`t") { return $i } + } + } + return -1 +} + +function ConvertFrom-YamlScalar { + <# + .SYNOPSIS + Converts a raw YAML scalar token into the appropriate PowerShell type. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Raw + ) + + $value = $Raw.Trim() + + if ($value.Length -eq 0) { return $null } + + # Quoted strings. + if ($value.Length -ge 2 -and $value.StartsWith("'") -and $value.EndsWith("'")) { + $inner = $value.Substring(1, $value.Length - 2) + return ($inner -replace "''", "'") + } + if ($value.Length -ge 2 -and $value.StartsWith('"') -and $value.EndsWith('"')) { + $inner = $value.Substring(1, $value.Length - 2) + return (Expand-YamlDoubleQuoted -Text $inner) + } + + # Null literals. + if ($value -in @('~', 'null', 'Null', 'NULL')) { return $null } + + # Boolean literals. + if ($value -in @('true', 'True', 'TRUE', 'yes', 'Yes', 'YES', 'on', 'On', 'ON')) { + return $true + } + if ($value -in @('false', 'False', 'FALSE', 'no', 'No', 'NO', 'off', 'Off', 'OFF')) { + return $false + } + + # Integer. + $intVal = 0 + if ([int]::TryParse($value, [System.Globalization.NumberStyles]::Integer, [cultureinfo]::InvariantCulture, [ref] $intVal)) { + return $intVal + } + $longVal = [long]0 + if ([long]::TryParse($value, [System.Globalization.NumberStyles]::Integer, [cultureinfo]::InvariantCulture, [ref] $longVal)) { + return $longVal + } + + # Float. + $dblVal = 0.0 + if ([double]::TryParse($value, [System.Globalization.NumberStyles]::Float, [cultureinfo]::InvariantCulture, [ref] $dblVal)) { + return $dblVal + } + + # Plain string. + return $value +} + +function Expand-YamlDoubleQuoted { + <# + .SYNOPSIS + Expands escape sequences inside a double-quoted YAML scalar. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Text + ) + + $sb = [System.Text.StringBuilder]::new() + $i = 0 + while ($i -lt $Text.Length) { + $c = $Text[$i] + if ($c -eq '\' -and $i + 1 -lt $Text.Length) { + $next = $Text[$i + 1] + $expanded = $true + if ($next -eq 'n') { $null = $sb.Append("`n") } + elseif ($next -eq 't') { $null = $sb.Append("`t") } + elseif ($next -eq 'r') { $null = $sb.Append("`r") } + elseif ($next -eq '"') { $null = $sb.Append('"') } + elseif ($next -eq '\') { $null = $sb.Append('\') } + elseif ($next -eq '0') { $null = $sb.Append([char]0) } + else { $expanded = $false } + + if ($expanded) { + $i += 2 + continue + } + } + $null = $sb.Append($c) + $i++ + } + return $sb.ToString() +} diff --git a/src/functions/public/ConvertFrom-Yaml.ps1 b/src/functions/public/ConvertFrom-Yaml.ps1 new file mode 100644 index 0000000..d9b4aea --- /dev/null +++ b/src/functions/public/ConvertFrom-Yaml.ps1 @@ -0,0 +1,119 @@ +function ConvertFrom-Yaml { + <# + .SYNOPSIS + Converts a YAML-formatted string to a PowerShell object. + + .DESCRIPTION + Parses a YAML document and returns a `[PSCustomObject]` (default) or an + `[ordered]` hashtable when `-AsHashtable` is specified. + + Supports a useful subset of YAML 1.2: + - Block-style mappings (key: value) + - Block-style sequences (- item) + - Nested structures + - Scalars: strings, integers, floats, booleans, null + - Single- and double-quoted strings (with `\n`, `\t`, `\r`, `\\`, `\"` in double quotes) + - YAML frontmatter delimited by `---` (typical in markdown) + - Full-line comments (`#`) and inline comments after values + + Out of scope for this version: flow style (`[a, b]`, `{a: 1}`), block scalars + (`|`, `>`), anchors/aliases, tags, multi-document streams, and `!!timestamp`. + + .PARAMETER InputObject + The YAML content as a string. Accepts pipeline input. + + .PARAMETER AsHashtable + Returns an `[ordered]` hashtable (`OrderedDictionary`) instead of a `[PSCustomObject]`. + + .PARAMETER NoEnumerate + When the top-level YAML node is a sequence, prevents PowerShell from unwrapping + a single-element result into a scalar. + + .PARAMETER Depth + Maximum nesting depth allowed. Throws when exceeded. Default: 1024. + + .EXAMPLE + 'name: Alice' | ConvertFrom-Yaml + + Returns a PSCustomObject with a `name` property set to `Alice`. + + .EXAMPLE + Get-Content config.yaml -Raw | ConvertFrom-Yaml -AsHashtable + + Reads a YAML file and returns it as an ordered hashtable. + + .EXAMPLE + Get-Content post.md -Raw | ConvertFrom-Yaml + + Extracts and parses the YAML frontmatter from a markdown file. + + .OUTPUTS + System.Management.Automation.PSCustomObject + + .OUTPUTS + System.Collections.Specialized.OrderedDictionary + #> + [Alias('ConvertFrom-Yml')] + [CmdletBinding()] + [OutputType([PSCustomObject], [System.Collections.Specialized.OrderedDictionary])] + param( + [Parameter(Mandatory, ValueFromPipeline, Position = 0)] + [AllowEmptyString()] + [string] $InputObject, + + [Parameter()] + [switch] $AsHashtable, + + [Parameter()] + [switch] $NoEnumerate, + + [Parameter()] + [ValidateRange(1, [int]::MaxValue)] + [int] $Depth = 1024 + ) + + begin { + $buffer = [System.Text.StringBuilder]::new() + } + + process { + if ($null -ne $InputObject) { + if ($buffer.Length -gt 0) { + $null = $buffer.AppendLine() + } + $null = $buffer.Append($InputObject) + } + } + + end { + $text = $buffer.ToString() + + if ([string]::IsNullOrWhiteSpace($text)) { + return $null + } + + # Strip frontmatter delimiters: leading "---" line and trailing "---" line. + $text = ConvertFrom-YamlFrontmatter -Text $text + + # Pre-process into logical lines (drop comments and blank lines, keep indentation). + $lines = ConvertFrom-YamlLineStream -Text $text + if ($lines.Count -eq 0) { + return $null + } + + $context = [pscustomobject]@{ + Lines = $lines + Index = 0 + AsHashtable = [bool] $AsHashtable + MaxDepth = $Depth + } + + $result = ConvertFrom-YamlNode -Context $context -Indent 0 -Depth 0 + + if ($NoEnumerate -and $result -is [System.Collections.IList]) { + return , $result + } + + return $result + } +} From b4245b4d4bc9573b75f66c1cb0fc481a9c3992f2 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 2 May 2026 16:44:56 +0200 Subject: [PATCH 03/21] Implement ConvertTo-Yaml with mappings, sequences, scalars, and quoting safety --- src/functions/private/ConvertTo-YamlNode.ps1 | 454 +++++++++++++++++++ src/functions/public/ConvertTo-Yaml.ps1 | 91 ++++ 2 files changed, 545 insertions(+) create mode 100644 src/functions/private/ConvertTo-YamlNode.ps1 create mode 100644 src/functions/public/ConvertTo-Yaml.ps1 diff --git a/src/functions/private/ConvertTo-YamlNode.ps1 b/src/functions/private/ConvertTo-YamlNode.ps1 new file mode 100644 index 0000000..e631bab --- /dev/null +++ b/src/functions/private/ConvertTo-YamlNode.ps1 @@ -0,0 +1,454 @@ +function ConvertTo-YamlNode { + <# + .SYNOPSIS + Recursively writes a value as a YAML block-style node into the supplied StringBuilder. + #> + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Value, + + [Parameter(Mandatory)] + [System.Text.StringBuilder] $Builder, + + [Parameter(Mandatory)] + [int] $Level, + + [Parameter(Mandatory)] + [int] $CurrentDepth, + + [Parameter(Mandatory)] + [pscustomobject] $Options + ) + + if ($CurrentDepth -gt $Options.Depth) { + $repr = if ($null -eq $Value) { 'null' } else { Format-YamlScalar -Value $Value.ToString() -Options $Options } + $null = $Builder.Append($repr).AppendLine() + return + } + + if ($null -eq $Value) { + $null = $Builder.Append('null').AppendLine() + return + } + + # Unwrap PSObject for type tests. + $raw = if ($Value -is [psobject] -and $null -ne $Value.PSObject -and $null -ne $Value.PSObject.BaseObject) { + $Value.PSObject.BaseObject + } else { + $Value + } + + if (Test-YamlMappingType -Value $raw) { + ConvertTo-YamlMapping -Value $Value -Builder $Builder -Level $Level -CurrentDepth $CurrentDepth -Options $Options + return + } + + if (Test-YamlSequenceType -Value $raw) { + ConvertTo-YamlSequence -Value $raw -Builder $Builder -Level $Level -CurrentDepth $CurrentDepth -Options $Options + return + } + + # Scalar. + $scalar = Format-YamlScalar -Value $raw -Options $Options + $null = $Builder.Append($scalar).AppendLine() +} + +function Test-YamlMappingType { + [CmdletBinding()] + [OutputType([bool])] + param([Parameter()] [AllowNull()] [object] $Value) + + if ($null -eq $Value) { return $false } + if ($Value -is [System.Collections.IDictionary]) { return $true } + if ($Value -is [string]) { return $false } + if ($Value -is [System.ValueType]) { return $false } + if ($Value -is [System.Collections.IEnumerable]) { return $false } + if ($Value -is [psobject] -or $Value -is [System.Management.Automation.PSCustomObject]) { return $true } + return $false +} + +function Test-YamlSequenceType { + [CmdletBinding()] + [OutputType([bool])] + param([Parameter()] [AllowNull()] [object] $Value) + + if ($null -eq $Value) { return $false } + if ($Value -is [string]) { return $false } + if ($Value -is [System.Collections.IDictionary]) { return $false } + if ($Value -is [System.Collections.IEnumerable]) { return $true } + return $false +} + +function ConvertTo-YamlMapping { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object] $Value, + [Parameter(Mandatory)] [System.Text.StringBuilder] $Builder, + [Parameter(Mandatory)] [int] $Level, + [Parameter(Mandatory)] [int] $CurrentDepth, + [Parameter(Mandatory)] [pscustomobject] $Options + ) + + $pairs = Get-YamlMappingPairs -Value $Value + if ($pairs.Count -eq 0) { + $null = $Builder.Append('{}').AppendLine() + return + } + + $indent = ' ' * ($Level * $Options.Indent) + foreach ($pair in $pairs) { + $keyText = Format-YamlKey -Key $pair.Key -Options $Options + $val = $pair.Value + $null = $Builder.Append($indent).Append($keyText).Append(':') + + if ($null -eq $val) { + $null = $Builder.Append(' null').AppendLine() + continue + } + + $rawVal = if ($val -is [psobject] -and $null -ne $val.PSObject -and $null -ne $val.PSObject.BaseObject) { + $val.PSObject.BaseObject + } else { + $val + } + + if (Test-YamlMappingType -Value $rawVal) { + $childPairs = Get-YamlMappingPairs -Value $val + if ($childPairs.Count -eq 0) { + $null = $Builder.Append(' {}').AppendLine() + } else { + $null = $Builder.AppendLine() + ConvertTo-YamlNode -Value $val -Builder $Builder -Level ($Level + 1) -CurrentDepth ($CurrentDepth + 1) -Options $Options + } + continue + } + + if (Test-YamlSequenceType -Value $rawVal) { + $arr = @($rawVal) + if ($arr.Count -eq 0) { + $null = $Builder.Append(' []').AppendLine() + } else { + $null = $Builder.AppendLine() + ConvertTo-YamlSequence -Value $rawVal -Builder $Builder -Level ($Level + 1) -CurrentDepth ($CurrentDepth + 1) -Options $Options + } + continue + } + + $scalar = Format-YamlScalar -Value $rawVal -Options $Options + $null = $Builder.Append(' ').Append($scalar).AppendLine() + } +} + +function ConvertTo-YamlSequence { + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object] $Value, + [Parameter(Mandatory)] [System.Text.StringBuilder] $Builder, + [Parameter(Mandatory)] [int] $Level, + [Parameter(Mandatory)] [int] $CurrentDepth, + [Parameter(Mandatory)] [pscustomobject] $Options + ) + + $items = @($Value) + if ($items.Count -eq 0) { + $null = $Builder.Append('[]').AppendLine() + return + } + + $indent = ' ' * ($Level * $Options.Indent) + + foreach ($item in $items) { + $rawItem = if ($item -is [psobject] -and $null -ne $item.PSObject -and $null -ne $item.PSObject.BaseObject) { + $item.PSObject.BaseObject + } else { + $item + } + + if ($null -eq $item) { + $null = $Builder.Append($indent).Append('- null').AppendLine() + continue + } + + if (Test-YamlMappingType -Value $rawItem) { + $pairs = Get-YamlMappingPairs -Value $item + if ($pairs.Count -eq 0) { + $null = $Builder.Append($indent).Append('- {}').AppendLine() + continue + } + $first = $true + $childIndent = ' ' * (($Level + 1) * $Options.Indent) + foreach ($pair in $pairs) { + $keyText = Format-YamlKey -Key $pair.Key -Options $Options + $prefix = if ($first) { "$indent- " } else { $childIndent } + $first = $false + + $val = $pair.Value + $rawVal = if ($val -is [psobject] -and $null -ne $val.PSObject -and $null -ne $val.PSObject.BaseObject) { + $val.PSObject.BaseObject + } else { + $val + } + + if ($null -eq $val) { + $null = $Builder.Append($prefix).Append($keyText).Append(': null').AppendLine() + continue + } + + if (Test-YamlMappingType -Value $rawVal) { + $null = $Builder.Append($prefix).Append($keyText).Append(':').AppendLine() + ConvertTo-YamlNode -Value $val -Builder $Builder -Level ($Level + 2) -CurrentDepth ($CurrentDepth + 1) -Options $Options + continue + } + + if (Test-YamlSequenceType -Value $rawVal) { + $null = $Builder.Append($prefix).Append($keyText).Append(':').AppendLine() + ConvertTo-YamlSequence -Value $rawVal -Builder $Builder -Level ($Level + 2) -CurrentDepth ($CurrentDepth + 1) -Options $Options + continue + } + + $scalar = Format-YamlScalar -Value $rawVal -Options $Options + $null = $Builder.Append($prefix).Append($keyText).Append(': ').Append($scalar).AppendLine() + } + continue + } + + if (Test-YamlSequenceType -Value $rawItem) { + $null = $Builder.Append($indent).Append('-').AppendLine() + ConvertTo-YamlSequence -Value $rawItem -Builder $Builder -Level ($Level + 1) -CurrentDepth ($CurrentDepth + 1) -Options $Options + continue + } + + $scalar = Format-YamlScalar -Value $rawItem -Options $Options + $null = $Builder.Append($indent).Append('- ').Append($scalar).AppendLine() + } +} + +function Get-YamlMappingPairs { + <# + .SYNOPSIS + Returns a list of [pscustomobject]@{ Key; Value } for a dictionary or PSObject. + #> + [CmdletBinding()] + [OutputType([System.Collections.Generic.List[pscustomobject]])] + param( + [Parameter(Mandatory)] + [object] $Value + ) + + $pairs = [System.Collections.Generic.List[pscustomobject]]::new() + $raw = if ($Value -is [psobject] -and $null -ne $Value.PSObject -and $null -ne $Value.PSObject.BaseObject) { + $Value.PSObject.BaseObject + } else { + $Value + } + + if ($raw -is [System.Collections.IDictionary]) { + foreach ($key in $raw.Keys) { + $pairs.Add([pscustomobject]@{ Key = $key; Value = $raw[$key] }) + } + return , $pairs + } + + if ($Value -is [psobject]) { + foreach ($prop in $Value.PSObject.Properties) { + $pairs.Add([pscustomobject]@{ Key = $prop.Name; Value = $prop.Value }) + } + } + + return , $pairs +} + +function Format-YamlScalar { + <# + .SYNOPSIS + Renders a scalar value as a YAML token. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter()] [AllowNull()] [object] $Value, + [Parameter(Mandatory)] [pscustomobject] $Options + ) + + if ($null -eq $Value) { return 'null' } + + if ($Value -is [bool]) { return $(if ($Value) { 'true' } else { 'false' }) } + + if ($Value -is [System.Enum]) { + if ($Options.EnumsAsStrings) { + return Format-YamlString -Text ($Value.ToString()) + } + return ([int64] $Value).ToString([cultureinfo]::InvariantCulture) + } + + if ($Value -is [byte] -or $Value -is [sbyte] -or + $Value -is [int16] -or $Value -is [uint16] -or + $Value -is [int] -or $Value -is [uint32] -or + $Value -is [long] -or $Value -is [uint64]) { + return ([System.IConvertible] $Value).ToString([cultureinfo]::InvariantCulture) + } + + if ($Value -is [single] -or $Value -is [double] -or $Value -is [decimal]) { + return ([System.IConvertible] $Value).ToString([cultureinfo]::InvariantCulture) + } + + if ($Value -is [datetime]) { + return $Value.ToString('o', [cultureinfo]::InvariantCulture) + } + + return Format-YamlString -Text ([string] $Value) +} + +function Format-YamlString { + <# + .SYNOPSIS + Renders a string as a YAML scalar, quoting and escaping as needed. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Text + ) + + if ($Text.Length -eq 0) { return "''" } + + # Always double-quote strings that contain control characters or quotes that need escaping. + $needsDoubleQuote = $false + foreach ($ch in $Text.ToCharArray()) { + $code = [int] $ch + if ($code -lt 0x20 -or $code -eq 0x7F) { + $needsDoubleQuote = $true + break + } + } + + if ($needsDoubleQuote) { + return Format-YamlDoubleQuoted -Text $Text + } + + if (Test-YamlPlainSafe -Text $Text) { + return $Text + } + + # Prefer single quotes when the text doesn't contain a single quote; otherwise double-quote. + if ($Text -notmatch "'") { + return "'$Text'" + } + + return Format-YamlDoubleQuoted -Text $Text +} + +function Format-YamlDoubleQuoted { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Text + ) + + $sb = [System.Text.StringBuilder]::new() + $null = $sb.Append('"') + foreach ($ch in $Text.ToCharArray()) { + $code = [int] $ch + if ($ch -eq '\') { $null = $sb.Append('\\'); continue } + if ($ch -eq '"') { $null = $sb.Append('\"'); continue } + if ($ch -eq "`n") { $null = $sb.Append('\n'); continue } + if ($ch -eq "`t") { $null = $sb.Append('\t'); continue } + if ($ch -eq "`r") { $null = $sb.Append('\r'); continue } + if ($code -lt 0x20 -or $code -eq 0x7F) { + $null = $sb.AppendFormat('\x{0:x2}', $code) + continue + } + $null = $sb.Append($ch) + } + $null = $sb.Append('"') + return $sb.ToString() +} + +function Format-YamlKey { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [object] $Key, + [Parameter(Mandatory)] [pscustomobject] $Options + ) + + $text = [string] $Key + if ([string]::IsNullOrEmpty($text)) { return "''" } + if (Test-YamlPlainSafe -Text $text -ForKey) { + return $text + } + if ($text -notmatch "'") { return "'$text'" } + return Format-YamlDoubleQuoted -Text $text +} + +function Test-YamlPlainSafe { + <# + .SYNOPSIS + Returns $true when a string can be emitted as a plain (unquoted) YAML scalar. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Text, + + [Parameter()] + [switch] $ForKey + ) + + if ($Text.Length -eq 0) { return $false } + if ($Text -ne $Text.Trim()) { return $false } + + # Strings that look like reserved literals must be quoted to preserve the string type. + $reserved = @( + 'true', 'True', 'TRUE', + 'false', 'False', 'FALSE', + 'yes', 'Yes', 'YES', + 'no', 'No', 'NO', + 'on', 'On', 'ON', + 'off', 'Off', 'OFF', + 'null', 'Null', 'NULL', + '~' + ) + if ($Text -in $reserved) { return $false } + + # Strings that parse as a number must be quoted. + $tmpInt = 0L + if ([long]::TryParse($Text, [System.Globalization.NumberStyles]::Integer, [cultureinfo]::InvariantCulture, [ref] $tmpInt)) { + return $false + } + $tmpDbl = 0.0 + if ([double]::TryParse($Text, [System.Globalization.NumberStyles]::Float, [cultureinfo]::InvariantCulture, [ref] $tmpDbl)) { + return $false + } + + # Disallowed leading characters per YAML plain scalar rules. + $first = $Text[0] + $disallowedFirst = @('-', '?', ':', ',', '[', ']', '{', '}', '#', '&', '*', '!', '|', '>', "'", '"', '%', '@', '`') + if ($disallowedFirst -contains [string] $first) { return $false } + + foreach ($ch in $Text.ToCharArray()) { + $code = [int] $ch + if ($code -lt 0x20 -or $code -eq 0x7F) { return $false } + } + + # Disallowed characters anywhere (would confuse parsing). + if ($Text -match '[:#]') { + # ': ' or ' #' would be ambiguous; conservatively quote whenever ':' or '#' appear. + return $false + } + + if ($ForKey) { + if ($Text -match '[\[\]\{\},&*!|>''"%@`]') { return $false } + } + + return $true +} diff --git a/src/functions/public/ConvertTo-Yaml.ps1 b/src/functions/public/ConvertTo-Yaml.ps1 new file mode 100644 index 0000000..d1e33c4 --- /dev/null +++ b/src/functions/public/ConvertTo-Yaml.ps1 @@ -0,0 +1,91 @@ +function ConvertTo-Yaml { + <# + .SYNOPSIS + Converts a PowerShell object to a YAML-formatted string. + + .DESCRIPTION + Serializes objects, hashtables/dictionaries, and arrays to a block-style YAML + string. Mirrors the parameter shape of `ConvertTo-Json` where applicable. + + Out of scope for this version: flow style (`[a, b]`, `{a: 1}`), block scalars + (`|`, `>`), anchors/aliases, tags, and timestamp formatting. + + .PARAMETER InputObject + The object to serialize. Accepts pipeline input. + + .PARAMETER Depth + Maximum nesting depth to traverse. Objects deeper than this are rendered via + their `.ToString()` representation. Default: 1024. + + .PARAMETER EnumsAsStrings + Renders enum values as their string names instead of their underlying integer values. + + .PARAMETER AsArray + Forces the top-level output to be a YAML sequence even when a single object is provided. + + .PARAMETER Indent + Number of spaces to use per nesting level. Default: 2. + + .EXAMPLE + @{ name = 'Alice'; age = 30 } | ConvertTo-Yaml + + Returns: + name: Alice + age: 30 + + .EXAMPLE + Get-Process | Select-Object -First 3 Name, Id | ConvertTo-Yaml -AsArray + + Serializes a list of objects as a YAML sequence. + + .OUTPUTS + System.String + #> + [Alias('ConvertTo-Yml')] + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory, ValueFromPipeline, Position = 0)] + [AllowNull()] + [object] $InputObject, + + [Parameter()] + [ValidateRange(1, [int]::MaxValue)] + [int] $Depth = 1024, + + [Parameter()] + [switch] $EnumsAsStrings, + + [Parameter()] + [switch] $AsArray, + + [Parameter()] + [ValidateRange(1, 16)] + [int] $Indent = 2 + ) + + begin { + $items = [System.Collections.Generic.List[object]]::new() + } + + process { + $items.Add($InputObject) + } + + end { + $options = [pscustomobject]@{ + Depth = $Depth + EnumsAsStrings = [bool] $EnumsAsStrings + Indent = $Indent + } + + $sb = [System.Text.StringBuilder]::new() + if ($AsArray) { + ConvertTo-YamlSequence -Value $items.ToArray() -Builder $sb -Level 0 -CurrentDepth 0 -Options $options + } else { + $root = if ($items.Count -eq 1) { $items[0] } else { $items.ToArray() } + ConvertTo-YamlNode -Value $root -Builder $sb -Level 0 -CurrentDepth 0 -Options $options + } + return $sb.ToString().TrimEnd("`r", "`n") + } +} From a2e28bcfaf3aafc2d75e565cf90fe05228b8dfcf Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 2 May 2026 16:45:28 +0200 Subject: [PATCH 04/21] Update README and example with Yaml usage --- README.md | 45 ++++++++++++++++++++++++++++------- examples/General.ps1 | 56 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 81 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 96936ee..ba5d2d9 100644 --- a/README.md +++ b/README.md @@ -18,23 +18,52 @@ Import-Module -Name {{ NAME }} ## Usage -Here is a list of example that are typical use cases for the module. +The module provides two cmdlets that mirror PowerShell's built-in `ConvertFrom-Json` / `ConvertTo-Json`: -### Example 1: Greet an entity +| Cmdlet | Alias | Purpose | +| ------------------- | ---------------- | -------------------------------------- | +| `ConvertFrom-Yaml` | `ConvertFrom-Yml` | Parse a YAML string into an object. | +| `ConvertTo-Yaml` | `ConvertTo-Yml` | Serialize an object into a YAML string. | -Provide examples for typical commands that a user would like to do with the module. +### Example 1: Parse a YAML string ```powershell -Greet-Entity -Name 'World' -Hello, World! +$yaml = @' +name: Alice +age: 30 +skills: + - PowerShell + - YAML +'@ + +$yaml | ConvertFrom-Yaml ``` -### Example 2 +### Example 2: Parse YAML as an ordered hashtable -Provide examples for typical commands that a user would like to do with the module. +```powershell +Get-Content config.yaml -Raw | ConvertFrom-Yaml -AsHashtable +``` + +### Example 3: Parse YAML frontmatter from a markdown file + +```powershell +Get-Content post.md -Raw | ConvertFrom-Yaml +``` + +### Example 4: Convert an object to YAML + +```powershell +[ordered]@{ + name = 'Alice' + skills = @('PowerShell', 'YAML') +} | ConvertTo-Yaml +``` + +### Example 5: Force a top-level YAML sequence ```powershell -Import-Module -Name PSModuleTemplate +Get-Process | Select-Object -First 3 Name, Id | ConvertTo-Yaml -AsArray ``` ### Find more examples diff --git a/examples/General.ps1 b/examples/General.ps1 index e193423..727ddcc 100644 --- a/examples/General.ps1 +++ b/examples/General.ps1 @@ -1,19 +1,51 @@ <# - .SYNOPSIS - This is a general example of how to use the module. + .SYNOPSIS + Example usage of the Yaml module: parse YAML to objects and convert objects back to YAML. #> -# Import the module -Import-Module -Name 'PSModule' +Import-Module -Name 'Yaml' -# Define the path to the font file -$FontFilePath = 'C:\Fonts\CodeNewRoman\CodeNewRomanNerdFontPropo-Regular.tff' +# 1. Parse a YAML string into a PSCustomObject +$yaml = @' +name: Alice +age: 30 +skills: + - PowerShell + - YAML +'@ -# Install the font -Install-Font -Path $FontFilePath -Verbose +$person = $yaml | ConvertFrom-Yaml +$person.name # Alice +$person.skills[0] # PowerShell -# List installed fonts -Get-Font -Name 'CodeNewRomanNerdFontPropo-Regular' +# 2. Parse YAML as an ordered hashtable +$hash = $yaml | ConvertFrom-Yaml -AsHashtable +$hash['age'] # 30 -# Uninstall the font -Get-Font -Name 'CodeNewRomanNerdFontPropo-Regular' | Uninstall-Font -Verbose +# 3. Parse YAML frontmatter from a markdown document +$markdown = @' +--- +title: Hello world +draft: false +--- +# Body + +Markdown content here. +'@ + +$frontmatter = $markdown | ConvertFrom-Yaml +$frontmatter.title # Hello world + +# 4. Convert an object to YAML +[ordered]@{ + name = 'Alice' + age = 30 + skills = @('PowerShell', 'YAML') +} | ConvertTo-Yaml + +# 5. Force a top-level sequence with -AsArray +Get-Process | Select-Object -First 3 Name, Id | ConvertTo-Yaml -AsArray + +# 6. Round-trip +$obj = [ordered]@{ a = 1; b = @('x', 'y') } +$obj | ConvertTo-Yaml | ConvertFrom-Yaml -AsHashtable From 4093211fc8ebfcbde6559f92d20515846ef5466c Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 2 May 2026 17:52:35 +0200 Subject: [PATCH 05/21] Align scalar resolution with YAML 1.2.2 core schema (case-sensitive true/false/null) --- README.md | 14 ++++++++ .../private/ConvertFrom-YamlNode.ps1 | 14 +++----- src/functions/private/ConvertTo-YamlNode.ps1 | 17 +++------- src/functions/public/ConvertFrom-Yaml.ps1 | 6 ++-- tests/ConvertFrom-Yaml.Tests.ps1 | 33 ++++++++++++++----- tests/ConvertTo-Yaml.Tests.ps1 | 8 ++--- 6 files changed, 56 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index ba5d2d9..797812b 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ ## Prerequisites This uses the following external resources: + - The [PSModule framework](https://github.com/PSModule) for building, testing and publishing the module. ## Installation @@ -25,6 +26,19 @@ The module provides two cmdlets that mirror PowerShell's built-in `ConvertFrom-J | `ConvertFrom-Yaml` | `ConvertFrom-Yml` | Parse a YAML string into an object. | | `ConvertTo-Yaml` | `ConvertTo-Yml` | Serialize an object into a YAML string. | +### YAML specification + +The module aligns with [**YAML 1.2.2**](https://yaml.org/spec/1.2.2/) (October 2021) — the latest revision of the YAML specification — and follows the [**core schema**](https://yaml.org/spec/1.2.2/#103-core-schema) for scalar resolution. + +Practical implications of the core schema: + +- `true` and `false` (lowercase only) parse as `[bool]`. `True`, `TRUE`, `yes`, `no`, `on`, `off`, etc. are plain strings. +- `null`, `~`, and an empty value parse as `$null`. `Null`, `NULL` are plain strings. +- Integers and floats parse to their native types using invariant culture. +- Anything else is a string. Quoted strings (`'...'`, `"..."`) always preserve the string type. + +The supported YAML subset covers block-style mappings, block-style sequences, nested structures, single- and double-quoted scalars (with `\n`, `\t`, `\r`, `\\`, `\"` escapes in double quotes), and full-line / inline `#` comments. Flow style (`[a, b]`, `{a: 1}`), block scalars (`|`, `>`), anchors, aliases, tags, multi-document streams, and `!!timestamp` are not yet supported. + ### Example 1: Parse a YAML string ```powershell diff --git a/src/functions/private/ConvertFrom-YamlNode.ps1 b/src/functions/private/ConvertFrom-YamlNode.ps1 index 91fc951..e70f0e3 100644 --- a/src/functions/private/ConvertFrom-YamlNode.ps1 +++ b/src/functions/private/ConvertFrom-YamlNode.ps1 @@ -232,16 +232,12 @@ function ConvertFrom-YamlScalar { return (Expand-YamlDoubleQuoted -Text $inner) } - # Null literals. - if ($value -in @('~', 'null', 'Null', 'NULL')) { return $null } + # Null literal (YAML 1.2.2 core schema): empty, ~, null only. Case-sensitive. + if ($value -ceq '~' -or $value -ceq 'null') { return $null } - # Boolean literals. - if ($value -in @('true', 'True', 'TRUE', 'yes', 'Yes', 'YES', 'on', 'On', 'ON')) { - return $true - } - if ($value -in @('false', 'False', 'FALSE', 'no', 'No', 'NO', 'off', 'Off', 'OFF')) { - return $false - } + # Boolean literal (YAML 1.2.2 core schema): true / false only. Case-sensitive. + if ($value -ceq 'true') { return $true } + if ($value -ceq 'false') { return $false } # Integer. $intVal = 0 diff --git a/src/functions/private/ConvertTo-YamlNode.ps1 b/src/functions/private/ConvertTo-YamlNode.ps1 index e631bab..902b5bb 100644 --- a/src/functions/private/ConvertTo-YamlNode.ps1 +++ b/src/functions/private/ConvertTo-YamlNode.ps1 @@ -407,18 +407,11 @@ function Test-YamlPlainSafe { if ($Text.Length -eq 0) { return $false } if ($Text -ne $Text.Trim()) { return $false } - # Strings that look like reserved literals must be quoted to preserve the string type. - $reserved = @( - 'true', 'True', 'TRUE', - 'false', 'False', 'FALSE', - 'yes', 'Yes', 'YES', - 'no', 'No', 'NO', - 'on', 'On', 'ON', - 'off', 'Off', 'OFF', - 'null', 'Null', 'NULL', - '~' - ) - if ($Text -in $reserved) { return $false } + # Strings that match YAML 1.2.2 core schema literals must be quoted to preserve string type. + # Comparison is case-sensitive — only the lowercase canonical forms are recognised by parsers. + if ($Text -ceq 'true' -or $Text -ceq 'false' -or $Text -ceq 'null' -or $Text -ceq '~') { + return $false + } # Strings that parse as a number must be quoted. $tmpInt = 0L diff --git a/src/functions/public/ConvertFrom-Yaml.ps1 b/src/functions/public/ConvertFrom-Yaml.ps1 index d9b4aea..66b6b11 100644 --- a/src/functions/public/ConvertFrom-Yaml.ps1 +++ b/src/functions/public/ConvertFrom-Yaml.ps1 @@ -102,10 +102,10 @@ } $context = [pscustomobject]@{ - Lines = $lines - Index = 0 + Lines = $lines + Index = 0 AsHashtable = [bool] $AsHashtable - MaxDepth = $Depth + MaxDepth = $Depth } $result = ConvertFrom-YamlNode -Context $context -Indent 0 -Depth 0 diff --git a/tests/ConvertFrom-Yaml.Tests.ps1 b/tests/ConvertFrom-Yaml.Tests.ps1 index 876c4ef..eaeabbb 100644 --- a/tests/ConvertFrom-Yaml.Tests.ps1 +++ b/tests/ConvertFrom-Yaml.Tests.ps1 @@ -29,18 +29,28 @@ Describe 'ConvertFrom-Yaml' { $result.ratio | Should -BeOfType [double] } - It 'Parses boolean true variants' { - $result = "a: true`nb: True`nc: yes" | ConvertFrom-Yaml + It 'Parses boolean true' { + $result = 'a: true' | ConvertFrom-Yaml $result.a | Should -BeTrue - $result.b | Should -BeTrue - $result.c | Should -BeTrue + $result.a | Should -BeOfType [bool] } - It 'Parses boolean false variants' { - $result = "a: false`nb: False`nc: no" | ConvertFrom-Yaml + It 'Parses boolean false' { + $result = 'a: false' | ConvertFrom-Yaml $result.a | Should -BeFalse - $result.b | Should -BeFalse - $result.c | Should -BeFalse + $result.a | Should -BeOfType [bool] + } + + It 'Treats non-canonical boolean-like words as strings (YAML 1.2.2)' { + # YAML 1.2.2 core schema only recognizes lowercase true/false. Everything else is a string. + $result = "a: True`nb: TRUE`nc: yes`nd: No`ne: on`nf: OFF" | ConvertFrom-Yaml + $result.a | Should -Be 'True' + $result.a | Should -BeOfType [string] + $result.b | Should -Be 'TRUE' + $result.c | Should -Be 'yes' + $result.d | Should -Be 'No' + $result.e | Should -Be 'on' + $result.f | Should -Be 'OFF' } It 'Parses null values' { @@ -50,6 +60,13 @@ Describe 'ConvertFrom-Yaml' { $result.c | Should -BeNullOrEmpty } + It 'Treats non-canonical null-like words as strings (YAML 1.2.2)' { + $result = "a: Null`nb: NULL" | ConvertFrom-Yaml + $result.a | Should -Be 'Null' + $result.a | Should -BeOfType [string] + $result.b | Should -Be 'NULL' + } + It 'Parses single-quoted strings preserving content' { $result = "value: 'true'" | ConvertFrom-Yaml $result.value | Should -Be 'true' diff --git a/tests/ConvertTo-Yaml.Tests.ps1 b/tests/ConvertTo-Yaml.Tests.ps1 index d939af8..1272384 100644 --- a/tests/ConvertTo-Yaml.Tests.ps1 +++ b/tests/ConvertTo-Yaml.Tests.ps1 @@ -62,7 +62,7 @@ Describe 'ConvertTo-Yaml' { Context 'Mappings' { It 'Renders a flat hashtable' { $yaml = ([ordered]@{ name = 'Alice'; age = 30 }) | ConvertTo-Yaml - $lines = $yaml.TrimEnd("`r","`n") -split "`r?`n" + $lines = $yaml.TrimEnd("`r", "`n") -split "`r?`n" $lines[0] | Should -Be 'name: Alice' $lines[1] | Should -Be 'age: 30' } @@ -70,7 +70,7 @@ Describe 'ConvertTo-Yaml' { It 'Renders nested mappings with 2-space indent by default' { $obj = [ordered]@{ person = [ordered]@{ - name = 'Alice' + name = 'Alice' address = [ordered]@{ city = 'Oslo' } @@ -200,9 +200,9 @@ Describe 'Round-trip ConvertTo-Yaml | ConvertFrom-Yaml' { It 'Preserves a nested mapping' { $obj = [ordered]@{ person = [ordered]@{ - name = 'Alice' + name = 'Alice' address = [ordered]@{ - city = 'Oslo' + city = 'Oslo' country = 'Norway' } } From 80420d360ba38baa620797a4b822c0c3037c630d Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 2 May 2026 17:56:30 +0200 Subject: [PATCH 06/21] Drop frontmatter extraction; require valid YAML input and tolerate document markers --- README.md | 38 +++++---- examples/General.ps1 | 20 +---- .../private/ConvertFrom-YamlFrontmatter.ps1 | 56 ------------ .../private/ConvertFrom-YamlLineStream.ps1 | 6 ++ src/functions/public/ConvertFrom-Yaml.ps1 | 19 ++--- tests/ConvertFrom-Yaml.Tests.ps1 | 85 +++++++++---------- 6 files changed, 77 insertions(+), 147 deletions(-) delete mode 100644 src/functions/private/ConvertFrom-YamlFrontmatter.ps1 diff --git a/README.md b/README.md index 797812b..6844a43 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,13 @@ -# {{ NAME }} +# Yaml -{{ DESCRIPTION }} +A PowerShell module for working with YAML — parse YAML strings into PowerShell objects and serialize PowerShell objects back into YAML. + +The module ships two cmdlets that mirror PowerShell's built-in `ConvertFrom-Json` / `ConvertTo-Json`, so the experience is familiar: + +- `ConvertFrom-Yaml` — parses a YAML string into a `[PSCustomObject]` (or an ordered hashtable with `-AsHashtable`). +- `ConvertTo-Yaml` — serializes a PowerShell object, hashtable, or array into a YAML-formatted string. + +No external dependencies — pure PowerShell. Aligned with the [YAML 1.2.2 core schema](https://yaml.org/spec/1.2.2/#103-core-schema). ## Prerequisites @@ -13,18 +20,21 @@ This uses the following external resources: To install the module from the PowerShell Gallery, you can use the following command: ```powershell -Install-PSResource -Name {{ NAME }} -Import-Module -Name {{ NAME }} +Install-PSResource -Name Yaml +Import-Module -Name Yaml ``` ## Usage The module provides two cmdlets that mirror PowerShell's built-in `ConvertFrom-Json` / `ConvertTo-Json`: -| Cmdlet | Alias | Purpose | -| ------------------- | ---------------- | -------------------------------------- | -| `ConvertFrom-Yaml` | `ConvertFrom-Yml` | Parse a YAML string into an object. | -| `ConvertTo-Yaml` | `ConvertTo-Yml` | Serialize an object into a YAML string. | +| Cmdlet | Alias | Purpose | +| ------------------- | ----------------- | ---------------------------------------- | +| `ConvertFrom-Yaml` | `ConvertFrom-Yml` | Parse a YAML string into an object. | +| `ConvertTo-Yaml` | `ConvertTo-Yml` | Serialize an object into a YAML string. | + +> [!IMPORTANT] +> The input to `ConvertFrom-Yaml` must be a valid YAML string. The cmdlet does not read files or extract YAML frontmatter from markdown — that is the caller's responsibility (for example, using a Markdown module to extract frontmatter, then piping the result into `ConvertFrom-Yaml`). ### YAML specification @@ -37,7 +47,7 @@ Practical implications of the core schema: - Integers and floats parse to their native types using invariant culture. - Anything else is a string. Quoted strings (`'...'`, `"..."`) always preserve the string type. -The supported YAML subset covers block-style mappings, block-style sequences, nested structures, single- and double-quoted scalars (with `\n`, `\t`, `\r`, `\\`, `\"` escapes in double quotes), and full-line / inline `#` comments. Flow style (`[a, b]`, `{a: 1}`), block scalars (`|`, `>`), anchors, aliases, tags, multi-document streams, and `!!timestamp` are not yet supported. +The supported YAML subset covers block-style mappings, block-style sequences, nested structures, single- and double-quoted scalars (with `\n`, `\t`, `\r`, `\\`, `\"` escapes in double quotes), document start (`---`) and end (`...`) markers, and full-line / inline `#` comments. Flow style (`[a, b]`, `{a: 1}`), block scalars (`|`, `>`), anchors, aliases, tags, multi-document streams, and `!!timestamp` are not yet supported. ### Example 1: Parse a YAML string @@ -59,13 +69,7 @@ $yaml | ConvertFrom-Yaml Get-Content config.yaml -Raw | ConvertFrom-Yaml -AsHashtable ``` -### Example 3: Parse YAML frontmatter from a markdown file - -```powershell -Get-Content post.md -Raw | ConvertFrom-Yaml -``` - -### Example 4: Convert an object to YAML +### Example 3: Convert an object to YAML ```powershell [ordered]@{ @@ -74,7 +78,7 @@ Get-Content post.md -Raw | ConvertFrom-Yaml } | ConvertTo-Yaml ``` -### Example 5: Force a top-level YAML sequence +### Example 4: Force a top-level YAML sequence ```powershell Get-Process | Select-Object -First 3 Name, Id | ConvertTo-Yaml -AsArray diff --git a/examples/General.ps1 b/examples/General.ps1 index 727ddcc..cffd00e 100644 --- a/examples/General.ps1 +++ b/examples/General.ps1 @@ -22,30 +22,16 @@ $person.skills[0] # PowerShell $hash = $yaml | ConvertFrom-Yaml -AsHashtable $hash['age'] # 30 -# 3. Parse YAML frontmatter from a markdown document -$markdown = @' ---- -title: Hello world -draft: false ---- -# Body - -Markdown content here. -'@ - -$frontmatter = $markdown | ConvertFrom-Yaml -$frontmatter.title # Hello world - -# 4. Convert an object to YAML +# 3. Convert an object to YAML [ordered]@{ name = 'Alice' age = 30 skills = @('PowerShell', 'YAML') } | ConvertTo-Yaml -# 5. Force a top-level sequence with -AsArray +# 4. Force a top-level sequence with -AsArray Get-Process | Select-Object -First 3 Name, Id | ConvertTo-Yaml -AsArray -# 6. Round-trip +# 5. Round-trip $obj = [ordered]@{ a = 1; b = @('x', 'y') } $obj | ConvertTo-Yaml | ConvertFrom-Yaml -AsHashtable diff --git a/src/functions/private/ConvertFrom-YamlFrontmatter.ps1 b/src/functions/private/ConvertFrom-YamlFrontmatter.ps1 deleted file mode 100644 index f4e0ca3..0000000 --- a/src/functions/private/ConvertFrom-YamlFrontmatter.ps1 +++ /dev/null @@ -1,56 +0,0 @@ -function ConvertFrom-YamlFrontmatter { - <# - .SYNOPSIS - Extracts YAML frontmatter from a string when present, otherwise returns the string unchanged. - - .DESCRIPTION - If the input begins with a `---` line, returns the content between the opening - `---` and the next `---` or `...` line. Anything after the closing delimiter - (typically markdown body) is discarded. - - If no frontmatter delimiter is detected, the original input is returned. - #> - [CmdletBinding()] - [OutputType([string])] - param( - [Parameter(Mandatory)] - [AllowEmptyString()] - [string] $Text - ) - - # Normalize line endings for matching. - $normalized = $Text -replace "`r`n", "`n" - $lines = $normalized -split "`n" - - # Find first non-empty line. - $firstIdx = -1 - for ($i = 0; $i -lt $lines.Count; $i++) { - if ($lines[$i].Trim().Length -gt 0) { - $firstIdx = $i - break - } - } - - if ($firstIdx -lt 0) { - return $Text - } - - if ($lines[$firstIdx].Trim() -ne '---') { - return $Text - } - - # Find closing delimiter. - for ($j = $firstIdx + 1; $j -lt $lines.Count; $j++) { - $trim = $lines[$j].Trim() - if ($trim -eq '---' -or $trim -eq '...') { - $body = $lines[($firstIdx + 1)..($j - 1)] -join "`n" - return $body - } - } - - # No closing delimiter — treat everything after opening as frontmatter. - if ($firstIdx + 1 -lt $lines.Count) { - return ($lines[($firstIdx + 1)..($lines.Count - 1)] -join "`n") - } - return '' -} diff --git a/src/functions/private/ConvertFrom-YamlLineStream.ps1 b/src/functions/private/ConvertFrom-YamlLineStream.ps1 index 58fb1c9..a13214a 100644 --- a/src/functions/private/ConvertFrom-YamlLineStream.ps1 +++ b/src/functions/private/ConvertFrom-YamlLineStream.ps1 @@ -47,6 +47,12 @@ continue } + # Skip YAML document markers: --- (start) and ... (end). + $trimmed = $stripped.Trim() + if ($trimmed -eq '---' -or $trimmed -eq '...') { + continue + } + $result.Add([pscustomobject]@{ Indent = $indent Content = $stripped.TrimEnd() diff --git a/src/functions/public/ConvertFrom-Yaml.ps1 b/src/functions/public/ConvertFrom-Yaml.ps1 index 66b6b11..64d58a3 100644 --- a/src/functions/public/ConvertFrom-Yaml.ps1 +++ b/src/functions/public/ConvertFrom-Yaml.ps1 @@ -7,15 +7,18 @@ Parses a YAML document and returns a `[PSCustomObject]` (default) or an `[ordered]` hashtable when `-AsHashtable` is specified. - Supports a useful subset of YAML 1.2: + Supports a useful subset of YAML 1.2.2 (core schema): - Block-style mappings (key: value) - Block-style sequences (- item) - Nested structures - - Scalars: strings, integers, floats, booleans, null + - Scalars: strings, integers, floats, booleans (`true`/`false`), null (`null`/`~`/empty) - Single- and double-quoted strings (with `\n`, `\t`, `\r`, `\\`, `\"` in double quotes) - - YAML frontmatter delimited by `---` (typical in markdown) + - Document start (`---`) and end (`...`) markers are tolerated - Full-line comments (`#`) and inline comments after values + Input must be a valid YAML string. Reading frontmatter out of a markdown + document is the responsibility of the caller (see the Markdown module). + Out of scope for this version: flow style (`[a, b]`, `{a: 1}`), block scalars (`|`, `>`), anchors/aliases, tags, multi-document streams, and `!!timestamp`. @@ -42,11 +45,6 @@ Reads a YAML file and returns it as an ordered hashtable. - .EXAMPLE - Get-Content post.md -Raw | ConvertFrom-Yaml - - Extracts and parses the YAML frontmatter from a markdown file. - .OUTPUTS System.Management.Automation.PSCustomObject @@ -92,10 +90,7 @@ return $null } - # Strip frontmatter delimiters: leading "---" line and trailing "---" line. - $text = ConvertFrom-YamlFrontmatter -Text $text - - # Pre-process into logical lines (drop comments and blank lines, keep indentation). + # Pre-process into logical lines (drop comments, blank lines, and document markers). $lines = ConvertFrom-YamlLineStream -Text $text if ($lines.Count -eq 0) { return $null diff --git a/tests/ConvertFrom-Yaml.Tests.ps1 b/tests/ConvertFrom-Yaml.Tests.ps1 index eaeabbb..eac951d 100644 --- a/tests/ConvertFrom-Yaml.Tests.ps1 +++ b/tests/ConvertFrom-Yaml.Tests.ps1 @@ -87,10 +87,10 @@ Describe 'ConvertFrom-Yaml' { Context 'Mappings' { It 'Parses a flat mapping into a PSCustomObject by default' { - $yaml = @" + $yaml = @' name: Alice age: 30 -"@ +'@ $result = $yaml | ConvertFrom-Yaml $result | Should -BeOfType [PSCustomObject] $result.name | Should -Be 'Alice' @@ -98,13 +98,13 @@ age: 30 } It 'Parses nested mappings' { - $yaml = @" + $yaml = @' person: name: Alice address: city: Oslo country: Norway -"@ +'@ $result = $yaml | ConvertFrom-Yaml $result.person.name | Should -Be 'Alice' $result.person.address.city | Should -Be 'Oslo' @@ -112,11 +112,11 @@ person: } It 'Preserves key insertion order' { - $yaml = @" + $yaml = @' zebra: 1 apple: 2 mango: 3 -"@ +'@ $result = $yaml | ConvertFrom-Yaml $names = $result.PSObject.Properties.Name $names[0] | Should -Be 'zebra' @@ -127,11 +127,11 @@ mango: 3 Context 'Sequences' { It 'Parses a sequence of scalars' { - $yaml = @" + $yaml = @' - one - two - three -"@ +'@ $result = $yaml | ConvertFrom-Yaml -NoEnumerate $result.Count | Should -Be 3 $result[0] | Should -Be 'one' @@ -139,25 +139,25 @@ mango: 3 } It 'Parses a sequence under a key' { - $yaml = @" + $yaml = @' items: - apple - banana - cherry -"@ +'@ $result = $yaml | ConvertFrom-Yaml $result.items.Count | Should -Be 3 $result.items[1] | Should -Be 'banana' } It 'Parses a sequence of mappings' { - $yaml = @" + $yaml = @' people: - name: Alice age: 30 - name: Bob age: 25 -"@ +'@ $result = $yaml | ConvertFrom-Yaml $result.people.Count | Should -Be 2 $result.people[0].name | Should -Be 'Alice' @@ -174,11 +174,11 @@ people: } It 'Returns nested structures as ordered dictionaries' { - $yaml = @" + $yaml = @' outer: inner: leaf: value -"@ +'@ $result = $yaml | ConvertFrom-Yaml -AsHashtable $result['outer'] | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] $result['outer']['inner']['leaf'] | Should -Be 'value' @@ -193,48 +193,43 @@ outer: Context '-NoEnumerate' { It 'Returns a single-element top-level sequence as an array when -NoEnumerate is set' { - $yaml = "- only" + $yaml = '- only' $result = $yaml | ConvertFrom-Yaml -NoEnumerate - ,$result | Should -BeOfType [System.Object[]] + , $result | Should -BeOfType [System.Object[]] $result.Count | Should -Be 1 } } - Context 'Frontmatter' { - It 'Parses YAML between --- delimiters' { - $content = @" + Context 'Document markers' { + It 'Tolerates a leading --- document-start marker' { + $yaml = @' --- -title: Hello -draft: false ---- -# Markdown body here - -Some content. -"@ - $result = $content | ConvertFrom-Yaml - $result.title | Should -Be 'Hello' - $result.draft | Should -BeFalse +name: Alice +age: 30 +'@ + $result = $yaml | ConvertFrom-Yaml + $result.name | Should -Be 'Alice' + $result.age | Should -Be 30 } - It 'Parses content that is only frontmatter' { - $content = @" ---- -key: value ---- -"@ - $result = $content | ConvertFrom-Yaml - $result.key | Should -Be 'value' + It 'Tolerates a trailing ... document-end marker' { + $yaml = @' +name: Alice +... +'@ + $result = $yaml | ConvertFrom-Yaml + $result.name | Should -Be 'Alice' } } Context 'Comments and blank lines' { It 'Ignores full-line comments' { - $yaml = @" + $yaml = @' # this is a comment name: Alice # another comment age: 30 -"@ +'@ $result = $yaml | ConvertFrom-Yaml $result.name | Should -Be 'Alice' $result.age | Should -Be 30 @@ -246,12 +241,12 @@ age: 30 } It 'Ignores blank lines' { - $yaml = @" + $yaml = @' name: Alice age: 30 -"@ +'@ $result = $yaml | ConvertFrom-Yaml $result.name | Should -Be 'Alice' $result.age | Should -Be 30 @@ -260,21 +255,21 @@ age: 30 Context '-Depth' { It 'Throws when nesting exceeds -Depth' { - $yaml = @" + $yaml = @' a: b: c: d: value -"@ +'@ { $yaml | ConvertFrom-Yaml -Depth 2 } | Should -Throw } It 'Allows nesting within -Depth' { - $yaml = @" + $yaml = @' a: b: c: value -"@ +'@ $result = $yaml | ConvertFrom-Yaml -Depth 5 $result.a.b.c | Should -Be 'value' } From 732b6e6904369a4163f124f8b094888f53a4867c Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 2 May 2026 18:01:38 +0200 Subject: [PATCH 07/21] Remove markdown and frontmatter references from module code and README --- README.md | 2 +- src/functions/public/ConvertFrom-Yaml.ps1 | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6844a43..ad6a927 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ The module provides two cmdlets that mirror PowerShell's built-in `ConvertFrom-J | `ConvertTo-Yaml` | `ConvertTo-Yml` | Serialize an object into a YAML string. | > [!IMPORTANT] -> The input to `ConvertFrom-Yaml` must be a valid YAML string. The cmdlet does not read files or extract YAML frontmatter from markdown — that is the caller's responsibility (for example, using a Markdown module to extract frontmatter, then piping the result into `ConvertFrom-Yaml`). +> The input to `ConvertFrom-Yaml` must be a valid YAML string. The cmdlet does not read files — use `Get-Content -Raw` or similar to read the file first, then pipe the string into `ConvertFrom-Yaml`. ### YAML specification diff --git a/src/functions/public/ConvertFrom-Yaml.ps1 b/src/functions/public/ConvertFrom-Yaml.ps1 index 64d58a3..c271e68 100644 --- a/src/functions/public/ConvertFrom-Yaml.ps1 +++ b/src/functions/public/ConvertFrom-Yaml.ps1 @@ -16,8 +16,7 @@ - Document start (`---`) and end (`...`) markers are tolerated - Full-line comments (`#`) and inline comments after values - Input must be a valid YAML string. Reading frontmatter out of a markdown - document is the responsibility of the caller (see the Markdown module). + Input must be a valid YAML string. Out of scope for this version: flow style (`[a, b]`, `{a: 1}`), block scalars (`|`, `>`), anchors/aliases, tags, multi-document streams, and `!!timestamp`. From 84344f77286d7c116cb1a91973130f8003ce120b Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 2 May 2026 21:15:13 +0200 Subject: [PATCH 08/21] Fix PSSA and codespell linter warnings: switch to switch-statement in escape handler, suppress false-positive ShouldProcess on Remove-YamlInlineComment, rename Get-YamlMappingPairs to singular noun, remove unused Options param from Format-YamlKey, use here-string in test to avoid codespell nd false positive --- .../private/ConvertFrom-YamlLineStream.ps1 | 2 ++ src/functions/private/ConvertFrom-YamlNode.ps1 | 16 +++++++++------- src/functions/private/ConvertTo-YamlNode.ps1 | 15 +++++++-------- tests/ConvertFrom-Yaml.Tests.ps1 | 10 +++++++++- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/functions/private/ConvertFrom-YamlLineStream.ps1 b/src/functions/private/ConvertFrom-YamlLineStream.ps1 index a13214a..c55dbd4 100644 --- a/src/functions/private/ConvertFrom-YamlLineStream.ps1 +++ b/src/functions/private/ConvertFrom-YamlLineStream.ps1 @@ -68,6 +68,8 @@ function Remove-YamlInlineComment { .SYNOPSIS Removes an unquoted `# comment` suffix from a YAML content line. #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'This function operates on a string parameter, not system state.')] [CmdletBinding()] [OutputType([string])] param( diff --git a/src/functions/private/ConvertFrom-YamlNode.ps1 b/src/functions/private/ConvertFrom-YamlNode.ps1 index e70f0e3..3da90ef 100644 --- a/src/functions/private/ConvertFrom-YamlNode.ps1 +++ b/src/functions/private/ConvertFrom-YamlNode.ps1 @@ -279,13 +279,15 @@ function Expand-YamlDoubleQuoted { if ($c -eq '\' -and $i + 1 -lt $Text.Length) { $next = $Text[$i + 1] $expanded = $true - if ($next -eq 'n') { $null = $sb.Append("`n") } - elseif ($next -eq 't') { $null = $sb.Append("`t") } - elseif ($next -eq 'r') { $null = $sb.Append("`r") } - elseif ($next -eq '"') { $null = $sb.Append('"') } - elseif ($next -eq '\') { $null = $sb.Append('\') } - elseif ($next -eq '0') { $null = $sb.Append([char]0) } - else { $expanded = $false } + switch ($next) { + 'n' { $null = $sb.Append("`n") } + 't' { $null = $sb.Append("`t") } + 'r' { $null = $sb.Append("`r") } + '"' { $null = $sb.Append('"') } + '\' { $null = $sb.Append('\') } + '0' { $null = $sb.Append([char]0) } + default { $expanded = $false } + } if ($expanded) { $i += 2 diff --git a/src/functions/private/ConvertTo-YamlNode.ps1 b/src/functions/private/ConvertTo-YamlNode.ps1 index 902b5bb..37d07ea 100644 --- a/src/functions/private/ConvertTo-YamlNode.ps1 +++ b/src/functions/private/ConvertTo-YamlNode.ps1 @@ -91,7 +91,7 @@ function ConvertTo-YamlMapping { [Parameter(Mandatory)] [pscustomobject] $Options ) - $pairs = Get-YamlMappingPairs -Value $Value + $pairs = Get-YamlMappingPair -Value $Value if ($pairs.Count -eq 0) { $null = $Builder.Append('{}').AppendLine() return @@ -99,7 +99,7 @@ function ConvertTo-YamlMapping { $indent = ' ' * ($Level * $Options.Indent) foreach ($pair in $pairs) { - $keyText = Format-YamlKey -Key $pair.Key -Options $Options + $keyText = Format-YamlKey -Key $pair.Key $val = $pair.Value $null = $Builder.Append($indent).Append($keyText).Append(':') @@ -115,7 +115,7 @@ function ConvertTo-YamlMapping { } if (Test-YamlMappingType -Value $rawVal) { - $childPairs = Get-YamlMappingPairs -Value $val + $childPairs = Get-YamlMappingPair -Value $val if ($childPairs.Count -eq 0) { $null = $Builder.Append(' {}').AppendLine() } else { @@ -172,7 +172,7 @@ function ConvertTo-YamlSequence { } if (Test-YamlMappingType -Value $rawItem) { - $pairs = Get-YamlMappingPairs -Value $item + $pairs = Get-YamlMappingPair -Value $item if ($pairs.Count -eq 0) { $null = $Builder.Append($indent).Append('- {}').AppendLine() continue @@ -180,7 +180,7 @@ function ConvertTo-YamlSequence { $first = $true $childIndent = ' ' * (($Level + 1) * $Options.Indent) foreach ($pair in $pairs) { - $keyText = Format-YamlKey -Key $pair.Key -Options $Options + $keyText = Format-YamlKey -Key $pair.Key $prefix = if ($first) { "$indent- " } else { $childIndent } $first = $false @@ -225,7 +225,7 @@ function ConvertTo-YamlSequence { } } -function Get-YamlMappingPairs { +function Get-YamlMappingPair { <# .SYNOPSIS Returns a list of [pscustomobject]@{ Key; Value } for a dictionary or PSObject. @@ -375,8 +375,7 @@ function Format-YamlKey { [OutputType([string])] param( [Parameter(Mandatory)] - [object] $Key, - [Parameter(Mandatory)] [pscustomobject] $Options + [object] $Key ) $text = [string] $Key diff --git a/tests/ConvertFrom-Yaml.Tests.ps1 b/tests/ConvertFrom-Yaml.Tests.ps1 index eac951d..c5c4c8b 100644 --- a/tests/ConvertFrom-Yaml.Tests.ps1 +++ b/tests/ConvertFrom-Yaml.Tests.ps1 @@ -43,7 +43,15 @@ Describe 'ConvertFrom-Yaml' { It 'Treats non-canonical boolean-like words as strings (YAML 1.2.2)' { # YAML 1.2.2 core schema only recognizes lowercase true/false. Everything else is a string. - $result = "a: True`nb: TRUE`nc: yes`nd: No`ne: on`nf: OFF" | ConvertFrom-Yaml + $yaml = @" +a: True +b: TRUE +c: yes +d: No +e: on +f: OFF +"@ + $result = $yaml | ConvertFrom-Yaml $result.a | Should -Be 'True' $result.a | Should -BeOfType [string] $result.b | Should -Be 'TRUE' From 2c3d26d6221de600df5b50fd7504d68d26400547 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 2 May 2026 22:36:19 +0200 Subject: [PATCH 09/21] Fix PSScriptAnalyzer violations: add comment help and OutputType declarations - Add [PSProvideCommentHelp] comment blocks to 8 private helper functions: ConvertFrom-YamlMapping, ConvertFrom-YamlSequence, Test-YamlMappingType, Test-YamlSequenceType, ConvertTo-YamlMapping, ConvertTo-YamlSequence, Format-YamlDoubleQuoted, Format-YamlKey - Add [OutputType] to ConvertFrom-YamlMapping, ConvertFrom-YamlSequence, ConvertFrom-YamlScalar - Suppress PSUseOutputTypeCorrectly on 4 functions that use comma-unary operator to prevent collection unwrapping (ConvertFrom-YamlLineStream, ConvertFrom-YamlSequence, Get-YamlMappingPair, ConvertFrom-Yaml) --- .../private/ConvertFrom-YamlLineStream.ps1 | 2 ++ .../private/ConvertFrom-YamlNode.ps1 | 13 ++++++++++ src/functions/private/ConvertTo-YamlNode.ps1 | 26 +++++++++++++++++++ src/functions/public/ConvertFrom-Yaml.ps1 | 2 ++ 4 files changed, 43 insertions(+) diff --git a/src/functions/private/ConvertFrom-YamlLineStream.ps1 b/src/functions/private/ConvertFrom-YamlLineStream.ps1 index c55dbd4..5a79c43 100644 --- a/src/functions/private/ConvertFrom-YamlLineStream.ps1 +++ b/src/functions/private/ConvertFrom-YamlLineStream.ps1 @@ -10,6 +10,8 @@ - Inline comments (` #...` outside quotes) are stripped from the content. - Tabs in indentation are not allowed (YAML spec); they are treated as one space here. #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', + Justification = 'Comma-unary operator preserves List type; PSScriptAnalyzer misdetects as Object[].')] [CmdletBinding()] [OutputType([System.Collections.Generic.List[pscustomobject]])] param( diff --git a/src/functions/private/ConvertFrom-YamlNode.ps1 b/src/functions/private/ConvertFrom-YamlNode.ps1 index 3da90ef..df8b292 100644 --- a/src/functions/private/ConvertFrom-YamlNode.ps1 +++ b/src/functions/private/ConvertFrom-YamlNode.ps1 @@ -40,7 +40,12 @@ } function ConvertFrom-YamlMapping { + <# + .SYNOPSIS + Parses a YAML block-style mapping into a PSCustomObject or OrderedDictionary. + #> [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary], [pscustomobject])] param( [Parameter(Mandatory)] [pscustomobject] $Context, [Parameter(Mandatory)] [int] $Indent, @@ -107,7 +112,14 @@ function ConvertFrom-YamlMapping { } function ConvertFrom-YamlSequence { + <# + .SYNOPSIS + Parses a YAML block-style sequence into a PowerShell array. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', + Justification = 'Comma-unary operator preserves array type; PSScriptAnalyzer misdetects as Object[].')] [CmdletBinding()] + [OutputType([object[]])] param( [Parameter(Mandatory)] [pscustomobject] $Context, [Parameter(Mandatory)] [int] $Indent, @@ -212,6 +224,7 @@ function ConvertFrom-YamlScalar { Converts a raw YAML scalar token into the appropriate PowerShell type. #> [CmdletBinding()] + [OutputType([string], [bool], [int], [long], [double])] param( [Parameter(Mandatory)] [AllowEmptyString()] diff --git a/src/functions/private/ConvertTo-YamlNode.ps1 b/src/functions/private/ConvertTo-YamlNode.ps1 index 37d07ea..3029950 100644 --- a/src/functions/private/ConvertTo-YamlNode.ps1 +++ b/src/functions/private/ConvertTo-YamlNode.ps1 @@ -56,6 +56,10 @@ } function Test-YamlMappingType { + <# + .SYNOPSIS + Returns true when a value should be serialized as a YAML mapping. + #> [CmdletBinding()] [OutputType([bool])] param([Parameter()] [AllowNull()] [object] $Value) @@ -70,6 +74,10 @@ function Test-YamlMappingType { } function Test-YamlSequenceType { + <# + .SYNOPSIS + Returns true when a value should be serialized as a YAML sequence. + #> [CmdletBinding()] [OutputType([bool])] param([Parameter()] [AllowNull()] [object] $Value) @@ -82,6 +90,10 @@ function Test-YamlSequenceType { } function ConvertTo-YamlMapping { + <# + .SYNOPSIS + Writes a mapping value as a YAML block-style mapping into the StringBuilder. + #> [CmdletBinding()] param( [Parameter(Mandatory)] [object] $Value, @@ -142,6 +154,10 @@ function ConvertTo-YamlMapping { } function ConvertTo-YamlSequence { + <# + .SYNOPSIS + Writes a sequence value as a YAML block-style sequence into the StringBuilder. + #> [CmdletBinding()] param( [Parameter(Mandatory)] [object] $Value, @@ -230,6 +246,8 @@ function Get-YamlMappingPair { .SYNOPSIS Returns a list of [pscustomobject]@{ Key; Value } for a dictionary or PSObject. #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', + Justification = 'Comma-unary operator preserves List type; PSScriptAnalyzer misdetects as Object[].')] [CmdletBinding()] [OutputType([System.Collections.Generic.List[pscustomobject]])] param( @@ -343,6 +361,10 @@ function Format-YamlString { } function Format-YamlDoubleQuoted { + <# + .SYNOPSIS + Wraps a string in double quotes, escaping special characters per YAML rules. + #> [CmdletBinding()] [OutputType([string])] param( @@ -371,6 +393,10 @@ function Format-YamlDoubleQuoted { } function Format-YamlKey { + <# + .SYNOPSIS + Renders a mapping key as a YAML scalar, quoting when necessary. + #> [CmdletBinding()] [OutputType([string])] param( diff --git a/src/functions/public/ConvertFrom-Yaml.ps1 b/src/functions/public/ConvertFrom-Yaml.ps1 index c271e68..a88e095 100644 --- a/src/functions/public/ConvertFrom-Yaml.ps1 +++ b/src/functions/public/ConvertFrom-Yaml.ps1 @@ -51,6 +51,8 @@ System.Collections.Specialized.OrderedDictionary #> [Alias('ConvertFrom-Yml')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', + Justification = 'Comma-unary operator for -NoEnumerate returns array wrapper; PSScriptAnalyzer misdetects as Object[].')] [CmdletBinding()] [OutputType([PSCustomObject], [System.Collections.Specialized.OrderedDictionary])] param( From a1c9b4e19c834a4486e8f11e2b742a997fa29213 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 2 May 2026 22:37:42 +0200 Subject: [PATCH 10/21] Fix Build-Docs lint: remove extra bullet indent in ConvertFrom-Yaml description, fix 'Id' to 'ID' in ConvertTo-Yaml example --- src/functions/public/ConvertFrom-Yaml.ps1 | 14 +++++++------- src/functions/public/ConvertTo-Yaml.ps1 | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/functions/public/ConvertFrom-Yaml.ps1 b/src/functions/public/ConvertFrom-Yaml.ps1 index a88e095..e3824ca 100644 --- a/src/functions/public/ConvertFrom-Yaml.ps1 +++ b/src/functions/public/ConvertFrom-Yaml.ps1 @@ -8,13 +8,13 @@ `[ordered]` hashtable when `-AsHashtable` is specified. Supports a useful subset of YAML 1.2.2 (core schema): - - Block-style mappings (key: value) - - Block-style sequences (- item) - - Nested structures - - Scalars: strings, integers, floats, booleans (`true`/`false`), null (`null`/`~`/empty) - - Single- and double-quoted strings (with `\n`, `\t`, `\r`, `\\`, `\"` in double quotes) - - Document start (`---`) and end (`...`) markers are tolerated - - Full-line comments (`#`) and inline comments after values + - Block-style mappings (key: value) + - Block-style sequences (- item) + - Nested structures + - Scalars: strings, integers, floats, booleans (`true`/`false`), null (`null`/`~`/empty) + - Single- and double-quoted strings (with `\n`, `\t`, `\r`, `\\`, `\"` in double quotes) + - Document start (`---`) and end (`...`) markers are tolerated + - Full-line comments (`#`) and inline comments after values Input must be a valid YAML string. diff --git a/src/functions/public/ConvertTo-Yaml.ps1 b/src/functions/public/ConvertTo-Yaml.ps1 index d1e33c4..5f4aa45 100644 --- a/src/functions/public/ConvertTo-Yaml.ps1 +++ b/src/functions/public/ConvertTo-Yaml.ps1 @@ -34,7 +34,7 @@ age: 30 .EXAMPLE - Get-Process | Select-Object -First 3 Name, Id | ConvertTo-Yaml -AsArray + Get-Process | Select-Object -First 3 Name, ID | ConvertTo-Yaml -AsArray Serializes a list of objects as a YAML sequence. From b4113eb44695e62863e566eb901262dcebbcc067 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 2 May 2026 23:18:16 +0200 Subject: [PATCH 11/21] Fix SourceCode tests: split private helpers into one-function-per-file The PSModule SourceCode test suite enforces that each .ps1 file contains exactly one function whose name matches the filename. The three private helper files each contained multiple helper functions, causing FunctionCount and FunctionName test failures on all platforms. Split into 16 individual files: - ConvertFrom-YamlLineStream.ps1: extracted Remove-YamlInlineComment - ConvertFrom-YamlNode.ps1: extracted ConvertFrom-YamlMapping, ConvertFrom-YamlSequence, Find-YamlMappingColon, ConvertFrom-YamlScalar, Expand-YamlDoubleQuoted - ConvertTo-YamlNode.ps1: extracted Test-YamlMappingType, Test-YamlSequenceType, ConvertTo-YamlMapping, ConvertTo-YamlSequence, Get-YamlMappingPair, Format-YamlScalar, Format-YamlString, Format-YamlDoubleQuoted, Format-YamlKey, Test-YamlPlainSafe All 56 Pester tests pass locally. --- .../private/ConvertFrom-YamlLineStream.ps1 | 42 -- .../private/ConvertFrom-YamlMapping.ps1 | 71 +++ .../private/ConvertFrom-YamlNode.ps1 | 274 ------------ .../private/ConvertFrom-YamlScalar.ps1 | 53 +++ .../private/ConvertFrom-YamlSequence.ps1 | 73 +++ .../private/ConvertTo-YamlMapping.ps1 | 63 +++ src/functions/private/ConvertTo-YamlNode.ps1 | 416 ------------------ .../private/ConvertTo-YamlSequence.ps1 | 87 ++++ .../private/Expand-YamlDoubleQuoted.ps1 | 40 ++ .../private/Find-YamlMappingColon.ps1 | 32 ++ .../private/Format-YamlDoubleQuoted.ps1 | 31 ++ src/functions/private/Format-YamlKey.ps1 | 20 + src/functions/private/Format-YamlScalar.ps1 | 40 ++ src/functions/private/Format-YamlString.ps1 | 40 ++ src/functions/private/Get-YamlMappingPair.ps1 | 36 ++ .../private/Remove-YamlInlineComment.ps1 | 41 ++ .../private/Test-YamlMappingType.ps1 | 17 + src/functions/private/Test-YamlPlainSafe.ps1 | 57 +++ .../private/Test-YamlSequenceType.ps1 | 15 + 19 files changed, 716 insertions(+), 732 deletions(-) create mode 100644 src/functions/private/ConvertFrom-YamlMapping.ps1 create mode 100644 src/functions/private/ConvertFrom-YamlScalar.ps1 create mode 100644 src/functions/private/ConvertFrom-YamlSequence.ps1 create mode 100644 src/functions/private/ConvertTo-YamlMapping.ps1 create mode 100644 src/functions/private/ConvertTo-YamlSequence.ps1 create mode 100644 src/functions/private/Expand-YamlDoubleQuoted.ps1 create mode 100644 src/functions/private/Find-YamlMappingColon.ps1 create mode 100644 src/functions/private/Format-YamlDoubleQuoted.ps1 create mode 100644 src/functions/private/Format-YamlKey.ps1 create mode 100644 src/functions/private/Format-YamlScalar.ps1 create mode 100644 src/functions/private/Format-YamlString.ps1 create mode 100644 src/functions/private/Get-YamlMappingPair.ps1 create mode 100644 src/functions/private/Remove-YamlInlineComment.ps1 create mode 100644 src/functions/private/Test-YamlMappingType.ps1 create mode 100644 src/functions/private/Test-YamlPlainSafe.ps1 create mode 100644 src/functions/private/Test-YamlSequenceType.ps1 diff --git a/src/functions/private/ConvertFrom-YamlLineStream.ps1 b/src/functions/private/ConvertFrom-YamlLineStream.ps1 index 5a79c43..92b2868 100644 --- a/src/functions/private/ConvertFrom-YamlLineStream.ps1 +++ b/src/functions/private/ConvertFrom-YamlLineStream.ps1 @@ -64,45 +64,3 @@ return , $result } - -function Remove-YamlInlineComment { - <# - .SYNOPSIS - Removes an unquoted `# comment` suffix from a YAML content line. - #> - [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', - Justification = 'This function operates on a string parameter, not system state.')] - [CmdletBinding()] - [OutputType([string])] - param( - [Parameter(Mandatory)] - [AllowEmptyString()] - [string] $Line - ) - - $inSingle = $false - $inDouble = $false - for ($i = 0; $i -lt $Line.Length; $i++) { - $c = $Line[$i] - if ($c -eq '\' -and $inDouble) { - # Skip escaped char inside double quotes. - $i++ - continue - } - if ($c -eq "'" -and -not $inDouble) { - $inSingle = -not $inSingle - continue - } - if ($c -eq '"' -and -not $inSingle) { - $inDouble = -not $inDouble - continue - } - if ($c -eq '#' -and -not $inSingle -and -not $inDouble) { - # Comment must be preceded by whitespace or be at start of line. - if ($i -eq 0 -or $Line[$i - 1] -eq ' ' -or $Line[$i - 1] -eq "`t") { - return $Line.Substring(0, $i) - } - } - } - return $Line -} diff --git a/src/functions/private/ConvertFrom-YamlMapping.ps1 b/src/functions/private/ConvertFrom-YamlMapping.ps1 new file mode 100644 index 0000000..54e7780 --- /dev/null +++ b/src/functions/private/ConvertFrom-YamlMapping.ps1 @@ -0,0 +1,71 @@ +function ConvertFrom-YamlMapping { + <# + .SYNOPSIS + Parses a YAML block-style mapping into a PSCustomObject or OrderedDictionary. + #> + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary], [pscustomobject])] + param( + [Parameter(Mandatory)] [pscustomobject] $Context, + [Parameter(Mandatory)] [int] $Indent, + [Parameter(Mandatory)] [int] $Depth + ) + + $lines = $Context.Lines + $map = [ordered]@{} + + while ($Context.Index -lt $lines.Count) { + $line = $lines[$Context.Index] + if ($line.Indent -lt $Indent) { break } + if ($line.Indent -gt $Indent) { + throw "ConvertFrom-Yaml: unexpected indentation at line $($line.Number)." + } + if ($line.Content.StartsWith('- ') -or $line.Content -eq '-') { + # A sequence at the same indent as a mapping key is a sibling, not part of mapping. + break + } + + $colonIdx = Find-YamlMappingColon -Content $line.Content + if ($colonIdx -lt 0) { + throw "ConvertFrom-Yaml: expected mapping key at line $($line.Number): '$($line.Content)'." + } + + $key = ConvertFrom-YamlScalar -Raw $line.Content.Substring(0, $colonIdx).Trim() + $rest = $line.Content.Substring($colonIdx + 1).Trim() + + $Context.Index++ + + if ($rest.Length -gt 0) { + $map[[string]$key] = ConvertFrom-YamlScalar -Raw $rest + continue + } + + # Value on subsequent indented lines (mapping or sequence) or null. + if ($Context.Index -ge $lines.Count) { + $map[[string]$key] = $null + continue + } + + $next = $lines[$Context.Index] + if ($next.Indent -le $Indent) { + $map[[string]$key] = $null + continue + } + + # Sequences are allowed to start at the same indent as the parent key in YAML. + # We require the child to be indented strictly greater than the key here for clarity. + $childIndent = $next.Indent + $value = ConvertFrom-YamlNode -Context $Context -Indent $childIndent -Depth ($Depth + 1) + $map[[string]$key] = $value + } + + if ($Context.AsHashtable) { + return $map + } + + $obj = [pscustomobject]@{} + foreach ($k in $map.Keys) { + Add-Member -InputObject $obj -MemberType NoteProperty -Name $k -Value $map[$k] + } + return $obj +} diff --git a/src/functions/private/ConvertFrom-YamlNode.ps1 b/src/functions/private/ConvertFrom-YamlNode.ps1 index df8b292..e21d3fb 100644 --- a/src/functions/private/ConvertFrom-YamlNode.ps1 +++ b/src/functions/private/ConvertFrom-YamlNode.ps1 @@ -38,277 +38,3 @@ return ConvertFrom-YamlMapping -Context $Context -Indent $Indent -Depth $Depth } - -function ConvertFrom-YamlMapping { - <# - .SYNOPSIS - Parses a YAML block-style mapping into a PSCustomObject or OrderedDictionary. - #> - [CmdletBinding()] - [OutputType([System.Collections.Specialized.OrderedDictionary], [pscustomobject])] - param( - [Parameter(Mandatory)] [pscustomobject] $Context, - [Parameter(Mandatory)] [int] $Indent, - [Parameter(Mandatory)] [int] $Depth - ) - - $lines = $Context.Lines - $map = [ordered]@{} - - while ($Context.Index -lt $lines.Count) { - $line = $lines[$Context.Index] - if ($line.Indent -lt $Indent) { break } - if ($line.Indent -gt $Indent) { - throw "ConvertFrom-Yaml: unexpected indentation at line $($line.Number)." - } - if ($line.Content.StartsWith('- ') -or $line.Content -eq '-') { - # A sequence at the same indent as a mapping key is a sibling, not part of mapping. - break - } - - $colonIdx = Find-YamlMappingColon -Content $line.Content - if ($colonIdx -lt 0) { - throw "ConvertFrom-Yaml: expected mapping key at line $($line.Number): '$($line.Content)'." - } - - $key = ConvertFrom-YamlScalar -Raw $line.Content.Substring(0, $colonIdx).Trim() - $rest = $line.Content.Substring($colonIdx + 1).Trim() - - $Context.Index++ - - if ($rest.Length -gt 0) { - $map[[string]$key] = ConvertFrom-YamlScalar -Raw $rest - continue - } - - # Value on subsequent indented lines (mapping or sequence) or null. - if ($Context.Index -ge $lines.Count) { - $map[[string]$key] = $null - continue - } - - $next = $lines[$Context.Index] - if ($next.Indent -le $Indent) { - $map[[string]$key] = $null - continue - } - - # Sequences are allowed to start at the same indent as the parent key in YAML. - # We require the child to be indented strictly greater than the key here for clarity. - $childIndent = $next.Indent - $value = ConvertFrom-YamlNode -Context $Context -Indent $childIndent -Depth ($Depth + 1) - $map[[string]$key] = $value - } - - if ($Context.AsHashtable) { - return $map - } - - $obj = [pscustomobject]@{} - foreach ($k in $map.Keys) { - Add-Member -InputObject $obj -MemberType NoteProperty -Name $k -Value $map[$k] - } - return $obj -} - -function ConvertFrom-YamlSequence { - <# - .SYNOPSIS - Parses a YAML block-style sequence into a PowerShell array. - #> - [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', - Justification = 'Comma-unary operator preserves array type; PSScriptAnalyzer misdetects as Object[].')] - [CmdletBinding()] - [OutputType([object[]])] - param( - [Parameter(Mandatory)] [pscustomobject] $Context, - [Parameter(Mandatory)] [int] $Indent, - [Parameter(Mandatory)] [int] $Depth - ) - - $lines = $Context.Lines - $list = [System.Collections.Generic.List[object]]::new() - - while ($Context.Index -lt $lines.Count) { - $line = $lines[$Context.Index] - if ($line.Indent -lt $Indent) { break } - if ($line.Indent -gt $Indent) { - throw "ConvertFrom-Yaml: unexpected indentation at line $($line.Number)." - } - if (-not ($line.Content.StartsWith('- ') -or $line.Content -eq '-')) { - break - } - - $afterDash = if ($line.Content.Length -ge 2) { $line.Content.Substring(2).TrimEnd() } else { '' } - - if ($afterDash.Length -eq 0) { - # Value on subsequent indented lines. - $Context.Index++ - if ($Context.Index -ge $lines.Count) { - $list.Add($null) - continue - } - $next = $lines[$Context.Index] - if ($next.Indent -le $Indent) { - $list.Add($null) - continue - } - $list.Add((ConvertFrom-YamlNode -Context $Context -Indent $next.Indent -Depth ($Depth + 1))) - continue - } - - # Inline element: could be a scalar, or a mapping like "- key: value" with possibly more - # mapping keys on following lines indented at "Indent + 2" (under the dash). - $colonIdx = Find-YamlMappingColon -Content $afterDash - if ($colonIdx -ge 0) { - # Treat this as a single-line entry into a mapping. Build a synthetic line stream: - # the current "key: value" line gets re-interpreted at indent (Indent + 2), and any - # continuation lines at indent > (Indent + 2) belong to the same mapping. - $childIndent = $Indent + 2 - $synthetic = [pscustomobject]@{ - Indent = $childIndent - Content = $afterDash - Number = $line.Number - } - # Replace current line with synthetic and recurse as a mapping. - $Context.Lines[$Context.Index] = $synthetic - $value = ConvertFrom-YamlMapping -Context $Context -Indent $childIndent -Depth ($Depth + 1) - $list.Add($value) - continue - } - - # Plain scalar element. - $list.Add((ConvertFrom-YamlScalar -Raw $afterDash)) - $Context.Index++ - } - - return , $list.ToArray() -} - -function Find-YamlMappingColon { - <# - .SYNOPSIS - Returns the index of the unquoted `:` separator in a content line, or -1 if not found. - - .DESCRIPTION - The colon must be followed by whitespace or end-of-line for it to be a YAML mapping - separator. Colons inside quoted strings are ignored. - #> - [CmdletBinding()] - [OutputType([int])] - param( - [Parameter(Mandatory)] - [AllowEmptyString()] - [string] $Content - ) - - $inSingle = $false - $inDouble = $false - for ($i = 0; $i -lt $Content.Length; $i++) { - $c = $Content[$i] - if ($c -eq '\' -and $inDouble) { $i++; continue } - if ($c -eq "'" -and -not $inDouble) { $inSingle = -not $inSingle; continue } - if ($c -eq '"' -and -not $inSingle) { $inDouble = -not $inDouble; continue } - if ($c -eq ':' -and -not $inSingle -and -not $inDouble) { - if ($i -eq $Content.Length - 1) { return $i } - $next = $Content[$i + 1] - if ($next -eq ' ' -or $next -eq "`t") { return $i } - } - } - return -1 -} - -function ConvertFrom-YamlScalar { - <# - .SYNOPSIS - Converts a raw YAML scalar token into the appropriate PowerShell type. - #> - [CmdletBinding()] - [OutputType([string], [bool], [int], [long], [double])] - param( - [Parameter(Mandatory)] - [AllowEmptyString()] - [string] $Raw - ) - - $value = $Raw.Trim() - - if ($value.Length -eq 0) { return $null } - - # Quoted strings. - if ($value.Length -ge 2 -and $value.StartsWith("'") -and $value.EndsWith("'")) { - $inner = $value.Substring(1, $value.Length - 2) - return ($inner -replace "''", "'") - } - if ($value.Length -ge 2 -and $value.StartsWith('"') -and $value.EndsWith('"')) { - $inner = $value.Substring(1, $value.Length - 2) - return (Expand-YamlDoubleQuoted -Text $inner) - } - - # Null literal (YAML 1.2.2 core schema): empty, ~, null only. Case-sensitive. - if ($value -ceq '~' -or $value -ceq 'null') { return $null } - - # Boolean literal (YAML 1.2.2 core schema): true / false only. Case-sensitive. - if ($value -ceq 'true') { return $true } - if ($value -ceq 'false') { return $false } - - # Integer. - $intVal = 0 - if ([int]::TryParse($value, [System.Globalization.NumberStyles]::Integer, [cultureinfo]::InvariantCulture, [ref] $intVal)) { - return $intVal - } - $longVal = [long]0 - if ([long]::TryParse($value, [System.Globalization.NumberStyles]::Integer, [cultureinfo]::InvariantCulture, [ref] $longVal)) { - return $longVal - } - - # Float. - $dblVal = 0.0 - if ([double]::TryParse($value, [System.Globalization.NumberStyles]::Float, [cultureinfo]::InvariantCulture, [ref] $dblVal)) { - return $dblVal - } - - # Plain string. - return $value -} - -function Expand-YamlDoubleQuoted { - <# - .SYNOPSIS - Expands escape sequences inside a double-quoted YAML scalar. - #> - [CmdletBinding()] - [OutputType([string])] - param( - [Parameter(Mandatory)] - [AllowEmptyString()] - [string] $Text - ) - - $sb = [System.Text.StringBuilder]::new() - $i = 0 - while ($i -lt $Text.Length) { - $c = $Text[$i] - if ($c -eq '\' -and $i + 1 -lt $Text.Length) { - $next = $Text[$i + 1] - $expanded = $true - switch ($next) { - 'n' { $null = $sb.Append("`n") } - 't' { $null = $sb.Append("`t") } - 'r' { $null = $sb.Append("`r") } - '"' { $null = $sb.Append('"') } - '\' { $null = $sb.Append('\') } - '0' { $null = $sb.Append([char]0) } - default { $expanded = $false } - } - - if ($expanded) { - $i += 2 - continue - } - } - $null = $sb.Append($c) - $i++ - } - return $sb.ToString() -} diff --git a/src/functions/private/ConvertFrom-YamlScalar.ps1 b/src/functions/private/ConvertFrom-YamlScalar.ps1 new file mode 100644 index 0000000..68c61a7 --- /dev/null +++ b/src/functions/private/ConvertFrom-YamlScalar.ps1 @@ -0,0 +1,53 @@ +function ConvertFrom-YamlScalar { + <# + .SYNOPSIS + Converts a raw YAML scalar token into the appropriate PowerShell type. + #> + [CmdletBinding()] + [OutputType([string], [bool], [int], [long], [double])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Raw + ) + + $value = $Raw.Trim() + + if ($value.Length -eq 0) { return $null } + + # Quoted strings. + if ($value.Length -ge 2 -and $value.StartsWith("'") -and $value.EndsWith("'")) { + $inner = $value.Substring(1, $value.Length - 2) + return ($inner -replace "''", "'") + } + if ($value.Length -ge 2 -and $value.StartsWith('"') -and $value.EndsWith('"')) { + $inner = $value.Substring(1, $value.Length - 2) + return (Expand-YamlDoubleQuoted -Text $inner) + } + + # Null literal (YAML 1.2.2 core schema): empty, ~, null only. Case-sensitive. + if ($value -ceq '~' -or $value -ceq 'null') { return $null } + + # Boolean literal (YAML 1.2.2 core schema): true / false only. Case-sensitive. + if ($value -ceq 'true') { return $true } + if ($value -ceq 'false') { return $false } + + # Integer. + $intVal = 0 + if ([int]::TryParse($value, [System.Globalization.NumberStyles]::Integer, [cultureinfo]::InvariantCulture, [ref] $intVal)) { + return $intVal + } + $longVal = [long]0 + if ([long]::TryParse($value, [System.Globalization.NumberStyles]::Integer, [cultureinfo]::InvariantCulture, [ref] $longVal)) { + return $longVal + } + + # Float. + $dblVal = 0.0 + if ([double]::TryParse($value, [System.Globalization.NumberStyles]::Float, [cultureinfo]::InvariantCulture, [ref] $dblVal)) { + return $dblVal + } + + # Plain string. + return $value +} diff --git a/src/functions/private/ConvertFrom-YamlSequence.ps1 b/src/functions/private/ConvertFrom-YamlSequence.ps1 new file mode 100644 index 0000000..a76966b --- /dev/null +++ b/src/functions/private/ConvertFrom-YamlSequence.ps1 @@ -0,0 +1,73 @@ +function ConvertFrom-YamlSequence { + <# + .SYNOPSIS + Parses a YAML block-style sequence into a PowerShell array. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', + Justification = 'Comma-unary operator preserves array type; PSScriptAnalyzer misdetects as Object[].')] + [CmdletBinding()] + [OutputType([object[]])] + param( + [Parameter(Mandatory)] [pscustomobject] $Context, + [Parameter(Mandatory)] [int] $Indent, + [Parameter(Mandatory)] [int] $Depth + ) + + $lines = $Context.Lines + $list = [System.Collections.Generic.List[object]]::new() + + while ($Context.Index -lt $lines.Count) { + $line = $lines[$Context.Index] + if ($line.Indent -lt $Indent) { break } + if ($line.Indent -gt $Indent) { + throw "ConvertFrom-Yaml: unexpected indentation at line $($line.Number)." + } + if (-not ($line.Content.StartsWith('- ') -or $line.Content -eq '-')) { + break + } + + $afterDash = if ($line.Content.Length -ge 2) { $line.Content.Substring(2).TrimEnd() } else { '' } + + if ($afterDash.Length -eq 0) { + # Value on subsequent indented lines. + $Context.Index++ + if ($Context.Index -ge $lines.Count) { + $list.Add($null) + continue + } + $next = $lines[$Context.Index] + if ($next.Indent -le $Indent) { + $list.Add($null) + continue + } + $list.Add((ConvertFrom-YamlNode -Context $Context -Indent $next.Indent -Depth ($Depth + 1))) + continue + } + + # Inline element: could be a scalar, or a mapping like "- key: value" with possibly more + # mapping keys on following lines indented at "Indent + 2" (under the dash). + $colonIdx = Find-YamlMappingColon -Content $afterDash + if ($colonIdx -ge 0) { + # Treat this as a single-line entry into a mapping. Build a synthetic line stream: + # the current "key: value" line gets re-interpreted at indent (Indent + 2), and any + # continuation lines at indent > (Indent + 2) belong to the same mapping. + $childIndent = $Indent + 2 + $synthetic = [pscustomobject]@{ + Indent = $childIndent + Content = $afterDash + Number = $line.Number + } + # Replace current line with synthetic and recurse as a mapping. + $Context.Lines[$Context.Index] = $synthetic + $value = ConvertFrom-YamlMapping -Context $Context -Indent $childIndent -Depth ($Depth + 1) + $list.Add($value) + continue + } + + # Plain scalar element. + $list.Add((ConvertFrom-YamlScalar -Raw $afterDash)) + $Context.Index++ + } + + return , $list.ToArray() +} diff --git a/src/functions/private/ConvertTo-YamlMapping.ps1 b/src/functions/private/ConvertTo-YamlMapping.ps1 new file mode 100644 index 0000000..2c17463 --- /dev/null +++ b/src/functions/private/ConvertTo-YamlMapping.ps1 @@ -0,0 +1,63 @@ +function ConvertTo-YamlMapping { + <# + .SYNOPSIS + Writes a mapping value as a YAML block-style mapping into the StringBuilder. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object] $Value, + [Parameter(Mandatory)] [System.Text.StringBuilder] $Builder, + [Parameter(Mandatory)] [int] $Level, + [Parameter(Mandatory)] [int] $CurrentDepth, + [Parameter(Mandatory)] [pscustomobject] $Options + ) + + $pairs = Get-YamlMappingPair -Value $Value + if ($pairs.Count -eq 0) { + $null = $Builder.Append('{}').AppendLine() + return + } + + $indent = ' ' * ($Level * $Options.Indent) + foreach ($pair in $pairs) { + $keyText = Format-YamlKey -Key $pair.Key + $val = $pair.Value + $null = $Builder.Append($indent).Append($keyText).Append(':') + + if ($null -eq $val) { + $null = $Builder.Append(' null').AppendLine() + continue + } + + $rawVal = if ($val -is [psobject] -and $null -ne $val.PSObject -and $null -ne $val.PSObject.BaseObject) { + $val.PSObject.BaseObject + } else { + $val + } + + if (Test-YamlMappingType -Value $rawVal) { + $childPairs = Get-YamlMappingPair -Value $val + if ($childPairs.Count -eq 0) { + $null = $Builder.Append(' {}').AppendLine() + } else { + $null = $Builder.AppendLine() + ConvertTo-YamlNode -Value $val -Builder $Builder -Level ($Level + 1) -CurrentDepth ($CurrentDepth + 1) -Options $Options + } + continue + } + + if (Test-YamlSequenceType -Value $rawVal) { + $arr = @($rawVal) + if ($arr.Count -eq 0) { + $null = $Builder.Append(' []').AppendLine() + } else { + $null = $Builder.AppendLine() + ConvertTo-YamlSequence -Value $rawVal -Builder $Builder -Level ($Level + 1) -CurrentDepth ($CurrentDepth + 1) -Options $Options + } + continue + } + + $scalar = Format-YamlScalar -Value $rawVal -Options $Options + $null = $Builder.Append(' ').Append($scalar).AppendLine() + } +} diff --git a/src/functions/private/ConvertTo-YamlNode.ps1 b/src/functions/private/ConvertTo-YamlNode.ps1 index 3029950..dfd26c6 100644 --- a/src/functions/private/ConvertTo-YamlNode.ps1 +++ b/src/functions/private/ConvertTo-YamlNode.ps1 @@ -54,419 +54,3 @@ $scalar = Format-YamlScalar -Value $raw -Options $Options $null = $Builder.Append($scalar).AppendLine() } - -function Test-YamlMappingType { - <# - .SYNOPSIS - Returns true when a value should be serialized as a YAML mapping. - #> - [CmdletBinding()] - [OutputType([bool])] - param([Parameter()] [AllowNull()] [object] $Value) - - if ($null -eq $Value) { return $false } - if ($Value -is [System.Collections.IDictionary]) { return $true } - if ($Value -is [string]) { return $false } - if ($Value -is [System.ValueType]) { return $false } - if ($Value -is [System.Collections.IEnumerable]) { return $false } - if ($Value -is [psobject] -or $Value -is [System.Management.Automation.PSCustomObject]) { return $true } - return $false -} - -function Test-YamlSequenceType { - <# - .SYNOPSIS - Returns true when a value should be serialized as a YAML sequence. - #> - [CmdletBinding()] - [OutputType([bool])] - param([Parameter()] [AllowNull()] [object] $Value) - - if ($null -eq $Value) { return $false } - if ($Value -is [string]) { return $false } - if ($Value -is [System.Collections.IDictionary]) { return $false } - if ($Value -is [System.Collections.IEnumerable]) { return $true } - return $false -} - -function ConvertTo-YamlMapping { - <# - .SYNOPSIS - Writes a mapping value as a YAML block-style mapping into the StringBuilder. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] [object] $Value, - [Parameter(Mandatory)] [System.Text.StringBuilder] $Builder, - [Parameter(Mandatory)] [int] $Level, - [Parameter(Mandatory)] [int] $CurrentDepth, - [Parameter(Mandatory)] [pscustomobject] $Options - ) - - $pairs = Get-YamlMappingPair -Value $Value - if ($pairs.Count -eq 0) { - $null = $Builder.Append('{}').AppendLine() - return - } - - $indent = ' ' * ($Level * $Options.Indent) - foreach ($pair in $pairs) { - $keyText = Format-YamlKey -Key $pair.Key - $val = $pair.Value - $null = $Builder.Append($indent).Append($keyText).Append(':') - - if ($null -eq $val) { - $null = $Builder.Append(' null').AppendLine() - continue - } - - $rawVal = if ($val -is [psobject] -and $null -ne $val.PSObject -and $null -ne $val.PSObject.BaseObject) { - $val.PSObject.BaseObject - } else { - $val - } - - if (Test-YamlMappingType -Value $rawVal) { - $childPairs = Get-YamlMappingPair -Value $val - if ($childPairs.Count -eq 0) { - $null = $Builder.Append(' {}').AppendLine() - } else { - $null = $Builder.AppendLine() - ConvertTo-YamlNode -Value $val -Builder $Builder -Level ($Level + 1) -CurrentDepth ($CurrentDepth + 1) -Options $Options - } - continue - } - - if (Test-YamlSequenceType -Value $rawVal) { - $arr = @($rawVal) - if ($arr.Count -eq 0) { - $null = $Builder.Append(' []').AppendLine() - } else { - $null = $Builder.AppendLine() - ConvertTo-YamlSequence -Value $rawVal -Builder $Builder -Level ($Level + 1) -CurrentDepth ($CurrentDepth + 1) -Options $Options - } - continue - } - - $scalar = Format-YamlScalar -Value $rawVal -Options $Options - $null = $Builder.Append(' ').Append($scalar).AppendLine() - } -} - -function ConvertTo-YamlSequence { - <# - .SYNOPSIS - Writes a sequence value as a YAML block-style sequence into the StringBuilder. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] [object] $Value, - [Parameter(Mandatory)] [System.Text.StringBuilder] $Builder, - [Parameter(Mandatory)] [int] $Level, - [Parameter(Mandatory)] [int] $CurrentDepth, - [Parameter(Mandatory)] [pscustomobject] $Options - ) - - $items = @($Value) - if ($items.Count -eq 0) { - $null = $Builder.Append('[]').AppendLine() - return - } - - $indent = ' ' * ($Level * $Options.Indent) - - foreach ($item in $items) { - $rawItem = if ($item -is [psobject] -and $null -ne $item.PSObject -and $null -ne $item.PSObject.BaseObject) { - $item.PSObject.BaseObject - } else { - $item - } - - if ($null -eq $item) { - $null = $Builder.Append($indent).Append('- null').AppendLine() - continue - } - - if (Test-YamlMappingType -Value $rawItem) { - $pairs = Get-YamlMappingPair -Value $item - if ($pairs.Count -eq 0) { - $null = $Builder.Append($indent).Append('- {}').AppendLine() - continue - } - $first = $true - $childIndent = ' ' * (($Level + 1) * $Options.Indent) - foreach ($pair in $pairs) { - $keyText = Format-YamlKey -Key $pair.Key - $prefix = if ($first) { "$indent- " } else { $childIndent } - $first = $false - - $val = $pair.Value - $rawVal = if ($val -is [psobject] -and $null -ne $val.PSObject -and $null -ne $val.PSObject.BaseObject) { - $val.PSObject.BaseObject - } else { - $val - } - - if ($null -eq $val) { - $null = $Builder.Append($prefix).Append($keyText).Append(': null').AppendLine() - continue - } - - if (Test-YamlMappingType -Value $rawVal) { - $null = $Builder.Append($prefix).Append($keyText).Append(':').AppendLine() - ConvertTo-YamlNode -Value $val -Builder $Builder -Level ($Level + 2) -CurrentDepth ($CurrentDepth + 1) -Options $Options - continue - } - - if (Test-YamlSequenceType -Value $rawVal) { - $null = $Builder.Append($prefix).Append($keyText).Append(':').AppendLine() - ConvertTo-YamlSequence -Value $rawVal -Builder $Builder -Level ($Level + 2) -CurrentDepth ($CurrentDepth + 1) -Options $Options - continue - } - - $scalar = Format-YamlScalar -Value $rawVal -Options $Options - $null = $Builder.Append($prefix).Append($keyText).Append(': ').Append($scalar).AppendLine() - } - continue - } - - if (Test-YamlSequenceType -Value $rawItem) { - $null = $Builder.Append($indent).Append('-').AppendLine() - ConvertTo-YamlSequence -Value $rawItem -Builder $Builder -Level ($Level + 1) -CurrentDepth ($CurrentDepth + 1) -Options $Options - continue - } - - $scalar = Format-YamlScalar -Value $rawItem -Options $Options - $null = $Builder.Append($indent).Append('- ').Append($scalar).AppendLine() - } -} - -function Get-YamlMappingPair { - <# - .SYNOPSIS - Returns a list of [pscustomobject]@{ Key; Value } for a dictionary or PSObject. - #> - [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', - Justification = 'Comma-unary operator preserves List type; PSScriptAnalyzer misdetects as Object[].')] - [CmdletBinding()] - [OutputType([System.Collections.Generic.List[pscustomobject]])] - param( - [Parameter(Mandatory)] - [object] $Value - ) - - $pairs = [System.Collections.Generic.List[pscustomobject]]::new() - $raw = if ($Value -is [psobject] -and $null -ne $Value.PSObject -and $null -ne $Value.PSObject.BaseObject) { - $Value.PSObject.BaseObject - } else { - $Value - } - - if ($raw -is [System.Collections.IDictionary]) { - foreach ($key in $raw.Keys) { - $pairs.Add([pscustomobject]@{ Key = $key; Value = $raw[$key] }) - } - return , $pairs - } - - if ($Value -is [psobject]) { - foreach ($prop in $Value.PSObject.Properties) { - $pairs.Add([pscustomobject]@{ Key = $prop.Name; Value = $prop.Value }) - } - } - - return , $pairs -} - -function Format-YamlScalar { - <# - .SYNOPSIS - Renders a scalar value as a YAML token. - #> - [CmdletBinding()] - [OutputType([string])] - param( - [Parameter()] [AllowNull()] [object] $Value, - [Parameter(Mandatory)] [pscustomobject] $Options - ) - - if ($null -eq $Value) { return 'null' } - - if ($Value -is [bool]) { return $(if ($Value) { 'true' } else { 'false' }) } - - if ($Value -is [System.Enum]) { - if ($Options.EnumsAsStrings) { - return Format-YamlString -Text ($Value.ToString()) - } - return ([int64] $Value).ToString([cultureinfo]::InvariantCulture) - } - - if ($Value -is [byte] -or $Value -is [sbyte] -or - $Value -is [int16] -or $Value -is [uint16] -or - $Value -is [int] -or $Value -is [uint32] -or - $Value -is [long] -or $Value -is [uint64]) { - return ([System.IConvertible] $Value).ToString([cultureinfo]::InvariantCulture) - } - - if ($Value -is [single] -or $Value -is [double] -or $Value -is [decimal]) { - return ([System.IConvertible] $Value).ToString([cultureinfo]::InvariantCulture) - } - - if ($Value -is [datetime]) { - return $Value.ToString('o', [cultureinfo]::InvariantCulture) - } - - return Format-YamlString -Text ([string] $Value) -} - -function Format-YamlString { - <# - .SYNOPSIS - Renders a string as a YAML scalar, quoting and escaping as needed. - #> - [CmdletBinding()] - [OutputType([string])] - param( - [Parameter(Mandatory)] - [AllowEmptyString()] - [string] $Text - ) - - if ($Text.Length -eq 0) { return "''" } - - # Always double-quote strings that contain control characters or quotes that need escaping. - $needsDoubleQuote = $false - foreach ($ch in $Text.ToCharArray()) { - $code = [int] $ch - if ($code -lt 0x20 -or $code -eq 0x7F) { - $needsDoubleQuote = $true - break - } - } - - if ($needsDoubleQuote) { - return Format-YamlDoubleQuoted -Text $Text - } - - if (Test-YamlPlainSafe -Text $Text) { - return $Text - } - - # Prefer single quotes when the text doesn't contain a single quote; otherwise double-quote. - if ($Text -notmatch "'") { - return "'$Text'" - } - - return Format-YamlDoubleQuoted -Text $Text -} - -function Format-YamlDoubleQuoted { - <# - .SYNOPSIS - Wraps a string in double quotes, escaping special characters per YAML rules. - #> - [CmdletBinding()] - [OutputType([string])] - param( - [Parameter(Mandatory)] - [AllowEmptyString()] - [string] $Text - ) - - $sb = [System.Text.StringBuilder]::new() - $null = $sb.Append('"') - foreach ($ch in $Text.ToCharArray()) { - $code = [int] $ch - if ($ch -eq '\') { $null = $sb.Append('\\'); continue } - if ($ch -eq '"') { $null = $sb.Append('\"'); continue } - if ($ch -eq "`n") { $null = $sb.Append('\n'); continue } - if ($ch -eq "`t") { $null = $sb.Append('\t'); continue } - if ($ch -eq "`r") { $null = $sb.Append('\r'); continue } - if ($code -lt 0x20 -or $code -eq 0x7F) { - $null = $sb.AppendFormat('\x{0:x2}', $code) - continue - } - $null = $sb.Append($ch) - } - $null = $sb.Append('"') - return $sb.ToString() -} - -function Format-YamlKey { - <# - .SYNOPSIS - Renders a mapping key as a YAML scalar, quoting when necessary. - #> - [CmdletBinding()] - [OutputType([string])] - param( - [Parameter(Mandatory)] - [object] $Key - ) - - $text = [string] $Key - if ([string]::IsNullOrEmpty($text)) { return "''" } - if (Test-YamlPlainSafe -Text $text -ForKey) { - return $text - } - if ($text -notmatch "'") { return "'$text'" } - return Format-YamlDoubleQuoted -Text $text -} - -function Test-YamlPlainSafe { - <# - .SYNOPSIS - Returns $true when a string can be emitted as a plain (unquoted) YAML scalar. - #> - [CmdletBinding()] - [OutputType([bool])] - param( - [Parameter(Mandatory)] - [AllowEmptyString()] - [string] $Text, - - [Parameter()] - [switch] $ForKey - ) - - if ($Text.Length -eq 0) { return $false } - if ($Text -ne $Text.Trim()) { return $false } - - # Strings that match YAML 1.2.2 core schema literals must be quoted to preserve string type. - # Comparison is case-sensitive — only the lowercase canonical forms are recognised by parsers. - if ($Text -ceq 'true' -or $Text -ceq 'false' -or $Text -ceq 'null' -or $Text -ceq '~') { - return $false - } - - # Strings that parse as a number must be quoted. - $tmpInt = 0L - if ([long]::TryParse($Text, [System.Globalization.NumberStyles]::Integer, [cultureinfo]::InvariantCulture, [ref] $tmpInt)) { - return $false - } - $tmpDbl = 0.0 - if ([double]::TryParse($Text, [System.Globalization.NumberStyles]::Float, [cultureinfo]::InvariantCulture, [ref] $tmpDbl)) { - return $false - } - - # Disallowed leading characters per YAML plain scalar rules. - $first = $Text[0] - $disallowedFirst = @('-', '?', ':', ',', '[', ']', '{', '}', '#', '&', '*', '!', '|', '>', "'", '"', '%', '@', '`') - if ($disallowedFirst -contains [string] $first) { return $false } - - foreach ($ch in $Text.ToCharArray()) { - $code = [int] $ch - if ($code -lt 0x20 -or $code -eq 0x7F) { return $false } - } - - # Disallowed characters anywhere (would confuse parsing). - if ($Text -match '[:#]') { - # ': ' or ' #' would be ambiguous; conservatively quote whenever ':' or '#' appear. - return $false - } - - if ($ForKey) { - if ($Text -match '[\[\]\{\},&*!|>''"%@`]') { return $false } - } - - return $true -} diff --git a/src/functions/private/ConvertTo-YamlSequence.ps1 b/src/functions/private/ConvertTo-YamlSequence.ps1 new file mode 100644 index 0000000..3491348 --- /dev/null +++ b/src/functions/private/ConvertTo-YamlSequence.ps1 @@ -0,0 +1,87 @@ +function ConvertTo-YamlSequence { + <# + .SYNOPSIS + Writes a sequence value as a YAML block-style sequence into the StringBuilder. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [object] $Value, + [Parameter(Mandatory)] [System.Text.StringBuilder] $Builder, + [Parameter(Mandatory)] [int] $Level, + [Parameter(Mandatory)] [int] $CurrentDepth, + [Parameter(Mandatory)] [pscustomobject] $Options + ) + + $items = @($Value) + if ($items.Count -eq 0) { + $null = $Builder.Append('[]').AppendLine() + return + } + + $indent = ' ' * ($Level * $Options.Indent) + + foreach ($item in $items) { + $rawItem = if ($item -is [psobject] -and $null -ne $item.PSObject -and $null -ne $item.PSObject.BaseObject) { + $item.PSObject.BaseObject + } else { + $item + } + + if ($null -eq $item) { + $null = $Builder.Append($indent).Append('- null').AppendLine() + continue + } + + if (Test-YamlMappingType -Value $rawItem) { + $pairs = Get-YamlMappingPair -Value $item + if ($pairs.Count -eq 0) { + $null = $Builder.Append($indent).Append('- {}').AppendLine() + continue + } + $first = $true + $childIndent = ' ' * (($Level + 1) * $Options.Indent) + foreach ($pair in $pairs) { + $keyText = Format-YamlKey -Key $pair.Key + $prefix = if ($first) { "$indent- " } else { $childIndent } + $first = $false + + $val = $pair.Value + $rawVal = if ($val -is [psobject] -and $null -ne $val.PSObject -and $null -ne $val.PSObject.BaseObject) { + $val.PSObject.BaseObject + } else { + $val + } + + if ($null -eq $val) { + $null = $Builder.Append($prefix).Append($keyText).Append(': null').AppendLine() + continue + } + + if (Test-YamlMappingType -Value $rawVal) { + $null = $Builder.Append($prefix).Append($keyText).Append(':').AppendLine() + ConvertTo-YamlNode -Value $val -Builder $Builder -Level ($Level + 2) -CurrentDepth ($CurrentDepth + 1) -Options $Options + continue + } + + if (Test-YamlSequenceType -Value $rawVal) { + $null = $Builder.Append($prefix).Append($keyText).Append(':').AppendLine() + ConvertTo-YamlSequence -Value $rawVal -Builder $Builder -Level ($Level + 2) -CurrentDepth ($CurrentDepth + 1) -Options $Options + continue + } + + $scalar = Format-YamlScalar -Value $rawVal -Options $Options + $null = $Builder.Append($prefix).Append($keyText).Append(': ').Append($scalar).AppendLine() + } + continue + } + + if (Test-YamlSequenceType -Value $rawItem) { + $null = $Builder.Append($indent).Append('-').AppendLine() + ConvertTo-YamlSequence -Value $rawItem -Builder $Builder -Level ($Level + 1) -CurrentDepth ($CurrentDepth + 1) -Options $Options + continue + } + + $scalar = Format-YamlScalar -Value $rawItem -Options $Options + $null = $Builder.Append($indent).Append('- ').Append($scalar).AppendLine() + } +} diff --git a/src/functions/private/Expand-YamlDoubleQuoted.ps1 b/src/functions/private/Expand-YamlDoubleQuoted.ps1 new file mode 100644 index 0000000..099faca --- /dev/null +++ b/src/functions/private/Expand-YamlDoubleQuoted.ps1 @@ -0,0 +1,40 @@ +function Expand-YamlDoubleQuoted { + <# + .SYNOPSIS + Expands escape sequences inside a double-quoted YAML scalar. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Text + ) + + $sb = [System.Text.StringBuilder]::new() + $i = 0 + while ($i -lt $Text.Length) { + $c = $Text[$i] + if ($c -eq '\' -and $i + 1 -lt $Text.Length) { + $next = $Text[$i + 1] + $expanded = $true + switch ($next) { + 'n' { $null = $sb.Append("`n") } + 't' { $null = $sb.Append("`t") } + 'r' { $null = $sb.Append("`r") } + '"' { $null = $sb.Append('"') } + '\' { $null = $sb.Append('\') } + '0' { $null = $sb.Append([char]0) } + default { $expanded = $false } + } + + if ($expanded) { + $i += 2 + continue + } + } + $null = $sb.Append($c) + $i++ + } + return $sb.ToString() +} diff --git a/src/functions/private/Find-YamlMappingColon.ps1 b/src/functions/private/Find-YamlMappingColon.ps1 new file mode 100644 index 0000000..3a371e1 --- /dev/null +++ b/src/functions/private/Find-YamlMappingColon.ps1 @@ -0,0 +1,32 @@ +function Find-YamlMappingColon { + <# + .SYNOPSIS + Returns the index of the unquoted `:` separator in a content line, or -1 if not found. + + .DESCRIPTION + The colon must be followed by whitespace or end-of-line for it to be a YAML mapping + separator. Colons inside quoted strings are ignored. + #> + [CmdletBinding()] + [OutputType([int])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Content + ) + + $inSingle = $false + $inDouble = $false + for ($i = 0; $i -lt $Content.Length; $i++) { + $c = $Content[$i] + if ($c -eq '\' -and $inDouble) { $i++; continue } + if ($c -eq "'" -and -not $inDouble) { $inSingle = -not $inSingle; continue } + if ($c -eq '"' -and -not $inSingle) { $inDouble = -not $inDouble; continue } + if ($c -eq ':' -and -not $inSingle -and -not $inDouble) { + if ($i -eq $Content.Length - 1) { return $i } + $next = $Content[$i + 1] + if ($next -eq ' ' -or $next -eq "`t") { return $i } + } + } + return -1 +} diff --git a/src/functions/private/Format-YamlDoubleQuoted.ps1 b/src/functions/private/Format-YamlDoubleQuoted.ps1 new file mode 100644 index 0000000..9fecb5f --- /dev/null +++ b/src/functions/private/Format-YamlDoubleQuoted.ps1 @@ -0,0 +1,31 @@ +function Format-YamlDoubleQuoted { + <# + .SYNOPSIS + Wraps a string in double quotes, escaping special characters per YAML rules. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Text + ) + + $sb = [System.Text.StringBuilder]::new() + $null = $sb.Append('"') + foreach ($ch in $Text.ToCharArray()) { + $code = [int] $ch + if ($ch -eq '\') { $null = $sb.Append('\\'); continue } + if ($ch -eq '"') { $null = $sb.Append('\"'); continue } + if ($ch -eq "`n") { $null = $sb.Append('\n'); continue } + if ($ch -eq "`t") { $null = $sb.Append('\t'); continue } + if ($ch -eq "`r") { $null = $sb.Append('\r'); continue } + if ($code -lt 0x20 -or $code -eq 0x7F) { + $null = $sb.AppendFormat('\x{0:x2}', $code) + continue + } + $null = $sb.Append($ch) + } + $null = $sb.Append('"') + return $sb.ToString() +} diff --git a/src/functions/private/Format-YamlKey.ps1 b/src/functions/private/Format-YamlKey.ps1 new file mode 100644 index 0000000..a007b30 --- /dev/null +++ b/src/functions/private/Format-YamlKey.ps1 @@ -0,0 +1,20 @@ +function Format-YamlKey { + <# + .SYNOPSIS + Renders a mapping key as a YAML scalar, quoting when necessary. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [object] $Key + ) + + $text = [string] $Key + if ([string]::IsNullOrEmpty($text)) { return "''" } + if (Test-YamlPlainSafe -Text $text -ForKey) { + return $text + } + if ($text -notmatch "'") { return "'$text'" } + return Format-YamlDoubleQuoted -Text $text +} diff --git a/src/functions/private/Format-YamlScalar.ps1 b/src/functions/private/Format-YamlScalar.ps1 new file mode 100644 index 0000000..c21f0c8 --- /dev/null +++ b/src/functions/private/Format-YamlScalar.ps1 @@ -0,0 +1,40 @@ +function Format-YamlScalar { + <# + .SYNOPSIS + Renders a scalar value as a YAML token. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter()] [AllowNull()] [object] $Value, + [Parameter(Mandatory)] [pscustomobject] $Options + ) + + if ($null -eq $Value) { return 'null' } + + if ($Value -is [bool]) { return $(if ($Value) { 'true' } else { 'false' }) } + + if ($Value -is [System.Enum]) { + if ($Options.EnumsAsStrings) { + return Format-YamlString -Text ($Value.ToString()) + } + return ([int64] $Value).ToString([cultureinfo]::InvariantCulture) + } + + if ($Value -is [byte] -or $Value -is [sbyte] -or + $Value -is [int16] -or $Value -is [uint16] -or + $Value -is [int] -or $Value -is [uint32] -or + $Value -is [long] -or $Value -is [uint64]) { + return ([System.IConvertible] $Value).ToString([cultureinfo]::InvariantCulture) + } + + if ($Value -is [single] -or $Value -is [double] -or $Value -is [decimal]) { + return ([System.IConvertible] $Value).ToString([cultureinfo]::InvariantCulture) + } + + if ($Value -is [datetime]) { + return $Value.ToString('o', [cultureinfo]::InvariantCulture) + } + + return Format-YamlString -Text ([string] $Value) +} diff --git a/src/functions/private/Format-YamlString.ps1 b/src/functions/private/Format-YamlString.ps1 new file mode 100644 index 0000000..edb6134 --- /dev/null +++ b/src/functions/private/Format-YamlString.ps1 @@ -0,0 +1,40 @@ +function Format-YamlString { + <# + .SYNOPSIS + Renders a string as a YAML scalar, quoting and escaping as needed. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Text + ) + + if ($Text.Length -eq 0) { return "''" } + + # Always double-quote strings that contain control characters or quotes that need escaping. + $needsDoubleQuote = $false + foreach ($ch in $Text.ToCharArray()) { + $code = [int] $ch + if ($code -lt 0x20 -or $code -eq 0x7F) { + $needsDoubleQuote = $true + break + } + } + + if ($needsDoubleQuote) { + return Format-YamlDoubleQuoted -Text $Text + } + + if (Test-YamlPlainSafe -Text $Text) { + return $Text + } + + # Prefer single quotes when the text doesn't contain a single quote; otherwise double-quote. + if ($Text -notmatch "'") { + return "'$Text'" + } + + return Format-YamlDoubleQuoted -Text $Text +} diff --git a/src/functions/private/Get-YamlMappingPair.ps1 b/src/functions/private/Get-YamlMappingPair.ps1 new file mode 100644 index 0000000..6c263ee --- /dev/null +++ b/src/functions/private/Get-YamlMappingPair.ps1 @@ -0,0 +1,36 @@ +function Get-YamlMappingPair { + <# + .SYNOPSIS + Returns a list of [pscustomobject]@{ Key; Value } for a dictionary or PSObject. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', + Justification = 'Comma-unary operator preserves List type; PSScriptAnalyzer misdetects as Object[].')] + [CmdletBinding()] + [OutputType([System.Collections.Generic.List[pscustomobject]])] + param( + [Parameter(Mandatory)] + [object] $Value + ) + + $pairs = [System.Collections.Generic.List[pscustomobject]]::new() + $raw = if ($Value -is [psobject] -and $null -ne $Value.PSObject -and $null -ne $Value.PSObject.BaseObject) { + $Value.PSObject.BaseObject + } else { + $Value + } + + if ($raw -is [System.Collections.IDictionary]) { + foreach ($key in $raw.Keys) { + $pairs.Add([pscustomobject]@{ Key = $key; Value = $raw[$key] }) + } + return , $pairs + } + + if ($Value -is [psobject]) { + foreach ($prop in $Value.PSObject.Properties) { + $pairs.Add([pscustomobject]@{ Key = $prop.Name; Value = $prop.Value }) + } + } + + return , $pairs +} diff --git a/src/functions/private/Remove-YamlInlineComment.ps1 b/src/functions/private/Remove-YamlInlineComment.ps1 new file mode 100644 index 0000000..bbd3537 --- /dev/null +++ b/src/functions/private/Remove-YamlInlineComment.ps1 @@ -0,0 +1,41 @@ +function Remove-YamlInlineComment { + <# + .SYNOPSIS + Removes an unquoted `# comment` suffix from a YAML content line. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'This function operates on a string parameter, not system state.')] + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Line + ) + + $inSingle = $false + $inDouble = $false + for ($i = 0; $i -lt $Line.Length; $i++) { + $c = $Line[$i] + if ($c -eq '\' -and $inDouble) { + # Skip escaped char inside double quotes. + $i++ + continue + } + if ($c -eq "'" -and -not $inDouble) { + $inSingle = -not $inSingle + continue + } + if ($c -eq '"' -and -not $inSingle) { + $inDouble = -not $inDouble + continue + } + if ($c -eq '#' -and -not $inSingle -and -not $inDouble) { + # Comment must be preceded by whitespace or be at start of line. + if ($i -eq 0 -or $Line[$i - 1] -eq ' ' -or $Line[$i - 1] -eq "`t") { + return $Line.Substring(0, $i) + } + } + } + return $Line +} diff --git a/src/functions/private/Test-YamlMappingType.ps1 b/src/functions/private/Test-YamlMappingType.ps1 new file mode 100644 index 0000000..92fc0b0 --- /dev/null +++ b/src/functions/private/Test-YamlMappingType.ps1 @@ -0,0 +1,17 @@ +function Test-YamlMappingType { + <# + .SYNOPSIS + Returns true when a value should be serialized as a YAML mapping. + #> + [CmdletBinding()] + [OutputType([bool])] + param([Parameter()] [AllowNull()] [object] $Value) + + if ($null -eq $Value) { return $false } + if ($Value -is [System.Collections.IDictionary]) { return $true } + if ($Value -is [string]) { return $false } + if ($Value -is [System.ValueType]) { return $false } + if ($Value -is [System.Collections.IEnumerable]) { return $false } + if ($Value -is [psobject] -or $Value -is [System.Management.Automation.PSCustomObject]) { return $true } + return $false +} diff --git a/src/functions/private/Test-YamlPlainSafe.ps1 b/src/functions/private/Test-YamlPlainSafe.ps1 new file mode 100644 index 0000000..58b9be3 --- /dev/null +++ b/src/functions/private/Test-YamlPlainSafe.ps1 @@ -0,0 +1,57 @@ +function Test-YamlPlainSafe { + <# + .SYNOPSIS + Returns $true when a string can be emitted as a plain (unquoted) YAML scalar. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Text, + + [Parameter()] + [switch] $ForKey + ) + + if ($Text.Length -eq 0) { return $false } + if ($Text -ne $Text.Trim()) { return $false } + + # Strings that match YAML 1.2.2 core schema literals must be quoted to preserve string type. + # Comparison is case-sensitive — only the lowercase canonical forms are recognised by parsers. + if ($Text -ceq 'true' -or $Text -ceq 'false' -or $Text -ceq 'null' -or $Text -ceq '~') { + return $false + } + + # Strings that parse as a number must be quoted. + $tmpInt = 0L + if ([long]::TryParse($Text, [System.Globalization.NumberStyles]::Integer, [cultureinfo]::InvariantCulture, [ref] $tmpInt)) { + return $false + } + $tmpDbl = 0.0 + if ([double]::TryParse($Text, [System.Globalization.NumberStyles]::Float, [cultureinfo]::InvariantCulture, [ref] $tmpDbl)) { + return $false + } + + # Disallowed leading characters per YAML plain scalar rules. + $first = $Text[0] + $disallowedFirst = @('-', '?', ':', ',', '[', ']', '{', '}', '#', '&', '*', '!', '|', '>', "'", '"', '%', '@', '`') + if ($disallowedFirst -contains [string] $first) { return $false } + + foreach ($ch in $Text.ToCharArray()) { + $code = [int] $ch + if ($code -lt 0x20 -or $code -eq 0x7F) { return $false } + } + + # Disallowed characters anywhere (would confuse parsing). + if ($Text -match '[:#]') { + # ': ' or ' #' would be ambiguous; conservatively quote whenever ':' or '#' appear. + return $false + } + + if ($ForKey) { + if ($Text -match '[\[\]\{\},&*!|>''"%@`]') { return $false } + } + + return $true +} diff --git a/src/functions/private/Test-YamlSequenceType.ps1 b/src/functions/private/Test-YamlSequenceType.ps1 new file mode 100644 index 0000000..1248ccd --- /dev/null +++ b/src/functions/private/Test-YamlSequenceType.ps1 @@ -0,0 +1,15 @@ +function Test-YamlSequenceType { + <# + .SYNOPSIS + Returns true when a value should be serialized as a YAML sequence. + #> + [CmdletBinding()] + [OutputType([bool])] + param([Parameter()] [AllowNull()] [object] $Value) + + if ($null -eq $Value) { return $false } + if ($Value -is [string]) { return $false } + if ($Value -is [System.Collections.IDictionary]) { return $false } + if ($Value -is [System.Collections.IEnumerable]) { return $true } + return $false +} From 8956c194e163e7af2fb73996fd878fc0e7be3083 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 3 May 2026 00:25:05 +0200 Subject: [PATCH 12/21] Fix mapping key parsing: preserve raw text without type resolution, use [pscustomobject] cast to handle member name collisions - Keys like true/false/null/~ are now preserved as literal strings instead of being type-resolved then cast back (which changed true->True, null->empty string) - Quoted keys still have their escapes expanded correctly - Replace Add-Member loop with [pscustomobject]$map cast to avoid errors when keys collide with built-in member names like ToString/GetType - Add 3 tests: YAML-special keys, quoted key escapes, member name collision keys --- .../private/ConvertFrom-YamlMapping.ps1 | 23 ++++++----- tests/ConvertFrom-Yaml.Tests.ps1 | 40 +++++++++++++++++++ 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/functions/private/ConvertFrom-YamlMapping.ps1 b/src/functions/private/ConvertFrom-YamlMapping.ps1 index 54e7780..2f56503 100644 --- a/src/functions/private/ConvertFrom-YamlMapping.ps1 +++ b/src/functions/private/ConvertFrom-YamlMapping.ps1 @@ -30,25 +30,32 @@ throw "ConvertFrom-Yaml: expected mapping key at line $($line.Number): '$($line.Content)'." } - $key = ConvertFrom-YamlScalar -Raw $line.Content.Substring(0, $colonIdx).Trim() + $rawKey = $line.Content.Substring(0, $colonIdx).Trim() + if ($rawKey.Length -ge 2 -and $rawKey[0] -eq "'" -and $rawKey[-1] -eq "'") { + $key = ($rawKey.Substring(1, $rawKey.Length - 2)) -replace "''", "'" + } elseif ($rawKey.Length -ge 2 -and $rawKey[0] -eq '"' -and $rawKey[-1] -eq '"') { + $key = Expand-YamlDoubleQuoted -Text ($rawKey.Substring(1, $rawKey.Length - 2)) + } else { + $key = $rawKey + } $rest = $line.Content.Substring($colonIdx + 1).Trim() $Context.Index++ if ($rest.Length -gt 0) { - $map[[string]$key] = ConvertFrom-YamlScalar -Raw $rest + $map[$key] = ConvertFrom-YamlScalar -Raw $rest continue } # Value on subsequent indented lines (mapping or sequence) or null. if ($Context.Index -ge $lines.Count) { - $map[[string]$key] = $null + $map[$key] = $null continue } $next = $lines[$Context.Index] if ($next.Indent -le $Indent) { - $map[[string]$key] = $null + $map[$key] = $null continue } @@ -56,16 +63,12 @@ # We require the child to be indented strictly greater than the key here for clarity. $childIndent = $next.Indent $value = ConvertFrom-YamlNode -Context $Context -Indent $childIndent -Depth ($Depth + 1) - $map[[string]$key] = $value + $map[$key] = $value } if ($Context.AsHashtable) { return $map } - $obj = [pscustomobject]@{} - foreach ($k in $map.Keys) { - Add-Member -InputObject $obj -MemberType NoteProperty -Name $k -Value $map[$k] - } - return $obj + return [pscustomobject]$map } diff --git a/tests/ConvertFrom-Yaml.Tests.ps1 b/tests/ConvertFrom-Yaml.Tests.ps1 index c5c4c8b..42d84a7 100644 --- a/tests/ConvertFrom-Yaml.Tests.ps1 +++ b/tests/ConvertFrom-Yaml.Tests.ps1 @@ -131,6 +131,46 @@ mango: 3 $names[1] | Should -Be 'apple' $names[2] | Should -Be 'mango' } + + It 'Preserves raw text of YAML-special keys without type resolution' { + $yaml = @' +true: a +false: b +null: c +~: d +'@ + $result = $yaml | ConvertFrom-Yaml -AsHashtable + $result.Keys | Should -Contain 'true' + $result.Keys | Should -Contain 'false' + $result.Keys | Should -Contain 'null' + $result.Keys | Should -Contain '~' + $result['true'] | Should -Be 'a' + $result['false'] | Should -Be 'b' + $result['null'] | Should -Be 'c' + $result['~'] | Should -Be 'd' + } + + It 'Handles quoted mapping keys with escapes' { + $yaml = @' +"key\nwith": value1 +'single''s': value2 +'@ + $result = $yaml | ConvertFrom-Yaml -AsHashtable + $result.Keys | Should -Contain "key`nwith" + $result["key`nwith"] | Should -Be 'value1' + $result.Keys | Should -Contain "single's" + $result["single's"] | Should -Be 'value2' + } + + It 'Parses keys that collide with built-in member names' { + $yaml = @' +ToString: hello +GetType: world +'@ + $result = $yaml | ConvertFrom-Yaml + $result.PSObject.Properties['ToString'].Value | Should -Be 'hello' + $result.PSObject.Properties['GetType'].Value | Should -Be 'world' + } } Context 'Sequences' { From 7ce8fd0f6dfb991808afcd566974237f66803ab4 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 3 May 2026 02:19:40 +0200 Subject: [PATCH 13/21] Fix empty collection serialization, depth-exceeded indent, and [] / {} round-tripping - Fix if/else expressions that collapsed empty arrays to $null (PowerShell unrolls @() to nothing in expression output). Converted to statement form in ConvertTo-YamlMapping, ConvertTo-YamlSequence, ConvertTo-YamlNode, Get-YamlMappingPair, and ConvertTo-Yaml. - Add indentation to empty {} and [] output in ConvertTo-YamlMapping and ConvertTo-YamlSequence so nested empty collections serialize correctly. - Add indentation to depth-exceeded branch in ConvertTo-YamlNode. - Add inline empty-collection checks in ConvertTo-YamlSequence for nested mapping/sequence values within sequence-item-mappings. - Recognize {} and [] inline literals in ConvertFrom-YamlMapping and ConvertFrom-YamlSequence to restore round-trip fidelity. - Add 17 new tests: empty collection rendering, depth-exceeded indentation, {} / [] parsing, and round-trip preservation of empty collections. --- .../private/ConvertFrom-YamlMapping.ps1 | 8 +- .../private/ConvertFrom-YamlSequence.ps1 | 13 ++++ .../private/ConvertTo-YamlMapping.ps1 | 9 ++- src/functions/private/ConvertTo-YamlNode.ps1 | 9 ++- .../private/ConvertTo-YamlSequence.ps1 | 42 ++++++---- src/functions/private/Get-YamlMappingPair.ps1 | 6 +- src/functions/public/ConvertTo-Yaml.ps1 | 6 +- tests/ConvertFrom-Yaml.Tests.ps1 | 35 +++++++++ tests/ConvertTo-Yaml.Tests.ps1 | 76 +++++++++++++++++++ 9 files changed, 178 insertions(+), 26 deletions(-) diff --git a/src/functions/private/ConvertFrom-YamlMapping.ps1 b/src/functions/private/ConvertFrom-YamlMapping.ps1 index 2f56503..53b422a 100644 --- a/src/functions/private/ConvertFrom-YamlMapping.ps1 +++ b/src/functions/private/ConvertFrom-YamlMapping.ps1 @@ -43,7 +43,13 @@ $Context.Index++ if ($rest.Length -gt 0) { - $map[$key] = ConvertFrom-YamlScalar -Raw $rest + if ($rest -ceq '{}') { + $map[$key] = if ($Context.AsHashtable) { [ordered]@{} } else { [pscustomobject][ordered]@{} } + } elseif ($rest -ceq '[]') { + $map[$key] = @() + } else { + $map[$key] = ConvertFrom-YamlScalar -Raw $rest + } continue } diff --git a/src/functions/private/ConvertFrom-YamlSequence.ps1 b/src/functions/private/ConvertFrom-YamlSequence.ps1 index a76966b..d697e6a 100644 --- a/src/functions/private/ConvertFrom-YamlSequence.ps1 +++ b/src/functions/private/ConvertFrom-YamlSequence.ps1 @@ -64,6 +64,19 @@ continue } + # Empty collection literals (flow-style shorthand). + if ($afterDash -ceq '{}') { + $val = if ($Context.AsHashtable) { [ordered]@{} } else { [pscustomobject][ordered]@{} } + $list.Add($val) + $Context.Index++ + continue + } + if ($afterDash -ceq '[]') { + $list.Add(@()) + $Context.Index++ + continue + } + # Plain scalar element. $list.Add((ConvertFrom-YamlScalar -Raw $afterDash)) $Context.Index++ diff --git a/src/functions/private/ConvertTo-YamlMapping.ps1 b/src/functions/private/ConvertTo-YamlMapping.ps1 index 2c17463..edc99b0 100644 --- a/src/functions/private/ConvertTo-YamlMapping.ps1 +++ b/src/functions/private/ConvertTo-YamlMapping.ps1 @@ -14,7 +14,8 @@ $pairs = Get-YamlMappingPair -Value $Value if ($pairs.Count -eq 0) { - $null = $Builder.Append('{}').AppendLine() + $indent = ' ' * ($Level * $Options.Indent) + $null = $Builder.Append($indent).Append('{}').AppendLine() return } @@ -29,10 +30,10 @@ continue } - $rawVal = if ($val -is [psobject] -and $null -ne $val.PSObject -and $null -ne $val.PSObject.BaseObject) { - $val.PSObject.BaseObject + if ($val -is [psobject] -and $null -ne $val.PSObject -and $null -ne $val.PSObject.BaseObject) { + $rawVal = $val.PSObject.BaseObject } else { - $val + $rawVal = $val } if (Test-YamlMappingType -Value $rawVal) { diff --git a/src/functions/private/ConvertTo-YamlNode.ps1 b/src/functions/private/ConvertTo-YamlNode.ps1 index dfd26c6..6cddff0 100644 --- a/src/functions/private/ConvertTo-YamlNode.ps1 +++ b/src/functions/private/ConvertTo-YamlNode.ps1 @@ -24,7 +24,8 @@ if ($CurrentDepth -gt $Options.Depth) { $repr = if ($null -eq $Value) { 'null' } else { Format-YamlScalar -Value $Value.ToString() -Options $Options } - $null = $Builder.Append($repr).AppendLine() + $indent = ' ' * ($Level * $Options.Indent) + $null = $Builder.Append($indent).Append($repr).AppendLine() return } @@ -34,10 +35,10 @@ } # Unwrap PSObject for type tests. - $raw = if ($Value -is [psobject] -and $null -ne $Value.PSObject -and $null -ne $Value.PSObject.BaseObject) { - $Value.PSObject.BaseObject + if ($Value -is [psobject] -and $null -ne $Value.PSObject -and $null -ne $Value.PSObject.BaseObject) { + $raw = $Value.PSObject.BaseObject } else { - $Value + $raw = $Value } if (Test-YamlMappingType -Value $raw) { diff --git a/src/functions/private/ConvertTo-YamlSequence.ps1 b/src/functions/private/ConvertTo-YamlSequence.ps1 index 3491348..0e7a71b 100644 --- a/src/functions/private/ConvertTo-YamlSequence.ps1 +++ b/src/functions/private/ConvertTo-YamlSequence.ps1 @@ -14,17 +14,18 @@ $items = @($Value) if ($items.Count -eq 0) { - $null = $Builder.Append('[]').AppendLine() + $indent = ' ' * ($Level * $Options.Indent) + $null = $Builder.Append($indent).Append('[]').AppendLine() return } $indent = ' ' * ($Level * $Options.Indent) foreach ($item in $items) { - $rawItem = if ($item -is [psobject] -and $null -ne $item.PSObject -and $null -ne $item.PSObject.BaseObject) { - $item.PSObject.BaseObject + if ($item -is [psobject] -and $null -ne $item.PSObject -and $null -ne $item.PSObject.BaseObject) { + $rawItem = $item.PSObject.BaseObject } else { - $item + $rawItem = $item } if ($null -eq $item) { @@ -46,10 +47,10 @@ $first = $false $val = $pair.Value - $rawVal = if ($val -is [psobject] -and $null -ne $val.PSObject -and $null -ne $val.PSObject.BaseObject) { - $val.PSObject.BaseObject + if ($val -is [psobject] -and $null -ne $val.PSObject -and $null -ne $val.PSObject.BaseObject) { + $rawVal = $val.PSObject.BaseObject } else { - $val + $rawVal = $val } if ($null -eq $val) { @@ -58,14 +59,24 @@ } if (Test-YamlMappingType -Value $rawVal) { - $null = $Builder.Append($prefix).Append($keyText).Append(':').AppendLine() - ConvertTo-YamlNode -Value $val -Builder $Builder -Level ($Level + 2) -CurrentDepth ($CurrentDepth + 1) -Options $Options + $childPairs = Get-YamlMappingPair -Value $val + if ($childPairs.Count -eq 0) { + $null = $Builder.Append($prefix).Append($keyText).Append(': {}').AppendLine() + } else { + $null = $Builder.Append($prefix).Append($keyText).Append(':').AppendLine() + ConvertTo-YamlNode -Value $val -Builder $Builder -Level ($Level + 2) -CurrentDepth ($CurrentDepth + 1) -Options $Options + } continue } if (Test-YamlSequenceType -Value $rawVal) { - $null = $Builder.Append($prefix).Append($keyText).Append(':').AppendLine() - ConvertTo-YamlSequence -Value $rawVal -Builder $Builder -Level ($Level + 2) -CurrentDepth ($CurrentDepth + 1) -Options $Options + $arr = @($rawVal) + if ($arr.Count -eq 0) { + $null = $Builder.Append($prefix).Append($keyText).Append(': []').AppendLine() + } else { + $null = $Builder.Append($prefix).Append($keyText).Append(':').AppendLine() + ConvertTo-YamlSequence -Value $rawVal -Builder $Builder -Level ($Level + 2) -CurrentDepth ($CurrentDepth + 1) -Options $Options + } continue } @@ -76,8 +87,13 @@ } if (Test-YamlSequenceType -Value $rawItem) { - $null = $Builder.Append($indent).Append('-').AppendLine() - ConvertTo-YamlSequence -Value $rawItem -Builder $Builder -Level ($Level + 1) -CurrentDepth ($CurrentDepth + 1) -Options $Options + $arr = @($rawItem) + if ($arr.Count -eq 0) { + $null = $Builder.Append($indent).Append('- []').AppendLine() + } else { + $null = $Builder.Append($indent).Append('-').AppendLine() + ConvertTo-YamlSequence -Value $rawItem -Builder $Builder -Level ($Level + 1) -CurrentDepth ($CurrentDepth + 1) -Options $Options + } continue } diff --git a/src/functions/private/Get-YamlMappingPair.ps1 b/src/functions/private/Get-YamlMappingPair.ps1 index 6c263ee..430d0df 100644 --- a/src/functions/private/Get-YamlMappingPair.ps1 +++ b/src/functions/private/Get-YamlMappingPair.ps1 @@ -13,10 +13,10 @@ ) $pairs = [System.Collections.Generic.List[pscustomobject]]::new() - $raw = if ($Value -is [psobject] -and $null -ne $Value.PSObject -and $null -ne $Value.PSObject.BaseObject) { - $Value.PSObject.BaseObject + if ($Value -is [psobject] -and $null -ne $Value.PSObject -and $null -ne $Value.PSObject.BaseObject) { + $raw = $Value.PSObject.BaseObject } else { - $Value + $raw = $Value } if ($raw -is [System.Collections.IDictionary]) { diff --git a/src/functions/public/ConvertTo-Yaml.ps1 b/src/functions/public/ConvertTo-Yaml.ps1 index 5f4aa45..6d21225 100644 --- a/src/functions/public/ConvertTo-Yaml.ps1 +++ b/src/functions/public/ConvertTo-Yaml.ps1 @@ -83,7 +83,11 @@ if ($AsArray) { ConvertTo-YamlSequence -Value $items.ToArray() -Builder $sb -Level 0 -CurrentDepth 0 -Options $options } else { - $root = if ($items.Count -eq 1) { $items[0] } else { $items.ToArray() } + if ($items.Count -eq 1) { + $root = $items[0] + } else { + $root = $items.ToArray() + } ConvertTo-YamlNode -Value $root -Builder $sb -Level 0 -CurrentDepth 0 -Options $options } return $sb.ToString().TrimEnd("`r", "`n") diff --git a/tests/ConvertFrom-Yaml.Tests.ps1 b/tests/ConvertFrom-Yaml.Tests.ps1 index 42d84a7..78bc5ec 100644 --- a/tests/ConvertFrom-Yaml.Tests.ps1 +++ b/tests/ConvertFrom-Yaml.Tests.ps1 @@ -329,4 +329,39 @@ a: Should -Be 'ConvertFrom-Yaml' } } + + Context 'Empty collection literals' { + It 'Parses {} as an empty mapping (PSCustomObject by default)' { + $result = 'data: {}' | ConvertFrom-Yaml + $result.data | Should -BeOfType [PSCustomObject] + @($result.data.PSObject.Properties).Count | Should -Be 0 + } + + It 'Parses {} as an empty OrderedDictionary with -AsHashtable' { + $result = 'data: {}' | ConvertFrom-Yaml -AsHashtable + $result['data'] | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + $result['data'].Count | Should -Be 0 + } + + It 'Parses [] as an empty array' { + $result = 'items: []' | ConvertFrom-Yaml + $result.items | Should -BeNullOrEmpty + # Verify via round-trip that ConvertTo-Yaml emits [] + $yaml = [ordered]@{ items = @() } | ConvertTo-Yaml + $yaml | Should -Match '\[\]' + } + + It 'Parses sequence items {} and [] correctly' { + $yaml = @' +- {} +- [] +- hello +'@ + $result = $yaml | ConvertFrom-Yaml -NoEnumerate -AsHashtable + $result[0] | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + $result[0].Count | Should -Be 0 + $result[1].Count | Should -Be 0 + $result[2] | Should -Be 'hello' + } + } } diff --git a/tests/ConvertTo-Yaml.Tests.ps1 b/tests/ConvertTo-Yaml.Tests.ps1 index 1272384..abaf88e 100644 --- a/tests/ConvertTo-Yaml.Tests.ps1 +++ b/tests/ConvertTo-Yaml.Tests.ps1 @@ -178,6 +178,69 @@ Describe 'ConvertTo-Yaml' { $yaml = $obj | ConvertTo-Yaml -Depth 5 $yaml | Should -Match 'b: value' } + + It 'Indents depth-exceeded values correctly under a mapping key' { + $obj = [ordered]@{ + a = [ordered]@{ + b = [ordered]@{ + c = 'value' + } + } + } + $yaml = $obj | ConvertTo-Yaml -Depth 1 -WarningAction SilentlyContinue + $lines = $yaml.TrimEnd("`r", "`n") -split "`r?`n" + # The depth-exceeded line should be indented under 'b:' + $bLine = $lines | Where-Object { $_ -match '^\s+b:' } + $bLine | Should -Not -BeNullOrEmpty + $depthLine = $lines[($lines.IndexOf($bLine) + 1)] + $depthLine | Should -Match '^\s{4}' + } + } + + Context 'Empty collections' { + It 'Renders an empty hashtable as {}' { + $yaml = [ordered]@{} | ConvertTo-Yaml + $yaml.Trim() | Should -Be '{}' + } + + It 'Renders an empty array as []' { + $yaml = ConvertTo-Yaml -InputObject @() + $yaml.Trim() | Should -Be '[]' + } + + It 'Renders a nested empty mapping inline' { + $obj = [ordered]@{ key = [ordered]@{} } + $yaml = $obj | ConvertTo-Yaml + $yaml.Trim() | Should -Be 'key: {}' + } + + It 'Renders a nested empty array inline' { + $obj = [ordered]@{ key = @() } + $yaml = $obj | ConvertTo-Yaml + $yaml.Trim() | Should -Be 'key: []' + } + + It 'Renders an empty mapping value in a sequence-of-mappings inline' { + $obj = @( + [ordered]@{ name = 'Alice'; data = [ordered]@{} } + ) + $yaml = ConvertTo-Yaml -InputObject $obj + $yaml | Should -Match 'data: \{\}' + } + + It 'Renders an empty array value in a sequence-of-mappings inline' { + $obj = @( + [ordered]@{ name = 'Alice'; items = @() } + ) + $yaml = ConvertTo-Yaml -InputObject $obj + $yaml | Should -Match 'items: \[\]' + } + + It 'Renders an empty sequence item in a sequence as - []' { + $obj = @( @(), @('a') ) + $yaml = ConvertTo-Yaml -InputObject $obj + $yaml | Should -Match '(?m)^- \[\]' + } } Context 'Aliases' { @@ -244,4 +307,17 @@ Describe 'Round-trip ConvertTo-Yaml | ConvertFrom-Yaml' { $result['c'] | Should -Be 'null' $result['c'] | Should -BeOfType [string] } + + It 'Preserves an empty mapping under a key' { + $obj = [ordered]@{ data = [ordered]@{} } + $result = $obj | ConvertTo-Yaml | ConvertFrom-Yaml -AsHashtable + $result['data'] | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] + $result['data'].Count | Should -Be 0 + } + + It 'Preserves an empty array under a key' { + $obj = [ordered]@{ items = @() } + $result = $obj | ConvertTo-Yaml | ConvertFrom-Yaml -AsHashtable + $result['items'].Count | Should -Be 0 + } } From da93f3580fd4780147d1558a1ac1629e58cf5d68 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 3 May 2026 09:27:20 +0200 Subject: [PATCH 14/21] Add tests for handling various YAML data types and structures in ConvertFrom-Yaml and ConvertTo-Yaml --- tests/ConvertFrom-Yaml.Tests.ps1 | 220 +++++++++++++++++++++++++++-- tests/ConvertTo-Yaml.Tests.ps1 | 234 +++++++++++++++++++++++++++++++ 2 files changed, 444 insertions(+), 10 deletions(-) diff --git a/tests/ConvertFrom-Yaml.Tests.ps1 b/tests/ConvertFrom-Yaml.Tests.ps1 index 78bc5ec..2c1d0c2 100644 --- a/tests/ConvertFrom-Yaml.Tests.ps1 +++ b/tests/ConvertFrom-Yaml.Tests.ps1 @@ -91,6 +91,74 @@ f: OFF $result = 'value: "line1\nline2\ttab"' | ConvertFrom-Yaml $result.value | Should -Be "line1`nline2`ttab" } + + It 'Handles all supported double-quoted escapes (\r, \\, \", \0)' { + $result = 'cr: "a\rb"' | ConvertFrom-Yaml + $result.cr | Should -Be "a`rb" + + $result = 'bs: "a\\b"' | ConvertFrom-Yaml + $result.bs | Should -Be 'a\b' + + $result = 'dq: "she said \"hi\""' | ConvertFrom-Yaml + $result.dq | Should -Be 'she said "hi"' + + $result = 'nul: "a\0b"' | ConvertFrom-Yaml + $result.nul | Should -Be "a$([char]0)b" + } + + It 'Preserves single-quoted escape (double apostrophe)' { + $result = "value: 'it''s a test'" | ConvertFrom-Yaml + $result.value | Should -Be "it's a test" + } + + It 'Parses a negative integer' { + $result = 'value: -7' | ConvertFrom-Yaml + $result.value | Should -Be -7 + $result.value | Should -BeOfType [int] + } + + It 'Parses a large integer as [long]' { + $result = 'value: 3000000000' | ConvertFrom-Yaml + $result.value | Should -Be 3000000000 + $result.value | Should -BeOfType [long] + } + + It 'Parses a negative floating-point value' { + $result = 'value: -3.14' | ConvertFrom-Yaml + $result.value | Should -Be -3.14 + $result.value | Should -BeOfType [double] + } + + It 'Parses scientific notation as a double' { + $result = 'value: 6.022e23' | ConvertFrom-Yaml + $result.value | Should -Be 6.022e23 + $result.value | Should -BeOfType [double] + } + + It 'Parses zero as an integer' { + $result = 'value: 0' | ConvertFrom-Yaml + $result.value | Should -Be 0 + $result.value | Should -BeOfType [int] + } + + It 'Parses leading-zero integer as a number (YAML 1.2.2 core schema)' { + $result = 'value: 01' | ConvertFrom-Yaml + $result.value | Should -Be 1 + $result.value | Should -BeOfType [int] + } + + It 'Parses leading-plus integer as a number (YAML 1.2.2 core schema)' { + $result = 'value: +42' | ConvertFrom-Yaml + $result.value | Should -Be 42 + $result.value | Should -BeOfType [int] + } + + It 'Returns empty/whitespace-only input as null' { + $result = '' | ConvertFrom-Yaml + $result | Should -BeNullOrEmpty + $result = ' ' | ConvertFrom-Yaml + $result | Should -BeNullOrEmpty + } } Context 'Mappings' { @@ -171,6 +239,44 @@ GetType: world $result.PSObject.Properties['ToString'].Value | Should -Be 'hello' $result.PSObject.Properties['GetType'].Value | Should -Be 'world' } + + It 'Parses a mapping with null values' { + $yaml = @' +present: value +empty: +tilde: ~ +explicit: null +'@ + $result = $yaml | ConvertFrom-Yaml + $result.present | Should -Be 'value' + $result.empty | Should -BeNullOrEmpty + $result.tilde | Should -BeNullOrEmpty + $result.explicit | Should -BeNullOrEmpty + } + + It 'Parses a mapping with mixed value types' { + $yaml = @' +str: hello +int: 10 +float: 2.5 +bool: true +nothing: null +list: + - a + - b +child: + key: val +'@ + $result = $yaml | ConvertFrom-Yaml + $result.str | Should -Be 'hello' + $result.str | Should -BeOfType [string] + $result.int | Should -Be 10 + $result.float | Should -Be 2.5 + $result.bool | Should -BeTrue + $result.nothing | Should -BeNullOrEmpty + $result.list.Count | Should -Be 2 + $result.child.key | Should -Be 'val' + } } Context 'Sequences' { @@ -211,6 +317,55 @@ people: $result.people[0].name | Should -Be 'Alice' $result.people[1].age | Should -Be 25 } + + It 'Parses nested sequences (sequence of sequences)' { + $yaml = @' +matrix: + - + - 1 + - 2 + - + - 3 + - 4 +'@ + $result = $yaml | ConvertFrom-Yaml + $result.matrix.Count | Should -Be 2 + $result.matrix[0].Count | Should -Be 2 + $result.matrix[0][0] | Should -Be 1 + $result.matrix[1][1] | Should -Be 4 + } + + It 'Parses a sequence with null items' { + $yaml = @' +- alpha +- +- bravo +'@ + $result = $yaml | ConvertFrom-Yaml -NoEnumerate + $result.Count | Should -Be 3 + $result[0] | Should -Be 'alpha' + $result[1] | Should -BeNullOrEmpty + $result[2] | Should -Be 'bravo' + } + + It 'Parses a sequence with mixed scalar types' { + $yaml = @' +- hello +- 42 +- true +- 3.14 +- null +'@ + $result = $yaml | ConvertFrom-Yaml -NoEnumerate + $result[0] | Should -Be 'hello' + $result[0] | Should -BeOfType [string] + $result[1] | Should -Be 42 + $result[1] | Should -BeOfType [int] + $result[2] | Should -BeTrue + $result[3] | Should -Be 3.14 + $result[3] | Should -BeOfType [double] + $result[4] | Should -BeNullOrEmpty + } } Context '-AsHashtable' { @@ -246,6 +401,35 @@ outer: , $result | Should -BeOfType [System.Object[]] $result.Count | Should -Be 1 } + + It 'Without -NoEnumerate, unwraps a single-element top-level sequence' { + $yaml = '- only' + $result = $yaml | ConvertFrom-Yaml + $result | Should -Be 'only' + $result | Should -BeOfType [string] + } + } + + Context 'Pipeline input' { + It 'Accepts multi-line pipeline strings (simulating Get-Content)' { + $lines = @('name: Alice', 'age: 30') + $result = $lines | ConvertFrom-Yaml + $result.name | Should -Be 'Alice' + $result.age | Should -Be 30 + } + } + + Context 'Deeply nested structures' { + It 'Parses 4 levels of nesting' { + $yaml = @' +a: + b: + c: + d: deep +'@ + $result = $yaml | ConvertFrom-Yaml + $result.a.b.c.d | Should -Be 'deep' + } } Context 'Document markers' { @@ -264,6 +448,16 @@ age: 30 $yaml = @' name: Alice ... +'@ + $result = $yaml | ConvertFrom-Yaml + $result.name | Should -Be 'Alice' + } + + It 'Tolerates both --- and ... markers together' { + $yaml = @' +--- +name: Alice +... '@ $result = $yaml | ConvertFrom-Yaml $result.name | Should -Be 'Alice' @@ -288,13 +482,23 @@ age: 30 $result.name | Should -Be 'Alice' } - It 'Ignores blank lines' { - $yaml = @' -name: Alice + It 'Preserves # inside double-quoted strings' { + $result = 'value: "has # inside"' | ConvertFrom-Yaml + $result.value | Should -Be 'has # inside' + } -age: 30 + It 'Preserves # inside single-quoted strings' { + $result = "value: 'has # inside'" | ConvertFrom-Yaml + $result.value | Should -Be 'has # inside' + } -'@ + It 'Does not treat # without leading space as an inline comment' { + $result = 'channel: news#general' | ConvertFrom-Yaml + $result.channel | Should -Be 'news#general' + } + + It 'Ignores blank lines' { + $yaml = "name: Alice`n`nage: 30`n" $result = $yaml | ConvertFrom-Yaml $result.name | Should -Be 'Alice' $result.age | Should -Be 30 @@ -352,11 +556,7 @@ a: } It 'Parses sequence items {} and [] correctly' { - $yaml = @' -- {} -- [] -- hello -'@ + $yaml = "- {}`n- []`n- hello" $result = $yaml | ConvertFrom-Yaml -NoEnumerate -AsHashtable $result[0] | Should -BeOfType [System.Collections.Specialized.OrderedDictionary] $result[0].Count | Should -Be 0 diff --git a/tests/ConvertTo-Yaml.Tests.ps1 b/tests/ConvertTo-Yaml.Tests.ps1 index abaf88e..952b0ef 100644 --- a/tests/ConvertTo-Yaml.Tests.ps1 +++ b/tests/ConvertTo-Yaml.Tests.ps1 @@ -57,6 +57,62 @@ Describe 'ConvertTo-Yaml' { $yaml = @{ value = "line1`nline2" } | ConvertTo-Yaml $yaml | Should -Match '"line1\\nline2"' } + + It 'Renders strings with \r using double-quoted escapes' { + $yaml = @{ value = "a`rb" } | ConvertTo-Yaml + $yaml | Should -Match '"a\\rb"' + } + + It 'Renders strings with backslash as plain text (no control characters)' { + $yaml = @{ value = 'path\to\file' } | ConvertTo-Yaml + $yaml.Trim() | Should -Be 'value: path\to\file' + } + + It 'Renders an empty string with quotes' { + $yaml = @{ value = '' } | ConvertTo-Yaml + $yaml.Trim() | Should -Match "value:\s+''" + } + + It 'Renders a negative integer without quotes' { + $yaml = @{ value = -7 } | ConvertTo-Yaml + $yaml.Trim() | Should -Be 'value: -7' + } + + It 'Renders a [long] integer without quotes' { + $yaml = @{ value = [long]3000000000 } | ConvertTo-Yaml + $yaml.Trim() | Should -Be 'value: 3000000000' + } + + It 'Renders a negative double without quotes' { + $yaml = @{ value = -3.14 } | ConvertTo-Yaml + $yaml.Trim() | Should -Be 'value: -3.14' + } + + It 'Renders zero without quotes' { + $yaml = @{ value = 0 } | ConvertTo-Yaml + $yaml.Trim() | Should -Be 'value: 0' + } + + It 'Quotes strings that look like tilde to preserve type' { + $yaml = @{ value = '~' } | ConvertTo-Yaml + $yaml.Trim() | Should -Match "value:\s+(""~""|'~')" + } + + It 'Renders a DateTime as an ISO 8601 string' { + $dt = [datetime]::new(2026, 5, 3, 12, 0, 0, [System.DateTimeKind]::Utc) + $yaml = @{ value = $dt } | ConvertTo-Yaml + $yaml | Should -Match '2026-05-03T12:00:00' + } + + It 'Quotes strings containing a colon to prevent ambiguity' { + $yaml = @{ value = 'http://example.com' } | ConvertTo-Yaml + $yaml | Should -Match "(""http://example\.com""|'http://example\.com')" + } + + It 'Quotes strings starting with special YAML characters' { + $yaml = @{ value = '- not a list' } | ConvertTo-Yaml + $yaml | Should -Match "(""- not a list""|'- not a list')" + } } Context 'Mappings' { @@ -89,6 +145,34 @@ Describe 'ConvertTo-Yaml' { $yaml | Should -Match 'name: Alice' $yaml | Should -Match 'age: 30' } + + It 'Renders a mapping with null values' { + $yaml = ([ordered]@{ a = $null; b = 'ok' }) | ConvertTo-Yaml + $yaml | Should -Match '(?m)^a: null' + $yaml | Should -Match '(?m)^b: ok' + } + + It 'Renders a mapping with mixed value types' { + $obj = [ordered]@{ + str = 'hello' + int = 10 + float = 2.5 + bool = $true + nothing = $null + list = @('a', 'b') + child = [ordered]@{ key = 'val' } + } + $yaml = $obj | ConvertTo-Yaml + $yaml | Should -Match '(?m)^str: hello' + $yaml | Should -Match '(?m)^int: 10' + $yaml | Should -Match '(?m)^float: 2\.5' + $yaml | Should -Match '(?m)^bool: true' + $yaml | Should -Match '(?m)^nothing: null' + $yaml | Should -Match '(?m)^list:' + $yaml | Should -Match '(?m)^ - a' + $yaml | Should -Match '(?m)^child:' + $yaml | Should -Match '(?m)^ key: val' + } } Context 'Sequences' { @@ -120,6 +204,35 @@ Describe 'ConvertTo-Yaml' { $yaml | Should -Match '(?m)^ age: 30' $yaml | Should -Match '(?m)^ - name: Bob' } + + It 'Renders nested sequences (array of arrays)' { + $obj = [ordered]@{ + matrix = @( + , @(1, 2) + , @(3, 4) + ) + } + $yaml = $obj | ConvertTo-Yaml + $yaml | Should -Match '(?m)^matrix:' + $yaml | Should -Match '(?m)^ -' + $yaml | Should -Match '(?m)^ - 1' + } + + It 'Renders a sequence with null items' { + $yaml = ConvertTo-Yaml -InputObject @('alpha', $null, 'bravo') + $yaml | Should -Match '(?m)^- alpha' + $yaml | Should -Match '(?m)^- null' + $yaml | Should -Match '(?m)^- bravo' + } + + It 'Renders a sequence with mixed scalar types' { + $yaml = ConvertTo-Yaml -InputObject @('hello', 42, $true, 3.14, $null) + $yaml | Should -Match '(?m)^- hello' + $yaml | Should -Match '(?m)^- 42' + $yaml | Should -Match '(?m)^- true' + $yaml | Should -Match '(?m)^- 3\.14' + $yaml | Should -Match '(?m)^- null' + } } Context '-AsArray' { @@ -128,6 +241,24 @@ Describe 'ConvertTo-Yaml' { $yaml = ConvertTo-Yaml -InputObject $obj -AsArray $yaml | Should -Match '(?m)^- name: Alice' } + + It 'Wraps multiple pipeline objects in a top-level sequence' { + $yaml = @( + [ordered]@{ name = 'Alice' } + [ordered]@{ name = 'Bob' } + ) | ConvertTo-Yaml -AsArray + $yaml | Should -Match '(?m)^- name: Alice' + $yaml | Should -Match '(?m)^- name: Bob' + } + } + + Context 'Pipeline input' { + It 'Collects multiple pipeline objects into a sequence' { + $yaml = 'Alice', 'Bob', 'Charlie' | ConvertTo-Yaml + $yaml | Should -Match '(?m)^- Alice' + $yaml | Should -Match '(?m)^- Bob' + $yaml | Should -Match '(?m)^- Charlie' + } } Context '-Indent' { @@ -320,4 +451,107 @@ Describe 'Round-trip ConvertTo-Yaml | ConvertFrom-Yaml' { $result = $obj | ConvertTo-Yaml | ConvertFrom-Yaml -AsHashtable $result['items'].Count | Should -Be 0 } + + It 'Preserves numeric types (int, long, double, negative)' { + $obj = [ordered]@{ + int = 42 + long = [long]3000000000 + double = 3.14 + neg = -7 + zero = 0 + } + $result = $obj | ConvertTo-Yaml | ConvertFrom-Yaml -AsHashtable + $result['int'] | Should -Be 42 + $result['int'] | Should -BeOfType [int] + $result['long'] | Should -Be 3000000000 + $result['long'] | Should -BeOfType [long] + $result['double'] | Should -Be 3.14 + $result['double'] | Should -BeOfType [double] + $result['neg'] | Should -Be -7 + $result['zero'] | Should -Be 0 + } + + It 'Round-trips booleans and null through unquoted YAML literals' { + $obj = [ordered]@{ yes = $true; no = $false; nothing = $null } + $yaml = $obj | ConvertTo-Yaml + + # Intermediate YAML must use unquoted canonical literals + $yaml | Should -Match '(?m)^yes: true\r?$' + $yaml | Should -Match '(?m)^no: false\r?$' + $yaml | Should -Match '(?m)^nothing: null\r?$' + + # Round-trip back to PowerShell must restore native types + $result = $yaml | ConvertFrom-Yaml -AsHashtable + $result['yes'] | Should -BeTrue + $result['yes'] | Should -BeOfType [bool] + $result['no'] | Should -BeFalse + $result['no'] | Should -BeOfType [bool] + $result['nothing'] | Should -BeNullOrEmpty + } + + It 'Preserves strings with special characters' { + $obj = [ordered]@{ + newline = "line1`nline2" + tab = "col1`tcol2" + backslash = 'a\b' + } + $result = $obj | ConvertTo-Yaml | ConvertFrom-Yaml -AsHashtable + $result['newline'] | Should -Be "line1`nline2" + $result['tab'] | Should -Be "col1`tcol2" + $result['backslash'] | Should -Be 'a\b' + } + + It 'Preserves tilde as a string when quoted' { + $obj = [ordered]@{ value = '~' } + $result = $obj | ConvertTo-Yaml | ConvertFrom-Yaml -AsHashtable + $result['value'] | Should -Be '~' + $result['value'] | Should -BeOfType [string] + } + + It 'Preserves a deeply nested structure' { + $obj = [ordered]@{ + a = [ordered]@{ + b = [ordered]@{ + c = [ordered]@{ + d = 'deep' + } + } + } + } + $result = $obj | ConvertTo-Yaml | ConvertFrom-Yaml -AsHashtable + $result['a']['b']['c']['d'] | Should -Be 'deep' + } + + It 'Preserves a mixed structure (mappings, sequences, nested)' { + $obj = [ordered]@{ + name = 'project' + version = 1 + tags = @('alpha', 'beta') + config = [ordered]@{ + debug = $true + timeout = 30 + } + servers = @( + [ordered]@{ host = 'a.example.com'; port = 80 } + [ordered]@{ host = 'b.example.com'; port = 443 } + ) + } + $result = $obj | ConvertTo-Yaml | ConvertFrom-Yaml -AsHashtable + $result['name'] | Should -Be 'project' + $result['version'] | Should -Be 1 + $result['tags'].Count | Should -Be 2 + $result['tags'][0] | Should -Be 'alpha' + $result['config']['debug'] | Should -BeTrue + $result['config']['timeout'] | Should -Be 30 + $result['servers'].Count | Should -Be 2 + $result['servers'][0]['host'] | Should -Be 'a.example.com' + $result['servers'][1]['port'] | Should -Be 443 + } + + It 'Preserves an empty string' { + $obj = [ordered]@{ value = '' } + $result = $obj | ConvertTo-Yaml | ConvertFrom-Yaml -AsHashtable + $result['value'] | Should -Be '' + $result['value'] | Should -BeOfType [string] + } } From 03cad1f256dff6e4b373965fa688ce719b71545e Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 3 May 2026 10:47:58 +0200 Subject: [PATCH 15/21] Enforce -Depth on nested sequences by routing through ConvertTo-YamlNode; fix enum serialization for unsigned underlying types; add tests for depth truncation of nested arrays and sequence-under-mapping, and unsigned enum serialization --- .../private/ConvertTo-YamlMapping.ps1 | 2 +- .../private/ConvertTo-YamlSequence.ps1 | 4 +-- src/functions/private/Format-YamlScalar.ps1 | 4 ++- tests/ConvertTo-Yaml.Tests.ps1 | 30 +++++++++++++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/functions/private/ConvertTo-YamlMapping.ps1 b/src/functions/private/ConvertTo-YamlMapping.ps1 index edc99b0..b6ce272 100644 --- a/src/functions/private/ConvertTo-YamlMapping.ps1 +++ b/src/functions/private/ConvertTo-YamlMapping.ps1 @@ -53,7 +53,7 @@ $null = $Builder.Append(' []').AppendLine() } else { $null = $Builder.AppendLine() - ConvertTo-YamlSequence -Value $rawVal -Builder $Builder -Level ($Level + 1) -CurrentDepth ($CurrentDepth + 1) -Options $Options + ConvertTo-YamlNode -Value $val -Builder $Builder -Level ($Level + 1) -CurrentDepth ($CurrentDepth + 1) -Options $Options } continue } diff --git a/src/functions/private/ConvertTo-YamlSequence.ps1 b/src/functions/private/ConvertTo-YamlSequence.ps1 index 0e7a71b..fe62712 100644 --- a/src/functions/private/ConvertTo-YamlSequence.ps1 +++ b/src/functions/private/ConvertTo-YamlSequence.ps1 @@ -75,7 +75,7 @@ $null = $Builder.Append($prefix).Append($keyText).Append(': []').AppendLine() } else { $null = $Builder.Append($prefix).Append($keyText).Append(':').AppendLine() - ConvertTo-YamlSequence -Value $rawVal -Builder $Builder -Level ($Level + 2) -CurrentDepth ($CurrentDepth + 1) -Options $Options + ConvertTo-YamlNode -Value $val -Builder $Builder -Level ($Level + 2) -CurrentDepth ($CurrentDepth + 1) -Options $Options } continue } @@ -92,7 +92,7 @@ $null = $Builder.Append($indent).Append('- []').AppendLine() } else { $null = $Builder.Append($indent).Append('-').AppendLine() - ConvertTo-YamlSequence -Value $rawItem -Builder $Builder -Level ($Level + 1) -CurrentDepth ($CurrentDepth + 1) -Options $Options + ConvertTo-YamlNode -Value $item -Builder $Builder -Level ($Level + 1) -CurrentDepth ($CurrentDepth + 1) -Options $Options } continue } diff --git a/src/functions/private/Format-YamlScalar.ps1 b/src/functions/private/Format-YamlScalar.ps1 index c21f0c8..656a92d 100644 --- a/src/functions/private/Format-YamlScalar.ps1 +++ b/src/functions/private/Format-YamlScalar.ps1 @@ -18,7 +18,9 @@ if ($Options.EnumsAsStrings) { return Format-YamlString -Text ($Value.ToString()) } - return ([int64] $Value).ToString([cultureinfo]::InvariantCulture) + $underlyingType = [System.Enum]::GetUnderlyingType($Value.GetType()) + $numeric = [System.Convert]::ChangeType($Value, $underlyingType) + return ([System.IConvertible]$numeric).ToString([cultureinfo]::InvariantCulture) } if ($Value -is [byte] -or $Value -is [sbyte] -or diff --git a/tests/ConvertTo-Yaml.Tests.ps1 b/tests/ConvertTo-Yaml.Tests.ps1 index 952b0ef..2467e9f 100644 --- a/tests/ConvertTo-Yaml.Tests.ps1 +++ b/tests/ConvertTo-Yaml.Tests.ps1 @@ -286,6 +286,16 @@ Describe 'ConvertTo-Yaml' { $yaml = $obj | ConvertTo-Yaml $yaml.Trim() | Should -Be 'day: 1' } + + It 'Serializes enums with unsigned underlying type correctly' { + # System.Security.AccessControl.FileSystemRights has UInt32 underlying type + # and values that can exceed Int32.MaxValue + $val = [System.Security.AccessControl.FileSystemRights]::FullControl + $obj = [ordered]@{ rights = $val } + $yaml = $obj | ConvertTo-Yaml + $expected = [int]$val + $yaml.Trim() | Should -Be "rights: $expected" + } } Context '-Depth' { @@ -326,6 +336,26 @@ Describe 'ConvertTo-Yaml' { $depthLine = $lines[($lines.IndexOf($bLine) + 1)] $depthLine | Should -Match '^\s{4}' } + + It 'Truncates deeply nested sequences beyond -Depth' { + $inner = ,('innermost') + $middle = ,$inner + $outer = ,$middle + $yaml = ConvertTo-Yaml -InputObject $outer -Depth 1 -WarningAction SilentlyContinue + $yaml | Should -Not -BeNullOrEmpty + $yaml | Should -Not -Match 'innermost' + } + + It 'Truncates sequences under mapping keys beyond -Depth' { + $obj = [ordered]@{ + a = [ordered]@{ + b = @(1, 2, 3) + } + } + $yaml = $obj | ConvertTo-Yaml -Depth 1 -WarningAction SilentlyContinue + $yaml | Should -Not -BeNullOrEmpty + $yaml | Should -Not -Match '- 1' + } } Context 'Empty collections' { From 7030c976a2e2ed4576bcb4094abcf323b100171f Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Mon, 4 May 2026 09:33:48 +0200 Subject: [PATCH 16/21] Fix PSUseConsistentWhitespace: add spaces after unary comma operators in nested-sequence depth test --- tests/ConvertTo-Yaml.Tests.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/ConvertTo-Yaml.Tests.ps1 b/tests/ConvertTo-Yaml.Tests.ps1 index 2467e9f..def7f07 100644 --- a/tests/ConvertTo-Yaml.Tests.ps1 +++ b/tests/ConvertTo-Yaml.Tests.ps1 @@ -338,9 +338,9 @@ Describe 'ConvertTo-Yaml' { } It 'Truncates deeply nested sequences beyond -Depth' { - $inner = ,('innermost') - $middle = ,$inner - $outer = ,$middle + $inner = , ('innermost') + $middle = , $inner + $outer = , $middle $yaml = ConvertTo-Yaml -InputObject $outer -Depth 1 -WarningAction SilentlyContinue $yaml | Should -Not -BeNullOrEmpty $yaml | Should -Not -Match 'innermost' From 29bba1d97e50c61c543ee12b47861321793503f0 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Mon, 4 May 2026 09:54:40 +0200 Subject: [PATCH 17/21] Fix NaN/Infinity as plain strings, sequence extra-dash-space child indent, test array IndexOf Thread r3179984727 (ConvertFrom-YamlSequence): inline mapping child indent was hardcoded to Indent+2, breaking continuation lines when extra spaces follow the dash (e.g. '- a: 1'). Fixed by computing extraSpaces from leading spaces in afterDash and setting childIndent = Indent + 2 + extraSpaces and stripping those spaces from the synthetic Content. Added test 'Parses sequence inline mappings with extra spaces after the dash'. Thread r3179984765 (ConvertFrom-YamlScalar): [double]::TryParse with NumberStyles.Float accepts NaN, Infinity, -Infinity etc., which are .NET-specific and not part of the YAML 1.2.2 core schema (the spec uses .inf/.nan dot-prefix forms). Added a pre-guard regex -imatch '^[+-]?(infinity|nan)$' to return these as plain strings before TryParse. Added test 'Treats .NET-specific special float tokens as strings (YAML 1.2.2)'. Thread r3179984792 (ConvertTo-Yaml.Tests.ps1): string arrays from -split have no instance IndexOf() method; the call would throw at runtime. Replaced \.IndexOf(\) with [array]::IndexOf(\, \). --- .../private/ConvertFrom-YamlScalar.ps1 | 4 ++ .../private/ConvertFrom-YamlSequence.ps1 | 14 +++++-- tests/ConvertFrom-Yaml.Tests.ps1 | 41 +++++++++++++++++++ tests/ConvertTo-Yaml.Tests.ps1 | 2 +- 4 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/functions/private/ConvertFrom-YamlScalar.ps1 b/src/functions/private/ConvertFrom-YamlScalar.ps1 index 68c61a7..b0631b1 100644 --- a/src/functions/private/ConvertFrom-YamlScalar.ps1 +++ b/src/functions/private/ConvertFrom-YamlScalar.ps1 @@ -43,6 +43,10 @@ } # Float. + # Reject .NET-specific special float tokens that are not part of the YAML 1.2.2 core schema. + # Core schema uses .inf/.Inf/.INF/.nan/.NaN/.NAN (dot-prefix form). The bare NaN/Infinity + # words are accepted by [double]::TryParse but must remain plain strings per the spec. + if ($value -imatch '^[+-]?(infinity|nan)$') { return $value } $dblVal = 0.0 if ([double]::TryParse($value, [System.Globalization.NumberStyles]::Float, [cultureinfo]::InvariantCulture, [ref] $dblVal)) { return $dblVal diff --git a/src/functions/private/ConvertFrom-YamlSequence.ps1 b/src/functions/private/ConvertFrom-YamlSequence.ps1 index d697e6a..615cf20 100644 --- a/src/functions/private/ConvertFrom-YamlSequence.ps1 +++ b/src/functions/private/ConvertFrom-YamlSequence.ps1 @@ -49,12 +49,18 @@ $colonIdx = Find-YamlMappingColon -Content $afterDash if ($colonIdx -ge 0) { # Treat this as a single-line entry into a mapping. Build a synthetic line stream: - # the current "key: value" line gets re-interpreted at indent (Indent + 2), and any - # continuation lines at indent > (Indent + 2) belong to the same mapping. - $childIndent = $Indent + 2 + # the current "key: value" line gets re-interpreted at the column where the key + # actually starts (Indent + 2 + any extra spaces after "- "), so that continuation + # lines aligned to the key column (e.g. "- key: v\n b: 2") are accepted. + $extraSpaces = 0 + while ($extraSpaces -lt $afterDash.Length -and $afterDash[$extraSpaces] -eq ' ') { + $extraSpaces++ + } + $childIndent = $Indent + 2 + $extraSpaces + $childContent = $afterDash.Substring($extraSpaces) $synthetic = [pscustomobject]@{ Indent = $childIndent - Content = $afterDash + Content = $childContent Number = $line.Number } # Replace current line with synthetic and recurse as a mapping. diff --git a/tests/ConvertFrom-Yaml.Tests.ps1 b/tests/ConvertFrom-Yaml.Tests.ps1 index 2c1d0c2..54b85f3 100644 --- a/tests/ConvertFrom-Yaml.Tests.ps1 +++ b/tests/ConvertFrom-Yaml.Tests.ps1 @@ -75,6 +75,31 @@ f: OFF $result.b | Should -Be 'NULL' } + It 'Treats .NET-specific special float tokens as strings (YAML 1.2.2)' { + # YAML 1.2.2 core schema uses .inf/.nan (dot-prefix). Bare NaN/Infinity are plain strings. + $yaml = @" +a: NaN +b: Infinity +c: -Infinity +d: +Infinity +e: nan +f: infinity +"@ + $result = $yaml | ConvertFrom-Yaml + $result.a | Should -Be 'NaN' + $result.a | Should -BeOfType [string] + $result.b | Should -Be 'Infinity' + $result.b | Should -BeOfType [string] + $result.c | Should -Be '-Infinity' + $result.c | Should -BeOfType [string] + $result.d | Should -Be '+Infinity' + $result.d | Should -BeOfType [string] + $result.e | Should -Be 'nan' + $result.e | Should -BeOfType [string] + $result.f | Should -Be 'infinity' + $result.f | Should -BeOfType [string] + } + It 'Parses single-quoted strings preserving content' { $result = "value: 'true'" | ConvertFrom-Yaml $result.value | Should -Be 'true' @@ -366,6 +391,22 @@ matrix: $result[3] | Should -BeOfType [double] $result[4] | Should -BeNullOrEmpty } + + It 'Parses sequence inline mappings with extra spaces after the dash' { + # Valid YAML: the key may be indented further than "- " suggests. + $yaml = @' +- a: 1 + b: 2 +- x: alpha + y: beta +'@ + $result = $yaml | ConvertFrom-Yaml -NoEnumerate + $result.Count | Should -Be 2 + $result[0].a | Should -Be 1 + $result[0].b | Should -Be 2 + $result[1].x | Should -Be 'alpha' + $result[1].y | Should -Be 'beta' + } } Context '-AsHashtable' { diff --git a/tests/ConvertTo-Yaml.Tests.ps1 b/tests/ConvertTo-Yaml.Tests.ps1 index def7f07..4ed4d45 100644 --- a/tests/ConvertTo-Yaml.Tests.ps1 +++ b/tests/ConvertTo-Yaml.Tests.ps1 @@ -333,7 +333,7 @@ Describe 'ConvertTo-Yaml' { # The depth-exceeded line should be indented under 'b:' $bLine = $lines | Where-Object { $_ -match '^\s+b:' } $bLine | Should -Not -BeNullOrEmpty - $depthLine = $lines[($lines.IndexOf($bLine) + 1)] + $depthLine = $lines[([array]::IndexOf($lines, $bLine) + 1)] $depthLine | Should -Match '^\s{4}' } From ba4fee0592a9671a9983ad2831204ba4e82b5828 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Mon, 4 May 2026 10:19:42 +0200 Subject: [PATCH 18/21] Support indentless sequences, add unconsumed-lines check, add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConvertFrom-YamlMapping: support indentless sequences (e.g. key:\n- a\n- b) where the sequence starts at the same indent as the parent mapping key. Removes misleading comment that claimed strict indentation was required. - ConvertFrom-Yaml: add post-parse check that all preprocessed lines were consumed. Throws a terminating error with the first unconsumed line number and content when trailing content is detected. - Tests: 4 new tests — indentless sequences (3 variants), unconsumed content. All 129 tests pass. --- .../private/ConvertFrom-YamlMapping.ps1 | 14 ++++- src/functions/public/ConvertFrom-Yaml.ps1 | 5 ++ tests/ConvertFrom-Yaml.Tests.ps1 | 59 +++++++++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/functions/private/ConvertFrom-YamlMapping.ps1 b/src/functions/private/ConvertFrom-YamlMapping.ps1 index 53b422a..a1f8c5f 100644 --- a/src/functions/private/ConvertFrom-YamlMapping.ps1 +++ b/src/functions/private/ConvertFrom-YamlMapping.ps1 @@ -60,13 +60,21 @@ } $next = $lines[$Context.Index] - if ($next.Indent -le $Indent) { + if ($next.Indent -lt $Indent) { + $map[$key] = $null + continue + } + if ($next.Indent -eq $Indent) { + # Indentless sequences: YAML allows a sequence to start at the same indent as the + # parent mapping key (e.g. "key:\n- a\n- b"). Parse these as the key's value. + if ($next.Content.StartsWith('- ') -or $next.Content -eq '-') { + $map[$key] = ConvertFrom-YamlSequence -Context $Context -Indent $Indent -Depth ($Depth + 1) + continue + } $map[$key] = $null continue } - # Sequences are allowed to start at the same indent as the parent key in YAML. - # We require the child to be indented strictly greater than the key here for clarity. $childIndent = $next.Indent $value = ConvertFrom-YamlNode -Context $Context -Indent $childIndent -Depth ($Depth + 1) $map[$key] = $value diff --git a/src/functions/public/ConvertFrom-Yaml.ps1 b/src/functions/public/ConvertFrom-Yaml.ps1 index e3824ca..812a180 100644 --- a/src/functions/public/ConvertFrom-Yaml.ps1 +++ b/src/functions/public/ConvertFrom-Yaml.ps1 @@ -106,6 +106,11 @@ $result = ConvertFrom-YamlNode -Context $context -Indent 0 -Depth 0 + if ($context.Index -lt $lines.Count) { + $leftover = $lines[$context.Index] + throw "ConvertFrom-Yaml: unexpected content at line $($leftover.Number): '$($leftover.Content)'. The document has trailing content that was not consumed by the parser." + } + if ($NoEnumerate -and $result -is [System.Collections.IList]) { return , $result } diff --git a/tests/ConvertFrom-Yaml.Tests.ps1 b/tests/ConvertFrom-Yaml.Tests.ps1 index 54b85f3..c217d35 100644 --- a/tests/ConvertFrom-Yaml.Tests.ps1 +++ b/tests/ConvertFrom-Yaml.Tests.ps1 @@ -392,6 +392,53 @@ matrix: $result[4] | Should -BeNullOrEmpty } + It 'Parses indentless sequences as mapping values' { + # YAML 1.2.2 allows sequences to start at the same indent as the parent mapping key. + $yaml = @' +items: +- apple +- banana +other: val +'@ + $result = $yaml | ConvertFrom-Yaml + $result.items.Count | Should -Be 2 + $result.items[0] | Should -Be 'apple' + $result.items[1] | Should -Be 'banana' + $result.other | Should -Be 'val' + } + + It 'Parses multiple indentless sequences in the same mapping' { + $yaml = @' +list1: +- a +- b +list2: +- c +- d +'@ + $result = $yaml | ConvertFrom-Yaml + $result.list1.Count | Should -Be 2 + $result.list1[0] | Should -Be 'a' + $result.list1[1] | Should -Be 'b' + $result.list2.Count | Should -Be 2 + $result.list2[0] | Should -Be 'c' + $result.list2[1] | Should -Be 'd' + } + + It 'Parses indentless sequences of mappings' { + $yaml = @' +people: +- name: Alice + age: 30 +- name: Bob + age: 25 +'@ + $result = $yaml | ConvertFrom-Yaml + $result.people.Count | Should -Be 2 + $result.people[0].name | Should -Be 'Alice' + $result.people[1].age | Should -Be 25 + } + It 'Parses sequence inline mappings with extra spaces after the dash' { # Valid YAML: the key may be indented further than "- " suggests. $yaml = @' @@ -605,4 +652,16 @@ a: $result[2] | Should -Be 'hello' } } + + Context 'Error handling' { + It 'Throws on trailing unconsumed content' { + # A mapping followed by a sequence at the same level is invalid at the root — + # the parser should not silently discard the sequence. + $yaml = @' +key: value +- orphan +'@ + { $yaml | ConvertFrom-Yaml } | Should -Throw '*unexpected content*' + } + } } From 73aa66fd23d8266d60dfe34ae9733f8d899f20b3 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Mon, 4 May 2026 11:40:42 +0200 Subject: [PATCH 19/21] Add \xHH hex escape parsing, fix PSAvoidLongLines, update help text and README escape lists --- README.md | 2 +- src/functions/private/Expand-YamlDoubleQuoted.ps1 | 12 ++++++++++++ src/functions/public/ConvertFrom-Yaml.ps1 | 6 ++++-- tests/ConvertTo-Yaml.Tests.ps1 | 11 +++++++++++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ad6a927..aa67518 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Practical implications of the core schema: - Integers and floats parse to their native types using invariant culture. - Anything else is a string. Quoted strings (`'...'`, `"..."`) always preserve the string type. -The supported YAML subset covers block-style mappings, block-style sequences, nested structures, single- and double-quoted scalars (with `\n`, `\t`, `\r`, `\\`, `\"` escapes in double quotes), document start (`---`) and end (`...`) markers, and full-line / inline `#` comments. Flow style (`[a, b]`, `{a: 1}`), block scalars (`|`, `>`), anchors, aliases, tags, multi-document streams, and `!!timestamp` are not yet supported. +The supported YAML subset covers block-style mappings, block-style sequences, nested structures, single- and double-quoted scalars (with `\n`, `\t`, `\r`, `\0`, `\\`, `\"`, `\xHH` escapes in double quotes), document start (`---`) and end (`...`) markers, and full-line / inline `#` comments. Flow style (`[a, b]`, `{a: 1}`), block scalars (`|`, `>`), anchors, aliases, tags, multi-document streams, and `!!timestamp` are not yet supported. ### Example 1: Parse a YAML string diff --git a/src/functions/private/Expand-YamlDoubleQuoted.ps1 b/src/functions/private/Expand-YamlDoubleQuoted.ps1 index 099faca..b1d9589 100644 --- a/src/functions/private/Expand-YamlDoubleQuoted.ps1 +++ b/src/functions/private/Expand-YamlDoubleQuoted.ps1 @@ -25,6 +25,18 @@ '"' { $null = $sb.Append('"') } '\' { $null = $sb.Append('\') } '0' { $null = $sb.Append([char]0) } + 'x' { + if ($i + 3 -lt $Text.Length) { + $hex = $Text.Substring($i + 2, 2) + $code = 0 + if ([int]::TryParse($hex, [System.Globalization.NumberStyles]::HexNumber, $null, [ref]$code)) { + $null = $sb.Append([char]$code) + $i += 4 + continue + } + } + $expanded = $false + } default { $expanded = $false } } diff --git a/src/functions/public/ConvertFrom-Yaml.ps1 b/src/functions/public/ConvertFrom-Yaml.ps1 index 812a180..4ccff5f 100644 --- a/src/functions/public/ConvertFrom-Yaml.ps1 +++ b/src/functions/public/ConvertFrom-Yaml.ps1 @@ -12,7 +12,7 @@ - Block-style sequences (- item) - Nested structures - Scalars: strings, integers, floats, booleans (`true`/`false`), null (`null`/`~`/empty) - - Single- and double-quoted strings (with `\n`, `\t`, `\r`, `\\`, `\"` in double quotes) + - Single- and double-quoted strings (with `\n`, `\t`, `\r`, `\0`, `\\`, `\"`, `\xHH` in double quotes) - Document start (`---`) and end (`...`) markers are tolerated - Full-line comments (`#`) and inline comments after values @@ -108,7 +108,9 @@ if ($context.Index -lt $lines.Count) { $leftover = $lines[$context.Index] - throw "ConvertFrom-Yaml: unexpected content at line $($leftover.Number): '$($leftover.Content)'. The document has trailing content that was not consumed by the parser." + $msg = 'ConvertFrom-Yaml: unexpected content at line {0}: ''{1}''.' + $msg += ' The document has trailing content that was not consumed.' + throw ($msg -f $leftover.Number, $leftover.Content) } if ($NoEnumerate -and $result -is [System.Collections.IList]) { diff --git a/tests/ConvertTo-Yaml.Tests.ps1 b/tests/ConvertTo-Yaml.Tests.ps1 index 4ed4d45..b2da2fd 100644 --- a/tests/ConvertTo-Yaml.Tests.ps1 +++ b/tests/ConvertTo-Yaml.Tests.ps1 @@ -584,4 +584,15 @@ Describe 'Round-trip ConvertTo-Yaml | ConvertFrom-Yaml' { $result['value'] | Should -Be '' $result['value'] | Should -BeOfType [string] } + + It 'Round-trips control characters via \xHH escapes' { + # Format-YamlDoubleQuoted emits \xHH for control chars (e.g. NUL, BEL). + # Expand-YamlDoubleQuoted must parse them back correctly. + $nul = [string][char]0 + $bel = [string][char]7 + $obj = [ordered]@{ nul = $nul; bel = $bel } + $result = $obj | ConvertTo-Yaml | ConvertFrom-Yaml -AsHashtable + $result['nul'] | Should -Be $nul + $result['bel'] | Should -Be $bel + } } From f3e279eee795330ac1741f5525701bdd90a8348c Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Mon, 4 May 2026 13:18:54 +0200 Subject: [PATCH 20/21] Fix null indentation in ConvertTo-YamlNode, reject tabs in indentation, strengthen empty-array and unsigned-enum test assertions --- src/functions/private/ConvertFrom-YamlLineStream.ps1 | 7 +++++-- src/functions/private/ConvertTo-YamlNode.ps1 | 3 ++- tests/ConvertFrom-Yaml.Tests.ps1 | 10 ++++++++-- tests/ConvertTo-Yaml.Tests.ps1 | 4 +++- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/functions/private/ConvertFrom-YamlLineStream.ps1 b/src/functions/private/ConvertFrom-YamlLineStream.ps1 index 92b2868..99bf8eb 100644 --- a/src/functions/private/ConvertFrom-YamlLineStream.ps1 +++ b/src/functions/private/ConvertFrom-YamlLineStream.ps1 @@ -8,7 +8,7 @@ - Lines that are empty or whitespace-only are skipped. - Lines whose first non-whitespace character is `#` are skipped. - Inline comments (` #...` outside quotes) are stripped from the content. - - Tabs in indentation are not allowed (YAML spec); they are treated as one space here. + - Tabs in indentation are not allowed (YAML spec); a terminating error is thrown if one is found. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Comma-unary operator preserves List type; PSScriptAnalyzer misdetects as Object[].')] @@ -31,9 +31,12 @@ continue } - # Compute indent (spaces before first non-space). + # Compute indent (spaces before first non-space). Tabs are invalid per YAML spec. $indent = 0 while ($indent -lt $raw.Length -and ($raw[$indent] -eq ' ' -or $raw[$indent] -eq "`t")) { + if ($raw[$indent] -eq "`t") { + throw "YAML forbids tab characters in indentation (line $($i + 1)). Use spaces instead." + } $indent++ } diff --git a/src/functions/private/ConvertTo-YamlNode.ps1 b/src/functions/private/ConvertTo-YamlNode.ps1 index 6cddff0..7065c82 100644 --- a/src/functions/private/ConvertTo-YamlNode.ps1 +++ b/src/functions/private/ConvertTo-YamlNode.ps1 @@ -30,7 +30,8 @@ } if ($null -eq $Value) { - $null = $Builder.Append('null').AppendLine() + $indent = ' ' * ($Level * $Options.Indent) + $null = $Builder.Append($indent).Append('null').AppendLine() return } diff --git a/tests/ConvertFrom-Yaml.Tests.ps1 b/tests/ConvertFrom-Yaml.Tests.ps1 index c217d35..8433034 100644 --- a/tests/ConvertFrom-Yaml.Tests.ps1 +++ b/tests/ConvertFrom-Yaml.Tests.ps1 @@ -636,8 +636,9 @@ a: } It 'Parses [] as an empty array' { - $result = 'items: []' | ConvertFrom-Yaml - $result.items | Should -BeNullOrEmpty + $result = 'items: []' | ConvertFrom-Yaml -AsHashtable + $result['items'].GetType().Name | Should -Be 'Object[]' + $result['items'].Count | Should -Be 0 # Verify via round-trip that ConvertTo-Yaml emits [] $yaml = [ordered]@{ items = @() } | ConvertTo-Yaml $yaml | Should -Match '\[\]' @@ -663,5 +664,10 @@ key: value '@ { $yaml | ConvertFrom-Yaml } | Should -Throw '*unexpected content*' } + + It 'Throws on tab characters in indentation' { + $yaml = "key:`n`tvalue: 1" + { $yaml | ConvertFrom-Yaml } | Should -Throw '*tab*' + } } } diff --git a/tests/ConvertTo-Yaml.Tests.ps1 b/tests/ConvertTo-Yaml.Tests.ps1 index b2da2fd..09ce66e 100644 --- a/tests/ConvertTo-Yaml.Tests.ps1 +++ b/tests/ConvertTo-Yaml.Tests.ps1 @@ -293,7 +293,9 @@ Describe 'ConvertTo-Yaml' { $val = [System.Security.AccessControl.FileSystemRights]::FullControl $obj = [ordered]@{ rights = $val } $yaml = $obj | ConvertTo-Yaml - $expected = [int]$val + $underlyingType = [System.Enum]::GetUnderlyingType($val.GetType()) + $numeric = [System.Convert]::ChangeType($val, $underlyingType) + $expected = ([System.IConvertible]$numeric).ToString([cultureinfo]::InvariantCulture) $yaml.Trim() | Should -Be "rights: $expected" } } From 9c0aafbb5c2e4209299bde849fd83973b9608d42 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Mon, 4 May 2026 15:01:28 +0200 Subject: [PATCH 21/21] Fix quote tracking in Find-YamlMappingColon and Remove-YamlInlineComment for plain scalars Quote characters inside plain (unquoted) scalars were incorrectly toggling quote state, causing colon detection and inline comment stripping to fail for values like owner's: Bob or name: O'Connor # comment. Added inPlain flag to both functions so quote characters only open quoted regions at token boundaries (position 0, after ': ', after '- '), not mid-token in plain scalars. Added 5 tests covering apostrophe/double-quote in plain keys, values, and sequence items with inline comments. --- .../private/Find-YamlMappingColon.ps1 | 21 ++++++++++-- .../private/Remove-YamlInlineComment.ps1 | 25 +++++++++++++-- tests/ConvertFrom-Yaml.Tests.ps1 | 32 +++++++++++++++++++ 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/functions/private/Find-YamlMappingColon.ps1 b/src/functions/private/Find-YamlMappingColon.ps1 index 3a371e1..1a879f4 100644 --- a/src/functions/private/Find-YamlMappingColon.ps1 +++ b/src/functions/private/Find-YamlMappingColon.ps1 @@ -5,7 +5,8 @@ .DESCRIPTION The colon must be followed by whitespace or end-of-line for it to be a YAML mapping - separator. Colons inside quoted strings are ignored. + separator. Colons inside quoted strings are ignored. Quote characters inside plain + (unquoted) scalars are treated as literal characters and do not toggle quote state. #> [CmdletBinding()] [OutputType([int])] @@ -17,16 +18,30 @@ $inSingle = $false $inDouble = $false + $inPlain = $false for ($i = 0; $i -lt $Content.Length; $i++) { $c = $Content[$i] if ($c -eq '\' -and $inDouble) { $i++; continue } - if ($c -eq "'" -and -not $inDouble) { $inSingle = -not $inSingle; continue } - if ($c -eq '"' -and -not $inSingle) { $inDouble = -not $inDouble; continue } + if ($c -eq "'" -and -not $inDouble) { + if ($inSingle) { $inSingle = $false; continue } + if (-not $inPlain) { $inSingle = $true; continue } + continue + } + if ($c -eq '"' -and -not $inSingle) { + if ($inDouble) { $inDouble = $false; continue } + if (-not $inPlain) { $inDouble = $true; continue } + continue + } if ($c -eq ':' -and -not $inSingle -and -not $inDouble) { if ($i -eq $Content.Length - 1) { return $i } $next = $Content[$i + 1] if ($next -eq ' ' -or $next -eq "`t") { return $i } } + if (-not $inSingle -and -not $inDouble -and -not $inPlain) { + if ($c -ne ' ' -and $c -ne "`t") { + $inPlain = $true + } + } } return -1 } diff --git a/src/functions/private/Remove-YamlInlineComment.ps1 b/src/functions/private/Remove-YamlInlineComment.ps1 index bbd3537..c736739 100644 --- a/src/functions/private/Remove-YamlInlineComment.ps1 +++ b/src/functions/private/Remove-YamlInlineComment.ps1 @@ -15,6 +15,7 @@ $inSingle = $false $inDouble = $false + $inPlain = $false for ($i = 0; $i -lt $Line.Length; $i++) { $c = $Line[$i] if ($c -eq '\' -and $inDouble) { @@ -23,11 +24,13 @@ continue } if ($c -eq "'" -and -not $inDouble) { - $inSingle = -not $inSingle + if ($inSingle) { $inSingle = $false; continue } + if (-not $inPlain) { $inSingle = $true; continue } continue } if ($c -eq '"' -and -not $inSingle) { - $inDouble = -not $inDouble + if ($inDouble) { $inDouble = $false; continue } + if (-not $inPlain) { $inDouble = $true; continue } continue } if ($c -eq '#' -and -not $inSingle -and -not $inDouble) { @@ -36,6 +39,24 @@ return $Line.Substring(0, $i) } } + # Track plain scalar vs token boundary. + if (-not $inSingle -and -not $inDouble -and -not $inPlain) { + if ($c -ne ' ' -and $c -ne "`t") { + # A '- ' sequence dash is a token boundary, not a plain scalar start. + if ($c -eq '-' -and $i + 1 -lt $Line.Length -and $Line[$i + 1] -eq ' ') { + # sequence dash — value after '- ' may be quoted; do not enter plain + } else { + $inPlain = $true + } + } + } + # Reset $inPlain after mapping separator ': ' to allow value-position quotes. + if ($c -eq ':' -and -not $inSingle -and -not $inDouble -and $i + 1 -lt $Line.Length) { + $next = $Line[$i + 1] + if ($next -eq ' ' -or $next -eq "`t") { + $inPlain = $false + } + } } return $Line } diff --git a/tests/ConvertFrom-Yaml.Tests.ps1 b/tests/ConvertFrom-Yaml.Tests.ps1 index 8433034..e40b5a6 100644 --- a/tests/ConvertFrom-Yaml.Tests.ps1 +++ b/tests/ConvertFrom-Yaml.Tests.ps1 @@ -670,4 +670,36 @@ key: value { $yaml | ConvertFrom-Yaml } | Should -Throw '*tab*' } } + + Context 'Plain scalars with embedded quotes' { + It 'Parses a plain key containing an apostrophe before the colon' { + $result = "owner's: Bob" | ConvertFrom-Yaml + $result."owner's" | Should -Be 'Bob' + } + + It 'Parses a plain value containing an apostrophe with inline comment stripped' { + $result = "name: O'Connor # a comment" | ConvertFrom-Yaml + $result.name | Should -Be "O'Connor" + } + + It 'Parses a plain key with embedded double-quote before the colon' { + $result = 'say"hello: world' | ConvertFrom-Yaml + $result.'say"hello' | Should -Be 'world' + } + + It 'Strips inline comment when value contains a mid-token double-quote' { + $result = 'key: val"ue # comment' | ConvertFrom-Yaml + $result.key | Should -Be 'val"ue' + } + + It 'Parses sequence items with apostrophe in plain scalar and inline comment' { + $yaml = @' +- it's fine # comment +- also's good +'@ + $result = $yaml | ConvertFrom-Yaml -NoEnumerate + $result[0] | Should -Be "it's fine" + $result[1] | Should -Be "also's good" + } + } }