diff --git a/README.md b/README.md index de6c865..24d5ab0 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,18 @@ $config.name # example $config.ports[0] # 80 ``` +Mapping and sequence documents render themselves as YAML through `ToString()`, so a +parsed value can be shown in its source notation: + +```powershell +$config.ToString() +# "name": "example" +# "enabled": true +# "ports": +# - 80 +# - 443 +``` + Use `-AsHashtable` for insertion-ordered dictionaries and mappings with complex, non-string, empty, or case-colliding keys, and `-NoEnumerate` to keep a top-level sequence as one pipeline record. Every document in a multi-document stream is @@ -220,6 +232,8 @@ console: Get-Help -Name ConvertFrom-Yaml -Examples ``` +Normative specifications for planned capabilities live in [spec](spec/README.md). + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/src/functions/private/Conversion/Add-YamlToStringMember.ps1 b/src/functions/private/Conversion/Add-YamlToStringMember.ps1 new file mode 100644 index 0000000..c7a6a00 --- /dev/null +++ b/src/functions/private/Conversion/Add-YamlToStringMember.ps1 @@ -0,0 +1,54 @@ +function Add-YamlToStringMember { + <# + .SYNOPSIS + Tags a projected YAML document root so ToString renders YAML text. + + .DESCRIPTION + Adds the PSModule.Yaml.Document type name and a ToString script method to a + projected mapping or sequence so callers can render the value back to YAML + text with ToString. Scalars are left untouched because overriding ToString on + a string, number, or date would change how ordinary values convert to text. + + Rendering is deferred until ToString runs, so decorating a document costs one + member addition and never serializes eagerly. The script method reports the + value's current state, which means edits made after parsing are reflected. + + .EXAMPLE + Add-YamlToStringMember -Value ([pscustomobject]@{ name = 'Ada' }) + + Tags the mapping so ToString returns "name": "Ada" instead of an empty string. + + .LINK + https://psmodule.io/Yaml/Functions/Conversion/ConvertFrom-Yaml/ + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseShouldProcessForStateChangingFunctions', '', + Justification = 'Decorates an in-memory projection result.' + )] + [CmdletBinding()] + [OutputType([void])] + param ( + # The projected document value to decorate; scalars and null are ignored. + [Parameter(Mandatory)] + [AllowNull()] + [object] $Value + ) + + if (-not (Test-YamlDocumentSurface -Value $Value)) { + return + } + + try { + $Value.PSObject.TypeNames.Insert(0, 'PSModule.Yaml.Document') + Add-Member -InputObject $Value -MemberType ScriptMethod -Name 'ToString' -Force ` + -ErrorAction Stop -Value { + try { + (ConvertTo-Yaml -InputObject $this).TrimEnd("`n") + } catch { + $this.PSObject.BaseObject.GetType().FullName + } + } + } catch { + Write-Debug "Add-YamlToStringMember skipped a document root: $($_.Exception.Message)" + } +} diff --git a/src/functions/private/Conversion/Test-YamlDocumentSurface.ps1 b/src/functions/private/Conversion/Test-YamlDocumentSurface.ps1 new file mode 100644 index 0000000..f500850 --- /dev/null +++ b/src/functions/private/Conversion/Test-YamlDocumentSurface.ps1 @@ -0,0 +1,48 @@ +function Test-YamlDocumentSurface { + <# + .SYNOPSIS + Tests whether a projected value can carry a YAML ToString member. + + .DESCRIPTION + Reports whether a projected value is a mapping or a sequence, which are the + shapes that gain a readable YAML rendering from ToString. Scalars, null, and + binary values are rejected so the module never changes how an ordinary string, + number, date, or byte array converts to text. + + .EXAMPLE + Test-YamlDocumentSurface -Value ([pscustomobject]@{ name = 'Ada' }) + + Returns true because a mapping renders as a YAML block. + + .EXAMPLE + Test-YamlDocumentSurface -Value 42 + + Returns false because a scalar keeps its own ToString. + + .LINK + https://psmodule.io/Yaml/Functions/Conversion/ConvertFrom-Yaml/ + #> + [CmdletBinding()] + [OutputType([bool])] + param ( + # The projected value whose YAML rendering eligibility is tested. + [Parameter(Mandatory)] + [AllowNull()] + [object] $Value + ) + + if ($null -eq $Value -or $Value -is [System.DBNull]) { + return $false + } + if ($Value -is [System.Management.Automation.PSCustomObject]) { + return $true + } + if ($Value -is [System.Collections.IDictionary]) { + return $true + } + if ($Value -is [byte[]] -or $Value -is [string] -or $Value -is [char]) { + return $false + } + + $Value -is [System.Collections.IEnumerable] +} diff --git a/src/functions/public/Conversion/Conversion.md b/src/functions/public/Conversion/Conversion.md index 987d5a3..5cf8f43 100644 --- a/src/functions/public/Conversion/Conversion.md +++ b/src/functions/public/Conversion/Conversion.md @@ -196,6 +196,39 @@ One object is emitted per document, in document order. An empty document emits `-NoEnumerate` applies per document, so a stream of two sequence documents writes two records instead of one per item. +### Rendering a parsed value back to YAML + +Parsed mappings and sequences carry the type name `PSModule.Yaml.Document` and a +`ToString()` that renders the value as YAML text, so a value can be inspected in +its source notation without calling `ConvertTo-Yaml` explicitly: + +```powershell +$config = @' +name: example +ports: [80, 443] +'@ | ConvertFrom-Yaml + +$config.ToString() +# "name": "example" +# "ports": +# - 80 +# - 443 + +"$config" # same text through string interpolation +$config.name # example - property access is unchanged +``` + +`ToString()` reports the value's current state, so edits made after parsing appear +in the rendered text. Rendering is equivalent to `ConvertTo-Yaml`, which means it +describes the constructed value rather than the original source text: comments, +anchors, and the original scalar styles are not part of the output. Use +`Format-Yaml` when those representation details must survive. + +Only mappings and sequences are decorated. Scalar documents keep their own +`ToString()`, so a parsed number, string, or date still converts to text the way +that type normally does. Nested values are not decorated either, because only the +document root is a document. + ### Anchors and aliases An alias to a collection projects to the **same object instance**, in both diff --git a/src/functions/public/Conversion/ConvertFrom-Yaml.ps1 b/src/functions/public/Conversion/ConvertFrom-Yaml.ps1 index 39c3472..59b55ff 100644 --- a/src/functions/public/Conversion/ConvertFrom-Yaml.ps1 +++ b/src/functions/public/Conversion/ConvertFrom-Yaml.ps1 @@ -12,11 +12,20 @@ function ConvertFrom-Yaml { Pipeline strings are joined with a line feed and parsed as one stream, which supports Get-Content. Each YAML document is written separately. + Mapping and sequence documents carry the PSModule.Yaml.Document type name and + a ToString that renders the value as YAML text, so a parsed value can be shown + in its source notation. Scalar documents keep their own ToString. + .EXAMPLE 'name: Ada' | ConvertFrom-Yaml Converts one mapping to a PSCustomObject. + .EXAMPLE + ('name: Ada' | ConvertFrom-Yaml).ToString() + + Renders the parsed mapping back to YAML text. + .EXAMPLE Get-Content -Path '.\config.yaml' | ConvertFrom-Yaml -AsHashtable @@ -117,9 +126,11 @@ function ConvertFrom-Yaml { if ($isTopLevelSequence -and -not $NoEnumerate) { foreach ($item in $value) { + Add-YamlToStringMember -Value $item $PSCmdlet.WriteObject($item, $false) } } else { + Add-YamlToStringMember -Value $value $PSCmdlet.WriteObject($value, $false) } } diff --git a/tests/ConvertFrom-Yaml.Tests.ps1 b/tests/ConvertFrom-Yaml.Tests.ps1 index 24d2101..982ed09 100644 --- a/tests/ConvertFrom-Yaml.Tests.ps1 +++ b/tests/ConvertFrom-Yaml.Tests.ps1 @@ -690,3 +690,108 @@ negativeZero: -0.0 { ConvertFrom-Yaml -Yaml ("value: x{0}" -f [char] 0xD800) } | Should -Throw } } + +Describe 'ConvertFrom-Yaml YAML document rendering' { + It 'renders a mapping back to YAML text with ToString' { + $result = ConvertFrom-Yaml -Yaml "name: Ada`nage: 36" + + $result.ToString() | Should -Be "`"name`": `"Ada`"`n`"age`": 36" + } + + It 'renders the same text through string interpolation' { + $result = ConvertFrom-Yaml -Yaml 'name: Ada' + + "$result" | Should -Be $result.ToString() + } + + It 'round-trips the rendered text back to an equal value' { + $result = ConvertFrom-Yaml -Yaml "name: Ada`nports:`n - 80`n - 443" + $roundTrip = ConvertFrom-Yaml -Yaml $result.ToString() + + $roundTrip.name | Should -Be 'Ada' + $roundTrip.ports | Should -Be @(80, 443) + $roundTrip.ToString() | Should -Be $result.ToString() + } + + It 'tags rendered document roots with the document type name' { + $result = ConvertFrom-Yaml -Yaml 'name: Ada' + + $result.PSObject.TypeNames | Should -Contain 'PSModule.Yaml.Document' + } + + It 'keeps property access, indexing, and formatting unchanged' { + $result = ConvertFrom-Yaml -Yaml "name: Ada`nports: [80, 443]`nnested:`n key: value" + + $result.name | Should -Be 'Ada' + $result.ports[0] | Should -Be 80 + $result.nested.key | Should -Be 'value' + @($result.PSObject.Properties.Name) | Should -Be @('name', 'ports', 'nested') + } + + It 'renders ordered dictionaries produced by AsHashtable' { + $result = ConvertFrom-Yaml -Yaml 'name: Ada' -AsHashtable + + $result | Should -BeOfType ([System.Collections.Specialized.OrderedDictionary]) + $result.ToString() | Should -Be '"name": "Ada"' + } + + It 'renders a sequence document held together by NoEnumerate' { + $result = ConvertFrom-Yaml -Yaml "- 1`n- 2" -NoEnumerate + + $result.ToString() | Should -Be "- 1`n- 2" + } + + It 'renders each enumerated top-level sequence item' { + $results = @(ConvertFrom-Yaml -Yaml "- name: Ada`n- name: Grace") + + $results.Count | Should -Be 2 + $results[0].ToString() | Should -Be '"name": "Ada"' + $results[1].ToString() | Should -Be '"name": "Grace"' + } + + It 'renders each document of a multi-document stream separately' { + $results = @(ConvertFrom-Yaml -Yaml "name: Ada`n---`nname: Grace") + + $results.Count | Should -Be 2 + $results[0].ToString() | Should -Be '"name": "Ada"' + $results[1].ToString() | Should -Be '"name": "Grace"' + } + + It 'reflects edits made after parsing' { + $result = ConvertFrom-Yaml -Yaml 'name: Ada' + $result.name = 'Grace' + + $result.ToString() | Should -Be '"name": "Grace"' + } + + It 'leaves scalar documents converting to text as themselves' -ForEach @( + @{ Yaml = '42'; Expected = '42' } + @{ Yaml = 'true'; Expected = 'True' } + @{ Yaml = 'plain text'; Expected = 'plain text' } + ) { + (ConvertFrom-Yaml -Yaml $Yaml).ToString() | Should -Be $Expected + } + + It 'leaves an empty document as null' { + ConvertFrom-Yaml -Yaml '---' | Should -BeNullOrEmpty + } + + It 'does not emit the rendering members as YAML content' { + $result = ConvertFrom-Yaml -Yaml 'name: Ada' + $yaml = ConvertTo-Yaml -InputObject $result + + $yaml | Should -Not -Match 'ToString' + $yaml | Should -Not -Match 'PSModule\.Yaml\.Document' + } + + It 'renders documents parsed from a file by Import-Yaml' { + $path = Join-Path ([System.IO.Path]::GetTempPath()) "yaml-tostring-$([guid]::NewGuid()).yaml" + try { + Set-Content -LiteralPath $path -Value "name: Ada" -Encoding utf8 + (Import-Yaml -Path $path).ToString() | Should -Be '"name": "Ada"' + } finally { + Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue + } + } +} +