completers/gawk_completer/gawk_completer.ps1

Set-StrictMode -Version Latest

if (-not (Get-Variable -Name GawkCompletionCatalog -Scope Script -ErrorAction Ignore)) {
    $script:GawkCompletionCatalog = @{
        Initialized             = $false
        ProbedExecutable        = $false
        ExecutablePath          = $null
        OptionDefinitions       = @()
        CanonicalOptionMap      = @{}
        ShortOptionMap          = $null
        LongOptionMap           = @{}
        UniqueLongPrefixMap     = @{}
        MinimalLongAbbreviations = @{}
        LongSuggestions         = @()
        LoadExtensions          = @()
        LintValues              = @('fatal', 'invalid', 'no-ext')
        FieldSeparators         = @(
            @{ Text = ','; Tooltip = 'Comma-separated fields' }
            @{ Text = ':'; Tooltip = 'Colon-separated fields' }
            @{ Text = ';'; Tooltip = 'Semicolon-separated fields' }
            @{ Text = '|'; Tooltip = 'Pipe-separated fields' }
            @{ Text = '\t'; Tooltip = 'Tab character' }
            @{ Text = '[[:space:]]+'; Tooltip = 'Runs of whitespace' }
        )
        AssignmentSuggestions   = @(
            @{ Text = 'name='; Tooltip = 'Set an awk variable assignment' }
            @{ Text = 'FS='; Tooltip = 'Field separator' }
            @{ Text = 'OFS='; Tooltip = 'Output field separator' }
            @{ Text = 'RS='; Tooltip = 'Record separator' }
            @{ Text = 'ORS='; Tooltip = 'Output record separator' }
            @{ Text = 'IGNORECASE='; Tooltip = 'Case-insensitive matching' }
            @{ Text = 'BINMODE='; Tooltip = 'Binary/text file mode selection' }
            @{ Text = 'CONVFMT='; Tooltip = 'Numeric-to-string conversion format' }
            @{ Text = 'OFMT='; Tooltip = 'Default numeric output format' }
            @{ Text = 'FIELDWIDTHS='; Tooltip = 'Fixed-width field specification' }
            @{ Text = 'FPAT='; Tooltip = 'Regular expression describing field contents' }
            @{ Text = 'SUBSEP='; Tooltip = 'Subscript separator for multi-dimensional arrays' }
            @{ Text = 'LINT='; Tooltip = 'Lint mode: fatal, invalid, no-ext, or 1/0' }
            @{ Text = 'PREC='; Tooltip = 'Arbitrary-precision working precision in bits' }
            @{ Text = 'ROUNDMODE='; Tooltip = 'Arbitrary-precision rounding mode' }
            @{ Text = 'TEXTDOMAIN='; Tooltip = 'Text domain for gettext translations' }
            @{ Text = 'AWKPATH='; Tooltip = 'Search path for -f/-i source files' }
            @{ Text = 'AWKLIBPATH='; Tooltip = 'Search path for -l extension libraries' }
        )
        AssignmentValues        = @{
            'FS'         = 'FieldSeparators'
            'OFS'        = 'FieldSeparators'
            'RS'         = 'FieldSeparators'
            'ORS'        = 'FieldSeparators'
            'IGNORECASE' = @('0', '1')
            'BINMODE'    = @('0', '1', '2', '3', 'r', 'w', 'rw')
            'LINT'       = 'LintValues'
            'ROUNDMODE'  = @('N', 'U', 'D', 'Z', 'A')
            'AWKPATH'    = 'Path'
            'AWKLIBPATH' = 'Path'
        }
    }
}

function New-GawkCompletionResult {
    param(
        [string]$CompletionText,
        [string]$ListItemText = $CompletionText,
        [string]$ResultType = 'ParameterValue',
        [string]$ToolTip = $CompletionText
    )

    [System.Management.Automation.CompletionResult]::new(
        $CompletionText,
        $ListItemText,
        $ResultType,
        $ToolTip
    )
}

function Get-GawkExecutablePath {
    param([string]$CommandName = 'gawk')

    if ($script:GawkCompletionCatalog.ProbedExecutable) {
        return $script:GawkCompletionCatalog.ExecutablePath
    }

    $script:GawkCompletionCatalog.ProbedExecutable = $true

    $candidates = @()
    if (-not [string]::IsNullOrWhiteSpace($CommandName)) {
        $leafName = Split-Path -Leaf $CommandName
        if (-not [string]::IsNullOrWhiteSpace($leafName)) {
            $candidates += $leafName
        }
    }
    $candidates += @('gawk.exe', 'gawk', 'awk.exe', 'awk')

    foreach ($candidate in $candidates | Select-Object -Unique) {
        $command = Get-Command -Name $candidate -ErrorAction SilentlyContinue
        if ($command) {
            $script:GawkCompletionCatalog.ExecutablePath = $command.Source
            break
        }
    }

    $script:GawkCompletionCatalog.ExecutablePath
}

function Get-GawkHelpText {
    param([string]$CommandName = 'gawk')

    $executablePath = Get-GawkExecutablePath -CommandName $CommandName
    if ([string]::IsNullOrWhiteSpace($executablePath)) {
        return ''
    }

    try {
        (($null | & $executablePath --help 2>$null | ForEach-Object { $_ -replace '\e\[[0-9;?]*[ -/]*[@-~]', '' }) -join "`n")
    } catch {
        ''
    }
}

