Private/Yaml.ps1

using namespace System.Collections
using namespace System.Collections.Generic
using namespace System.Collections.Specialized
using namespace System.Globalization

# YAML access built on the YamlDotNet representation model that ships with powershell-yaml.
#
# ConvertFrom-Yaml is not used for Taskfiles because it returns case-insensitive
# dictionaries (Taskfile variable and task names are case-sensitive) and drops the
# position of nodes. Working on the node tree keeps both.

[NoRunspaceAffinity()]
class TaskYaml {
    hidden static [type] $StreamType

    hidden static [type] ResolveStreamType() {
        if ($null -ne [TaskYaml]::StreamType) { return [TaskYaml]::StreamType }
        for ($attempt = 0; $attempt -lt 2; $attempt++) {
            foreach ($assembly in [AppDomain]::CurrentDomain.GetAssemblies()) {
                if ($assembly.GetName().Name -ne 'YamlDotNet') { continue }
                $type = $assembly.GetType('YamlDotNet.RepresentationModel.YamlStream')
                if ($null -ne $type) {
                    [TaskYaml]::StreamType = $type
                    return $type
                }
            }
            # Importing powershell-yaml and converting once loads YamlDotNet into the process.
            Import-Module powershell-yaml -ErrorAction Stop
            $null = ConvertFrom-Yaml -Yaml 'a: 1'
        }
        throw "PS-TaskFile requires the 'powershell-yaml' module (YamlDotNet could not be located)."
    }

    # Parses YAML text and returns the root node ($null for an empty document).
    static [object] Load([string] $text) {
        $stream = [Activator]::CreateInstance([TaskYaml]::ResolveStreamType())
        $reader = [System.IO.StringReader]::new($text)
        try { $stream.Load($reader) } finally { $reader.Dispose() }
        if ($stream.Documents.Count -eq 0) { return $null }
        return $stream.Documents[0].RootNode
    }

    hidden static [bool] IsNullScalar([object] $node) {
        if ([string]$node.Style -ne 'Plain') { return $false }
        $text = [string]$node.Value
        return $text.Length -eq 0 -or $text -ceq '~' -or $text -ceq 'null' -or $text -ceq 'Null' -or $text -ceq 'NULL'
    }

    # 'map', 'seq', 'scalar' or 'null'.
    static [string] Kind([object] $node) {
        if ($null -eq $node) { return 'null' }
        $type = [string]$node.NodeType
        if ($type -eq 'Mapping') { return 'map' }
        if ($type -eq 'Sequence') { return 'seq' }
        if ($type -eq 'Scalar' -and [TaskYaml]::IsNullScalar($node)) { return 'null' }
        return 'scalar'
    }

    static [bool] IsMap([object] $node) { return [TaskYaml]::Kind($node) -eq 'map' }

    static [int] Line([object] $node) { if ($null -eq $node) { return 0 }; return [int]$node.Start.Line }

    static [int] Column([object] $node) { if ($null -eq $node) { return 0 }; return [int]$node.Start.Column }

    # Child nodes of a mapping keyed by name (case-sensitive, document order, merge keys applied).
    static [OrderedDictionary] Map([object] $node) {
        $result = [OrderedDictionary]::new()
        if ([TaskYaml]::Kind($node) -ne 'map') { return $result }
        $merges = [List[object]]::new()
        foreach ($pair in $node.Children) {
            $keyNode = $pair.Key
            $key = if ([string]$keyNode.NodeType -eq 'Scalar') { [string]$keyNode.Value } else { [string]$keyNode }
            if ($key -ceq '<<' -and [string]$keyNode.Style -eq 'Plain') { $merges.Add($pair.Value); continue }
            $result[$key] = $pair.Value
        }
        foreach ($merge in $merges) {
            $sources = [List[object]]::new()
            if ([TaskYaml]::Kind($merge) -eq 'seq') { foreach ($child in $merge.Children) { $sources.Add($child) } } else { $sources.Add($merge) }
            foreach ($source in $sources) {
                $merged = [TaskYaml]::Map($source)
                foreach ($key in $merged.get_Keys()) { if (-not $result.Contains($key)) { $result[[string]$key] = $merged[$key] } }
            }
        }
        return $result
    }

    # Key nodes of a mapping by name (for positions).
    static [OrderedDictionary] KeyNodes([object] $node) {
        $result = [OrderedDictionary]::new()
        if ([TaskYaml]::Kind($node) -ne 'map') { return $result }
        foreach ($pair in $node.Children) {
            if ([string]$pair.Key.NodeType -eq 'Scalar') { $result[[string]$pair.Key.Value] = $pair.Key }
        }
        return $result
    }

