Private/Writer.ps1

# Objects to .psd1 text.

$script:PsdFormatDefaults = [ordered]@{
    Indent         = 4
    Quote          = 'Single'
    SortKeys       = $false
    AlignEquals    = $false
    InlineArrayMax = 4
    LineEnding     = 'Auto'
    Bom            = $false
}
# What has to be escaped in each kind of quote. PowerShell also reads typographic quotes as quotes.
$script:PsdRxEscDouble = New-Object regex ('[`$"' + [char]0x201C + '-' + [char]0x201E + ']')
$script:PsdRxEscSingle = New-Object regex ("['" + [char]0x2018 + '-' + [char]0x201B + ']')

function Get-PsdFormat {
    param([System.Collections.IDictionary] $Format, $Ctx)
    $f = Copy-PsdPlain $script:PsdFormatDefaults
    if ($null -eq $Format) { return $f }
    $options = @($f.PSBase.Keys)
    foreach ($k in $Format.PSBase.Keys) {
        $name = [string]$k
        $v = $Format[$k]
        $known = @($options -ieq $name)
        if ($known.Count -eq 0) {
            Add-PsdError $Ctx 'PSD604' "'$name' isn't a format option." -Hint @((Get-PsdDidYouMean $name $options), "Options: $($options -join ', ').")
            continue
        }
        $want, $ok = switch ($known[0]) {
            'Indent' { 'a number from 0 to 16'; (Test-PsdNumber $v) -and $v -ge 0 -and $v -le 16 }
            'InlineArrayMax' { 'a number of 0 or more'; (Test-PsdNumber $v) -and $v -ge 0 }
            'Quote' { "'Single' or 'Double'"; $v -in 'Single', 'Double' }
            'LineEnding' { "'Auto', 'LF' or 'CRLF'"; $v -in 'Auto', 'LF', 'CRLF' }
            default { '$true or $false'; $v -is [bool] }
        }
        if ($ok) { $f[$known[0]] = $v }
        else { Add-PsdError $Ctx 'PSD604' "the format option $($known[0]) has to be $want, but it's $(Format-PsdValueShort $v)." }
    }
    $f
}

function Get-PsdNewLine {
    param($Format)
    switch ($Format.LineEnding) { 'LF' { return "`n" } 'CRLF' { return "`r`n" } }
    [Environment]::NewLine
}

function Format-PsdString {
    param([string] $Value, $Format, [bool] $Raw = $false)
    $v = $Value
    if (-not $Raw) { $v = $Value.Replace('!<', '!!<') }
    if ($Format.Quote -eq 'Double') { return '"' + $script:PsdRxEscDouble.Replace($v, '`$0') + '"' }
    "'" + $script:PsdRxEscSingle.Replace($v, '$0$0') + "'"
}

function Format-PsdKeyOut {
    param($Key, $S)
    $k = $null
    if ($Key -is [string]) { $k = $Key }
    elseif ($Key -is [char] -or $Key -is [enum] -or (Test-PsdNumber $Key) -or $Key -is [guid]) { $k = Format-PsdScalarText $Key }
    if ($null -eq $k) {
        Add-PsdError $S.Ctx 'PSD603' "a key of type $($Key.GetType().Name) at $($S.Path) can't be written. Keys have to be text."
        return $null
    }
    $raw = [bool]$S['Raw']
    if (-not $raw) {
        if ($script:PsdRxOp.IsMatch($k)) {
            Add-PsdError $S.Ctx 'PSD603' "the key '$k' at $($S.Path) ends in a merge operator, so it would be read back as one."
            return $null
        }
        if ([regex]::IsMatch($k, '(?<!!)!<')) {
            Add-PsdError $S.Ctx 'PSD603' "the key '$k' at $($S.Path) contains !<, which keys can't have."
            return $null
        }
        if ($k.StartsWith('!')) { $k = '!' + $k }
    }
    if ($script:PsdRxIdent.IsMatch($k)) { return $k }
    Format-PsdString $k @{ Quote = 'Single' } $raw
}

function Test-PsdScalarOut {
    param($Value)
    if ($null -eq $Value) { return $true }
    $Value -is [string] -or $Value -is [bool] -or (Test-PsdNumber $Value) -or $Value -is [char] -or $Value -is [enum] -or
    $Value -is [datetime] -or $Value -is [datetimeoffset] -or $Value -is [guid] -or $Value -is [version] -or $Value -is [uri] -or $Value -is [timespan]
}

