completers/groff_completer/groff_completer.ps1

Set-StrictMode -Version 2.0

if (-not (Get-Variable -Name GroffCompletionCatalog -Scope Script -ErrorAction Ignore)) {
    $script:GroffCompletionCatalog = @{
        Initialized        = $false
        ExecutablePath     = $null
        HelpText           = $null
        HelpOptionTokens   = @()
        OptionDefinitions  = @()
        ShortOptionMap     = @{}
        LongOptionMap      = @{}
        OptionSuggestions  = @()
        LongSuggestions    = @()
        OutputDevices      = @()
        MacroPackages      = @()
        WarningCategories  = @(
            'all', 'w', 'char', 'number', 'break', 'delim', 'el', 'scale', 'range', 'syntax',
            'di', 'mac', 'reg', 'tab', 'right-brace', 'missing', 'input', 'escape', 'space',
            'font', 'ig', 'color', 'file'
        )
        EncodingHints      = @(
            'utf8', 'utf-8', 'latin1', 'latin2', 'latin5', 'latin9', 'koi8-r', 'cp1047', 'ascii'
        )
        FontFamilyFallback = @('T', 'H', 'C', 'N', 'P', 'A', 'BM', 'HN', 'ZCM')
        FontFamiliesByDevice = @{}
        PlaceholderHints   = @{
            '-d' = @('name=text', 'foo=bar', 's=string')
            '-n' = @('1', '5', '10')
            '-o' = @('1', '1-3', '1,3-5,8-')
            '-r' = @('S=12', 'Pn=1', 'LL=72u')
            '-L' = @('<spooler-arg>')
            '-P' = @('<postprocessor-arg>')
        }
        AttachedPlaceholderHints = @{
            '-d' = @('name=text', 'foo=bar', 's=string')
            '-n' = @('1', '5', '10')
            '-o' = @('1', '1-3', '1,3-5,8-')
            '-r' = @('S12', 'Pn1', 'LL72u')
            '-L' = @('<spooler-arg>')
            '-P' = @('<postprocessor-arg>')
        }
    }
}

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

    if ([string]::IsNullOrWhiteSpace($ListItemText)) {
        $ListItemText = $CompletionText
    }

    if ([string]::IsNullOrWhiteSpace($ToolTip)) {
        $ToolTip = $CompletionText
    }

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

function New-GroffStringSet {
    return ,([System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase))
}

function New-GroffStringObjectMap {
    return ,([System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal))
}

function Get-GroffUniqueStrings {
    param([string[]]$Items)

    $seen = New-GroffStringSet
    $results = New-Object System.Collections.Generic.List[string]

    foreach ($item in @($Items)) {
        if ([string]::IsNullOrWhiteSpace($item)) {
            continue
        }

        if ($seen.Add($item)) {
            [void]$results.Add($item)
        }
    }

    @($results.ToArray())
}

function Get-GroffCurrentToken {
    param(
        [string]$Line,
        [int]$CursorPosition,
        [string]$Fallback = ''
    )

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

    $safeCursor = [Math]::Min([Math]::Max($CursorPosition, 0), $Line.Length)
    $prefix = $Line.Substring(0, $safeCursor)
    if ($prefix -match '\s$') {
        return ''
    }

    $tokenStart = 0
    $inSingleQuote = $false
    $inDoubleQuote = $false

    for ($index = 0; $index -lt $prefix.Length; $index++) {
        $character = $prefix[$index]
        if (($character -eq '`') -and $inDoubleQuote -and (($index + 1) -lt $prefix.Length)) {
            $index++
            continue
        }

        if (($character -eq "'") -and -not $inDoubleQuote) {
            if ($inSingleQuote -and (($index + 1) -lt $prefix.Length) -and ($prefix[$index + 1] -eq "'")) {
                $index++
                continue
            }

            $inSingleQuote = -not $inSingleQuote
            continue
        }

        if (($character -eq '"') -and -not $inSingleQuote) {
            $inDoubleQuote = -not $inDoubleQuote
            continue
        }

        if ([char]::IsWhiteSpace($character) -and -not $inSingleQuote -and -not $inDoubleQuote) {
            $tokenStart = $index + 1
        }
    }

    if ($tokenStart -lt $prefix.Length) {
        return $prefix.Substring($tokenStart)
    }

    $Fallback
}

function Get-GroffQuoteCharacter {
    param([string]$InputText)

    if ([string]::IsNullOrEmpty($InputText)) {
        return $null
    }

    if ($InputText.StartsWith("'", [System.StringComparison]::Ordinal)) {
        return "'"
    }

    if ($InputText.StartsWith('"', [System.StringComparison]::Ordinal)) {
        return '"'
    }

    $null
}

function Remove-GroffOuterQuotes {
    param([string]$InputText)

    if ([string]::IsNullOrEmpty($InputText)) {
        return ''
    }

    $quoteCharacter = Get-GroffQuoteCharacter -InputText $InputText
    if ($null -eq $quoteCharacter) {
        return $InputText
    }

    $unquoted = $InputText.Substring(1)
    if ($unquoted.EndsWith($quoteCharacter, [System.StringComparison]::Ordinal)) {
        $unquoted = $unquoted.Substring(0, $unquoted.Length - 1)
    }

    if ($quoteCharacter -eq "'") {
        return $unquoted.Replace("''", "'")
    }

    $unquoted.Replace('`"', '"')
}

function ConvertFrom-GroffTypedWord {
    # The value of a typed word. A word opened with a quote (ASCII or typographic) is read by
    # the PowerShell tokenizer, which drops the quotes and undoes that quote style's escapes.
    param([string]$Value)

    if ($Value -notmatch '^[''"\u2018-\u201E]') {
        return $Value
    }

    $tokens = $null
    $parseErrors = $null
    [void][System.Management.Automation.Language.Parser]::ParseInput($Value, [ref]$tokens, [ref]$parseErrors)
    $tokens[0].Value
}

