Add --with-shell for shelling out with different command and flags

Close #3732
pull/3746/head
Junegunn Choi 2 months ago
parent 608232568b
commit 0ed4493856
No known key found for this signature in database
GPG Key ID: 254BC280FEF9C627

@ -818,6 +818,14 @@ the finder only after the input stream is complete.
e.g. \fBfzf --multi | fzf --sync\fR e.g. \fBfzf --multi | fzf --sync\fR
.RE .RE
.TP .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]" .B "--listen[=[ADDR:]PORT]" "--listen-unsafe[=[ADDR:]PORT]"
Start HTTP server and listen on the given address. It allows external processes Start HTTP server and listen on the given address. It allows external processes
to send actions to perform via POST method. to send actions to perform via POST method.

@ -121,13 +121,16 @@ func Run(opts *Options, version string, revision string) {
}) })
} }
// Process executor
executor := util.NewExecutor(opts.WithShell)
// Reader // Reader
streamingFilter := opts.Filter != nil && !sort && !opts.Tac && !opts.Sync streamingFilter := opts.Filter != nil && !sort && !opts.Tac && !opts.Sync
var reader *Reader var reader *Reader
if !streamingFilter { if !streamingFilter {
reader = NewReader(func(data []byte) bool { reader = NewReader(func(data []byte) bool {
return chunkList.Push(data) 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) go reader.ReadSource(opts.WalkerRoot, opts.WalkerOpts, opts.WalkerSkip)
} }
@ -178,7 +181,7 @@ func Run(opts *Options, version string, revision string) {
mutex.Unlock() mutex.Unlock()
} }
return false return false
}, eventBox, opts.ReadZero, false) }, executor, eventBox, opts.ReadZero, false)
reader.ReadSource(opts.WalkerRoot, opts.WalkerOpts, opts.WalkerSkip) reader.ReadSource(opts.WalkerRoot, opts.WalkerOpts, opts.WalkerSkip)
} else { } else {
eventBox.Unwatch(EvtReadNew) eventBox.Unwatch(EvtReadNew)
@ -209,7 +212,7 @@ func Run(opts *Options, version string, revision string) {
go matcher.Loop() go matcher.Loop()
// Terminal I/O // Terminal I/O
terminal := NewTerminal(opts, eventBox) terminal := NewTerminal(opts, eventBox, executor)
maxFit := 0 // Maximum number of items that can fit on screen maxFit := 0 // Maximum number of items that can fit on screen
padHeight := 0 padHeight := 0
heightUnknown := opts.Height.auto heightUnknown := opts.Height.auto

@ -120,6 +120,7 @@ const usage = `usage: fzf [options]
--read0 Read input delimited by ASCII NUL characters --read0 Read input delimited by ASCII NUL characters
--print0 Print output delimited by ASCII NUL characters --print0 Print output delimited by ASCII NUL characters
--sync Synchronous search for multi-staged filtering --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 /) --listen[=[ADDR:]PORT] Start HTTP server to receive actions (POST /)
(To allow remote process execution, use --listen-unsafe) (To allow remote process execution, use --listen-unsafe)
--version Display version information and exit --version Display version information and exit
@ -356,6 +357,7 @@ type Options struct {
Unicode bool Unicode bool
Ambidouble bool Ambidouble bool
Tabstop int Tabstop int
WithShell string
ListenAddr *listenAddress ListenAddr *listenAddress
Unsafe bool Unsafe bool
ClearOnExit 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)")) nextString(allArgs, &i, "padding required (TRBL / TB,RL / T,RL,B / T,R,B,L)"))
case "--tabstop": case "--tabstop":
opts.Tabstop = nextInt(allArgs, &i, "tab stop required") 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": case "--listen", "--listen-unsafe":
given, str := optionalNextString(allArgs, &i) given, str := optionalNextString(allArgs, &i)
addr := defaultListenAddr addr := defaultListenAddr
@ -2073,6 +2077,8 @@ func parseOptions(opts *Options, allArgs []string) {
opts.Padding = parseMargin("padding", value) opts.Padding = parseMargin("padding", value)
} else if match, value := optString(arg, "--tabstop="); match { } else if match, value := optString(arg, "--tabstop="); match {
opts.Tabstop = atoi(value) opts.Tabstop = atoi(value)
} else if match, value := optString(arg, "--with-shell="); match {
opts.WithShell = value
} else if match, value := optString(arg, "--listen="); match { } else if match, value := optString(arg, "--listen="); match {
addr, err := parseListenAddress(value) addr, err := parseListenAddress(value)
if err != nil { if err != nil {

@ -18,6 +18,7 @@ import (
// Reader reads from command or standard input // Reader reads from command or standard input
type Reader struct { type Reader struct {
pusher func([]byte) bool pusher func([]byte) bool
executor *util.Executor
eventBox *util.EventBox eventBox *util.EventBox
delimNil bool delimNil bool
event int32 event int32
@ -30,8 +31,8 @@ type Reader struct {
} }
// NewReader returns new Reader object // NewReader returns new Reader object
func NewReader(pusher func([]byte) bool, eventBox *util.EventBox, delimNil bool, wait bool) *Reader { func NewReader(pusher func([]byte) bool, executor *util.Executor, 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} return &Reader{pusher, executor, eventBox, delimNil, int32(EvtReady), make(chan bool, 1), sync.Mutex{}, nil, nil, false, wait}
} }
func (r *Reader) startEventPoller() { func (r *Reader) startEventPoller() {
@ -242,7 +243,7 @@ func (r *Reader) readFromCommand(command string, environ []string) bool {
r.mutex.Lock() r.mutex.Lock()
r.killed = false r.killed = false
r.command = &command r.command = &command
r.exec = util.ExecCommand(command, true) r.exec = r.executor.ExecCommand(command, true)
if environ != nil { if environ != nil {
r.exec.Env = environ r.exec.Env = environ
} }

@ -10,9 +10,10 @@ import (
func TestReadFromCommand(t *testing.T) { func TestReadFromCommand(t *testing.T) {
strs := []string{} strs := []string{}
eb := util.NewEventBox() eb := util.NewEventBox()
exec := util.NewExecutor("")
reader := NewReader( reader := NewReader(
func(s []byte) bool { strs = append(strs, string(s)); return true }, func(s []byte) bool { strs = append(strs, string(s)); return true },
eb, false, true) exec, eb, false, true)
reader.startEventPoller() reader.startEventPoller()

@ -245,6 +245,7 @@ type Terminal struct {
listenUnsafe bool listenUnsafe bool
borderShape tui.BorderShape borderShape tui.BorderShape
cleanExit bool cleanExit bool
executor *util.Executor
paused bool paused bool
border tui.Window border tui.Window
window tui.Window window tui.Window
@ -640,7 +641,7 @@ func evaluateHeight(opts *Options, termHeight int) int {
} }
// NewTerminal returns new Terminal object // 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) input := trimQuery(opts.Query)
var delay time.Duration var delay time.Duration
if opts.Tac { if opts.Tac {
@ -736,6 +737,7 @@ func NewTerminal(opts *Options, eventBox *util.EventBox) *Terminal {
previewLabel: nil, previewLabel: nil,
previewLabelOpts: opts.PreviewLabel, previewLabelOpts: opts.PreviewLabel,
cleanExit: opts.ClearOnExit, cleanExit: opts.ClearOnExit,
executor: executor,
paused: opts.Phony, paused: opts.Phony,
cycle: opts.Cycle, cycle: opts.Cycle,
headerVisible: true, headerVisible: true,
@ -2522,6 +2524,7 @@ type replacePlaceholderParams struct {
allItems []*Item allItems []*Item
lastAction actionType lastAction actionType
prompt string prompt string
executor *util.Executor
} }
func (t *Terminal) replacePlaceholder(template string, forcePlus bool, input string, list []*Item) string { 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, allItems: list,
lastAction: t.lastAction, lastAction: t.lastAction,
prompt: t.promptString, prompt: t.promptString,
executor: t.executor,
}) })
} }
@ -2595,7 +2599,7 @@ func replacePlaceholder(params replacePlaceholderParams) string {
case escaped: case escaped:
return match return match
case match == "{q}" || match == "{fzf:query}": case match == "{q}" || match == "{fzf:query}":
return quoteEntry(params.query) return params.executor.QuoteEntry(params.query)
case match == "{}": case match == "{}":
replace = func(item *Item) string { replace = func(item *Item) string {
switch { switch {
@ -2608,13 +2612,13 @@ func replacePlaceholder(params replacePlaceholderParams) string {
case flags.file: case flags.file:
return item.AsString(params.stripAnsi) return item.AsString(params.stripAnsi)
default: default:
return quoteEntry(item.AsString(params.stripAnsi)) return params.executor.QuoteEntry(item.AsString(params.stripAnsi))
} }
} }
case match == "{fzf:action}": case match == "{fzf:action}":
return params.lastAction.Name() return params.lastAction.Name()
case match == "{fzf:prompt}": case match == "{fzf:prompt}":
return quoteEntry(params.prompt) return params.executor.QuoteEntry(params.prompt)
default: default:
// token type and also failover (below) // token type and also failover (below)
rangeExpressions := strings.Split(match[1:len(match)-1], ",") rangeExpressions := strings.Split(match[1:len(match)-1], ",")
@ -2648,7 +2652,7 @@ func replacePlaceholder(params replacePlaceholderParams) string {
str = strings.TrimSpace(str) str = strings.TrimSpace(str)
} }
if !flags.file { if !flags.file {
str = quoteEntry(str) str = params.executor.QuoteEntry(str)
} }
return str return str
} }
@ -2688,7 +2692,7 @@ func (t *Terminal) executeCommand(template string, forcePlus bool, background bo
return line return line
} }
command := t.replacePlaceholder(template, forcePlus, string(t.input), list) command := t.replacePlaceholder(template, forcePlus, string(t.input), list)
cmd := util.ExecCommand(command, false) cmd := t.executor.ExecCommand(command, false)
cmd.Env = t.environ() cmd.Env = t.environ()
t.executing.Set(true) t.executing.Set(true)
if !background { if !background {
@ -2965,7 +2969,7 @@ func (t *Terminal) Loop() {
if items[0] != nil { if items[0] != nil {
_, query := t.Input() _, query := t.Input()
command := t.replacePlaceholder(commandTemplate, false, string(query), items) command := t.replacePlaceholder(commandTemplate, false, string(query), items)
cmd := util.ExecCommand(command, true) cmd := t.executor.ExecCommand(command, true)
env := t.environ() env := t.environ()
if pwindowSize.Lines > 0 { if pwindowSize.Lines > 0 {
lines := fmt.Sprintf("LINES=%d", pwindowSize.Lines) lines := fmt.Sprintf("LINES=%d", pwindowSize.Lines)

@ -23,6 +23,7 @@ func replacePlaceholderTest(template string, stripAnsi bool, delimiter Delimiter
allItems: allItems, allItems: allItems,
lastAction: actBackwardDeleteCharEof, lastAction: actBackwardDeleteCharEof,
prompt: "prompt", prompt: "prompt",
executor: util.NewExecutor(""),
}) })
} }
@ -244,6 +245,7 @@ func TestQuoteEntry(t *testing.T) {
unixStyle := quotes{``, `'`, `'\''`, `"`, `\`} unixStyle := quotes{``, `'`, `'\''`, `"`, `\`}
windowsStyle := quotes{`^`, `^"`, `'`, `\^"`, `\\`} windowsStyle := quotes{`^`, `^"`, `'`, `\^"`, `\\`}
var effectiveStyle quotes var effectiveStyle quotes
exec := util.NewExecutor("")
if util.IsWindows() { if util.IsWindows() {
effectiveStyle = windowsStyle effectiveStyle = windowsStyle
@ -278,7 +280,7 @@ func TestQuoteEntry(t *testing.T) {
} }
for input, expected := range tests { for input, expected := range tests {
escaped := quoteEntry(input) escaped := exec.QuoteEntry(input)
expected = templateToString(expected, effectiveStyle) expected = templateToString(expected, effectiveStyle)
if escaped != expected { if escaped != expected {
t.Errorf("Input: %s, expected: %s, actual %s", input, expected, escaped) t.Errorf("Input: %s, expected: %s, actual %s", input, expected, escaped)

@ -5,26 +5,11 @@ package fzf
import ( import (
"os" "os"
"os/signal" "os/signal"
"strings"
"syscall" "syscall"
"golang.org/x/sys/unix" "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) { func notifyOnResize(resizeChan chan<- os.Signal) {
signal.Notify(resizeChan, syscall.SIGWINCH) signal.Notify(resizeChan, syscall.SIGWINCH)
} }
@ -41,7 +26,3 @@ func notifyStop(p *os.Process) {
func notifyOnCont(resizeChan chan<- os.Signal) { func notifyOnCont(resizeChan chan<- os.Signal) {
signal.Notify(resizeChan, syscall.SIGCONT) signal.Notify(resizeChan, syscall.SIGCONT)
} }
func quoteEntry(entry string) string {
return "'" + escaper.Replace(entry) + "'"
}

@ -4,8 +4,6 @@ package fzf
import ( import (
"os" "os"
"regexp"
"strings"
) )
func notifyOnResize(resizeChan chan<- os.Signal) { func notifyOnResize(resizeChan chan<- os.Signal) {
@ -19,27 +17,3 @@ func notifyStop(p *os.Process) {
func notifyOnCont(resizeChan chan<- os.Signal) { func notifyOnCont(resizeChan chan<- os.Signal) {
// NOOP // 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) + "'"
}
}

@ -5,29 +5,57 @@ package util
import ( import (
"os" "os"
"os/exec" "os/exec"
"strings"
"syscall" "syscall"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
) )
// ExecCommand executes the given command with $SHELL type Executor struct {
func ExecCommand(command string, setpgid bool) *exec.Cmd { shell string
args []string
escaper *strings.Replacer
}
func NewExecutor(withShell string) *Executor {
shell := os.Getenv("SHELL") shell := os.Getenv("SHELL")
if len(shell) == 0 { args := strings.Fields(withShell)
shell = "sh" 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 // ExecCommand executes the given command with $SHELL
func ExecCommandWith(shell string, command string, setpgid bool) *exec.Cmd { func (x *Executor) ExecCommand(command string, setpgid bool) *exec.Cmd {
cmd := exec.Command(shell, "-c", command) cmd := exec.Command(x.shell, append(x.args, command)...)
if setpgid { if setpgid {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
} }
return cmd return cmd
} }
func (x *Executor) QuoteEntry(entry string) string {
return "'" + x.escaper.Replace(entry) + "'"
}
// KillCommand kills the process for the given command // KillCommand kills the process for the given command
func KillCommand(cmd *exec.Cmd) error { func KillCommand(cmd *exec.Cmd) error {
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)

@ -3,58 +3,52 @@
package util package util
import ( import (
"fmt"
"os" "os"
"os/exec" "os/exec"
"regexp"
"strings" "strings"
"sync/atomic"
"syscall" "syscall"
) )
var shellPath atomic.Value type Executor struct {
shell string
args []string
}
// ExecCommand executes the given command with $SHELL func NewExecutor(withShell string) *Executor {
func ExecCommand(command string, setpgid bool) *exec.Cmd { shell := os.Getenv("SHELL")
var shell string args := strings.Fields(withShell)
if cached := shellPath.Load(); cached != nil { if len(args) > 0 {
shell = cached.(string) shell = args[0]
} else { } else if len(shell) == 0 {
shell = os.Getenv("SHELL") shell = "cmd"
if len(shell) == 0 { }
shell = "cmd" if strings.Contains(shell, "/") {
} else if strings.Contains(shell, "/") { out, err := exec.Command("cygpath", "-w", shell).Output()
out, err := exec.Command("cygpath", "-w", shell).Output() if err == nil {
if err == nil { shell = strings.Trim(string(out), "\n")
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 // FIXME: setpgid is unused. We set it in the Unix implementation so that we
// can kill preview process with its child processes at once. // can kill preview process with its child processes at once.
// NOTE: For "powershell", we should ideally set output encoding to UTF8, // 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. // but it is left as is now because no adverse effect has been observed.
func ExecCommandWith(shell string, command string, setpgid bool) *exec.Cmd { func (x *Executor) ExecCommand(command string, setpgid bool) *exec.Cmd {
var cmd *exec.Cmd cmd := exec.Command(x.shell, append(x.args, command)...)
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)
}
cmd.SysProcAttr = &syscall.SysProcAttr{ cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: false, HideWindow: false,
CreationFlags: 0, CreationFlags: 0,
@ -62,6 +56,25 @@ func ExecCommandWith(shell string, command string, setpgid bool) *exec.Cmd {
return 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 // KillCommand kills the process for the given command
func KillCommand(cmd *exec.Cmd) error { func KillCommand(cmd *exec.Cmd) error {
return cmd.Process.Kill() return cmd.Process.Kill()

Loading…
Cancel
Save