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)." } } |