function Get-GawkHelpOptionTokens {
    param([string]$HelpText)

    if ([string]::IsNullOrWhiteSpace($HelpText)) {
        return @()
    }

    $tokenMatches = [regex]::Matches(
        $HelpText,
        '(?<!\w)(--[a-z][a-z\-]*)(?:\[[^\]]+\])?(?:=[^\s]+)?|(?<!\w)(-[A-Za-z])(?:\[[^\]]+\])?'
    )

    $seen = @{}
    $results = New-Object System.Collections.Generic.List[string]
    foreach ($match in $tokenMatches) {
        $token = if ($match.Groups[1].Success) {
            $match.Groups[1].Value
        } else {
            $match.Groups[2].Value
        }

        if ([string]::IsNullOrWhiteSpace($token)) {
            continue
        }

        $key = $token.ToLowerInvariant()
        if ($seen.ContainsKey($key)) {
            continue
        }

        $seen[$key] = $true
        [void]$results.Add($token)
    }

    @($results.ToArray())
}

function Get-GawkStaticOptionDefinitions {
    @(
        @{
            Short = '-f'; Long = '--file'; Canonical = '--file'
            Description = 'Read awk program source from file'
            ValueMode = 'Required'; ValueKind = 'SourceFile'
            ShortAllowsSeparate = $true; ShortAllowsAttached = $true
            LongAllowsSeparate = $true; LongAllowsEquals = $true
            ProvidesProgramSource = $true
        }
        @{
            Short = '-F'; Long = '--field-separator'; Canonical = '--field-separator'
            Description = 'Set FS to the given field separator'
            ValueMode = 'Required'; ValueKind = 'FieldSeparator'
            ShortAllowsSeparate = $true; ShortAllowsAttached = $true
            LongAllowsSeparate = $true; LongAllowsEquals = $true
        }
        @{
            Short = '-v'; Long = '--assign'; Canonical = '--assign'
            Description = 'Assign an awk variable before execution starts'
            ValueMode = 'Required'; ValueKind = 'Assignment'
            ShortAllowsSeparate = $true; ShortAllowsAttached = $true
            LongAllowsSeparate = $true; LongAllowsEquals = $true
        }
        @{
            Short = '-b'; Long = '--characters-as-bytes'; Canonical = '--characters-as-bytes'
            Description = 'Treat input and output data as single-byte characters'
            ValueMode = 'None'; ValueKind = 'None'
        }
        @{
            Short = '-c'; Long = '--traditional'; Canonical = '--traditional'
            Description = 'Disable GNU awk language extensions'
            ValueMode = 'None'; ValueKind = 'None'
        }
        @{
            Short = '-C'; Long = '--copyright'; Canonical = '--copyright'
            Description = 'Print copyright and license summary'
            ValueMode = 'None'; ValueKind = 'None'
        }
        @{
            Short = '-d'; Long = '--dump-variables'; Canonical = '--dump-variables'
            Description = 'Dump global variables to awkvars.out or the attached file path'
            ValueMode = 'Optional'; ValueKind = 'OutputFile'
            ShortAllowsSeparate = $false; ShortAllowsAttached = $true
            LongAllowsSeparate = $false; LongAllowsEquals = $true
        }
        @{
            Short = '-D'; Long = '--debug'; Canonical = '--debug'
            Description = 'Enable the debugger; optional attached file supplies debugger commands'
            ValueMode = 'Optional'; ValueKind = 'OutputFile'
            ShortAllowsSeparate = $false; ShortAllowsAttached = $true
            LongAllowsSeparate = $false; LongAllowsEquals = $true
        }
        @{
            Short = '-e'; Long = '--source'; Canonical = '--source'
            Description = 'Provide awk program source on the command line'
            ValueMode = 'Required'; ValueKind = 'ProgramText'
            ShortAllowsSeparate = $true; ShortAllowsAttached = $true
            LongAllowsSeparate = $true; LongAllowsEquals = $true
            ProvidesProgramSource = $true
        }
        @{
            Short = '-E'; Long = '--exec'; Canonical = '--exec'
            Description = 'Read awk program source from file and stop option parsing'
            ValueMode = 'Required'; ValueKind = 'SourceFile'
            ShortAllowsSeparate = $true; ShortAllowsAttached = $true
            LongAllowsSeparate = $true; LongAllowsEquals = $true
            ProvidesProgramSource = $true; TerminatesOptionParsing = $true; DisallowsAssignments = $true
        }
        @{
            Short = '-g'; Long = '--gen-pot'; Canonical = '--gen-pot'
            Description = 'Generate a gettext POT template from marked strings'
            ValueMode = 'None'; ValueKind = 'None'
        }
        @{
            Short = '-h'; Long = '--help'; Canonical = '--help'
            Description = 'Show help and exit'
            ValueMode = 'None'; ValueKind = 'None'
        }
        @{
            Short = '-i'; Long = '--include'; Canonical = '--include'
            Description = 'Read an awk source library file once'
            ValueMode = 'Required'; ValueKind = 'SourceFile'
            ShortAllowsSeparate = $true; ShortAllowsAttached = $true
            LongAllowsSeparate = $true; LongAllowsEquals = $true
        }
        @{
            Short = '-I'; Long = '--trace'; Canonical = '--trace'
            Description = 'Trace internal bytecode execution'
            ValueMode = 'None'; ValueKind = 'None'
        }
        @{
            Short = '-k'; Long = '--csv'; Canonical = '--csv'
            Description = 'Enable CSV processing mode'
            ValueMode = 'None'; ValueKind = 'None'
        }
        @{
            Short = '-l'; Long = '--load'; Canonical = '--load'
            Description = 'Load a dynamic extension by library name'
            ValueMode = 'Required'; ValueKind = 'LoadExtension'
            ShortAllowsSeparate = $true; ShortAllowsAttached = $true
            LongAllowsSeparate = $true; LongAllowsEquals = $true
        }
        @{
            Short = '-L'; Long = '--lint'; Canonical = '--lint'
            Description = 'Enable lint warnings, optionally with fatal, invalid, or no-ext'
            ValueMode = 'Optional'; ValueKind = 'Lint'
            ShortAllowsSeparate = $false; ShortAllowsAttached = $true
            LongAllowsSeparate = $false; LongAllowsEquals = $true
        }
        @{
            Short = '-M'; Long = '--bignum'; Canonical = '--bignum'
            Description = 'Enable arbitrary-precision arithmetic when available'
            ValueMode = 'None'; ValueKind = 'None'
        }
        @{
            Short = '-N'; Long = '--use-lc-numeric'; Canonical = '--use-lc-numeric'
            Description = 'Use the locale decimal point when parsing numeric input'
            ValueMode = 'None'; ValueKind = 'None'
        }
        @{
            Short = '-n'; Long = '--non-decimal-data'; Canonical = '--non-decimal-data'
            Description = 'Interpret octal and hexadecimal values in input data'
            ValueMode = 'None'; ValueKind = 'None'
        }
        @{
            Short = '-o'; Long = '--pretty-print'; Canonical = '--pretty-print'
            Description = 'Pretty-print the program to awkprof.out or the attached file path'
            ValueMode = 'Optional'; ValueKind = 'OutputFile'
            ShortAllowsSeparate = $false; ShortAllowsAttached = $true
            LongAllowsSeparate = $false; LongAllowsEquals = $true
        }
        @{
            Short = '-O'; Long = '--optimize'; Canonical = '--optimize'
            Description = 'Enable optimizer behavior'
            ValueMode = 'None'; ValueKind = 'None'
        }
        @{
            Short = '-p'; Long = '--profile'; Canonical = '--profile'
            Description = 'Write execution profile to awkprof.out or the attached file path'
            ValueMode = 'Optional'; ValueKind = 'OutputFile'
            ShortAllowsSeparate = $false; ShortAllowsAttached = $true
            LongAllowsSeparate = $false; LongAllowsEquals = $true
        }
        @{
            Short = '-P'; Long = '--posix'; Canonical = '--posix'
            Description = 'Operate in strict POSIX mode'
            ValueMode = 'None'; ValueKind = 'None'
        }
        @{
            Short = '-r'; Long = '--re-interval'; Canonical = '--re-interval'
            Description = 'Allow interval expressions in regular expressions'
            ValueMode = 'None'; ValueKind = 'None'
        }
        @{
            Short = '-s'; Long = '--no-optimize'; Canonical = '--no-optimize'
            Description = 'Disable optimizer behavior'
            ValueMode = 'None'; ValueKind = 'None'
        }
        @{
            Short = '-S'; Long = '--sandbox'; Canonical = '--sandbox'
            Description = 'Disable system access, redirections, and dynamic extensions'
            ValueMode = 'None'; ValueKind = 'None'
        }
        @{
            Short = '-t'; Long = '--lint-old'; Canonical = '--lint-old'
            Description = 'Warn about constructs missing from original Version 7 awk'
            ValueMode = 'None'; ValueKind = 'None'
        }
        @{
            Short = '-V'; Long = '--version'; Canonical = '--version'
            Description = 'Show version information and exit'
            ValueMode = 'None'; ValueKind = 'None'
        }
    )
}

