diff --git a/README.md b/README.md index 96936ee..aa67518 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,18 @@ -# {{ 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 This uses the following external resources: + - The [PSModule framework](https://github.com/PSModule) for building, testing and publishing the module. ## Installation @@ -12,29 +20,68 @@ 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 -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`: + +| 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 — use `Get-Content -Raw` or similar to read the file first, then pipe the string into `ConvertFrom-Yaml`. + +### 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: -### Example 1: Greet an entity +- `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. -Provide examples for typical commands that a user would like to do with the module. +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 + +```powershell +$yaml = @' +name: Alice +age: 30 +skills: + - PowerShell + - YAML +'@ + +$yaml | ConvertFrom-Yaml +``` + +### Example 2: Parse YAML as an ordered hashtable ```powershell -Greet-Entity -Name 'World' -Hello, World! +Get-Content config.yaml -Raw | ConvertFrom-Yaml -AsHashtable ``` -### Example 2 +### Example 3: Convert an object to YAML + +```powershell +[ordered]@{ + name = 'Alice' + skills = @('PowerShell', 'YAML') +} | ConvertTo-Yaml +``` -Provide examples for typical commands that a user would like to do with the module. +### Example 4: 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..cffd00e 100644 --- a/examples/General.ps1 +++ b/examples/General.ps1 @@ -1,19 +1,37 @@ <# - .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. Convert an object to YAML +[ordered]@{ + name = 'Alice' + age = 30 + skills = @('PowerShell', 'YAML') +} | ConvertTo-Yaml + +# 4. Force a top-level sequence with -AsArray +Get-Process | Select-Object -First 3 Name, Id | ConvertTo-Yaml -AsArray + +# 5. Round-trip +$obj = [ordered]@{ a = 1; b = @('x', 'y') } +$obj | ConvertTo-Yaml | ConvertFrom-Yaml -AsHashtable diff --git a/src/functions/private/ConvertFrom-YamlLineStream.ps1 b/src/functions/private/ConvertFrom-YamlLineStream.ps1 new file mode 100644 index 0000000..99bf8eb --- /dev/null +++ b/src/functions/private/ConvertFrom-YamlLineStream.ps1 @@ -0,0 +1,69 @@ +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); a terminating error is thrown if one is found. + #> + [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)] + [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). 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++ + } + + $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 + } + + # 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() + Number = $i + 1 + }) + } + + return , $result +} diff --git a/src/functions/private/ConvertFrom-YamlMapping.ps1 b/src/functions/private/ConvertFrom-YamlMapping.ps1 new file mode 100644 index 0000000..a1f8c5f --- /dev/null +++ b/src/functions/private/ConvertFrom-YamlMapping.ps1 @@ -0,0 +1,88 @@ +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)'." + } + + $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) { + 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 + } + + # Value on subsequent indented lines (mapping or sequence) or null. + if ($Context.Index -ge $lines.Count) { + $map[$key] = $null + continue + } + + $next = $lines[$Context.Index] + 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 + } + + $childIndent = $next.Indent + $value = ConvertFrom-YamlNode -Context $Context -Indent $childIndent -Depth ($Depth + 1) + $map[$key] = $value + } + + if ($Context.AsHashtable) { + return $map + } + + return [pscustomobject]$map +} diff --git a/src/functions/private/ConvertFrom-YamlNode.ps1 b/src/functions/private/ConvertFrom-YamlNode.ps1 new file mode 100644 index 0000000..e21d3fb --- /dev/null +++ b/src/functions/private/ConvertFrom-YamlNode.ps1 @@ -0,0 +1,40 @@ +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 +} diff --git a/src/functions/private/ConvertFrom-YamlScalar.ps1 b/src/functions/private/ConvertFrom-YamlScalar.ps1 new file mode 100644 index 0000000..b0631b1 --- /dev/null +++ b/src/functions/private/ConvertFrom-YamlScalar.ps1 @@ -0,0 +1,57 @@ +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. + # 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 + } + + # 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..615cf20 --- /dev/null +++ b/src/functions/private/ConvertFrom-YamlSequence.ps1 @@ -0,0 +1,92 @@ +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 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 = $childContent + 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 + } + + # 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++ + } + + 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..b6ce272 --- /dev/null +++ b/src/functions/private/ConvertTo-YamlMapping.ps1 @@ -0,0 +1,64 @@ +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) { + $indent = ' ' * ($Level * $Options.Indent) + $null = $Builder.Append($indent).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 + } + + if ($val -is [psobject] -and $null -ne $val.PSObject -and $null -ne $val.PSObject.BaseObject) { + $rawVal = $val.PSObject.BaseObject + } else { + $rawVal = $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-YamlNode -Value $val -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 new file mode 100644 index 0000000..7065c82 --- /dev/null +++ b/src/functions/private/ConvertTo-YamlNode.ps1 @@ -0,0 +1,58 @@ +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 } + $indent = ' ' * ($Level * $Options.Indent) + $null = $Builder.Append($indent).Append($repr).AppendLine() + return + } + + if ($null -eq $Value) { + $indent = ' ' * ($Level * $Options.Indent) + $null = $Builder.Append($indent).Append('null').AppendLine() + return + } + + # Unwrap PSObject for type tests. + if ($Value -is [psobject] -and $null -ne $Value.PSObject -and $null -ne $Value.PSObject.BaseObject) { + $raw = $Value.PSObject.BaseObject + } else { + $raw = $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() +} diff --git a/src/functions/private/ConvertTo-YamlSequence.ps1 b/src/functions/private/ConvertTo-YamlSequence.ps1 new file mode 100644 index 0000000..fe62712 --- /dev/null +++ b/src/functions/private/ConvertTo-YamlSequence.ps1 @@ -0,0 +1,103 @@ +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) { + $indent = ' ' * ($Level * $Options.Indent) + $null = $Builder.Append($indent).Append('[]').AppendLine() + return + } + + $indent = ' ' * ($Level * $Options.Indent) + + foreach ($item in $items) { + if ($item -is [psobject] -and $null -ne $item.PSObject -and $null -ne $item.PSObject.BaseObject) { + $rawItem = $item.PSObject.BaseObject + } else { + $rawItem = $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 + if ($val -is [psobject] -and $null -ne $val.PSObject -and $null -ne $val.PSObject.BaseObject) { + $rawVal = $val.PSObject.BaseObject + } else { + $rawVal = $val + } + + if ($null -eq $val) { + $null = $Builder.Append($prefix).Append($keyText).Append(': null').AppendLine() + continue + } + + if (Test-YamlMappingType -Value $rawVal) { + $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) { + $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-YamlNode -Value $val -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) { + $arr = @($rawItem) + if ($arr.Count -eq 0) { + $null = $Builder.Append($indent).Append('- []').AppendLine() + } else { + $null = $Builder.Append($indent).Append('-').AppendLine() + ConvertTo-YamlNode -Value $item -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..b1d9589 --- /dev/null +++ b/src/functions/private/Expand-YamlDoubleQuoted.ps1 @@ -0,0 +1,52 @@ +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) } + '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 } + } + + 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..1a879f4 --- /dev/null +++ b/src/functions/private/Find-YamlMappingColon.ps1 @@ -0,0 +1,47 @@ +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. Quote characters inside plain + (unquoted) scalars are treated as literal characters and do not toggle quote state. + #> + [CmdletBinding()] + [OutputType([int])] + param( + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Content + ) + + $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) { + 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/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..656a92d --- /dev/null +++ b/src/functions/private/Format-YamlScalar.ps1 @@ -0,0 +1,42 @@ +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()) + } + $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 + $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..430d0df --- /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() + if ($Value -is [psobject] -and $null -ne $Value.PSObject -and $null -ne $Value.PSObject.BaseObject) { + $raw = $Value.PSObject.BaseObject + } else { + $raw = $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..c736739 --- /dev/null +++ b/src/functions/private/Remove-YamlInlineComment.ps1 @@ -0,0 +1,62 @@ +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 + $inPlain = $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) { + 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) { + # 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) + } + } + # 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/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 +} diff --git a/src/functions/public/ConvertFrom-Yaml.ps1 b/src/functions/public/ConvertFrom-Yaml.ps1 new file mode 100644 index 0000000..4ccff5f --- /dev/null +++ b/src/functions/public/ConvertFrom-Yaml.ps1 @@ -0,0 +1,122 @@ +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.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`, `\0`, `\\`, `\"`, `\xHH` 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. + + 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. + + .OUTPUTS + System.Management.Automation.PSCustomObject + + .OUTPUTS + 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( + [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 + } + + # Pre-process into logical lines (drop comments, blank lines, and document markers). + $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 ($context.Index -lt $lines.Count) { + $leftover = $lines[$context.Index] + $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]) { + return , $result + } + + return $result + } +} diff --git a/src/functions/public/ConvertTo-Yaml.ps1 b/src/functions/public/ConvertTo-Yaml.ps1 new file mode 100644 index 0000000..6d21225 --- /dev/null +++ b/src/functions/public/ConvertTo-Yaml.ps1 @@ -0,0 +1,95 @@ +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 { + 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/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..e40b5a6 --- /dev/null +++ b/tests/ConvertFrom-Yaml.Tests.ps1 @@ -0,0 +1,705 @@ +[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' { + $result = 'a: true' | ConvertFrom-Yaml + $result.a | Should -BeTrue + $result.a | Should -BeOfType [bool] + } + + It 'Parses boolean false' { + $result = 'a: false' | ConvertFrom-Yaml + $result.a | 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. + $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' + $result.c | Should -Be 'yes' + $result.d | Should -Be 'No' + $result.e | Should -Be 'on' + $result.f | Should -Be 'OFF' + } + + It 'Parses null values' { + $result = "a: null`nb: ~`nc:" | ConvertFrom-Yaml + $result.a | Should -BeNullOrEmpty + $result.b | Should -BeNullOrEmpty + $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 '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' + $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" + } + + 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' { + 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' + } + + 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' + } + + 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' { + 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 + } + + 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 + } + + 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 = @' +- 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' { + 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 + } + + 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' { + It 'Tolerates a leading --- document-start marker' { + $yaml = @' +--- +name: Alice +age: 30 +'@ + $result = $yaml | ConvertFrom-Yaml + $result.name | Should -Be 'Alice' + $result.age | Should -Be 30 + } + + It 'Tolerates a trailing ... document-end marker' { + $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' + } + } + + 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 'Preserves # inside double-quoted strings' { + $result = 'value: "has # inside"' | ConvertFrom-Yaml + $result.value | Should -Be 'has # inside' + } + + 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 + } + } + + 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' + } + } + + 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 -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 '\[\]' + } + + It 'Parses sequence items {} and [] correctly' { + $yaml = "- {}`n- []`n- 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' + } + } + + 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*' + } + + It 'Throws on tab characters in indentation' { + $yaml = "key:`n`tvalue: 1" + { $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" + } + } +} diff --git a/tests/ConvertTo-Yaml.Tests.ps1 b/tests/ConvertTo-Yaml.Tests.ps1 new file mode 100644 index 0000000..09ce66e --- /dev/null +++ b/tests/ConvertTo-Yaml.Tests.ps1 @@ -0,0 +1,600 @@ +[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"' + } + + 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' { + 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' + } + + 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' { + 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' + } + + 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' { + 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' + } + + 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' { + 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' + } + + 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 + $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" + } + } + + 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' + } + + 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[([array]::IndexOf($lines, $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' { + 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' { + 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] + } + + 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 + } + + 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] + } + + 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 + } +} 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!' - } -}