function Format-PsdScalarOut {
    param($Value, $S)
    if ($null -eq $Value) { return '$null' }
    if ($Value -is [bool]) { if ($Value) { return '$true' } return '$false' }
    if ($Value -is [string]) { return (Format-PsdString $Value $S.Format $S.Raw) }
    if ($Value -is [char]) { return (Format-PsdString ([string]$Value) $S.Format) }
    if ($Value -is [double] -or $Value -is [single]) {
        $d = [double]$Value
        if ([double]::IsNaN($d) -or [double]::IsInfinity($d)) {
            Add-PsdError $S.Ctx 'PSD602' "$($S.Path) is $d, which a data file can't hold."
            return '$null'
        }
        $t = $d.ToString('R', $script:PsdInvariant)
        if ($t -notmatch '[.Ee]') { $t += '.0' }
        return $t
    }
    if ($Value -is [decimal]) { return $Value.ToString($script:PsdInvariant) + 'd' }
    if ($Value -is [long]) {
        $t = $Value.ToString($script:PsdInvariant)
        if ($Value -ge [int]::MinValue -and $Value -le [int]::MaxValue) { $t += 'l' }
        return $t
    }
    if (Test-PsdNumber $Value) { return $Value.ToString($null, $script:PsdInvariant) }
    if ($Value -is [enum]) { return (Format-PsdString $Value.ToString() $S.Format) }
    if ($Value -is [datetime] -or $Value -is [datetimeoffset]) { return (Format-PsdString $Value.ToString('o', $script:PsdInvariant) $S.Format) }
    if ($Value -is [timespan]) { return (Format-PsdString $Value.ToString('c', $script:PsdInvariant) $S.Format) }
    Format-PsdString $Value.ToString() $S.Format
}

function Test-PsdSeen {
    param($S, $Value)
    foreach ($x in $S.Stack) { if ([object]::ReferenceEquals($x, $Value)) { return $true } }
    $false
}

function Get-PsdMapPairs {
    param($Map)
    $pairs = New-PsdList
    foreach ($k in $Map.PSBase.Keys) { $pairs.Add(([object[]]($k, $Map[$k]))) }
    , $pairs
}

# Turns any object into its parts: @{ Kind = 'map'|'list'|'scalar'; Pairs; Items; Value }.
function Split-PsdObject {
    param($Value, $S)
    if (Test-PsdScalarOut $Value) { return @{ Kind = 'scalar'; Value = $Value } }
    if ($Value -is [scriptblock] -or $Value -is [System.Security.SecureString] -or $Value -is [System.Management.Automation.PSCredential]) {
        Add-PsdError $S.Ctx 'PSD602' "$($S.Path) is a $($Value.GetType().Name), which a data file can't hold."
        return @{ Kind = 'scalar'; Value = $null }
    }
    if ($Value -is [System.Collections.IDictionary]) { return @{ Kind = 'map'; Pairs = (Get-PsdMapPairs $Value) } }
    if ($Value -is [System.Collections.IEnumerable]) {
        $items = New-PsdList
        foreach ($x in $Value) { $items.Add($x) }
        return @{ Kind = 'list'; Items = $items }
    }
    $kept = Get-PsdKeptMap $Value
    if ($null -ne $kept) { return @{ Kind = 'map'; Pairs = (Get-PsdMapPairs $kept); Raw = $true } }
    $base = $Value
    if ($Value -is [psobject]) { $base = $Value.PSObject.BaseObject }
    $isCustom = $Value -is [System.Management.Automation.PSCustomObject]
    $isClass = (-not $isCustom) -and $base.GetType().Assembly.IsDynamic
    $pairs = New-PsdList
    foreach ($p in $Value.PSObject.Properties) {
        if (-not $isCustom -and $p.MemberType -ne 'Property' -and $p.MemberType -ne 'NoteProperty') { continue }
        if (-not $p.IsGettable) { continue }
        $pv = $null
        try { $pv = $p.Value } catch { continue }
        if ($isClass -and $null -eq $pv) { continue }
        $pairs.Add(([object[]]($p.Name, $pv)))
    }
    if ($pairs.Count -eq 0 -and -not $isCustom -and -not $isClass) {
        $S.Ctx.Warnings.Add("$($S.Path) is a $($base.GetType().Name) with no properties, so it was written as text.")
        return @{ Kind = 'scalar'; Value = $Value.ToString() }
    }
    if ($isClass) { return @{ Kind = 'map'; Pairs = $pairs; Raw = $false } }
    @{ Kind = 'map'; Pairs = $pairs }
}

