From f5c14dff905ca9d08f995de5d3a4679ca7c1aa4b Mon Sep 17 00:00:00 2001 From: Florian Walther Date: Sat, 3 Oct 2026 20:32:39 +0200 Subject: [PATCH] add command lists (;, &&, ||) and $? - split input at unquoted ;, && and || and run the commands like sh; variables are expanded per command when it runs - $? and ${?} expand to the exit status of the last command, exit/quit without argument use it - syntax errors for missing commands around operators (status 2) - "command not found" message instead of the raw exec error, no more "exit status N" message when a command fails Co-Authored-By: Claude Opus 5.5 --- README.md | 32 +++++++++- main.go | 170 ++++++++++++++++++++++++++++++++++++++++++++++----- main_test.go | 125 ++++++++++++++++++++++++++++++++++++- 3 files changed, 307 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 7fbc51a..aa2ec9c 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ Built-in commands: cd [dir] - Change directory (cd with no args goes to home) pwd - Print working directory echo [args] - Echo arguments - exit [code] - Exit shell with optional status code - quit [code] - Quit shell with optional status code + exit [code] - Exit shell with status code (default: $?) + quit [code] - Quit shell with status code (default: $?) set VAR=val - Set environment variable export VAR=val - Export environment variable help - Show this help @@ -29,6 +29,8 @@ Features: disabled with $NO_COLOR, 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 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 - Non-interactive use: gosh -c 'command', gosh script, command | gosh; the exit status is the one of the last command @@ -53,7 +55,31 @@ gosh -version Ohne Terminal bricht gosh bei einem fehlgeschlagenen Befehl nicht ab und beendet sich mit dem Exit-Status des letzten Befehls: `127`, wenn ein Befehl nicht gefunden wurde, `126`, wenn er nicht ausführbar ist, `128+n`, wenn er durch Signal `n` beendet wurde. Leere Zeilen und Zeilen, die mit `#` beginnen, werden übersprungen, Skripte können also mit `#!/usr/bin/gosh` beginnen. -Damit funktionieren auch `ssh host befehl`, `scp` und `rsync`, wenn gosh als Login-Shell gesetzt ist. Da gosh `;`, `&&` und Pipes noch nicht kennt, darf der Befehl dabei aber nur aus einem einfachen Kommando bestehen. +Damit funktionieren auch `ssh host befehl`, `scp` und `rsync`, wenn gosh als Login-Shell gesetzt ist. Befehle lassen sich mit `;`, `&&` und `||` verketten (z. B. `ssh host 'cd /srv && git pull'`), Pipes und Umleitungen kennt gosh aber noch nicht. + +### Befehlslisten und Exit-Status + +Mehrere Befehle in einer Zeile werden wie in `sh` verknüpft: + +| Liste | Bedeutung | +|------------|---------------------------------------------------| +| `a ; b` | erst `a`, dann `b` | +| `a && b` | `b` nur, wenn `a` erfolgreich war (Exit-Status 0) | +| `a \|\| b` | `b` nur, wenn `a` fehlgeschlagen ist | + +`&&` und `||` werden von links nach rechts ausgewertet, `a && b || c` führt also `c` aus, wenn `a` oder `b` fehlschlägt. Variablen werden erst beim Ausführen des jeweiligen Befehls expandiert, `cd /tmp && echo $PWD` gibt also schon das neue Verzeichnis aus. + +`$?` (bzw. `${?}`) enthält den Exit-Status des letzten Befehls, `exit` ohne Argument beendet gosh mit diesem Status: + +``` +$ false; echo $? +1 +$ gibtsnicht || echo "Status $?" +gosh: gibtsnicht: command not found +Status 127 +``` + +Schlägt ein Programm fehl, gibt gosh selbst keine Meldung mehr aus (das Programm meldet den Fehler selbst), nur bei Fehlern von gosh, z. B. wenn ein Befehl nicht gefunden wird. ### Farben diff --git a/main.go b/main.go index 01933ef..242c257 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,7 @@ import ( "os/user" "path/filepath" "runtime/debug" + "strconv" "strings" "syscall" "unicode" @@ -216,6 +217,9 @@ func expandVar(s string) (string, int, error) { return "", 0, errors.New("missing } in variable reference") } name, def, hasDef := strings.Cut(s[2:end], ":-") + if name == "?" { + return strconv.Itoa(lastStatus), end + 1, nil + } if !isVarName(name) { return "", 0, fmt.Errorf("bad substitution: %s", s[:end+1]) } @@ -229,6 +233,10 @@ func expandVar(s string) (string, int, error) { return value, end + 1, nil } + if len(s) > 1 && s[1] == '?' { + return strconv.Itoa(lastStatus), 2, nil + } + n := 1 for n < len(s) && isVarNameByte(s[n], n == 1) { n++ @@ -693,13 +701,8 @@ func runScript(r io.Reader) int { for { line, err := readLine(r) if trimmed := strings.TrimSpace(line); trimmed != "" && !strings.HasPrefix(trimmed, "#") { - cmdErr := execInput(line) - status = exitStatus(cmdErr) - // A command that ran and failed reports its own errors. - var exitErr *exec.ExitError - if cmdErr != nil && !errors.As(cmdErr, &exitErr) { - fmt.Fprintln(os.Stderr, "gosh:", cmdErr) - } + printError(execInput(line)) + status = lastStatus } if err != nil { if err != io.EOF { @@ -747,7 +750,7 @@ func exitStatus(err error) int { } return exitErr.ExitCode() } - if errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrNotExist) { + if errors.Is(err, errCommandNotFound) || errors.Is(err, fs.ErrNotExist) { return 127 } if errors.Is(err, fs.ErrPermission) { @@ -819,13 +822,142 @@ func interactive() { } // Execute the input - if err := execInput(line); err != nil { - fmt.Fprintln(os.Stderr, err) - } + printError(execInput(line)) } } +// lastStatus is the exit status of the last command ($?). +var lastStatus int + +// errCommandNotFound is returned when an external command does not exist. +var errCommandNotFound = errors.New("command not found") + +// printError prints an error of a command to stderr. Commands that ran and +// failed have already reported the problem themselves, only their exit +// status is kept. +func printError(err error) { + var exitErr *exec.ExitError + if err == nil || errors.As(err, &exitErr) { + return + } + fmt.Fprintln(os.Stderr, "gosh:", err) +} + +// listItem is a command of a command list together with the operator that +// connects it to the previous command ("" for the first one). +type listItem struct { + op string // "", ";", "&&" or "||" + command string +} + +// splitList splits input at unquoted ;, && and ||. Quotes, escapes, ${...} +// and comments are handled like in splitWords, but nothing is expanded yet: +// that happens per command when it runs, so "cd /tmp && echo $PWD" works. +func splitList(input string) ([]listItem, error) { + var ( + items []listItem + op string + start int + quote byte + escaped bool + wordStart = true // the next character starts a word + ) + + end := len(input) + add := func(i int, next string) error { + command := input[start:i] + if strings.TrimSpace(command) == "" { + // "a;" is fine, but an operator needs a command on both sides + if next != "" || op == "&&" || op == "||" { + if next == "" { + next = op + } + return fmt.Errorf("syntax error near unexpected token `%s'", next) + } + return nil + } + items = append(items, listItem{op: op, command: command}) + return nil + } + +scan: + for i := 0; i < len(input); i++ { + ch := input[i] + isWordStart := wordStart + wordStart = false + switch { + case escaped: + escaped = false + case quote == '\'': + if ch == '\'' { + quote = 0 + } + case ch == '\\': + escaped = true + case ch == '$' && i+1 < len(input) && input[i+1] == '{': + // Skip ${...} as a whole, like expandVar does + if j := strings.IndexByte(input[i:], '}'); j >= 0 { + i += j + } + case quote == '"': + if ch == '"' { + quote = 0 + } + case ch == '"' || ch == '\'': + quote = ch + case ch == ' ' || ch == '\t' || ch == '\n': + wordStart = true + case ch == '#' && isWordStart: + end = i + break scan + case ch == ';', + ch == '&' && i+1 < len(input) && input[i+1] == '&', + ch == '|' && i+1 < len(input) && input[i+1] == '|': + next := input[i : i+1] + if ch != ';' { + next = input[i : i+2] + } + if err := add(i, next); err != nil { + return nil, err + } + i += len(next) - 1 + op = next + start = i + 1 + wordStart = true + } + } + + // An unterminated quote or a trailing \ is reported by splitWords + if err := add(end, ""); err != nil { + return nil, err + } + return items, nil +} + +// execInput executes a line of input: a list of commands separated by ;, && +// and ||. It returns the error of the last command that was run; errors of +// earlier commands are printed. $? is updated after each command. func execInput(input string) error { + items, err := splitList(input) + if err != nil { + lastStatus = 2 + return err + } + + var lastErr error + for _, item := range items { + if (item.op == "&&" && lastStatus != 0) || (item.op == "||" && lastStatus == 0) { + continue + } + printError(lastErr) + lastErr = execCommand(item.command) + lastStatus = exitStatus(lastErr) + } + return lastErr +} + +// execCommand executes a single simple command. +func execCommand(input string) error { // Split the input into the command and its arguments, expanding // variables and ~ on the way. args, err := splitWords(input) @@ -879,7 +1011,7 @@ func execInput(input string) error { fmt.Sscanf(args[1], "%d", &exitCode) os.Exit(exitCode) } - os.Exit(0) + os.Exit(lastStatus) case "quit": // quit with optional status code if len(args) > 1 { @@ -887,7 +1019,7 @@ func execInput(input string) error { fmt.Sscanf(args[1], "%d", &exitCode) os.Exit(exitCode) } - os.Exit(0) + os.Exit(lastStatus) case "set": // set VAR=value - set environment variable if len(args) < 2 { @@ -919,8 +1051,8 @@ Built-in commands: cd [dir] - Change directory (cd with no args goes to home) pwd - Print working directory echo [args] - Echo arguments - exit [code] - Exit shell with optional status code - quit [code] - Quit shell with optional status code + exit [code] - Exit shell with status code (default: $?) + quit [code] - Quit shell with status code (default: $?) set VAR=val - Set environment variable export VAR=val - Export environment variable help - Show this help @@ -940,6 +1072,8 @@ Features: disabled with $NO_COLOR, 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 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 - Non-interactive use: gosh -c 'command', gosh script, command | gosh; the exit status is the one of the last command`) return nil @@ -957,5 +1091,9 @@ Features: cmd.Stderr = os.Stderr // Execute the command return the error. - return cmd.Run() + err = cmd.Run() + if errors.Is(err, exec.ErrNotFound) { + return fmt.Errorf("%s: %w", sanitize(args[0]), errCommandNotFound) + } + return err } diff --git a/main_test.go b/main_test.go index ff11bae..91cc012 100644 --- a/main_test.go +++ b/main_test.go @@ -1,8 +1,8 @@ package main import ( - "errors" "bytes" + "errors" "io" "os" "os/exec" @@ -1348,3 +1348,126 @@ func TestReadLine_DoesNotReadAhead(t *testing.T) { t.Errorf("readLine() consumed more than one line, %d bytes left", r.Len()) } } + +func TestSplitList(t *testing.T) { + tests := []struct { + input string + want []listItem + }{ + {"", nil}, + {" ", nil}, + {"# only a comment", nil}, + {"ls", []listItem{{"", "ls"}}}, + {"a; b", []listItem{{"", "a"}, {";", " b"}}}, + {"a;", []listItem{{"", "a"}}}, + {"a && b || c", []listItem{{"", "a "}, {"&&", " b "}, {"||", " c"}}}, + {`echo "a;b" 'c&&d' e\;f`, []listItem{{"", `echo "a;b" 'c&&d' e\;f`}}}, + {`echo "x\"; y"`, []listItem{{"", `echo "x\"; y"`}}}, + {"echo ${X:-a;b}; c", []listItem{{"", "echo ${X:-a;b}"}, {";", " c"}}}, + {"echo a # b; c", []listItem{{"", "echo a "}}}, + {"echo a#b; c", []listItem{{"", "echo a#b"}, {";", " c"}}}, + {"echo a|b", []listItem{{"", "echo a|b"}}}, + } + for _, tc := range tests { + got, err := splitList(tc.input) + if err != nil { + t.Errorf("splitList(%q) returned error: %v", tc.input, err) + continue + } + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("splitList(%q) = %q, want %q", tc.input, got, tc.want) + } + } +} + +func TestSplitList_SyntaxErrors(t *testing.T) { + for _, input := range []string{"; a", "a ;; b", "&& a", "a &&", "a ||", "a && ; b", "a || && b"} { + if _, err := splitList(input); err == nil || !strings.Contains(err.Error(), "syntax error") { + t.Errorf("splitList(%q) = %v, want syntax error", input, err) + } + } +} + +func TestExecInput_Lists(t *testing.T) { + dir := t.TempDir() + t.Setenv("D", dir) + tests := []struct { + input string + created []string + missing []string + status int + }{ + {"touch $D/a; touch $D/b", []string{"a", "b"}, nil, 0}, + {"false; touch $D/c", []string{"c"}, nil, 0}, + {"true && touch $D/d", []string{"d"}, nil, 0}, + {"false && touch $D/e", nil, []string{"e"}, 1}, + {"false || touch $D/f", []string{"f"}, nil, 0}, + {"true || touch $D/g", nil, []string{"g"}, 0}, + {"false && touch $D/h || touch $D/i", []string{"i"}, []string{"h"}, 0}, + {"true && false || touch $D/j", []string{"j"}, nil, 0}, + {"gosh-no-such-command || touch $D/k", []string{"k"}, nil, 0}, + {"true && sh -c 'exit 4'", nil, nil, 4}, + } + for _, tc := range tests { + execInput(tc.input) + if lastStatus != tc.status { + t.Errorf("%q: $? = %d, want %d", tc.input, lastStatus, tc.status) + } + for _, name := range tc.created { + if _, err := os.Stat(filepath.Join(dir, name)); err != nil { + t.Errorf("%q: expected %s to be created", tc.input, name) + } + } + for _, name := range tc.missing { + if _, err := os.Stat(filepath.Join(dir, name)); err == nil { + t.Errorf("%q: %s must not be created", tc.input, name) + } + } + } +} + +func TestExecInput_ExpandsPerCommand(t *testing.T) { + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + dir := t.TempDir() + out := filepath.Join(dir, "out") + t.Setenv("GOSH_TEST_OUT", out) + os.Unsetenv("GOSH_TEST_LIST") + defer os.Unsetenv("GOSH_TEST_LIST") + + // The variable is set by the first command and used by the second one + err := execInput(`set GOSH_TEST_LIST=hello && sh -c 'printf %s "$GOSH_TEST_LIST" > "$GOSH_TEST_OUT"'`) + if err != nil { + t.Fatal(err) + } + if got, _ := os.ReadFile(out); string(got) != "hello" { + t.Errorf("got %q, want %q", got, "hello") + } +} + +func TestExpandVar_LastStatus(t *testing.T) { + execInput("sh -c 'exit 3'") + for _, input := range []string{"$?", "${?}"} { + words, err := splitWords("echo " + input) + if err != nil { + t.Fatal(err) + } + if words[1] != "3" { + t.Errorf("%s = %q, want %q", input, words[1], "3") + } + } + execInput("true") + if words, _ := splitWords("echo $?"); words[1] != "0" { + t.Errorf("$? = %q after true, want 0", words[1]) + } +} + +func TestExecInput_CommandNotFound(t *testing.T) { + err := execInput("gosh-no-such-command") + if err == nil || err.Error() != "gosh-no-such-command: command not found" { + t.Errorf("got %v, want command not found", err) + } + if lastStatus != 127 { + t.Errorf("$? = %d, want 127", lastStatus) + } +}