Private/Merger.ps1

# Applies layers on top of each other. Maps deep merge, everything else is replaced.
# List operators are recorded as 'O' nodes and run when the key is resolved.
# A key without an operator can get a default one from -Merge or Merge in the schema.

$script:PsdDefaultOperators = @('+=', '=+', '|=', ':=')

# Rule: the schema rule of the map, for Merge of its keys. Keys: the key path of the map, for -Merge patterns.
function Merge-PsdMap {
    param($Base, $Layer, $M, [string] $Path, $Rule = $null, $Keys = $null)
    $out = New-PsdMapNode $Layer.File $null $Path
    Copy-PsdLocation $Layer $out
    if ($null -ne $Base) { foreach ($k in @($Base.V.PSBase.Keys)) { $out.V[$k] = $Base.V[$k] } }

    foreach ($k in @($Layer.V.PSBase.Keys)) {
        $v = $Layer.V[$k]
        $cp = Join-PsdPath $Path $k
        $prev = $null
        if ($out.V.Contains($k)) { $prev = $out.V[$k] }
        $childRule = Get-PsdChildRule $Rule $k
        $childKeys = Join-PsdList $Keys ([string]$k)
        # a key without an operator gets the default one of -Merge or the schema, if it fits the value
        if ($null -ne $prev) {
            $op = Get-PsdDefaultOp $M $childKeys $childRule
            $emptyPrev = $prev.K -eq 'S' -and $null -eq $prev.V
            $fits = ($op -eq ':=' -and $v.K -eq 'M') -or ($op -in '+=', '=+', '|=' -and $v.K -eq 'L' -and ($prev.K -in 'L', 'O' -or $emptyPrev))
            if ($fits) {
                Invoke-PsdMergeOp $out @{ Key = $k; Op = $op; Index = $null; Match = $null; Arg = $v; File = $v.File; Line = $v.KeyLine; Col = $v.KeyCol; Len = $v.KeyLen; Text = [string]$k; Path = $cp } $M
                continue
            }
        }
        if ($v.K -eq 'M') {
            $nb = $null
            if ($null -ne $prev -and $prev.K -eq 'M') { $nb = $prev }
            $nv = Merge-PsdMap $nb $v $M $cp $childRule $childKeys
        }
        else { $nv = ConvertTo-PsdNormal $v $M $cp }
        Set-PsdProv $M $cp $nv $prev
        $out.V[$k] = $nv
    }
    foreach ($op in $Layer.Ops) { Invoke-PsdMergeOp $out $op $M }
    foreach ($if in $Layer.Ifs) {
        if (Test-PsdIfTrue $if $M) { $out = Merge-PsdMap $out $if.Body $M $Path $Rule $Keys }
    }
    $out
}

# The rule of a key in the rule of its map: its entry in Properties, else Items. $null without a rule.
function Get-PsdChildRule {
    param($Rule, [string] $Key)
    if ($null -eq $Rule) { return $null }
    if ($Rule.Properties -and $Rule.Properties.Contains($Key)) { return $Rule.Properties[$Key] }
    $Rule.Items
}

# Returns $true when a key path matches a -Merge pattern: * is one key, ** any number of keys, other parts are
# wildcards on one key, ignoring case.
function Test-PsdMergePattern {
    param($Pattern, [int] $Pi, $Keys, [int] $Ki)
    if ($Pi -ge $Pattern.Count) { return ($Ki -ge $Keys.Count) }
    $p = $Pattern[$Pi]
    if ($p -eq '**') {
        for ($i = $Ki; $i -le $Keys.Count; $i++) { if (Test-PsdMergePattern $Pattern ($Pi + 1) $Keys $i) { return $true } }
        return $false
    }
    if ($Ki -ge $Keys.Count) { return $false }
    if (-not ([System.Management.Automation.WildcardPattern]::new($p, 'IgnoreCase').IsMatch($Keys[$Ki]))) { return $false }
    Test-PsdMergePattern $Pattern ($Pi + 1) $Keys ($Ki + 1)
}

# The operator a key written without one gets: the first -Merge pattern that matches its path, else Merge of its
# schema rule, else $null.
function Get-PsdDefaultOp {
    param($M, $Keys, $Rule)
    foreach ($d in $M.Defaults) {
        if (Test-PsdMergePattern $d.Pattern 0 $Keys 0) { return $d.Op }
    }
    if ($null -ne $Rule) { $Rule['Merge'] }
}

