Compare commits

..
5 Commits
Author SHA1 Message Date
Junegunn Choi d3d6e40962 change-nth: empty value restores default --nth
- Same rule as change-with-nth and transform-with-nth
    - Empty: default
    - '..': all fields
    - Non-empty invalid: ignored
- Previously, change-nth() and empty transform-nth output searched
  all fields, and transform-nth could not restore default
- Partly reverts af0014ab. Use change-nth(..) to unset default
2026-10-21 08:29:43 +09:00
Junegunn Choi c943963281 change-(with-)nth: apply value after current one
- Find current value in bound list and apply next valid,
  non-current one
- Keep rotating bound list as a position hint, so repeated
  values in the list still reach every position
- Treat empty --nth and '..' as the same value
- Ignore invalid nth values, also in transform-nth, as with-nth
  does. Previously, they restored default --nth
- Previously, first value of rotated list was applied blindly
    - Key press spent on value already applied, e.g. first value
      equal to initial --nth
    - Stuck on change-nth printed by transform (new action on
      each run), as it always applied first value
- Add nextValue helper for reuse by change-layout
2026-10-20 16:19:06 +09:00
Junegunn Choi a0f6bac731 transform-(with-)nth: use whole output, no cycling
- Rotation of '|'-separated values applied to transform and
  bg-transform variants, where a.a is the command template
- Output '1|2' turned the binding into '2|1', run as a shell
  command on the next key press
- Output was also cut at '|', breaking --with-nth templates
  like '{2}|{1}'
