classes/InputFileStack.ps1

class InputFileStack {
    [System.Collections.Generic.List[InputFileContext]]$AllContexts = [System.Collections.Generic.List[InputFileContext]]::new()
    [System.Collections.Generic.Stack[Object]]$Stack = [System.Collections.Generic.Stack[InputFileContext]]::new()
    [System.Collections.Generic.HashSet[string]]$IncludeOnceFiles = [System.Collections.Generic.HashSet[string]]::new()

    [bool] IsRootedPath([string]$path) {
        # Doing this with native PS and/or .net methods is surprisingly hard.
        # So we use a regex to cover common absolute path formats on Windows and Unix.
        # Path may still contain relative components like .. or . after this check.
        # Regex explanation:
        # ^ / $ - anchors whole string.
        # /.* - Unix absolute paths.
        # [A-Za-z]:[\\/].* - Windows drive letter C:\... or D:/....
        # \\\\\?\\.* and \\\\\.\\.* - \\?\... and \\.\... extended/device forms.
        # \\\\[^\\\/]+[\\/][^\\\/]+.* - UNC root \\server\share\....
        # [^\\\/:\s]+:[\\/]?.* - PowerShell/PSDrive and provider-style drive names: one-or-more chars that are not backslash/slash/colon/space, then :, optionally a \ or /, then anything. This covers Env:, Env:\Path, HKLM:\Software, MyDrive:\folder.
        return $path -match '^(?:/.*|[A-Za-z]:[\\/].*|\\\\\?\\.*|\\\\\.\\.*|\\\\[^\\\/]+[\\/][^\\\/]+.*|[^\\\/:\s]+:[\\/]?.*)$'
    }

    [void] PushFile([string]$path) {
        # Resolve the path to a full path
        $resolved = $this.ResolvePath($path)

        # Detect circular include
        $stackArray = $this.Stack.ToArray()
        [Array]::Reverse($stackArray)  # root -> current

        $firstOccurrence = $null
        $parentFile = $null

        for ($j = 0; $j -lt $stackArray.Count; $j++) {
            if ($stackArray[$j].FilePath -eq $resolved) {
                $firstOccurrence = $stackArray[$j].FilePath
                $parentFile = if ($j -eq 0) { $null } else { $stackArray[$j - 1].FilePath }
                break
            }
        }

        if ($firstOccurrence) {
            $parentMsg = if ($parentFile) { " was already included at '$parentFile'" } else { " was already included" }
            $chain = ($stackArray | ForEach-Object FilePath) + $resolved -join " -> "
            throw "Circular include detected: '$resolved'$parentMsg. Chain: $chain"
        }

        # Check for include-once
        if ($this.IncludeOnceFiles.Contains($resolved)) {
            return  # silently skip re-inclusion
        }

        $ctx = [InputFileContext]::new($resolved)
        $this.AllContexts.Add($ctx)
        $this.Stack.Push($ctx)
    }


    [void] PushVirtualFile([string]$filename, [string]$content) {
        $ctx = [InputFileContext]::new($filename, $content)
        $this.AllContexts.Add($ctx)
        $this.Stack.Push($ctx)
    }

    [void] PopFile() {
        if ($this.Stack.Count -gt 0) {
            $ctx = $this.Stack.Pop()
            $ctx.Close()
        }
    }

    [char] PeekChar() {
        while ($this.Stack.Count -gt 0) {
            $ctx = $this.Stack.Peek()
            $char = $ctx.PeekChar()
            if ($char -ne 0) { return $char }
            else { $this.PopFile() }
        }
        return 0
    }

    [char] ReadChar() {
        while ($this.Stack.Count -gt 0) {
            $ctx = $this.Stack.Peek()
            $char = $ctx.ReadChar()
            if ($char -ne 0) { return $char }
            else { $this.PopFile() }
        }
        return 0
    }

    [void] UnReadChar() {
        if ($this.Stack.Count -gt 0) {
            $ctx = $this.Stack.Peek()
            $ctx.UnReadChar()
        }
    }

    [object] CurrentContext() {
        if ($this.Stack.Count -eq 0) { return $null }
        $ctx = $this.Stack.Peek()
        return @{ File=$ctx.FilePath; Line=$ctx.Line; Column=$ctx.Column }
    }

    [void] AddIncludeDir([string]$path) {
        if ($this.Stack.Count -eq 0) { throw "No active file context" }
        $this.Stack.Peek().AddIncludeDir($path)
    }

    [string] ResolvePath([string]$path) {
        if ($this.IsRootedPath($path) -or $this.Stack.Count -eq 0) {
            # To support PSDrive and provider paths, we use ProviderPath here
            return (Resolve-Path $path).ProviderPath
        }

        if ($path -notmatch '^(?:\.\.[\\/]|\.?[\\/]).*') {
            # We only search IncludeDirs for non-relative includes.
            $ctx = $this.Stack.Peek()
            foreach ($dir in $ctx.IncludeDirs) {
                if ($resolved = Join-Path -Path $dir -ChildPath $path -Resolve -ErrorAction SilentlyContinue) {
                    return $resolved
                }
            }
        }

        return (Resolve-Path $path).Path

        # throw "Include file not found: $path"
    }

    [void] MarkCurrentFileIncludeOnce() {
        if ($this.Stack.Count -eq 0) { return }
        $ctx = $this.Stack.Peek()
        [void]$this.IncludeOnceFiles.Add($ctx.FilePath)
    }

    [void] Dispose() {
        while ($this.Stack.Count -gt 0) {
            $this.PopFile()
        }
    }
}