function ConvertTo-GroffQuotedValue {
    # Renders a value as one PowerShell argument: bare when safe and no quote was typed,
    # otherwise in the quote the user typed (single by default). PowerShell reads ' and
    # U+2018-U+201B as single quotes and " and U+201C-U+201E as double quotes.
    param(
        [string]$Value,
        [string]$QuoteCharacter = ''
    )

    if ([string]::IsNullOrEmpty($Value)) {
        return $Value
    }

    if (-not $QuoteCharacter) {
        if ($Value -notmatch '[\s{}();,|&<>''"`$@#\u2018-\u201E]') {
            return $Value
        }

        $QuoteCharacter = "'"
    }

    if ($QuoteCharacter -match '^[''\u2018-\u201B]$') {
        return $QuoteCharacter + ($Value -replace '([''\u2018-\u201B])', '$1$1') + $QuoteCharacter
    }

    $QuoteCharacter + ($Value -replace '([`"$\u201C-\u201E])', '`$1') + $QuoteCharacter
}

function Get-GroffExecutablePath {
    if ($script:GroffCompletionCatalog.ExecutablePath) {
        return $script:GroffCompletionCatalog.ExecutablePath
    }

    foreach ($candidate in @('groff.exe', 'groff')) {
        $command = Get-Command -Name $candidate -ErrorAction Ignore
        if (-not $command) {
            continue
        }

        $script:GroffCompletionCatalog.ExecutablePath = if ($command.Path) {
            $command.Path
        } elseif ($command.Source) {
            $command.Source
        } else {
            $command.Name
        }

        if (-not [string]::IsNullOrWhiteSpace($script:GroffCompletionCatalog.ExecutablePath)) {
            return $script:GroffCompletionCatalog.ExecutablePath
        }
    }

    $null
}


function Get-GroffHelpText {
    if ($null -ne $script:GroffCompletionCatalog.HelpText) {
        return $script:GroffCompletionCatalog.HelpText
    }

    $executablePath = Get-GroffExecutablePath
    if ([string]::IsNullOrWhiteSpace($executablePath)) {
        $script:GroffCompletionCatalog.HelpText = ''
        return $script:GroffCompletionCatalog.HelpText
    }

    try {
        $script:GroffCompletionCatalog.HelpText = (($null | & $executablePath --help 2>&1 | ForEach-Object { $_ -replace '\e\[[0-9;?]*[ -/]*[@-~]', '' }) -join [Environment]::NewLine)
    } catch {
        $script:GroffCompletionCatalog.HelpText = ''
    }

    $script:GroffCompletionCatalog.HelpText
}

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

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

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

    Get-GroffUniqueStrings -Items @(
        foreach ($match in $matches) {
            if ($match.Groups[1].Success) {
                $match.Groups[1].Value
                continue
            }

            if ($match.Groups[2].Success) {
                $match.Groups[2].Value
            }
        }
    )
}

