completers/pspasswd_completer/pspasswd_completer.ps1

# pspasswd tab completion for PowerShell
# Static native completer for PsPasswd with safe remote and account placeholders.

Set-StrictMode -Version 2.0

function New-PsPasswdCompletionResult {
    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)
}

# PowerShell treats U+2018-U+201B as single quotes and U+201C-U+201E as double
# quotes, exactly like ' and ", so every quote test below covers both.
function Get-PsPasswdTypedQuote {
    param([string]$Value)
    if ($Value -match '^([''"\u2018-\u201E])') { return $Matches[1] }
    ''
}

function Remove-PsPasswdOuterQuotes {
    # The value of a typed word. A word opened with a quote is read by the PowerShell
    # tokenizer, which drops the quotes and undoes that quote style's escapes.
    param([string]$Value)
    if ([string]::IsNullOrEmpty($Value)) { return '' }
    if (-not (Get-PsPasswdTypedQuote -Value $Value)) { return $Value -replace '`(.)', '$1' }
    $tokens = $null
    $parseErrors = $null
    [void][System.Management.Automation.Language.Parser]::ParseInput($Value, [ref]$tokens, [ref]$parseErrors)
    [string]$tokens[0].Value
}

function ConvertTo-PsPasswdQuotedValue {
    # Bare when safe and no quote was typed, otherwise in the quote the user typed
    # (single by default). Inside single quotes every single-quote character is
    # doubled; inside double quotes ` " $ and the typographic double quotes take a backtick.
    param([string]$Value, [string]$Quote = '')
    if ([string]::IsNullOrWhiteSpace($Value)) { return $Value }
    if (-not $Quote -and $Value -notmatch '[\s{}();,|&<>''"`$@#\u2018-\u201E]') { return $Value }
    if (-not $Quote) { $Quote = "'" }
    if ($Quote -match "['\u2018-\u201B]") {
        return $Quote + ($Value -replace "(['\u2018-\u201B])", '$1$1') + $Quote
    }
    $Quote + ($Value -replace '([`$"\u201C-\u201E])', '`$1') + $Quote
}

function Get-PsPasswdArgumentState {
    param([System.Management.Automation.Language.CommandAst]$CommandAst, [int]$CursorPosition)
    # A bare '@' is an unrecognized token, so the parser splits '@.\hosts.txt' into '@' and
    # '.\hosts.txt'; glue it back onto the element it touches so the word is the @file word.
    $words = New-Object System.Collections.Generic.List[object]
    foreach ($element in @($CommandAst.CommandElements | Select-Object -Skip 1)) {
        $previous = if ($words.Count -gt 0) { $words[$words.Count - 1] } else { $null }
        if ($previous -and $previous.Text -eq '@' -and -not $previous.At -and $previous.End -eq $element.Extent.StartOffset) {
            $previous.Text = '@' + $element.Extent.Text
            $previous.End = $element.Extent.EndOffset
            $previous.At = $true
            continue
        }
        [void]$words.Add([pscustomobject]@{ Start = $element.Extent.StartOffset; End = $element.Extent.EndOffset; Text = $element.Extent.Text; At = $false })
    }
    # The word under the cursor comes from the parser (an unterminated quote is one element),
    # cut at the cursor. SpanText is the text PowerShell replaces: the whole element, so an echo
    # of it changes nothing. For a glued '@' the span is only the path after it (StrandedAt),
    # or only the '@' itself when the cursor sits right after it.
    $currentWord = ''
    $spanText = ''
    $strandedAt = $false
    foreach ($word in $words) {
        if ($word.Start -lt $CursorPosition -and $word.End -ge $CursorPosition) {
            $currentWord = $word.Text.Substring(0, $CursorPosition - $word.Start)
            $spanText = $word.Text
            if ($word.At) {
                $atOnly = $CursorPosition -eq $word.Start + 1
                $strandedAt = -not $atOnly
                $spanText = if ($atOnly) { '@' } else { $word.Text.Substring(1) }
            }
            break
        }
    }
    # Only elements that end at or before the cursor are established state; a token to the
    # right of the caret must not be consumed as the Account or NewPassword positional.
    $tokens = @($words | Where-Object { $_.End -le $CursorPosition } | ForEach-Object { $_.Text })
    $tokensBeforeCurrent = @($tokens)
    if (-not [string]::IsNullOrEmpty($currentWord) -and $tokensBeforeCurrent.Count -gt 0 -and $tokensBeforeCurrent[-1] -eq $currentWord) {
        $tokensBeforeCurrent = @($tokensBeforeCurrent | Select-Object -First ($tokensBeforeCurrent.Count - 1))
    }
    [pscustomobject]@{
        CurrentWord         = $currentWord
        SpanText            = $spanText
        StrandedAt          = $strandedAt
        TokensBeforeCurrent = $tokensBeforeCurrent
    }
}

