Private/Fzf.ps1

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# fzf, mirroring zoxide 0.10.0 src/util.rs (Fzf, FzfChild) and the get_fzf builders in
# src/cmd/query.rs and src/cmd/edit.rs, plus the numbered console picker that replaces fzf for
# `query -i` when fzf is missing (a deliberate deviation, spec section 2).
#
# FzfCommand [pscustomobject] PSTypeName 'PSZoxide.FzfCommand'
# FileName the full path of the program (never a bare name, so CreateProcess cannot pick an
# fzf.exe out of the current directory)
# Arguments [Collections.Generic.List[string]], each passed as its own ArgumentList item
# Environment ordered hashtable of variables set on the child only
#
# Feed [pscustomobject] PSTypeName 'PSZoxide.Feed': a query stream, its time and the --exclude
# path; Get-ZoFeedNext yields the fzf feed lines ("{score:>6.1}\t{path}") lazily.

# The picker's two seams. Tests swap these scriptblocks; the defaults use the host UI, so the list
# and the prompt never reach the pipeline that `$result = zoxide query -i` captures.
$script:ZoPickerWriter = { param([string]$Text) $host.UI.WriteLine($Text) }
$script:ZoPickerReader = { param([string]$Prompt) $host.UI.Write($Prompt); $host.UI.ReadLine() }

function Find-ZoExecutable {
    <#
    .SYNOPSIS
        The full path of a program on PATH, or $null.
    .DESCRIPTION
        Mirrors how the binary finds its child programs: on Windows the which crate resolves
        '<name>.exe' through PATH only (never the current directory); on Unix execvp searches PATH
        for an executable file. Each PATH entry is checked in order.
    .PARAMETER Windows
        Use the Windows rules. Defaults to the host platform.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string]$Name,

        [bool]$Windows = $IsWindows
    )
    $file = if ($Windows) { "$Name.exe" } else { $Name }
    $pathList = [Environment]::GetEnvironmentVariable('PATH')
    if (-not $pathList) { return $null }
    foreach ($dir in Split-ZoPathList -Value $pathList -Windows $Windows) {
        if (-not $dir) { continue }
        $candidate = [IO.Path]::Join($dir, $file)
        if (-not [IO.File]::Exists($candidate)) { continue }
        if (-not $Windows) {
            $execute = [IO.UnixFileMode]::UserExecute -bor [IO.UnixFileMode]::GroupExecute -bor [IO.UnixFileMode]::OtherExecute
            if (([IO.File]::GetUnixFileMode($candidate) -band $execute) -eq 0) { continue }
        }
        return $candidate
    }
    $null
}

function Find-ZoFzf {
    <#
    .SYNOPSIS
        The full path of the fzf executable on PATH; throws the binary's message when there is none.
    .DESCRIPTION
        Mirrors the lookup in Fzf::new (which::which("fzf.exe") on Windows, a PATH search on Unix)
        and its error, 'could not find fzf, is it installed?'. There is no current-directory
        fallback.
    .PARAMETER Windows
        Use the Windows rules. Defaults to the host platform.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [bool]$Windows = $IsWindows
    )
    $path = Find-ZoExecutable -Name 'fzf' -Windows $Windows
    if (-not $path) { throw 'could not find fzf, is it installed?' }
    $path
}

function New-ZoFzfCommand {
    <#
    .SYNOPSIS
        A new fzf command line with the arguments every zoxide fzf call starts with.
    .DESCRIPTION
        Mirrors Fzf::new after the lookup: --delimiter=<TAB>, --nth=2 and --read0, with stdin and
        stdout piped (Invoke-ZoFzf redirects both).
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an in-memory object only.')]
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [string]$Path
    )
    $arguments = [Collections.Generic.List[string]]::new([string[]]@("--delimiter=`t", '--nth=2', '--read0'))
    [pscustomobject]@{
        PSTypeName  = 'PSZoxide.FzfCommand'
        FileName    = $Path
        Arguments   = $arguments
        Environment = [ordered]@{}
    }
}

function Add-ZoFzfPreview {
    <#
    .SYNOPSIS
        Adds the directory preview to an fzf command line, on Unix only.
    .DESCRIPTION
        Mirrors Fzf::enable_preview: nothing on Windows; elsewhere an ls preview (GNU flags on
        Linux) in a sharp-bordered window below, with CLICOLOR=1, CLICOLOR_FORCE=1 and SHELL=sh set
        on the child.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$Command,

        [bool]$Windows = $IsWindows,

        [bool]$Linux = $IsLinux
    )
    if ($Windows) { return }
    $preview = if ($Linux) { '--preview=\command -p ls -Cp --color=always --group-directories-first {2..}' }
    else { '--preview=\command -p ls -Cp {2..}' }
    $Command.Arguments.AddRange([string[]]@($preview, '--preview-window=down,30%,sharp'))
    $Command.Environment['CLICOLOR'] = '1'
    $Command.Environment['CLICOLOR_FORCE'] = '1'
    $Command.Environment['SHELL'] = 'sh'
}