function Get-GawkUniqueLongPrefixMaps {
    param([string[]]$LongOptions)

    $rawPrefixMap = @{}
    foreach ($longOption in @($LongOptions)) {
        if ([string]::IsNullOrWhiteSpace($longOption) -or -not $longOption.StartsWith('--')) {
            continue
        }

        $name = $longOption.Substring(2)
        for ($index = 1; $index -lt $name.Length; $index++) {
            $prefix = '--' + $name.Substring(0, $index)
            if (-not $rawPrefixMap.ContainsKey($prefix)) {
                $rawPrefixMap[$prefix] = New-Object System.Collections.Generic.List[string]
            }

            [void]$rawPrefixMap[$prefix].Add($longOption)
        }
    }

    $uniquePrefixes = @{}
    $minimalPrefixes = @{}
    foreach ($entry in $rawPrefixMap.GetEnumerator()) {
        $targets = @($entry.Value | Select-Object -Unique)
        if ($targets.Count -eq 1) {
            $uniquePrefixes[$entry.Key] = $targets[0]
        }
    }

    foreach ($longOption in @($LongOptions)) {
        $candidates = @(
            $uniquePrefixes.Keys |
                Where-Object { $uniquePrefixes[$_] -eq $longOption } |
                Sort-Object { $_.Length }
        )

        if ($candidates.Count -gt 0) {
            $minimalPrefixes[$longOption] = $candidates[0]
        }
    }

    @{
        UniquePrefixes = $uniquePrefixes
        MinimalPrefixes = $minimalPrefixes
    }
}

function Resolve-GawkRealExecutablePath {
    param([string]$ExecutablePath)

    if ([string]::IsNullOrWhiteSpace($ExecutablePath)) {
        return $ExecutablePath
    }

    # A scoop shim is a launcher next to a '<name>.shim' file whose 'path = "..."'
    # line names the real binary; a symlink carries its target directly.
    $shimFile = [System.IO.Path]::ChangeExtension($ExecutablePath, '.shim')
    if (Test-Path -LiteralPath $shimFile -PathType Leaf) {
        foreach ($line in @(Get-Content -LiteralPath $shimFile -ErrorAction SilentlyContinue)) {
            if ($line -match '^\s*path\s*=\s*"?([^"]+)"?\s*$') {
                $target = $Matches[1].Trim()
                if (Test-Path -LiteralPath $target -PathType Leaf) {
                    return $target
                }
            }
        }
    }

    $item = Get-Item -LiteralPath $ExecutablePath -ErrorAction SilentlyContinue
    if ($item -and $item.PSObject.Properties['Target'] -and -not [string]::IsNullOrWhiteSpace($item.Target)) {
        $target = [string]@($item.Target)[0]
        if (Test-Path -LiteralPath $target -PathType Leaf) {
            return $target
        }
    }

    $ExecutablePath
}