function Get-GroffStaticOptionDefinitions {
    @(
        [pscustomobject]@{ Short = '-a'; Long = $null; Canonical = '-a'; Description = 'Produce an ASCII description of the output'; ValueMode = 'None'; ValueKind = 'None'; ShortAllowsSeparate = $false; ShortAllowsAttached = $false; Terminal = $false }
        [pscustomobject]@{ Short = '-b'; Long = $null; Canonical = '-b'; Description = 'Print backtraces with errors or warnings'; ValueMode = 'None'; ValueKind = 'None'; ShortAllowsSeparate = $false; ShortAllowsAttached = $false; Terminal = $false }
        [pscustomobject]@{ Short = '-c'; Long = $null; Canonical = '-c'; Description = 'Disable color output'; ValueMode = 'None'; ValueKind = 'None'; ShortAllowsSeparate = $false; ShortAllowsAttached = $false; Terminal = $false }
        [pscustomobject]@{ Short = '-C'; Long = $null; Canonical = '-C'; Description = 'Enable compatibility mode'; ValueMode = 'None'; ValueKind = 'None'; ShortAllowsSeparate = $false; ShortAllowsAttached = $false; Terminal = $false }
        [pscustomobject]@{ Short = '-d'; Long = $null; Canonical = '-d'; Description = 'Define a string'; ValueMode = 'Required'; ValueKind = 'DefineString'; ShortAllowsSeparate = $true; ShortAllowsAttached = $true; Terminal = $false }
        [pscustomobject]@{ Short = '-D'; Long = $null; Canonical = '-D'; Description = 'Use the default input encoding'; ValueMode = 'Required'; ValueKind = 'Encoding'; ShortAllowsSeparate = $true; ShortAllowsAttached = $true; Terminal = $false }
        [pscustomobject]@{ Short = '-e'; Long = $null; Canonical = '-e'; Description = 'Preprocess with eqn'; ValueMode = 'None'; ValueKind = 'None'; ShortAllowsSeparate = $false; ShortAllowsAttached = $false; Terminal = $false }
        [pscustomobject]@{ Short = '-E'; Long = $null; Canonical = '-E'; Description = 'Inhibit all errors'; ValueMode = 'None'; ValueKind = 'None'; ShortAllowsSeparate = $false; ShortAllowsAttached = $false; Terminal = $false }
        [pscustomobject]@{ Short = '-f'; Long = $null; Canonical = '-f'; Description = 'Use the default font family'; ValueMode = 'Required'; ValueKind = 'FontFamily'; ShortAllowsSeparate = $true; ShortAllowsAttached = $true; Terminal = $false }
        [pscustomobject]@{ Short = '-F'; Long = $null; Canonical = '-F'; Description = 'Search a directory for device directories'; ValueMode = 'Required'; ValueKind = 'DirectoryPath'; ShortAllowsSeparate = $true; ShortAllowsAttached = $true; Terminal = $false }
        [pscustomobject]@{ Short = '-g'; Long = $null; Canonical = '-g'; Description = 'Preprocess with grn'; ValueMode = 'None'; ValueKind = 'None'; ShortAllowsSeparate = $false; ShortAllowsAttached = $false; Terminal = $false }
        [pscustomobject]@{ Short = '-G'; Long = $null; Canonical = '-G'; Description = 'Preprocess with grap'; ValueMode = 'None'; ValueKind = 'None'; ShortAllowsSeparate = $false; ShortAllowsAttached = $false; Terminal = $false }
        [pscustomobject]@{ Short = '-h'; Long = '--help'; Canonical = '--help'; Description = 'Show help and exit'; ValueMode = 'None'; ValueKind = 'None'; ShortAllowsSeparate = $false; ShortAllowsAttached = $false; Terminal = $true }
        [pscustomobject]@{ Short = '-i'; Long = $null; Canonical = '-i'; Description = 'Read standard input after named input files'; ValueMode = 'None'; ValueKind = 'None'; ShortAllowsSeparate = $false; ShortAllowsAttached = $false; Terminal = $false }
        [pscustomobject]@{ Short = '-I'; Long = $null; Canonical = '-I'; Description = 'Search a directory for include files and grops'; ValueMode = 'Required'; ValueKind = 'DirectoryPath'; ShortAllowsSeparate = $true; ShortAllowsAttached = $true; Terminal = $false }
        [pscustomobject]@{ Short = '-j'; Long = $null; Canonical = '-j'; Description = 'Preprocess with chem'; ValueMode = 'None'; ValueKind = 'None'; ShortAllowsSeparate = $false; ShortAllowsAttached = $false; Terminal = $false }
        [pscustomobject]@{ Short = '-J'; Long = $null; Canonical = '-J'; Description = 'Preprocess with gideal when available'; ValueMode = 'None'; ValueKind = 'None'; ShortAllowsSeparate = $false; ShortAllowsAttached = $false; Terminal = $false }
        [pscustomobject]@{ Short = '-k'; Long = $null; Canonical = '-k'; Description = 'Preprocess with preconv'; ValueMode = 'None'; ValueKind = 'None'; ShortAllowsSeparate = $false; ShortAllowsAttached = $false; Terminal = $false }
        [pscustomobject]@{ Short = '-K'; Long = $null; Canonical = '-K'; Description = 'Use the input encoding'; ValueMode = 'Required'; ValueKind = 'Encoding'; ShortAllowsSeparate = $true; ShortAllowsAttached = $true; Terminal = $false }
        [pscustomobject]@{ Short = '-l'; Long = $null; Canonical = '-l'; Description = 'Spool the output'; ValueMode = 'None'; ValueKind = 'None'; ShortAllowsSeparate = $false; ShortAllowsAttached = $false; Terminal = $false }
        [pscustomobject]@{ Short = '-L'; Long = $null; Canonical = '-L'; Description = 'Pass an argument to the spooler'; ValueMode = 'Required'; ValueKind = 'OpaqueValue'; ShortAllowsSeparate = $true; ShortAllowsAttached = $true; Terminal = $false }
        [pscustomobject]@{ Short = '-m'; Long = $null; Canonical = '-m'; Description = 'Read a macro package'; ValueMode = 'Required'; ValueKind = 'MacroPackage'; ShortAllowsSeparate = $true; ShortAllowsAttached = $true; Terminal = $false }
        [pscustomobject]@{ Short = '-M'; Long = $null; Canonical = '-M'; Description = 'Search a directory for macro files'; ValueMode = 'Required'; ValueKind = 'DirectoryPath'; ShortAllowsSeparate = $true; ShortAllowsAttached = $true; Terminal = $false }
        [pscustomobject]@{ Short = '-n'; Long = $null; Canonical = '-n'; Description = 'Set the first page number'; ValueMode = 'Required'; ValueKind = 'OpaqueValue'; ShortAllowsSeparate = $true; ShortAllowsAttached = $true; Terminal = $false }
        [pscustomobject]@{ Short = '-N'; Long = $null; Canonical = '-N'; Description = 'Disallow newlines within eqn delimiters'; ValueMode = 'None'; ValueKind = 'None'; ShortAllowsSeparate = $false; ShortAllowsAttached = $false; Terminal = $false }
        [pscustomobject]@{ Short = '-o'; Long = $null; Canonical = '-o'; Description = 'Output only selected pages'; ValueMode = 'Required'; ValueKind = 'OpaqueValue'; ShortAllowsSeparate = $true; ShortAllowsAttached = $true; Terminal = $false }
        [pscustomobject]@{ Short = '-p'; Long = $null; Canonical = '-p'; Description = 'Preprocess with pic'; ValueMode = 'None'; ValueKind = 'None'; ShortAllowsSeparate = $false; ShortAllowsAttached = $false; Terminal = $false }
        [pscustomobject]@{ Short = '-P'; Long = $null; Canonical = '-P'; Description = 'Pass an argument to the postprocessor'; ValueMode = 'Required'; ValueKind = 'OpaqueValue'; ShortAllowsSeparate = $true; ShortAllowsAttached = $true; Terminal = $false }
        [pscustomobject]@{ Short = '-r'; Long = $null; Canonical = '-r'; Description = 'Define a number register'; ValueMode = 'Required'; ValueKind = 'DefineRegister'; ShortAllowsSeparate = $true; ShortAllowsAttached = $true; Terminal = $false }
        [pscustomobject]@{ Short = '-R'; Long = $null; Canonical = '-R'; Description = 'Preprocess with refer'; ValueMode = 'None'; ValueKind = 'None'; ShortAllowsSeparate = $false; ShortAllowsAttached = $false; Terminal = $false }
        [pscustomobject]@{ Short = '-s'; Long = $null; Canonical = '-s'; Description = 'Preprocess with soelim'; ValueMode = 'None'; ValueKind = 'None'; ShortAllowsSeparate = $false; ShortAllowsAttached = $false; Terminal = $false }
        [pscustomobject]@{ Short = '-S'; Long = $null; Canonical = '-S'; Description = 'Enable safer mode'; ValueMode = 'None'; ValueKind = 'None'; ShortAllowsSeparate = $false; ShortAllowsAttached = $false; Terminal = $false }
        [pscustomobject]@{ Short = '-t'; Long = $null; Canonical = '-t'; Description = 'Preprocess with tbl'; ValueMode = 'None'; ValueKind = 'None'; ShortAllowsSeparate = $false; ShortAllowsAttached = $false; Terminal = $false }
        [pscustomobject]@{ Short = '-T'; Long = $null; Canonical = '-T'; Description = 'Use an output device'; ValueMode = 'Required'; ValueKind = 'OutputDevice'; ShortAllowsSeparate = $true; ShortAllowsAttached = $true; Terminal = $false }
        [pscustomobject]@{ Short = '-U'; Long = $null; Canonical = '-U'; Description = 'Enable unsafe mode'; ValueMode = 'None'; ValueKind = 'None'; ShortAllowsSeparate = $false; ShortAllowsAttached = $false; Terminal = $false }
        [pscustomobject]@{ Short = '-v'; Long = '--version'; Canonical = '--version'; Description = 'Show version information and exit'; ValueMode = 'None'; ValueKind = 'None'; ShortAllowsSeparate = $false; ShortAllowsAttached = $false; Terminal = $false }
        [pscustomobject]@{ Short = '-V'; Long = $null; Canonical = '-V'; Description = 'Print commands instead of running them'; ValueMode = 'None'; ValueKind = 'None'; ShortAllowsSeparate = $false; ShortAllowsAttached = $false; Terminal = $false }
        [pscustomobject]@{ Short = '-w'; Long = $null; Canonical = '-w'; Description = 'Enable a warning category'; ValueMode = 'Required'; ValueKind = 'WarningCategory'; ShortAllowsSeparate = $true; ShortAllowsAttached = $true; Terminal = $false }
        [pscustomobject]@{ Short = '-W'; Long = $null; Canonical = '-W'; Description = 'Inhibit a warning category'; ValueMode = 'Required'; ValueKind = 'WarningCategory'; ShortAllowsSeparate = $true; ShortAllowsAttached = $true; Terminal = $false }
        [pscustomobject]@{ Short = '-X'; Long = $null; Canonical = '-X'; Description = 'Use the X11 previewer'; ValueMode = 'None'; ValueKind = 'None'; ShortAllowsSeparate = $false; ShortAllowsAttached = $false; Terminal = $false }
        [pscustomobject]@{ Short = '-z'; Long = $null; Canonical = '-z'; Description = 'Suppress formatted output'; ValueMode = 'None'; ValueKind = 'None'; ShortAllowsSeparate = $false; ShortAllowsAttached = $false; Terminal = $false }
        [pscustomobject]@{ Short = '-Z'; Long = $null; Canonical = '-Z'; Description = 'Do not postprocess'; ValueMode = 'None'; ValueKind = 'None'; ShortAllowsSeparate = $false; ShortAllowsAttached = $false; Terminal = $false }
    )
}