function New-ZoQueryFzfCommand {
    <#
    .SYNOPSIS
        The fzf command line for `query -i`.
    .DESCRIPTION
        Mirrors Query::get_fzf: when _ZO_FZF_OPTS is set, its raw value becomes FZF_DEFAULT_OPTS in
        the child's environment and nothing else is added (it is never split into arguments);
        otherwise zoxide's default arguments and the Unix preview are added.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an in-memory object only.')]
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [string]$Path,

        [bool]$Windows = $IsWindows,

        [bool]$Linux = $IsLinux
    )
    $command = New-ZoFzfCommand -Path $Path
    $opts = Get-ZoFzfOpts
    if ($null -ne $opts) {
        $command.Environment['FZF_DEFAULT_OPTS'] = $opts
        return $command
    }
    $command.Arguments.AddRange([string[]]@(
        '--exact', '--no-sort', '--bind=ctrl-z:ignore,btab:up,tab:down', '--cycle', '--keep-right',
        '--border=sharp', '--height=45%', '--info=inline', '--layout=reverse', '--tabstop=1', '--exit-0'
    ))
    Add-ZoFzfPreview -Command $command -Windows $Windows -Linux $Linux
    $command
}

function Get-ZoFzfShell {
    <#
    .SYNOPSIS
        Which kind of shell fzf runs its bindings with: cmd, pwsh or sh.
    .DESCRIPTION
        Mirrors fzf 0.74.4's executor choice (CONFIRMED with the pinned binary). On Windows fzf uses
        $SHELL when it is set and cmd otherwise; a basename starting with 'cmd' gets cmd's
        `/s/c` quoting, one starting with 'pwsh' or 'powershell' gets `-NoProfile -Command`, and
        anything else (Git Bash's bash.exe, for one) is run with `-c` as a POSIX shell. The prefix
        test is case-sensitive, as Go's strings.HasPrefix is. Off Windows fzf always quotes for sh,
        and Add-ZoFzfPreview sets SHELL=sh on the child.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [bool]$Windows = $IsWindows
    )
    if (-not $Windows) { return 'sh' }
    $shell = [Environment]::GetEnvironmentVariable('SHELL')
    if (-not $shell) { return 'cmd' }
    # Split on both separators by hand: [IO.Path]::GetFileName treats '\' as a plain character off
    # Windows, and the tests exercise the Windows rules on every platform.
    $base = $shell.Split([char[]]@('\', '/'))[-1]
    if ($base.StartsWith('cmd', [StringComparison]::Ordinal)) { return 'cmd' }
    if ($base.StartsWith('pwsh', [StringComparison]::Ordinal) -or $base.StartsWith('powershell', [StringComparison]::Ordinal)) { return 'pwsh' }
    'sh'
}

function ConvertTo-ZoShellWord {
    <#
    .SYNOPSIS
        Quotes a fixed path as one word for the shell fzf runs bindings with.
    .DESCRIPTION
        cmd: double quotes, and every % becomes "^%" (close the quote, a caret-escaped percent,
        reopen), because cmd expands %VAR% even inside double quotes and a doubled %% is not an
        escape on a command line (CONFIRMED: `"%%TEMP%%"` still expands under `cmd /s /c`). sh:
        single quotes, with ' written as '\'' ($, backtick, \ and " are literal inside). pwsh:
        single quotes with ' doubled.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string]$Text,

        [Parameter(Mandatory)]
        [ValidateSet('cmd', 'sh', 'pwsh')]
        [string]$Shell
    )
    switch ($Shell) {
        'cmd' { '"' + $Text.Replace('%', '"^%"') + '"' }
        'sh' { "'" + $Text.Replace("'", "'\''") + "'" }
        'pwsh' { "'" + $Text.Replace("'", "''") + "'" }
    }
}

function New-ZoEditFzfCommand {
    <#
    .SYNOPSIS
        The fzf command line for `zoxide edit`, with reload bindings that run the edit helper.
    .DESCRIPTION
        Mirrors Edit::get_fzf. The bindings, header, border label and layout are the binary's. Only
        the command inside each reload action differs: where the binary runs `zoxide edit <action>`
        (a program on PATH), this runs `<pwsh> -NoProfile -NonInteractive -File <helper> <action>`,
        because the zoxide command here is a PowerShell alias that fzf's shell cannot see. The two
        paths are quoted for the shell fzf will run the binding with (Get-ZoFzfShell,
        ConvertTo-ZoShellWord), and under pwsh the line starts with the call operator. The action
        keeps fzf's `{2..}` placeholder, which fzf quotes for that same shell. The action
        delimiter is ( ) as in the binary unless the command contains ')', in which case the first
        free pair of fzf's alternatives is used. _ZO_FZF_OPTS is not consulted, as in the binary.
    .PARAMETER Helper
        The full path of the module's edit-helper.ps1.
    .PARAMETER Pwsh
        The PowerShell executable the bindings run. Defaults to the current process.
    .PARAMETER Shell
        cmd, sh or pwsh: the shell fzf runs the bindings with. Defaults to Get-ZoFzfShell.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an in-memory object only.')]
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [string]$Path,

        [Parameter(Mandatory)]
        [string]$Helper,

        [string]$Pwsh = [Environment]::ProcessPath,

        [ValidateSet('', 'cmd', 'sh', 'pwsh')]
        [string]$Shell = '',

        [bool]$Windows = $IsWindows,

        [bool]$Linux = $IsLinux
    )
    if (-not $Shell) { $Shell = Get-ZoFzfShell -Windows $Windows }
    $call = if ($Shell -eq 'pwsh') { '& ' } else { '' }
    $run = $call + (ConvertTo-ZoShellWord -Text $Pwsh -Shell $Shell) + ' -NoProfile -NonInteractive -File ' + (ConvertTo-ZoShellWord -Text $Helper -Shell $Shell)
    $open, $close = '(', ')'
    foreach ($pair in @('()', '[]', '<>', '~~', '!!', '@@', '##', '%%', '^^', '**', ';;', '||')) {
        if (-not $run.Contains($pair[1])) { $open, $close = $pair[0], $pair[1]; break }
    }
    $reload = "reload$open$run reload$close"
    $action = { param([string]$Name) "reload$open$run $Name {2..}$close" }
    $bind = "--bind=btab:up,ctrl-r:$reload,ctrl-d:$(& $action delete),ctrl-w:$(& $action increment)," +
    "ctrl-s:$(& $action decrement),ctrl-z:ignore,double-click:ignore,enter:abort,start:$reload,tab:down"

    $command = New-ZoFzfCommand -Path $Path
    $command.Arguments.AddRange([string[]]@(
        '--exact', '--no-sort', $bind, '--cycle', '--keep-right', '--border=sharp', '--border-label= zoxide-edit ',
        "--header=ctrl-r:reload `tctrl-d:delete`nctrl-w:increment`tctrl-s:decrement`n`n SCORE`tPATH",
        '--info=inline', '--layout=reverse', '--padding=1,0,0,0', '--color=label:bold', '--tabstop=1'
    ))
    Add-ZoFzfPreview -Command $command -Windows $Windows -Linux $Linux
    $command
}

function New-ZoFeed {
    <#
    .SYNOPSIS
        Wraps a query stream as the entry source for fzf or the console picker.
    .DESCRIPTION
        Mirrors the loop in Query::query_interactive: entries come from the stream in score order,
        the --exclude path is skipped, and each is formatted with its score and a tab separator.
    .PARAMETER Exclude
        The --exclude path, or $null when there is none ('' is a real value).
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an in-memory object only.')]
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [object]$Stream,

        [Parameter(Mandatory)]
        [uint64]$Now,

        [AllowNull()]
        [object]$Exclude = $null
    )
    [pscustomobject]@{ PSTypeName = 'PSZoxide.Feed'; Stream = $Stream; Now = $Now; Exclude = $Exclude }
}

