- $(command) is replaced by the output of command, which runs in a subshell (gosh -c), so cd/set in it do not change the shell - trailing newlines are removed; unquoted the output is split into words at blanks and newlines, "$(...)" stays one word; wildcards in the output are not expanded - nested substitutions, quotes and operators inside are handled; the list, alias and brace scanners skip $(...) as a whole - a command that consists only of a substitution has its exit status, Ctrl+C in a substitution aborts the command - $((...)) is reported as not supported Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
99 lines
2.4 KiB
Go
99 lines
2.4 KiB
Go
package main
|
|
|
|
// Command substitution: $(command) is replaced by the output of command,
|
|
// which runs in a subshell (a new gosh process), so that cd, set etc. in it
|
|
// do not change the shell.
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"syscall"
|
|
)
|
|
|
|
// subshellPath is the gosh binary used for command substitution; empty means
|
|
// the running executable. Tests set it, since there the running executable is
|
|
// the test binary.
|
|
var subshellPath string
|
|
|
|
// substStatus is the exit status of the last command substitution of the
|
|
// current command (a command that consists only of a substitution has this
|
|
// status, like in sh).
|
|
var substStatus int
|
|
|
|
// isSubstStart reports whether a command substitution $( starts at s[i].
|
|
func isSubstStart(s string, i int) bool {
|
|
return s[i] == '$' && i+1 < len(s) && s[i+1] == '('
|
|
}
|
|
|
|
// substEnd returns the index of the ) that ends the command substitution
|
|
// starting with $( at s[i], or -1 if it is not terminated. Quotes, escapes
|
|
// and nested substitutions inside are taken into account.
|
|
func substEnd(s string, i int) int {
|
|
depth := 0
|
|
var quote byte
|
|
for j := i + 1; j < len(s); j++ {
|
|
ch := s[j]
|
|
switch {
|
|
case quote == '\'':
|
|
if ch == '\'' {
|
|
quote = 0
|
|
}
|
|
case ch == '\\':
|
|
j++
|
|
case quote == '"':
|
|
if ch == '"' {
|
|
quote = 0
|
|
} else if isSubstStart(s, j) {
|
|
end := substEnd(s, j)
|
|
if end < 0 {
|
|
return -1
|
|
}
|
|
j = end
|
|
}
|
|
case ch == '"' || ch == '\'':
|
|
quote = ch
|
|
case ch == '(':
|
|
depth++
|
|
case ch == ')':
|
|
depth--
|
|
if depth == 0 {
|
|
return j
|
|
}
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// commandSubst runs command in a subshell and returns its output without
|
|
// trailing newlines. stdin and stderr are those of the shell.
|
|
func commandSubst(command string) (string, error) {
|
|
if strings.HasPrefix(command, "(") {
|
|
return "", errors.New("arithmetic expansion $((...)) is not supported")
|
|
}
|
|
path := subshellPath
|
|
if path == "" {
|
|
var err error
|
|
if path, err = os.Executable(); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
var out bytes.Buffer
|
|
cmd := exec.Command(path, "-c", command)
|
|
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, &out, os.Stderr
|
|
err := cmd.Run()
|
|
substStatus = exitStatus(err)
|
|
if substStatus == 128+int(syscall.SIGINT) {
|
|
// Ctrl+C aborts the whole command, like in bash
|
|
return "", statusError(substStatus)
|
|
}
|
|
var exitErr *exec.ExitError
|
|
if err != nil && !errors.As(err, &exitErr) {
|
|
return "", err
|
|
}
|
|
return strings.TrimRight(out.String(), "\n"), nil
|
|
}
|