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

Close #3732
pull/3746/head
Junegunn Choi 4 weeks 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
.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.

@ -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

@ -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 {

@ -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
}

@ -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()

@ -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)

@ -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)

@ -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) + "'"
}

@ -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) + "'"
}
}

@ -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)

@ -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()

Loading…
Cancel
Save