diff --git a/README.md b/README.md index f11d7d8..bad7394 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,9 @@ Features: TERM=dumb or when output is not a terminal - Environment variable expansion ($VAR, ${VAR}, ${VAR:-default}); using an unset variable is an error, use ${VAR:-} for an empty default + - Command substitution: $(command) is replaced by the output of command + (run in a subshell); unquoted it is split into words, "$(command)" + stays one word - Command lists: a ; b (run both), a && b (b only if a succeeded), a || b (b only if a failed); $? is the exit status of the last command - Pipes (a | b) and redirections: < file, > file, >> file, 2> file, @@ -122,6 +125,26 @@ export LANG=de_DE.UTF-8 export PATH=$HOME/bin:$PATH ``` +### Befehlsersetzung + +`$(befehl)` wird durch die Ausgabe von `befehl` ersetzt: + +``` +$ echo "Heute ist $(date +%A)" +Heute ist Montag +$ kill $(pgrep -f mein-server) +$ cd $(git rev-parse --show-toplevel) +$ vim $(grep -l TODO *.go) +$ echo "Dateien: $(ls | wc -l)" +``` + +- Der Befehl läuft in einer eigenen Subshell (einem neuen gosh-Prozess): `cd`, `set` oder `export` darin ändern die laufende Shell nicht. Er darf alles enthalten, was gosh kann (Pipes, `;`, `&&`, Umleitungen, verschachtelte `$(...)`), und liest von der gleichen Eingabe, Fehlermeldungen gehen auf stderr. +- Abschließende Zeilenumbrüche der Ausgabe werden entfernt. +- Ohne Anführungszeichen wird die Ausgabe an Leerzeichen, Tabs und Zeilenumbrüchen in mehrere Wörter geteilt, `"$(befehl)"` bleibt ein einziges Wort (mit den Zeilenumbrüchen darin). +- Anders als in `sh` werden Wildcards in der Ausgabe nicht expandiert, und Zeichen wie `;` oder `|` darin sind normaler Text: Die Ausgabe ist immer nur Daten. +- Besteht ein Befehl nur aus einer Ersetzung (`$(false)`), ist ihr Exit-Status `$?`, sonst zählt der Status des Befehls. Ctrl+C während der Ersetzung bricht die ganze Zeile ab. +- In `'...'` oder mit `\$(` wird nichts ersetzt. Die alte Schreibweise mit Backticks (`` `befehl` ``) und Arithmetik `$((1+2))` gibt es nicht. + ### Befehlslisten und Exit-Status Mehrere Befehle in einer Zeile werden wie in `sh` verknüpft: diff --git a/alias.go b/alias.go index eb82d80..7b9213a 100644 --- a/alias.go +++ b/alias.go @@ -96,6 +96,16 @@ func expandAliasesIn(line string, active map[string]bool) string { } } + if quote != '\'' && !escaped && isSubstStart(line, i) { + // $(...) is copied as it is; the subshell does not expand + // aliases + if end := substEnd(line, i); end >= 0 { + out.WriteString(line[i : end+1]) + i = end + continue + } + } + out.WriteByte(ch) switch { case escaped: diff --git a/brace.go b/brace.go index 848f816..a1414a6 100644 --- a/brace.go +++ b/brace.go @@ -15,7 +15,8 @@ import ( const maxBraceWords = 100000 // expandBraceWords applies brace expansion to every word of a command (the -// text of a pipeline). Quoted or escaped braces and ${...} are left alone. +// text of a pipeline). Quoted or escaped braces, ${...} and $(...) are left +// alone. // A redirection target must expand to a single word. func expandBraceWords(input string) (string, error) { var ( @@ -69,6 +70,10 @@ func wordEnd(s string, i int) int { } case ch == '\\': i++ + case isSubstStart(s, i): + if j := substEnd(s, i); j >= 0 { + i = j + } case ch == '$' && i+1 < len(s) && s[i+1] == '{': if j := strings.IndexByte(s[i:], '}'); j >= 0 { i += j @@ -103,6 +108,12 @@ func braceScan(s string, fn func(i, depth int) bool) { case ch == '\\': i++ continue + case isSubstStart(s, i): + // The subshell does its own brace expansion + if j := substEnd(s, i); j >= 0 { + i = j + } + continue case ch == '$' && i+1 < len(s) && s[i+1] == '{': if j := strings.IndexByte(s[i:], '}'); j >= 0 { i += j diff --git a/main.go b/main.go index 0cd64ef..deb4f1d 100644 --- a/main.go +++ b/main.go @@ -131,6 +131,8 @@ type token struct { // - '...' is taken literally, nothing is expanded inside // - "..." expands $VAR and ${VAR}; \ only escapes $ " \ and ` // - outside quotes \ escapes any character +// - $(command) is replaced by the output of command (see commandSubst); +// unquoted, the output is split into words at blanks and newlines // - $VAR, ${VAR} and ${VAR:-default} are expanded outside single quotes; // an unset variable is an error, so that e.g. "rm -rf $DIR/x" never runs // with an empty $DIR @@ -190,6 +192,32 @@ func tokenize(input string) ([]token, error) { case ch == '\\': startRaw(i) escaped = true + case isSubstStart(input, i): + startRaw(i) + end := substEnd(input, i) + if end < 0 { + return nil, errors.New("missing ) in command substitution") + } + out, err := commandSubst(input[i+2 : end]) + if err != nil { + return nil, err + } + if quote == '"' { + literal(out) + inWord = true + } else { + // Unquoted, the output is split into words at blanks and + // newlines (but, unlike in sh, wildcards in it are not + // expanded) + for k, field := range strings.Fields(out) { + if k > 0 { + endWord() + } + literal(field) + inWord = true + } + } + i = end case ch == '$': startRaw(i) value, n, err := expandVar(input[i:]) @@ -1287,6 +1315,11 @@ scan: } case ch == '\\': escaped = true + case isSubstStart(input, i): + // Skip $(...) as a whole, its ; | & belong to the subshell + if j := substEnd(input, i); j >= 0 { + i = j + } case ch == '$' && i+1 < len(input) && input[i+1] == '{': // Skip ${...} as a whole, like expandVar does if j := strings.IndexByte(input[i:], '}'); j >= 0 { @@ -1367,6 +1400,7 @@ func execInput(input string) error { // the background if background is set. func execCommand(input string, background bool) error { // Brace expansion comes first, so that its results are expanded further + substStatus = 0 input, err := expandBraceWords(input) if err != nil { return err @@ -1375,8 +1409,11 @@ func execCommand(input string, background bool) error { // Split the input into words and operators, expanding variables and ~ on // the way. tokens, err := tokenize(input) - var synErr syntaxError - if errors.As(err, &synErr) { + var ( + synErr syntaxError + statusErr statusError + ) + if errors.As(err, &synErr) || errors.As(err, &statusErr) { return err } if err != nil { @@ -1387,6 +1424,10 @@ func execCommand(input string, background bool) error { return err } if len(stages) == 0 { + // A command that consists only of $(...) has its status + if substStatus != 0 { + return statusError(substStatus) + } return nil } // "exit" with stopped jobs only warns, unless it is repeated right away @@ -1814,6 +1855,9 @@ Features: TERM=dumb or when output is not a terminal - Environment variable expansion ($VAR, ${VAR}, ${VAR:-default}); using an unset variable is an error, use ${VAR:-} for an empty default + - Command substitution: $(command) is replaced by the output of command + (run in a subshell); unquoted it is split into words, "$(command)" + stays one word - Command lists: a ; b (run both), a && b (b only if a succeeded), a || b (b only if a failed); $? is the exit status of the last command - Pipes (a | b) and redirections: < file, > file, >> file, 2> file, diff --git a/main_test.go b/main_test.go index 8b6e47e..54ee6a6 100644 --- a/main_test.go +++ b/main_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "errors" + "fmt" "io" "os" "os/exec" @@ -892,6 +893,11 @@ func TestMain(m *testing.M) { // Never read the system's startup files; tests that read the user's // files set $HOME themselves sysConfDir = filepath.Join(dir, "etc") + // Command substitution runs gosh, not the test binary + subshellPath = filepath.Join(dir, "gosh") + if out, err := exec.Command("go", "build", "-o", subshellPath, ".").CombinedOutput(); err != nil { + panic(fmt.Sprintf("building gosh: %v\n%s", err, out)) + } code := m.Run() os.RemoveAll(dir) os.Exit(code) diff --git a/subst.go b/subst.go new file mode 100644 index 0000000..7beb8b8 --- /dev/null +++ b/subst.go @@ -0,0 +1,98 @@ +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 +} diff --git a/subst_test.go b/subst_test.go new file mode 100644 index 0000000..a389b2f --- /dev/null +++ b/subst_test.go @@ -0,0 +1,116 @@ +package main + +import ( + "os" + "reflect" + "testing" +) + +func TestSubstEnd(t *testing.T) { + tests := []struct { + s string + want int + }{ + {"$(echo a)", 8}, + {"$(echo a) b", 8}, + {"$(echo $(date))x", 14}, + {`$(echo ")")`, 10}, + {`$(echo ')')`, 10}, + {`$(echo \))`, 9}, + {`$(echo "$(echo ")")")`, 20}, + {"$(echo (a))", 10}, + {"$(echo a", -1}, + {`$(echo ")`, -1}, + } + for _, tc := range tests { + if got := substEnd(tc.s, 0); got != tc.want { + t.Errorf("substEnd(%q) = %d, want %d", tc.s, got, tc.want) + } + } +} + +func TestSplitList_SkipsSubstitution(t *testing.T) { + got, err := splitList(`echo $(a; b && c | d) "$(e || f)" & g`) + if err != nil { + t.Fatal(err) + } + want := []listItem{{"", `echo $(a; b && c | d) "$(e || f)" `, true}, {";", " g", false}} + if !reflect.DeepEqual(got, want) { + t.Errorf("splitList = %+v, want %+v", got, want) + } +} + +func TestExpandAliasesAndBraces_SkipSubstitution(t *testing.T) { + withAliases(t, map[string]string{"ll": "ls -l"}) + if got := expandAliases("echo $(ll; ll) && ll"); got != "echo $(ll; ll) && ls -l" { + t.Errorf("expandAliases = %q", got) + } + got, err := expandBraceWords("echo $(echo {a,b}) {c,d}") + if err != nil || got != "echo $(echo {a,b}) c d" { + t.Errorf("expandBraceWords = %q, %v", got, err) + } +} + +func TestExecInput_CommandSubstitution(t *testing.T) { + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + dir := t.TempDir() + os.Chdir(dir) + os.WriteFile("a.go", nil, 0644) + os.WriteFile("b.go", nil, 0644) + + tests := []struct { + input string + want string + }{ + {"echo $(echo hello) > out", "hello\n"}, + // Unquoted, the output is split into words + {"printf '[%s]' $(printf 'a b\\nc\\n\\n') > out", "[a][b][c]"}, + {"printf '[%s]' x$(echo a b)y > out", "[xa][by]"}, + // Quoted, it is a single word, only trailing newlines are removed + {`printf '[%s]' "$(printf 'a b\nc\n\n')" > out`, "[a b\nc]"}, + {`printf '[%s]' "$(true)" > out`, "[]"}, + {"printf '[%s]' $(true) x > out", "[x]"}, + // Nested, with quotes and operators inside + {"echo $(echo $(echo nested)) > out", "nested\n"}, + {`echo "$(echo "a;b|c")" > out`, "a;b|c\n"}, + {"echo $(echo a | tr a-z A-Z) > out", "A\n"}, + // Wildcards in the output are not expanded, in the command they are + {`echo $(echo "*") > out`, "*\n"}, + {"echo $(echo *.go) > out", "a.go b.go\n"}, + // Quoted or escaped, $( is literal + {`echo '$(echo a)' \$(echo b) > out`, "$(echo a) $(echo b)\n"}, + // The subshell does not change the shell + {"echo $(cd /; pwd) > out; pwd >> out", "/\n" + dir + "\n"}, + {"set GOSH_SUBST=$(echo value); echo $GOSH_SUBST > out", "value\n"}, + } + for _, tc := range tests { + if err := execInput(tc.input); err != nil { + t.Errorf("%q: %v", tc.input, err) + } + if got, _ := os.ReadFile("out"); string(got) != tc.want { + t.Errorf("%q: out = %q, want %q", tc.input, got, tc.want) + } + } + os.Unsetenv("GOSH_SUBST") + + // A command that consists only of a substitution has its status + execInput("$(sh -c 'exit 3')") + if lastStatus != 3 { + t.Errorf("$? = %d after $(exit 3), want 3", lastStatus) + } + // Otherwise the status of the command counts + execInput("echo $(false) > out") + if lastStatus != 0 { + t.Errorf("$? = %d after echo $(false), want 0", lastStatus) + } + + for input, wantErr := range map[string]string{ + "echo $(echo a": "invalid input: missing ) in command substitution", + "echo $((1 + 2))": "invalid input: arithmetic expansion $((...)) is not supported", + } { + if err := execInput(input); err == nil || err.Error() != wantErr { + t.Errorf("%q: error %v, want %q", input, err, wantErr) + } + } +}