Private/Grammar.ps1

# Tokenizer and parser for references (!<...>), paths and conditions.
# Hand-written so every error can point to an exact column.

$script:PsdRxName = New-Object regex '\G[A-Za-z_][A-Za-z0-9_\-]*'
$script:PsdRxInt = New-Object regex '\G-?[0-9]+'
$script:PsdRxNumber = New-Object regex '\G-?[0-9]+(\.[0-9]+)?'
$script:PsdRxNs = New-Object regex '\G([^\s"<>|?()\[\],:@!]+)::'
$script:PsdRxEnv = New-Object regex '\G[eE][nN][vV]:'
$script:PsdRxEnvName = New-Object regex '\G[A-Za-z_][A-Za-z0-9_]*'
$script:PsdRxCmpOp = New-Object regex '\G-(eq|ne|gt|ge|lt|le|in|like|match)(?![A-Za-z0-9_])', 'IgnoreCase'
$script:PsdRxWord = New-Object regex '\G(true|false|null)(?![A-Za-z0-9_\-])', 'IgnoreCase'
$script:PsdRxPlainKey = New-Object regex '^[A-Za-z_][A-Za-z0-9_\-]*$'
$script:PsdRxIdent = New-Object regex '^[A-Za-z_][A-Za-z0-9_]*$'
$script:PsdRxFileName = New-Object regex '^[^\s"<>|?()\[\],:@!]+$'

function New-PsdParser {
    param([string] $Text, [int] $Pos = 0)
    @{ T = $Text; P = $Pos }
}

function Stop-PsdParse {
    param($P, [string] $Message, [int] $At = -1, [int] $Length = 1)
    if ($At -lt 0) { $At = $P.P }
    $ex = New-Object System.FormatException $Message
    $ex.Data['PsdPos'] = $At
    $ex.Data['PsdLen'] = $Length
    throw $ex
}

function Skip-PsdSpace {
    param($P)
    $t = $P.T
    while ($P.P -lt $t.Length -and [char]::IsWhiteSpace($t[$P.P])) { $P.P++ }
}

function Test-PsdAt {
    param($P, [string] $S)
    ($P.P + $S.Length -le $P.T.Length) -and ([string]::CompareOrdinal($P.T, $P.P, $S, 0, $S.Length) -eq 0)
}

function Test-PsdWordAt {
    param($P, [string] $Word)
    $t = $P.T
    if ($P.P + $Word.Length -gt $t.Length) { return $false }
    if ([string]::Compare($t, $P.P, $Word, 0, $Word.Length, [System.StringComparison]::OrdinalIgnoreCase) -ne 0) { return $false }
    $next = $P.P + $Word.Length
    if ($next -lt $t.Length -and ([char]::IsLetterOrDigit($t[$next]) -or $t[$next] -eq '_')) { return $false }
    $true
}

function Get-PsdCharText {
    param($P)
    if ($P.P -ge $P.T.Length) { return 'the end' }
    "'" + $P.T[$P.P] + "'"
}

# The text of a regex match at this position, moving past it, or $null if it doesn't match here.
function Read-PsdMatch {
    param($P, [regex] $Rx)
    $m = $Rx.Match($P.T, $P.P)
    if (-not $m.Success) { return $null }
    $P.P += $m.Length
    $m.Value
}

function Read-PsdString {
    param($P)
    $t = $P.T
    $start = $P.P
    $P.P++
    $sb = New-Object System.Text.StringBuilder
    while ($true) {
        if ($P.P -ge $t.Length) { Stop-PsdParse $P 'this text is never closed. Add a " at the end of it.' $start ($t.Length - $start) }
        $c = $t[$P.P]
        if ($c -eq '"') {
            if ($P.P + 1 -lt $t.Length -and $t[$P.P + 1] -eq '"') { [void]$sb.Append('"'); $P.P += 2; continue }
            $P.P++
            break
        }
        [void]$sb.Append($c)
        $P.P++
    }
    $sb.ToString()
}

# Text of digits as a whole number: [int] if it fits, else [long].
function ConvertFrom-PsdIntText {
    param($P, [string] $Text, [int] $At)
    $n = [long]0
    if (-not [long]::TryParse($Text, [System.Globalization.NumberStyles]::AllowLeadingSign, $script:PsdInvariant, [ref]$n)) {
        Stop-PsdParse $P "the number $Text is too big." $At $Text.Length
    }
    if ($n -ge [int]::MinValue -and $n -le [int]::MaxValue) { return [int]$n }
    $n
}