function Get-GroffDiscoveryRoots {
    $commandPath = Get-GroffExecutablePath
    if ([string]::IsNullOrWhiteSpace($commandPath)) {
        return @()
    }

    $commandDirectory = Split-Path -Path $commandPath -Parent
    $candidates = New-Object System.Collections.Generic.List[string]

    foreach ($relativePath in @(
            '..\share\groff',
            '..\..\share\groff',
            '..\tools\share\groff',
            '..\..\tools\share\groff',
            '..\lib\groff\tools\share\groff',
            '..\..\lib\groff\tools\share\groff'
        )) {
        try {
            $candidate = [System.IO.Path]::GetFullPath((Join-Path -Path $commandDirectory -ChildPath $relativePath))
            if (Test-Path -LiteralPath $candidate) {
                [void]$candidates.Add($candidate)
            }
        } catch {
        }
    }

    Get-GroffUniqueStrings -Items @($candidates.ToArray())
}

function Get-GroffOutputDevices {
    $devices = New-Object System.Collections.Generic.List[string]
    foreach ($root in @(Get-GroffDiscoveryRoots)) {
        $fontRoot = Join-Path -Path $root -ChildPath 'current\font'
        if (-not (Test-Path -LiteralPath $fontRoot)) {
            continue
        }

        foreach ($directory in @(Get-ChildItem -LiteralPath $fontRoot -Directory -Filter 'dev*' -ErrorAction Ignore)) {
            if ($directory.Name.Length -gt 3) {
                [void]$devices.Add($directory.Name.Substring(3))
            }
        }
    }

    foreach ($device in @('ascii', 'cp1047', 'dvi', 'html', 'latin1', 'lbp', 'lj4', 'pdf', 'ps', 'utf8', 'xhtml', 'X75', 'X75-12', 'X100', 'X100-12')) {
        [void]$devices.Add($device)
    }

    Get-GroffUniqueStrings -Items @($devices.ToArray()) | Sort-Object
}

function Get-GroffMacroPackages {
    $packages = New-Object System.Collections.Generic.List[string]
    foreach ($root in @(Get-GroffDiscoveryRoots)) {
        foreach ($childPath in @('current\tmac', 'site-tmac')) {
            $tmacRoot = Join-Path -Path $root -ChildPath $childPath
            if (-not (Test-Path -LiteralPath $tmacRoot)) {
                continue
            }

            foreach ($file in @(Get-ChildItem -LiteralPath $tmacRoot -File -ErrorAction Ignore)) {
                $name = $null
                if ($file.Name.StartsWith('tmac.', [System.StringComparison]::OrdinalIgnoreCase)) {
                    $name = $file.Name.Substring(5)
                } elseif ($file.Name.EndsWith('.tmac', [System.StringComparison]::OrdinalIgnoreCase)) {
                    $name = [System.IO.Path]::GetFileNameWithoutExtension($file.Name)
                }

                if (-not [string]::IsNullOrWhiteSpace($name)) {
                    [void]$packages.Add($name)
                }
            }
        }
    }

    foreach ($package in @('man', 'mandoc', 'mdoc', 'me', 'mm', 'mom', 'ms', 'www', 'andoc')) {
        [void]$packages.Add($package)
    }

    Get-GroffUniqueStrings -Items @($packages.ToArray()) | Sort-Object
}

