From c46903e3d257b12ae09fc8df674e21fdca71b3b5 Mon Sep 17 00:00:00 2001 From: Florian Walther Date: Sat, 3 Oct 2026 20:39:38 +0200 Subject: [PATCH] add pipes and redirections - tokenizer recognizes unquoted |, <, >, >>, N>, N>>, N>&M, N<&M, &> and &>>; only a word of unquoted digits is a file descriptor - pipelines run concurrently, the status is the one of the last command, errors of earlier stages are printed - redirections are applied left to right (fds 0-2), a redirection without a command creates/truncates the file - pwd and help can be redirected and piped; cd, set, export, exit and quit are rejected in pipelines - syntax errors have exit status 2 - completion: commands after |, ;, && and ||, files after redirections; operator characters are escaped in completed names Co-Authored-By: Claude Opus 5.5 --- README.md | 31 +++- main.go | 433 ++++++++++++++++++++++++++++++++++++++++++++++----- main_test.go | 149 ++++++++++++++++++ 3 files changed, 570 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index aa2ec9c..7c61b27 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ Features: 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 + - Pipes (a | b) and redirections: < file, > file, >> file, 2> file, + 2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file - Non-interactive use: gosh -c 'command', gosh script, command | gosh; the exit status is the one of the last command @@ -55,7 +57,7 @@ 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. Befehle lassen sich mit `;`, `&&` und `||` verketten (z. B. `ssh host 'cd /srv && git pull'`), Pipes und Umleitungen kennt gosh aber noch nicht. +Damit funktionieren auch `ssh host befehl`, `scp` und `rsync`, wenn gosh als Login-Shell gesetzt ist. Befehle lassen sich mit `;`, `&&` und `||` verketten und mit Pipes verbinden, z. B. `ssh host 'cd /srv && git log | head'`. ### Befehlslisten und Exit-Status @@ -81,6 +83,33 @@ 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. +### Pipes und Umleitungen + +``` +$ ls | sort -r | head -3 +$ make > build.log 2>&1 +$ grep -c error < build.log +``` + +| Syntax | Bedeutung | +|-----------------|------------------------------------------------------------| +| `a \| b` | stdout von `a` wird stdin von `b` | +| `< datei` | stdin aus `datei` lesen | +| `> datei` | stdout in `datei` schreiben (wird überschrieben) | +| `>> datei` | stdout an `datei` anhängen | +| `2> datei` | stderr in `datei` schreiben (`2>>` hängt an) | +| `2>&1` | stderr dorthin, wohin stdout gerade geht | +| `>&2` | stdout dorthin, wohin stderr gerade geht | +| `&> datei` | stdout und stderr in `datei` (`&>>` hängt an) | +| `> datei` | ohne Befehl: legt `datei` an bzw. leert sie | + +- Umleitungen werden von links nach rechts ausgewertet: `befehl > log 2>&1` schreibt beides in `log`, `befehl 2>&1 > log` schickt stderr dorthin, wo stdout vorher hinging (z. B. in eine Pipe). +- Unterstützt werden die Dateideskriptoren 0, 1 und 2. Vor `>` und `<` zählt nur eine Zahl ohne Anführungszeichen als Deskriptor (`2>f`, aber nicht `x2>f` oder `"2">f`). +- Operatoren in Anführungszeichen oder mit `\` davor sind normale Zeichen: `echo "a|b" 'c>d' e\>f`. +- Der Exit-Status einer Pipeline ist der des letzten Befehls. Beendet sich ein Befehl am Ende (z. B. `head`), bekommt der schreibende Befehl davor `SIGPIPE`, `yes | head -1` endet also sofort. +- Die Built-ins `pwd` und `help` lassen sich umleiten und in Pipes verwenden (`help | less`). `cd`, `set`, `export`, `exit` und `quit` verändern die Shell selbst und sind in einer Pipeline nicht erlaubt. +- Ein einzelnes `&` (Hintergrundprozesse) gibt es noch nicht, es wird als normales Zeichen übergeben. + ### Farben Der Prompt wird über die Umgebungsvariable `GOSH_COLORS` eingefärbt. Sie besteht aus durch `:` getrennten Einträgen `schlüssel=code`: diff --git a/main.go b/main.go index 242c257..b7f63d8 100644 --- a/main.go +++ b/main.go @@ -111,7 +111,16 @@ func expandTilde(path string) string { return path } -// splitWords splits input into words like a (very) simple POSIX shell: +// token is a word or an operator of a command line. +type token struct { + op string // "" for a word, otherwise |, <, >, >>, >&, <&, &> or &>> + word string // the word, if op == "" + fd int // the redirected file descriptor (for redirections) + dup int // the target file descriptor of >& and <& +} + +// tokenize splits input into words and operators like a (very) simple POSIX +// shell: // - words are separated by unquoted blanks, an unquoted # starts a comment // - '...' is taken literally, nothing is expanded inside // - "..." expands $VAR and ${VAR}; \ only escapes $ " \ and ` @@ -120,23 +129,31 @@ func expandTilde(path string) string { // an unset variable is an error, so that e.g. "rm -rf $DIR/x" never runs // with an empty $DIR // - ~ and ~/... are expanded at the start of an unquoted word +// - unquoted |, <, >, >>, N<, N>, N>>, N>&M, N<&M, &> and &>> are operators // // Expanded values are never split into several words. -func splitWords(input string) ([]string, error) { +func tokenize(input string) ([]token, error) { var ( - words []string - b strings.Builder - inWord bool // a word has been started (even if it is still empty) - quote byte - escaped bool + tokens []token + b strings.Builder + inWord bool // a word has been started (even if it is still empty) + quote byte + escaped bool + rawStart = -1 // start of the current word in input ) endWord := func() { if inWord { - words = append(words, b.String()) + tokens = append(tokens, token{word: b.String()}) } b.Reset() inWord = false + rawStart = -1 + } + startRaw := func(i int) { + if rawStart < 0 { + rawStart = i + } } for i := 0; i < len(input); i++ { @@ -156,8 +173,10 @@ func splitWords(input string) ([]string, error) { b.WriteByte(ch) } case ch == '\\': + startRaw(i) escaped = true case ch == '$': + startRaw(i) value, n, err := expandVar(input[i:]) if err != nil { return nil, err @@ -176,6 +195,7 @@ func splitWords(input string) ([]string, error) { b.WriteByte(ch) } case ch == '"' || ch == '\'': + startRaw(i) quote = ch inWord = true case ch == ' ' || ch == '\t' || ch == '\n': @@ -183,8 +203,55 @@ func splitWords(input string) ([]string, error) { case ch == '#' && !inWord && b.Len() == 0: // Comment until the end of the line i = len(input) + case ch == '|': + endWord() + tokens = append(tokens, token{op: "|"}) + case ch == '&' && i+1 < len(input) && input[i+1] == '>': + // &> and &>> redirect stdout and stderr + endWord() + op := "&>" + if i+2 < len(input) && input[i+2] == '>' { + op = "&>>" + } + tokens = append(tokens, token{op: op, fd: 1}) + i += len(op) - 1 + case ch == '<' || ch == '>': + // A word consisting only of unquoted digits right before the + // operator is the file descriptor, e.g. 2>file + fd := 0 + if ch == '>' { + fd = 1 + } + if inWord && rawStart >= 0 && isDigits(input[rawStart:i]) { + fd, _ = strconv.Atoi(input[rawStart:i]) + b.Reset() + inWord = false + rawStart = -1 + } else { + endWord() + } + t := token{op: string(ch), fd: fd} + switch { + case ch == '>' && i+1 < len(input) && input[i+1] == '>': + t.op = ">>" + i++ + case i+1 < len(input) && input[i+1] == '&': + // N>&M and N<&M duplicate file descriptor M + j := i + 2 + for j < len(input) && input[j] >= '0' && input[j] <= '9' { + j++ + } + if j == i+2 { + return nil, syntaxError(input[i : i+2]) + } + t.op = string(ch) + "&" + t.dup, _ = strconv.Atoi(input[i+2 : j]) + i = j - 1 + } + tokens = append(tokens, t) case ch == '~' && !inWord && b.Len() == 0 && - (i+1 == len(input) || strings.IndexByte("/ \t\n", input[i+1]) >= 0): + (i+1 == len(input) || strings.IndexByte("/ \t\n|<>", input[i+1]) >= 0): + startRaw(i) home := getHomeDir() if home == "" { home = "~" @@ -192,6 +259,7 @@ func splitWords(input string) ([]string, error) { b.WriteString(home) inWord = true default: + startRaw(i) b.WriteByte(ch) inWord = true } @@ -204,6 +272,35 @@ func splitWords(input string) ([]string, error) { return nil, fmt.Errorf("unterminated %c quote", quote) } endWord() + return tokens, nil +} + +// isDigits reports whether s is a non-empty string of ASCII digits. +func isDigits(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} + +// splitWords splits input into words (see tokenize). Operators are an error. +func splitWords(input string) ([]string, error) { + tokens, err := tokenize(input) + if err != nil { + return nil, err + } + var words []string + for _, t := range tokens { + if t.op != "" { + return nil, fmt.Errorf("unexpected operator %s", t.op) + } + words = append(words, t.word) + } return words, nil } @@ -469,7 +566,10 @@ func newShellCompleter() *shellCompleter { // runes of the current word that are already typed. func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) { word, start, quote := currentWord(string(line[:pos])) - isFirstWord := strings.TrimSpace(string(line[:start])) == "" + // A command is expected at the start and after |, ;, && and || + before := strings.TrimSpace(string(line[:start])) + isFirstWord := before == "" || strings.HasSuffix(before, "|") || + strings.HasSuffix(before, ";") || strings.HasSuffix(before, "&&") // Plain "~" -> "~/" if word == "~" { @@ -553,7 +653,8 @@ func currentWord(s string) (word string, start int, quote byte) { } case ch == '"' || ch == '\'': quote = ch - case ch == ' ' || ch == '\t': + case ch == ' ' || ch == '\t' || strings.IndexByte(";|&<>", ch) >= 0: + // Blanks and operators end a word b.Reset() start = i + 1 default: @@ -567,7 +668,7 @@ func currentWord(s string) (word string, start int, quote byte) { func escapeWord(s string) string { var b strings.Builder for _, r := range s { - if strings.ContainsRune(" \t\\'\"", r) { + if strings.ContainsRune(" \t\\'\";|&<>", r) { b.WriteByte('\\') } b.WriteRune(r) @@ -743,6 +844,14 @@ func exitStatus(err error) int { if err == nil { return 0 } + var statusErr statusError + if errors.As(err, &statusErr) { + return int(statusErr) + } + var synErr syntaxError + if errors.As(err, &synErr) { + return 2 + } var exitErr *exec.ExitError if errors.As(err, &exitErr) { if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() { @@ -829,6 +938,21 @@ func interactive() { // lastStatus is the exit status of the last command ($?). var lastStatus int +// statusError is an error that only carries an exit status; the problem has +// already been reported. +type statusError int + +func (e statusError) Error() string { + return "exit status " + strconv.Itoa(int(e)) +} + +// syntaxError is a syntax error in the input (exit status 2, like sh). +type syntaxError string + +func (e syntaxError) Error() string { + return "syntax error near unexpected token `" + string(e) + "'" +} + // errCommandNotFound is returned when an external command does not exist. var errCommandNotFound = errors.New("command not found") @@ -836,8 +960,11 @@ var errCommandNotFound = errors.New("command not found") // 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) { + var ( + exitErr *exec.ExitError + statusErr statusError + ) + if err == nil || errors.As(err, &exitErr) || errors.As(err, &statusErr) { return } fmt.Fprintln(os.Stderr, "gosh:", err) @@ -872,7 +999,7 @@ func splitList(input string) ([]listItem, error) { if next == "" { next = op } - return fmt.Errorf("syntax error near unexpected token `%s'", next) + return syntaxError(next) } return nil } @@ -940,7 +1067,7 @@ scan: func execInput(input string) error { items, err := splitList(input) if err != nil { - lastStatus = 2 + lastStatus = exitStatus(err) return err } @@ -956,19 +1083,256 @@ func execInput(input string) error { return lastErr } -// execCommand executes a single simple command. +// execCommand executes a pipeline of simple commands with redirections. func execCommand(input string) error { - // Split the input into the command and its arguments, expanding - // variables and ~ on the way. - args, err := splitWords(input) + // 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) { + return err + } if err != nil { return fmt.Errorf("invalid input: %w", err) } - if len(args) == 0 { + stages, err := parsePipeline(tokens) + if err != nil { + return err + } + if len(stages) == 0 { return nil } + return runPipeline(stages) +} - // Check for built-in commands. +// redirect is a redirection of a simple command, e.g. 2>>file or 2>&1. +type redirect struct { + op string // <, >, >>, &>, &>>, >& or <& + fd int // the redirected file descriptor + dup int // the target file descriptor of >& and <& + file string // the file name for all other operators +} + +// stage is a simple command of a pipeline. +type stage struct { + args []string + redirects []redirect +} + +// parsePipeline groups tokens into the stages of a pipeline. +func parsePipeline(tokens []token) ([]stage, error) { + var ( + stages []stage + cur stage + ) + isEmpty := func(s stage) bool { return len(s.args) == 0 && len(s.redirects) == 0 } + + for i := 0; i < len(tokens); i++ { + t := tokens[i] + switch t.op { + case "": + cur.args = append(cur.args, t.word) + case "|": + if isEmpty(cur) { + return nil, syntaxError("|") + } + stages = append(stages, cur) + cur = stage{} + case ">&", "<&": + cur.redirects = append(cur.redirects, redirect{op: t.op, fd: t.fd, dup: t.dup}) + default: + if i+1 == len(tokens) { + return nil, syntaxError("newline") + } + if next := tokens[i+1]; next.op != "" { + return nil, syntaxError(next.op) + } + i++ + cur.redirects = append(cur.redirects, redirect{op: t.op, fd: t.fd, file: tokens[i].word}) + } + } + + if isEmpty(cur) { + if len(stages) > 0 { + return nil, syntaxError("|") + } + return nil, nil + } + return append(stages, cur), nil +} + +// runPipeline runs the stages of a pipeline concurrently, connecting the +// stdout of each stage to the stdin of the next one. It returns the error of +// the last stage (so $? is its exit status, like in sh); errors of the other +// stages are printed. +func runPipeline(stages []stage) error { + waits := make([]func() error, len(stages)) + stdin := os.Stdin + for i, st := range stages { + stdout := os.Stdout + var next *os.File + if i < len(stages)-1 { + r, w, err := os.Pipe() + if err != nil { + // Let the stages that already run finish + if stdin != os.Stdin { + stdin.Close() + } + for _, wait := range waits[:i] { + printError(wait()) + } + return err + } + stdout, next = w, r + } + waits[i] = startStage(st, stdin, stdout, len(stages) > 1) + stdin = next + } + + var err error + for i, wait := range waits { + err = wait() + if i < len(waits)-1 { + printError(err) + } + } + return err +} + +// startStage starts a stage of a pipeline with the given stdin and stdout and +// returns a function that waits for it to finish. Pipe ends passed in are +// closed by startStage once the stage no longer needs them, so that the +// neighbors see EOF or get SIGPIPE. +func startStage(st stage, stdin, stdout *os.File, inPipeline bool) func() error { + var owned []*os.File + if stdin != os.Stdin { + owned = append(owned, stdin) + } + if stdout != os.Stdout { + owned = append(owned, stdout) + } + closeOwned := func() { + for _, f := range owned { + f.Close() + } + } + result := func(err error) func() error { + return func() error { return err } + } + + // Connect the command to the terminal unless redirected. Without Stdin + // the command would read from /dev/null, so interactive programs (su, + // vim, ssh, ...) would get EOF right away. readline only reads from stdin + // while a line is being edited, so it does not steal input. + fds := [3]*os.File{stdin, stdout, os.Stderr} + opened, err := applyRedirects(&fds, st.redirects) + owned = append(owned, opened...) + if err != nil || len(st.args) == 0 { + // A command without arguments only creates or truncates the files + closeOwned() + return result(err) + } + + if builtin, ok := builtins[st.args[0]]; ok { + if inPipeline && builtin.changesState { + closeOwned() + return result(fmt.Errorf("%s: cannot be used in a pipeline", st.args[0])) + } + run := func() error { + defer closeOwned() + err := runBuiltin(st.args, fds[1]) + if err != nil && fds[2] != os.Stderr { + // Report the error where stderr is redirected to + fmt.Fprintln(fds[2], "gosh:", err) + return statusError(1) + } + return err + } + if !inPipeline { + return result(run()) + } + done := make(chan error, 1) + go func() { done <- run() }() + return func() error { return <-done } + } + + cmd := exec.Command(st.args[0], st.args[1:]...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = fds[0], fds[1], fds[2] + err = cmd.Start() + // The child has its own copies of the files now + closeOwned() + if errors.Is(err, exec.ErrNotFound) { + err = fmt.Errorf("%s: %w", sanitize(st.args[0]), errCommandNotFound) + } + if err != nil { + return result(err) + } + return cmd.Wait +} + +// applyRedirects applies the redirections in order to fds (stdin, stdout, +// stderr) and returns the files it opened. +func applyRedirects(fds *[3]*os.File, redirects []redirect) ([]*os.File, error) { + var opened []*os.File + fail := func(err error) ([]*os.File, error) { + for _, f := range opened { + f.Close() + } + return nil, err + } + + for _, r := range redirects { + if r.fd > 2 { + return fail(fmt.Errorf("%d: bad file descriptor", r.fd)) + } + if r.op == ">&" || r.op == "<&" { + if r.dup > 2 { + return fail(fmt.Errorf("%d: bad file descriptor", r.dup)) + } + fds[r.fd] = fds[r.dup] + continue + } + + flags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC + switch r.op { + case "<": + flags = os.O_RDONLY + case ">>", "&>>": + flags = os.O_WRONLY | os.O_CREATE | os.O_APPEND + } + f, err := os.OpenFile(r.file, flags, 0666) + if err != nil { + // "file: No such file or directory" with exit status 1, like sh + var pathErr *fs.PathError + if errors.As(err, &pathErr) { + err = fmt.Errorf("%s: %v", sanitize(r.file), pathErr.Err) + } + return fail(err) + } + opened = append(opened, f) + fds[r.fd] = f + if r.op == "&>" || r.op == "&>>" { + fds[2] = f + } + } + return opened, nil +} + +// builtins lists the built-in commands. Commands that change the state of +// the shell cannot be used in a pipeline, since there they would run +// concurrently (sh runs them in a subshell where they have no effect). +var builtins = map[string]struct{ changesState bool }{ + "cd": {changesState: true}, + "pwd": {}, + "exit": {changesState: true}, + "quit": {changesState: true}, + "set": {changesState: true}, + "export": {changesState: true}, + "help": {}, +} + +// runBuiltin executes a built-in command, writing its output to stdout. +func runBuiltin(args []string, stdout io.Writer) error { switch args[0] { case "cd": // Save current directory before changing (for 'cd -') @@ -1002,7 +1366,7 @@ func execCommand(input string) error { return os.Chdir(path) case "pwd": wd, err := os.Getwd() - fmt.Println(wd) + fmt.Fprintln(stdout, wd) return err case "exit": // exit with optional status code @@ -1045,7 +1409,7 @@ func execCommand(input string) error { os.Setenv(parts[0], parts[1]) return nil case "help": - fmt.Println("gosh - Go Shell\nVersion: " + versionInfo() + ` + fmt.Fprintln(stdout, "gosh - Go Shell\nVersion: " + versionInfo() + ` Built-in commands: cd [dir] - Change directory (cd with no args goes to home) @@ -1074,26 +1438,11 @@ Features: 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 + - Pipes (a | b) and redirections: < file, > file, >> file, 2> file, + 2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file - Non-interactive use: gosh -c 'command', gosh script, command | gosh; the exit status is the one of the last command`) return nil } - - // Prepare the command to execute. - cmd := exec.Command(args[0], args[1:]...) - - // Connect the command to the terminal. Without Stdin the command would - // read from /dev/null, so interactive programs (su, vim, ssh, ...) would - // get EOF right away. readline only reads from stdin while a line is - // being edited, so it does not steal input from the command. - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - // Execute the command return the error. - err = cmd.Run() - if errors.Is(err, exec.ErrNotFound) { - return fmt.Errorf("%s: %w", sanitize(args[0]), errCommandNotFound) - } - return err + return fmt.Errorf("%s: not a builtin", args[0]) } diff --git a/main_test.go b/main_test.go index 91cc012..71a9dff 100644 --- a/main_test.go +++ b/main_test.go @@ -859,6 +859,15 @@ func TestShellCompleter_Do(t *testing.T) { {"ls " + tmpDir + "/ma", []string{"in.go"}, 2}, {"ls xyz", nil, 3}, {"ls ~", []string{"/"}, 1}, + // Commands are completed after |, ;, && and ||, files after redirections + {"ls | gi", []string{"tty "}, 2}, + {"ls|gi", []string{"tty "}, 2}, + {"true; gi", []string{"tty "}, 2}, + {"true && gi", []string{"tty "}, 2}, + {"false || gi", []string{"tty "}, 2}, + {"ls > ma", []string{"in.go"}, 2}, + {"ls 2>ma", []string{"in.go"}, 2}, + {"cat f", []token{{word: "a"}, {op: ">", fd: 1}, {word: "f"}}}, + {"a >>f", []token{{word: "a"}, {op: ">>", fd: 1}, {word: "f"}}}, + {"a < f", []token{{word: "a"}, {op: "<", fd: 0}, {word: "f"}}}, + {"a 2>f", []token{{word: "a"}, {op: ">", fd: 2}, {word: "f"}}}, + {"a 2>>f", []token{{word: "a"}, {op: ">>", fd: 2}, {word: "f"}}}, + {"a 2>&1", []token{{word: "a"}, {op: ">&", fd: 2, dup: 1}}}, + {"a >&2", []token{{word: "a"}, {op: ">&", fd: 1, dup: 2}}}, + {"a 0<&2", []token{{word: "a"}, {op: "<&", fd: 0, dup: 2}}}, + {"a &>f", []token{{word: "a"}, {op: "&>", fd: 1}, {word: "f"}}}, + {"a &>>f", []token{{word: "a"}, {op: "&>>", fd: 1}, {word: "f"}}}, + // Only a word of unquoted digits is a file descriptor + {"a x2>f", []token{{word: "a"}, {word: "x2"}, {op: ">", fd: 1}, {word: "f"}}}, + {`a "2">f`, []token{{word: "a"}, {word: "2"}, {op: ">", fd: 1}, {word: "f"}}}, + {"a 2 >f", []token{{word: "a"}, {word: "2"}, {op: ">", fd: 1}, {word: "f"}}}, + // Quoted and escaped operators are words + {`echo "a|b" 'c>d' e\d"}, {word: "e", "echo a > | b", "echo a >&", "echo a 2>&x", "; a"} { + err := execInput(input) + if err == nil || !strings.Contains(err.Error(), "syntax error") { + t.Errorf("execInput(%q) = %v, want syntax error", input, err) + } + if lastStatus != 2 { + t.Errorf("execInput(%q): $? = %d, want 2", input, lastStatus) + } + } +} + +func TestExecInput_PipesAndRedirections(t *testing.T) { + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + dir := t.TempDir() + os.Chdir(dir) + + read := func(name string) string { + data, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + t.Errorf("reading %s: %v", name, err) + } + return string(data) + } + + tests := []struct { + input string + file string + want string + status int + }{ + {`printf 'b\na\nc\n' | sort | head -n 2 > out`, "out", "a\nb\n", 0}, + {"echo one > f1; echo two >> f1", "f1", "one\ntwo\n", 0}, + {"echo one > f2; echo two > f2", "f2", "two\n", 0}, + {"tr a-z A-Z < f1 > f3", "f3", "ONE\nTWO\n", 0}, + {"sh -c 'echo out; echo err >&2' > f4 2>&1", "f4", "out\nerr\n", 0}, + {"sh -c 'echo out; echo err >&2' &> f5", "f5", "out\nerr\n", 0}, + {"sh -c 'echo err >&2' 2> f6", "f6", "err\n", 0}, + // 2>&1 before >file sends stderr to the old stdout (the pipe) + {"sh -c 'echo out; echo err >&2' 2>&1 > /dev/null | cat > f7", "f7", "err\n", 0}, + {"sh -c 'echo err >&2' 2>&1 | tr a-z A-Z > f8", "f8", "ERR\n", 0}, + // Builtins write to redirections and pipes + {"pwd > f9", "f9", dir + "\n", 0}, + {"pwd | cat > f10", "f10", dir + "\n", 0}, + {"help | head -n 1 > f11", "f11", "gosh - Go Shell\n", 0}, + // Errors of builtins go to the redirected stderr + {"cd /gosh/no/such/dir 2> f12", "f12", "gosh: chdir /gosh/no/such/dir: no such file or directory\n", 1}, + // The status of a pipeline is the one of the last command + {"false | true > f13", "f13", "", 0}, + {"true | false > f14", "f14", "", 1}, + // A redirection without a command creates the file + {"> f15", "f15", "", 0}, + // SIGPIPE ends the writer when the reader exits + {"yes | head -n 1 > f16", "f16", "y\n", 0}, + // A command that is not found does not stop the pipeline + {"gosh-no-such-command | echo after > f17", "f17", "after\n", 0}, + {"echo before | gosh-no-such-command 2> f18", "f18", "", 127}, + } + for _, tc := range tests { + execInput(tc.input) + if lastStatus != tc.status { + t.Errorf("%q: $? = %d, want %d", tc.input, lastStatus, tc.status) + } + if got := read(tc.file); got != tc.want { + t.Errorf("%q: %s = %q, want %q", tc.input, tc.file, got, tc.want) + } + } +} + +func TestExecInput_RedirectionErrors(t *testing.T) { + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + os.Chdir(t.TempDir()) + + tests := []struct { + input string + wantErr string + }{ + {"cat < gosh-no-such-file", "gosh-no-such-file: no such file or directory"}, + {"echo a > gosh-no-such-dir/f", "gosh-no-such-dir/f: no such file or directory"}, + {"echo a 3> f", "3: bad file descriptor"}, + {"echo a 2>&5", "5: bad file descriptor"}, + {"echo a | cd /", "cd: cannot be used in a pipeline"}, + {"echo a | exit 3", "exit: cannot be used in a pipeline"}, + } + for _, tc := range tests { + err := execInput(tc.input) + if err == nil || err.Error() != tc.wantErr { + t.Errorf("execInput(%q) = %v, want %q", tc.input, err, tc.wantErr) + } + if lastStatus != 1 { + t.Errorf("execInput(%q): $? = %d, want 1", tc.input, lastStatus) + } + } + // The command is not run if a redirection fails + if _, err := os.Stat("f"); err == nil { + t.Error("file f must not be created for a bad file descriptor") + } +}