Private/Dotenv.ps1

using namespace System.Collections.Specialized
using namespace System.Text

# .env file parser (the format understood by godotenv, which go-task uses).

[NoRunspaceAffinity()]
class TaskDotenv {
    static [OrderedDictionary] Read([string] $path) {
        return [TaskDotenv]::Parse([System.IO.File]::ReadAllText($path))
    }

    hidden static [string] Expand([string] $value, [OrderedDictionary] $known) {
        return [regex]::Replace($value, '(\\)?\$(?:\{([A-Za-z0-9_]+)\}|([A-Za-z0-9_]+))', {
                param($match)
                if ($match.Groups[1].Success) { return $match.Value.Substring(1) }
                $name = if ($match.Groups[2].Success) { $match.Groups[2].Value } else { $match.Groups[3].Value }
                if ($known.Contains($name)) { return [string]$known[$name] }
                $fromEnvironment = [Environment]::GetEnvironmentVariable($name)
                if ($null -ne $fromEnvironment) { return $fromEnvironment }
                return ''
            }.GetNewClosure())
    }

    static [OrderedDictionary] Parse([string] $text) {
        $result = [OrderedDictionary]::new()
        $source = $text.Replace("`r`n", "`n").Replace("`r", "`n")
        $length = $source.Length
        $i = 0
        while ($i -lt $length) {
            while ($i -lt $length -and [char]::IsWhiteSpace($source[$i])) { $i++ }
            if ($i -ge $length) { break }
            $lineEnd = $source.IndexOf("`n", $i)
            if ($lineEnd -lt 0) { $lineEnd = $length }
            if ($source[$i] -eq '#') { $i = $lineEnd; continue }

            $line = $source.Substring($i, $lineEnd - $i)
            $match = [regex]::Match($line, '^(?:export\s+)?([A-Za-z_][A-Za-z0-9_.\-]*)[ \t]*[=:][ \t]*')
            if (-not $match.Success) { throw "unexpected character in variable name near `"$line`"" }
            $key = $match.Groups[1].Value
            $position = $i + $match.Length
            $value = ''

            if ($position -ge $lineEnd) {
                $i = $lineEnd
            }
            elseif ($source[$position] -eq "'") {
                $close = $source.IndexOf("'", $position + 1)
                if ($close -lt 0) { throw "unterminated quoted value for `"$key`"" }
                $value = $source.Substring($position + 1, $close - $position - 1)
                $i = $source.IndexOf("`n", $close)
                if ($i -lt 0) { $i = $length }
            }
            elseif ($source[$position] -eq '"') {
                $builder = [StringBuilder]::new()
                $j = $position + 1
                $closed = $false
                while ($j -lt $length) {
                    $c = $source[$j]
                    if ($c -eq '"') { $closed = $true; break }
                    if ($c -eq '\' -and $j + 1 -lt $length) {
                        $next = $source[$j + 1]
                        if ($next -eq 'n') { [void]$builder.Append("`n") }
                        elseif ($next -eq 'r') { [void]$builder.Append("`r") }
                        elseif ($next -eq 't') { [void]$builder.Append("`t") }
                        elseif ($next -eq '$') { [void]$builder.Append('\$') }
                        else { [void]$builder.Append($next) }
                        $j += 2
                        continue
                    }
                    [void]$builder.Append($c)
                    $j++
                }
                if (-not $closed) { throw "unterminated quoted value for `"$key`"" }
                $value = [TaskDotenv]::Expand($builder.ToString(), $result)
                $i = $source.IndexOf("`n", $j)
                if ($i -lt 0) { $i = $length }
            }
            else {
                $raw = $source.Substring($position, $lineEnd - $position)
                $comment = [regex]::Match($raw, '\s#')
                if ($comment.Success) { $raw = $raw.Substring(0, $comment.Index) }
                $value = [TaskDotenv]::Expand($raw.Trim(), $result)
                $i = $lineEnd
            }
            $result[$key] = $value
        }
        return $result
    }
}