From 443367f66fd2d4f8dbd09b65f021eb3928276b7b Mon Sep 17 00:00:00 2001 From: Florian Walther Date: Sun, 4 Oct 2026 17:20:59 +0200 Subject: [PATCH] add functions, positional parameters, case and read - functions: name() { ...; }, function name { ...; }, return, local, unset -f; in pipes, & and $(...) they run in a subshell that gets all function definitions and the positional parameters - positional parameters $0, $1 ... ${10}, $#, "$@", "$*", $$; set by gosh script args, gosh -c command name args, function calls, source file args and set --; shift; for x without "in" - case word in pattern|pattern) ...;; esac and { list; } groups - read [-r] [-p prompt] [name ...] with $IFS splitting and $REPLY Co-Authored-By: Claude Opus 5.5 --- README.md | 134 ++++++++++++++- funcs.go | 231 ++++++++++++++++++++++++++ funcs_test.go | 363 +++++++++++++++++++++++++++++++++++++++++ main.go | 202 ++++++++++++++++++----- main_test.go | 2 +- packaging/man/gosh.1 | 166 +++++++++++++++++-- parser.go | 379 ++++++++++++++++++++++++++++++++++++++++--- read.go | 215 ++++++++++++++++++++++++ subst.go | 35 ++-- 9 files changed, 1629 insertions(+), 98 deletions(-) create mode 100644 funcs.go create mode 100644 funcs_test.go create mode 100644 read.go diff --git a/README.md b/README.md index 276f110..ff662a8 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,10 @@ Built-in commands: exit [code] - Exit shell with status code (default: $?) quit [code] - Quit shell with status code (default: $?) set VAR=val - Set environment variable + set -- args - Set the positional parameters $1, $2, ... export VAR=val - Export environment variable - unset VAR - Remove a variable - source file - Run the commands of file in this shell (also: . file) + unset VAR - Remove a variable (unset -f name: a function) + source file [args] - Run the commands of file in this shell (also: . file) jobs - List background and stopped jobs fg [%n] - Continue job n (default: current job) in the foreground bg [%n] - Continue stopped job n in the background @@ -23,6 +24,12 @@ Built-in commands: : - 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 + return [n] - Leave a function or sourced file with status n + local VAR[=val] - Variable that gets its old value back when the + function returns + shift [n] - Remove the first n positional parameters (default 1) + read [-r] [-p prompt] [VAR ...] - Read a line into variables (split + at $IFS; the last one gets the rest; default $REPLY) help - Show this help Features: @@ -50,6 +57,9 @@ Features: a pattern - Environment variable expansion ($VAR, ${VAR}, ${VAR:-default}); using an unset variable is an error, use ${VAR:-} for an empty default + - Positional parameters: $0, $1 ... $9, ${10}, $# (count), "$@" (one + word each), "$*" (one word), $$ (process ID); set by gosh script + args, gosh -c command name args, functions, set -- and source - Command substitution: $(command) is replaced by the output of command (run in a subshell); unquoted it is split into words, "$(command)" stays one word @@ -57,7 +67,10 @@ Features: 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 ..., + case word in pattern|pattern) ...;; esac, { cmd; cmd; } groups; ! cmd negates the status; they can span several lines (prompt "> ") + - Functions: name() { ...; } (or function name { ...; }); the arguments + are $1, $2, ...; in pipes, & and $(...) they run in a subshell - 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 @@ -75,8 +88,8 @@ Features: ### Aufruf ``` -gosh [-l] [-i] [--norc] [--noprofile] [script] -gosh [-l] [--noprofile] -c command +gosh [-l] [-i] [--norc] [--noprofile] [script [args ...]] +gosh [-l] [--noprofile] -c command [name [args ...]] gosh -version ``` @@ -84,7 +97,8 @@ gosh -version |------------------------|------------------------------------------------------------------| | `gosh` | interaktive Shell (wenn stdin ein Terminal ist) | | `gosh -c 'befehl'` | führt den Befehl aus und beendet sich | -| `gosh skript.gosh` | führt das Skript Zeile für Zeile aus | +| `gosh -c 'befehl' name a b` | wie oben, mit `$0` = `name`, `$1` = `a`, `$2` = `b` | +| `gosh skript.gosh a b` | führt das Skript Zeile für Zeile aus, `$1` = `a`, `$2` = `b` | | `befehle \| gosh` | liest die Befehle von stdin | | `-i` | erzwingt den interaktiven Modus, auch ohne Terminal | | `-l`, `--login` | Login-Shell, liest die Profil-Dateien (siehe unten) | @@ -137,7 +151,7 @@ export PATH=$HOME/bin:$PATH ### Kontrollstrukturen -gosh kennt `if`, `for`, `while` und `until` wie `sh`, auf einer Zeile oder über mehrere Zeilen: +gosh kennt `if`, `for`, `while`, `until`, `case` und `{ …; }` wie `sh`, auf einer Zeile oder über mehrere Zeilen: ```bash if [ -f ~/.goshrc ]; then @@ -161,6 +175,15 @@ while [ $n -gt 0 ]; do done until ping -c1 -W1 server >/dev/null; do sleep 1; done + +case "$1" in + start|run) echo "starte" ;; + stop) echo "stoppe" ;; + *.tar.gz) tar xzf "$1" ;; + *) echo "unbekannt: $1" ;; +esac + +test -d build || { echo "kein build-Verzeichnis"; exit 1; } ``` | Konstrukt | Bedeutung | @@ -169,17 +192,110 @@ until ping -c1 -W1 server >/dev/null; do sleep 1; done | `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 | +| `case wort in muster\|muster) …;; esac` | führt den ersten Zweig aus, dessen Muster auf `wort` passt | +| `{ befehl; befehl; }` | fasst Befehle zusammen (z. B. nach `\|\|`) | | `! 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). +- Die Wörter nach `for … in` werden wie Argumente expandiert: `*.go`, `{1..10}`, `$VAR`, `$(befehl)`, `"$@"`. Ohne `in` läuft die Schleife über die Positionsparameter (`for a; do …` ist `for a in "$@"; do …`). +- `case`: Die Muster sind Wildcards wie bei Dateinamen (`*`, `?`, `[…]`), nur passen `*` und `?` hier auch auf `/`. Mehrere Muster werden mit `|` getrennt, das `(` vor dem Muster ist optional, das `;;` im letzten Zweig auch. Zeichen in Anführungszeichen passen nur wörtlich (`"*")` passt nur auf `*`). Wie überall in gosh sind Werte von Variablen keine Muster: Nach `p='*.go'` passt `$p)` nur auf den Text `*.go`. Passt kein Muster, ist der Status 0. +- `{ …; }`: `{` und `}` sind nur am Anfang eines Befehls Schlüsselwörter, vor dem `}` muss deshalb ein `;` oder ein Zeilenumbruch stehen. Die Befehle laufen in der Shell selbst (keine Subshell). - 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 und `read`. Arithmetik geht z. B. mit `n=$(expr $n + 1)`. +- Nicht unterstützt: Kontrollstrukturen in Pipes (`for …; done | sort`, `… | while read line`), mit Umleitung (`done > datei`, `done < datei`) oder im Hintergrund (`done &`), außerdem `( … )`-Subshells. Funktionen lassen sich dagegen in Pipes und mit Umleitungen verwenden (siehe unten), damit lässt sich das oft umgehen. Arithmetik geht z. B. mit `n=$(expr $n + 1)`. + +### Funktionen + +```bash +# Definition (auch: function gruss { … }) +gruss() { + local name=${1:-Welt} # local: nur in der Funktion + echo "Hallo $name" +} + +gruss # Hallo Welt +gruss Flo # Hallo Flo +gruss Flo > datei # Umleitungen gelten für alle Befehle der Funktion +gruss Flo | tr a-z A-Z # auch in Pipes +x=$(gruss Flo) + +ist_root() { + [ "$(id -u)" = 0 ] && return 0 + return 1 +} +ist_root || echo "kein root" + +# Zeilen einer Datei verarbeiten: die Funktion liest von stdin +zeilen() { + while read -r zeile; do echo "> $zeile"; done +} +zeilen < /etc/hostname +``` + +- Eine Funktion wird mit `name() { …; }`, `name () { …; }` oder `function name { …; }` definiert. Der Rumpf ist ein zusammengesetzter Befehl, meist `{ …; }`, aber auch `if`, `for`, `while` oder `case` sind möglich. Die Definition darf über mehrere Zeilen gehen. +- Die Argumente des Aufrufs sind in der Funktion `$1`, `$2`, …, `$#` und `"$@"`; danach haben die Positionsparameter wieder ihre alten Werte. `$0` bleibt der Name des Skripts. +- `return [n]` beendet die Funktion mit dem Status `n` (ohne `n`: der Status des letzten Befehls). Ohne `return` ist der Status der des letzten Befehls. +- Funktionen laufen in der Shell selbst: Variablen, die sie setzen, und `cd` gelten danach weiter. `local name[=wert]` macht eine Variable lokal, sie bekommt beim Verlassen der Funktion ihren alten Wert zurück (Funktionen, die von dort aufgerufen werden, sehen den lokalen Wert). +- `name=wert funktion` setzt die Variable nur während des Aufrufs. +- In einer Pipeline, im Hintergrund (`funktion &`) und in `$(...)` läuft eine Funktion in einer Subshell, Änderungen an Variablen gehen dort also verloren (wie in `sh`). Die Subshell kennt alle Funktionen. +- Eine Funktion hat Vorrang vor Built-ins und Programmen gleichen Namens. Weil es noch kein `command` gibt, kann eine Funktion `ls` das Programm `ls` aber nicht aufrufen. Mit `\ls` umgeht man nur Aliase. Besser ist ein anderer Name, z. B. `ll() { ls -l "$@"; }`. +- `unset -f name` entfernt eine Funktion. Rekursion ist erlaubt, aber nur bis zu einer Tiefe von 1000. +- Namen dürfen aus Buchstaben, Ziffern, `_`, `-` und `.` bestehen (nicht mit einer Ziffer oder `-` beginnend). Funktionen werden auch mit Tab vervollständigt. + +### Positionsparameter + +| Parameter | Bedeutung | +|--------------------|------------------------------------------------------------------| +| `$0` | Name des Skripts (bzw. `name` bei `gosh -c befehl name`) | +| `$1` … `$9`, `${10}` | die Argumente | +| `$#` | Anzahl der Argumente | +| `"$@"` | alle Argumente, jedes als eigenes Wort | +| `"$*"` | alle Argumente als ein Wort, durch Leerzeichen getrennt | +| `$$` | Prozess-ID der Shell | + +```bash +#!/usr/bin/gosh +# aufruf: skript.gosh [-v] datei ... +if [ $# -eq 0 ]; then + echo "usage: $0 [-v] datei ..." >&2 + exit 2 +fi +verbose=no +case $1 in + -v) verbose=yes; shift ;; +esac +for f in "$@"; do + test $verbose = yes && echo "verarbeite $f" + wc -l "$f" +done +``` + +- Gesetzt werden sie von `gosh skript a b`, `gosh -c befehl name a b`, beim Aufruf einer Funktion, mit `source datei a b` (nur während die Datei läuft) und mit `set -- a b c`. +- `shift [n]` entfernt die ersten `n` (Standard: 1) Parameter, `$2` wird zu `$1` usw. +- Wie bei Variablen ist ein nicht gesetzter Parameter ein Fehler: `$1` ohne Argumente meldet `1: unbound variable`. Für optionale Argumente `${1:-}` bzw. `${1:-standard}` verwenden oder vorher `$#` prüfen. +- `"$@"` ohne Argumente ergibt kein Wort (auch nicht das leere). Ohne Anführungszeichen verhalten sich `$@` und `$*` wie `"$@"`, da gosh Werte nie an Leerzeichen teilt. + +### read + +`read` liest eine Zeile von stdin und verteilt sie auf Variablen: + +```bash +read -p "Name? " name # Eingabe vom Terminal mit Prompt +read vorname nachname < name.txt # erste Zeile einer Datei +read -r zeile # Backslashes nicht auswerten +while read -r zeile; do echo "$zeile"; done # in einem Skript: alle Zeilen von stdin +``` + +- `read name1 name2 …` teilt die Zeile an den Zeichen von `$IFS` (Standard: Leerzeichen, Tab, Zeilenumbruch). Jede Variable bekommt ein Feld, die letzte den Rest der Zeile. Bei zu wenigen Feldern werden die übrigen Variablen leer. Leerzeichen am Anfang und Ende werden entfernt. Andere `IFS`-Zeichen trennen genau ein Feld (`IFS=:` macht aus `a::b` die Felder `a`, ``, `b`). +- Ohne Namen landet die ganze Zeile unverändert in `$REPLY`. +- Ohne `-r` maskiert `\` das nächste Zeichen (`a\ b` bleibt ein Feld), und ein `\` am Zeilenende setzt die Zeile fort. `-p prompt` gibt den Prompt auf stderr aus, wenn stdin ein Terminal ist. +- Der Status ist 0, oder 1 am Ende der Eingabe (die Variablen werden trotzdem mit dem Rest gesetzt). So endet `while read …` nach der letzten Zeile. +- `read` liest Byte für Byte und damit nie mehr als eine Zeile, den Rest bekommen die nächsten Befehle. Ctrl+C bricht `read` in der interaktiven Shell ab (Status 130). +- `read` verändert die Shell und lässt sich deshalb nicht in einer Pipeline verwenden (`echo a | read x`, in `sh` wirkungslos). Umleitungen funktionieren: `read x < datei`. ### Variablen @@ -215,7 +331,7 @@ $ vim $(grep -l TODO *.go) $ echo "Dateien: $(ls | wc -l)" ``` -- Der Befehl läuft in einer eigenen Subshell (einem neuen gosh-Prozess): `cd`, `set` oder `export` darin ändern die laufende Shell nicht. Er darf alles enthalten, was gosh kann (Pipes, `;`, `&&`, Umleitungen, verschachtelte `$(...)`), und liest von der gleichen Eingabe, Fehlermeldungen gehen auf stderr. +- Der Befehl läuft in einer eigenen Subshell (einem neuen gosh-Prozess): `cd`, `set` oder `export` darin ändern die laufende Shell nicht. Die Subshell kennt die Funktionen und Positionsparameter der Shell. Er darf alles enthalten, was gosh kann (Pipes, `;`, `&&`, Umleitungen, verschachtelte `$(...)`), und liest von der gleichen Eingabe, Fehlermeldungen gehen auf stderr. - Abschließende Zeilenumbrüche der Ausgabe werden entfernt. - Ohne Anführungszeichen wird die Ausgabe an Leerzeichen, Tabs und Zeilenumbrüchen in mehrere Wörter geteilt, `"$(befehl)"` bleibt ein einziges Wort (mit den Zeilenumbrüchen darin). - Anders als in `sh` werden Wildcards in der Ausgabe nicht expandiert, und Zeichen wie `;` oder `|` darin sind normaler Text: Die Ausgabe ist immer nur Daten. diff --git a/funcs.go b/funcs.go new file mode 100644 index 0000000..2ed0216 --- /dev/null +++ b/funcs.go @@ -0,0 +1,231 @@ +package main + +// Functions and positional parameters. A function runs in the shell itself +// (it can change variables and the directory), with its arguments as the +// positional parameters $1, $2, ... In a pipeline, in the background and in +// $(...) it runs in a subshell, which gets the definitions of all functions. + +import ( + "errors" + "fmt" + "os" + "slices" + "sort" + "strconv" + "strings" +) + +// funcDef is a function defined with name() { ...; }. +type funcDef struct { + name string + body node + text string // the source of the definition, passed to subshells +} + +var ( + functions = map[string]*funcDef{} + + // scriptName is $0: the script, the name given after gosh -c command, + // or the name gosh was started with. + scriptName = "gosh" + // positional are the positional parameters $1, $2, ... + positional []string + + // funcDepth is the number of functions that are currently running. + funcDepth int + // localFrames holds, for every running function, the previous values + // of the variables it declared with local. + localFrames []map[string]savedVar + + // ioOverride replaces stdin, stdout and stderr of the shell while a + // function with redirections runs (f > file), so that the commands in + // it use the redirected files. + ioOverride [3]*os.File +) + +// maxFuncDepth limits the nesting of function calls, so that endless +// recursion ends with an error. +const maxFuncDepth = 1000 + +type savedVar struct { + value string + ok bool +} + +// returnSignal is returned by the return builtin and ends the running +// function or sourced file with the given status. +type returnSignal struct { + status int +} + +func (r returnSignal) Error() string { return "return" } + +// stdio returns stdin (0), stdout (1) or stderr (2) of the shell, which are +// redirected while a function with redirections runs. +func stdio(fd int) *os.File { + if f := ioOverride[fd]; f != nil { + return f + } + return [3]*os.File{os.Stdin, os.Stdout, os.Stderr}[fd] +} + +// isFuncName reports whether name can be the name of a function: letters, +// digits, _, - and ., not starting with a digit or -. +func isFuncName(name string) bool { + if name == "" || name[0] == '-' || name[0] >= '0' && name[0] <= '9' || terminators[name] || compoundStarts[name] { + return false + } + for i := 0; i < len(name); i++ { + if c := name[i]; !isVarNameByte(c, false) && c != '-' && c != '.' { + return false + } + } + return true +} + +func defineFunction(def *funcDef) { + functions[def.name] = def +} + +// functionNames returns the names of all functions, sorted. +func functionNames() []string { + names := make([]string, 0, len(functions)) + for name := range functions { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// functionDefs returns the definitions of all functions as source text, to +// run them in a subshell. +func functionDefs() string { + var b strings.Builder + for _, name := range functionNames() { + b.WriteString(functions[name].text) + b.WriteByte('\n') + } + return b.String() +} + +// callFunction runs a function in the shell with args[1:] as positional +// parameters, the files fds as stdin, stdout and stderr and the assignments +// that came before the call set while it runs. +func callFunction(fn *funcDef, args []string, fds [3]*os.File, assigns []string) error { + if funcDepth >= maxFuncDepth { + return fmt.Errorf("%s: maximum function nesting level exceeded (%d)", sanitize(fn.name), maxFuncDepth) + } + defer setTemporarily(assigns)() + + savedIO, savedPositional, savedLoopDepth := ioOverride, positional, loopDepth + ioOverride, positional, loopDepth = fds, slices.Clone(args[1:]), 0 + funcDepth++ + localFrames = append(localFrames, map[string]savedVar{}) + defer func() { + // Restore the variables declared with local + for name, v := range localFrames[len(localFrames)-1] { + if v.ok { + os.Setenv(name, v.value) + } else { + os.Unsetenv(name) + } + } + localFrames = localFrames[:len(localFrames)-1] + funcDepth-- + ioOverride, positional, loopDepth = savedIO, savedPositional, savedLoopDepth + }() + + err := fn.body.exec() + var ret returnSignal + if errors.As(err, &ret) { + if ret.status == 0 { + return nil + } + return statusError(ret.status) + } + return err +} + +// lookupParam returns the value of a special or positional parameter ($?, +// $#, $$, $0, $1, ..., $@ and $* joined with blanks) or of a variable. +func lookupParam(name string) (string, bool) { + switch { + case name == "?": + return strconv.Itoa(lastStatus), true + case name == "#": + return strconv.Itoa(len(positional)), true + case name == "$": + return strconv.Itoa(os.Getpid()), true + case name == "@" || name == "*": + return strings.Join(positional, " "), true + case name == "0": + return scriptName, true + case isDigits(name): + n, err := strconv.Atoi(name) + if err != nil || n > len(positional) { + return "", false + } + return positional[n-1], true + } + return os.LookupEnv(name) +} + +// builtinReturn implements return [n]. +func builtinReturn(args []string) error { + if funcDepth == 0 && sourceDepth == 0 { + return errors.New("return: can only `return' from a function or sourced script") + } + status := lastStatus + if len(args) > 1 { + n, err := strconv.Atoi(args[1]) + if err != nil { + return fmt.Errorf("return: %s: numeric argument required", sanitize(args[1])) + } + status = n & 0xff + } + return returnSignal{status} +} + +// builtinLocal implements local name[=value] ...: the variables get their +// previous values back when the function returns. Without a value the +// variable is unset in the function. +func builtinLocal(args []string) error { + if funcDepth == 0 { + return errors.New("local: can only be used in a function") + } + frame := localFrames[len(localFrames)-1] + var err error + for _, arg := range args[1:] { + name, value, hasValue := strings.Cut(arg, "=") + if !isVarName(name) { + err = fmt.Errorf("local: `%s': not a valid identifier", sanitize(arg)) + continue + } + if _, saved := frame[name]; !saved { + old, ok := os.LookupEnv(name) + frame[name] = savedVar{old, ok} + } + if hasValue { + os.Setenv(name, value) + } else { + os.Unsetenv(name) + } + } + return err +} + +// builtinShift implements shift [n]. +func builtinShift(args []string) error { + n := 1 + if len(args) > 1 { + var err error + if n, err = strconv.Atoi(args[1]); err != nil || n < 0 { + return fmt.Errorf("shift: %s: numeric argument required", sanitize(args[1])) + } + } + if n > len(positional) { + return fmt.Errorf("shift: %d: shift count out of range", n) + } + positional = positional[n:] + return nil +} diff --git a/funcs_test.go b/funcs_test.go new file mode 100644 index 0000000..2402125 --- /dev/null +++ b/funcs_test.go @@ -0,0 +1,363 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// resetShell removes functions and positional parameters after a test. +func resetShell(t *testing.T) { + t.Helper() + t.Cleanup(func() { + functions = map[string]*funcDef{} + positional, scriptName = nil, "gosh" + }) +} + +func TestTokenize_PositionalParameters(t *testing.T) { + resetShell(t) + scriptName, positional = "prog", []string{"a", "b c", ""} + + tests := []struct { + input string + want []string + }{ + {"echo $0 $1 $2", []string{"echo", "prog", "a", "b c"}}, + {"echo $# ${#}", []string{"echo", "3", "3"}}, + {`echo "$@"`, []string{"echo", "a", "b c", ""}}, + {`echo x"$@"y`, []string{"echo", "xa", "b c", "y"}}, + {`echo $@`, []string{"echo", "a", "b c", ""}}, + {`echo "$*"`, []string{"echo", "a b c "}}, + {`echo ${2}x $10`, []string{"echo", "b cx", "a0"}}, + {`echo ${4:-four}`, []string{"echo", "four"}}, + {`echo '$1' \$1`, []string{"echo", "$1", "$1"}}, + } + for _, tc := range tests { + got, err := splitWords(tc.input) + if err != nil { + t.Errorf("%q: %v", tc.input, err) + continue + } + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("%q: got %q, want %q", tc.input, got, tc.want) + } + } + + // An unset positional parameter is an error, like an unset variable + if _, err := splitWords("echo $4"); err == nil || !strings.Contains(err.Error(), "4: unbound variable") { + t.Errorf("$4: err = %v", err) + } + + // "$@" without parameters is no word at all + positional = nil + for input, want := range map[string][]string{ + `echo "$@"`: {"echo"}, + `echo "$@" x`: {"echo", "x"}, + `echo "x$@"`: {"echo", "x"}, + `echo "$*"`: {"echo", ""}, + `echo $#`: {"echo", "0"}, + } { + if got, _ := splitWords(input); !reflect.DeepEqual(got, want) { + t.Errorf("%q without parameters: got %q, want %q", input, got, want) + } + } +} + +func TestExec_Functions(t *testing.T) { + resetShell(t) + for _, name := range []string{"GOSH_A", "GOSH_L"} { + os.Unsetenv(name) + defer os.Unsetenv(name) + } + tests := []struct { + input string + want string + }{ + {"f() { echo hi > out; }; f", "hi\n"}, + {"f () { echo $1-$2-$# >> out; }; f a b", "a-b-2\n"}, + {"f(){ echo braces >> out; }; f", "braces\n"}, + {"function f { echo keyword >> out; }; f", "keyword\n"}, + {"function f() { echo both >> out; }; f", "both\n"}, + {"f() {\n echo multi >> out\n echo line >> out\n}\nf", "multi\nline\n"}, + {"f()\n{\n echo next-line >> out\n}\nf", "next-line\n"}, + {"f() if true; then echo if-body >> out; fi; f", "if-body\n"}, + {`f() { for a in "$@"; do echo "<$a>" >> out; done; }; f x "y z"`, "\n\n"}, + // The positional parameters are restored after the call + {`set -- outer; f() { echo $1 >> out; }; f inner; echo $1 >> out`, "inner\nouter\n"}, + // Functions change the shell (variables, directory) + {"f() { GOSH_A=set; }; f; echo $GOSH_A > out", "set\n"}, + // return + {"f() { echo a >> out; return; echo b >> out; }; f", "a\n"}, + {"f() { return 3; }; f; echo $? > out", "3\n"}, + {"f() { false; return; }; f; echo $? > out", "1\n"}, + {"f() { for x in 1 2 3; do if [ $x = 2 ]; then return 7; fi; echo $x >> out; done; }; f; echo $? >> out", "1\n7\n"}, + {"f() { sh -c 'exit 4'; }; f; echo $? > out", "4\n"}, + // local + {"GOSH_L=global; f() { local GOSH_L=local; echo $GOSH_L >> out; }; f; echo $GOSH_L >> out", "local\nglobal\n"}, + {"f() { local GOSH_L; GOSH_L=x; }; f; echo ${GOSH_L:-unset} > out", "unset\n"}, + {"f() { local GOSH_L=1; g; echo $GOSH_L >> out; }; g() { GOSH_L=2; }; f", "2\n"}, + // Assignments before a call apply only while it runs + {"f() { echo $GOSH_A >> out; }; GOSH_A=temp f; echo ${GOSH_A:-unset} >> out", "temp\nunset\n"}, + // Redirections of a call apply to all commands in the function + {"f() { echo one; echo two; }; f > out", "one\ntwo\n"}, + {"f() { echo err >&2; }; f 2> out", "err\n"}, + {"echo input > in; f() { cat; }; f < in > out", "input\n"}, + {"f() { echo nested > out; }; g() { f; }; g", "nested\n"}, + // Functions run before builtins and commands of the same name + {"echo() { printf 'own\\n' > out; }; echo x; unset -f echo", "own\n"}, + {"f() { echo x > out; }; unset -f f; f", ""}, + // In pipelines, in the background and in $(...) they run in a subshell + {"f() { echo piped; }; f | tr a-z A-Z > out", "PIPED\n"}, + {"f() { tr a-z A-Z; }; echo lower | f > out", "LOWER\n"}, + {"f() { echo bg > out; }; f & wait", "bg\n"}, + {`f() { echo "sub $1"; }; echo "$(f arg)" > out`, "sub arg\n"}, + {`f() { g; }; g() { echo g-in-sub; }; echo $(f) > out`, "g-in-sub\n"}, + {`set -- p1; echo "$(echo $1)" > out`, "p1\n"}, + {"f() { GOSH_A=changed; }; f | cat; echo ${GOSH_A:-unchanged} > out", "unchanged\n"}, + } + for _, tc := range tests { + os.Unsetenv("GOSH_A") + os.Unsetenv("GOSH_L") + if got := runIn(t, tc.input); got != tc.want { + t.Errorf("%q: out = %q, want %q", tc.input, got, tc.want) + } + if funcDepth != 0 || len(localFrames) != 0 || ioOverride != [3]*os.File{} { + t.Fatalf("%q: function state not restored", tc.input) + } + } + + for input, want := range map[string]string{ + "return": "return: can only `return' from a function or sourced script", + "local x": "local: can only be used in a function", + "f() { f; }; f": "f: maximum function nesting level exceeded (1000)", + "f() echo x": "f: the body of a function must be a compound command like { ...; }", + "f() { return x; }; f": "return: x: numeric argument required", + } { + if err := execInput(input); err == nil || err.Error() != want { + t.Errorf("%q: err = %v, want %q", input, err, want) + } + } +} + +func TestExec_Case(t *testing.T) { + resetShell(t) + tests := []struct { + input string + want string + }{ + {"case abc in a*) echo a > out;; b*) echo b > out;; esac", "a\n"}, + {"case bcd in a*) echo a > out;; b*) echo b > out;; esac", "b\n"}, + {"case xyz in a*) echo a > out;; esac", ""}, + {"case stop in start|run) echo s > out;; stop|halt) echo h > out;; esac", "h\n"}, + {"case x in (x) echo paren > out;; esac", "paren\n"}, + {"case x in x)echo tight > out;;esac", "tight\n"}, + {"case x in x ) echo blank > out ;; esac", "blank\n"}, + {"case x in\n y) echo y > out ;;\n x)\n echo x1 >> out\n echo x2 >> out\n ;;\nesac", "x1\nx2\n"}, + // The last clause needs no ;; + {"case x in x) echo last > out; esac", "last\n"}, + {"case x in x) ;; esac; echo empty > out", "empty\n"}, + {"case x in esac; echo none > out", "none\n"}, + // Patterns: wildcards, also across /, quoted characters are literal + {"case a/b/c in *) echo star > out;; esac", "star\n"}, + {"case a/b in a?b) echo q > out;; esac", "q\n"}, + {"case b in [abc]) echo class > out;; esac", "class\n"}, + {"case d in [!abc]) echo neg > out;; esac", "neg\n"}, + {`case '*' in "*") echo quoted > out;; *) echo glob > out;; esac`, "quoted\n"}, + {`case abc in "*") echo quoted > out;; *) echo glob > out;; esac`, "glob\n"}, + {`case 'a b' in "a b") echo space > out;; esac`, "space\n"}, + {`case 'x)' in 'x)') echo paren-in-quotes > out;; esac`, "paren-in-quotes\n"}, + // Variables in the word and in patterns + {`set -- --help; case $1 in -h|--help) echo help > out;; esac`, "help\n"}, + // Values of variables are no patterns (like everywhere in gosh) + {`P='b*'; case bar in $P) echo pattern > out;; *) echo literal > out;; esac`, "literal\n"}, + {`P='b*'; case 'b*' in $P) echo same > out;; esac`, "same\n"}, + {`case $(echo hi) in hi) echo subst > out;; esac`, "subst\n"}, + {`case "" in "") echo empty-word > out;; esac`, "empty-word\n"}, + // Only the first matching clause runs + {"case a in a) echo 1 >> out;; *) echo 2 >> out;; esac", "1\n"}, + // Nested in other structures + {"for x in a b; do case $x in a) echo A >> out;; *) echo other >> out;; esac; done", "A\nother\n"}, + {"f() { case $1 in y) return 0;; *) return 1;; esac; }; f y && echo yes > out", "yes\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) + } + } + + for input, status := range map[string]int{ + "case x in y) false;; esac": 0, + "case x in x) false;; esac": 1, + "case x in x) true;; esac": 0, + "case x in x) echo;": 2, + "case x y in x) ;; esac": 2, + "echo a;; echo b": 2, + } { + runIn(t, input) + if lastStatus != status { + t.Errorf("%q: $? = %d, want %d", input, lastStatus, status) + } + } +} + +func TestExec_Group(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"{ echo a >> out; echo b >> out; }", "a\nb\n"}, + {"{\n echo multi >> out\n}", "multi\n"}, + {"false || { echo fallback >> out; }", "fallback\n"}, + {"echo { } > out", "{ }\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_PositionalBuiltins(t *testing.T) { + resetShell(t) + tests := []struct { + input string + want string + }{ + {"set -- a b c; echo $# $1 $3 > out", "3 a c\n"}, + {"set -- a b c; shift; echo $# $1 > out", "2 b\n"}, + {"set -- a b c; shift 2; echo $# $1 > out", "1 c\n"}, + {"set -- a b; shift 3; echo $# $? > out", "2 1\n"}, + {"set --; echo $# > out", "0\n"}, + {"set -- a b; for x; do echo $x >> out; done", "a\nb\n"}, + {"set -- 1 2 3; while [ $# -gt 0 ]; do echo $1 >> out; shift; done", "1\n2\n3\n"}, + // source with arguments + {"echo 'echo $# $1 > out' > lib; set -- x; . ./lib a b", "2 a\n"}, + {"echo 'echo $1 > out' > lib; set -- x; . ./lib a b; echo $1 >> out", "a\nx\n"}, + // return ends a sourced file + {"printf 'echo one >> out\\nreturn 3\\necho two >> out\\n' > lib; . ./lib; echo $? >> out", "one\n3\n"}, + } + for _, tc := range tests { + positional = nil + if got := runIn(t, tc.input); got != tc.want { + t.Errorf("%q: out = %q, want %q", tc.input, got, tc.want) + } + } +} + +func TestRead(t *testing.T) { + for _, name := range []string{"GOSH_A", "GOSH_B", "GOSH_C", "REPLY", "IFS"} { + os.Unsetenv(name) + defer os.Unsetenv(name) + } + tests := []struct { + input string + data string + want string + }{ + {"read GOSH_A", "hello world\n", "[hello world]"}, + {"read GOSH_A GOSH_B", " one two three \n", "[one][two three]"}, + {"read GOSH_A GOSH_B GOSH_C", "one\n", "[one][][]"}, + {"read GOSH_A", "no newline", "[no newline]"}, + {"read GOSH_A GOSH_B", `a\ b c` + "\n", "[a b][c]"}, + {"read -r GOSH_A GOSH_B", `a\ b c` + "\n", `[a\][b c]`}, + {"read GOSH_A", "con\\\ntinued\n", "[continued]"}, + {"read", " reply \n", "[ reply ]"}, + {"IFS=:; read GOSH_A GOSH_B GOSH_C", "a::b:c\n", "[a][][b:c]"}, + {"IFS=:; read GOSH_A GOSH_B", "a b:c d\n", "[a b][c d]"}, + {"IFS=; read GOSH_A", " keep \n", "[ keep ]"}, + {"read GOSH_A; read GOSH_B", "first\nsecond\n", "[first][second]"}, + } + for _, tc := range tests { + for _, name := range []string{"GOSH_A", "GOSH_B", "GOSH_C", "REPLY", "IFS"} { + os.Unsetenv(name) + } + dir := t.TempDir() + in := filepath.Join(dir, "in") + os.WriteFile(in, []byte(tc.data), 0644) + f, _ := os.Open(in) + saved := os.Stdin + os.Stdin = f + execInput(tc.input) + os.Stdin = saved + f.Close() + + var got strings.Builder + names := []string{"GOSH_A", "GOSH_B", "GOSH_C"} + if !strings.Contains(tc.input, "GOSH_") { + names = []string{"REPLY"} + } + for _, name := range names { + if v, ok := os.LookupEnv(name); ok { + got.WriteString("[" + v + "]") + } + } + if got.String() != tc.want { + t.Errorf("%q with %q: got %s, want %s", tc.input, tc.data, got.String(), tc.want) + } + } + + // Status 1 at the end of the input, also after a last line without newline + for data, want := range map[string]int{"line\n": 0, "partial": 1, "": 1} { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "in"), []byte(data), 0644) + runIn(t, "read GOSH_A < "+filepath.Join(dir, "in")) + if lastStatus != want { + t.Errorf("read from %q: $? = %d, want %d", data, lastStatus, want) + } + } + + if err := execInput("read 1x"); err == nil || err.Error() != "read: `1x': not a valid identifier" { + t.Errorf("read 1x: %v", err) + } + if err := execInput("echo a | read GOSH_A"); err == nil || !strings.Contains(err.Error(), "cannot be used in a pipeline") { + t.Errorf("read in a pipeline: %v", err) + } +} + +// TestShell_Arguments checks $0 and the positional parameters of scripts and +// gosh -c, and that a script reads lines from stdin with read. +func TestShell_Arguments(t *testing.T) { + dir := t.TempDir() + script := filepath.Join(dir, "script.gosh") + os.WriteFile(script, []byte(`echo "$# [$1] [$2]" +usage() { + echo "usage: $1" >&2 + return 2 +} +case ${1:-} in + -h) usage x; exit $? ;; +esac +while read -r line; do + echo "line: $line" +done +`), 0644) + + tests := []struct { + args []string + stdin string + want string + }{ + {[]string{"-c", `echo $0 $# "$@"`}, "", subshellPath + " 0\n"}, + {[]string{"-c", `echo $0 $# "$@"`, "name", "a", "b c"}, "", "name 2 a b c\n"}, + {[]string{"-c", `for a; do echo "<$a>"; done`, "n", "x y", "z"}, "", "\n\n"}, + {[]string{script, "one", "two words"}, "l1\nl2\n", "2 [one] [two words]\nline: l1\nline: l2\n"}, + } + for _, tc := range tests { + cmd := exec.Command(subshellPath, tc.args...) + cmd.Stdin = strings.NewReader(tc.stdin) + out, _ := cmd.Output() + if string(out) != tc.want { + t.Errorf("gosh %q: got %q, want %q", tc.args, out, tc.want) + } + } + + cmd := exec.Command(subshellPath, script, "-h") + out, err := cmd.CombinedOutput() + if exitStatus(err) != 2 || !strings.Contains(string(out), "usage: x") { + t.Errorf("script -h: status %d, output %q", exitStatus(err), out) + } +} diff --git a/main.go b/main.go index 1c0bfb8..0d7d074 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,7 @@ import ( "os/user" "path/filepath" "runtime/debug" + "slices" "sort" "strconv" "strings" @@ -152,18 +153,20 @@ func tokenize(input string) ([]token, error) { plain = true // the word so far has no quotes, escapes or expansions assign bool // the word is an assignment name=value tildeOK bool // ~ may be expanded here (after = or : of an assignment) + noParams bool // the word contains "$@" without positional parameters quote byte escaped bool rawStart = -1 // start of the current word in input ) endWord := func() { - if inWord { + // "$@" without parameters is no word at all, unlike "" + if inWord && !(noParams && b.Len() == 0) { tokens = append(tokens, token{word: b.String(), pattern: pat.String(), glob: glob, assign: assign}) } b.Reset() pat.Reset() - glob, assign, plain = false, false, true + glob, assign, plain, noParams = false, false, true, false inWord = false rawStart = -1 } @@ -227,6 +230,20 @@ func tokenize(input string) ([]token, error) { } } i = end + case ch == '$' && !assign && positionalWords(input[i:], quote == '"') > 0: + // $@ and "$@": one word per positional parameter + startRaw(i) + if len(positional) == 0 && quote == '"' { + noParams = true + } + for k, param := range positional { + if k > 0 { + endWord() + } + literal(param) + inWord = true + } + i += positionalWords(input[i:], quote == '"') - 1 case ch == '$': startRaw(i) value, n, err := expandVar(input[i:]) @@ -506,13 +523,10 @@ 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) { + if !isVarName(name) && !isDigits(name) && !isSpecialParam(name) { return "", 0, fmt.Errorf("bad substitution: %s", s[:end+1]) } - value, ok := os.LookupEnv(name) + value, ok := lookupParam(name) if hasDef && value == "" { return def, end + 1, nil } @@ -522,8 +536,13 @@ func expandVar(s string) (string, int, error) { return value, end + 1, nil } - if len(s) > 1 && s[1] == '?' { - return strconv.Itoa(lastStatus), 2, nil + // $?, $#, $$, $@, $*, $0 and $1 to $9 ($10 is $1 followed by 0) + if len(s) > 1 && (isSpecialParam(s[1:2]) || isDigits(s[1:2])) { + value, ok := lookupParam(s[1:2]) + if !ok { + return "", 0, fmt.Errorf("%s: unbound variable", s[1:2]) + } + return value, 2, nil } n := 1 @@ -540,6 +559,25 @@ func expandVar(s string) (string, int, error) { return value, n, nil } +// isSpecialParam reports whether name is one of the special parameters ?, +// #, $, @ and *. +func isSpecialParam(name string) bool { + return len(name) == 1 && strings.Contains("?#$@*", name) +} + +// positionalWords returns the number of bytes of $@ or ${@} at the start of +// s, which expands to one word per positional parameter (also in double +// quotes). Unquoted, $* and ${*} do the same (gosh never splits values). +// It returns 0 for anything else. +func positionalWords(s string, quoted bool) int { + for _, p := range []string{"$@", "${@}", "$*", "${*}"} { + if strings.HasPrefix(s, p) && (strings.Contains(p, "@") || !quoted) { + return len(p) + } + } + return 0 +} + func isVarName(name string) bool { if name == "" { return false @@ -760,7 +798,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", "break", "continue", "unset", "help"}, + commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "source", "jobs", "fg", "bg", "wait", "alias", "unalias", "break", "continue", "unset", "help", "return", "local", "shift", "read"}, } } @@ -777,7 +815,7 @@ func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) { // ... 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", "!": + case "if", "then", "elif", "else", "do", "while", "until", "!", "{", ";;": isFirstWord = true } } @@ -791,7 +829,7 @@ func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) { // Complete commands var candidates [][]rune seen := make(map[string]bool) - names := append(append(append([]string(nil), c.commands...), aliasNames()...), pathExecutables(word)...) + names := append(append(append(append([]string(nil), c.commands...), aliasNames()...), functionNames()...), pathExecutables(word)...) for _, cmd := range names { if strings.HasPrefix(cmd, word) && !seen[cmd] { seen[cmd] = true @@ -935,8 +973,8 @@ func completeFilePath(line, prefix string) []string { return matches } -const usage = `usage: gosh [-l] [-i] [--norc] [--noprofile] [script] - gosh [-l] [--noprofile] -c command +const usage = `usage: gosh [-l] [-i] [--norc] [--noprofile] [script [args ...]] + gosh [-l] [--noprofile] -c command [name [args ...]] gosh -version` // sysConfDir holds the system-wide startup files. @@ -949,6 +987,7 @@ func main() { if strings.HasPrefix(os.Args[0], "-") { args = append([]string{"-l"}, args...) } + scriptName = os.Args[0] os.Exit(run(args)) } @@ -986,21 +1025,26 @@ flags: fmt.Println(usage) return 0 case arg == "-c": - // Like sh, further arguments are allowed (they would become $0, - // $1, ...), but gosh has no positional parameters yet. + // gosh -c command [name [args ...]]: name is $0, args are $1, ... if len(args) < 2 { fmt.Fprintln(os.Stderr, "gosh: -c: option requires an argument") fmt.Fprintln(os.Stderr, usage) return 2 } + if len(args) > 2 { + scriptName, positional = args[2], args[3:] + } script = func() int { return runScript(strings.NewReader(args[1]), "") } case strings.HasPrefix(arg, "-") && arg != "-": fmt.Fprintf(os.Stderr, "gosh: %s: invalid option\n", arg) fmt.Fprintln(os.Stderr, usage) return 2 case arg == "-": + positional = args[1:] script = func() int { return runScript(os.Stdin, "") } default: + // gosh script [args ...] + scriptName, positional = arg, args[1:] f, err := os.Open(arg) if err != nil { fmt.Fprintln(os.Stderr, "gosh:", err) @@ -1064,6 +1108,8 @@ func sourceStartupFile(path string) { return } defer f.Close() + sourceDepth++ + defer func() { sourceDepth-- }() runScript(bufio.NewReader(f), path) } @@ -1127,12 +1173,18 @@ func runScript(r io.Reader, name string) int { goto next // read the rest of the command } buf.Reset() - if err != nil { - lastStatus = exitStatus(err) - report(err, startLine) + if err == nil { + err = tree.exec() + var ret returnSignal + if errors.As(err, &ret) { + // return in a sourced file + lastStatus = ret.status + return lastStatus + } } else { - report(tree.exec(), startLine) + lastStatus = exitStatus(err) } + report(err, startLine) } next: if readErr != nil { @@ -1192,6 +1244,10 @@ func exitStatus(err error) int { if errors.As(err, &lc) { return 0 } + var ret returnSignal + if errors.As(err, &ret) { + return ret.status + } var exitErr *exec.ExitError if errors.As(err, &exitErr) { if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() { @@ -1339,11 +1395,12 @@ func printError(err error) { exitErr *exec.ExitError statusErr statusError lc loopControl + ret returnSignal ) - if err == nil || errors.As(err, &exitErr) || errors.As(err, &statusErr) || errors.As(err, &lc) { + if err == nil || errors.As(err, &exitErr) || errors.As(err, &statusErr) || errors.As(err, &lc) || errors.As(err, &ret) { return } - fmt.Fprintln(os.Stderr, "gosh:", err) + fmt.Fprintln(stdio(2), "gosh:", err) } // execInput parses and executes input: a command line or a whole script. @@ -1476,7 +1533,7 @@ func runPipeline(stages []stage, text string, background bool) error { shellTTY, _ = readline.GetState(ttyFd) } - stdin := os.Stdin + stdin := stdio(0) if background && !jobControl { // Without job control background jobs must not read from the // terminal, like in sh @@ -1486,12 +1543,12 @@ func runPipeline(stages []stage, text string, background bool) error { } var startErr error for i, st := range stages { - stdout := os.Stdout + stdout := stdio(1) var next *os.File if i < len(stages)-1 { r, w, err := os.Pipe() if err != nil { - if stdin != os.Stdin { + if stdin != stdio(0) { stdin.Close() } startErr = err @@ -1531,10 +1588,10 @@ func runPipeline(stages []stage, text string, background bool) error { // longer needs them, so that the neighbors see EOF or get SIGPIPE. func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background bool) *proc { var owned []*os.File - if stdin != os.Stdin { + if stdin != stdio(0) { owned = append(owned, stdin) } - if stdout != os.Stdout { + if stdout != stdio(1) { owned = append(owned, stdout) } closeOwned := func() { @@ -1547,7 +1604,7 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background // 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} + fds := [3]*os.File{stdin, stdout, stdio(2)} opened, err := applyRedirects(&fds, st.redirects) owned = append(owned, opened...) if err == nil && len(st.args) == 0 && len(st.assigns) > 0 { @@ -1570,7 +1627,12 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background return finishedProc(err) } - if builtin, ok := builtins[st.args[0]]; ok { + fn, isFunc := functions[st.args[0]] + if isFunc && !inPipeline && !background { + defer closeOwned() + return finishedProc(callFunction(fn, st.args, fds, st.assigns)) + } + if builtin, ok := builtins[st.args[0]]; !isFunc && ok { switch { case inPipeline && builtin.changesState: closeOwned() @@ -1581,8 +1643,8 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background } run := func() error { defer closeOwned() - err := runBuiltin(st.args, fds[1]) - if err != nil && fds[2] != os.Stderr { + err := runBuiltin(st.args, fds[0], fds[1]) + if err != nil && fds[2] != stdio(2) { // Report the error where stderr is redirected to fmt.Fprintln(fds[2], "gosh:", err) return statusError(1) @@ -1599,7 +1661,17 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background return p } - cmd := exec.Command(st.args[0], st.args[1:]...) + var cmd *exec.Cmd + if isFunc { + // A function in a pipeline or in the background runs in a subshell + cmd, err = subshellCommand(`"$@"`, st.args) + if err != nil { + closeOwned() + return finishedProc(err) + } + } else { + cmd = exec.Command(st.args[0], st.args[1:]...) + } cmd.Stdin, cmd.Stdout, cmd.Stderr = fds[0], fds[1], fds[2] if len(st.assigns) > 0 { // name=value before a command is only in its environment (the @@ -1734,10 +1806,15 @@ var builtins = map[string]struct{ changesState bool }{ "unset": {changesState: true}, "break": {changesState: true}, "continue": {changesState: true}, + "return": {changesState: true}, + "local": {changesState: true}, + "shift": {changesState: true}, + "read": {changesState: true}, } -// runBuiltin executes a built-in command, writing its output to stdout. -func runBuiltin(args []string, stdout io.Writer) error { +// runBuiltin executes a built-in command, reading from stdin and writing its +// output to stdout. +func runBuiltin(args []string, stdin *os.File, stdout io.Writer) error { switch args[0] { case "cd": // Save current directory before changing (for 'cd -') @@ -1790,6 +1867,11 @@ func runBuiltin(args []string, stdout io.Writer) error { } return exitShell(lastStatus) case "set": + // set -- args: set the positional parameters + if len(args) > 1 && args[1] == "--" { + positional = slices.Clone(args[2:]) + return nil + } // set VAR=value - set environment variable if len(args) < 2 { return fmt.Errorf("usage: set VAR=value") @@ -1817,12 +1899,21 @@ func runBuiltin(args []string, stdout io.Writer) error { // Does nothing, successfully (e.g. while :; do ...) return nil case "unset": - // unset NAME ... - remove variables - if len(args) < 2 { - return fmt.Errorf("usage: unset NAME ...") + // unset [-v] NAME ... - remove variables, unset -f NAME ... - functions + names := args[1:] + funcs := len(names) > 0 && names[0] == "-f" + if len(names) > 0 && (names[0] == "-f" || names[0] == "-v") { + names = names[1:] + } + if len(names) == 0 { + return fmt.Errorf("usage: unset [-f|-v] NAME ...") } var err error - for _, name := range args[1:] { + for _, name := range names { + if funcs { + delete(functions, name) + continue + } if !isVarName(name) { err = fmt.Errorf("unset: `%s': not a valid identifier", sanitize(name)) continue @@ -1832,6 +1923,14 @@ func runBuiltin(args []string, stdout io.Writer) error { return err case "break", "continue": return builtinLoopControl(args) + case "return": + return builtinReturn(args) + case "local": + return builtinLocal(args) + case "shift": + return builtinShift(args) + case "read": + return builtinRead(args, stdin) case "alias": return builtinAlias(args, stdout) case "unalias": @@ -1845,9 +1944,15 @@ func runBuiltin(args []string, stdout io.Writer) error { case "wait": return builtinWait(args) case "source", ".": - // source file - run the commands of file in the current shell + // source file [args] - run the commands of file in the current shell if len(args) < 2 { - return fmt.Errorf("usage: %s file", args[0]) + return fmt.Errorf("usage: %s file [args ...]", args[0]) + } + if len(args) > 2 { + // The arguments are the positional parameters while it runs + saved := positional + positional = slices.Clone(args[2:]) + defer func() { positional = saved }() } return sourceFile(args[0], args[1]) case "help": @@ -1860,9 +1965,10 @@ Built-in commands: exit [code] - Exit shell with status code (default: $?) quit [code] - Quit shell with status code (default: $?) set VAR=val - Set environment variable + set -- args - Set the positional parameters $1, $2, ... export VAR=val - Export environment variable - unset VAR - Remove a variable - source file - Run the commands of file in this shell (also: . file) + unset VAR - Remove a variable (unset -f name: a function) + source file [args] - Run the commands of file in this shell (also: . file) jobs - List background and stopped jobs fg [%n] - Continue job n (default: current job) in the foreground bg [%n] - Continue stopped job n in the background @@ -1872,6 +1978,12 @@ Built-in commands: : - 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 + return [n] - Leave a function or sourced file with status n + local VAR[=val] - Variable that gets its old value back when the + function returns + shift [n] - Remove the first n positional parameters (default 1) + read [-r] [-p prompt] [VAR ...] - Read a line into variables (split + at $IFS; the last one gets the rest; default $REPLY) help - Show this help Features: @@ -1899,6 +2011,9 @@ Features: a pattern - Environment variable expansion ($VAR, ${VAR}, ${VAR:-default}); using an unset variable is an error, use ${VAR:-} for an empty default + - Positional parameters: $0, $1 ... $9, ${10}, $# (count), "$@" (one + word each), "$*" (one word), $$ (process ID); set by gosh script + args, gosh -c command name args, functions, set -- and source - Command substitution: $(command) is replaced by the output of command (run in a subshell); unquoted it is split into words, "$(command)" stays one word @@ -1906,7 +2021,10 @@ Features: 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 ..., + case word in pattern|pattern) ...;; esac, { cmd; cmd; } groups; ! cmd negates the status; they can span several lines (prompt "> ") + - Functions: name() { ...; } (or function name { ...; }); the arguments + are $1, $2, ...; in pipes, & and $(...) they run in a subshell - 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 6189884..0882944 100644 --- a/main_test.go +++ b/main_test.go @@ -941,7 +941,7 @@ func TestSplitWords(t *testing.T) { {`echo x${GOSH_A}y`, []string{"echo", "xa by"}}, // Escaped and lone dollar signs stay literal {`echo \$GOSH_A "\$GOSH_A"`, []string{"echo", "$GOSH_A", "$GOSH_A"}}, - {`echo $ 5$ $1`, []string{"echo", "$", "5$", "$1"}}, + {`echo $ 5$ $%`, []string{"echo", "$", "5$", "$%"}}, // Backslash in double quotes only escapes $ " \ and ` {`echo "a\nb\\c"`, []string{"echo", `a\nb\c`}}, // Defaults diff --git a/packaging/man/gosh.1 b/packaging/man/gosh.1 index f34c336..53ca9f6 100644 --- a/packaging/man/gosh.1 +++ b/packaging/man/gosh.1 @@ -9,13 +9,16 @@ gosh \- a simple shell written in Go .RB [ \-i ] .RB [ \-\-norc ] .RB [ \-\-noprofile ] -.RI [ script ] +.RI [ script +.RI [ args " ...]]" .br .B gosh .RB [ \-l ] .RB [ \-\-noprofile ] .B \-c .I command +.RI [ name +.RI [ args " ...]]" .br .B gosh .B \-version @@ -23,9 +26,10 @@ gosh \- a simple shell written in Go .B gosh is a small command interpreter with a syntax close to .BR sh (1). -It supports command history, tab completion, variables, command -substitution, command lists, control structures, pipes, redirections, -wildcards, brace expansion, aliases and job control. +It supports command history, tab completion, variables, positional +parameters, command substitution, command lists, control structures, +functions, pipes, redirections, wildcards, brace expansion, aliases and job +control. .PP Without arguments and with a terminal on standard input, .B gosh @@ -34,16 +38,32 @@ starts an interactive shell. With a argument it runs the commands of that file, with .B \-c the given command string, and otherwise it reads commands from standard -input. +input. The +.I args +after the script, or after the +.I name +of +.BR \-c , +become the positional parameters +.BR $1 , +.BR $2 ", ..." .PP .B gosh is a proof of concept and not meant for production use. .SH OPTIONS .TP -.B \-c \fIcommand\fP +.B \-c \fIcommand\fP [\fIname\fP [\fIargs\fP ...]] Run .I command and exit. +.I name +becomes +.BR $0 , +the +.I args +become +.BR $1 , +.BR $2 ", ..." .TP .B \-i Force interactive mode, even if standard input is not a terminal. @@ -168,6 +188,37 @@ for an empty default. .TP .B $? The exit status of the last command. +.SS Positional parameters +.TP +.B $0 +The name of the script, or the +.I name +given after +.BR "gosh \-c" . +.TP +.BR $1 " ... " $9 ", " ${10} +The arguments of the script, of +.BR "gosh \-c" , +of a function call or of +.BR source , +or the words set with +.BR "set \-\-" . +An unset parameter is an error like an unset variable; use +.B ${1:\-} +for an optional argument. +.TP +.B $# +The number of positional parameters. +.TP +.B \(dq$@\(dq +All positional parameters, each as a separate word. Without parameters it +expands to no word at all. +.TP +.B \(dq$*\(dq +All positional parameters as one word, separated by blanks. +.TP +.B $$ +The process ID of the shell. .SS Command substitution .B $(\fIcommand\fP) is replaced by the output of @@ -278,6 +329,8 @@ and 2 are supported. \fBfor\fP \fIname\fP \fBin\fP \fIwords\fP; \fBdo\fP \fIlist\fP; \fBdone\fP \fBwhile\fP \fIlist\fP; \fBdo\fP \fIlist\fP; \fBdone\fP \fBuntil\fP \fIlist\fP; \fBdo\fP \fIlist\fP; \fBdone\fP +\fBcase\fP \fIword\fP \fBin\fP [\fB(\fP]\fIpattern\fP[\fB|\fP\fIpattern\fP]...\fB)\fP \fIlist\fP \fB;;\fP ... \fBesac\fP +\fB{\fP \fIlist\fP; \fB}\fP \fB!\fP \fIcommand\fP .fi .PP @@ -285,10 +338,52 @@ Control structures may span several lines; an incomplete command shows the continuation prompt .BR "> " . .B ! -negates the exit status of a command. Control structures cannot be used -in pipes, with redirections or in the background. +negates the exit status of a command. +.B for +without +.B in +loops over the positional parameters. +.PP .B case -and functions are not supported. +runs the list of the first clause with a pattern that matches +.IR word . +Patterns are wildcards as for file names, but +.B * +and +.B ? +also match a +.BR / ; +quoted characters match literally, and values of variables are not +patterns. The +.B ;; +of the last clause may be omitted. +.PP +.B { +.IR list ; +.B } +groups commands; they run in the current shell. +.PP +Control structures cannot be used in pipes, with redirections or in the +background, and there are no +.B ( ... ) +subshells. +.SS Functions +.nf +\fIname\fP\fB() {\fP \fIlist\fP; \fB}\fP +\fBfunction\fP \fIname\fP \fB{\fP \fIlist\fP; \fB}\fP +.fi +.PP +defines a function; the body can be any compound command. When the +function is called, its arguments are the positional parameters, which get +their old values back afterwards. Functions run in the current shell, so +variables they set stay set; use +.B local +for variables that belong to the function. Redirections of a call apply to +all commands of the function. In pipes, in the background and in +.B $(...) +a function runs in a subshell that knows all functions. Functions take +precedence over builtins and programs of the same name. Calls can be nested +up to a depth of 1000. .SS Aliases In interactive shells, the first word of a command is replaced by its alias. If the alias value ends with a blank, the next word is checked for @@ -318,16 +413,21 @@ Exit the shell with status .BI set " VAR" = value Set a variable. .TP +.BR "set \-\-" " [\fIargs\fP ...]" +Set the positional parameters. +.TP .BI export " VAR" = value Export a variable. .TP -.BI unset " VAR" -Remove a variable. +.BR unset " [\fB\-v\fP] \fIVAR\fP ..., " "unset \-f" " \fIname\fP ..." +Remove variables, or functions. .TP -.BI source " file\fR, \fP" ". " file +.BR source " \fIfile\fP [\fIargs\fP ...], " . " \fIfile\fP [\fIargs\fP ...]" Run the commands of .I file -in the current shell. +in the current shell, with +.I args +as positional parameters if given. .TP .B jobs List background and stopped jobs. @@ -361,6 +461,39 @@ Leave the innermost (or .IR n -th) loop, or continue with its next iteration. .TP +.BR return " [\fIn\fP]" +Leave a function or sourced file with status +.I n +(default: +.BR $? ). +.TP +.BR local " \fIVAR\fP[\fB=\fP\fIvalue\fP] ..." +In a function: the variable gets its previous value back when the function +returns. Without a value it is unset. +.TP +.BR shift " [\fIn\fP]" +Remove the first +.I n +(default 1) positional parameters. +.TP +.BR read " [\fB\-r\fP] [\fB\-p\fP \fIprompt\fP] [\fIVAR\fP ...]" +Read a line from standard input and split it into fields at the +characters of +.B IFS +(default blank, tab and newline). Each +.I VAR +gets one field, the last one the rest of the line; without +.I VAR +the whole line is stored in +.BR REPLY . +Without +.BR \-r , +a backslash escapes the next character and continues the line at its end. +.B \-p +prints +.I prompt +if the input is a terminal. The exit status is 1 at the end of the input. +.TP .B help Show a summary of builtins and features. .SH JOB CONTROL @@ -448,6 +581,10 @@ without argument. .TP .B PATH Directories searched for commands. +.TP +.B IFS +Field separators for +.BR read . .SH FILES .TP .I /etc/gosh/profile @@ -493,6 +630,9 @@ cp config.yaml{,.bak} make > build.log 2>&1 || echo "failed: $?" LANG=C sort file alias ll=\(aqls \-l\(aq +greet() { echo "Hello ${1:\-World}"; } +case "$1" in start|stop) echo "$1ing" ;; *) echo "usage: $0 start|stop" ;; esac +read \-r \-p \(aqName? \(aq name chsh \-s /usr/bin/gosh .fi .SH SEE ALSO diff --git a/parser.go b/parser.go index 4699878..7005010 100644 --- a/parser.go +++ b/parser.go @@ -1,8 +1,8 @@ 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 +// newlines), pipelines, ! and the compound commands if, for, while, until, +// case and { ...; }, and function definitions. 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. @@ -11,6 +11,8 @@ import ( "errors" "fmt" "os" + "path" + "slices" "strconv" "strings" "syscall" @@ -48,7 +50,13 @@ 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} +var terminators = map[string]bool{ + "then": true, "elif": true, "else": true, "fi": true, "do": true, "done": true, + "esac": true, "}": true, +} + +// compoundStarts are the reserved words that start a compound command. +var compoundStarts = map[string]bool{"if": true, "for": true, "while": true, "until": true, "case": true, "{": true} // --------------------------------------------------------------------------- // Lexer @@ -57,7 +65,7 @@ type tokenKind int const ( tWord tokenKind = iota - tOp // ; & && || | or newline + tOp // ; ;; & && || | or newline ) type rawToken struct { @@ -126,6 +134,9 @@ func lex(src string) ([]rawToken, error) { switch { case ch == ' ' || ch == '\t': i++ + case ch == ';' && next == ';': + op(";;", i) + i += 2 case ch == '\n' || ch == ';': op(string(ch), i) i++ @@ -259,6 +270,27 @@ type whileNode struct { body *listNode } +// caseNode is case word in pattern|pattern) list ;; ... esac. +type caseNode struct { + word string // expanded when the case command runs + clauses []caseClause +} + +type caseClause struct { + patterns []string // expanded when they are tried + body *listNode +} + +// groupNode is { list; }. +type groupNode struct { + body *listNode +} + +// funcDefNode is a function definition name() compound-command. +type funcDefNode struct { + def *funcDef +} + // 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 { @@ -356,7 +388,7 @@ func (p *parser) parseList(terms map[string]bool) (*listNode, error) { for { p.skipNewlines() t := p.peek() - if t == nil || t.kind == tWord && terms[t.text] { + if t == nil || terms[t.text] { return list, nil } ao, err := p.parseAndOr() @@ -371,8 +403,9 @@ func (p *parser) parseList(terms map[string]bool) (*listNode, error) { 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 + case terms[t.text]: + // e.g. "fi" right after the end of a nested control structure, + // or ;; after a command in a case clause default: return nil, p.unexpected(t) } @@ -436,14 +469,12 @@ func (p *parser) parsePipeline() (*pipelineNode, error) { 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: + start := t.start // funcDefName may split the token + if name, ok := p.funcDefName(); ok { + pl.cmd, err = p.parseFuncDef(name, start) + } else if compoundStarts[t.text] { + pl.cmd, err = p.parseCompound() + } else { compound = false pl.cmd, err = p.parseSimple() } @@ -486,13 +517,30 @@ func (p *parser) parseSimple() (*simpleNode, error) { 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": + case compoundStarts[next.text]: return nil, errors.New("control structures in pipelines are not supported") } } return &simpleNode{text: p.src[start:end]}, nil } +// parseCompound parses the compound command that starts at the current +// token. +func (p *parser) parseCompound() (node, error) { + switch p.peek().text { + case "if": + return p.parseIf() + case "for": + return p.parseFor() + case "while", "until": + return p.parseWhile() + case "case": + return p.parseCase() + default: // { + return p.parseGroup() + } +} + func (p *parser) parseIf() (*ifNode, error) { p.pos++ // if n := &ifNode{} @@ -604,6 +652,212 @@ func (p *parser) parseWhile() (*whileNode, error) { return n, nil } +// parseGroup parses { list; }. +func (p *parser) parseGroup() (*groupNode, error) { + p.pos++ // { + body, err := p.parseList(map[string]bool{"}": true}) + if err == nil { + err = p.nonEmpty(body) + } + if err == nil { + err = p.expect("}") + } + if err != nil { + return nil, err + } + return &groupNode{body: body}, nil +} + +// parseCase parses case word in [(]pattern[|pattern]...) list ;; ... esac. +// The last clause may omit the ;;. +func (p *parser) parseCase() (*caseNode, error) { + p.pos++ // case + t := p.peek() + if t == nil { + return nil, errIncomplete + } + if t.kind != tWord { + return nil, p.unexpected(t) + } + n := &caseNode{word: t.text} + p.pos++ + p.skipNewlines() + if err := p.expect("in"); err != nil { + return nil, err + } + for { + p.skipNewlines() + t := p.peek() + if t == nil { + return nil, errIncomplete + } + if t.kind == tWord && t.text == "esac" { + p.pos++ + return n, nil + } + patterns, err := p.parsePatterns() + if err != nil { + return nil, err + } + body, err := p.parseList(map[string]bool{";;": true, "esac": true}) + if err != nil { + return nil, err + } + n.clauses = append(n.clauses, caseClause{patterns: patterns, body: body}) + if t := p.peek(); t == nil { + return nil, errIncomplete + } else if t.text == ";;" { + p.pos++ + } + } +} + +// parsePatterns parses the patterns of a case clause up to and including the +// ). Since ( and ) are not operators for the lexer, they can be part of a +// word token ("a)", "(a", "*)echo"); such tokens are split here. +func (p *parser) parsePatterns() ([]string, error) { + var patterns []string + if t := p.peek(); t.kind == tWord && strings.HasPrefix(t.text, "(") { + // The optional ( before the first pattern + if t.text == "(" { + p.pos++ + } else { + t.text, t.start = t.text[1:], t.start+1 + } + } + for { + t := p.peek() + if t == nil { + return nil, errIncomplete + } + if t.kind != tWord { + return nil, p.unexpected(t) + } + i := unquotedIndex(t.text, ')') + if i < 0 { + patterns = append(patterns, t.text) + p.pos++ + next := p.peek() + switch { + case next == nil: + return nil, errIncomplete + case next.kind == tOp && next.text == "|": + p.pos++ + case next.kind == tWord && strings.HasPrefix(next.text, ")"): + default: + return nil, p.unexpected(next) + } + continue + } + if i > 0 { + patterns = append(patterns, t.text[:i]) + } + if len(patterns) == 0 { + return nil, syntaxError(")") + } + if rest := t.text[i+1:]; rest == "" { + p.pos++ + } else { + // The first word of the body follows right after the ) + t.text, t.start = rest, t.start+i+1 + } + return patterns, nil + } +} + +// unquotedIndex returns the index of the first c in word that is not quoted, +// escaped or inside $(...) or ${...}, or -1. +func unquotedIndex(word string, c byte) int { + var quote byte + for i := 0; i < len(word); i++ { + ch := word[i] + switch { + case quote == '\'': + if ch == '\'' { + quote = 0 + } + case ch == '\\': + i++ + case isSubstStart(word, i): + if end := substEnd(word, i); end >= 0 { + i = end + } + case ch == '$' && i+1 < len(word) && word[i+1] == '{': + if k := strings.IndexByte(word[i:], '}'); k >= 0 { + i += k + } + case quote == '"': + if ch == '"' { + quote = 0 + } + case ch == '"' || ch == '\'': + quote = ch + case ch == c: + return i + } + } + return -1 +} + +// funcDefName reports whether a function definition starts at the current +// token: name(), name () or function name [()]. It consumes the tokens up to +// the body; a body that starts in the same token (name(){) becomes a token of +// its own. +func (p *parser) funcDefName() (string, bool) { + start := p.pos + t := p.peek() + keyword := t.kind == tWord && t.text == "function" + if keyword { + p.pos++ + if t = p.peek(); t == nil || t.kind != tWord { + p.pos = start + return "", false + } + } + var name, rest string + if n, r, ok := strings.Cut(t.text, "()"); ok && isFuncName(n) { + // name() or name(){ + name, rest = n, r + } else if next := p.pos + 1; t.kind == tWord && isFuncName(t.text) && next < len(p.toks) && + p.toks[next].kind == tWord && strings.HasPrefix(p.toks[next].text, "()") { + // name () + name = t.text + p.pos = next + t = &p.toks[next] + rest = t.text[2:] + } else if keyword && isFuncName(t.text) { + // function name + name = t.text + } else { + p.pos = start + return "", false + } + if rest == "" { + p.pos++ + } else { + t.text, t.start = rest, t.end-len(rest) + } + return name, true +} + +// parseFuncDef parses the body of a function definition that started at +// src[start]: a compound command. +func (p *parser) parseFuncDef(name string, start int) (*funcDefNode, error) { + p.skipNewlines() + t := p.peek() + if t == nil { + return nil, errIncomplete + } + if t.kind != tWord || !compoundStarts[t.text] { + return nil, fmt.Errorf("%s: the body of a function must be a compound command like { ...; }", sanitize(name)) + } + body, err := p.parseCompound() + if err != nil { + return nil, err + } + return &funcDefNode{def: &funcDef{name: name, body: body, text: p.src[start:p.toks[p.pos-1].end]}}, nil +} + // parseLoopBody parses "do list done". func (p *parser) parseLoopBody() (*listNode, error) { if err := p.expect("do"); err != nil { @@ -623,11 +877,14 @@ func (p *parser) parseLoopBody() (*listNode, error) { // 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). +// break, continue, return, 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) + var ( + lc loopControl + ret returnSignal + ) + return errors.As(err, &lc) || errors.As(err, &ret) || exitStatus(err) == 128+int(syscall.SIGINT) } // silent returns an error with only the exit status of err, for errors that @@ -779,7 +1036,8 @@ func (n *whileNode) exec() error { } func (n *forNode) exec() error { - var words []string + // Without "in", the loop runs over the positional parameters + words := slices.Clone(positional) if n.hasIn { var err error if words, err = expandWordList(n.words); err != nil { @@ -804,6 +1062,85 @@ func (n *forNode) exec() error { return result } +func (n *groupNode) exec() error { + return n.body.exec() +} + +func (n *funcDefNode) exec() error { + defineFunction(n.def) + return nil +} + +// exec runs the body of the first clause with a pattern that matches the +// word. The status is 0 if no pattern matches. +func (n *caseNode) exec() error { + word, err := expandCaseWord(n.word) + if err != nil { + return err + } + for _, clause := range n.clauses { + for _, pattern := range clause.patterns { + ok, err := caseMatch(pattern, word) + if err != nil { + return err + } + if ok { + return clause.body.exec() + } + } + } + lastStatus = 0 + return nil +} + +// expandCaseWord expands the word of a case command: like an argument, but +// without brace expansion, wildcards and splitting. +func expandCaseWord(text string) (string, error) { + tokens, err := tokenize(text) + if err != nil { + return "", fmt.Errorf("invalid input: %w", err) + } + var words []string + for _, t := range tokens { + if t.op != "" { + return "", syntaxError(t.op) + } + words = append(words, t.word) + } + return strings.Join(words, " "), nil +} + +// caseMatch reports whether word matches the pattern of a case clause. The +// pattern is expanded first; quoted characters in it match literally. Unlike +// for file names, * and ? also match a /. +func caseMatch(pattern, word string) (bool, error) { + tokens, err := tokenize(pattern) + if err != nil { + return false, fmt.Errorf("invalid input: %w", err) + } + var literal, glob []string + isGlob := false + for _, t := range tokens { + if t.op != "" { + return false, syntaxError(t.op) + } + literal = append(literal, t.word) + glob = append(glob, t.pattern) + isGlob = isGlob || t.glob + } + if !isGlob { + return word == strings.Join(literal, " "), nil + } + // path.Match does not let * match a /; NUL can be in neither string + toNUL := strings.NewReplacer("/", "\x00") + ok, err := path.Match(toNUL.Replace(strings.Join(glob, " ")), toNUL.Replace(word)) + if err != nil { + // An invalid pattern only matches itself + return word == strings.Join(literal, " "), nil + } + return ok, nil +} + // expandWordList expands the words of a for loop like the arguments of a // command (braces, ~, variables, $(...), wildcards). func expandWordList(text string) ([]string, error) { diff --git a/read.go b/read.go new file mode 100644 index 0000000..3092ae2 --- /dev/null +++ b/read.go @@ -0,0 +1,215 @@ +package main + +// The read builtin: read [-r] [-p prompt] [name ...] reads a line and splits +// it into fields at the characters of $IFS (default: blank, tab, newline). + +import ( + "errors" + "fmt" + "io" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/chzyer/readline" + "golang.org/x/sys/unix" +) + +// builtinRead implements read. Each name gets one field, the last one the +// rest of the line; without names the whole line is stored in $REPLY. The +// status is 1 at the end of the input. +func builtinRead(args []string, stdin *os.File) error { + raw := false + prompt := "" + args = args[1:] + for len(args) > 0 && strings.HasPrefix(args[0], "-") && args[0] != "-" { + opt := args[0] + args = args[1:] + if opt == "--" { + break + } + for k := 1; k < len(opt); k++ { + switch opt[k] { + case 'r': + raw = true + case 'p': + switch { + case k+1 < len(opt): + prompt = opt[k+1:] + case len(args) > 0: + prompt, args = args[0], args[1:] + default: + return errors.New("read: -p: option requires an argument") + } + k = len(opt) + default: + return fmt.Errorf("read: -%c: invalid option\nusage: read [-r] [-p prompt] [name ...]", opt[k]) + } + } + } + for _, name := range args { + if !isVarName(name) { + return fmt.Errorf("read: `%s': not a valid identifier", sanitize(name)) + } + } + + terminal := readline.IsTerminal(int(stdin.Fd())) + if prompt != "" && terminal { + fmt.Fprint(stdio(2), prompt) + } + line, literal, eof, err := readInputLine(stdin, raw, interactiveShell && terminal) + if err != nil { + return err + } + + if len(args) == 0 { + os.Setenv("REPLY", line) + } else { + ifs, ok := os.LookupEnv("IFS") + if !ok { + ifs = " \t\n" + } + fields := splitFields(line, literal, ifs, len(args)) + for i, name := range args { + value := "" + if i < len(fields) { + value = fields[i] + } + os.Setenv(name, value) + } + } + if eof { + return statusError(1) + } + return nil +} + +// readInputLine reads a line from f, byte by byte so that nothing after the +// line is consumed. Unless raw is set, a backslash escapes the next +// character (literal marks the escaped characters, which never split +// fields) and backslash-newline continues the line. eof reports that the +// input ended before a newline. If interruptible is set, Ctrl+C aborts the +// read with status 130. +func readInputLine(f *os.File, raw, interruptible bool) (line string, literal []bool, eof bool, err error) { + readByte := plainByteReader(f) + if interruptible { + intr := make(chan os.Signal, 1) + signal.Notify(intr, os.Interrupt) + defer signal.Stop(intr) + readByte = interruptibleByteReader(f, intr) + } + + var b []byte + for { + c, err := readByte() + if err == io.EOF { + return string(b), literal, true, nil + } + if err != nil { + return "", nil, false, err + } + if c == '\n' { + return string(b), literal, false, nil + } + if c == '\\' && !raw { + c, err = readByte() + if err == io.EOF { + return string(b), literal, true, nil + } + if err != nil { + return "", nil, false, err + } + if c == '\n' { + continue // the line goes on + } + b = append(b, c) + literal = append(literal, true) + continue + } + b = append(b, c) + literal = append(literal, false) + } +} + +func plainByteReader(f *os.File) func() (byte, error) { + buf := make([]byte, 1) + return func() (byte, error) { + for { + n, err := f.Read(buf) + if n == 1 { + return buf[0], nil + } + if err != nil { + return 0, err + } + } + } +} + +// interruptibleByteReader reads from the terminal f only when input is +// available, so that a SIGINT on intr can end the read in between. +func interruptibleByteReader(f *os.File, intr chan os.Signal) func() (byte, error) { + next := plainByteReader(f) + fds := []unix.PollFd{{Fd: int32(f.Fd()), Events: unix.POLLIN}} + return func() (byte, error) { + for { + select { + case <-intr: + fmt.Fprintln(stdio(2)) + return 0, statusError(128 + int(syscall.SIGINT)) + default: + } + n, err := unix.Poll(fds, 100) + if err != nil && !errors.Is(err, unix.EINTR) { + return 0, err + } + if n > 0 { + return next() + } + } + } +} + +// splitFields splits line into at most n fields like read does: IFS +// whitespace (blank, tab, newline in ifs) around fields is removed, other IFS +// characters separate fields (also empty ones). The last field is the rest of +// the line. Characters marked in literal are never separators. +func splitFields(line string, literal []bool, ifs string, n int) []string { + isSep := func(i int) bool { return !literal[i] && strings.IndexByte(ifs, line[i]) >= 0 } + isSpace := func(i int) bool { return isSep(i) && strings.IndexByte(" \t\n", line[i]) >= 0 } + + start, end := 0, len(line) + for start < end && isSpace(start) { + start++ + } + for end > start && isSpace(end-1) { + end-- + } + + var fields []string + i := start + for i < end && len(fields) < n-1 { + j := i + for j < end && !isSep(j) { + j++ + } + fields = append(fields, line[i:j]) + // The separator: whitespace, at most one other IFS character, + // whitespace + for j < end && isSpace(j) { + j++ + } + if j < end && isSep(j) && !isSpace(j) { + j++ + for j < end && isSpace(j) { + j++ + } + } + i = j + } + if i < end { + fields = append(fields, line[i:end]) + } + return fields +} diff --git a/subst.go b/subst.go index 7beb8b8..38282f2 100644 --- a/subst.go +++ b/subst.go @@ -2,7 +2,8 @@ package main // Command substitution: $(command) is replaced by the output of command, // which runs in a subshell (a new gosh process), so that cd, set etc. in it -// do not change the shell. +// do not change the shell. Functions also run in a subshell in pipelines and +// in the background. import ( "bytes" @@ -67,24 +68,34 @@ func substEnd(s string, i int) int { return -1 } +// subshellCommand returns a command that runs script in a new gosh process +// with the same $0, the given positional parameters and the functions of the +// shell. (Variables need not be passed: they are all in the environment.) +func subshellCommand(script string, params []string) (*exec.Cmd, error) { + path := subshellPath + if path == "" { + var err error + if path, err = os.Executable(); err != nil { + return nil, err + } + } + args := append([]string{"-c", functionDefs() + script, scriptName}, params...) + return exec.Command(path, args...), nil +} + // commandSubst runs command in a subshell and returns its output without // trailing newlines. stdin and stderr are those of the shell. func commandSubst(command string) (string, error) { if strings.HasPrefix(command, "(") { return "", errors.New("arithmetic expansion $((...)) is not supported") } - path := subshellPath - if path == "" { - var err error - if path, err = os.Executable(); err != nil { - return "", err - } - } - var out bytes.Buffer - cmd := exec.Command(path, "-c", command) - cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, &out, os.Stderr - err := cmd.Run() + cmd, err := subshellCommand(command, positional) + if err != nil { + return "", err + } + cmd.Stdin, cmd.Stdout, cmd.Stderr = stdio(0), &out, stdio(2) + err = cmd.Run() substStatus = exitStatus(err) if substStatus == 128+int(syscall.SIGINT) { // Ctrl+C aborts the whole command, like in bash