function Get-PsPasswdAtFileCompletions {
    param([string]$CurrentWord, [string]$SpanText, [bool]$StrandedAt = $false)
    $trimmed = Remove-PsPasswdOuterQuotes -Value $CurrentWord
    if (-not $trimmed.StartsWith('@')) { return @() }
    $pathPortion = $trimmed.Substring(1)
    if ([string]::IsNullOrWhiteSpace($pathPortion)) {
        $parent = '.'
        $leaf = ''
    } elseif ($pathPortion -match '[\\/]+$') {
        $parent = $pathPortion
        $leaf = ''
    } else {
        $parent = Split-Path -Path $pathPortion -Parent
        if ([string]::IsNullOrWhiteSpace($parent)) { $parent = '.' }
        $leaf = Split-Path -Path $pathPortion -Leaf
    }
    $quote = Get-PsPasswdTypedQuote -Value $CurrentWord
    $items = @(Get-ChildItem -LiteralPath $parent -ErrorAction Ignore | Where-Object { $_.Name -like ([System.Management.Automation.WildcardPattern]::Escape($leaf) + '*') })
    # The directory part is kept exactly as typed (.\ ./ ..\ C:\ C:), so no typed text is dropped.
    $typedDirectory = $pathPortion.Substring(0, $pathPortion.LastIndexOfAny([char[]]'\/:') + 1)
    $results = foreach ($item in $items) {
        $completionPath = $typedDirectory + $item.Name
        if ($item.PSIsContainer -and -not $completionPath.EndsWith([System.IO.Path]::DirectorySeparatorChar)) {
            $completionPath += [System.IO.Path]::DirectorySeparatorChar
        }
        # A stranded '@' stays in the line, and only '@(' keeps it valid: it passes the
        # quoted '@path' to pspasswd as one argument.
        $completionText = if ($StrandedAt) {
            '(' + (ConvertTo-PsPasswdQuotedValue -Value ('@' + $completionPath) -Quote "'") + ')'
        } else {
            ConvertTo-PsPasswdQuotedValue -Value ('@' + $completionPath) -Quote $quote
        }
        New-PsPasswdCompletionResult -CompletionText $completionText -ListItemText ('@' + $item.Name) -ResultType 'ParameterValue' -ToolTip 'File containing remote computer names for @file syntax.'
    }
    if (@($results).Count -eq 0) {
        return @(New-PsPasswdCompletionResult -CompletionText $SpanText -ResultType 'ParameterValue' -ToolTip 'File containing remote computer names for @file syntax.')
    }
    @($results)
}

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

    $state = Get-PsPasswdArgumentState -CommandAst $CommandAst -CursorPosition $CursorPosition
    $currentWord = $state.CurrentWord
    $spanText = $state.SpanText
    # A placeholder offered inside a typed quote stays inside that quote.
    $typedQuote = Get-PsPasswdTypedQuote -Value $currentWord
    $tokensBeforeCurrent = @($state.TokensBeforeCurrent)
    $used = @{}
    $valueContext = $null
    $remoteTarget = $null
    $account = $null
    $newPassword = $null

    for ($i = 0; $i -lt $tokensBeforeCurrent.Count; $i++) {
        $token = $tokensBeforeCurrent[$i]
        $lowerToken = $token.ToLowerInvariant()
        if ($lowerToken -eq '-u') { $used['-u'] = $true; if ($i -eq ($tokensBeforeCurrent.Count - 1)) { $valueContext = 'User'; break }; $i++; continue }
        if ($lowerToken -eq '-p') { $used['-p'] = $true; if ($i -eq ($tokensBeforeCurrent.Count - 1)) { $valueContext = 'Password'; break }; $i++; continue }
        if ($lowerToken -eq '-nobanner') { $used['-nobanner'] = $true; continue }
        if ($lowerToken -eq '-accepteula') { $used['-accepteula'] = $true; continue }
        if ($lowerToken -eq '-?') { $used['-?'] = $true; continue }
        if ($lowerToken -eq '/?') { $used['/?'] = $true; continue }

        if (-not $remoteTarget -and ((Remove-PsPasswdOuterQuotes -Value $token).StartsWith('\\') -or (Remove-PsPasswdOuterQuotes -Value $token).StartsWith('@'))) {
            $remoteTarget = $token
            continue
        }
        if (-not $account) { $account = $token; continue }
        if (-not $newPassword) { $newPassword = $token }
    }

    switch ($valueContext) {
        'User' {
            $userResults = @(
                foreach ($userHint in @(
                        @{ Text = '<username>'; ToolTip = 'Remote user name.' }
                        @{ Text = '<domain\user>'; ToolTip = 'Remote user name in Domain\User syntax.' }
                    )) {
                    if ([string]::IsNullOrWhiteSpace($currentWord) -or $userHint.Text.StartsWith((Remove-PsPasswdOuterQuotes -Value $currentWord), [System.StringComparison]::OrdinalIgnoreCase)) {
                        New-PsPasswdCompletionResult -CompletionText $(if ($typedQuote) { ConvertTo-PsPasswdQuotedValue -Value $userHint.Text -Quote $typedQuote } else { $userHint.Text }) -ListItemText $userHint.Text -ResultType 'ParameterValue' -ToolTip $userHint.ToolTip
                    }
                }
            )
            if ($userResults.Count -gt 0) { return $userResults }
            # A typed user name that matches no placeholder is kept as-is instead of being overwritten.
            return @(New-PsPasswdCompletionResult -CompletionText $spanText -ResultType 'ParameterValue' -ToolTip 'Remote user name.')
        }
        'Password' {
            return @(New-PsPasswdCompletionResult -CompletionText $(if ([string]::IsNullOrWhiteSpace($currentWord)) { '<password>' } else { $spanText }) -ResultType 'ParameterValue' -ToolTip 'Remote password value.')
        }
    }

    if ((Remove-PsPasswdOuterQuotes -Value $currentWord).StartsWith('@')) {
        return Get-PsPasswdAtFileCompletions -CurrentWord $currentWord -SpanText $spanText -StrandedAt $state.StrandedAt
    }

    if ($account -and -not $newPassword) {
        return @(New-PsPasswdCompletionResult -CompletionText $(if ([string]::IsNullOrWhiteSpace($currentWord)) { '<new-password>' } else { $spanText }) -ResultType 'ParameterValue' -ToolTip 'New password. Completion intentionally does not enumerate or transform secrets.')
    }

    # pspasswd [\\computer|@file] [-u Username [-p Password]] <Account> [NewPassword]: nothing follows NewPassword.
    if ($account -and $newPassword) {
        return @()
    }

    $results = New-Object System.Collections.Generic.List[object]
    if (-not $remoteTarget) {
        foreach ($target in @('\\<computer>', '\\localhost', '\\*', '@file')) {
            if ([string]::IsNullOrWhiteSpace($currentWord) -or $target.StartsWith((Remove-PsPasswdOuterQuotes -Value $currentWord), [System.StringComparison]::OrdinalIgnoreCase)) {
                [void]$results.Add((New-PsPasswdCompletionResult -CompletionText $(if ($typedQuote) { ConvertTo-PsPasswdQuotedValue -Value $target -Quote $typedQuote } else { $target }) -ListItemText $target -ResultType 'ParameterValue' -ToolTip 'Remote target placeholder for local-account password changes.'))
            }
        }
    }

    foreach ($switchSpec in @(
            @{ Token = '-u'; Description = 'Optional user name for remote login.'; NeedsRemote = $true; BeforeAccount = $true }
            @{ Token = '-p'; Description = 'Optional password for remote login.'; NeedsRemote = $true; BeforeAccount = $true }
            @{ Token = '-nobanner'; Description = 'Do not display the startup banner and copyright message.' }
            @{ Token = '-accepteula'; Description = 'Suppress the Sysinternals EULA dialog on first run.' }
            @{ Token = '-?'; Description = 'Display PsPasswd help.' }
            @{ Token = '/?'; Description = 'Display PsPasswd help.' }
        )) {
        if ($used.ContainsKey($switchSpec.Token.ToLowerInvariant())) { continue }
        if ($switchSpec.ContainsKey('NeedsRemote') -and $switchSpec.NeedsRemote -and -not $remoteTarget) { continue }
        if ($switchSpec.ContainsKey('BeforeAccount') -and $switchSpec.BeforeAccount -and $account) { continue }
        if (-not [string]::IsNullOrWhiteSpace($currentWord) -and -not $switchSpec.Token.StartsWith($currentWord, [System.StringComparison]::OrdinalIgnoreCase)) { continue }
        [void]$results.Add((New-PsPasswdCompletionResult -CompletionText $switchSpec.Token -ResultType 'ParameterName' -ToolTip $switchSpec.Description))
    }

    if (-not $account) {
        foreach ($hint in @('<account>', '<domain\account>', 'Administrator', 'CONTOSO\User')) {
            if ([string]::IsNullOrWhiteSpace($currentWord) -or $hint.StartsWith((Remove-PsPasswdOuterQuotes -Value $currentWord), [System.StringComparison]::OrdinalIgnoreCase)) {
                [void]$results.Add((New-PsPasswdCompletionResult -CompletionText $(if ($typedQuote) { ConvertTo-PsPasswdQuotedValue -Value $hint -Quote $typedQuote } else { $hint }) -ListItemText $hint -ResultType 'ParameterValue' -ToolTip 'Local account or domain account placeholder.'))
            }
        }
    }

    @($results.ToArray())
}


Register-ArgumentCompleter -Native -CommandName @('pspasswd', 'pspasswd.exe') -ScriptBlock {
    param($wordToComplete, $commandAst, $cursorPosition)
    Complete-PsPasswd -WordToComplete $wordToComplete -CommandAst $commandAst -CursorPosition $cursorPosition
}