    # Items of a sequence. A single non-null node is treated as a one-element list.
    static [List[object]] Seq([object] $node) {
        $list = [List[object]]::new()
        $kind = [TaskYaml]::Kind($node)
        if ($kind -eq 'null') { return $list }
        if ($kind -eq 'seq') { foreach ($child in $node.Children) { $list.Add($child) } }
        else { $list.Add($node) }
        return $list
    }

    # The raw text of a scalar ('' for null and for non-scalars).
    static [string] Str([object] $node) {
        if ([TaskYaml]::Kind($node) -ne 'scalar') { return '' }
        return [string]$node.Value
    }

    static [bool] Bool([object] $node) {
        $text = [TaskYaml]::Str($node)
        return $text -cin @('true', 'True', 'TRUE', 'yes', 'Yes', 'YES', 'on', 'On', 'ON', 'y', 'Y')
    }

    static [string[]] StrList([object] $node) {
        $list = [List[string]]::new()
        foreach ($item in [TaskYaml]::Seq($node)) { $list.Add([TaskYaml]::Str($item)) }
        return $list.ToArray()
    }

    hidden static [object] ParseInteger([string] $plain) {
        $negative = $plain.StartsWith('-')
        $digits = $plain.TrimStart('+', '-')
        $value = 0L
        if ($digits.StartsWith('0x', [StringComparison]::OrdinalIgnoreCase)) { $value = [Convert]::ToInt64($digits.Substring(2), 16) }
        elseif ($digits.StartsWith('0o', [StringComparison]::OrdinalIgnoreCase)) { $value = [Convert]::ToInt64($digits.Substring(2), 8) }
        elseif ($digits.StartsWith('0b', [StringComparison]::OrdinalIgnoreCase)) { $value = [Convert]::ToInt64($digits.Substring(2), 2) }
        elseif ($digits.Length -gt 1 -and $digits[0] -eq '0') { $value = [Convert]::ToInt64($digits, 8) }
        else { $value = [long]::Parse($digits, [NumberStyles]::None, [CultureInfo]::InvariantCulture) }
        if ($negative) { $value = - $value }
        if ($value -ge [int]::MinValue -and $value -le [int]::MaxValue) { return [int]$value }
        return $value
    }

    # Resolves a plain scalar like the YAML 1.2 core schema (as go-yaml does).
    static [object] ResolvePlain([string] $text) {
        if ($text.Length -eq 0 -or $text -ceq '~' -or $text -ceq 'null' -or $text -ceq 'Null' -or $text -ceq 'NULL') { return $null }
        if ($text -ceq 'true' -or $text -ceq 'True' -or $text -ceq 'TRUE') { return $true }
        if ($text -ceq 'false' -or $text -ceq 'False' -or $text -ceq 'FALSE') { return $false }
        if ($text -cmatch '^[-+]?\.(inf|Inf|INF)$') { if ($text.StartsWith('-')) { return [double]::NegativeInfinity }; return [double]::PositiveInfinity }
        if ($text -cmatch '^\.(nan|NaN|NAN)$') { return [double]::NaN }
        $first = $text[0]
        if (-not ([char]::IsDigit($first) -or $first -eq '-' -or $first -eq '+' -or $first -eq '.')) { return $text }
        $plain = $text.Replace('_', '')
        if ($plain -cmatch '^[-+]?(0[xX][0-9a-fA-F]+|0[oO][0-7]+|0[bB][01]+|[0-9]+)$') {
            try { return [TaskYaml]::ParseInteger($plain) } catch { }
        }
        if ($plain -cmatch '^[-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?$') {
            return [double]::Parse($plain, [NumberStyles]::Float, [CultureInfo]::InvariantCulture)
        }
        return $text
    }

    hidden static [object] Scalar([object] $node) {
        $text = [string]$node.Value
        $tag = [string]$node.Tag
        if ($tag.EndsWith(':str')) { return $text }
        if ([string]$node.Style -ne 'Plain' -and $tag -notmatch ':(int|float|bool|null)$') { return $text }
        return [TaskYaml]::ResolvePlain($text)
    }

    # Converts a node tree into case-sensitive ordered dictionaries, lists and typed scalars.
    static [object] Value([object] $node) {
        if ($null -eq $node) { return $null }
        $type = [string]$node.NodeType
        if ($type -eq 'Mapping') {
            $result = [OrderedDictionary]::new()
            $map = [TaskYaml]::Map($node)
            foreach ($key in $map.get_Keys()) { $result[[string]$key] = [TaskYaml]::Value($map[$key]) }
            return $result
        }
        if ($type -eq 'Sequence') {
            $list = [List[object]]::new()
            foreach ($child in $node.Children) { $list.Add([TaskYaml]::Value($child)) }
            return $list
        }
        if ($type -eq 'Scalar') { return [TaskYaml]::Scalar($node) }
        return [string]$node
    }

    # Parses YAML text straight into values.
    static [object] Parse([string] $text) { return [TaskYaml]::Value([TaskYaml]::Load($text)) }
}