From 0ed4493856dee47d70f58cd189fb952f3a51cd70 Mon Sep 17 00:00:00 2001 From: Junegunn Choi Date: Sun, 21 Apr 2024 10:24:36 +0900 Subject: [PATCH] Add --with-shell for shelling out with different command and flags Close #3732 --- man/man1/fzf.1 | 8 ++++ src/core.go | 9 +++-- src/options.go | 6 +++ src/reader.go | 7 ++-- src/reader_test.go | 3 +- src/terminal.go | 18 +++++---- src/terminal_test.go | 4 +- src/terminal_unix.go | 19 --------- src/terminal_windows.go | 26 ------------ src/util/util_unix.go | 44 ++++++++++++++++---- src/util/util_windows.go | 87 +++++++++++++++++++++++----------------- 11 files changed, 126 insertions(+), 105 deletions(-) diff --git a/man/man1/fzf.1 b/man/man1/fzf.1 index 458c6a5f..228d9d79 100644 --- a/man/man1/fzf.1 +++ b/man/man1/fzf.1 @@ -818,6 +818,14 @@ the finder only after the input stream is complete. e.g. \fBfzf --multi | fzf --sync\fR .RE .TP +.B "--with-shell=STR" +Shell command prefix to start child processes with. The default value is +\fB$SHELL -c\fR if \fBSHELL\fR is set, otherwise \fBsh -c\fR on *nix systems. + +.RS +e.g. \fBfzf --with-shell 'ruby -e' --preview 'puts {}.upcase.reverse'\fR +.RE +.TP .B "--listen[=[ADDR:]PORT]" "--listen-unsafe[=[ADDR:]PORT]" Start HTTP server and listen on the given address. It allows external processes to send actions to perform via POST method. diff --git a/src/core.go b/src/core.go index ec137698..b65816e3 100644 --- a/src/core.go +++ b/src/core.go @@ -121,13 +121,16 @@ func Run(opts *Options, version string, revision string) { }) } + // Process executor + executor := util.NewExecutor(opts.WithShell) + // Reader streamingFilter := opts.Filter != nil && !sort && !opts.Tac && !opts.Sync var reader *Reader if !streamingFilter { reader = NewReader(func(data []byte) bool { return chunkList.Push(data) - }, eventBox, opts.ReadZero, opts.Filter == nil) + }, executor, eventBox, opts.ReadZero, opts.Filter == nil) go reader.ReadSource(opts.WalkerRoot, opts.WalkerOpts, opts.WalkerSkip) } @@ -178,7 +181,7 @@ func Run(opts *Options, version string, revision string) { mutex.Unlock() } return false - }, eventBox, opts.ReadZero, false) + }, executor, eventBox, opts.ReadZero, false) reader.ReadSource(opts.WalkerRoot, opts.WalkerOpts, opts.WalkerSkip) } else { eventBox.Unwatch(EvtReadNew) @@ -209,7 +212,7 @@ func Run(opts *Options, version string, revision string) { go matcher.Loop() // Terminal I/O - terminal := NewTerminal(opts, eventBox) + terminal := NewTerminal(opts, eventBox, executor) maxFit := 0 // Maximum number of items that can fit on screen padHeight := 0 heightUnknown := opts.Height.auto diff --git a/src/options.go b/src/options.go index c8a3fa15..4584e284 100644 --- a/src/options.go +++ b/src/options.go @@ -120,6 +120,7 @@ const usage = `usage: fzf [options] --read0 Read input delimited by ASCII NUL characters --print0 Print output delimited by ASCII NUL characters --sync Synchronous search for multi-staged filtering + --with-shell=STR Shell command prefix to start child processes with --listen[=[ADDR:]PORT] Start HTTP server to receive actions (POST /) (To allow remote process execution, use --listen-unsafe) --version Display version information and exit @@ -356,6 +357,7 @@ type Options struct { Unicode bool Ambidouble bool Tabstop int + WithShell string ListenAddr *listenAddress Unsafe bool ClearOnExit bool @@ -1957,6 +1959,8 @@ func parseOptions(opts *Options, allArgs []string) { nextString(allArgs, &i, "padding required (TRBL / TB,RL / T,RL,B / T,R,B,L)")) case "--tabstop": opts.Tabstop = nextInt(allArgs, &i, "tab stop required") + case "--with-shell": + opts.WithShell = nextString(allArgs, &i, "shell command and flags required") case "--listen", "--listen-unsafe": given, str := optionalNextString(allArgs, &i) addr := defaultListenAddr @@ -2073,6 +2077,8 @@ func parseOptions(opts *Options, allArgs []string) { opts.Padding = parseMargin("padding", value) } else if match, value := optString(arg, "--tabstop="); match { opts.Tabstop = atoi(value) + } else if match, value := optString(arg, "--with-shell="); match { + opts.WithShell = value } else if match, value := optString(arg, "--listen="); match { addr, err := parseListenAddress(value) if err != nil { diff --git a/src/reader.go b/src/reader.go index 82648a68..c4f7662a 100644 --- a/src/reader.go +++ b/src/reader.go @@ -18,6 +18,7 @@ import ( // Reader reads from command or standard input type Reader struct { pusher func([]byte) bool + executor *util.Executor eventBox *util.EventBox delimNil bool event int32 @@ -30,8 +31,8 @@ type Reader struct { } // NewReader returns new Reader object -func NewReader(pusher func([]byte) bool, eventBox *util.EventBox, delimNil bool, wait bool) *Reader { - return &Reader{pusher, eventBox, delimNil, int32(EvtReady), make(chan bool, 1), sync.Mutex{}, nil, nil, false, wait} +func NewReader(pusher func([]byte) bool, executor *util.Executor, eventBox *util.EventBox, delimNil bool, wait bool) *Reader { + return &Reader{pusher, executor, eventBox, delimNil, int32(EvtReady), make(chan bool, 1), sync.Mutex{}, nil, nil, false, wait} } func (r *Reader) startEventPoller() { @@ -242,7 +243,7 @@ func (r *Reader) readFromCommand(command string, environ []string) bool { r.mutex.Lock() r.killed = false r.command = &command - r.exec = util.ExecCommand(command, true) + r.exec = r.executor.ExecCommand(command, true) if environ != nil { r.exec.Env = environ } diff --git a/src/reader_test.go b/src/reader_test.go index bf06fd09..65918375 100644 --- a/src/reader_test.go +++ b/src/reader_test.go @@ -10,9 +10,10 @@ import ( func TestReadFromCommand(t *testing.T) { strs := []string{} eb := util.NewEventBox() + exec := util.NewExecutor("") reader := NewReader( func(s []byte) bool { strs = append(strs, string(s)); return true }, - eb, false, true) + exec, eb, false, true) reader.startEventPoller() diff --git a/src/terminal.go b/src/terminal.go index 25f30150..3a5bf794 100644 --- a/src/terminal.go +++ b/src/terminal.go @@ -245,6 +245,7 @@ type Terminal struct { listenUnsafe bool borderShape tui.BorderShape cleanExit bool + executor *util.Executor paused bool border tui.Window window tui.Window @@ -640,7 +641,7 @@ func evaluateHeight(opts *Options, termHeight int) int { } // NewTerminal returns new Terminal object -func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal { +func NewTerminal(opts *Options, eventBox *util.EventBox, executor *util.Executor) *Terminal { input := trimQuery(opts.Query) var delay time.Duration if opts.Tac { @@ -736,6 +737,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal { previewLabel: nil, previewLabelOpts: opts.PreviewLabel, cleanExit: opts.ClearOnExit, + executor: executor, paused: opts.Phony, cycle: opts.Cycle, headerVisible: true, @@ -2522,6 +2524,7 @@ type replacePlaceholderParams struct { allItems []*Item lastAction actionType prompt string + executor *util.Executor } func (t *Terminal) replacePlaceholder(template string, forcePlus bool, input string, list []*Item) string { @@ -2535,6 +2538,7 @@ func (t *Terminal) replacePlaceholder(template string, forcePlus bool, input str allItems: list, lastAction: t.lastAction, prompt: t.promptString, + executor: t.executor, }) } @@ -2595,7 +2599,7 @@ func replacePlaceholder(params replacePlaceholderParams) string { case escaped: return match case match == "{q}" || match == "{fzf:query}": - return quoteEntry(params.query) + return params.executor.QuoteEntry(params.query) case match == "{}": replace = func(item *Item) string { switch { @@ -2608,13 +2612,13 @@ func replacePlaceholder(params replacePlaceholderParams) string { case flags.file: return item.AsString(params.stripAnsi) default: - return quoteEntry(item.AsString(params.stripAnsi)) + return params.executor.QuoteEntry(item.AsString(params.stripAnsi)) } } case match == "{fzf:action}": return params.lastAction.Name() case match == "{fzf:prompt}": - return quoteEntry(params.prompt) + return params.executor.QuoteEntry(params.prompt) default: // token type and also failover (below) rangeExpressions := strings.Split(match[1:len(match)-1], ",") @@ -2648,7 +2652,7 @@ func replacePlaceholder(params replacePlaceholderParams) string { str = strings.TrimSpace(str) } if !flags.file { - str = quoteEntry(str) + str = params.executor.QuoteEntry(str) } return str } @@ -2688,7 +2692,7 @@ func (t *Terminal) executeCommand(template string, forcePlus bool, background bo return line } command := t.replacePlaceholder(template, forcePlus, string(t.input), list) - cmd := util.ExecCommand(command, false) + cmd := t.executor.ExecCommand(command, false) cmd.Env = t.environ() t.executing.Set(true) if !background { @@ -2965,7 +2969,7 @@ func (t *Terminal) Loop() { if items[0] != nil { _, query := t.Input() command := t.replacePlaceholder(commandTemplate, false, string(query), items) - cmd := util.ExecCommand(command, true) + cmd := t.executor.ExecCommand(command, true) env := t.environ() if pwindowSize.Lines > 0 { lines := fmt.Sprintf("LINES=%d", pwindowSize.Lines) diff --git a/src/terminal_test.go b/src/terminal_test.go index e7d3e751..9fc53919 100644 --- a/src/terminal_test.go +++ b/src/terminal_test.go @@ -23,6 +23,7 @@ func replacePlaceholderTest(template string, stripAnsi bool, delimiter Delimiter allItems: allItems, lastAction: actBackwardDeleteCharEof, prompt: "prompt", + executor: util.NewExecutor(""), }) } @@ -244,6 +245,7 @@ func TestQuoteEntry(t *testing.T) { unixStyle := quotes{``, `'`, `'\''`, `"`, `\`} windowsStyle := quotes{`^`, `^"`, `'`, `\^"`, `\\`} var effectiveStyle quotes + exec := util.NewExecutor("") if util.IsWindows() { effectiveStyle = windowsStyle @@ -278,7 +280,7 @@ func TestQuoteEntry(t *testing.T) { } for input, expected := range tests { - escaped := quoteEntry(input) + escaped := exec.QuoteEntry(input) expected = templateToString(expected, effectiveStyle) if escaped != expected { t.Errorf("Input: %s, expected: %s, actual %s", input, expected, escaped) diff --git a/src/terminal_unix.go b/src/terminal_unix.go index c7fa7f12..d0b00f2f 100644 --- a/src/terminal_unix.go +++ b/src/terminal_unix.go @@ -5,26 +5,11 @@ package fzf import ( "os" "os/signal" - "strings" "syscall" "golang.org/x/sys/unix" ) -var escaper *strings.Replacer - -func init() { - tokens := strings.Split(os.Getenv("SHELL"), "/") - if tokens[len(tokens)-1] == "fish" { - // https://fishshell.com/docs/current/language.html#quotes - // > The only meaningful escape sequences in single quotes are \', which - // > escapes a single quote and \\, which escapes the backslash symbol. - escaper = strings.NewReplacer("\\", "\\\\", "'", "\\'") - } else { - escaper = strings.NewReplacer("'", "'\\''") - } -} - func notifyOnResize(resizeChan chan<- os.Signal) { signal.Notify(resizeChan, syscall.SIGWINCH) } @@ -41,7 +26,3 @@ func notifyStop(p *os.Process) { func notifyOnCont(resizeChan chan<- os.Signal) { signal.Notify(resizeChan, syscall.SIGCONT) } - -func quoteEntry(entry string) string { - return "'" + escaper.Replace(entry) + "'" -} diff --git a/src/terminal_windows.go b/src/terminal_windows.go index a1ea7a22..112cd68d 100644 --- a/src/terminal_windows.go +++ b/src/terminal_windows.go @@ -4,8 +4,6 @@ package fzf import ( "os" - "regexp" - "strings" ) func notifyOnResize(resizeChan chan<- os.Signal) { @@ -19,27 +17,3 @@ func notifyStop(p *os.Process) { func notifyOnCont(resizeChan chan<- os.Signal) { // NOOP } - -func quoteEntry(entry string) string { - shell := os.Getenv("SHELL") - if len(shell) == 0 { - shell = "cmd" - } - - if strings.Contains(shell, "cmd") { - // backslash escaping is done here for applications - // (see ripgrep test case in terminal_test.go#TestWindowsCommands) - escaped := strings.Replace(entry, `\`, `\\`, -1) - escaped = `"` + strings.Replace(escaped, `"`, `\"`, -1) + `"` - // caret is the escape character for cmd shell - r, _ := regexp.Compile(`[&|<>()@^%!"]`) - return r.ReplaceAllStringFunc(escaped, func(match string) string { - return "^" + match - }) - } else if strings.Contains(shell, "pwsh") || strings.Contains(shell, "powershell") { - escaped := strings.Replace(entry, `"`, `\"`, -1) - return "'" + strings.Replace(escaped, "'", "''", -1) + "'" - } else { - return "'" + strings.Replace(entry, "'", "'\\''", -1) + "'" - } -} diff --git a/src/util/util_unix.go b/src/util/util_unix.go index 2991fd2c..190cc694 100644 --- a/src/util/util_unix.go +++ b/src/util/util_unix.go @@ -5,29 +5,57 @@ package util import ( "os" "os/exec" + "strings" "syscall" "golang.org/x/sys/unix" ) -// ExecCommand executes the given command with $SHELL -func ExecCommand(command string, setpgid bool) *exec.Cmd { +type Executor struct { + shell string + args []string + escaper *strings.Replacer +} + +func NewExecutor(withShell string) *Executor { shell := os.Getenv("SHELL") - if len(shell) == 0 { - shell = "sh" + args := strings.Fields(withShell) + if len(args) > 0 { + shell = args[0] + args = args[1:] + } else { + if len(shell) == 0 { + shell = "sh" + } + args = []string{"-c"} + } + + var escaper *strings.Replacer + tokens := strings.Split(shell, "/") + if tokens[len(tokens)-1] == "fish" { + // https://fishshell.com/docs/current/language.html#quotes + // > The only meaningful escape sequences in single quotes are \', which + // > escapes a single quote and \\, which escapes the backslash symbol. + escaper = strings.NewReplacer("\\", "\\\\", "'", "\\'") + } else { + escaper = strings.NewReplacer("'", "'\\''") } - return ExecCommandWith(shell, command, setpgid) + return &Executor{shell, args, escaper} } -// ExecCommandWith executes the given command with the specified shell -func ExecCommandWith(shell string, command string, setpgid bool) *exec.Cmd { - cmd := exec.Command(shell, "-c", command) +// ExecCommand executes the given command with $SHELL +func (x *Executor) ExecCommand(command string, setpgid bool) *exec.Cmd { + cmd := exec.Command(x.shell, append(x.args, command)...) if setpgid { cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} } return cmd } +func (x *Executor) QuoteEntry(entry string) string { + return "'" + x.escaper.Replace(entry) + "'" +} + // KillCommand kills the process for the given command func KillCommand(cmd *exec.Cmd) error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) diff --git a/src/util/util_windows.go b/src/util/util_windows.go index aa69b99d..999ba905 100644 --- a/src/util/util_windows.go +++ b/src/util/util_windows.go @@ -3,58 +3,52 @@ package util import ( - "fmt" "os" "os/exec" + "regexp" "strings" - "sync/atomic" "syscall" ) -var shellPath atomic.Value +type Executor struct { + shell string + args []string +} -// ExecCommand executes the given command with $SHELL -func ExecCommand(command string, setpgid bool) *exec.Cmd { - var shell string - if cached := shellPath.Load(); cached != nil { - shell = cached.(string) - } else { - shell = os.Getenv("SHELL") - if len(shell) == 0 { - shell = "cmd" - } else if strings.Contains(shell, "/") { - out, err := exec.Command("cygpath", "-w", shell).Output() - if err == nil { - shell = strings.Trim(string(out), "\n") - } +func NewExecutor(withShell string) *Executor { + shell := os.Getenv("SHELL") + args := strings.Fields(withShell) + if len(args) > 0 { + shell = args[0] + } else if len(shell) == 0 { + shell = "cmd" + } + if strings.Contains(shell, "/") { + out, err := exec.Command("cygpath", "-w", shell).Output() + if err == nil { + shell = strings.Trim(string(out), "\n") } - shellPath.Store(shell) } - return ExecCommandWith(shell, command, setpgid) + + if len(args) > 0 { + args = args[1:] + } else if strings.Contains(shell, "cmd") { + args = []string{"/v:on/s/c"} + } else if strings.Contains(shell, "pwsh") || strings.Contains(shell, "powershell") { + args = []string{"-NoProfile", "-Command"} + } else { + args = []string{"-c"} + } + return &Executor{shell, args} } -// ExecCommandWith executes the given command with the specified shell +// ExecCommand executes the given command with $SHELL // FIXME: setpgid is unused. We set it in the Unix implementation so that we // can kill preview process with its child processes at once. // NOTE: For "powershell", we should ideally set output encoding to UTF8, // but it is left as is now because no adverse effect has been observed. -func ExecCommandWith(shell string, command string, setpgid bool) *exec.Cmd { - var cmd *exec.Cmd - if strings.Contains(shell, "cmd") { - cmd = exec.Command(shell) - cmd.SysProcAttr = &syscall.SysProcAttr{ - HideWindow: false, - CmdLine: fmt.Sprintf(` /v:on/s/c "%s"`, command), - CreationFlags: 0, - } - return cmd - } - - if strings.Contains(shell, "pwsh") || strings.Contains(shell, "powershell") { - cmd = exec.Command(shell, "-NoProfile", "-Command", command) - } else { - cmd = exec.Command(shell, "-c", command) - } +func (x *Executor) ExecCommand(command string, setpgid bool) *exec.Cmd { + cmd := exec.Command(x.shell, append(x.args, command)...) cmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: false, CreationFlags: 0, @@ -62,6 +56,25 @@ func ExecCommandWith(shell string, command string, setpgid bool) *exec.Cmd { return cmd } +func (x *Executor) QuoteEntry(entry string) string { + if strings.Contains(x.shell, "cmd") { + // backslash escaping is done here for applications + // (see ripgrep test case in terminal_test.go#TestWindowsCommands) + escaped := strings.Replace(entry, `\`, `\\`, -1) + escaped = `"` + strings.Replace(escaped, `"`, `\"`, -1) + `"` + // caret is the escape character for cmd shell + r, _ := regexp.Compile(`[&|<>()@^%!"]`) + return r.ReplaceAllStringFunc(escaped, func(match string) string { + return "^" + match + }) + } else if strings.Contains(x.shell, "pwsh") || strings.Contains(x.shell, "powershell") { + escaped := strings.Replace(entry, `"`, `\"`, -1) + return "'" + strings.Replace(escaped, "'", "''", -1) + "'" + } else { + return "'" + strings.Replace(entry, "'", "'\\''", -1) + "'" + } +} + // KillCommand kills the process for the given command func KillCommand(cmd *exec.Cmd) error { return cmd.Process.Kill()