function Get-GawkDiscoveredLoadExtensions {
    param([string]$CommandName = 'gawk')

    $seededExtensions = @(
        'filefuncs',
        'fnmatch',
        'fork',
        'inplace',
        'intdiv',
        'ordchr',
        'readdir',
        'readfile',
        'revoutput',
        'revtwoway',
        'rwarray',
        'time'
    )

    $results = New-Object System.Collections.Generic.List[string]
    $seen = @{}

    foreach ($name in $seededExtensions) {
        $key = $name.ToLowerInvariant()
        if (-not $seen.ContainsKey($key)) {
            $seen[$key] = $true
            [void]$results.Add($name)
        }
    }

    $candidateDirectories = New-Object System.Collections.Generic.List[string]
    $libPath = [Environment]::GetEnvironmentVariable('AWKLIBPATH')
    if (-not [string]::IsNullOrWhiteSpace($libPath)) {
        foreach ($part in $libPath.Split([System.IO.Path]::PathSeparator)) {
            if (-not [string]::IsNullOrWhiteSpace($part) -and (Test-Path -LiteralPath $part)) {
                [void]$candidateDirectories.Add($part)
            }
        }
    }

    # gawk installs its extensions under <prefix>\lib\gawk (Git for Windows) or
    # <prefix>\lib\gawk\ext-<API> (scoop, MSYS2), where <prefix> is the parent of
    # the bin directory holding the real gawk.exe. The bin directory itself is
    # never scanned: it holds the msys/mingw runtime DLLs, not extensions.
    $executablePath = Resolve-GawkRealExecutablePath -ExecutablePath (Get-GawkExecutablePath -CommandName $CommandName)
    if (-not [string]::IsNullOrWhiteSpace($executablePath)) {
        $exeDirectory = Split-Path -Parent $executablePath
        $prefixDirectory = Split-Path -Parent $exeDirectory
        $libRoots = @(
            (Join-Path -Path $exeDirectory -ChildPath 'lib\gawk')
            if (-not [string]::IsNullOrWhiteSpace($prefixDirectory)) { Join-Path -Path $prefixDirectory -ChildPath 'lib\gawk' }
        )
        foreach ($libRoot in $libRoots) {
            if (-not (Test-Path -LiteralPath $libRoot -PathType Container)) {
                continue
            }

            [void]$candidateDirectories.Add($libRoot)
            foreach ($apiDirectory in @(Get-ChildItem -LiteralPath $libRoot -Directory -Filter 'ext-*' -ErrorAction SilentlyContinue)) {
                [void]$candidateDirectories.Add($apiDirectory.FullName)
            }
        }
    }

    foreach ($directory in @($candidateDirectories | Select-Object -Unique)) {
        Get-ChildItem -LiteralPath $directory -File -ErrorAction SilentlyContinue |
            Where-Object { $_.Extension -in @('.dll', '.so', '.dylib', '.bundle') } |
            ForEach-Object {
                $name = [System.IO.Path]::GetFileNameWithoutExtension($_.Name)
                if ([string]::IsNullOrWhiteSpace($name)) {
                    return
                }

                # Runtime libraries (libgmp-10, msys-2.0, cygwin1) sit beside real
                # extensions in some layouts and are never loadable with --load.
                if ($name -match '^(lib|msys|cyg)') {
                    return
                }

                $key = $name.ToLowerInvariant()
                if ($seen.ContainsKey($key)) {
                    return
                }

                $seen[$key] = $true
                [void]$results.Add($name)
            }
    }

    @($results.ToArray() | Sort-Object)
}

function Initialize-GawkCompletionCatalog {
    param([string]$CommandName = 'gawk')

    if ($script:GawkCompletionCatalog.Initialized) {
        return
    }

    $definitions = @(Get-GawkStaticOptionDefinitions)
    $helpText = Get-GawkHelpText -CommandName $CommandName
    $helpTokens = @(Get-GawkHelpOptionTokens -HelpText $helpText)
    $helpTokenSet = @{}
    foreach ($token in $helpTokens) {
        $helpTokenSet[$token.ToLowerInvariant()] = $true
    }

    $availableDefinitions = @()
    foreach ($definition in $definitions) {
        $shortKey = $definition.Short.ToLowerInvariant()
        $longKey = $definition.Long.ToLowerInvariant()
        $isAvailable = if ($helpTokenSet.Count -eq 0) {
            $true
        } elseif ($helpTokenSet.ContainsKey($shortKey) -or $helpTokenSet.ContainsKey($longKey)) {
            $true
        } else {
            $false
        }

        if ($isAvailable) {
            $availableDefinitions += $definition
        }
    }

    if ($availableDefinitions.Count -eq 0) {
        $availableDefinitions = $definitions
    }

    $canonicalMap = @{}
    $shortMap = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal)
    $longMap = @{}
    foreach ($definition in $availableDefinitions) {
        $canonicalMap[$definition.Canonical] = $definition
        $shortMap[$definition.Short] = $definition.Canonical
        $longMap[$definition.Long.ToLowerInvariant()] = $definition.Canonical
    }

    $prefixData = Get-GawkUniqueLongPrefixMaps -LongOptions ($availableDefinitions | ForEach-Object { $_.Long })

    $script:GawkCompletionCatalog.OptionDefinitions = $availableDefinitions
    $script:GawkCompletionCatalog.CanonicalOptionMap = $canonicalMap
    $script:GawkCompletionCatalog.ShortOptionMap = $shortMap
    $script:GawkCompletionCatalog.LongOptionMap = $longMap
    $script:GawkCompletionCatalog.UniqueLongPrefixMap = $prefixData.UniquePrefixes
    $script:GawkCompletionCatalog.MinimalLongAbbreviations = $prefixData.MinimalPrefixes
    $script:GawkCompletionCatalog.LongSuggestions = @(
        foreach ($definition in $availableDefinitions) {
            [pscustomobject]@{
                CompletionText = $definition.Long
                Canonical = $definition.Canonical
                ToolTip = $definition.Description
                IsAbbreviation = $false
            }

            if ($prefixData.MinimalPrefixes.ContainsKey($definition.Long)) {
                $abbreviation = $prefixData.MinimalPrefixes[$definition.Long]
                if (-not [string]::IsNullOrWhiteSpace($abbreviation) -and ($abbreviation -ne $definition.Long)) {
                    [pscustomobject]@{
                        CompletionText = $abbreviation
                        Canonical = $definition.Canonical
                        ToolTip = 'Unique abbreviation for {0}' -f $definition.Long
                        IsAbbreviation = $true
                    }
                }
            }
        }
    )
    $script:GawkCompletionCatalog.LoadExtensions = Get-GawkDiscoveredLoadExtensions -CommandName $CommandName
    $script:GawkCompletionCatalog.Initialized = $true
}