function Get-ZoFeedNext {
    <#
    .SYNOPSIS
        The next fzf feed line from a feed, or $null when the stream is done.
    .DESCRIPTION
        Mirrors `dir.display().with_score(now).with_separator('\t')` for each stream entry whose
        path is not the --exclude path.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [object]$Feed
    )
    while ($null -ne ($dir = Get-ZoStreamNext -Stream $Feed.Stream)) {
        if ($null -ne $Feed.Exclude -and $dir.Path -ceq $Feed.Exclude) { continue }
        return Format-ZoDir -Dir $dir -Now $Feed.Now -Separator "`t"
    }
    $null
}

function Invoke-ZoFzf {
    <#
    .SYNOPSIS
        Runs fzf, feeds it the entries and returns what it printed.
    .DESCRIPTION
        Mirrors Fzf::spawn, FzfChild::write and FzfChild::wait. The program starts from its full
        path with UseShellExecute off; stdin and stdout are redirected as UTF-8 without a BOM and
        stderr is left alone (fzf draws its interface on the terminal). The feed's lines go to
        stdin, each followed by NUL (--read0), and stdin is closed when they run out or when fzf
        stops reading (a broken pipe ends the feed, as in FzfChild::write). The selection is read
        from stdout. Exit codes: 0 returns the output; 1 throws 'no match found'; 2 throws 'fzf
        returned an error'; 130 throws a silent exit (New-ZoSilentExit -Code 130); 128 to 254
        throw 'fzf was terminated'; anything else throws 'fzf returned an unknown error'. A program
        that cannot be found throws 'could not find fzf, is it installed?' and any other launch
        failure 'could not launch fzf' with the cause.
    .PARAMETER Feed
        A feed from New-ZoFeed, or $null to send nothing (edit loads its list with a reload binding).
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [object]$Command,

        [AllowNull()]
        [object]$Feed = $null
    )
    $utf8 = [Text.UTF8Encoding]::new($false)
    $info = [Diagnostics.ProcessStartInfo]::new($Command.FileName)
    foreach ($argument in $Command.Arguments) { $info.ArgumentList.Add($argument) }
    foreach ($name in $Command.Environment.Keys) { $info.Environment[$name] = [string]$Command.Environment[$name] }
    $info.UseShellExecute = $false
    $info.RedirectStandardInput = $true
    $info.RedirectStandardOutput = $true
    $info.StandardInputEncoding = $utf8
    $info.StandardOutputEncoding = $utf8

    try {
        $process = [Diagnostics.Process]::Start($info)
    }
    catch {
        $inner = $_.Exception
        while ($null -ne $inner.InnerException) { $inner = $inner.InnerException }
        if ($inner -is [ComponentModel.Win32Exception] -and $inner.NativeErrorCode -eq 2) { throw 'could not find fzf, is it installed?' }
        throw (New-ZoError -Chain 'could not launch fzf', $inner.Message)
    }

    try {
        $reader = $process.StandardOutput.ReadToEndAsync()
        $stdin = $process.StandardInput
        try {
            if ($null -ne $Feed) {
                while ($null -ne ($line = Get-ZoFeedNext -Feed $Feed)) {
                    $stdin.Write($line)
                    $stdin.Write([char]0)
                }
            }
            $stdin.Close()
        }
        catch [IO.IOException] {
            # fzf stopped reading (it exited or made its selection): stop feeding, as FzfChild::write
            # does on a broken pipe, and collect what it printed.
            $null = $_
        }
        $output = $reader.GetAwaiter().GetResult()
        $process.WaitForExit()
        $code = $process.ExitCode
    }
    finally {
        if (-not $process.HasExited) {
            try { $process.Kill($true) } catch { $null = $_ }
        }
        $process.Dispose()
    }

    switch ($code) {
        0 { return $output }
        1 { throw 'no match found' }
        2 { throw 'fzf returned an error' }
        130 { throw (New-ZoSilentExit -Code 130) }
        { $_ -ge 128 -and $_ -le 254 } { throw 'fzf was terminated' }
        default { throw 'fzf returned an unknown error' }
    }
}