# A number, true, false or null at this position as a Lit operand, or $null if there is none.
function Read-PsdLiteral {
    param($P)
    $start = $P.P
    $m = $script:PsdRxNumber.Match($P.T, $P.P)
    if ($m.Success) {
        $P.P += $m.Length
        if ($m.Groups[1].Success) { $v = [double]::Parse($m.Value, $script:PsdInvariant) }
        else { $v = ConvertFrom-PsdIntText $P $m.Value $start }
        return @{ T = 'Lit'; V = $v; Start = $start; End = $P.P }
    }
    $w = Read-PsdMatch $P $script:PsdRxWord
    if ($null -eq $w) { return $null }
    @{ T = 'Lit'; V = @{ true = $true; false = $false }[$w]; Start = $start; End = $P.P }
}

function Read-PsdKey {
    param($P)
    $start = $P.P
    if (Test-PsdAt $P '"') { $k = Read-PsdString $P }
    else {
        $k = Read-PsdMatch $P $script:PsdRxName
        if ($null -eq $k) {
            if (Test-PsdAt $P '!') { Stop-PsdParse $P "a reference can't contain another !<...>. Write the key path directly: !<A.B>." }
            Stop-PsdParse $P "expected a key name here, but found $(Get-PsdCharText $P). Keys with spaces, dots or other signs go in double quotes: !<""my.key"">."
        }
    }
    @{ Key = $k; Index = 0; IsIndex = $false; Start = $start; End = $P.P }
}

function Read-PsdPath {
    param($P)
    $start = $P.P
    $path = @{ T = 'Path'; Ns = $null; Env = $null; Builtin = $null; Segs = (New-PsdList); Optional = $false; Start = $start; End = $start }

    if ($null -ne (Read-PsdMatch $P $script:PsdRxEnv)) {
        if (Test-PsdAt $P '"') { $path.Env = Read-PsdString $P }
        else {
            $path.Env = Read-PsdMatch $P $script:PsdRxEnvName
            if ($null -eq $path.Env) { Stop-PsdParse $P 'expected the name of an environment variable after env:.' }
        }
        $path.End = $P.P
        return $path
    }

    $ns = Read-PsdMatch $P $script:PsdRxNs
    if ($ns) { $path.Ns = $ns.Substring(0, $ns.Length - 2) }

    if (Test-PsdAt $P '@') {
        $P.P++
        $path.Builtin = Read-PsdMatch $P $script:PsdRxName
        if ($null -eq $path.Builtin) { Stop-PsdParse $P 'expected a built-in name after @, like @name or @dir.' }
        $path.End = $P.P
        return $path
    }

    $path.Segs.Add((Read-PsdKey $P))
    while ($true) {
        if (Test-PsdAt $P '.') {
            $P.P++
            $path.Segs.Add((Read-PsdKey $P))
        }
        elseif (Test-PsdAt $P '[') { $path.Segs.Add((Read-PsdIndex $P)) }
        else { break }
    }
    $path.End = $P.P
    $path
}

function Read-PsdIndex {
    param($P)
    $start = $P.P
    $P.P++
    Skip-PsdSpace $P
    $at = $P.P
    $n = Read-PsdMatch $P $script:PsdRxInt
    if ($null -eq $n) { Stop-PsdParse $P 'expected a number inside [ ], like [0] or [-1].' }
    $index = ConvertFrom-PsdIntText $P $n $at
    if ($index -isnot [int]) { Stop-PsdParse $P "the index $n is too big." $at $n.Length }
    Skip-PsdSpace $P
    if (-not (Test-PsdAt $P ']')) { Stop-PsdParse $P 'expected ] to close the index.' }
    $P.P++
    @{ Key = $null; Index = $index; IsIndex = $true; Start = $start; End = $P.P }
}

function Read-PsdOperand {
    param($P, [bool] $KeyFirst = $false)
    Skip-PsdSpace $P
    $start = $P.P
    if ($P.P -ge $P.T.Length) { Stop-PsdParse $P 'expected a key, text or a number here.' }
    if (Test-PsdAt $P '>') { Stop-PsdParse $P 'expected a key, text or a number here, but the reference ends.' }
    $optional = (Test-PsdAt $P '?') -and -not (Test-PsdAt $P '??')
    if ($optional) {
        $P.P++
        Skip-PsdSpace $P
    }
    else {
        if (-not $KeyFirst -and (Test-PsdAt $P '"')) {
            $v = Read-PsdString $P
            # "my.key".Sub and "my.key"[0] are quoted keys, not text.
            if (-not (Test-PsdAt $P '.') -and -not (Test-PsdAt $P '[')) { return @{ T = 'Lit'; V = $v; Start = $start; End = $P.P } }
            $P.P = $start
        }
        $lit = Read-PsdLiteral $P
        if ($lit) { return $lit }
    }
    $path = Read-PsdPath $P
    $path.Optional = $optional
    $path.Start = $start
    $path
}

