PSFzf.TabExpansion.ps1

# check if we're running on Windows PowerShell. This method is faster than Get-Command:
if ($(get-host).Version.Major -le 5) {
    $script:PowershellCmd = 'powershell'
} else {
    $script:PowershellCmd = 'pwsh'
}

# borrowed from https://github.com/dahlbyk/posh-git/blob/f69efd9229029519adb32e37a464b7e1533a372c/src/GitTabExpansion.ps1#L81
filter script:quoteStringWithSpecialChars {
    if ($_ -and ($_ -match '\s+|#|@|\$|;|,|''|\{|\}|\(|\)')) {
        $str = $_ -replace "'", "''"
        "'$str'"
    }
    else {
        $_
    }
}

# taken from https://github.com/dahlbyk/posh-git/blob/2ad946347e7342199fd4bb1b42738833f68721cd/src/GitUtils.ps1#L407
function script:Get-AliasPattern($cmd) {
    $aliases = @($cmd) + @(Get-Alias | Where-Object { $_.Definition -eq $cmd } | Select-Object -Exp Name)
   "($($aliases -join '|'))"
}

function Expand-GitWithFzf($lastBlock) {
    $gitResults = Expand-GitCommand $lastBlock
    # if no results, invoke filesystem completion:
    if ($null -eq $gitResults) {
        $results = Invoke-Fzf -Multi | script:quoteStringWithSpecialChars
    } else {
        $results = $gitResults | Invoke-Fzf -Multi | script:quoteStringWithSpecialChars
    }

    if ($results.Count -gt 1) {
        $results -join ' '
    } else {
        if (-not $null -eq $results) {
            $results
        } else {
            '' # output something to prevent default tab expansion
        }
    }

    InvokePromptHack
}

function Expand-FileDirectoryPath($lastWord) {
    # find dir and file pattern connected to the trigger:
    $lastWord = $lastWord.Substring(0, $lastWord.Length - 2)
    if ($lastWord.EndsWith('\')) {
        $dir = $lastWord.Substring(0, $lastWord.Length - 1)
        $file = $null
    } elseif (-not [string]::IsNullOrWhiteSpace($lastWord)) {
        $dir = Split-Path $lastWord -Parent
        $file = Split-Path $lastWord -Leaf
    }
    if (-not [System.IO.Path]::IsPathRooted($dir)) {
        $dir = Join-Path $PWD.ProviderPath $dir
    }
    $prevPath = $Pwd.ProviderPath
    try {
        if (-not [string]::IsNullOrEmpty($dir)) {
            Set-Location $dir
        }
        if (-not [string]::IsNullOrEmpty($file)) {
            Invoke-Fzf -Query $file
        }
        else {
            Invoke-Fzf
        }
    }
    finally {
        Set-Location $prevPath
    }

    InvokePromptHack
}

$script:TabExpansionEnabled = $false
function SetTabExpansion($enable)
{
    if ($enable) {
        if (-not $script:TabExpansionEnabled) {
                $script:TabExpansionEnabled = $true

                RegisterBuiltinCompleters

                Register-ArgumentCompleter -CommandName git,tgit,gitk -Native -ScriptBlock {
                    param($wordToComplete, $commandAst, $cursorPosition)

                    # The PowerShell completion has a habit of stripping the trailing space when completing:
                    # git checkout <tab>
                    # The Expand-GitCommand expects this trailing space, so pad with a space if necessary.
                    $padLength = $cursorPosition - $commandAst.Extent.StartOffset
                    $textToComplete = $commandAst.ToString().PadRight($padLength, ' ').Substring(0, $padLength)

                    Expand-GitCommandPsFzf $textToComplete
                }

        }
    } else {
        if ($script:TabExpansionEnabled) {
            $script:TabExpansionEnabled = $false
        }
    }
}

function CheckFzfTrigger {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $cursorPosition,$action)
    if ([string]::IsNullOrWhiteSpace($env:FZF_COMPLETION_TRIGGER)) {
        $completionTrigger = '**'
    } else {
        $completionTrigger = $env:FZF_COMPLETION_TRIGGER
    }
    if ($wordToComplete.EndsWith($completionTrigger)) {
        $wordToComplete = $wordToComplete.Substring(0, $wordToComplete.Length - $completionTrigger.Length)
        $wordToComplete
    }
}