function Get-GroffFontFamilyList {
    # Font families for -f: the device's font file names with the DESC 'styles' suffixes stripped
    # (devps: TR/TI/TB/TBI -> T). Cached per device; falls back to the classic family list.
    param([string]$Device)

    $deviceName = if ([string]::IsNullOrWhiteSpace($Device)) { 'ps' } else { $Device }
    if ($script:GroffCompletionCatalog.FontFamiliesByDevice.ContainsKey($deviceName)) {
        return $script:GroffCompletionCatalog.FontFamiliesByDevice[$deviceName]
    }

    $families = New-Object System.Collections.Generic.List[string]
    foreach ($root in @(Get-GroffDiscoveryRoots)) {
        $deviceDirectory = Join-Path -Path $root -ChildPath ('current\font\dev' + $deviceName)
        $descPath = Join-Path -Path $deviceDirectory -ChildPath 'DESC'
        if (-not (Test-Path -LiteralPath $descPath)) {
            continue
        }

        $styles = @()
        $candidates = New-Object System.Collections.Generic.List[string]
        foreach ($line in @(Get-Content -LiteralPath $descPath -ErrorAction Ignore)) {
            if ($line -match '^styles\s+(?<styles>.+)$') {
                $styles = @($Matches['styles'].Trim() -split '\s+')
            } elseif ($line -match '^family\s+(?<family>\S+)') {
                [void]$candidates.Add($Matches['family'])
            }
        }

        if ($styles.Count -eq 0) {
            continue
        }

        $fontNames = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::Ordinal)
        foreach ($file in @(Get-ChildItem -LiteralPath $deviceDirectory -File -ErrorAction Ignore)) {
            $name = $file.Name
            if ($name -eq 'DESC' -or $name.Contains('.')) {
                continue
            }

            [void]$fontNames.Add($name)
            foreach ($style in ($styles | Sort-Object -Property Length -Descending)) {
                if ($name.Length -gt $style.Length -and $name.EndsWith($style, [System.StringComparison]::Ordinal)) {
                    [void]$candidates.Add($name.Substring(0, $name.Length - $style.Length))
                    break
                }
            }
        }

        # troff mounts <family><first style> (R) when the family is selected and exits fatally
        # ("invalid default family") without it, so lone specials such as ZCMI or MI are not families.
        foreach ($candidate in $candidates) {
            if ($fontNames.Contains($candidate + $styles[0])) {
                [void]$families.Add($candidate)
            }
        }
    }

    $result = if ($families.Count -gt 0) {
        @(Get-GroffUniqueStrings -Items @($families.ToArray()) | Sort-Object)
    } else {
        @($script:GroffCompletionCatalog.FontFamilyFallback)
    }

    $script:GroffCompletionCatalog.FontFamiliesByDevice[$deviceName] = $result
    $result
}

function Initialize-GroffCompletionCatalog {
    if ($script:GroffCompletionCatalog.Initialized) {
        return
    }

    $helpText = Get-GroffHelpText
    $helpTokens = @(Get-GroffHelpOptionTokens -HelpText $helpText)
    $definitions = @(Get-GroffStaticOptionDefinitions)

    if ($helpTokens.Count -gt 0) {
        $script:GroffCompletionCatalog.HelpOptionTokens = @($helpTokens)
        $helpSet = New-GroffStringSet
        foreach ($token in $helpTokens) {
            [void]$helpSet.Add($token)
        }

        $definitions = @(
            foreach ($definition in $definitions) {
                if (($definition.Long -eq '--help') -or ($definition.Long -eq '--version')) {
                    $definition
                    continue
                }

                if ($helpSet.Contains($definition.Short)) {
                    $definition
                }
            }
        )
    }

    $script:GroffCompletionCatalog.OptionDefinitions = $definitions
    $script:GroffCompletionCatalog.ShortOptionMap = New-GroffStringObjectMap
    $script:GroffCompletionCatalog.LongOptionMap = New-GroffStringObjectMap
    $script:GroffCompletionCatalog.OptionSuggestions = @()
    $script:GroffCompletionCatalog.LongSuggestions = @()

    foreach ($definition in $definitions) {
        $script:GroffCompletionCatalog.ShortOptionMap[$definition.Short] = $definition
        $script:GroffCompletionCatalog.OptionSuggestions += [pscustomobject]@{
            Token       = $definition.Short
            Description = $definition.Description
        }

        if (-not [string]::IsNullOrWhiteSpace($definition.Long)) {
            $script:GroffCompletionCatalog.LongOptionMap[$definition.Long] = $definition
            $entry = [pscustomobject]@{
                Token       = $definition.Long
                Description = $definition.Description
            }

            $script:GroffCompletionCatalog.OptionSuggestions += $entry
            $script:GroffCompletionCatalog.LongSuggestions += $entry
        }
    }

    $script:GroffCompletionCatalog.OptionSuggestions += [pscustomobject]@{
        Token       = '--'
        Description = 'Stop option parsing and treat later arguments as input files'
    }

    $script:GroffCompletionCatalog.OutputDevices = @(Get-GroffOutputDevices)
    $script:GroffCompletionCatalog.MacroPackages = @(Get-GroffMacroPackages)
    $script:GroffCompletionCatalog.WarningCategories = @(Get-GroffUniqueStrings -Items $script:GroffCompletionCatalog.WarningCategories)
    $script:GroffCompletionCatalog.EncodingHints = @(Get-GroffUniqueStrings -Items $script:GroffCompletionCatalog.EncodingHints)
    $script:GroffCompletionCatalog.Initialized = $true
}

function New-GroffParseState {
    @{
        EndOfOptions          = $false
        PendingSeparateOption = $null
        HelpRequested         = $false
        OutputDevice          = $null
    }
}

function Parse-GroffShortToken {
    param([string]$Token)

    if ([string]::IsNullOrWhiteSpace($Token) -or ($Token -eq '-') -or -not $Token.StartsWith('-') -or $Token.StartsWith('--')) {
        return $null
    }

    for ($index = 1; $index -lt $Token.Length; $index++) {
        $optionToken = '-' + $Token[$index]
        $definition = $script:GroffCompletionCatalog.ShortOptionMap[$optionToken]
        if ($null -eq $definition) {
            break
        }

        if ($definition.ValueMode -ne 'None') {
            $attachedValue = ''
            if (($index + 1) -lt $Token.Length) {
                $attachedValue = $Token.Substring($index + 1)
            }

            return [pscustomobject]@{
                OptionToken      = $optionToken
                RequiresSeparate = [string]::IsNullOrEmpty($attachedValue)
                AttachedValue    = $attachedValue
                PrefixText       = $Token.Substring(0, $index + 1)
            }
        }
    }

    $null
}