function Read-PsdExpr {
    param($P, [bool] $KeyFirst = $false)
    $start = $P.P
    $ops = New-PsdList
    $ops.Add((Read-PsdOperand $P $KeyFirst))
    while ($true) {
        Skip-PsdSpace $P
        if (-not (Test-PsdAt $P '??')) { break }
        $P.P += 2
        $ops.Add((Read-PsdOperand $P))
    }
    $filters = New-PsdList
    while ($true) {
        Skip-PsdSpace $P
        if (-not (Test-PsdAt $P '|')) { break }
        $filters.Add((Read-PsdFilter $P))
    }
    @{ T = 'Expr'; Ops = $ops; Filters = $filters; Start = $start; End = $P.P }
}

# Reads '| name' or '| name(a, b)' starting at the '|'.
function Read-PsdFilter {
    param($P)
    $start = $P.P
    $P.P++
    Skip-PsdSpace $P
    $name = Read-PsdMatch $P $script:PsdRxName
    if ($null -eq $name) { Stop-PsdParse $P 'expected a filter name after |, like | lower.' }
    $fargs = New-PsdList
    Skip-PsdSpace $P
    if (Test-PsdAt $P '(') {
        $P.P++
        Skip-PsdSpace $P
        if (Test-PsdAt $P ')') { $P.P++ }
        else {
            while ($true) {
                $fargs.Add((Read-PsdExpr $P))
                Skip-PsdSpace $P
                if (Test-PsdAt $P ',') { $P.P++; continue }
                if (Test-PsdAt $P ')') { $P.P++; break }
                Stop-PsdParse $P "expected , or ) in the arguments of '$name', but found $(Get-PsdCharText $P)."
            }
        }
    }
    @{ Name = $name; Args = $fargs; Start = $start; End = $P.P }
}

# Reads '!<' expr '>' starting at the '!<'. Returns a Ref part.
function Read-PsdRef {
    param($P)
    $start = $P.P
    $P.P += 2
    $e = Read-PsdExpr $P $true
    Skip-PsdSpace $P
    if (-not (Test-PsdAt $P '>')) {
        if ($P.P -ge $P.T.Length) { Stop-PsdParse $P 'this reference is never closed. Add a > at the end of it.' $start ($P.T.Length - $start) }
        Stop-PsdParse $P "this reference has $(Get-PsdCharText $P) where it should end. Close it with >, or use ?? or | to continue it."
    }
    $P.P++
    @{ T = 'Ref'; Expr = $e; Start = $start; End = $P.P; Raw = $P.T.Substring($start, $P.P - $start) }
}

# Splits a string into literal text and references. '!!<' is a literal '!<'.
function ConvertFrom-PsdTemplate {
    param([string] $Text)
    $parts = New-PsdList
    $p = New-PsdParser $Text
    $literal = ''
    $hasRef = $false
    while ($p.P -lt $Text.Length) {
        $at = $Text.IndexOf('!<', $p.P, [StringComparison]::Ordinal)
        if ($at -lt 0) { $literal += $Text.Substring($p.P); break }
        if ($at -gt $p.P -and $Text[$at - 1] -eq '!') {
            $literal += $Text.Substring($p.P, $at - 1 - $p.P) + '!<'
            $p.P = $at + 2
            continue
        }
        $literal += $Text.Substring($p.P, $at - $p.P)
        if ($literal) { $parts.Add($literal); $literal = '' }
        $p.P = $at
        $parts.Add((Read-PsdRef $p))
        $hasRef = $true
    }
    if ($literal) { $parts.Add($literal) }
    @{ Parts = $parts; HasRef = $hasRef }
}

# --- Conditions ---------------------------------------------------------------

function ConvertFrom-PsdCondition {
    param([string] $Text)
    $p = New-PsdParser $Text
    Skip-PsdSpace $p
    if ($p.P -ge $Text.Length) { Stop-PsdParse $p 'this !if has no condition. Write one after it, like ''!if !<Stage> -eq "prod"''.' }
    $c = Read-PsdOr $p
    Skip-PsdSpace $p
    if ($p.P -lt $Text.Length) {
        Stop-PsdParse $p "this condition has $(Get-PsdCharText $p) where it should end. Join conditions with -and or -or." $p.P ($Text.Length - $p.P)
    }
    $c
}

