Private/ConvertFrom-EGIRuleString.ps1

function ConvertFrom-EGIRuleString {
    <#
    .SYNOPSIS
        Parses an Entra ID dynamic membership rule string into a logic tree.

    .DESCRIPTION
        Tokenizes and parses rules of the form used by dynamic group membership,
        e.g. '(user.department -eq "Sales") -and -not (user.jobTitle -startsWith "SDE")'.

        Operator precedence: this parser binds -not tightest, then -and, then -or
        (conventional boolean precedence). CAUTION: Microsoft's dynamic-membership
        documentation lists -or with HIGHER precedence than -and, the opposite of
        most languages. Because real-world rules almost always parenthesize, this
        parser emits a warning whenever -and and -or are mixed at the same
        parenthesization level instead of silently picking one interpretation.

        LIMITATION: the body of an -any(...) / -all(...) lambda (e.g.
        'user.proxyAddresses -any (_ -startsWith "SMTP:")') is treated as an opaque
        leaf expression rather than recursively parsed, because its internal syntax
        (the '_' placeholder) is not the same grammar as the outer rule. This is a
        deliberate simplification for a v0.1 prototype.

    .PARAMETER Rule
        The raw dynamic membership rule string.

    .OUTPUTS
        A nested [pscustomobject] tree with Type 'And' | 'Or' | 'Not' | 'Leaf'.

    .EXAMPLE
        ConvertFrom-EGIRuleString -Rule '(user.department -eq "Sales") -or (user.department -eq "Marketing")'
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [string] $Rule
    )

    # ---- Tokenizer -------------------------------------------------------
    # NOTE: state is kept in hashtables (reference types) and passed
    # explicitly into helper functions, rather than relying on PowerShell's
    # scope chain, so nested function calls reliably mutate shared state.
    $tokens = [System.Collections.Generic.List[pscustomobject]]::new()
    $atomState = @{ Buffer = '' }

    function Complete-EGIAtom {
        param($AtomState, $TokenList)
        if ($AtomState.Buffer.Trim().Length -gt 0) {
            $TokenList.Add([pscustomobject]@{ Type = 'Atom'; Value = $AtomState.Buffer.Trim() })
        }
        $AtomState.Buffer = ''
    }

    $i = 0
    $n = $Rule.Length

    while ($i -lt $n) {
        $rest = $Rule.Substring($i)

        if ($Rule[$i] -match '\s') {
            # Keep whitespace inside an atom ('user.department -eq "Sales"');
            # leading/trailing runs are trimmed when the atom completes.
            if ($atomState.Buffer.Length -gt 0) { $atomState.Buffer += $Rule[$i] }
            $i++
            continue
        }

        if ($Rule[$i] -eq '(') {
            # Is this an -any( / -all( lambda body? If the atom buffer just ended
            # with -any / -all, keep the parenthesized block as part of the atom.
            if ($atomState.Buffer.TrimEnd() -match '(?i)-(any|all)$') {
                $depth = 1
                $j = $i + 1
                while ($j -lt $n -and $depth -gt 0) {
                    $ch = $Rule[$j]
                    if ($ch -eq '"' -or $ch -eq "'") {
                        # Skip quoted sections so parens inside values don't affect depth.
                        $j++
                        while ($j -lt $n -and $Rule[$j] -ne $ch) { $j++ }
                    }
                    elseif ($ch -eq '(') { $depth++ }
                    elseif ($ch -eq ')') { $depth-- }
                    $j++
                }
                $j = [Math]::Min($j, $n)
                $atomState.Buffer += ' ' + $Rule.Substring($i, $j - $i)
                $i = $j
                continue
            }
            Complete-EGIAtom -AtomState $atomState -TokenList $tokens
            $tokens.Add([pscustomobject]@{ Type = 'LParen' })
            $i++
            continue
        }

        if ($Rule[$i] -eq ')') {
            Complete-EGIAtom -AtomState $atomState -TokenList $tokens
            $tokens.Add([pscustomobject]@{ Type = 'RParen' })
            $i++
            continue
        }

        if ($rest -match '(?i)^-and\b') {
            Complete-EGIAtom -AtomState $atomState -TokenList $tokens
            $tokens.Add([pscustomobject]@{ Type = 'And' })
            $i += 4
            continue
        }
        if ($rest -match '(?i)^-or\b') {
            Complete-EGIAtom -AtomState $atomState -TokenList $tokens
            $tokens.Add([pscustomobject]@{ Type = 'Or' })
            $i += 3
            continue
        }
        if ($rest -match '(?i)^-not\b') {
            Complete-EGIAtom -AtomState $atomState -TokenList $tokens
            $tokens.Add([pscustomobject]@{ Type = 'Not' })
            $i += 4
            continue
        }

        # Respect quoted strings so -and/-or text inside quotes is never split.
        if ($Rule[$i] -eq '"' -or $Rule[$i] -eq "'") {
            $quote = $Rule[$i]
            $j = $i + 1
            while ($j -lt $n -and $Rule[$j] -ne $quote) { $j++ }
            $j = [Math]::Min($j + 1, $n)
            $atomState.Buffer += $Rule.Substring($i, $j - $i)
            $i = $j
            continue
        }

        $atomState.Buffer += $Rule[$i]
        $i++
    }
    Complete-EGIAtom -AtomState $atomState -TokenList $tokens

    if ($tokens.Count -eq 0) {
        throw 'Empty rule string.'
    }

    # Warn when -and / -or are mixed at the same parenthesization level: this
    # parser binds -and tighter than -or, but Microsoft's documentation lists
    # -or above -and, so an unparenthesized mix is ambiguous.
    $levelOps = [System.Collections.Generic.HashSet[string]]::new()
    $opStack = [System.Collections.Generic.Stack[object]]::new()
    $mixed = $false
    foreach ($t in $tokens) {
        switch ($t.Type) {
            'LParen' { $opStack.Push($levelOps); $levelOps = [System.Collections.Generic.HashSet[string]]::new() }
            'RParen' { if ($opStack.Count -gt 0) { $levelOps = $opStack.Pop() } }
            'And' { [void]$levelOps.Add('And'); if ($levelOps.Count -gt 1) { $mixed = $true } }
            'Or' { [void]$levelOps.Add('Or'); if ($levelOps.Count -gt 1) { $mixed = $true } }
        }
    }
    if ($mixed) {
        Write-Warning ('Rule mixes -and and -or at the same parenthesization level. This parser evaluates -and before -or, ' +
            "but Microsoft's dynamic-membership documentation lists -or with higher precedence than -and. " +
            'Add explicit parentheses so the intended grouping is unambiguous.')
    }

    # ---- Recursive-descent parser -----------------------------------------
    # $parseState.Pos is mutated by reference (hashtable) so every helper
    # below sees the same cursor position without relying on scope chains.
    $parseState = @{ Pos = 0 }

    function Get-EGIToken {
        param($TokenList, $ParseState)
        if ($ParseState.Pos -lt $TokenList.Count) { return $TokenList[$ParseState.Pos] }
        return $null
    }

    function Step-EGIToken {
        param($TokenList, $ParseState)
        $t = Get-EGIToken -TokenList $TokenList -ParseState $ParseState
        $ParseState.Pos++
        return $t
    }

    function Read-EGIPrimary {
        param($TokenList, $ParseState)
        $t = Get-EGIToken -TokenList $TokenList -ParseState $ParseState
        if ($null -eq $t) { throw 'Unexpected end of rule while parsing an expression.' }
        if ($t.Type -eq 'LParen') {
            Step-EGIToken -TokenList $TokenList -ParseState $ParseState | Out-Null
            $node = Read-EGIOr -TokenList $TokenList -ParseState $ParseState
            $close = Step-EGIToken -TokenList $TokenList -ParseState $ParseState
            if ($null -eq $close -or $close.Type -ne 'RParen') {
                throw 'Missing closing parenthesis in rule.'
            }
            return $node
        }
        if ($t.Type -eq 'Atom') {
            Step-EGIToken -TokenList $TokenList -ParseState $ParseState | Out-Null
            return [pscustomobject]@{ Type = 'Leaf'; Expression = $t.Value }
        }
        throw "Unexpected token '$($t.Type)' while parsing an expression."
    }

    function Read-EGINot {
        param($TokenList, $ParseState)
        $peeked = Get-EGIToken -TokenList $TokenList -ParseState $ParseState
        if ($peeked -and $peeked.Type -eq 'Not') {
            Step-EGIToken -TokenList $TokenList -ParseState $ParseState | Out-Null
            return [pscustomobject]@{ Type = 'Not'; Child = (Read-EGINot -TokenList $TokenList -ParseState $ParseState) }
        }
        return Read-EGIPrimary -TokenList $TokenList -ParseState $ParseState
    }

    function Read-EGIAnd {
        param($TokenList, $ParseState)
        $left = Read-EGINot -TokenList $TokenList -ParseState $ParseState
        $children = [System.Collections.Generic.List[object]]::new()
        $children.Add($left)
        while (($p = Get-EGIToken -TokenList $TokenList -ParseState $ParseState) -and $p.Type -eq 'And') {
            Step-EGIToken -TokenList $TokenList -ParseState $ParseState | Out-Null
            $children.Add((Read-EGINot -TokenList $TokenList -ParseState $ParseState))
        }
        if ($children.Count -eq 1) { return $left }
        return [pscustomobject]@{ Type = 'And'; Children = $children }
    }

    function Read-EGIOr {
        param($TokenList, $ParseState)
        $left = Read-EGIAnd -TokenList $TokenList -ParseState $ParseState
        $children = [System.Collections.Generic.List[object]]::new()
        $children.Add($left)
        while (($p = Get-EGIToken -TokenList $TokenList -ParseState $ParseState) -and $p.Type -eq 'Or') {
            Step-EGIToken -TokenList $TokenList -ParseState $ParseState | Out-Null
            $children.Add((Read-EGIAnd -TokenList $TokenList -ParseState $ParseState))
        }
        if ($children.Count -eq 1) { return $left }
        return [pscustomobject]@{ Type = 'Or'; Children = $children }
    }

    $tree = Read-EGIOr -TokenList $tokens -ParseState $parseState
    if ($parseState.Pos -ne $tokens.Count) {
        throw "Unexpected trailing tokens after position $($parseState.Pos) - check for unbalanced parentheses."
    }
    return $tree
}