function Update-GroffParseState {
    param(
        [hashtable]$State,
        [string[]]$Tokens
    )

    foreach ($token in @($Tokens)) {
        if ($State.PendingSeparateOption) {
            if ($State.PendingSeparateOption -eq '-T') {
                $State.OutputDevice = Remove-GroffOuterQuotes -InputText $token
            }
            $State.PendingSeparateOption = $null
            continue
        }

        if ($State.EndOfOptions) {
            continue
        }

        if ($token -eq '--') {
            $State.EndOfOptions = $true
            continue
        }

        if ([string]::IsNullOrWhiteSpace($token) -or ($token -eq '-')) {
            continue
        }

        if ($token.StartsWith('--')) {
            if ($token -eq '--help') {
                $State.HelpRequested = $true
            }
            continue
        }

        if ($token -eq '-h') {
            $State.HelpRequested = $true
            continue
        }

        if ($token.StartsWith('-')) {
            $parsedToken = Parse-GroffShortToken -Token $token
            if ($parsedToken -and $parsedToken.RequiresSeparate) {
                $State.PendingSeparateOption = $parsedToken.OptionToken
            } elseif ($parsedToken -and $parsedToken.OptionToken -eq '-T') {
                $State.OutputDevice = Remove-GroffOuterQuotes -InputText $parsedToken.AttachedValue
            }
        }
    }
}

function Get-GroffPathCompletions {
    param(
        [string]$InputPath,
        [string]$TokenPrefix = '',
        [switch]$DirectoriesOnly
    )

    $quoteCharacter = if ($InputPath -match '^[''"\u2018-\u201E]') { $InputPath.Substring(0, 1) } else { '' }
    $cleanInput = ConvertFrom-GroffTypedWord -Value $InputPath
    $pathSeparatorChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)

    # Candidates keep the directory part exactly as typed (.\, ./, ..\, C:\, sub/).
    $lastSeparator = $cleanInput.LastIndexOfAny($pathSeparatorChars)
    $typedDirectory = if ($lastSeparator -ge 0) {
        $cleanInput.Substring(0, $lastSeparator + 1)
    } elseif ($cleanInput -match '^[A-Za-z]:') {
        $cleanInput.Substring(0, 2)
    } else {
        ''
    }
    $leaf = $cleanInput.Substring($typedDirectory.Length)
    $parentPath = if ($typedDirectory) { $typedDirectory } else { '.' }

    if (-not (Test-Path -LiteralPath $parentPath -PathType Container -ErrorAction Ignore)) {
        return @()
    }

    # A plain prefix test: -like skips control characters (a typed "a`b), so a*-style matches leak through.
    $items = @(Get-ChildItem -LiteralPath $parentPath -ErrorAction Ignore | Where-Object { $_.Name.StartsWith($leaf, [System.StringComparison]::OrdinalIgnoreCase) })
    if ($DirectoriesOnly) {
        $items = @($items | Where-Object { $_.PSIsContainer })
    }

    $results = foreach ($item in $items) {
        # A bare name starting with a dash would parse as a parameter, so it gets .\ like PowerShell's own file completion.
        $completionPath = if (-not $typedDirectory -and -not $TokenPrefix -and $item.Name -match '^[-\u2013-\u2015]') {
            '.' + [System.IO.Path]::DirectorySeparatorChar + $item.Name
        } else {
            $typedDirectory + $item.Name
        }

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

        # Attached forms keep the option bare: -F'sub dir\' is still one constant argument.
        $quotedPath = ConvertTo-GroffQuotedValue -Value $completionPath -QuoteCharacter $quoteCharacter
        $resultType = if ($item.PSIsContainer) { 'ProviderContainer' } else { 'ParameterValue' }
        New-GroffCompletionResult -CompletionText ($TokenPrefix + $quotedPath) -ListItemText ($TokenPrefix + $completionPath) -ResultType $resultType -ToolTip $item.FullName
    }

    @($results)
}

function Get-GroffCatalogValueCompletions {
    param(
        [string[]]$Values,
        [string]$CurrentValue,
        [string]$TokenPrefix,
        [string]$ToolTipPrefix
    )

    $cleanValue = Remove-GroffOuterQuotes -InputText $CurrentValue
    $results = foreach ($value in @($Values)) {
        if (-not [string]::IsNullOrWhiteSpace($cleanValue) -and -not $value.StartsWith($cleanValue, [System.StringComparison]::OrdinalIgnoreCase)) {
            continue
        }

        $completionText = if ([string]::IsNullOrWhiteSpace($TokenPrefix)) { $value } else { $TokenPrefix + $value }
        $toolTip = if ([string]::IsNullOrWhiteSpace($ToolTipPrefix)) { $value } else { $ToolTipPrefix + $value }
        New-GroffCompletionResult -CompletionText $completionText -ResultType 'ParameterValue' -ToolTip $toolTip
    }

    @($results)
}

function Get-GroffPlaceholderValueCompletions {
    param(
        [string]$OptionToken,
        [string]$CurrentValue,
        [string]$TokenPrefix
    )

    $hintSource = if ([string]::IsNullOrWhiteSpace($TokenPrefix)) {
        $script:GroffCompletionCatalog.PlaceholderHints
    } else {
        $script:GroffCompletionCatalog.AttachedPlaceholderHints
    }

    $hints = @($hintSource[$OptionToken])
    $cleanValue = Remove-GroffOuterQuotes -InputText $CurrentValue
    $results = New-Object System.Collections.Generic.List[object]

    foreach ($hint in $hints) {
        if (-not [string]::IsNullOrWhiteSpace($cleanValue) -and -not $hint.StartsWith($cleanValue, [System.StringComparison]::OrdinalIgnoreCase)) {
            continue
        }

        $completionText = if ([string]::IsNullOrWhiteSpace($TokenPrefix)) { $hint } else { $TokenPrefix + $hint }
        [void]$results.Add((New-GroffCompletionResult -CompletionText $completionText -ResultType 'ParameterValue' -ToolTip ($OptionToken + ' value')))
    }

    if ($results.Count -eq 0 -and -not [string]::IsNullOrWhiteSpace($cleanValue)) {
        $completionText = if ([string]::IsNullOrWhiteSpace($TokenPrefix)) { $cleanValue } else { $TokenPrefix + $cleanValue }
        [void]$results.Add((New-GroffCompletionResult -CompletionText $completionText -ResultType 'ParameterValue' -ToolTip ($OptionToken + ' value')))
    }

    @($results.ToArray())
}