# Operands joined by one word, like a -and b -and c. Next is the function that reads an operand.
function Read-PsdChain {
    param($P, [string] $Word, [string] $Type, [string] $Next)
    $l = & $Next $P
    while ($true) {
        Skip-PsdSpace $P
        if (-not (Test-PsdWordAt $P $Word)) { return $l }
        $P.P += $Word.Length
        $l = @{ T = $Type; L = $l; R = (& $Next $P) }
    }
}

function Read-PsdOr {
    param($P)
    Read-PsdChain $P '-or' 'Or' 'Read-PsdAnd'
}

function Read-PsdAnd {
    param($P)
    Read-PsdChain $P '-and' 'And' 'Read-PsdNot'
}

function Read-PsdNot {
    param($P)
    Skip-PsdSpace $P
    if (Test-PsdWordAt $P '-not') {
        $P.P += 4
        $x = Read-PsdNot $P
        return @{ T = 'Not'; X = $x }
    }
    Read-PsdCompare $P
}

function Read-PsdCompare {
    param($P)
    Skip-PsdSpace $P
    if (Test-PsdAt $P '(') {
        $open = $P.P
        $P.P++
        $c = Read-PsdOr $P
        Skip-PsdSpace $P
        if (-not (Test-PsdAt $P ')')) { Stop-PsdParse $P 'this ( is never closed. Add a ) for it.' $open }
        $P.P++
        return $c
    }
    $start = $P.P
    $l = Read-PsdCondValue $P
    Skip-PsdSpace $P
    $m = $script:PsdRxCmpOp.Match($P.T, $P.P)
    if ($m.Success) {
        $op = $m.Value.ToLowerInvariant()
        $P.P += $m.Length
        $r = Read-PsdCondValue $P
        return @{ T = 'Cmp'; Op = $op; L = $l; R = $r; Start = $start; End = $P.P }
    }
    @{ T = 'Truth'; X = $l; Start = $start; End = $P.P }
}

function Read-PsdCondValue {
    param($P)
    Skip-PsdSpace $P
    $start = $P.P
    if (Test-PsdAt $P '!<') { return (Read-PsdRef $P) }
    if (Test-PsdAt $P '"') {
        $v = Read-PsdString $P
        return @{ T = 'Lit'; V = $v; Start = $start; End = $P.P }
    }
    $lit = Read-PsdLiteral $P
    if ($lit) { return $lit }
    $n = $script:PsdRxName.Match($P.T, $P.P)
    if ($n.Success) {
        Stop-PsdParse $P "'$($n.Value)' needs to be a reference here: !<$($n.Value)>. Text in a condition goes in double quotes." $P.P $n.Length
    }
    Stop-PsdParse $P "expected a value here, but found $(Get-PsdCharText $P). Use a reference like !<Stage>, ""text"", a number, true, false or null."
}

# --- Paths given to commands --------------------------------------------------

function ConvertFrom-PsdPathText {
    param([string] $Text)
    $p = New-PsdParser $Text
    try {
        $path = Read-PsdPath $p
        if ($p.P -lt $Text.Length) { Stop-PsdParse $p "unexpected $(Get-PsdCharText $p)." }
        if ($path.Env -or $path.Ns -or $path.Builtin) { Stop-PsdParse $p 'a path here is just keys and indexes, like Server.Port or Plugins[0].' 0 }
    }
    catch [System.FormatException] {
        throw "'$Text' isn't a valid path: $($_.Exception.Message)"
    }
    , $path.Segs
}

function Format-PsdKeyText {
    param([string] $Key)
    if ($script:PsdRxPlainKey.IsMatch($Key)) { return $Key }
    '"' + $Key.Replace('"', '""') + '"'
}

function Join-PsdPath {
    param([string] $Path, $Key)
    if ($Key -is [int]) { return "$Path[$Key]" }
    $k = Format-PsdKeyText ([string]$Key)
    if ($Path) { return "$Path.$k" }
    $k
}

function Format-PsdSegs {
    param($Segs, [int] $Count = -1)
    if ($Count -lt 0) { $Count = $Segs.Count }
    $s = ''
    for ($i = 0; $i -lt $Count; $i++) {
        $seg = $Segs[$i]
        if ($seg.IsIndex) { $s = Join-PsdPath $s ([int]$seg.Index) } else { $s = Join-PsdPath $s $seg.Key }
    }
    $s
}