function Resolve-GawkLongOption {
    param([string]$Token)

    if ([string]::IsNullOrWhiteSpace($Token)) {
        return $null
    }

    $normalized = $Token.ToLowerInvariant()
    if ($script:GawkCompletionCatalog.LongOptionMap.ContainsKey($normalized)) {
        return $script:GawkCompletionCatalog.LongOptionMap[$normalized]
    }

    if ($script:GawkCompletionCatalog.UniqueLongPrefixMap.ContainsKey($normalized)) {
        $targetLong = $script:GawkCompletionCatalog.UniqueLongPrefixMap[$normalized]
        return $script:GawkCompletionCatalog.LongOptionMap[$targetLong.ToLowerInvariant()]
    }

    $null
}

function Get-GawkOptionDefinition {
    param([string]$CanonicalOption)

    if ([string]::IsNullOrWhiteSpace($CanonicalOption)) {
        return $null
    }

    if ($script:GawkCompletionCatalog.CanonicalOptionMap.ContainsKey($CanonicalOption)) {
        return $script:GawkCompletionCatalog.CanonicalOptionMap[$CanonicalOption]
    }

    $null
}

function New-GawkParseState {
    @{
        EndOfOptions = $false
        ProgramSourceProvided = $false
        PendingSeparateOption = $null
        AssignmentsAllowed = $true
    }
}

function Test-GawkAssignmentToken {
    param([string]$Token)

    if ([string]::IsNullOrWhiteSpace($Token)) {
        return $false
    }

    $trimmed = $Token.Trim('"', "'")
    $trimmed -match '^[A-Za-z_][A-Za-z0-9_]*='
}

function Update-GawkStateFromValue {
    param(
        [hashtable]$State,
        [hashtable]$Definition,
        [string]$Value
    )

    if ($null -eq $Definition) {
        return
    }

    if ($Definition.ContainsKey('ProvidesProgramSource') -and $Definition.ProvidesProgramSource) {
        $State.ProgramSourceProvided = $true
    }

    if ($Definition.ContainsKey('TerminatesOptionParsing') -and $Definition.TerminatesOptionParsing) {
        $State.EndOfOptions = $true
    }

    if ($Definition.ContainsKey('DisallowsAssignments') -and $Definition.DisallowsAssignments) {
        $State.AssignmentsAllowed = $false
    }
}

function Update-GawkStateFromOption {
    param(
        [hashtable]$State,
        [hashtable]$Definition
    )

    if ($null -eq $Definition) {
        return
    }

    if ($Definition.ValueMode -eq 'None') {
        if ($Definition.ContainsKey('TerminatesOptionParsing') -and $Definition.TerminatesOptionParsing) {
            $State.EndOfOptions = $true
        }
        if ($Definition.ContainsKey('DisallowsAssignments') -and $Definition.DisallowsAssignments) {
            $State.AssignmentsAllowed = $false
        }
    }
}

function Parse-GawkShortToken {
    param(
        [string]$Token,
        [hashtable]$State
    )

    if ([string]::IsNullOrWhiteSpace($Token) -or ($Token.Length -lt 2)) {
        return
    }

    $offset = 1
    while ($offset -lt $Token.Length) {
        $shortToken = '-' + $Token[$offset]
        if (-not $script:GawkCompletionCatalog.ShortOptionMap.ContainsKey($shortToken)) {
            break
        }

        $canonical = $script:GawkCompletionCatalog.ShortOptionMap[$shortToken]
        $definition = Get-GawkOptionDefinition -CanonicalOption $canonical
        if ($null -eq $definition) {
            break
        }

        $remaining = if ($offset + 1 -lt $Token.Length) {
            $Token.Substring($offset + 1)
        } else {
            ''
        }

        switch ($definition.ValueMode) {
            'None' {
                Update-GawkStateFromOption -State $State -Definition $definition
                $offset += 1
                continue
            }
            'Required' {
                if (-not [string]::IsNullOrEmpty($remaining)) {
                    Update-GawkStateFromValue -State $State -Definition $definition -Value $remaining
                } elseif ($definition.ShortAllowsSeparate) {
                    $State.PendingSeparateOption = $canonical
                }
                return
            }
            'Optional' {
                if (-not [string]::IsNullOrEmpty($remaining)) {
                    Update-GawkStateFromValue -State $State -Definition $definition -Value $remaining
                } else {
                    Update-GawkStateFromOption -State $State -Definition $definition
                }
                return
            }
            default {
                return
            }
        }
    }
}