function Get-GroffDirectoryValueCompletions {
    param(
        [string]$CurrentValue,
        [string]$TokenPrefix
    )

    if ([string]::IsNullOrWhiteSpace($CurrentValue)) {
        $completionText = if ([string]::IsNullOrWhiteSpace($TokenPrefix)) { '<directory>' } else { $TokenPrefix + '<directory>' }
        return @(
            New-GroffCompletionResult -CompletionText $completionText -ListItemText '<directory>' -ResultType 'ParameterValue' -ToolTip 'Directory path.'
        )
    }

    $results = @(Get-GroffPathCompletions -InputPath $CurrentValue -TokenPrefix $TokenPrefix -DirectoriesOnly)

    if ($results.Count -gt 0) {
        return $results
    }

    # No matching subdirectory: suppress PowerShell's filename fallback, which would offer files here.
    $completionText = if ([string]::IsNullOrWhiteSpace($TokenPrefix)) { '<directory>' } else { $TokenPrefix + '<directory>' }
    @(New-GroffCompletionResult -CompletionText $completionText -ListItemText '<directory>' -ResultType 'ParameterValue' -ToolTip 'Directory path.')
}

function Get-GroffOperandCompletions {
    param([string]$CurrentValue)

    $results = New-Object System.Collections.Generic.List[object]

    if ([string]::IsNullOrEmpty($CurrentValue) -or '-'.StartsWith($CurrentValue, [System.StringComparison]::Ordinal)) {
        [void]$results.Add((New-GroffCompletionResult -CompletionText '-' -ResultType 'ParameterValue' -ToolTip 'Read from standard input'))
    }

    # Input files are groff's primary operand, so the empty slot lists the current directory too.
    if ($CurrentValue -ne '-') {
        foreach ($result in @(Get-GroffPathCompletions -InputPath $CurrentValue)) {
            [void]$results.Add($result)
        }
    }

    @($results.ToArray())
}

function Get-GroffValueCompletions {
    param(
        [string]$OptionToken,
        [string]$CurrentValue,
        [string]$TokenPrefix = '',
        [string]$OutputDevice = ''
    )

    $definition = $script:GroffCompletionCatalog.ShortOptionMap[$OptionToken]
    if ($null -eq $definition) {
        return @()
    }

    switch ($definition.ValueKind) {
        'FontFamily' {
            return @(Get-GroffCatalogValueCompletions -Values (Get-GroffFontFamilyList -Device $OutputDevice) -CurrentValue $CurrentValue -TokenPrefix $TokenPrefix -ToolTipPrefix 'Font family ')
        }
        'OutputDevice' {
            return @(Get-GroffCatalogValueCompletions -Values $script:GroffCompletionCatalog.OutputDevices -CurrentValue $CurrentValue -TokenPrefix $TokenPrefix -ToolTipPrefix 'Output device ')
        }
        'MacroPackage' {
            return @(Get-GroffCatalogValueCompletions -Values $script:GroffCompletionCatalog.MacroPackages -CurrentValue $CurrentValue -TokenPrefix $TokenPrefix -ToolTipPrefix 'Macro package ')
        }
        'WarningCategory' {
            return @(Get-GroffCatalogValueCompletions -Values $script:GroffCompletionCatalog.WarningCategories -CurrentValue $CurrentValue -TokenPrefix $TokenPrefix -ToolTipPrefix 'Warning category ')
        }
        'Encoding' {
            return @(Get-GroffCatalogValueCompletions -Values $script:GroffCompletionCatalog.EncodingHints -CurrentValue $CurrentValue -TokenPrefix $TokenPrefix -ToolTipPrefix 'Encoding ')
        }
        'DirectoryPath' {
            return @(Get-GroffDirectoryValueCompletions -CurrentValue $CurrentValue -TokenPrefix $TokenPrefix)
        }
        'DefineString' { return @(Get-GroffPlaceholderValueCompletions -OptionToken $OptionToken -CurrentValue $CurrentValue -TokenPrefix $TokenPrefix) }
        'DefineRegister' { return @(Get-GroffPlaceholderValueCompletions -OptionToken $OptionToken -CurrentValue $CurrentValue -TokenPrefix $TokenPrefix) }
        'OpaqueValue' { return @(Get-GroffPlaceholderValueCompletions -OptionToken $OptionToken -CurrentValue $CurrentValue -TokenPrefix $TokenPrefix) }
        Default { return @() }
    }
}

function Get-GroffOptionCompletions {
    param([string]$CurrentToken)

    $suggestions = if ($CurrentToken.StartsWith('--', [System.StringComparison]::Ordinal)) {
        @($script:GroffCompletionCatalog.LongSuggestions)
    } else {
        @($script:GroffCompletionCatalog.OptionSuggestions)
    }

    $results = foreach ($suggestion in $suggestions) {
        if (-not [string]::IsNullOrWhiteSpace($CurrentToken) -and -not $suggestion.Token.StartsWith($CurrentToken, [System.StringComparison]::Ordinal)) {
            continue
        }

        New-GroffCompletionResult -CompletionText $suggestion.Token -ResultType 'ParameterName' -ToolTip $suggestion.Description
    }

    @($results)
}