# Turns the -Merge map (key path pattern -> operator) into a list of @{ Pattern = keys; Op }, in the given order.
function ConvertFrom-PsdMergeOption {
    param($Merge, $Ctx)
    $out = New-PsdList
    if ($null -eq $Merge) { return , $out }
    foreach ($k in @($Merge.PSBase.Keys)) {
        $pattern = [string]$k
        $op = $Merge[$k]
        if ($op -notin $script:PsdDefaultOperators) {
            Add-PsdError $Ctx 'PSD206' "-Merge: '$op' for '$pattern' isn't a default operator." -Hint @("Use $($script:PsdDefaultOperators -join ', '). Removing (-=) can only be written in the data.")
            continue
        }
        $parts = @($pattern.Split('.'))
        if ($pattern -eq '' -or @($parts | Where-Object { $_ -eq '' }).Count) {
            Add-PsdError $Ctx 'PSD206' "-Merge: '$pattern' isn't a key path." -Hint @("Write keys with dots, * for one key and ** for any number: 'Server.Tags', '*.Tags', '**'.")
            continue
        }
        $out.Add(@{ Pattern = $parts; Op = [string]$op })
    }
    , $out
}

# Applies the operators and conditions inside a value that isn't merged onto anything.
function ConvertTo-PsdNormal {
    param($Node, $M, [string] $Path)
    if ($Node.K -eq 'M') { return (Merge-PsdMap $null $Node $M $Path) }
    if ($Node.K -ne 'L') { return $Node }
    $needs = $false
    foreach ($x in $Node.V) { if ($x.K -eq 'M' -or $x.K -eq 'L') { $needs = $true; break } }
    if (-not $needs) { return $Node }
    $items = New-PsdList
    $i = 0
    foreach ($x in $Node.V) { $items.Add((ConvertTo-PsdNormal $x $M (Join-PsdPath $Path $i))); $i++ }
    $n = New-PsdNode 'L' $items $Node.File $null $Path
    Copy-PsdLocation $Node $n
    $n
}

function Set-PsdProv {
    param($M, [string] $Path, $New, $Prev)
    if ($null -eq $M.Prov) { return }
    $over = New-PsdList
    if ($null -ne $Prev) {
        $over.Add($Prev)
        if ($M.Prov.ContainsKey($Path)) { foreach ($x in $M.Prov[$Path].Over) { if ($x.Id -ne $Prev.Id) { $over.Add($x) } } }
    }
    $M.Prov[$Path] = @{ Node = $New; Over = $over }
}

function Add-PsdMergeError {
    param($M, [string] $Code, [string] $Message, $Op, [string[]] $Hint = @())
    if ($M.Quiet) { return }
    Add-PsdError $M.Ctx $Code $Message -File $Op.File -Line $Op.Line -Column $Op.Col -Length $Op.Len -Key $Op.Path -Hint $Hint
}

function New-PsdOpNode {
    param($Base, $Op, $Arg)
    $n = New-PsdNode 'O' $null $Op.File $null $Op.Path
    $n.Line = $Op.Line; $n.Col = $Op.Col; $n.Text = $Op.Text
    $n.KeyLine = $Op.Line; $n.KeyCol = $Op.Col; $n.KeyLen = $Op.Len
    $n.Base = $Base
    $n.Op = $Op.Op
    $n.Index = $Op.Index
    $n.Match = $Op.Match
    $n.Arg = $Arg
    $n
}

function Get-PsdNodeKindText {
    param($Node)
    switch ($Node.K) {
        'M' { return 'a map' }
        'L' { return 'a list' }
        'S' { return (Get-PsdKindText $Node.V) }
        default { return 'a reference' }
    }
}