function GetServiceSelection() {
    param(
        [scriptblock]
        $ResultAction
    )
    $header = [System.Environment]::NewLine + $("{0,-24} NAME" -f "DISPLAYNAME") + [System.Environment]::NewLine
    $result = Get-Service | Where-Object { ![string]::IsNullOrEmpty($_.Name) } | ForEach-Object {
        "{0,-24} {1}" -f $_.DisplayName.Substring(0,[System.Math]::Min(24,$_.DisplayName.Length)),$_.Name } | Invoke-Fzf -Multi -Header $header
    $result | ForEach-Object {
        &$ResultAction $_
    }
}

function RegisterBuiltinCompleters {
    $processIdOrNameScriptBlock = {
        param($commandName, $parameterName, $wordToComplete, $commandAst, $cursorPosition, $action)
        $wordToComplete = CheckFzfTrigger $commandName $parameterName $wordToComplete $commandAst $cursorPosition
        if ($null -ne $wordToComplete)
        {
            if ($parameterName -eq 'Name') {
                $group = '$2'
            } elseif ($parameterName -eq 'Id') {
                $group = '$1'
            }

            $script:resultArr = @()
            GetProcessSelection -ResultAction {
                param($result)
                $script:resultArr += $result -replace "([0-9]+)\s*(.*)",$group
            }

            if ($script:resultArr.Length -ge 1) {
                $script:resultArr -join ', '
            }

            InvokePromptHack
        } else {
            # don't return anything - let normal tab completion work
        }
    }

    'Get-Process','Stop-Process' | ForEach-Object {
        Register-ArgumentCompleter -CommandName $_ -ParameterName "Name" -ScriptBlock $processIdOrNameScriptBlock
        Register-ArgumentCompleter -CommandName $_ -ParameterName "Id" -ScriptBlock $processIdOrNameScriptBlock
    }

    $serviceNameScriptBlock = {
        param($commandName, $parameterName, $wordToComplete, $commandAst, $cursorPosition,$action)
        $wordToComplete = CheckFzfTrigger $commandName $parameterName $wordToComplete $commandAst $cursorPosition
        if ($null -ne $wordToComplete)
        {
            if ($parameterName -eq 'Name') {
                $group = '$2'
            } elseif ($parameterName -eq 'DisplayName') {
                $group = '$1'
            }

            $script:resultArr = @()
            GetServiceSelection -ResultAction {
                param($result)
                $script:resultArr += $result.Substring(24+1)
            }

            if ($script:resultArr.Length -ge 1) {
                $script:resultArr -join ', '
            }
            InvokePromptHack
        } else {
            # don't return anything - let normal tab completion work
        }
    }

    'Get-Service','Start-Service','Stop-Service' | ForEach-Object {
        Register-ArgumentCompleter -CommandName $_ -ParameterName "Name" -ScriptBlock $serviceNameScriptBlock
        Register-ArgumentCompleter -CommandName $_ -ParameterName "DisplayName" -ScriptBlock $serviceNameScriptBlock
    }
}


function Expand-GitCommandPsFzf($lastWord) {
    if ([string]::IsNullOrWhiteSpace($env:FZF_COMPLETION_TRIGGER)) {
        $completionTrigger = '**'
    }
    else {
        $completionTrigger = $env:FZF_COMPLETION_TRIGGER
    }
    if ($lastWord.EndsWith($completionTrigger)) {
            $lastWord = $lastWord.Substring(0, $lastWord.Length - $completionTrigger.Length)
            Expand-GitWithFzf $lastWord
    } else {
        Expand-GitCommand $lastWord
    }
}


function Invoke-FzfTabCompletion()
{
    $script:continueCompletion = $true
    do
    {
        $script:continueCompletion = script:Invoke-FzfTabCompletionInner
    }
    while ($script:continueCompletion)
}

