completers/strings_completer/strings_completer.ps1

# strings tab completion for PowerShell
# Native completer for Strings with switch-aware numeric hints and path completion.

Set-StrictMode -Version 2.0

if (-not (Get-Variable -Name StringsCompletionCatalog -Scope Script -ErrorAction Ignore)) {
    $script:StringsCompletionCatalog = @{
        Switches = @(
            @{ Token = '-a'; Description = 'Ascii-only search.'; TakesValue = $false }
            @{ Token = '-b'; Description = 'Bytes of file to scan.'; TakesValue = $true; ValueKind = 'Bytes' }
            @{ Token = '-f'; Description = 'File offset at which to start scanning.'; TakesValue = $true; ValueKind = 'Offset' }
            @{ Token = '-n'; Description = 'Minimum string length.'; TakesValue = $true; ValueKind = 'Length' }
            @{ Token = '-o'; Description = 'Print offset where the string was located.'; TakesValue = $false }
            @{ Token = '-s'; Description = 'Recurse subdirectories.'; TakesValue = $false }
            @{ Token = '-u'; Description = 'Unicode-only search.'; TakesValue = $false }
            @{ Token = '-nobanner'; Description = 'Do not display the startup banner.'; TakesValue = $false }
            @{ Token = '-accepteula'; Description = 'Accept the Sysinternals license agreement (suppresses the first-run EULA dialog).'; TakesValue = $false }
            @{ Token = '-?'; Description = 'Show Strings help.'; TakesValue = $false }
            @{ Token = '/?'; Description = 'Show Strings help.'; TakesValue = $false }
        )
        ByteHints   = @('256', '512', '1024', '4096', '65536')
        OffsetHints = @('0', '512', '4096', '65536', '0x1000')
        LengthHints = @('3', '4', '8', '16', '32')
    }
}