function Update-GawkParseState {
    param(
        [string[]]$CompletedTokens
    )

    $state = New-GawkParseState

    foreach ($token in @($CompletedTokens)) {
        if ([string]::IsNullOrWhiteSpace($token)) {
            continue
        }

        if ($state.PendingSeparateOption) {
            $definition = Get-GawkOptionDefinition -CanonicalOption $state.PendingSeparateOption
            Update-GawkStateFromValue -State $state -Definition $definition -Value $token
            $state.PendingSeparateOption = $null
            continue
        }

        if (-not $state.EndOfOptions -and $token -eq '--') {
            $state.EndOfOptions = $true
            continue
        }

        if (-not $state.EndOfOptions) {
            if ($token.StartsWith('--')) {
                $equalsIndex = $token.IndexOf('=')
                $optionToken = if ($equalsIndex -ge 0) { $token.Substring(0, $equalsIndex) } else { $token }
                $canonical = Resolve-GawkLongOption -Token $optionToken
                if ($canonical) {
                    $definition = Get-GawkOptionDefinition -CanonicalOption $canonical
                    if ($definition.ValueMode -eq 'Required') {
                        if ($equalsIndex -ge 0) {
                            $valueText = $token.Substring($equalsIndex + 1)
                            Update-GawkStateFromValue -State $state -Definition $definition -Value $valueText
                        } elseif ($definition.LongAllowsSeparate) {
                            $state.PendingSeparateOption = $canonical
                        }
                    } elseif ($definition.ValueMode -eq 'Optional') {
                        if ($equalsIndex -ge 0) {
                            $valueText = $token.Substring($equalsIndex + 1)
                            Update-GawkStateFromValue -State $state -Definition $definition -Value $valueText
                        } else {
                            Update-GawkStateFromOption -State $state -Definition $definition
                        }
                    } else {
                        Update-GawkStateFromOption -State $state -Definition $definition
                    }

                    continue
                }

                # An unrecognised long option (typo, newer gawk) is still an
                # option, not the program text; leave the state untouched.
                continue
            } elseif ($token.StartsWith('-') -and ($token -ne '-')) {
                Parse-GawkShortToken -Token $token -State $state
                continue
            }
        }

        if (-not $state.ProgramSourceProvided) {
            $state.ProgramSourceProvided = $true
            $state.EndOfOptions = $true
            continue
        }

        if ($state.AssignmentsAllowed -and (Test-GawkAssignmentToken -Token $token)) {
            $state.EndOfOptions = $true
            continue
        }

        $state.EndOfOptions = $true
    }

    $state
}

function Get-GawkCurrentWord {
    param(
        [string]$WordToComplete
    )

    if ($null -eq $WordToComplete) {
        return ''
    }

    $WordToComplete
}