function Select-ZoConsolePicker {
    <#
    .SYNOPSIS
        Numbered console selection for `query -i` when fzf is not installed.
    .DESCRIPTION
        A deliberate deviation (spec section 2): where the binary fails with 'could not find fzf',
        `query -i` lists the feed's entries with numbers through $script:ZoPickerWriter and reads a
        number through $script:ZoPickerReader (both default to the host UI). It returns the chosen
        feed line ("{score:>6.1}\t{path}"), the same text fzf would print, so the caller handles -s
        the same way. No entries throws 'no match found' (fzf with --exit-0 exits 1). Empty or blank
        input, or the end of input, cancels like Esc in fzf: a silent exit with code 130. Anything
        that is not a listed number asks again.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [object]$Feed
    )
    $entries = [Collections.Generic.List[string]]::new()
    while ($null -ne ($line = Get-ZoFeedNext -Feed $Feed)) { $entries.Add($line) }
    if ($entries.Count -eq 0) { throw 'no match found' }
    for ($i = 0; $i -lt $entries.Count; $i++) {
        & $script:ZoPickerWriter ('{0,3}) {1}' -f ($i + 1), $entries[$i].Replace("`t", ' '))
    }
    while ($true) {
        $reply = & $script:ZoPickerReader 'Select a directory by number (empty to cancel): '
        if ($null -eq $reply -or ([string]$reply).Trim() -eq '') { throw (New-ZoSilentExit -Code 130) }
        $number = 0
        if ([int]::TryParse(([string]$reply).Trim(), [Globalization.NumberStyles]::None, [Globalization.CultureInfo]::InvariantCulture, [ref]$number) -and
            $number -ge 1 -and $number -le $entries.Count) {
            return $entries[$number - 1]
        }
        & $script:ZoPickerWriter "Not a number from 1 to $($entries.Count)."
    }
}