function New-StringsCompletionResult {
    param(
        [string]$CompletionText,
        [string]$ResultType,
        [string]$ToolTip,
        [string]$ListItemText
    )

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

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

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

function Remove-StringsOuterQuotes {
    param([string]$Value)

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

    if ($Value.Length -ge 2 -and $Value.StartsWith('"') -and $Value.EndsWith('"')) {
        return $Value.Substring(1, $Value.Length - 2)
    }

    $Value.TrimStart('"')
}

function ConvertTo-StringsQuotedValue {
    param(
        [string]$Value,
        [bool]$AlwaysQuote = $false
    )

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

    if (($AlwaysQuote -or $Value -match '\s') -and -not ($Value.StartsWith('"') -and $Value.EndsWith('"'))) {
        $escaped = $Value.Replace('`', '``').Replace('"', '`"')
        return '"' + $escaped + '"'
    }

    $Value
}

function Get-StringsTokenState {
    param(
        [string]$Line,
        [int]$CursorPosition
    )

    if ($null -eq $Line) {
        $Line = ''
    }

    $safeCursor = [Math]::Min([Math]::Max($CursorPosition, 0), $Line.Length)
    $prefix = $Line.Substring(0, $safeCursor)
    $tokens = New-Object System.Collections.Generic.List[string]
    $builder = New-Object System.Text.StringBuilder
    $quoteChar = [char]0

    foreach ($character in $prefix.ToCharArray()) {
        if (($character -eq [char]34) -or ($character -eq [char]39)) {
            if ($quoteChar -eq [char]0) {
                $quoteChar = $character
            } elseif ($quoteChar -eq $character) {
                $quoteChar = [char]0
            }

            [void]$builder.Append($character)
            continue
        }

        if ([char]::IsWhiteSpace($character) -and $quoteChar -eq [char]0) {
            if ($builder.Length -gt 0) {
                $tokens.Add($builder.ToString())
                [void]$builder.Clear()
            }

            continue
        }

        [void]$builder.Append($character)
    }

    $hasTrailingSpace = $prefix -match '\s$'
    if ($builder.Length -gt 0) {
        $tokens.Add($builder.ToString())
    }

    if ($hasTrailingSpace) {
        return [pscustomobject]@{
            TokensBeforeCurrent = @($tokens)
            CurrentToken        = ''
        }
    }

    if ($tokens.Count -gt 0) {
        return [pscustomobject]@{
            TokensBeforeCurrent = @($tokens | Select-Object -First ($tokens.Count - 1))
            CurrentToken        = $tokens[$tokens.Count - 1]
        }
    }

    [pscustomobject]@{
        TokensBeforeCurrent = @()
        CurrentToken        = ''
    }
}

function Get-StringsArgumentsFromTokenState {
    param([pscustomobject]$TokenState)

    [pscustomobject]@{
        ArgumentsBeforeCurrent = @($TokenState.TokensBeforeCurrent | Select-Object -Skip 1)
        CurrentArgument        = $TokenState.CurrentToken
    }
}

function Get-StringsUniqueCompletions {
    param([object[]]$Results)

    $seen = @{}
    $unique = New-Object System.Collections.Generic.List[object]
    foreach ($result in $Results) {
        if ($null -eq $result) {
            continue
        }

        if ($seen.ContainsKey($result.CompletionText)) {
            continue
        }

        $seen[$result.CompletionText] = $true
        [void]$unique.Add($result)
    }

    @($unique.ToArray())
}

function Get-StringsStaticValueResults {
    param(
        [string]$CurrentWord,
        [string[]]$Values,
        [string]$ToolTip
    )

    $typedValue = Remove-StringsOuterQuotes -Value $CurrentWord
    $results = New-Object System.Collections.Generic.List[object]

    foreach ($value in $Values) {
        if (-not [string]::IsNullOrWhiteSpace($typedValue) -and
            -not $value.StartsWith($typedValue, [System.StringComparison]::OrdinalIgnoreCase)) {
            continue
        }

        [void]$results.Add((New-StringsCompletionResult -CompletionText $value -ListItemText $value -ResultType 'ParameterValue' -ToolTip $ToolTip))
    }

    if ($results.Count -eq 0) {
        if ([string]::IsNullOrWhiteSpace($CurrentWord)) {
            [void]$results.Add((New-StringsCompletionResult -CompletionText '<value>' -ListItemText '<value>' -ResultType 'ParameterValue' -ToolTip $ToolTip))
        } else {
            [void]$results.Add((New-StringsCompletionResult -CompletionText $CurrentWord -ListItemText $CurrentWord -ResultType 'ParameterValue' -ToolTip $ToolTip))
        }
    }

    @($results.ToArray())
}

function Expand-StringsPathText {
    # Resolve the forms a user types at the prompt ($env:NAME, ${env:NAME}, %NAME%, ~) to a
    # filesystem path for enumeration, while the typed text itself stays in the completion.
    param([string]$Path)

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

    $expanded = [regex]::Replace($Path, '\$\{?env:([A-Za-z_][A-Za-z0-9_]*)\}?', {
            param($match)
            $value = [System.Environment]::GetEnvironmentVariable($match.Groups[1].Value)
            if ($null -eq $value) { $match.Value } else { $value }
        })
    $expanded = [System.Environment]::ExpandEnvironmentVariables($expanded)

    if ($expanded -eq '~' -or $expanded.StartsWith('~\') -or $expanded.StartsWith('~/')) {
        $expanded = $HOME + $expanded.Substring(1)
    }

    $expanded
}

function Get-StringsPathCompletions {
    param(
        [string]$CurrentWord,
        [string]$ToolTip,
        [string]$Placeholder = '<file-or-directory>'
    )

    $typedValue = Remove-StringsOuterQuotes -Value $CurrentWord
    $alwaysQuote = $CurrentWord.StartsWith('"')
    $results = New-Object System.Collections.Generic.List[object]

    # Split on the typed text (so the completion keeps the user's prefix verbatim) and
    # enumerate the expanded form.
    $typedParent = ''
    $leaf = $typedValue
    $separatorIndex = $typedValue.LastIndexOfAny([char[]]@('\', '/'))
    if ($separatorIndex -ge 0) {
        $typedParent = $typedValue.Substring(0, $separatorIndex + 1)
        $leaf = $typedValue.Substring($separatorIndex + 1)
    }

    $enumeratePath = if ([string]::IsNullOrEmpty($typedParent)) { '.' } else { Expand-StringsPathText -Path $typedParent }
    $items = @()
    try {
        $items = @(Get-ChildItem -LiteralPath $enumeratePath -ErrorAction Ignore)
    } catch {
        Write-Debug "strings path enumeration failed for '$enumeratePath': $($_.Exception.Message)"
    }

    foreach ($item in $items) {
        if (-not [string]::IsNullOrWhiteSpace($leaf) -and
            -not $item.Name.StartsWith($leaf, [System.StringComparison]::OrdinalIgnoreCase)) {
            continue
        }

        $candidate = $typedParent + $item.Name
        if ($item.PSIsContainer) {
            $candidate += '\'
        }

        $completionText = ConvertTo-StringsQuotedValue -Value $candidate -AlwaysQuote $alwaysQuote
        [void]$results.Add((New-StringsCompletionResult -CompletionText $completionText -ListItemText $completionText -ResultType 'ParameterValue' -ToolTip $ToolTip))
    }

    # With nothing typed, the placeholder keeps the slot visible; with a typed value that
    # matches nothing, return nothing so PowerShell's own path completion can take over.
    if ($results.Count -eq 0 -and [string]::IsNullOrWhiteSpace($CurrentWord)) {
        [void]$results.Add((New-StringsCompletionResult -CompletionText $Placeholder -ListItemText $Placeholder -ResultType 'ParameterValue' -ToolTip $ToolTip))
    }

    @($results.ToArray())
}

function Get-StringsCommandState {
    param([string[]]$ArgumentsBeforeCurrent)

    $usedTokens = @{}
    $valueContext = $null
    $positionals = New-Object System.Collections.Generic.List[string]

    for ($index = 0; $index -lt $ArgumentsBeforeCurrent.Count; $index++) {
        $token = $ArgumentsBeforeCurrent[$index]
        if ([string]::IsNullOrWhiteSpace($token)) {
            continue
        }

        $lookup = $token.ToLowerInvariant()
        $usedTokens[$lookup] = $true

        switch ($lookup) {
            '-b' {
                if ($index -eq ($ArgumentsBeforeCurrent.Count - 1)) {
                    $valueContext = 'Bytes'
                    break
                }

                $index++
                continue
            }
            '-f' {
                if ($index -eq ($ArgumentsBeforeCurrent.Count - 1)) {
                    $valueContext = 'Offset'
                    break
                }

                $index++
                continue
            }
            '-n' {
                if ($index -eq ($ArgumentsBeforeCurrent.Count - 1)) {
                    $valueContext = 'Length'
                    break
                }

                $index++
                continue
            }
            default {
                if ($lookup.StartsWith('-') -or $lookup.StartsWith('/')) {
                    continue
                }

                $positionals.Add($token)
            }
        }
    }

    [pscustomobject]@{
        UsedTokens   = $usedTokens
        ValueContext = $valueContext
        Positionals  = @($positionals.ToArray())
    }
}

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

    $line = if ($CommandAst.Extent -and $null -ne $CommandAst.Extent.Text) { $CommandAst.Extent.Text } else { $CommandAst.ToString() }
    # $CursorPosition is line-absolute; the extent text is command-relative.
    $relativeCursor = [Math]::Max(0, $CursorPosition - $CommandAst.Extent.StartOffset)
    if ($relativeCursor -gt $line.Length) {
        $line = $line.PadRight($relativeCursor)
    }
    $tokenState = Get-StringsTokenState -Line $line -CursorPosition $relativeCursor
    $argumentsState = Get-StringsArgumentsFromTokenState -TokenState $tokenState
    $state = Get-StringsCommandState -ArgumentsBeforeCurrent $argumentsState.ArgumentsBeforeCurrent
    $currentWord = $argumentsState.CurrentArgument

    switch ($state.ValueContext) {
        'Bytes' { return Get-StringsStaticValueResults -CurrentWord $currentWord -Values $script:StringsCompletionCatalog.ByteHints -ToolTip 'Bytes of file to scan.' }
        'Offset' { return Get-StringsStaticValueResults -CurrentWord $currentWord -Values $script:StringsCompletionCatalog.OffsetHints -ToolTip 'File offset at which to start scanning.' }
        'Length' { return Get-StringsStaticValueResults -CurrentWord $currentWord -Values $script:StringsCompletionCatalog.LengthHints -ToolTip 'Minimum string length.' }
    }

    $results = New-Object System.Collections.Generic.List[object]
    $wantsSwitches = [string]::IsNullOrEmpty($currentWord) -or $currentWord.StartsWith('-') -or $currentWord.StartsWith('/')

    if ($wantsSwitches) {
        foreach ($switchSpec in $script:StringsCompletionCatalog.Switches) {
            if (-not $switchSpec.Token.StartsWith($currentWord, [System.StringComparison]::OrdinalIgnoreCase)) {
                continue
            }

            if ($state.UsedTokens.ContainsKey($switchSpec.Token.ToLowerInvariant()) -and
                $switchSpec.Token -notin @('-?', '/?')) {
                continue
            }

            [void]$results.Add((New-StringsCompletionResult -CompletionText $switchSpec.Token -ListItemText $switchSpec.Token -ResultType 'ParameterName' -ToolTip $switchSpec.Description))
        }
    }

    if (-not $currentWord.StartsWith('-') -and -not $currentWord.StartsWith('/')) {
        foreach ($item in (Get-StringsPathCompletions -CurrentWord $currentWord -ToolTip 'File or directory to scan for strings.' -Placeholder '<file-or-directory>')) {
            [void]$results.Add($item)
        }
    }

    Get-StringsUniqueCompletions -Results @($results.ToArray())
}

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

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