function Get-GawkPathCompletions {
    param(
        [string]$InputText,
        [string]$AttachedPrefix = '',
        [string[]]$PreferredExtensions = @()
    )

    $text = if ($null -eq $InputText) { '' } else { $InputText }
    $trimmedInput = $text.Trim('"')

    # $prefixText is the part of the typed word that precedes the leaf, kept
    # exactly as typed so completions extend the user's own text ('.\',
    # 'sub\', '..\', 'C:\') instead of pasting absolute paths.
    if ([string]::IsNullOrWhiteSpace($trimmedInput)) {
        $parent = '.'
        $leaf = ''
        $prefixText = ''
    } elseif ($trimmedInput -match '[\\/]$') {
        $parent = $trimmedInput
        $leaf = ''
        $prefixText = $trimmedInput
    } else {
        $parent = Split-Path -Path $trimmedInput -Parent
        if ([string]::IsNullOrWhiteSpace($parent)) {
            $parent = '.'
        }

        $leaf = Split-Path -Path $trimmedInput -Leaf
        $prefixText = $trimmedInput.Substring(0, $trimmedInput.Length - $leaf.Length)
    }

    $filter = if ([string]::IsNullOrWhiteSpace($leaf)) { '*' } else { "$leaf*" }
    $quoteResult = $text.StartsWith('"')

    $items = @(Get-ChildItem -Path $parent -Filter $filter -ErrorAction SilentlyContinue)
    $preferredMap = @{}
    foreach ($extension in @($PreferredExtensions)) {
        if ([string]::IsNullOrWhiteSpace($extension)) {
            continue
        }

        $preferredMap[$extension.ToLowerInvariant()] = $true
    }

    $sortedItems = @(
        $items | Sort-Object `
            @{ Expression = { -not $_.PSIsContainer } }, `
            @{ Expression = {
                    if ($_.PSIsContainer) {
                        0
                    } elseif ($preferredMap.ContainsKey($_.Extension.ToLowerInvariant())) {
                        0
                    } else {
                        1
                    }
                }
            }, `
            @{ Expression = { $_.Name } }
    )

    foreach ($item in $sortedItems) {
        $completionText = $prefixText + $item.Name

        if ($item.PSIsContainer -and -not $completionText.EndsWith([System.IO.Path]::DirectorySeparatorChar)) {
            $completionText += [System.IO.Path]::DirectorySeparatorChar
        }

        if (($quoteResult -or $completionText -match '\s') -and -not ($completionText.StartsWith('"') -and $completionText.EndsWith('"'))) {
            $completionText = '"' + $completionText + '"'
        }

        $tooltip = if ($item.PSIsContainer) {
            'Directory: {0}' -f $item.FullName
        } else {
            $item.FullName
        }

        New-GawkCompletionResult -CompletionText ($AttachedPrefix + $completionText) -ResultType 'ParameterValue' -ToolTip $tooltip
    }
}

function Get-GawkAssignmentCompletions {
    param(
        [string]$CurrentWord,
        [string]$AttachedPrefix = ''
    )

    $word = if ($null -eq $CurrentWord) { '' } else { $CurrentWord }
    $equalsIndex = $word.IndexOf('=')
    if ($equalsIndex -ge 0) {
        # 'NAME=' is complete; offer the values the catalog knows for that variable.
        $variableName = $word.Substring(0, $equalsIndex).Trim('"', "'").ToUpperInvariant()
        $valueText = $word.Substring($equalsIndex + 1)
        $valuePrefix = $AttachedPrefix + $word.Substring(0, $equalsIndex + 1)
        $catalogValues = $script:GawkCompletionCatalog.AssignmentValues
        if (-not $catalogValues.ContainsKey($variableName)) {
            return @()
        }

        $valueSource = $catalogValues[$variableName]
        if ($valueSource -is [string]) {
            if ($valueSource -eq 'Path') {
                return @(Get-GawkPathCompletions -InputText $valueText -AttachedPrefix $valuePrefix)
            }

            $valueSource = $script:GawkCompletionCatalog[$valueSource]
        }

        return @(Get-GawkSimpleValueCompletions -Values $valueSource -CurrentWord $valueText -AttachedPrefix $valuePrefix)
    }

    $results = New-Object System.Collections.Generic.List[System.Management.Automation.CompletionResult]

    if ($word -match '^[A-Za-z_][A-Za-z0-9_]*$') {
        $toolTip = 'Assign awk variable {0}' -f $word
        [void]$results.Add(
            (New-GawkCompletionResult -CompletionText ($AttachedPrefix + $word + '=') -ResultType 'ParameterValue' -ToolTip $toolTip)
        )
    }

    foreach ($suggestion in $script:GawkCompletionCatalog.AssignmentSuggestions) {
        $completionText = $AttachedPrefix + $suggestion.Text
        if ([string]::IsNullOrWhiteSpace($word) -or $completionText.StartsWith($AttachedPrefix + $word, [System.StringComparison]::OrdinalIgnoreCase)) {
            [void]$results.Add(
                (New-GawkCompletionResult -CompletionText $completionText -ResultType 'ParameterValue' -ToolTip $suggestion.Tooltip)
            )
        }
    }

    @($results.ToArray() | Sort-Object CompletionText -Unique)
}

function Get-GawkSimpleValueCompletions {
    param(
        [object[]]$Values,
        [string]$CurrentWord,
        [string]$AttachedPrefix = ''
    )

    $word = if ($null -eq $CurrentWord) { '' } else { $CurrentWord }

    foreach ($value in @($Values)) {
        $text = if ($value -is [string]) { $value } else { $value.Text }
        $toolTip = if ($value -is [string]) { $value } else { $value.Tooltip }

        if (($AttachedPrefix + $text).StartsWith($AttachedPrefix + $word, [System.StringComparison]::OrdinalIgnoreCase)) {
            New-GawkCompletionResult -CompletionText ($AttachedPrefix + $text) -ResultType 'ParameterValue' -ToolTip $toolTip
        }
    }
}

function Get-GawkValueCompletions {
    param(
        [hashtable]$Definition,
        [string]$CurrentWord,
        [string]$AttachedPrefix = ''
    )

    if ($null -eq $Definition) {
        return @()
    }

    switch ($Definition.ValueKind) {
        'SourceFile' {
            return @(Get-GawkPathCompletions -InputText $CurrentWord -AttachedPrefix $AttachedPrefix -PreferredExtensions @('.awk', '.gawk', '.inc'))
        }
        'OutputFile' {
            return @(Get-GawkPathCompletions -InputText $CurrentWord -AttachedPrefix $AttachedPrefix)
        }
        'LoadExtension' {
            return @(Get-GawkSimpleValueCompletions -Values $script:GawkCompletionCatalog.LoadExtensions -CurrentWord $CurrentWord -AttachedPrefix $AttachedPrefix)
        }
        'Lint' {
            return @(Get-GawkSimpleValueCompletions -Values $script:GawkCompletionCatalog.LintValues -CurrentWord $CurrentWord -AttachedPrefix $AttachedPrefix)
        }
        'FieldSeparator' {
            return @(Get-GawkSimpleValueCompletions -Values $script:GawkCompletionCatalog.FieldSeparators -CurrentWord $CurrentWord -AttachedPrefix $AttachedPrefix)
        }
        'Assignment' {
            return @(Get-GawkAssignmentCompletions -CurrentWord $CurrentWord -AttachedPrefix $AttachedPrefix)
        }
        default {
            return @()
        }
    }
}

function Get-GawkOptionCompletions {
    param([string]$CurrentWord)

    $word = if ($null -eq $CurrentWord) { '' } else { $CurrentWord }
    $results = New-Object System.Collections.Generic.List[System.Management.Automation.CompletionResult]
    # Short options are case-distinct (-f/-F, -d/-D ...), so dedupe ordinally.
    $seen = [System.Collections.Generic.Dictionary[string, bool]]::new([System.StringComparer]::Ordinal)

    if ([string]::IsNullOrWhiteSpace($word) -or '--'.StartsWith($word, [System.StringComparison]::OrdinalIgnoreCase)) {
        $key = '--'
        if (-not $seen.ContainsKey($key)) {
            $seen[$key] = $true
            [void]$results.Add(
                (New-GawkCompletionResult -CompletionText '--' -ResultType 'ParameterName' -ToolTip 'End option parsing')
            )
        }
    }

    foreach ($definition in $script:GawkCompletionCatalog.OptionDefinitions) {
        foreach ($candidate in @(
            [pscustomobject]@{ Text = $definition.Short; ToolTip = $definition.Description },
            [pscustomobject]@{ Text = $definition.Long; ToolTip = $definition.Description }
        )) {
            if ($candidate.Text.StartsWith($word, [System.StringComparison]::OrdinalIgnoreCase)) {
                $key = $candidate.Text
                if ($seen.ContainsKey($key)) {
                    continue
                }

                $seen[$key] = $true
                [void]$results.Add(
                    (New-GawkCompletionResult -CompletionText $candidate.Text -ResultType 'ParameterName' -ToolTip $candidate.ToolTip)
                )
            }
        }
    }

    foreach ($candidate in $script:GawkCompletionCatalog.LongSuggestions) {
        if (-not $candidate.IsAbbreviation) {
            continue
        }

        if ($candidate.CompletionText.StartsWith($word, [System.StringComparison]::OrdinalIgnoreCase)) {
            $key = $candidate.CompletionText
            if ($seen.ContainsKey($key)) {
                continue
            }

            $seen[$key] = $true
            [void]$results.Add(
                (New-GawkCompletionResult -CompletionText $candidate.CompletionText -ResultType 'ParameterName' -ToolTip $candidate.ToolTip)
            )
        }
    }

    @($results.ToArray())
}

function Get-GawkPositionalCompletions {
    param(
        [hashtable]$State,
        [string]$CurrentWord
    )

    if (-not $State.ProgramSourceProvided) {
        return @()
    }

    if ($State.AssignmentsAllowed -and -not [string]::IsNullOrWhiteSpace($CurrentWord) -and ($CurrentWord -match '^[A-Za-z_][A-Za-z0-9_]*$')) {
        return @(Get-GawkAssignmentCompletions -CurrentWord $CurrentWord)
    }

    if ($State.AssignmentsAllowed -and [string]::IsNullOrWhiteSpace($CurrentWord)) {
        return @(
            (Get-GawkAssignmentCompletions -CurrentWord $CurrentWord) +
            (Get-GawkPathCompletions -InputText $CurrentWord)
        )
    }

    if ($State.AssignmentsAllowed -and (Test-GawkAssignmentToken -Token $CurrentWord)) {
        return @(Get-GawkAssignmentCompletions -CurrentWord $CurrentWord)
    }

    @(Get-GawkPathCompletions -InputText $CurrentWord)
}

function Complete-GawkNative {
    param(
        [string]$WordToComplete,
        [System.Management.Automation.Language.CommandAst]$CommandAst,
        [int]$CursorPosition
    )

    # Tokenise the raw command text up to the cursor instead of using
    # CommandElements: the parser drops a bare comma ('-F,'), and text after
    # the cursor must not influence the parse state.
    $commandText = $CommandAst.Extent.Text
    $relativeCursor = [Math]::Max(0, [Math]::Min($CursorPosition - $CommandAst.Extent.StartOffset, $commandText.Length))
    $prefixText = $commandText.Substring(0, $relativeCursor)
    [object[]]$rawTokens = @([regex]::Matches($prefixText, '(?:"[^"]*"?|''[^'']*''?|[^\s"'']+)+') | ForEach-Object { $_.Value })
    if ($rawTokens.Count -eq 0) {
        return
    }

    Initialize-GawkCompletionCatalog -CommandName $rawTokens[0]

    $cursorAfterWhitespace = ($CursorPosition -gt $CommandAst.Extent.EndOffset) -or ($prefixText -match '\s$')
    $currentWord = if ($cursorAfterWhitespace) { '' } else { [string]$rawTokens[-1] }
    if (-not $cursorAfterWhitespace -and $rawTokens.Count -eq 1) {
        $currentWord = Get-GawkCurrentWord -WordToComplete $WordToComplete
    }

    $completedCount = if ($cursorAfterWhitespace) { $rawTokens.Count - 1 } else { $rawTokens.Count - 2 }
    [object[]]$completedTokens = if ($completedCount -gt 0) {
        @($rawTokens | Select-Object -Skip 1 -First $completedCount)
    } else {
        @()
    }

    $state = Update-GawkParseState -CompletedTokens $completedTokens

    if ($state.PendingSeparateOption) {
        $definition = Get-GawkOptionDefinition -CanonicalOption $state.PendingSeparateOption
        return @(Get-GawkValueCompletions -Definition $definition -CurrentWord $currentWord)
    }

    if (-not $state.EndOfOptions -and $currentWord.StartsWith('--')) {
        $equalsIndex = $currentWord.IndexOf('=')
        if ($equalsIndex -ge 0) {
            $left = $currentWord.Substring(0, $equalsIndex)
            $right = $currentWord.Substring($equalsIndex + 1)
            $canonical = Resolve-GawkLongOption -Token $left
            if ($canonical) {
                $definition = Get-GawkOptionDefinition -CanonicalOption $canonical
                if (($definition.ValueMode -eq 'Required' -and $definition.LongAllowsEquals) -or
                    ($definition.ValueMode -eq 'Optional' -and $definition.LongAllowsEquals)) {
                    return @(Get-GawkValueCompletions -Definition $definition -CurrentWord $right -AttachedPrefix ($left + '='))
                }
            }
        }

        return @(Get-GawkOptionCompletions -CurrentWord $currentWord)
    }

    if (-not $state.EndOfOptions -and $currentWord.StartsWith('-') -and ($currentWord -ne '-')) {
        if ($currentWord.Length -ge 2) {
            $shortToken = $currentWord.Substring(0, 2)
            if ($script:GawkCompletionCatalog.ShortOptionMap.ContainsKey($shortToken)) {
                $canonical = $script:GawkCompletionCatalog.ShortOptionMap[$shortToken]
                $definition = Get-GawkOptionDefinition -CanonicalOption $canonical
                if ($definition.ValueMode -eq 'Required' -or $definition.ValueMode -eq 'Optional') {
                    $attachedValue = if ($currentWord.Length -gt 2) { $currentWord.Substring(2) } else { '' }
                    return @(Get-GawkValueCompletions -Definition $definition -CurrentWord $attachedValue -AttachedPrefix $currentWord.Substring(0, 2))
                }
            }
        }

        return @(Get-GawkOptionCompletions -CurrentWord $currentWord)
    }

    if (-not $state.EndOfOptions -and ($currentWord -eq '-')) {
        return @(Get-GawkOptionCompletions -CurrentWord $currentWord)
    }

    @(Get-GawkPositionalCompletions -State $state -CurrentWord $currentWord)
}

Register-ArgumentCompleter -Native -CommandName @('gawk', 'gawk.exe', 'awk', 'awk.exe') -ScriptBlock {
    param($wordToComplete, $commandAst, $cursorPosition)

    Complete-GawkNative -WordToComplete $wordToComplete -CommandAst $commandAst -CursorPosition $cursorPosition
}