function Get-GroffShortClusterCompletion {
    # An all-flag cluster such as -ab is valid as typed; offer it and every extension by one more flag.
    param([string]$CurrentToken)

    if ($CurrentToken.Length -lt 3 -or -not $CurrentToken.StartsWith('-') -or $CurrentToken.StartsWith('--')) {
        return @()
    }

    $present = New-GroffStringSet
    for ($index = 1; $index -lt $CurrentToken.Length; $index++) {
        $optionToken = '-' + $CurrentToken[$index]
        $definition = $script:GroffCompletionCatalog.ShortOptionMap[$optionToken]
        if ($null -eq $definition -or $definition.ValueMode -ne 'None') {
            return @()
        }

        [void]$present.Add($optionToken)
    }

    $results = New-Object System.Collections.Generic.List[object]
    [void]$results.Add((New-GroffCompletionResult -CompletionText $CurrentToken -ResultType 'ParameterName' -ToolTip ('Flags ' + $CurrentToken.Substring(1))))

    foreach ($definition in $script:GroffCompletionCatalog.OptionDefinitions) {
        if ($present.Contains($definition.Short) -or $definition.Terminal) {
            continue
        }

        [void]$results.Add((New-GroffCompletionResult -CompletionText ($CurrentToken + $definition.Short.Substring(1)) -ResultType 'ParameterName' -ToolTip $definition.Description))
    }

    @($results.ToArray())
}

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

    $completionText = if ([string]::IsNullOrEmpty($CurrentWord)) { ' ' } else { $CurrentWord }
    @(
        New-GroffCompletionResult -CompletionText $completionText -ResultType 'ParameterValue' -ToolTip 'No further arguments are valid after help.'
    )
}

function Complete-Groff {
    param(
        [string]$wordToComplete,
        [System.Management.Automation.Language.CommandAst]$commandAst,
        [int]$cursorPosition
    )

    Initialize-GroffCompletionCatalog

    # Words are rebuilt from the element extents (adjacent elements such as '-F' + '.\dir' re-joined)
    # and the completed word is the one under the cursor, so editing an earlier token works too.
    $words = New-Object System.Collections.Generic.List[object]
    foreach ($element in ($commandAst.CommandElements | Select-Object -Skip 1)) {
        $extent = $element.Extent
        if (($words.Count -gt 0) -and ($words[$words.Count - 1].End -eq $extent.StartOffset)) {
            $words[$words.Count - 1].Text += $extent.Text
            $words[$words.Count - 1].End = $extent.EndOffset
        } else {
            [void]$words.Add([pscustomobject]@{ Start = $extent.StartOffset; End = $extent.EndOffset; Text = $extent.Text })
        }
    }

    $currentWordEntry = $null
    foreach ($word in $words) {
        if (($word.Start -lt $cursorPosition) -and ($cursorPosition -le $word.End)) {
            $currentWordEntry = $word
            break
        }
    }

    $currentWord = if ($null -ne $currentWordEntry) {
        $currentWordEntry.Text.Substring(0, [Math]::Min($currentWordEntry.Text.Length, $cursorPosition - $currentWordEntry.Start))
    } elseif ([string]::IsNullOrEmpty($wordToComplete)) {
        ''
    } else {
        Get-GroffCurrentToken -Line $commandAst.ToString() -CursorPosition ($cursorPosition - $commandAst.Extent.StartOffset) -Fallback $wordToComplete
    }

    $tokensBeforeCurrent = @(
        $words | Where-Object {
            if ($null -ne $currentWordEntry) { $_.End -le $currentWordEntry.Start } else { $_.End -le $cursorPosition }
        } | ForEach-Object { $_.Text }
    )

    $state = New-GroffParseState
    Update-GroffParseState -State $state -Tokens $tokensBeforeCurrent

    if ($state.HelpRequested) {
        return @(Get-GroffTerminalCompletions -CurrentWord $currentWord)
    }

    if ($state.PendingSeparateOption) {
        return @(Get-GroffValueCompletions -OptionToken $state.PendingSeparateOption -CurrentValue $currentWord -OutputDevice $state.OutputDevice)
    }

    if ($state.EndOfOptions) {
        return @(Get-GroffOperandCompletions -CurrentValue $currentWord)
    }

    if ($currentWord.StartsWith('--', [System.StringComparison]::Ordinal)) {
        if ($currentWord.Contains('=')) {
            return @()
        }

        return @(Get-GroffOptionCompletions -CurrentToken $currentWord)
    }

    if ($currentWord -eq '-') {
        return @(Get-GroffOptionCompletions -CurrentToken $currentWord)
    }

    if (-not [string]::IsNullOrEmpty($currentWord) -and $currentWord.StartsWith('-', [System.StringComparison]::Ordinal)) {
        $parsedCurrent = Parse-GroffShortToken -Token $currentWord
        if ($parsedCurrent) {
            return @(Get-GroffValueCompletions -OptionToken $parsedCurrent.OptionToken -CurrentValue $parsedCurrent.AttachedValue -TokenPrefix $parsedCurrent.PrefixText -OutputDevice $state.OutputDevice)
        }

        $clusterResults = @(Get-GroffShortClusterCompletion -CurrentToken $currentWord)
        if ($clusterResults.Count -gt 0) {
            return $clusterResults
        }

        return @(Get-GroffOptionCompletions -CurrentToken $currentWord)
    }

    if ([string]::IsNullOrWhiteSpace($currentWord)) {
        $results = New-Object System.Collections.Generic.List[object]
        foreach ($optionResult in @(Get-GroffOptionCompletions -CurrentToken $currentWord)) {
            [void]$results.Add($optionResult)
        }
        foreach ($operandResult in @(Get-GroffOperandCompletions -CurrentValue $currentWord)) {
            [void]$results.Add($operandResult)
        }
        return @($results.ToArray())
    }

    @(Get-GroffOperandCompletions -CurrentValue $currentWord)
}

Register-ArgumentCompleter -Native -CommandName @('groff', 'groff.exe') -ScriptBlock {
    param($wordToComplete, $commandAst, $cursorPosition)
    Complete-Groff -wordToComplete $wordToComplete -commandAst $commandAst -cursorPosition $cursorPosition
}