function Write-PsdValueText {
    param($Value, $S, [int] $Level)
    if ($Level -gt $S.Depth) {
        Add-PsdError $S.Ctx 'PSD601' "$($S.Path) is nested deeper than -Depth ($($S.Depth))." -Hint @('Raise -Depth if this is on purpose.')
        return '$null'
    }
    $parts = Split-PsdObject $Value $S
    if ($parts.Kind -eq 'scalar') { return (Format-PsdScalarOut $parts.Value $S) }
    if (Test-PsdSeen $S $Value) {
        Add-PsdError $S.Ctx 'PSD600' "$($S.Path) contains itself, so it can't be written." -Hint @("It points back to an object above it.")
        return '$null'
    }
    $S.Stack.Add($Value)
    $wasRaw = $S.Raw
    if ($parts.ContainsKey('Raw')) { $S.Raw = $parts.Raw }
    try {
        $f = $S.Format
        $nl = $S.NL
        $pad = ' ' * ($f.Indent * ($Level + 1))
        $endPad = ' ' * ($f.Indent * $Level)
        $here = $S.Path
        if ($parts.Kind -eq 'list') {
            $items = $parts.Items
            if ($items.Count -eq 0) { return '@()' }
            $allScalar = $true
            foreach ($x in $items) { if (-not (Test-PsdScalarOut $x)) { $allScalar = $false; break } }
            $texts = New-PsdList
            for ($i = 0; $i -lt $items.Count; $i++) {
                $S.Path = "$here[$i]"
                $texts.Add((Write-PsdValueText $items[$i] $S ($Level + 1)))
            }
            $S.Path = $here
            if ($allScalar -and $items.Count -le $f.InlineArrayMax) { return '@(' + ($texts -join ', ') + ')' }
            return '@(' + $nl + (($texts | ForEach-Object { $pad + $_ }) -join $nl) + $nl + $endPad + ')'
        }
        $pairs = $parts.Pairs
        if ($pairs.Count -eq 0) { return '@{}' }
        if ($f.SortKeys) {
            $pairs.Sort([Comparison[object]] { param($a, $b) [string]::Compare([string]$a[0], [string]$b[0], [StringComparison]::OrdinalIgnoreCase) })
        }
        $lines = New-PsdList
        $keys = New-PsdList
        foreach ($pr in $pairs) {
            $S.Path = Join-PsdPath $here ([string]$pr[0])
            $keys.Add((Format-PsdKeyOut $pr[0] $S))
        }
        $width = 0
        if ($f.AlignEquals) { foreach ($k in $keys) { if ($k -and $k.Length -gt $width) { $width = $k.Length } } }
        for ($i = 0; $i -lt $pairs.Count; $i++) {
            if ($null -eq $keys[$i]) { continue }
            $S.Path = Join-PsdPath $here ([string]$pairs[$i][0])
            $vt = Write-PsdValueText $pairs[$i][1] $S ($Level + 1)
            $lines.Add($pad + $keys[$i].PadRight($width) + ' = ' + $vt)
        }
        $S.Path = $here
        return '@{' + $nl + ($lines -join $nl) + $nl + $endPad + '}'
    }
    finally {
        $S.Stack.RemoveAt($S.Stack.Count - 1)
        $S.Raw = $wasRaw
    }
}

function ConvertTo-PsdTextInternal {
    param($Value, $Format, [int] $Depth, $Ctx)
    $S = @{ Ctx = $Ctx; Format = $Format; Depth = $Depth; Stack = (New-PsdList); Path = '(top)'; NL = (Get-PsdNewLine $Format); Raw = $false }
    Write-PsdValueText $Value $S 0
}

function Write-PsdTextFile {
    param([string] $Path, [string] $Text, [bool] $Bom, $Ctx)
    try {
        $enc = New-Object System.Text.UTF8Encoding $Bom
        [IO.File]::WriteAllText($Path, $Text, $enc)
        return $true
    }
    catch {
        Add-PsdError $Ctx 'PSD605' "the file $Path can't be written: $($_.Exception.InnerException.Message)"
        return $false
    }
}