- Split and rotate only for change-nth and change-with-nth
2026-10-20 14:09:55 +09:00
Simon Désaulniers 55c122f1a0 fix(nushell): adapt the completer to the 0.116 contract (#4934)
* fix(nushell): adapt the completer to the 0.116 contract

Nushell 0.116 runs an external completer on a background worker
without the terminal unless the registered closure reaches a
command carrying the @interactive attribute. Interactivity is
seen through the closure to the command it calls, so wrap the
completion logic in an @interactive entry point and register a
closure that ends with the call to it.

Read the named `place` input instead of the deprecated
positional `spans`; $place.command carries the same token list.

An @interactive completer cannot decline with null (null reads
as a dismissed picker), so request the fallback explicitly when
the `**` trigger is absent, keeping plain file completion
working. The previous external completer is still called when
the trigger is absent.

The script now requires Nushell 0.116+, since @interactive is a
parse error on older versions.

Fixes #4932

* fix(nushell): retry the previous completer with the legacy spans input

The fallback to the previously configured external completer always
handed it the new `place` input. A completer that still declares the
legacy `spans` parameter errors on a record, so the fallback silently
produced no completions for pre-0.116 completers (e.g. older Carapace
installs).

Try `place` first, then retry with the token list, which is what the
old positional bridge handed such completers.

* docs(nushell): note the 0.116 requirement and load order

Requires Nushell 0.116.1+, where a completion can take the terminal
through the @interactive attribute, and the wrapper captures the
external completer present at load time.
2026-10-17 11:10:52 +09:00
Daehyun KimandJunegunn Choi c8c2d0828a Fix Windows shell detection with explicit --with-shell
Close #4936

Co-authored-by: Junegunn Choi <junegunn.c@gmail.com>
2026-10-15 10:31:52 +09:00
10 changed files with 581 additions and 56 deletions

No files matched your search

+4 -2
View File
@@ -38,9 +38,11 @@ jobs:
wget -qO- https://apt.fury.io/nushell/gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/fury-nushell.gpg
echo "deb [signed-by=/etc/apt/keyrings/fury-nushell.gpg] https://apt.fury.io/nushell/ /" | sudo tee /etc/apt/sources.list.d/fury-nushell.list
sudo apt-get update
# Pinned: 0.115.0 runs external completers on a background thread, so
# Pinned: the Nushell scripts now require 0.116+, where an external
# completer reaches the terminal through the @interactive attribute.
# Before that (e.g. 0.115.0), completers run on a background thread, so
# fzf never receives keystrokes. https://github.com/nushell/nushell/issues/18771
sudo apt-get install --yes zsh fish tmux shfmt nushell=0.114.1-0
sudo apt-get install --yes zsh fish tmux shfmt nushell=0.116.1-0
- name: Install Ruby gems
run: bundle install
+26
View File
@@ -1,6 +1,32 @@
CHANGELOG
=========
0.74.6
------
- Fixed `--with-shell` on Windows not detecting cmd, PowerShell, and pwsh, which made fzf quote commands and entries for a POSIX shell (#4936) (@vimkim)
- e.g. With `--with-shell "cmd.exe /d /c"`, `--preview '"C:\Program Files\nu\bin\nu.exe" --version'` failed
- `transform-nth` and `transform-with-nth` (and their `bg-` variants) no longer split the output at `|`
- Only `change-nth` and `change-with-nth` rotate through `|`-separated values
- Fixed the bound command being replaced with the output. e.g. With `transform-nth(echo '1|2')`, the next key press ran `2|1` as a shell command.
- A `--with-nth` template in the output can now contain `|`. e.g. `{2}|{1}` was cut to `{2}`.
- `change-nth` and `transform-nth` now handle empty and invalid values the same way as `change-with-nth` and `transform-with-nth`
- An empty value restores the default `--nth`. Previously, `change-nth()` and an empty `transform-nth` output searched all fields. Use `change-nth(..)` for that.
- A non-empty invalid value is ignored. Previously, it restored the default `--nth`.
- `change-nth` and `change-with-nth` now apply the value after the current one in the `|`-separated list
- A value equal to the current one is skipped, e.g. the first value when it is the initial `--nth`
- Invalid values in the list are skipped
- The rotation follows the current value even when another binding changed it, or when the action is printed by `transform` (unless the list contains the same value more than once)
- You can list the values in their natural order, starting with the initial one
```sh
# Previously, you would have to write:
ls -al | fzf --with-nth .. --bind 'space:change-with-nth:1|2|..'
# Now you can write:
ls -al | fzf --with-nth .. --bind 'space:change-with-nth:..|1|2'
```
- nushell
- The completion integration now requires nushell 0.116.1 or above, where an interactive completer can take the terminal through the `@interactive` attribute (#4934) (@sim590)
0.74.5
------
- Fixed `--popup` unzooming the window when opened over a zoomed pane on tmux 3.8 or above (#4927, junegunn/fzf.vim#1625) (@jereviikari)
+7
View File
@@ -841,6 +841,13 @@ Fuzzy completion in Nushell works via the
[external completer](https://www.nushell.sh/cookbook/external_completers.html)
mechanism. There are some differences compared to bash and zsh:
- Nushell 0.116.1 or newer is required. The completion runs on the line-editor
thread so fzf can take the terminal; a previously configured external
completer (e.g. Carapace) is therefore invoked inline when the `**` trigger
is absent.
- The script wraps whichever external completer is configured when it loads,
so load it after any other external completer setup. The autoload setup
described above runs after `config.nu`, so it already does this.
- On Nushell >= 0.103.0, the external completer is no longer called for
built-in commands (e.g. `cd`, `ls`). Fuzzy completion with `**<TAB>` only
works for external commands.
+26 -4
View File
@@ -2060,8 +2060,8 @@ A key or an event can be bound to one or more of the following actions.
\fBchange\-list\-label(...)\fR (change \fB\-\-list\-label\fR to the given string)
\fBchange\-multi\fR (enable multi-select mode with no limit)
\fBchange\-multi(...)\fR (enable multi-select mode with a limit or disable it with 0)
\fBchange\-nth(...)\fR (change \fB\-\-nth\fR option; rotate through the multiple options separated by '|')
\fBchange\-with\-nth(...)\fR (change \fB\-\-with\-nth\fR option; rotate through the multiple options separated by '|')
\fBchange\-nth(...)\fR (change \fB\-\-nth\fR option; see CHANGE NTH AND WITH\-NTH)
\fBchange\-with\-nth(...)\fR (change \fB\-\-with\-nth\fR option; see CHANGE NTH AND WITH\-NTH)
\fBchange\-pointer(...)\fR (change \fB\-\-pointer\fR option)
\fBchange\-preview(...)\fR (change \fB\-\-preview\fR option)
\fBchange\-preview\-label(...)\fR (change \fB\-\-preview\-label\fR to the given string)
@@ -2166,8 +2166,8 @@ A key or an event can be bound to one or more of the following actions.
\fBtransform\-header\-label(...)\fR (transform header label using an external command)
\fBtransform\-input\-label(...)\fR (transform input label using an external command)
\fBtransform\-list\-label(...)\fR (transform list label using an external command)
\fBtransform\-nth(...)\fR (transform nth using an external command)
\fBtransform\-with\-nth(...)\fR (transform with-nth using an external command)
\fBtransform\-nth(...)\fR (transform nth using an external command; see CHANGE NTH AND WITH\-NTH)
\fBtransform\-with\-nth(...)\fR (transform with-nth using an external command; see CHANGE NTH AND WITH\-NTH)
\fBtransform\-pointer(...)\fR (transform pointer using an external command)
\fBtransform\-preview\-label(...)\fR (transform preview label using an external command)
\fBtransform\-prompt(...)\fR (transform prompt string using an external command)
@@ -2405,6 +2405,28 @@ e.g.
# This is equivalent to toggle\-preview action
fzf \-\-preview 'cat {}' \-\-bind 'ctrl\-/:change\-preview\-window(hidden|)'
.SS CHANGE NTH AND WITH\-NTH
\fBchange\-nth\fR and \fBchange\-with\-nth\fR actions change \fB\-\-nth\fR and
\fB\-\-with\-nth\fR options. You can specify multiple values separated by '|'
characters. Each time the action runs, fzf applies the value after the current
one in the list, skipping the values equal to the current one.
\fBtransform\-nth\fR and \fBtransform\-with\-nth\fR actions (and their
\fBbg\-\fR variants) take the output of the command as a single value. The
following rules apply to all of these actions.
* An empty value restores the default given by the option
* \fB..\fR selects all fields
* A non\-empty invalid value is ignored
e.g.
# All fields, then fields 1, 2, and 3
fzf \-\-bind 'ctrl\-/:change\-nth(..|1|2|3)'
# An empty string in the list is interpreted as the default
fzf \-\-nth 1 \-\-bind 'ctrl\-/:change\-nth(2|)'
.SH AUTHOR
Junegunn Choi (\fIjunegunn.c@gmail.com\fR)
+54 -21
View File
@@ -8,6 +8,15 @@
# An implementation of completion.nu
# This loads FZF as a Nushell External Completer
# https://www.nushell.sh/cookbook/external_completers.html
#
# Requires Nushell 0.116.0 or newer.
#
# On 0.116+, the registered completer closure must reach a command carrying the
# `@interactive` attribute: interactivity is seen through the closure to that
# command (a closure cannot carry the attribute itself), and that is what makes
# the completion run on the REPL thread with the terminal, so fzf can take it.
# A closure that does not reach such a command runs on a background worker
# without the terminal, where fzf cannot run.
# --- Default Environment Variables ---
@@ -367,9 +376,9 @@ def _fzf_complete_kill [query: string] {
# --- Main FZF External Completer ---
# This function is registered with Nushell's external completion system.
# It gets called when Tab is pressed.
let fzf_external_completer = {|spans|
# Main completion logic. It receives the token list of the command being
# completed (the legacy `spans`). Called directly, so no attribute is needed.
def __fzf_completer_logic [spans] {
let trigger: string = $env.FZF_COMPLETION_TRIGGER? | default '**'
if ($trigger | is-empty) { return null } # Cannot work with empty trigger
@@ -446,6 +455,40 @@ let fzf_external_completer = {|spans|
}
}
# Nushell 0.116+ entry point. The registered closure must END with a call to
# this command for the completion to run on the REPL thread with the terminal.
#
# It declares `place` (a named input) rather than the legacy `spans`, so the
# completer never goes through the deprecated positional bridge. `$place.command`
# is the token list of the command being completed, the same value the old
# `spans` held (with a trailing "" when the cursor sits on an empty slot).
@interactive
def __fzf_external_completer [place] {
let result = (__fzf_completer_logic $place.command)
if $result != null {
$result
} else {
# Fall back to the previously configured external completer, if any. It is
# handed the new `place` input first. A completer that still declares the
# legacy `spans` parameter errors on a record, so it is retried with the
# token list, which is what the old positional bridge handed it.
let previous = ($env.__fzf_previous_completer? | default null)
let previous_result = if $previous != null {
try {
do $previous $place
} catch {
try { do $previous $place.command } catch { null }
}
} else {
null
}
# An @interactive completer cannot decline with null: null reads as a
# dismissed picker with an empty, final answer. Request the fallback
# explicitly so file completion still runs when the trigger is absent.
if $previous_result != null { $previous_result } else { {completions: [], fallback: true} }
}
}
# --- WRAPPER AND REGISTRATION ---
# Guard against re-sourcing: wrapping the completer multiple times would
@@ -455,25 +498,15 @@ if ($env.__fzf_completer_registered? | default false) != true {
# Get the currently configured external completer, if any exists
let previous_external_completer = $env.config? | get completions? | get external? | get completer?
# Define the new wrapper completer
let fzf_wrapper_completer = {|spans|
# 1. Try the FZF completer logic first
let fzf_result = do $fzf_external_completer $spans
# Stash it for `__fzf_external_completer`: a def cannot capture a local
# variable, and the fallback to the previous completer happens inside it.
$env.__fzf_previous_completer = $previous_external_completer
# 2. If FZF returned a result (a list, even an empty one), return it.
# `null` means FZF didn't handle it because the trigger wasn't present.
if $fzf_result != null {
$fzf_result
} else {
# 3. FZF didn't handle it, so call the previous completer (if it exists).
if $previous_external_completer != null {
do $previous_external_completer $spans
} else {
# 4. No previous completer, and FZF didn't handle it. Return null.
null
}
}
}
# The registered closure must END with a call to an `@interactive` command so
# that Nushell 0.116+ runs the completion on the REPL thread with the terminal
# (it inspects the last pipeline element of the closure). Declaring `place`
# instead of `spans` keeps it off the deprecated positional bridge.
let fzf_wrapper_completer = {|place| __fzf_external_completer $place }
# Register the new wrapper completer
# This ensures external completions are enabled and sets our wrapper.
+78 -21
View File
@@ -6304,6 +6304,43 @@ func (t *Terminal) startTimers(ctx context.Context) {
}
}
// nextValue returns the first valid, non-current value after the current one
// in a '|'-separated list, and the list rotated to end with it.
// isValid can be nil.
func nextValue(expr string, isCurrent func(string) bool, isValid func(string) bool) (string, string) {
valid := func(value string) bool {
return isValid == nil || isValid(value)
}
// An invalid value is never current, even if it resolves to the current one
current := func(value string) bool {
return valid(value) && isCurrent(value)
}
values := strings.Split(expr, "|")
start := 0
// Last value is the one applied by the previous call
if !current(values[len(values)-1]) {
for i, value := range values {
if current(value) {
start = i + 1
break
}
}
}
for i := range values {
pick := (start + i) % len(values)
if value := values[pick]; valid(value) && !current(value) {
return value, strings.Join(slices.Concat(values[pick+1:], values[:pick+1]), "|")
}
}
// Nothing to pick
for _, value := range values {
if valid(value) {
return value, expr
}
}
return values[0], expr
}
// Loop is called to start Terminal I/O
func (t *Terminal) Loop() error {
// prof := profile.Start(profile.ProfilePath("/tmp/"))
@@ -7304,22 +7341,35 @@ func (t *Terminal) Loop() error {
req(reqList, reqInfo)
case actChangeNth, actTransformNth, actBgTransformNth:
capture(true, func(expr string) {
// Split nth expression
tokens := strings.Split(expr, "|")
if nth, err := splitNth(tokens[0]); err == nil || len(expr) == 0 {
// Changed
resolve := func(value string) ([]Range, bool) {
// Empty value restores the default --nth
if len(value) == 0 {
return t.nth, true
}
nth, err := splitNth(value)
return nth, err == nil
}
// Empty range list or a single '..' means all fields
allFields := func(nth []Range) bool {
return len(nth) == 0 || len(nth) == 1 && nth[0].IsFull()
}
isCurrent := func(nth []Range) bool {
return compareRanges(t.nthCurrent, nth) || allFields(t.nthCurrent) && allFields(nth)
}
// Only change-nth cycles through '|'-separated values
if a.t == actChangeNth {
expr, a.a = nextValue(expr, func(value string) bool {
nth, _ := resolve(value)
return isCurrent(nth)
}, func(value string) bool {
_, ok := resolve(value)
return ok
})
}
if nth, ok := resolve(expr); ok && !isCurrent(nth) {
newNth = &nth
} else {
// The default
newNth = &t.nth
}
// Cycle
if len(tokens) > 1 {
a.a = strings.Join(append(tokens[1:], tokens[0]), "|")
}
if !compareRanges(t.nthCurrent, *newNth) {
changed = true
t.nthCurrent = *newNth
t.nthCurrent = nth
t.forceRerenderList()
}
})
@@ -7328,15 +7378,22 @@ func (t *Terminal) Loop() error {
break Action
}
capture(true, func(expr string) {
tokens := strings.Split(expr, "|")
withNthExpr := tokens[0]
if len(tokens) > 1 {
a.a = strings.Join(append(tokens[1:], tokens[0]), "|")
resolve := func(value string) string {
// Empty value restores the default --with-nth
if len(value) == 0 {
return t.withNthDefault
}
return value
}
// Empty value restores the default --with-nth
if len(withNthExpr) == 0 {
withNthExpr = t.withNthDefault
if a.t == actChangeWithNth {
expr, a.a = nextValue(expr, func(value string) bool {
return resolve(value) == t.withNthExpr
}, func(value string) bool {
_, err := nthTransformer(resolve(value))
return err == nil
})
}
withNthExpr := resolve(expr)
if withNthExpr != t.withNthExpr {
if factory, err := nthTransformer(withNthExpr); err == nil {
newWithNth = &withNthSpec{fn: factory(t.delimiter)}
+53
View File
@@ -927,3 +927,56 @@ func TestSplitOnIND(t *testing.T) {
}
}
}
func TestNextValue(t *testing.T) {
tests := []struct {
expr string
current string
want string
rotated string
}{
{"a", "x", "a", "a"},
{"a", "a", "a", "a"},
{"a|b|c", "x", "a", "b|c|a"},
{"a|b|c", "a", "b", "c|a|b"},
{"a|b|c", "b", "c", "a|b|c"},
{"a|b|c", "c", "a", "b|c|a"},
{"a|a|b", "a", "b", "a|a|b"},
{"a|b|b", "b", "a", "b|b|a"},
{"a|a", "a", "a", "a|a"},
{"a|b|", "", "a", "b||a"},
{"|a", "", "a", "|a"},
{"a|bad|b", "a", "b", "a|bad|b"},
{"bad|a", "x", "a", "bad|a"},
{"a|bad", "a", "a", "a|bad"},
// An invalid value is never current
{"a|bad|b", "bad", "a", "bad|b|a"},
// Nothing to pick: the first valid value
{"bad|a", "a", "a", "bad|a"},
{"bad", "x", "bad", "bad"},
// The last value is checked first
{"b|a|c|a", "a", "b", "a|c|a|b"},
}
isValid := func(v string) bool { return v != "bad" }
for _, tc := range tests {
got, rotated := nextValue(tc.expr, func(v string) bool { return v == tc.current }, isValid)
if got != tc.want || rotated != tc.rotated {
t.Errorf("nextValue(%q) with current %q: got (%q, %q), want (%q, %q)",
tc.expr, tc.current, got, rotated, tc.want, tc.rotated)
}
}
}
func TestNextValueRepeated(t *testing.T) {
// Presses with a rotated list reach every position, even with a repeated value
expr := "1|2|1|3"
current := "1"
got := []string{}
for range 6 {
current, expr = nextValue(expr, func(v string) bool { return v == current }, nil)
got = append(got, current)
}
if want := "2 1 3 1 2 1"; strings.Join(got, " ") != want {
t.Errorf("got %q, want %q", strings.Join(got, " "), want)
}
}
+9 -7
View File
@@ -44,19 +44,21 @@ func NewExecutor(withShell string) *Executor {
shellType := shellTypeUnknown
basename := filepath.Base(shell)
if len(args) > 0 {
args = args[1:]
} else if strings.HasPrefix(basename, "cmd") {
defaultArgs := []string{"-c"}
if strings.HasPrefix(basename, "cmd") {
shellType = shellTypeCmd
args = []string{"/s/c"}
defaultArgs = []string{"/s/c"}
} else if strings.HasPrefix(basename, "pwsh") {
shellType = shellTypePwsh
args = []string{"-NoProfile", "-Command"}
defaultArgs = []string{"-NoProfile", "-Command"}
} else if strings.HasPrefix(basename, "powershell") {
shellType = shellTypePowerShell
args = []string{"-NoProfile", "-Command"}
defaultArgs = []string{"-NoProfile", "-Command"}
}
if len(args) > 0 {
args = args[1:]
} else {
args = []string{"-c"}
args = defaultArgs
}
return &Executor{shell: shell, shellType: shellType, args: args}
}
+203
View File
@@ -0,0 +1,203 @@
//go:build windows
package util
import (
"bytes"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"golang.org/x/sys/windows"
)
func TestNewWindowsExecutor(t *testing.T) {
for _, test := range []struct {
name string
envShell string
withShell string
shell string
shellType shellType
args []string
}{
{"default", "", "", "cmd", shellTypeCmd, []string{"/s/c"}},
{"cmd_env", "cmd.exe", "", "cmd.exe", shellTypeCmd, []string{"/s/c"}},
{"cmd_path_env", `C:\Windows\System32\cmd.exe`, "", `C:\Windows\System32\cmd.exe`, shellTypeCmd, []string{"/s/c"}},
{"powershell_env", "powershell.exe", "", "powershell.exe", shellTypePowerShell, []string{"-NoProfile", "-Command"}},
{"pwsh_env", "pwsh.exe", "", "pwsh.exe", shellTypePwsh, []string{"-NoProfile", "-Command"}},
{"custom_env", "custom-shell", "", "custom-shell", shellTypeUnknown, []string{"-c"}},
{"cmd_explicit", "powershell.exe", "cmd.exe /d /c", "cmd.exe", shellTypeCmd, []string{"/d", "/c"}},
{"cmd_path_explicit", "", `C:\Windows\System32\cmd.exe /d /s /c`, `C:\Windows\System32\cmd.exe`, shellTypeCmd, []string{"/d", "/s", "/c"}},
{"powershell_explicit", "cmd.exe", "powershell.exe -NoLogo -NonInteractive -Command", "powershell.exe", shellTypePowerShell, []string{"-NoLogo", "-NonInteractive", "-Command"}},
{"pwsh_explicit", "cmd.exe", "pwsh.exe -NoLogo -Command", "pwsh.exe", shellTypePwsh, []string{"-NoLogo", "-Command"}},
{"custom_explicit", "cmd.exe", "custom-shell --custom -e", "custom-shell", shellTypeUnknown, []string{"--custom", "-e"}},
{"cmd_no_flags", "", "cmd.exe", "cmd.exe", shellTypeCmd, []string{}},
{"powershell_no_flags", "", "powershell.exe", "powershell.exe", shellTypePowerShell, []string{}},
{"pwsh_no_flags", "", "pwsh.exe", "pwsh.exe", shellTypePwsh, []string{}},
{"custom_no_flags", "", "custom-shell", "custom-shell", shellTypeUnknown, []string{}},
} {
t.Run(test.name, func(t *testing.T) {
t.Setenv("SHELL", test.envShell)
x := NewExecutor(test.withShell)
if x.shell != test.shell || x.shellType != test.shellType || !reflect.DeepEqual(x.args, test.args) {
t.Errorf("got shell=%q type=%v args=%q; want shell=%q type=%v args=%q",
x.shell, x.shellType, x.args, test.shell, test.shellType, test.args)
}
})
}
}
func TestWindowsExecutorQuoteEntry(t *testing.T) {
entry := `hello 한글 O'Brien & "quoted"`
for _, test := range []struct {
withShell string
want string
}{
{"cmd.exe /d /c", `^"hello 한글 O'Brien ^& \^"quoted\^"^"`},
{"powershell.exe -NoProfile -Command", `'hello 한글 O''Brien & \"quoted\"'`},
{"pwsh.exe -NoProfile -Command", `'hello 한글 O''Brien & \"quoted\"'`},
{"custom-shell -c", `'hello 한글 O'\''Brien & "quoted"'`},
} {
t.Run(test.withShell, func(t *testing.T) {
if got := NewExecutor(test.withShell).QuoteEntry(entry); got != test.want {
t.Errorf("got %q; want %q", got, test.want)
}
})
}
}
func TestWindowsExecutorProcessGroup(t *testing.T) {
for _, test := range []struct {
withShell string
flags uint32
args []string
}{
{"cmd.exe /d /c", 0, nil},
{"powershell.exe -NoLogo -Command", 0, []string{"-NoLogo", "-Command"}},
{"pwsh.exe -NoLogo -Command", windows.CREATE_NEW_PROCESS_GROUP, []string{"-NonInteractive", "-NoLogo", "-Command"}},
{"custom-shell -e", windows.CREATE_NEW_PROCESS_GROUP, []string{"-e"}},
} {
t.Run(test.withShell, func(t *testing.T) {
x := NewExecutor(test.withShell)
originalArgs := append([]string{}, x.args...)
for _, setpgid := range []bool{false, true} {
cmd := x.ExecCommand("echo hello", setpgid)
var wantFlags uint32
if setpgid {
wantFlags = test.flags
}
if cmd.SysProcAttr.CreationFlags != wantFlags {
t.Errorf("setpgid=%v: got flags=%v; want %v", setpgid, cmd.SysProcAttr.CreationFlags, wantFlags)
}
if strings.HasPrefix(test.withShell, "cmd") {
if cmd.SysProcAttr.CmdLine != `/d /c "echo hello"` {
t.Errorf("got CMD command line %q", cmd.SysProcAttr.CmdLine)
}
} else {
wantArgs := test.args
if !setpgid && strings.HasPrefix(test.withShell, "pwsh") {
wantArgs = wantArgs[1:]
}
want := append(append([]string{cmd.Args[0]}, wantArgs...), "echo hello")
if !reflect.DeepEqual(cmd.Args, want) {
t.Errorf("setpgid=%v: got args=%q; want %q", setpgid, cmd.Args, want)
}
}
}
if !reflect.DeepEqual(x.args, originalArgs) {
t.Errorf("ExecCommand changed executor arguments: %q", x.args)
}
})
}
}
func TestWindowsExecutorQuotedCommand(t *testing.T) {
// Run the test binary as a helper so this regression needs no external tools.
// Both its path and its argument exercise Windows shell quoting.
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(executable)
if err != nil {
t.Fatal(err)
}
dir := filepath.Join(t.TempDir(), "quoted path 한글 & O'Brien")
if err := os.Mkdir(dir, 0700); err != nil {
t.Fatal(err)
}
helper := filepath.Join(dir, "shell-probe.exe")
if err := os.WriteFile(helper, data, 0700); err != nil {
t.Fatal(err)
}
for _, test := range []struct {
shell string
withShell string
prefix string
}{
{"cmd.exe", "", ""},
{"cmd.exe", "cmd.exe /d /c", ""},
{"powershell.exe", "", "& "},
{"powershell.exe", "powershell.exe -NoProfile -NonInteractive -Command", "& "},
{"pwsh.exe", "", "& "},
{"pwsh.exe", "pwsh.exe -NoProfile -Command", "& "},
} {
name := "default"
if test.withShell != "" {
name = "explicit"
}
t.Run(test.shell+"/"+name, func(t *testing.T) {
if _, err := exec.LookPath(test.shell); err != nil {
t.Skipf("%s is unavailable: %v", test.shell, err)
}
t.Setenv("SHELL", test.shell)
x := NewExecutor(test.withShell)
entry := `hello 한글 O'Brien &|<>()^%!`
if test.prefix == "" {
entry += ` "quoted" C:\trailing\`
}
command := test.prefix + `"` + helper + `" ` + x.QuoteEntry("-test.run=TestWindowsExecutorHelper") + " -- " + x.QuoteEntry(entry)
cmd := x.ExecCommand(command, true)
cmd.Env = append(os.Environ(), "FZF_WINDOWS_EXECUTOR_HELPER=1")
var output bytes.Buffer
cmd.Stdout = &output
cmd.Stderr = &output
cmd.WaitDelay = 10 * time.Second
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
timer := time.AfterFunc(10*time.Second, func() { _ = cmd.Process.Kill() })
defer timer.Stop()
if err := cmd.Wait(); err != nil {
t.Fatalf("command failed: %v\n%s", err, output.String())
}
var got []string
if err := json.Unmarshal(output.Bytes(), &got); err != nil {
t.Fatalf("invalid helper output: %v\n%s", err, output.String())
}
if !reflect.DeepEqual(got, []string{entry}) {
t.Errorf("got arguments %q; want %q", got, []string{entry})
}
})
}
}
func TestWindowsExecutorHelper(t *testing.T) {
if os.Getenv("FZF_WINDOWS_EXECUTOR_HELPER") != "1" {
return
}
for index, arg := range os.Args {
if arg == "--" {
if err := json.NewEncoder(os.Stdout).Encode(os.Args[index+1:]); err != nil {
os.Exit(1)
}
os.Exit(0)
}
}
os.Exit(1)
}
+121 -1
View File
@@ -2342,6 +2342,94 @@ class TestCore < TestInteractive
end
end
def test_change_nth_next_value
tmux.send_keys %(echo 'a b c d' | #{FZF} --nth 1 --bind 'space:change-nth(1|2|3|4),x:change-nth(3),result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
tmux.until { |lines| assert lines.any_include?('[1]>') }
tmux.send_keys :Space
tmux.until { |lines| assert lines.any_include?('[2]>') }
# Changed by another binding. The next value is the one after '3'.
tmux.send_keys :x
tmux.until { |lines| assert lines.any_include?('[3]>') }
tmux.send_keys :Space
tmux.until { |lines| assert lines.any_include?('[4]>') }
tmux.send_keys :Space
tmux.until { |lines| assert lines.any_include?('[1]>') }
end
def test_change_nth_all_fields_is_current
# '--nth ..' is stored as an empty list, which must match '..'
tmux.send_keys %(echo 'a b c' | #{FZF} --nth .. --bind 'space:change-nth(..|1|2)' --bind 'result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
tmux.until { |lines| assert lines.any_include?('[]>') }
tmux.send_keys :Space
tmux.until { |lines| assert lines.any_include?('[1]>') }
end
def test_change_nth_repeated_value
tmux.send_keys %(echo 'a b c' | #{FZF} --nth 1 --bind 'space:change-nth(1|2|1|3)' --bind 'result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
tmux.until { |lines| assert lines.any_include?('[1]>') }
%w[2 1 3 1 2].each do |nth|
tmux.send_keys :Space
tmux.until { |lines| assert lines.any_include?("[#{nth}]>") }
end
end
def test_change_nth_skips_invalid_value
tmux.send_keys %(echo 'a b c' | #{FZF} --nth 2 --bind 'space:change-nth(1|x|3)' --bind 'result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
tmux.until { |lines| assert lines.any_include?('[2]>') }
%w[1 3 1].each do |nth|
tmux.send_keys :Space
tmux.until { |lines| assert lines.any_include?("[#{nth}]>") }
end
end
def test_change_nth_only_invalid_values_left
# 'x' is invalid and '3' is current, so nothing changes
tmux.send_keys %(echo 'a b c' | #{FZF} --nth 1 --bind 'a:change-nth(3),space:change-nth(x|3)+transform-prompt:echo "S[$FZF_NTH]> "' --bind 'result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
tmux.until { |lines| assert lines.any_include?('[1]>') }
tmux.send_keys :a
tmux.until { |lines| assert lines.any_include?('[3]>') }
tmux.send_keys :Space
tmux.until { |lines| assert lines.any_include?('S[3]>') }
end
def test_change_nth_invalid_value_ignored
tmux.send_keys %(echo 'a b c' | #{FZF} --nth 1 --bind 'a:change-nth(3),space:change-nth(bad)+transform-prompt:echo "S[$FZF_NTH]> "' --bind 'b:transform-nth(echo bad)+transform-prompt:echo "T[$FZF_NTH]> "' --bind 'result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
tmux.until { |lines| assert lines.any_include?('[1]>') }
tmux.send_keys :a
tmux.until { |lines| assert lines.any_include?('[3]>') }
tmux.send_keys :Space
tmux.until { |lines| assert lines.any_include?('S[3]>') }
tmux.send_keys :b
tmux.until { |lines| assert lines.any_include?('T[3]>') }
end
def test_change_nth_from_transform
# A new action is created on each run, so the next value is found from the current state
tmux.send_keys %(echo 'a b c' | #{FZF} --nth 1 --bind 'space:transform:echo "change-nth(1|2|3)"' --bind 'result:transform-prompt:echo "[$FZF_NTH]> "'), :Enter
tmux.until { |lines| assert lines.any_include?('[1]>') }
%w[2 3 1].each do |nth|
tmux.send_keys :Space
tmux.until { |lines| assert lines.any_include?("[#{nth}]>") }
end
end
def test_change_with_nth_skips_default_value
tmux.send_keys %(echo 'a b c' | #{FZF} --with-nth 1 --bind 'space:change-with-nth(|2)'), :Enter
tmux.until { |lines| assert lines.any_include?('> a') }
# Empty value is the default (field 1), which is current
tmux.send_keys :Space
tmux.until { |lines| assert lines.any_include?('> b') }
end
def test_change_with_nth_skips_invalid_value
tmux.send_keys %(echo 'a b c' | #{FZF} --with-nth 1 --bind 'space:change-with-nth(1|foo|2)'), :Enter
tmux.until { |lines| assert lines.any_include?('> a') }
tmux.send_keys :Space
tmux.until { |lines| assert lines.any_include?('> b') }
tmux.send_keys :Space
tmux.until { |lines| assert lines.any_include?('> a') }
end
def test_transform_with_nth_search
input = [
'alpha bravo charlie',
@@ -2374,6 +2462,25 @@ class TestCore < TestInteractive
tmux.until { |lines| assert lines.any_include?('a b c') || lines.any_include?('d e f') }
end
def test_transform_nth_output_does_not_replace_command
# '|' in the output must not be treated as a list to cycle through
File.write(tempname, '')
tmux.send_keys %(echo 'a b' | #{FZF} --with-nth .. --bind "space:transform-nth(echo >> #{tempname}; echo '1|2')+transform-with-nth(echo >> #{tempname}; echo '1|2')"), :Enter
tmux.until { |lines| assert_equal 1, lines.item_count }
3.times do |i|
tmux.send_keys :Space
wait { assert_equal 2 * (i + 1), File.readlines(tempname).length }
end
end
def test_transform_with_nth_output_with_pipe
File.write(tempname, '{2}|{1}')
tmux.send_keys %(echo 'a b' | #{FZF} --with-nth .. --bind 'space:transform-with-nth(cat #{tempname})'), :Enter
tmux.until { |lines| assert lines.any_include?('a b') }
tmux.send_keys :Space
tmux.until { |lines| assert lines.any_include?('b|a') }
end
# Regression: actions emitted by bg-transform must affect the iteration that
# processes the async result, not the (no-longer-active) iteration that
# scheduled the transform. Covers reload (newCommand) and exclude (denylist).
@@ -2926,7 +3033,7 @@ class TestCore < TestInteractive
end
def test_change_nth_unset_default
tmux.send_keys %(echo foo bar | #{FZF} --nth 2 --query fb --bind space:change-nth:), :Enter
tmux.send_keys %(echo foo bar | #{FZF} --nth 2 --query fb --bind space:change-nth:..), :Enter
tmux.until do
assert_equal 1, it.item_count
assert_equal 0, it.match_count
@@ -2940,6 +3047,19 @@ class TestCore < TestInteractive
end
end
def test_change_nth_empty_restores_default
tmux.send_keys %(echo foo bar | #{FZF} --nth 2 --query fb --bind 'a:change-nth(..),space:change-nth(),b:transform-nth(true)'), :Enter
tmux.until { |lines| assert_equal 0, lines.match_count }
tmux.send_keys :a
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :Space
tmux.until { |lines| assert_equal 0, lines.match_count }
tmux.send_keys :a
tmux.until { |lines| assert_equal 1, lines.match_count }
tmux.send_keys :b
tmux.until { |lines| assert_equal 0, lines.match_count }
end
def test_change_header_lines
tmux.send_keys %(seq 10 | #{FZF} --header-lines 3 --bind 'space:change-header-lines(5),enter:transform-header-lines(echo 1)'), :Enter
tmux.until do |lines|