function script:Invoke-FzfTabCompletionInner()
{
    $script:result = @()

    [void] [System.Reflection.Assembly]::LoadWithPartialName("System.Management.Automation")
    $line = $null
    $cursor = $null
    [Microsoft.PowerShell.PSConsoleReadline]::GetBufferState([ref]$line, [ref]$cursor)

    if ($cursor -lt 0 -or [string]::IsNullOrWhiteSpace($line)) {
        return $false
    }
    $completions = [System.Management.Automation.CommandCompletion]::CompleteInput($line, $cursor, @{})

    $completionMatches = $completions.CompletionMatches
    if ($completionMatches.Count -le 0) {
        return $false
    }
    $script:continueCompletion = $false

    $addSpace = $null -ne $currentPath -and $currentPath.StartsWith(" ")

    if ($completionMatches.Count -eq 1) {
        $script:result = $completionMatches[0].CompletionText
    } elseif ($completionMatches.Count -gt 1) {
        $helpers = New-Object PSFzf.IO.CompletionHelpers
        $ambiguous = $false
        $addSpace  = $false
        $prefix = $helpers.GetUnambiguousPrefix($completionMatches,[ref]$ambiguous)

        $script:result = @()
        $script:checkCompletion = $true
        $expectTrigger = $script:TabContinuousTrigger
        # need to escape the key if it's a forward slash:
        if ($expectTrigger -eq '\') {
            $expectTrigger += $expectTrigger
        }

        # normalize so path works correctly for Windows:
        $path = $PWD.ProviderPath.Replace('\','/')

        # need to handle parameters differently so PowerShell doesn't parse completion item as a script parameter:
        if( $completionMatches[0].ResultType -eq 'ParameterName'){
            $Command = $Line.Substring(0, $Line.indexof(' '))
            $previewScript = $(Join-Path $PsScriptRoot 'helpers/PsFzfTabExpansion-Parameter.ps1')
            $additionalCmd = @{ Preview=$("$PowerShellCMD -NoProfile -NonInteractive -File \""$previewScript\"" $Command {}") }

        } else {
            $previewScript = $(Join-Path $PsScriptRoot 'helpers/PsFzfTabExpansion-Preview.ps1')
            $additionalCmd = @{ Preview=$($script:PowershellCmd + " -NoProfile -NonInteractive -File \""$previewScript\"" \""" + $path + "\"" {}") }
        }

        $script:fzfOutput = @()
        $completionMatches | ForEach-Object { $_.CompletionText } | Invoke-Fzf `
            -Layout reverse `
            -Expect $expectTrigger `
            -Query "$prefix" `
            -Bind 'tab:down,btab:up' `
            @additionalCmd | ForEach-Object {
                if ($null -ne $_ -and -not [string]::IsNullOrWhiteSpace($_)) {
                    $script:fzfOutput += $_
                }
            }

        # check if there's a selection:
        if ($script:fzfOutput.Length -ge 1) {
            $script:result = $script:fzfOutput[0]
        }
        # or just complete with the query string:
        else {
            $script:result = $prefix
        }

        # check if we should continue completion:
        $script:continueCompletion = $script:fzfOutput[1] -eq $script:TabContinuousTrigger

        InvokePromptHack
    }

    $result = $script:result
    if ($null -ne $result) {
        # quote strings if we need to:
        if ($result -is [system.array]) {
            for ($i = 0;$i -lt $result.Length;$i++) {
                $result[$i] = FixCompletionResult $result[$i]
            }
            $str = $result -join ','
        } else {
            $str = FixCompletionResult $result
        }

        if ($script:continueCompletion) {
            $isQuoted = $str.EndsWith("'")
            $resultTrimmed = $str.Trim(@('''','"'))
            if (Test-Path "$resultTrimmed"  -PathType Container) {
                if ($isQuoted) {
                    $str = "'{0}{1}'" -f "$resultTrimmed",$script:TabContinuousTrigger
                } else {
                    $str = "$resultTrimmed" + $script:TabContinuousTrigger
                }
            } else {
                # no more paths to complete, so let's stop completion:
                $str += ' '
                $script:continueCompletion = $false
            }
        }

        if ($addSpace) {
            $str = ' ' + $str
        }

        $leftCursor = $completions.ReplacementIndex
        $replacementLength = $completions.ReplacementLength
        if ($leftCursor -le 0 -and $replacementLength -le 0) {
            [Microsoft.PowerShell.PSConsoleReadLine]::Insert($str)
        } else {
            [Microsoft.PowerShell.PSConsoleReadLine]::Replace($leftCursor,$replacementLength,$str)
        }

    }

    return $script:continueCompletion
}