Compare commits

...
2 Commits
Author SHA1 Message Date
Florian WaltherandClaude Opus 5.5 e49fe6fe11 add command substitution $(...)
- $(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>
2026-10-03 23:52:09 +02:00
Florian WaltherandClaude Opus 5.5 041406a340 show the exit status of a failed command in the prompt
- user@host:dir [N] $ after a command failed with exit status N,
  the plain prompt again after a successful command
- the status is colored bold red by default, configurable with the new
  GOSH_COLORS key error

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:46:11 +02:00
7 changed files with 397 additions and 31 deletions

No files matched your search

+50 -4
View File
@@ -34,12 +34,18 @@ Features:
- Wildcards: * ? [abc] [a-z] [!abc] match file names (not hidden files - Wildcards: * ? [abc] [a-z] [!abc] match file names (not hidden files
unless the pattern starts with a dot); quoted wildcards and values of unless the pattern starts with a dot); quoted wildcards and values of
variables are not expanded, a pattern without match is kept as is variables are not expanded, a pattern without match is kept as is
- Colored prompt (user@host green, red for root; directory blue), - Prompt user@host:dir $, with [N] before the $ if the last command
configurable with $GOSH_COLORS, e.g. GOSH_COLORS='user=1;33:path=36' failed with exit status N
(keys user, root, path; values are ANSI SGR codes, empty = no color); - Colored prompt (user@host green, red for root; directory blue; exit
disabled with $NO_COLOR, TERM=dumb or when output is not a terminal status red), configurable with $GOSH_COLORS, e.g.
GOSH_COLORS='user=1;33:path=36' (keys user, root, path, error; values
are ANSI SGR codes, empty = no color); disabled with $NO_COLOR,
TERM=dumb or when output is not a terminal
- Environment variable expansion ($VAR, ${VAR}, ${VAR:-default}); - Environment variable expansion ($VAR, ${VAR}, ${VAR:-default});
using an unset variable is an error, use ${VAR:-} for an empty 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), - 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 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, - Pipes (a | b) and redirections: < file, > file, >> file, 2> file,
@@ -119,6 +125,26 @@ export LANG=de_DE.UTF-8
export PATH=$HOME/bin:$PATH 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 ### Befehlslisten und Exit-Status
Mehrere Befehle in einer Zeile werden wie in `sh` verknüpft: Mehrere Befehle in einer Zeile werden wie in `sh` verknüpft:
@@ -289,6 +315,22 @@ $ bg %2 # oder: angehaltenen Job im Hintergrund weiterlauf
- Wird ein angehaltener Job von außen fortgesetzt (`kill -CONT`), zeigt `jobs` ihn weiter als `Stopped`, bis er endet oder erneut angehalten wird; `fg` und `bg` funktionieren trotzdem. - Wird ein angehaltener Job von außen fortgesetzt (`kill -CONT`), zeigt `jobs` ihn weiter als `Stopped`, bis er endet oder erneut angehalten wird; `fg` und `bg` funktionieren trotzdem.
- Einen Job beenden lässt sich mit `kill` über die Prozess-ID (z. B. `kill 12345`); `kill %1` versteht gosh noch nicht. - Einen Job beenden lässt sich mit `kill` über die Prozess-ID (z. B. `kill 12345`); `kill %1` versteht gosh noch nicht.
### Prompt
Der Prompt zeigt Benutzer, Rechner und Verzeichnis. Ist der letzte Befehl fehlgeschlagen, steht sein Exit-Status in eckigen Klammern davor, bis wieder ein Befehl erfolgreich war:
```
flow@host:~/src $ false
flow@host:~/src [1] $ gibtsnicht
gosh: gibtsnicht: command not found
flow@host:~/src [127] $ sleep 100 # mit Ctrl+C abgebrochen
^C
flow@host:~/src [130] $ true
flow@host:~/src $
```
Typische Werte: `1` allgemeiner Fehler, `2` Syntaxfehler, `126` nicht ausführbar, `127` Befehl nicht gefunden, `128+n` durch Signal `n` beendet (`130` = Ctrl+C, `148` = mit Ctrl+Z angehalten unter Linux). Eine leere Eingabe ändert den Status nicht.
### Farben ### Farben
Der Prompt wird über die Umgebungsvariable `GOSH_COLORS` eingefärbt. Sie besteht aus durch `:` getrennten Einträgen `schlüssel=code`: Der Prompt wird über die Umgebungsvariable `GOSH_COLORS` eingefärbt. Sie besteht aus durch `:` getrennten Einträgen `schlüssel=code`:
@@ -298,6 +340,7 @@ Der Prompt wird über die Umgebungsvariable `GOSH_COLORS` eingefärbt. Sie beste
| `user` | `user@host` | `1;32` (fett grün) | | `user` | `user@host` | `1;32` (fett grün) |
| `root` | `user@host`, wenn man root ist | `1;31` (fett rot) | | `root` | `user@host`, wenn man root ist | `1;31` (fett rot) |
| `path` | aktuelles Verzeichnis | `1;34` (fett blau) | | `path` | aktuelles Verzeichnis | `1;34` (fett blau) |
| `error` | Exit-Status `[N]` nach einem Fehler | `1;31` (fett rot) |
Die Werte sind [ANSI-SGR-Codes](https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters), mehrere werden mit `;` kombiniert: Die Werte sind [ANSI-SGR-Codes](https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters), mehrere werden mit `;` kombiniert:
@@ -335,6 +378,9 @@ GOSH_COLORS='user=1;38;2;136;192;208:path=38;2;216;222;233'
# Ozean: türkis und kursiv blau # Ozean: türkis und kursiv blau
GOSH_COLORS='user=1;96:path=3;94' GOSH_COLORS='user=1;96:path=3;94'
# Fehler-Status gelb auf rotem Hintergrund
GOSH_COLORS='error=1;33;41'
# Root-Warnung: weiß auf rotem Hintergrund # Root-Warnung: weiß auf rotem Hintergrund
GOSH_COLORS='root=1;97;41' GOSH_COLORS='root=1;97;41'
+10
View File
@@ -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) out.WriteByte(ch)
switch { switch {
case escaped: case escaped:
+12 -1
View File
@@ -15,7 +15,8 @@ import (
const maxBraceWords = 100000 const maxBraceWords = 100000
// expandBraceWords applies brace expansion to every word of a command (the // 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. // A redirection target must expand to a single word.
func expandBraceWords(input string) (string, error) { func expandBraceWords(input string) (string, error) {
var ( var (
@@ -69,6 +70,10 @@ func wordEnd(s string, i int) int {
} }
case ch == '\\': case ch == '\\':
i++ i++
case isSubstStart(s, i):
if j := substEnd(s, i); j >= 0 {
i = j
}
case ch == '$' && i+1 < len(s) && s[i+1] == '{': case ch == '$' && i+1 < len(s) && s[i+1] == '{':
if j := strings.IndexByte(s[i:], '}'); j >= 0 { if j := strings.IndexByte(s[i:], '}'); j >= 0 {
i += j i += j
@@ -103,6 +108,12 @@ func braceScan(s string, fn func(i, depth int) bool) {
case ch == '\\': case ch == '\\':
i++ i++
continue 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] == '{': case ch == '$' && i+1 < len(s) && s[i+1] == '{':
if j := strings.IndexByte(s[i:], '}'); j >= 0 { if j := strings.IndexByte(s[i:], '}'); j >= 0 {
i += j i += j
+76 -17
View File
@@ -131,6 +131,8 @@ type token struct {
// - '...' is taken literally, nothing is expanded inside // - '...' is taken literally, nothing is expanded inside
// - "..." expands $VAR and ${VAR}; \ only escapes $ " \ and ` // - "..." expands $VAR and ${VAR}; \ only escapes $ " \ and `
// - outside quotes \ escapes any character // - 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; // - $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 // an unset variable is an error, so that e.g. "rm -rf $DIR/x" never runs
// with an empty $DIR // with an empty $DIR
@@ -190,6 +192,32 @@ func tokenize(input string) ([]token, error) {
case ch == '\\': case ch == '\\':
startRaw(i) startRaw(i)
escaped = true 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 == '$': case ch == '$':
startRaw(i) startRaw(i)
value, n, err := expandVar(input[i:]) value, n, err := expandVar(input[i:])
@@ -612,21 +640,23 @@ func getPrompt() string {
c := parseColors(os.Getenv("GOSH_COLORS")) c := parseColors(os.Getenv("GOSH_COLORS"))
colors = &c colors = &c
} }
return buildPrompt(username, hostname, wd, os.Geteuid() == 0, colors) return buildPrompt(username, hostname, wd, os.Geteuid() == 0, lastStatus, colors)
} }
// promptColors holds the SGR parameters (e.g. "1;32") for the parts of the // promptColors holds the SGR parameters (e.g. "1;32") for the parts of the
// prompt. An empty value means no color for that part. // prompt. An empty value means no color for that part.
type promptColors struct { type promptColors struct {
user string // user@host user string // user@host
root string // user@host when running as root root string // user@host when running as root
path string // working directory path string // working directory
error string // exit status of the last command, if it failed
} }
// defaultColors: bold green user, bold red root, bold blue directory // defaultColors: bold green user, bold red root, bold blue directory, bold
var defaultColors = promptColors{user: "1;32", root: "1;31", path: "1;34"} // red exit status
var defaultColors = promptColors{user: "1;32", root: "1;31", path: "1;34", error: "1;31"}
// parseColors reads a GOSH_COLORS value like "user=1;33:root=1;31:path=36" // parseColors reads a GOSH_COLORS value like "user=1;33:path=36:error=31"
// and overrides the corresponding defaults. Unknown keys and values that are // and overrides the corresponding defaults. Unknown keys and values that are
// not plain SGR parameters (digits and ';') are ignored, so the variable // not plain SGR parameters (digits and ';') are ignored, so the variable
// cannot be used to write arbitrary escape sequences to the terminal. // cannot be used to write arbitrary escape sequences to the terminal.
@@ -644,6 +674,8 @@ func parseColors(spec string) promptColors {
colors.root = value colors.root = value
case "path": case "path":
colors.path = value colors.path = value
case "error":
colors.error = value
} }
} }
return colors return colors
@@ -669,20 +701,28 @@ func colorize(text, sgr string) string {
return "\x1b[" + sgr + "m" + text + "\x1b[0m" return "\x1b[" + sgr + "m" + text + "\x1b[0m"
} }
// buildPrompt formats the prompt as user@host:path $. The parts are sanitized // buildPrompt formats the prompt as user@host:path $, or user@host:path [N] $
// if the last command failed with exit status N. The parts are sanitized
// before the color codes are added, so only our own escape sequences end up // before the color codes are added, so only our own escape sequences end up
// in the prompt. colors == nil disables colors. // in the prompt. colors == nil disables colors.
func buildPrompt(username, hostname, path string, root bool, colors *promptColors) string { func buildPrompt(username, hostname, path string, root bool, status int, colors *promptColors) string {
userHost := sanitize(username + "@" + hostname) userHost := sanitize(username + "@" + hostname)
path = sanitize(path) path = sanitize(path)
var failed string
if status != 0 {
failed = " [" + strconv.Itoa(status) + "]"
}
if colors == nil { if colors == nil {
return userHost + ":" + path + " $ " return userHost + ":" + path + failed + " $ "
} }
userColor := colors.user userColor := colors.user
if root { if root {
userColor = colors.root userColor = colors.root
} }
return colorize(userHost, userColor) + ":" + colorize(path, colors.path) + " $ " if failed != "" {
failed = " " + colorize(failed[1:], colors.error)
}
return colorize(userHost, userColor) + ":" + colorize(path, colors.path) + failed + " $ "
} }
// useColor reports whether the prompt should be colored: stdout must be a // useColor reports whether the prompt should be colored: stdout must be a
@@ -1275,6 +1315,11 @@ scan:
} }
case ch == '\\': case ch == '\\':
escaped = true 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] == '{': case ch == '$' && i+1 < len(input) && input[i+1] == '{':
// Skip ${...} as a whole, like expandVar does // Skip ${...} as a whole, like expandVar does
if j := strings.IndexByte(input[i:], '}'); j >= 0 { if j := strings.IndexByte(input[i:], '}'); j >= 0 {
@@ -1355,6 +1400,7 @@ func execInput(input string) error {
// the background if background is set. // the background if background is set.
func execCommand(input string, background bool) error { func execCommand(input string, background bool) error {
// Brace expansion comes first, so that its results are expanded further // Brace expansion comes first, so that its results are expanded further
substStatus = 0
input, err := expandBraceWords(input) input, err := expandBraceWords(input)
if err != nil { if err != nil {
return err return err
@@ -1363,8 +1409,11 @@ func execCommand(input string, background bool) error {
// Split the input into words and operators, expanding variables and ~ on // Split the input into words and operators, expanding variables and ~ on
// the way. // the way.
tokens, err := tokenize(input) tokens, err := tokenize(input)
var synErr syntaxError var (
if errors.As(err, &synErr) { synErr syntaxError
statusErr statusError
)
if errors.As(err, &synErr) || errors.As(err, &statusErr) {
return err return err
} }
if err != nil { if err != nil {
@@ -1375,6 +1424,10 @@ func execCommand(input string, background bool) error {
return err return err
} }
if len(stages) == 0 { if len(stages) == 0 {
// A command that consists only of $(...) has its status
if substStatus != 0 {
return statusError(substStatus)
}
return nil return nil
} }
// "exit" with stopped jobs only warns, unless it is repeated right away // "exit" with stopped jobs only warns, unless it is repeated right away
@@ -1793,12 +1846,18 @@ Features:
- Wildcards: * ? [abc] [a-z] [!abc] match file names (not hidden files - Wildcards: * ? [abc] [a-z] [!abc] match file names (not hidden files
unless the pattern starts with a dot); quoted wildcards and values of unless the pattern starts with a dot); quoted wildcards and values of
variables are not expanded, a pattern without match is kept as is variables are not expanded, a pattern without match is kept as is
- Colored prompt (user@host green, red for root; directory blue), - Prompt user@host:dir $, with [N] before the $ if the last command
configurable with $GOSH_COLORS, e.g. GOSH_COLORS='user=1;33:path=36' failed with exit status N
(keys user, root, path; values are ANSI SGR codes, empty = no color); - Colored prompt (user@host green, red for root; directory blue; exit
disabled with $NO_COLOR, TERM=dumb or when output is not a terminal status red), configurable with $GOSH_COLORS, e.g.
GOSH_COLORS='user=1;33:path=36' (keys user, root, path, error; values
are ANSI SGR codes, empty = no color); disabled with $NO_COLOR,
TERM=dumb or when output is not a terminal
- Environment variable expansion ($VAR, ${VAR}, ${VAR:-default}); - Environment variable expansion ($VAR, ${VAR}, ${VAR:-default});
using an unset variable is an error, use ${VAR:-} for an empty 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), - 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 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, - Pipes (a | b) and redirections: < file, > file, >> file, 2> file,
+35 -9
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"bytes" "bytes"
"errors" "errors"
"fmt"
"io" "io"
"os" "os"
"os/exec" "os/exec"
@@ -892,6 +893,11 @@ func TestMain(m *testing.M) {
// Never read the system's startup files; tests that read the user's // Never read the system's startup files; tests that read the user's
// files set $HOME themselves // files set $HOME themselves
sysConfDir = filepath.Join(dir, "etc") 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() code := m.Run()
os.RemoveAll(dir) os.RemoveAll(dir)
os.Exit(code) os.Exit(code)
@@ -1054,24 +1060,24 @@ func TestGetPrompt_EscapesControlCharacters(t *testing.T) {
} }
func TestBuildPrompt_Color(t *testing.T) { func TestBuildPrompt_Color(t *testing.T) {
got := buildPrompt("flow", "host", "~/src", false, &defaultColors) got := buildPrompt("flow", "host", "~/src", false, 0, &defaultColors)
want := "\x1b[1;32mflow@host\x1b[0m:\x1b[1;34m~/src\x1b[0m $ " want := "\x1b[1;32mflow@host\x1b[0m:\x1b[1;34m~/src\x1b[0m $ "
if got != want { if got != want {
t.Errorf("buildPrompt() = %q, want %q", got, want) t.Errorf("buildPrompt() = %q, want %q", got, want)
} }
got = buildPrompt("root", "host", "/", true, &defaultColors) got = buildPrompt("root", "host", "/", true, 0, &defaultColors)
if !strings.HasPrefix(got, "\x1b[1;31mroot@host") { if !strings.HasPrefix(got, "\x1b[1;31mroot@host") {
t.Errorf("expected red user for root, got %q", got) t.Errorf("expected red user for root, got %q", got)
} }
got = buildPrompt("flow", "host", "~/src", false, nil) got = buildPrompt("flow", "host", "~/src", false, 0, nil)
if got != "flow@host:~/src $ " { if got != "flow@host:~/src $ " {
t.Errorf("buildPrompt() without color = %q", got) t.Errorf("buildPrompt() without color = %q", got)
} }
colors := promptColors{user: "38;5;208", path: ""} colors := promptColors{user: "38;5;208", path: ""}
got = buildPrompt("flow", "host", "~/src", false, &colors) got = buildPrompt("flow", "host", "~/src", false, 0, &colors)
if got != "\x1b[38;5;208mflow@host\x1b[0m:~/src $ " { if got != "\x1b[38;5;208mflow@host\x1b[0m:~/src $ " {
t.Errorf("buildPrompt() with custom colors = %q", got) t.Errorf("buildPrompt() with custom colors = %q", got)
} }
@@ -1083,13 +1089,14 @@ func TestParseColors(t *testing.T) {
want promptColors want promptColors
}{ }{
{"", defaultColors}, {"", defaultColors},
{"user=1;33", promptColors{user: "1;33", root: "1;31", path: "1;34"}}, {"user=1;33", promptColors{user: "1;33", root: "1;31", path: "1;34", error: "1;31"}},
{"user=33:root=41;97:path=38;5;208", promptColors{user: "33", root: "41;97", path: "38;5;208"}}, {"user=33:root=41;97:path=38;5;208", promptColors{user: "33", root: "41;97", path: "38;5;208", error: "1;31"}},
{"path=", promptColors{user: "1;32", root: "1;31", path: ""}}, {"error=33", promptColors{user: "1;32", root: "1;31", path: "1;34", error: "33"}},
{"path=", promptColors{user: "1;32", root: "1;31", path: "", error: "1;31"}},
{"path=0m\x1b[2J", defaultColors}, // escape sequences are rejected {"path=0m\x1b[2J", defaultColors}, // escape sequences are rejected
{"path=red", defaultColors}, // only numeric codes {"path=red", defaultColors}, // only numeric codes
{"foo=1:user", defaultColors}, // unknown key, missing value {"foo=1:user", defaultColors}, // unknown key, missing value
{"user=35::path=36", promptColors{user: "35", root: "1;31", path: "36"}}, {"user=35::path=36", promptColors{user: "35", root: "1;31", path: "36", error: "1;31"}},
} }
for _, tc := range tests { for _, tc := range tests {
if got := parseColors(tc.spec); got != tc.want { if got := parseColors(tc.spec); got != tc.want {
@@ -1098,8 +1105,27 @@ func TestParseColors(t *testing.T) {
} }
} }
func TestBuildPrompt_Status(t *testing.T) {
if got := buildPrompt("flow", "host", "~", false, 0, nil); got != "flow@host:~ $ " {
t.Errorf("status 0: %q", got)
}
if got := buildPrompt("flow", "host", "~", false, 127, nil); got != "flow@host:~ [127] $ " {
t.Errorf("status 127 without color: %q", got)
}
got := buildPrompt("flow", "host", "~", false, 1, &defaultColors)
want := "\x1b[1;32mflow@host\x1b[0m:\x1b[1;34m~\x1b[0m \x1b[1;31m[1]\x1b[0m $ "
if got != want {
t.Errorf("status 1 with color = %q, want %q", got, want)
}
colors := defaultColors
colors.error = ""
if got := buildPrompt("flow", "host", "~", false, 2, &colors); !strings.HasSuffix(got, " [2] $ ") {
t.Errorf("status without error color = %q", got)
}
}
func TestBuildPrompt_ColorEscapesControlCharacters(t *testing.T) { func TestBuildPrompt_ColorEscapesControlCharacters(t *testing.T) {
got := buildPrompt("flow", "host", "evil\x1b[2J\x07", false, &defaultColors) got := buildPrompt("flow", "host", "evil\x1b[2J\x07", false, 0, &defaultColors)
if strings.Contains(got, "\x1b[2J") || strings.Contains(got, "\x07") { if strings.Contains(got, "\x1b[2J") || strings.Contains(got, "\x07") {
t.Errorf("prompt contains control characters from path: %q", got) t.Errorf("prompt contains control characters from path: %q", got)
} }
+98
View File
@@ -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
}
+116
View File
@@ -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)
}
}
}