function Invoke-PsdMergeOp {
    param($Out, $Op, $M)
    $k = $Op.Key
    $has = $Out.V.Contains($k)
    $prev = $null
    if ($has) { $prev = $Out.V[$k] }
    $arg = ConvertTo-PsdNormal $Op.Arg $M $Op.Path
    $shown = Format-PsdKeyText $k
    if ($null -ne $Op.Index -or $null -ne $Op.Match) {
        Invoke-PsdIndexOp $Out $Op $arg $prev $M
        return
    }

    switch ($Op.Op) {
        '-=' {
            if ($arg.K -eq 'S' -and $arg.V -is [bool] -and $arg.V) {
                if ($has) {
                    $Out.V.Remove($k)
                    if ($M.Prov) { [void]$M.Prov.Remove($Op.Path) }
                }
                else { Add-PsdMergeError $M 'PSD212' "$shown can't be deleted, because it doesn't exist." $Op @((Get-PsdDidYouMean $k $Out.V.PSBase.Keys)) }
                return
            }
            if ($arg.K -eq 'S' -and ($null -eq $arg.V -or $arg.V -is [bool])) {
                Add-PsdMergeError $M 'PSD211' "'$shown -=' needs `$true to delete the key, or a list of items to remove." $Op @("Delete it: '$k -=' = `$true. Remove items: '$k -=' = @('a', 'b').")
                return
            }
            if ($arg.K -eq 'M') {
                Add-PsdMergeError $M 'PSD211' "'$shown -=' can't take a map. Give it `$true to delete the key, or a list of items or keys to remove." $Op
                return
            }
            if (-not $has) {
                Add-PsdMergeError $M 'PSD212' "nothing can be removed from $shown, because it doesn't exist." $Op @((Get-PsdDidYouMean $k $Out.V.PSBase.Keys))
                return
            }
            if ($prev.K -eq 'M') {
                $copy = New-PsdMapNode $prev.File $null $prev.Path
                Copy-PsdLocation $prev $copy
                foreach ($x in $prev.V.PSBase.Keys) { $copy.V[$x] = $prev.V[$x] }
                foreach ($n in (Get-PsdNodeItems $arg)) {
                    if ($n.K -ne 'S' -or $n.V -isnot [string]) {
                        Add-PsdMergeError $M 'PSD211' "removing from the map $shown needs key names." $Op
                        continue
                    }
                    if ($copy.V.Contains($n.V)) {
                        $copy.V.Remove($n.V)
                        if ($M.Prov) { [void]$M.Prov.Remove((Join-PsdPath $Op.Path $n.V)) }
                    }
                    else { Add-PsdMergeError $M 'PSD212' "'$($n.V)' isn't a key of $shown, so it can't be removed." $Op @((Get-PsdDidYouMean $n.V $prev.V.PSBase.Keys)) }
                }
                $Out.V[$k] = $copy
                return
            }
            if ($prev.K -eq 'S' -and $null -ne $prev.V) {
                Add-PsdMergeError $M 'PSD210' "-= only works on lists and maps, but $shown is $(Get-PsdNodeKindText $prev)." $Op
                return
            }
            $Out.V[$k] = New-PsdOpNode $prev $Op $arg
            Set-PsdProv $M $Op.Path $Out.V[$k] $prev
        }
        ':=' {
            $Out.V[$k] = $arg
            Set-PsdProv $M $Op.Path $arg $prev
        }
        default {
            $base = $prev
            if ($null -ne $base -and $base.K -eq 'S' -and $null -eq $base.V) { $base = $null }
            if ($null -ne $base -and $base.K -eq 'M') {
                Add-PsdMergeError $M 'PSD210' "$($Op.Op) only works on lists, but $shown is a map." $Op @("To add keys to a map, write them: $k = @{ NewKey = ... }")
                return
            }
            if ($null -ne $base -and $base.K -eq 'S') {
                Add-PsdMergeError $M 'PSD210' "$($Op.Op) only works on lists, but $shown is $(Get-PsdNodeKindText $base)." $Op
                return
            }
            $Out.V[$k] = New-PsdOpNode $base $Op $arg
            Set-PsdProv $M $Op.Path $Out.V[$k] $prev
        }
    }
}

# An operator with an index or a value, like 'X[1] :='. Checks what is known now; the list is worked on when the key is resolved,
# because only then is its length known: it can be a reference or the result of other operators.
function Invoke-PsdIndexOp {
    param($Out, $Op, $Arg, $Prev, $M)
    $k = $Op.Key
    $shown = Format-PsdKeyText $k
    $place = if ($null -ne $Op.Match) { "'$($Op.Match)'" } else { $Op.Index }
    $written = "$k[$place] $($Op.Op)"
    if ($Op.Op -eq '-=' -and -not ($Arg.K -eq 'S' -and $Arg.V -is [bool] -and $Arg.V)) {
        Add-PsdMergeError $M 'PSD211' "'$written' needs `$true to remove that item." $Op @("Remove it: '$written' = `$true.")
        return
    }
    $base = $Prev
    if ($null -ne $base -and $base.K -eq 'S' -and $null -eq $base.V) { $base = $null }
    if ($null -eq $base -and $Op.Op -ne '+=') {
        Add-PsdMergeError $M 'PSD212' "$shown doesn't exist, so '$written' has no item to work on." $Op @((Get-PsdDidYouMean $k $Out.V.PSBase.Keys))
        return
    }
    if ($null -ne $base -and $base.K -in 'M', 'S') {
        Add-PsdMergeError $M 'PSD210' "an index only works on lists, but $shown is $(Get-PsdNodeKindText $base)." $Op
        return
    }
    $Out.V[$k] = New-PsdOpNode $base $Op $Arg
    Set-PsdProv $M $Op.Path $Out.V[$k] $Prev
}

function Test-PsdIfTrue {
    param($If, $M)
    switch ($M.Mode) {
        'None' { return $false }
        'All' { return $true }
    }
    $doc = $M.Doc
    if ($doc.Decisions.ContainsKey($If.Id)) { return $doc.Decisions[$If.Id] }
    $doc.Decisions[$If.Id] = $false
    $ev = @{ Doc = $doc.Prelim; Node = $If; InCond = $true; Final = $doc }
    $r = Test-PsdCondition $If.Cond $ev
    $doc.Decisions[$If.Id] = $r
    $r
}