From f79fb0b8d54da75ed36e1a239487fbe2c7d6d303 Mon Sep 17 00:00:00 2001 From: Florian Walther Date: Sun, 4 Oct 2026 00:05:28 +0200 Subject: [PATCH] add control structures: if, for, while, until - new parser (parser.go) for lists, pipelines, ! and if/elif/else/fi, for ... in ... do/done, while/until ... do/done; simple commands are kept as text and expanded when they run (in every iteration) - break [n], continue [n] and the : builtin - incomplete input (open control structure, trailing &&, |, quote or backslash) continues on the next line: prompt "> " in interactive shells, scripts and startup files read on - Ctrl+C aborts the whole command line, including loops - aliases are expanded at the start of every simple command, also inside control structures - control structures in pipelines, with redirections or in the background are reported as not supported - completion: commands after if, then, do etc. - main_test.go reformatted with gofmt Co-Authored-By: Claude Opus 5.5 --- README.md | 52 +++ alias.go | 2 +- main.go | 304 ++++++++---------- main_test.go | 222 ++++++++++--- parser.go | 841 +++++++++++++++++++++++++++++++++++++++++++++++++ parser_test.go | 164 ++++++++++ subst_test.go | 14 +- 7 files changed, 1362 insertions(+), 237 deletions(-) create mode 100644 parser.go create mode 100644 parser_test.go diff --git a/README.md b/README.md index bad7394..cf7cc32 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,9 @@ Built-in commands: wait [%n] - Wait for job n or all background jobs alias [name[=value] ...] - Define or show aliases (alias ll='ls -l') unalias name - Remove an alias (unalias -a: all aliases) + : - Do nothing (exit status 0) + break [n] - Leave the innermost (or n-th) for/while/until loop + continue [n] - Continue with the next iteration of a loop help - Show this help Features: @@ -48,6 +51,9 @@ Features: 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 + - Control structures: if cmd; then ...; elif cmd; then ...; else ...; fi, + for x in words; do ...; done, while cmd; do ...; done, until ..., + ! cmd negates the status; they can span several lines (prompt "> ") - Pipes (a | b) and redirections: < file, > file, >> file, 2> file, 2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file - Job control: command & runs in the background, Ctrl+Z stops the @@ -125,6 +131,52 @@ export LANG=de_DE.UTF-8 export PATH=$HOME/bin:$PATH ``` +### Kontrollstrukturen + +gosh kennt `if`, `for`, `while` und `until` wie `sh`, auf einer Zeile oder über mehrere Zeilen: + +```bash +if [ -f ~/.goshrc ]; then + echo "rc vorhanden" +elif [ -d ~/.config ]; then + echo "nur .config" +else + echo "nichts" +fi + +for f in *.log; do + gzip "$f" +done + +for i in {1..5}; do echo "Durchlauf $i"; done + +set n=3 +while [ $n -gt 0 ]; do + echo $n + set n=$(expr $n - 1) +done + +until ping -c1 -W1 server >/dev/null; do sleep 1; done +``` + +| Konstrukt | Bedeutung | +|---------------------------------------------|-------------------------------------------------------------------| +| `if befehl; then …; elif …; then …; else …; fi` | führt den Zweig aus, dessen Bedingung den Exit-Status 0 hat | +| `for x in wörter; do …; done` | setzt `x` nacheinander auf jedes Wort | +| `while befehl; do …; done` | wiederholt, solange `befehl` erfolgreich ist | +| `until befehl; do …; done` | wiederholt, bis `befehl` erfolgreich ist | +| `! befehl` | kehrt den Exit-Status um | +| `break [n]`, `continue [n]` | verlässt die (n-te) Schleife bzw. springt zum nächsten Durchlauf | +| `:` | tut nichts, Exit-Status 0 (z. B. `while :; do …; done`) | + +- Bedingungen sind normale Befehle (auch Listen mit `&&`/`||`), meist `test` bzw. `[ … ]` (z. B. `[ -f datei ]`, `[ "$a" = b ]`, `[ $n -lt 3 ]`). +- Die Wörter nach `for … in` werden wie Argumente expandiert: `*.go`, `{1..10}`, `$VAR`, `$(befehl)`. Ohne `in` läuft die Schleife nicht (Positionsparameter gibt es nicht). +- Variablen, `$(...)` und Wildcards im Rumpf werden bei jedem Durchlauf neu ersetzt. +- Ist ein Befehl noch nicht vollständig (z. B. nach `if …; then` oder `for …` + Enter, nach `&&`, `|` oder einem `\` am Zeilenende), zeigt gosh den Fortsetzungs-Prompt `> `; Ctrl+C verwirft die angefangene Eingabe. Skripte und Startdateien lesen entsprechend weiter. +- Ctrl+C bricht die ganze Befehlszeile ab, also auch eine Schleife (`while true; do sleep 1; done`). +- Der Exit-Status ist der des zuletzt ausgeführten Befehls; ein `if` ohne passenden Zweig und eine Schleife ohne Durchlauf haben den Status 0. +- Nicht unterstützt: Kontrollstrukturen in Pipes (`for …; done | sort`), mit Umleitung (`done > datei`) oder im Hintergrund (`done &`), außerdem `case`, Funktionen, `read` und Zuweisungen der Form `x=1` – Variablen setzt man mit `set x=1`, Arithmetik geht z. B. mit `$(expr $n + 1)`. + ### Befehlsersetzung `$(befehl)` wird durch die Ausgabe von `befehl` ersetzt: diff --git a/alias.go b/alias.go index 7b9213a..2bd7426 100644 --- a/alias.go +++ b/alias.go @@ -75,7 +75,7 @@ func expandAliasesIn(line string, active map[string]bool) string { for i := 0; i < len(line); i++ { ch := line[i] if quote == 0 && !escaped && commandPos { - if ch == ' ' || ch == '\t' { + if ch == ' ' || ch == '\t' || ch == '\n' { out.WriteByte(ch) continue } diff --git a/main.go b/main.go index deb4f1d..82a8700 100644 --- a/main.go +++ b/main.go @@ -742,7 +742,7 @@ type shellCompleter struct { func newShellCompleter() *shellCompleter { return &shellCompleter{ - commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "source", "jobs", "fg", "bg", "wait", "alias", "unalias", "help"}, + commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "source", "jobs", "fg", "bg", "wait", "alias", "unalias", "break", "continue", "help"}, } } @@ -756,6 +756,13 @@ func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) { before := strings.TrimSpace(string(line[:start])) isFirstWord := before == "" || strings.HasSuffix(before, "|") || strings.HasSuffix(before, ";") || strings.HasSuffix(before, "&&") + // ... and after if, then, do etc. + if fields := strings.Fields(before); len(fields) > 0 { + switch fields[len(fields)-1] { + case "if", "then", "elif", "else", "do", "while", "until", "!": + isFirstWord = true + } + } // Plain "~" -> "~/" if word == "~" { @@ -1070,28 +1077,56 @@ func sourceFile(cmd, path string) error { return nil } -// runScript executes the commands from r line by line. Like sh, it does not -// stop at a failing command and returns the exit status of the last one. -// Empty lines and lines starting with # (e.g. a #!/usr/bin/gosh line) are -// skipped. If name is set, errors are reported with file name and line. +// runScript executes the commands from r. Each command is executed as soon +// as it is complete (a control structure can span several lines). Like sh, +// it does not stop at a failing command and returns the exit status of the +// last one. If name is set, errors are reported with file name and line. func runScript(r io.Reader, name string) int { - status := 0 - for lineNo := 1; ; lineNo++ { - line, err := readLine(r) - if trimmed := strings.TrimSpace(line); trimmed != "" && !strings.HasPrefix(trimmed, "#") { - cmdErr := execInput(line) - status = lastStatus - if name != "" && cmdErr != nil { - cmdErr = fmt.Errorf("%s:%d: %w", sanitize(name), lineNo, cmdErr) - } - printError(cmdErr) + var ( + buf strings.Builder + startLine int + ) + report := func(err error, line int) { + if name != "" && err != nil { + err = fmt.Errorf("%s:%d: %w", sanitize(name), line, err) } - if err != nil { - if err != io.EOF { - fmt.Fprintln(os.Stderr, "gosh:", err) + printError(err) + } + + for lineNo := 1; ; lineNo++ { + line, readErr := readLine(r) + if readErr == nil || line != "" { + if buf.Len() == 0 { + if strings.TrimSpace(line) == "" { + goto next + } + startLine = lineNo + } + buf.WriteString(line) + buf.WriteByte('\n') + tree, err := parse(buf.String()) + if errors.Is(err, errIncomplete) { + goto next // read the rest of the command + } + buf.Reset() + if err != nil { + lastStatus = exitStatus(err) + report(err, startLine) + } else { + report(tree.exec(), startLine) + } + } + next: + if readErr != nil { + if readErr != io.EOF { + fmt.Fprintln(os.Stderr, "gosh:", readErr) return 1 } - return status + if buf.Len() > 0 { + lastStatus = 2 + report(errors.New("syntax error: unexpected end of file"), startLine) + } + return lastStatus } } } @@ -1129,10 +1164,16 @@ func exitStatus(err error) int { if errors.As(err, &statusErr) { return int(statusErr) } - var synErr syntaxError - if errors.As(err, &synErr) { + var ( + synErr syntaxError + lc loopControl + ) + if errors.As(err, &synErr) || errors.Is(err, errIncomplete) { return 2 } + if errors.As(err, &lc) { + return 0 + } var exitErr *exec.ExitError if errors.As(err, &exitErr) { if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() { @@ -1184,23 +1225,35 @@ func interactive() { } defer l.Close() - // Main loop + // Main loop. A command that is not complete yet (e.g. if without fi) + // is continued on the next line with the prompt "> ". + var buf strings.Builder for { - // Report finished and stopped background jobs - reapJobs(true) + if buf.Len() == 0 { + // Report finished and stopped background jobs + reapJobs(true) + // Update prompt dynamically (in case directory changed) + l.SetPrompt(getPrompt()) + } else { + l.SetPrompt("> ") + } - // Update prompt dynamically (in case directory changed) - l.SetPrompt(getPrompt()) - line, err := l.Readline() if err != nil { - // Ctrl+C discards the current line + // Ctrl+C discards the current line (and an incomplete command) if err == readline.ErrInterrupt { + buf.Reset() continue } // Handle EOF (Ctrl+D) if err == io.EOF { fmt.Println() + if buf.Len() > 0 { + buf.Reset() + lastStatus = 2 + fmt.Fprintln(os.Stderr, "gosh: syntax error: unexpected end of file") + continue + } if hasStoppedJobs() && !exitWarned { exitWarned = true fmt.Fprintln(os.Stderr, "gosh: there are stopped jobs") @@ -1213,7 +1266,7 @@ func interactive() { } // Skip empty input - if strings.TrimSpace(line) == "" { + if buf.Len() == 0 && strings.TrimSpace(line) == "" { continue } @@ -1223,8 +1276,19 @@ func interactive() { l.SaveHistory(line) } - // Execute the input - printError(execInput(line)) + buf.WriteString(line) + buf.WriteByte('\n') + tree, err := parse(buf.String()) + if errors.Is(err, errIncomplete) { + continue + } + buf.Reset() + if err != nil { + lastStatus = exitStatus(err) + printError(err) + continue + } + printError(tree.exec()) } } @@ -1256,144 +1320,24 @@ func printError(err error) { var ( exitErr *exec.ExitError statusErr statusError + lc loopControl ) - if err == nil || errors.As(err, &exitErr) || errors.As(err, &statusErr) { + if err == nil || errors.As(err, &exitErr) || errors.As(err, &statusErr) || errors.As(err, &lc) { 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 - background bool // the command ends with & -} - -// 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 syntaxError(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 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 { - 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 == '&' && !(i+1 < len(input) && strings.IndexByte("&>", input[i+1]) >= 0) && - !(i > 0 && strings.IndexByte("<>", input[i-1]) >= 0): - // A single & (not &&, &>, >& or <&) runs the command in the - // background - if err := add(i, "&"); err != nil { - return nil, err - } - items[len(items)-1].background = true - op = ";" - start = i + 1 - wordStart = true - 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. +// execInput parses and executes input: a command line or a whole script. +// 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 { - if interactiveShell { - input = expandAliases(input) - } - items, err := splitList(input) + tree, err := parse(input) if err != nil { lastStatus = exitStatus(err) 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, item.background) - lastStatus = exitStatus(lastErr) - } - return lastErr + return tree.exec() } // execCommand executes a pipeline of simple commands with redirections, in @@ -1701,21 +1645,24 @@ func applyRedirects(fds *[3]*os.File, redirects []redirect) ([]*os.File, error) // 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": {}, - "source": {changesState: true}, - ".": {changesState: true}, - "jobs": {}, - "fg": {changesState: true}, - "bg": {changesState: true}, - "wait": {changesState: true}, - "alias": {}, - "unalias": {changesState: true}, + "cd": {changesState: true}, + "pwd": {}, + "exit": {changesState: true}, + "quit": {changesState: true}, + "set": {changesState: true}, + "export": {changesState: true}, + "help": {}, + "source": {changesState: true}, + ".": {changesState: true}, + "jobs": {}, + "fg": {changesState: true}, + "bg": {changesState: true}, + "wait": {changesState: true}, + "alias": {}, + "unalias": {changesState: true}, + ":": {}, + "break": {changesState: true}, + "continue": {changesState: true}, } // runBuiltin executes a built-in command, writing its output to stdout. @@ -1795,6 +1742,11 @@ func runBuiltin(args []string, stdout io.Writer) error { } os.Setenv(parts[0], parts[1]) return nil + case ":": + // Does nothing, successfully (e.g. while :; do ...) + return nil + case "break", "continue": + return builtinLoopControl(args) case "alias": return builtinAlias(args, stdout) case "unalias": @@ -1831,6 +1783,9 @@ Built-in commands: wait [%n] - Wait for job n or all background jobs alias [name[=value] ...] - Define or show aliases (alias ll='ls -l') unalias name - Remove an alias (unalias -a: all aliases) + : - Do nothing (exit status 0) + break [n] - Leave the innermost (or n-th) for/while/until loop + continue [n] - Continue with the next iteration of a loop help - Show this help Features: @@ -1860,6 +1815,9 @@ Features: 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 + - Control structures: if cmd; then ...; elif cmd; then ...; else ...; fi, + for x in words; do ...; done, while cmd; do ...; done, until ..., + ! cmd negates the status; they can span several lines (prompt "> ") - Pipes (a | b) and redirections: < file, > file, >> file, 2> file, 2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file - Job control: command & runs in the background, Ctrl+Z stops the diff --git a/main_test.go b/main_test.go index 54ee6a6..bb87aa7 100644 --- a/main_test.go +++ b/main_test.go @@ -170,12 +170,12 @@ func TestExecInput_CdNoArgs(t *testing.T) { // Save current directory origDir, _ := os.Getwd() defer os.Chdir(origDir) - + err := execInput("cd") if err != nil { t.Errorf("cd with no args should go to home, got error: %v", err) } - + // Verify we changed to home directory home := getHomeDir() newDir, _ := os.Getwd() @@ -289,7 +289,7 @@ func TestShell_EOF_AfterCommand(t *testing.T) { if err != nil { t.Logf("Shell exited with: %v", err) } - + output := stdout.String() // Should contain the echo output if !strings.Contains(output, "test") { @@ -313,7 +313,7 @@ func TestShell_SIGINT_HandlerSetup(t *testing.T) { if err != nil { t.Logf("Shell exited with: %v, stderr: %s", err, stderr.String()) } - + output := stdout.String() if !strings.Contains(output, "test") { t.Errorf("expected 'test' in output, got: %s", output) @@ -333,7 +333,7 @@ func TestExecInput_CdToHomeWithTilde(t *testing.T) { if err != nil { t.Errorf("cd ~ failed: %v", err) } - + home := getHomeDir() newDir, _ := os.Getwd() if newDir != home { @@ -364,13 +364,13 @@ func TestExecInput_CdDash(t *testing.T) { if err != nil { t.Fatalf("cd to tmpDir failed: %v", err) } - + // Now cd - should go back err = execInput("cd -") if err != nil { t.Fatalf("cd - failed: %v", err) } - + // Verify we're back in origDir newDir, _ := os.Getwd() if newDir != origDir { @@ -383,7 +383,7 @@ func TestExecInput_CdDashNoPrevious(t *testing.T) { origPrevDir := previousDir defer func() { previousDir = origPrevDir }() previousDir = "" - + // cd - with no previous directory should return an error err := execInput("cd -") if err == nil { @@ -400,7 +400,7 @@ func TestExecInput_CdDashNoPrevious(t *testing.T) { func TestGetPrompt_Format(t *testing.T) { prompt := getPrompt() - + // Should contain username@hostname:path $ pattern if !strings.Contains(prompt, "@") { t.Errorf("expected @ in prompt, got: %s", prompt) @@ -417,10 +417,10 @@ func TestGetPrompt_IncludesHome(t *testing.T) { // Save and restore current directory origDir, _ := os.Getwd() defer os.Chdir(origDir) - + // Change to home directory os.Chdir(getHomeDir()) - + prompt := getPrompt() // When in home, should show ~ if !strings.Contains(prompt, "~") { @@ -436,7 +436,7 @@ func TestExecInput_ExitWithStatusCode(t *testing.T) { // We can't easily test os.Exit from a unit test // So we just verify the function doesn't panic with a status code // The actual exit behavior is tested in integration tests - + // This test just documents the expected behavior // Actual testing requires subprocess _ = true // Placeholder - integration tests handle this @@ -457,19 +457,19 @@ func TestShell_ExitWithStatusCode(t *testing.T) { {"quit 0\n", 0}, {"quit 5\n", 5}, } - + // Build the binary once build := exec.Command("go", "build", "-o", "gosh_test", ".") if err := build.Run(); err != nil { t.Fatalf("failed to build: %v", err) } defer os.Remove("gosh_test") - + for _, tc := range tests { t.Run(tc.input, func(t *testing.T) { cmd := exec.Command("./gosh_test") cmd.Stdin = strings.NewReader(tc.input) - + err := cmd.Run() if err == nil && tc.expected != 0 { t.Errorf("expected non-zero exit code %d, got 0", tc.expected) @@ -598,13 +598,13 @@ func TestExecInput_SetEnvVar(t *testing.T) { // Save original value orig := os.Getenv("GOSH_TEST_VAR") defer os.Setenv("GOSH_TEST_VAR", orig) - + // Set a new variable err := execInput("set GOSH_TEST_VAR=test_value") if err != nil { t.Errorf("set failed: %v", err) } - + // Verify it was set val := os.Getenv("GOSH_TEST_VAR") if val != "test_value" { @@ -616,13 +616,13 @@ func TestExecInput_ExportEnvVar(t *testing.T) { // Save original value orig := os.Getenv("GOSH_TEST_VAR2") defer os.Setenv("GOSH_TEST_VAR2", orig) - + // Export a new variable err := execInput("export GOSH_TEST_VAR2=exported_value") if err != nil { t.Errorf("export failed: %v", err) } - + // Verify it was set val := os.Getenv("GOSH_TEST_VAR2") if val != "exported_value" { @@ -842,7 +842,7 @@ func TestShellCompleter_Do(t *testing.T) { want []string length int }{ - {"c", []string{"d "}, 1}, + {"c", []string{"d ", "ontinue "}, 1}, {"e", []string{"cho ", "xit ", "xport "}, 1}, {"gi", []string{"tty "}, 2}, {" gi", []string{"tty "}, 2}, @@ -1387,48 +1387,170 @@ func TestReadLine_DoesNotReadAhead(t *testing.T) { } } -func TestSplitList(t *testing.T) { +// treeString formats a syntax tree for the tests: simple commands in +// brackets, background commands with a trailing &. +func treeString(l *listNode) string { + var parts []string + for _, e := range l.items { + s := pipelineString(e.andOr.first) + for _, r := range e.andOr.rest { + s += " " + r.op + " " + pipelineString(r.pipeline) + } + if e.background { + s += " &" + } + parts = append(parts, s) + } + return strings.Join(parts, "; ") +} + +func pipelineString(pl *pipelineNode) string { + s := "" + if pl.negate { + s = "! " + } + switch n := pl.cmd.(type) { + case *simpleNode: + s += "[" + n.text + "]" + case *ifNode: + s += "if" + for i := range n.conds { + if i > 0 { + s += " elif" + } + s += " " + treeString(n.conds[i]) + " then " + treeString(n.bodies[i]) + } + if n.elseBody != nil { + s += " else " + treeString(n.elseBody) + } + s += " fi" + case *forNode: + s += "for " + n.name + if n.hasIn { + s += " in (" + n.words + ")" + } + s += " do " + treeString(n.body) + " done" + case *whileNode: + if n.until { + s += "until " + } else { + s += "while " + } + s += treeString(n.cond) + " do " + treeString(n.body) + " done" + } + return s +} + +func TestParse_Lists(t *testing.T) { tests := []struct { input string - want []listItem + want string }{ - {"", nil}, - {" ", nil}, - {"# only a comment", nil}, - {"ls", []listItem{{"", "ls", false}}}, - {"a; b", []listItem{{"", "a", false}, {";", " b", false}}}, - {"a;", []listItem{{"", "a", false}}}, - {"a && b || c", []listItem{{"", "a ", false}, {"&&", " b ", false}, {"||", " c", false}}}, - {`echo "a;b" 'c&&d' e\;f`, []listItem{{"", `echo "a;b" 'c&&d' e\;f`, false}}}, - {`echo "x\"; y"`, []listItem{{"", `echo "x\"; y"`, false}}}, - {"echo ${X:-a;b}; c", []listItem{{"", "echo ${X:-a;b}", false}, {";", " c", false}}}, - {"echo a # b; c", []listItem{{"", "echo a ", false}}}, - {"echo a#b; c", []listItem{{"", "echo a#b", false}, {";", " c", false}}}, - {"echo a|b", []listItem{{"", "echo a|b", false}}}, - {"a & b", []listItem{{"", "a ", true}, {";", " b", false}}}, - {"a &", []listItem{{"", "a ", true}}}, - {"a&", []listItem{{"", "a", true}}}, - {"a 2>&1 &", []listItem{{"", "a 2>&1 ", true}}}, - {"a &> f &", []listItem{{"", "a &> f ", true}}}, - {"a >&2 && b", []listItem{{"", "a >&2 ", false}, {"&&", " b", false}}}, - {`echo "a & b" \&`, []listItem{{"", `echo "a & b" \&`, false}}}, + {"", ""}, + {" ", ""}, + {"# only a comment", ""}, + {"ls", "[ls]"}, + {"a; b", "[a]; [b]"}, + {"a;", "[a]"}, + {"a\nb\n", "[a]; [b]"}, + {"a && b || c", "[a] && [b] || [c]"}, + {"a &&\n b", "[a] && [b]"}, + {"a | b|c", "[a | b|c]"}, + {"a |\n b", "[a |\n b]"}, + {`echo "a;b" 'c&&d' e\;f`, `[echo "a;b" 'c&&d' e\;f]`}, + {`echo "x\"; y"`, `[echo "x\"; y"]`}, + {"echo ${X:-a;b}; c", "[echo ${X:-a;b}]; [c]"}, + {"echo a # b; c", "[echo a]"}, + {"echo a#b; c", "[echo a#b]; [c]"}, + {"a & b", "[a] &; [b]"}, + {"a&", "[a] &"}, + {"a 2>&1 &", "[a 2>&1] &"}, + {"a &> f &", "[a &> f] &"}, + {"a >&2 && b", "[a >&2] && [b]"}, + {`echo "a & b" \&`, `[echo "a & b" \&]`}, + {"echo $(a; b && c | d) \"$(e || f)\" & g", `[echo $(a; b && c | d) "$(e || f)"] &; [g]`}, + {"! false", "! [false]"}, + {"echo a \\\n b", "[echo a b]"}, + // Reserved words are only special as the first word of a command + {"echo if then fi do done", "[echo if then fi do done]"}, } for _, tc := range tests { - got, err := splitList(tc.input) + tree, err := parse(tc.input) if err != nil { - t.Errorf("splitList(%q) returned error: %v", tc.input, err) + t.Errorf("parse(%q) returned error: %v", tc.input, err) continue } - if !reflect.DeepEqual(got, tc.want) { - t.Errorf("splitList(%q) = %+v, want %+v", tc.input, got, tc.want) + if got := treeString(tree); got != tc.want { + t.Errorf("parse(%q) = %s, want %s", 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", "& 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 TestParse_ControlStructures(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"if a; then b; fi", "if [a] then [b] fi"}, + {"if a\nthen\n b\n c\nfi", "if [a] then [b]; [c] fi"}, + {"if a; then b; elif c; then d; else e; fi", "if [a] then [b] elif [c] then [d] else [e] fi"}, + {"if a && ! b; then c; fi; echo x", "if [a] && ! [b] then [c] fi; [echo x]"}, + {"for x in a b c; do echo $x; done", "for x in (a b c) do [echo $x] done"}, + {"for x in *.go {1..3}\ndo\n echo $x\ndone", "for x in (*.go {1..3}) do [echo $x] done"}, + {"for x; do echo; done", "for x do [echo] done"}, + {"for x in; do echo; done", "for x in () do [echo] done"}, + {"while a; do b; done", "while [a] do [b] done"}, + {"until a; do b; done", "until [a] do [b] done"}, + {"while a; do if b; then break; fi; done", "while [a] do if [b] then [break] fi done"}, + {"for i in 1 2; do for j in 3 4; do echo $i$j; done; done", "for i in (1 2) do for j in (3 4) do [echo $i$j] done done"}, + {"if a; then if b; then c; fi fi", "if [a] then if [b] then [c] fi fi"}, + } + for _, tc := range tests { + tree, err := parse(tc.input) + if err != nil { + t.Errorf("parse(%q) returned error: %v", tc.input, err) + continue + } + if got := treeString(tree); got != tc.want { + t.Errorf("parse(%q) = %s, want %s", tc.input, got, tc.want) + } + } +} + +func TestParse_Incomplete(t *testing.T) { + for _, input := range []string{ + "if a", "if a; then", "if a; then b", "if a; then b; else", "if a; then b; elif c", + "for x", "for x in a b", "for x in a; do", "for x in a; do b", + "while a", "while a; do b", "until a; do", + "a &&", "a ||", "a |", "echo 'open", `echo "open`, "echo $(a", "echo a \\", + } { + if _, err := parse(input); !errors.Is(err, errIncomplete) { + t.Errorf("parse(%q) = %v, want incomplete", input, err) + } + } +} + +func TestParse_SyntaxErrors(t *testing.T) { + for _, input := range []string{ + "; a", "a ;; b", "&& a", "a && ; b", "a || && b", "& a", "a & && b", "a & & b", + "fi", "then", "done", "if a; then fi", "if ; then a; fi", "while a; do done", + "if a; then b; done", "for 1x in a; do b; done", "for x in a do b; done; x", + "a | | b", "| a", + } { + _, err := parse(input) + if err == nil || errors.Is(err, errIncomplete) { + t.Errorf("parse(%q) = %v, want syntax error", input, err) + } + } + for input, want := range map[string]string{ + "for x in a; do b; done | cat": "control structures in pipelines are not supported", + "echo | while a; do b; done": "control structures in pipelines are not supported", + "if a; then b; fi > out": "redirections of control structures are not supported (near `>')", + "for x in a; do b; done &": "only simple commands and pipelines can run in the background", + "a && b &": "only simple commands and pipelines can run in the background", + } { + if _, err := parse(input); err == nil || err.Error() != want { + t.Errorf("parse(%q) = %v, want %q", input, err, want) } } } diff --git a/parser.go b/parser.go new file mode 100644 index 0000000..4699878 --- /dev/null +++ b/parser.go @@ -0,0 +1,841 @@ +package main + +// Parser and executor for command lines and scripts: lists (; & && || and +// newlines), pipelines, ! and the control structures if, for, while and +// until. Simple commands (with their pipes and redirections) are kept as +// text; they are expanded and run by execCommand only when they are +// executed, so that e.g. the variable of a for loop is expanded anew in +// every iteration. + +import ( + "errors" + "fmt" + "os" + "strconv" + "strings" + "syscall" +) + +// incompleteError is returned by parse if the input ends inside a +// construct (if without fi, an open quote, a trailing && ...): an +// interactive shell then reads another line. +type incompleteError struct{} + +func (incompleteError) Error() string { return "syntax error: unexpected end of input" } + +var errIncomplete error = incompleteError{} + +// loopControl is returned by break and continue and ends the given number of +// enclosing loops (break) or iterations (continue). +type loopControl struct { + isBreak bool + n int +} + +func (c loopControl) Error() string { + if c.isBreak { + return "break" + } + return "continue" +} + +// loopDepth is the number of loops that are currently running. +var loopDepth int + +// aliasExpansion is false while the result of an alias expansion runs, so +// that it is not expanded again. +var aliasExpansion = true + +// reserved words that end a list; they are only recognized as the first word +// of a command. +var terminators = map[string]bool{"then": true, "elif": true, "else": true, "fi": true, "do": true, "done": true} + +// --------------------------------------------------------------------------- +// Lexer + +type tokenKind int + +const ( + tWord tokenKind = iota + tOp // ; & && || | or newline +) + +type rawToken struct { + kind tokenKind + text string + start, end int +} + +// joinContinuationLines removes backslash-newline (outside single quotes), +// which continues a line. continued reports whether src ends with such a +// continuation (so that the command goes on in the next line). +func joinContinuationLines(src string) (joined string, continued bool) { + if !strings.Contains(src, "\\\n") { + return src, false + } + var b strings.Builder + var quote byte + for i := 0; i < len(src); i++ { + ch := src[i] + switch { + case quote == '\'': + if ch == '\'' { + quote = 0 + } + case ch == '\\' && i+1 < len(src): + if src[i+1] == '\n' { + i++ + continued = i == len(src)-1 + continue + } + continued = false + b.WriteByte(ch) + i++ + ch = src[i] + case ch == '"': + if quote == '"' { + quote = 0 + } else { + quote = '"' + } + case ch == '\'' && quote == 0: + quote = '\'' + } + if ch != '\n' && ch != ' ' && ch != '\t' { + continued = false + } + b.WriteByte(ch) + } + return b.String(), continued +} + +// lex splits src into words and list operators. Words keep their quotes, +// escapes, $(...) and redirections; they are only split at blanks and list +// operators. +func lex(src string) ([]rawToken, error) { + var toks []rawToken + op := func(text string, i int) { + toks = append(toks, rawToken{tOp, text, i, i + len(text)}) + } + for i := 0; i < len(src); { + ch := src[i] + next := byte(0) + if i+1 < len(src) { + next = src[i+1] + } + switch { + case ch == ' ' || ch == '\t': + i++ + case ch == '\n' || ch == ';': + op(string(ch), i) + i++ + case ch == '#': + // Comment until the end of the line + for i < len(src) && src[i] != '\n' { + i++ + } + case ch == '|' && next == '|', ch == '&' && next == '&': + op(src[i:i+2], i) + i += 2 + case ch == '|', ch == '&' && next != '>': + op(string(ch), i) + i++ + default: + end, err := lexWord(src, i) + if err != nil { + return nil, err + } + toks = append(toks, rawToken{tWord, src[i:end], i, end}) + i = end + } + } + return toks, nil +} + +// lexWord returns the end of the word that starts at src[i]. +func lexWord(src string, i int) (int, error) { + var quote byte + for j := i; j < len(src); j++ { + ch := src[j] + switch { + case quote == '\'': + if ch == '\'' { + quote = 0 + } + case ch == '\\': + if j+1 == len(src) { + return 0, errIncomplete + } + j++ + case isSubstStart(src, j): + end := substEnd(src, j) + if end < 0 { + return 0, errIncomplete + } + j = end + case ch == '$' && j+1 < len(src) && src[j+1] == '{': + if k := strings.IndexByte(src[j:], '}'); k >= 0 { + j += k + } + case quote == '"': + if ch == '"' { + quote = 0 + } + case ch == '"' || ch == '\'': + quote = ch + case ch == ' ' || ch == '\t' || ch == '\n' || ch == ';' || ch == '|': + return j, nil + case ch == '&': + // &>, >& and <& are redirections, not the & operator + if !(j+1 < len(src) && src[j+1] == '>') && !(j > i && (src[j-1] == '>' || src[j-1] == '<')) { + return j, nil + } + } + } + if quote != 0 { + return 0, errIncomplete + } + return len(src), nil +} + +// --------------------------------------------------------------------------- +// Syntax tree + +// node is a command that can be executed: it returns the error of the last +// command it ran (so that exitStatus gives its status). +type node interface { + exec() error +} + +// listNode is a list of and-or lists separated by ;, & or newlines. +type listNode struct { + items []listEntry +} + +type listEntry struct { + andOr *andOrNode + background bool +} + +// andOrNode is a pipeline followed by && and || pipelines. +type andOrNode struct { + first *pipelineNode + rest []andOrPart +} + +type andOrPart struct { + op string // && or || + pipeline *pipelineNode +} + +// pipelineNode is a simple command (whose text may contain a pipeline) or a +// control structure, optionally negated with !. +type pipelineNode struct { + negate bool + cmd node +} + +// simpleNode is the text of a pipeline of simple commands. +type simpleNode struct { + text string +} + +type ifNode struct { + conds []*listNode + bodies []*listNode + elseBody *listNode +} + +type forNode struct { + name string + words string // the words after "in", expanded when the loop starts + hasIn bool + body *listNode +} + +type whileNode struct { + until bool + cond *listNode + body *listNode +} + +// singleSimple returns the simple command if the list is just that. +func (l *listNode) singleSimple() *simpleNode { + if len(l.items) != 1 || l.items[0].background { + return nil + } + ao := l.items[0].andOr + if len(ao.rest) > 0 || ao.first.negate { + return nil + } + s, _ := ao.first.cmd.(*simpleNode) + return s +} + +// --------------------------------------------------------------------------- +// Parser + +type parser struct { + src string + toks []rawToken + pos int +} + +// parse parses a command line or script. It returns errIncomplete if the +// input ends inside a construct. +func parse(src string) (*listNode, error) { + src, continued := joinContinuationLines(src) + if continued { + return nil, errIncomplete + } + toks, err := lex(src) + if err != nil { + return nil, err + } + p := &parser{src: src, toks: toks} + list, err := p.parseList(nil) + if err != nil { + return nil, err + } + if t := p.peek(); t != nil { + return nil, p.unexpected(t) + } + return list, nil +} + +func (p *parser) peek() *rawToken { + if p.pos < len(p.toks) { + return &p.toks[p.pos] + } + return nil +} + +func (p *parser) peekWord(words ...string) bool { + t := p.peek() + if t == nil || t.kind != tWord { + return false + } + for _, w := range words { + if t.text == w { + return true + } + } + return false +} + +func (p *parser) skipNewlines() { + for t := p.peek(); t != nil && t.kind == tOp && t.text == "\n"; t = p.peek() { + p.pos++ + } +} + +func (p *parser) unexpected(t *rawToken) error { + if t.text == "\n" { + return syntaxError("newline") + } + return syntaxError(t.text) +} + +// expect consumes the reserved word w. +func (p *parser) expect(w string) error { + t := p.peek() + if t == nil { + return errIncomplete + } + if t.kind != tWord || t.text != w { + return p.unexpected(t) + } + p.pos++ + return nil +} + +// parseList parses and-or lists until the end of the input or one of the +// reserved words in terms at the start of a command. +func (p *parser) parseList(terms map[string]bool) (*listNode, error) { + list := &listNode{} + for { + p.skipNewlines() + t := p.peek() + if t == nil || t.kind == tWord && terms[t.text] { + return list, nil + } + ao, err := p.parseAndOr() + if err != nil { + return nil, err + } + entry := listEntry{andOr: ao} + + t = p.peek() + switch { + case t == nil: + case t.kind == tOp && (t.text == ";" || t.text == "&" || t.text == "\n"): + p.pos++ + entry.background = t.text == "&" + case t.kind == tWord && terms[t.text]: + // e.g. "fi" right after the end of a nested control structure + default: + return nil, p.unexpected(t) + } + if entry.background { + if _, ok := ao.first.cmd.(*simpleNode); !ok || len(ao.rest) > 0 || ao.first.negate { + return nil, errors.New("only simple commands and pipelines can run in the background") + } + } + list.items = append(list.items, entry) + } +} + +// nonEmpty returns an error if a list that must contain commands is empty. +func (p *parser) nonEmpty(list *listNode) error { + if len(list.items) > 0 { + return nil + } + if t := p.peek(); t != nil { + return p.unexpected(t) + } + return errIncomplete +} + +func (p *parser) parseAndOr() (*andOrNode, error) { + first, err := p.parsePipeline() + if err != nil { + return nil, err + } + ao := &andOrNode{first: first} + for { + t := p.peek() + if t == nil || t.kind != tOp || (t.text != "&&" && t.text != "||") { + return ao, nil + } + p.pos++ + p.skipNewlines() + if p.peek() == nil { + return nil, errIncomplete + } + next, err := p.parsePipeline() + if err != nil { + return nil, err + } + ao.rest = append(ao.rest, andOrPart{op: t.text, pipeline: next}) + } +} + +func (p *parser) parsePipeline() (*pipelineNode, error) { + pl := &pipelineNode{} + if p.peekWord("!") { + pl.negate = true + p.pos++ + } + t := p.peek() + if t == nil { + return nil, errIncomplete + } + if t.kind == tOp || terminators[t.text] { + return nil, p.unexpected(t) + } + + var err error + compound := true + switch t.text { + case "if": + pl.cmd, err = p.parseIf() + case "for": + pl.cmd, err = p.parseFor() + case "while", "until": + pl.cmd, err = p.parseWhile() + default: + compound = false + pl.cmd, err = p.parseSimple() + } + if err != nil { + return nil, err + } + + if compound { + if t := p.peek(); t != nil { + switch { + case t.kind == tOp && t.text == "|": + return nil, errors.New("control structures in pipelines are not supported") + case t.kind == tWord && !terminators[t.text]: + return nil, fmt.Errorf("redirections of control structures are not supported (near `%s')", t.text) + } + } + } + return pl, nil +} + +// parseSimple parses a pipeline of simple commands and returns its text. +func (p *parser) parseSimple() (*simpleNode, error) { + start := p.toks[p.pos].start + end := start + for t := p.peek(); t != nil; t = p.peek() { + if t.kind == tWord { + end = t.end + p.pos++ + continue + } + if t.text != "|" { + break + } + p.pos++ + end = t.end + p.skipNewlines() + next := p.peek() + switch { + case next == nil: + return nil, errIncomplete + case next.kind == tOp || terminators[next.text]: + return nil, p.unexpected(next) + case next.text == "if" || next.text == "for" || next.text == "while" || next.text == "until": + return nil, errors.New("control structures in pipelines are not supported") + } + } + return &simpleNode{text: p.src[start:end]}, nil +} + +func (p *parser) parseIf() (*ifNode, error) { + p.pos++ // if + n := &ifNode{} + for { + cond, err := p.parseList(map[string]bool{"then": true}) + if err == nil { + err = p.nonEmpty(cond) + } + if err == nil { + err = p.expect("then") + } + if err != nil { + return nil, err + } + body, err := p.parseList(map[string]bool{"elif": true, "else": true, "fi": true}) + if err == nil { + err = p.nonEmpty(body) + } + if err != nil { + return nil, err + } + n.conds = append(n.conds, cond) + n.bodies = append(n.bodies, body) + + t := p.peek() + if t == nil { + return nil, errIncomplete + } + p.pos++ + switch t.text { + case "elif": + continue + case "else": + body, err := p.parseList(map[string]bool{"fi": true}) + if err == nil { + err = p.nonEmpty(body) + } + if err == nil { + err = p.expect("fi") + } + if err != nil { + return nil, err + } + n.elseBody = body + } + return n, nil // fi + } +} + +func (p *parser) parseFor() (*forNode, error) { + p.pos++ // for + t := p.peek() + if t == nil { + return nil, errIncomplete + } + if t.kind != tWord || !isVarName(t.text) { + return nil, fmt.Errorf("for: `%s': not a valid identifier", sanitize(t.text)) + } + n := &forNode{name: t.text} + p.pos++ + + p.skipNewlines() + if p.peekWord("in") { + p.pos++ + n.hasIn = true + var words []string + for t := p.peek(); t != nil && t.kind == tWord; t = p.peek() { + words = append(words, t.text) + p.pos++ + } + n.words = strings.Join(words, " ") + } + t = p.peek() + switch { + case t == nil: + return nil, errIncomplete + case t.kind == tOp && (t.text == ";" || t.text == "\n"): + p.pos++ + case !n.hasIn && t.kind == tWord && t.text == "do": + default: + return nil, p.unexpected(t) + } + + p.skipNewlines() + body, err := p.parseLoopBody() + if err != nil { + return nil, err + } + n.body = body + return n, nil +} + +func (p *parser) parseWhile() (*whileNode, error) { + n := &whileNode{until: p.peek().text == "until"} + p.pos++ + cond, err := p.parseList(map[string]bool{"do": true}) + if err == nil { + err = p.nonEmpty(cond) + } + if err != nil { + return nil, err + } + n.cond = cond + body, err := p.parseLoopBody() + if err != nil { + return nil, err + } + n.body = body + return n, nil +} + +// parseLoopBody parses "do list done". +func (p *parser) parseLoopBody() (*listNode, error) { + if err := p.expect("do"); err != nil { + return nil, err + } + body, err := p.parseList(map[string]bool{"done": true}) + if err == nil { + err = p.nonEmpty(body) + } + if err == nil { + err = p.expect("done") + } + return body, err +} + +// --------------------------------------------------------------------------- +// Execution + +// isAbort reports whether err stops the execution of the enclosing lists: +// break, continue, or a command that was interrupted with Ctrl+C (like in +// bash, Ctrl+C aborts the whole command line, e.g. a loop). +func isAbort(err error) bool { + var lc loopControl + return errors.As(err, &lc) || exitStatus(err) == 128+int(syscall.SIGINT) +} + +// silent returns an error with only the exit status of err, for errors that +// were already reported. +func silent(err error) error { + if err == nil { + return nil + } + return statusError(exitStatus(err)) +} + +func (l *listNode) exec() error { + var err error + for _, entry := range l.items { + printError(err) + if entry.background { + err = entry.andOr.first.cmd.(*simpleNode).run(true) + } else { + err = entry.andOr.exec() + } + lastStatus = exitStatus(err) + if isAbort(err) { + return err + } + } + return err +} + +func (a *andOrNode) exec() error { + err := a.first.exec() + for _, part := range a.rest { + lastStatus = exitStatus(err) + if isAbort(err) { + return err + } + if (part.op == "&&") != (lastStatus == 0) { + continue + } + printError(err) + err = part.pipeline.exec() + } + return err +} + +func (pl *pipelineNode) exec() error { + err := pl.cmd.exec() + if !pl.negate || isAbort(err) { + return err + } + printError(err) + if exitStatus(err) == 0 { + return statusError(1) + } + return nil +} + +func (s *simpleNode) exec() error { + return s.run(false) +} + +// run executes the simple command, in the background if background is set. +// Aliases are expanded here (in interactive shells), so that they work at +// the start of every command, also inside control structures. +func (s *simpleNode) run(background bool) error { + text := s.text + if interactiveShell && aliasExpansion { + if expanded := expandAliases(text); expanded != text { + tree, err := parse(expanded) + if err != nil { + return err + } + if simple := tree.singleSimple(); simple != nil { + text = simple.text + } else { + if background { + return errors.New("an alias with a command list cannot run in the background") + } + aliasExpansion = false + defer func() { aliasExpansion = true }() + return tree.exec() + } + } + } + return execCommand(text, background) +} + +func (n *ifNode) exec() error { + for i, cond := range n.conds { + err := cond.exec() + if isAbort(err) { + return err + } + printError(err) + if exitStatus(err) == 0 { + return n.bodies[i].exec() + } + } + if n.elseBody != nil { + return n.elseBody.exec() + } + lastStatus = 0 + return nil +} + +// runLoopBody runs one iteration of a loop. It returns whether the loop +// must stop, and the error to return from the loop. +func runLoopBody(body *listNode, result *error) (stop bool, err error) { + err = body.exec() + var lc loopControl + if errors.As(err, &lc) { + *result = nil + if lc.n > 1 { + lc.n-- + return true, lc // ends an outer loop too + } + return lc.isBreak, nil + } + if isAbort(err) { + return true, err + } + printError(err) + *result = silent(err) + return false, nil +} + +func (n *whileNode) exec() error { + loopDepth++ + defer func() { loopDepth-- }() + + var result error + for { + err := n.cond.exec() + if isAbort(err) { + return err + } + printError(err) + if (exitStatus(err) == 0) == n.until { + break + } + if stop, err := runLoopBody(n.body, &result); stop { + if err != nil { + return err + } + break + } + } + lastStatus = exitStatus(result) + return result +} + +func (n *forNode) exec() error { + var words []string + if n.hasIn { + var err error + if words, err = expandWordList(n.words); err != nil { + return err + } + } + + loopDepth++ + defer func() { loopDepth-- }() + + var result error + for _, word := range words { + os.Setenv(n.name, word) + if stop, err := runLoopBody(n.body, &result); stop { + if err != nil { + return err + } + break + } + } + lastStatus = exitStatus(result) + return result +} + +// expandWordList expands the words of a for loop like the arguments of a +// command (braces, ~, variables, $(...), wildcards). +func expandWordList(text string) ([]string, error) { + text, err := expandBraceWords(text) + if err != nil { + return nil, err + } + tokens, err := tokenize(text) + if err != nil { + return nil, fmt.Errorf("invalid input: %w", err) + } + var words []string + for _, t := range tokens { + if t.op != "" { + return nil, syntaxError(t.op) + } + words = append(words, expandWord(t)...) + } + return words, nil +} + +// builtinLoopControl implements break [n] and continue [n]. +func builtinLoopControl(args []string) error { + n := 1 + if len(args) > 1 { + var err error + if n, err = strconv.Atoi(args[1]); err != nil || n < 1 { + return fmt.Errorf("%s: %s: loop count out of range", args[0], sanitize(args[1])) + } + } + if loopDepth == 0 { + return fmt.Errorf("%s: only meaningful in a loop", args[0]) + } + return loopControl{isBreak: args[0] == "break", n: min(n, loopDepth)} +} diff --git a/parser_test.go b/parser_test.go new file mode 100644 index 0000000..2163d0f --- /dev/null +++ b/parser_test.go @@ -0,0 +1,164 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +// runIn runs input in a temporary directory and returns the content of the +// file "out" there. +func runIn(t *testing.T, input string) string { + t.Helper() + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + os.Chdir(t.TempDir()) + execInput(input) + data, _ := os.ReadFile("out") + return string(data) +} + +func TestExec_If(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"if true; then echo yes >> out; fi", "yes\n"}, + {"if false; then echo yes >> out; fi", ""}, + {"if false; then echo a >> out; else echo b >> out; fi", "b\n"}, + {"if false; then echo a >> out; elif true; then echo b >> out; else echo c >> out; fi", "b\n"}, + {"if false; then echo a >> out; elif false; then echo b >> out; else echo c >> out; fi", "c\n"}, + {"if ! false; then echo negated >> out; fi", "negated\n"}, + {"if true && false; then echo a >> out; else echo b >> out; fi", "b\n"}, + {"if false || true; then echo a >> out; fi", "a\n"}, + {"if echo cond >> out; then echo body >> out; fi", "cond\nbody\n"}, + {"if true\nthen\n echo multi >> out\n echo line >> out\nfi", "multi\nline\n"}, + {"if true; then if false; then echo a >> out; else echo nested >> out; fi; fi", "nested\n"}, + {"if [ -d / ]; then echo dir >> out; fi", "dir\n"}, + } + for _, tc := range tests { + if got := runIn(t, tc.input); got != tc.want { + t.Errorf("%q: out = %q, want %q", tc.input, got, tc.want) + } + } +} + +func TestExec_Loops(t *testing.T) { + defer os.Unsetenv("GOSH_I") + tests := []struct { + input string + want string + }{ + {"for x in a b c; do echo $x >> out; done", "a\nb\nc\n"}, + {"for x in {1..3}; do echo $x >> out; done", "1\n2\n3\n"}, + {"for x in $(echo a b); do echo $x >> out; done", "a\nb\n"}, + {"for x in \"a b\" c; do echo $x >> out; done", "a b\nc\n"}, + {"touch f1 f2; for f in f*; do echo $f >> out; done", "f1\nf2\n"}, + {"for x in; do echo never >> out; done", ""}, + {"for x; do echo never >> out; done", ""}, + {"set GOSH_I=0; while [ $GOSH_I -lt 3 ]; do echo $GOSH_I >> out; set GOSH_I=$(expr $GOSH_I + 1); done", "0\n1\n2\n"}, + {"set GOSH_I=3; until [ $GOSH_I -eq 0 ]; do echo $GOSH_I >> out; set GOSH_I=$(expr $GOSH_I - 1); done", "3\n2\n1\n"}, + {"while false; do echo never >> out; done", ""}, + // The loop variable keeps the last value + {"for GOSH_I in a b; do :; done; echo $GOSH_I > out", "b\n"}, + // break and continue + {"for x in 1 2 3 4; do if [ $x = 3 ]; then break; fi; echo $x >> out; done", "1\n2\n"}, + {"for x in 1 2 3; do if [ $x = 2 ]; then continue; fi; echo $x >> out; done", "1\n3\n"}, + {"for a in 1 2; do for b in x y; do if [ $b = y ]; then continue 2; fi; echo $a$b >> out; done; done", "1x\n2x\n"}, + {"for a in 1 2; do for b in x y; do echo $a$b >> out; break 2; done; done", "1x\n"}, + {"for a in 1 2; do for b in x y; do echo $a$b >> out; break 5; done; done", "1x\n"}, + {"while true; do echo once >> out; break; done", "once\n"}, + {"for x in 1 2; do echo $x >> out; break; echo never >> out; done; echo after >> out", "1\nafter\n"}, + } + for _, tc := range tests { + if got := runIn(t, tc.input); got != tc.want { + t.Errorf("%q: out = %q, want %q", tc.input, got, tc.want) + } + } + if loopDepth != 0 { + t.Errorf("loopDepth = %d after the loops", loopDepth) + } +} + +func TestExec_Status(t *testing.T) { + tests := []struct { + input string + status int + }{ + {"if false; then :; fi", 0}, + {"if true; then false; fi", 1}, + {"if false; then :; else sh -c 'exit 3'; fi", 3}, + {"for x in a; do false; done", 1}, + {"for x in a; do false; done; true", 0}, + {"for x in; do false; done", 0}, + {"while false; do :; done", 0}, + {"for x in a b; do sh -c 'exit 4'; break; done", 0}, + {"! true", 1}, + {"! false", 0}, + {"! gosh-no-such-command", 0}, + {":", 0}, + {"break", 1}, + {"continue", 1}, + {"for x in a; do break x; done", 1}, + {"if a; then", 2}, + {"fi", 2}, + } + for _, tc := range tests { + runIn(t, tc.input) + if lastStatus != tc.status { + t.Errorf("%q: $? = %d, want %d", tc.input, lastStatus, tc.status) + } + } + if err := execInput("break"); err == nil || err.Error() != "break: only meaningful in a loop" { + t.Errorf("break outside a loop: %v", err) + } +} + +func TestRunScript_MultiLine(t *testing.T) { + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + os.Chdir(t.TempDir()) + + script := `# a script with control structures +for x in a b +do + if [ $x = a ]; then + echo first >> out + else + echo second >> out + fi +done +echo continued \ + line >> out +` + if status := runScript(strings.NewReader(script), ""); status != 0 { + t.Errorf("status = %d", status) + } + if got, _ := os.ReadFile("out"); string(got) != "first\nsecond\ncontinued line\n" { + t.Errorf("out = %q", got) + } + + // A script that ends inside a control structure is a syntax error + if status := runScript(strings.NewReader("echo ok >> out2\nif true; then\n echo never >> out2\n"), ""); status != 2 { + t.Errorf("status = %d for an unterminated if, want 2", status) + } + if got, _ := os.ReadFile("out2"); string(got) != "ok\n" { + t.Errorf("out2 = %q", got) + } +} + +func TestExec_AliasesInControlStructures(t *testing.T) { + withAliases(t, map[string]string{"say": "echo said", "both": "echo one >> out; echo two"}) + defer func(saved bool) { interactiveShell = saved }(interactiveShell) + interactiveShell = true + + if got := runIn(t, "for x in a b; do say $x >> out; done"); got != "said a\nsaid b\n" { + t.Errorf("alias in for: out = %q", got) + } + if got := runIn(t, "if true; then both >> out; fi"); got != "one\ntwo\n" { + t.Errorf("alias with a list in if: out = %q", got) + } + if got := runIn(t, "if say x > out; then :; fi"); got != "said x\n" { + t.Errorf("alias in condition: out = %q", got) + } +} diff --git a/subst_test.go b/subst_test.go index a389b2f..e0a9125 100644 --- a/subst_test.go +++ b/subst_test.go @@ -2,7 +2,6 @@ package main import ( "os" - "reflect" "testing" ) @@ -29,17 +28,6 @@ func TestSubstEnd(t *testing.T) { } } -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" { @@ -106,7 +94,7 @@ func TestExecInput_CommandSubstitution(t *testing.T) { } for input, wantErr := range map[string]string{ - "echo $(echo a": "invalid input: missing ) in command substitution", + "echo $(echo a": "syntax error: unexpected end of input", "echo $((1 + 2))": "invalid input: arithmetic expansion $((...)) is not supported", } { if err := execInput(input); err == nil || err.Error() != wantErr {