Compare commits

..
4 Commits
Author SHA1 Message Date
Florian WaltherandClaude Opus 5.5 af5281eed2 report the working directory to the terminal with OSC 7
Before each prompt gosh sends ESC ] 7 ; file://host/path BEL (host and
path percent-encoded), so terminals like GNOME Terminal open new tabs
and windows in the directory of the shell instead of the one gosh was
started in. Nothing is sent if stdout is not a terminal or TERM=dumb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:22:59 +02:00
Florian WaltherandClaude Opus 5.5 1f507f6562 add parameter expansion, arithmetic, set -e/-x/pipefail, trap, eval, exec
- ${#v}, ${v#p} ${v##p} ${v%p} ${v%%p}, ${v/p/r} ${v//p/r} ${v/#p/r}
  ${v/%p/r}, ${v:off:len}, ${v-w} ${v:=w} ${v:+w} ${v:?msg}; nested ${}
- arithmetic expansion $((...)) with the C operators, ** and assignments
- set -e, set -x, set -o pipefail (also as gosh options, passed on to
  subshells); set shows variables, set -o the options
- trap for EXIT and signals; scripts with an EXIT trap run it on
  SIGINT/SIGTERM/SIGHUP too
- eval, exec (also exec > file without a command), $!, wait pid

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:15:24 +02:00
Florian WaltherandClaude Opus 5.5 f66cf538c0 add subshells, here-documents and redirections/pipes for control structures
- control structures can have redirections (while read l; do ...; done
  < file), which apply to all commands in them; they run in the shell
- in pipes and in the background they run in a subshell (cmd | while
  read l; do ...; done, for ...; done &, a && b &)
- ( list ) subshells; ( and ) are operators now
- here-documents <<EOF, <<-EOF, <<'EOF' and here-strings <<<word
- redirections of source apply to the commands of the file
- fix non-ASCII characters in quotes being mangled ('für')

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:57:15 +02:00
Florian WaltherandClaude Opus 5.5 443367f66f 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 <noreply@anthropic.com>
2026-10-04 17:20:59 +02:00
21 changed files with 5126 additions and 249 deletions

No files matched your search

+347 -16
View File
@@ -11,18 +11,32 @@ Built-in commands:
exit [code] - Exit shell with status code (default: $?) exit [code] - Exit shell with status code (default: $?)
quit [code] - Quit shell with status code (default: $?) quit [code] - Quit shell with status code (default: $?)
set VAR=val - Set environment variable set VAR=val - Set environment variable
set [-ex] [-o pipefail] [--] [args] - Options: -e exit when a command
fails, -x print commands, -o pipefail; args are $1, ...
(set: show variables, set -o: show options)
export VAR=val - Export environment variable export VAR=val - Export environment variable
unset VAR - Remove a variable unset VAR - Remove a variable (unset -f name: a function)
source file - Run the commands of file in this shell (also: . file) source file [args] - Run the commands of file in this shell (also: . file)
jobs - List background and stopped jobs jobs - List background and stopped jobs
fg [%n] - Continue job n (default: current job) in the foreground fg [%n] - Continue job n (default: current job) in the foreground
bg [%n] - Continue stopped job n in the background bg [%n] - Continue stopped job n in the background
wait [%n] - Wait for job n or all background jobs wait [%n|pid] - Wait for job n (or process pid, e.g. $!) or all jobs
alias [name[=value] ...] - Define or show aliases (alias ll='ls -l') alias [name[=value] ...] - Define or show aliases (alias ll='ls -l')
unalias name - Remove an alias (unalias -a: all aliases) unalias name - Remove an alias (unalias -a: all aliases)
: - Do nothing (exit status 0) : - Do nothing (exit status 0)
break [n] - Leave the innermost (or n-th) for/while/until loop break [n] - Leave the innermost (or n-th) for/while/until loop
continue [n] - Continue with the next iteration of a 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)
eval args - Run the arguments as a command in this shell
exec [cmd] - Replace the shell with cmd; without cmd, its
redirections apply to the shell (exec > log)
trap [cmd] SIG... - Run cmd on a signal (INT, TERM, HUP, USR1, ...)
or on EXIT; trap '' SIG ignores it, trap - SIG resets
help - Show this help help - Show this help
Features: Features:
@@ -45,11 +59,23 @@ Features:
GOSH_COLORS='user=1;33:path=36' (keys user, root, path, error; values GOSH_COLORS='user=1;33:path=36' (keys user, root, path, error; values
are ANSI SGR codes, empty = no color); disabled with $NO_COLOR, are ANSI SGR codes, empty = no color); disabled with $NO_COLOR,
TERM=dumb or when output is not a terminal TERM=dumb or when output is not a terminal
- Reports the working directory to the terminal (OSC 7) before each
prompt, so new tabs/windows open in the same directory
- Variables: name=value sets a variable (like set), name=value command - Variables: name=value sets a variable (like set), name=value command
sets it only for that command; the value is not split or expanded as sets it only for that command; the value is not split or expanded as
a pattern a pattern
- Environment variable expansion ($VAR, ${VAR}, ${VAR:-default}); - Environment variable expansion ($VAR, ${VAR}, ${VAR:-default});
using an unset variable is an error, use ${VAR:-} for an empty default using an unset variable is an error, use ${VAR:-} for an empty default
- Parameter expansion: ${#VAR} (length), ${VAR#pat} ${VAR##pat}
${VAR%pat} ${VAR%%pat} (remove prefix/suffix), ${VAR/pat/rep}
${VAR//pat/rep}, ${VAR:offset:length}, ${VAR-x} ${VAR:=x} ${VAR:+x}
${VAR:?msg}
- Arithmetic: $((expression)) with integers and the C operators
(+ - * / % ** << >> < == && || ?: = += ++ ...)
- Positional parameters: $0, $1 ... $9, ${10}, $# (count), "$@" (one
word each), "$*" (one word), $$ (process ID), $! (process ID of the
last background job); set by gosh script
args, gosh -c command name args, functions, set -- and source
- Command substitution: $(command) is replaced by the output of command - Command substitution: $(command) is replaced by the output of command
(run in a subshell); unquoted it is split into words, "$(command)" (run in a subshell); unquoted it is split into words, "$(command)"
stays one word stays one word
@@ -57,9 +83,16 @@ Features:
a || b (b only if a failed); $? is the exit status of the last command a || b (b only if a failed); $? is the exit status of the last command
- Control structures: if cmd; then ...; elif cmd; then ...; else ...; fi, - Control structures: if cmd; then ...; elif cmd; then ...; else ...; fi,
for x in words; do ...; done, while cmd; do ...; done, until ..., for x in words; do ...; done, while cmd; do ...; done, until ...,
! cmd negates the status; they can span several lines (prompt "> ") case word in pattern|pattern) ...;; esac, { cmd; cmd; } groups,
( cmd; cmd ) subshells; ! cmd negates the status; they can span
several lines (prompt "> "), have redirections (done < file) and can
be used in pipes (| while read l; do ...; done) and with &
- 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, - Pipes (a | b) and redirections: < file, > file, >> file, 2> file,
2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file 2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file,
<<EOF here-documents (<<-EOF strips tabs, <<'EOF' expands nothing),
<<<word here-strings
- Job control: command & runs in the background, Ctrl+Z stops the - Job control: command & runs in the background, Ctrl+Z stops the
foreground job; jobs, fg, bg and wait manage the jobs foreground job; jobs, fg, bg and wait manage the jobs
- Aliases replace the first word of a command in interactive shells - Aliases replace the first word of a command in interactive shells
@@ -75,8 +108,8 @@ Features:
### Aufruf ### Aufruf
``` ```
gosh [-l] [-i] [--norc] [--noprofile] [script] gosh [-l] [-i] [-ex] [-o option] [--norc] [--noprofile] [script [args ...]]
gosh [-l] [--noprofile] -c command gosh [-l] [-ex] [-o option] [--noprofile] -c command [name [args ...]]
gosh -version gosh -version
``` ```
@@ -84,12 +117,14 @@ gosh -version
|------------------------|------------------------------------------------------------------| |------------------------|------------------------------------------------------------------|
| `gosh` | interaktive Shell (wenn stdin ein Terminal ist) | | `gosh` | interaktive Shell (wenn stdin ein Terminal ist) |
| `gosh -c 'befehl'` | führt den Befehl aus und beendet sich | | `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 | | `befehle \| gosh` | liest die Befehle von stdin |
| `-i` | erzwingt den interaktiven Modus, auch ohne Terminal | | `-i` | erzwingt den interaktiven Modus, auch ohne Terminal |
| `-l`, `--login` | Login-Shell, liest die Profil-Dateien (siehe unten) | | `-l`, `--login` | Login-Shell, liest die Profil-Dateien (siehe unten) |
| `--norc` | `goshrc`-Dateien nicht lesen | | `--norc` | `goshrc`-Dateien nicht lesen |
| `--noprofile` | `profile`-Dateien nicht lesen | | `--noprofile` | `profile`-Dateien nicht lesen |
| `-e`, `-x`, `-o option` | Optionen wie bei `set` (z. B. `gosh -e skript`, `-o pipefail`) |
Ohne Terminal bricht gosh bei einem fehlgeschlagenen Befehl nicht ab und beendet sich mit dem Exit-Status des letzten Befehls: `127`, wenn ein Befehl nicht gefunden wurde, `126`, wenn er nicht ausführbar ist, `128+n`, wenn er durch Signal `n` beendet wurde. Leere Zeilen und Zeilen, die mit `#` beginnen, werden übersprungen, Skripte können also mit `#!/usr/bin/gosh` beginnen. Ohne Terminal bricht gosh bei einem fehlgeschlagenen Befehl nicht ab und beendet sich mit dem Exit-Status des letzten Befehls: `127`, wenn ein Befehl nicht gefunden wurde, `126`, wenn er nicht ausführbar ist, `128+n`, wenn er durch Signal `n` beendet wurde. Leere Zeilen und Zeilen, die mit `#` beginnen, werden übersprungen, Skripte können also mit `#!/usr/bin/gosh` beginnen.
@@ -137,7 +172,7 @@ export PATH=$HOME/bin:$PATH
### Kontrollstrukturen ### 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 ```bash
if [ -f ~/.goshrc ]; then if [ -f ~/.goshrc ]; then
@@ -157,10 +192,19 @@ for i in {1..5}; do echo "Durchlauf $i"; done
n=3 n=3
while [ $n -gt 0 ]; do while [ $n -gt 0 ]; do
echo $n echo $n
n=$(expr $n - 1) n=$((n - 1))
done done
until ping -c1 -W1 server >/dev/null; do sleep 1; 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 | | Konstrukt | Bedeutung |
@@ -169,17 +213,156 @@ until ping -c1 -W1 server >/dev/null; do sleep 1; done
| `for x in wörter; do …; done` | setzt `x` nacheinander auf jedes Wort | | `for x in wörter; do …; done` | setzt `x` nacheinander auf jedes Wort |
| `while befehl; do …; done` | wiederholt, solange `befehl` erfolgreich ist | | `while befehl; do …; done` | wiederholt, solange `befehl` erfolgreich ist |
| `until befehl; do …; done` | wiederholt, bis `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; befehl )` | führt die Befehle in einer Subshell aus |
| `! befehl` | kehrt den Exit-Status um | | `! befehl` | kehrt den Exit-Status um |
| `break [n]`, `continue [n]` | verlässt die (n-te) Schleife bzw. springt zum nächsten Durchlauf | | `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`) | | `:` | 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 ]`). - 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. - 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. - 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`). - 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. - 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)`. - Zum Rechnen in Bedingungen und Schleifen siehe [Arithmetik](#arithmetik): `while [ $i -lt 10 ]; do i=$((i + 1)); done`.
### Umleitungen, Pipes und Hintergrund bei Kontrollstrukturen
Kontrollstrukturen lassen sich wie einfache Befehle umleiten, in Pipes verwenden und im Hintergrund starten:
```bash
# Datei Zeile für Zeile lesen
while read -r name rest; do
echo "Name: $name"
done < /etc/passwd
# Ausgabe einer Schleife sortieren bzw. umleiten
for f in *.log; do wc -l < "$f"; done | sort -n
if make; then echo ok; else echo kaputt; fi > build.status 2>&1
{ date; uptime; } >> status.log
# Zeilen aus einer Pipe verarbeiten
git ls-files | while read -r f; do echo "== $f"; done
# Schleife im Hintergrund
for host in a b c; do ping -c1 $host; done > ping.log &
```
- Umleitungen nach `done`, `fi`, `esac`, `}` und `)` gelten für alle Befehle darin. Die Kontrollstruktur läuft dabei in der Shell selbst, Variablen aus `while read x; do …; done < datei` sind danach also gesetzt.
- In einer Pipe und im Hintergrund (`&`) läuft eine Kontrollstruktur (wie in `sh`) in einer Subshell, einem eigenen gosh-Prozess, weil alle Teile einer Pipe gleichzeitig laufen. Variablen, die dort gesetzt werden, gelten danach nicht mehr: Nach `ls | while read f; do n=$f; done` ist `n` nicht gesetzt. Die Subshell kennt die Funktionen und Positionsparameter der Shell.
- Auch Listen lassen sich in den Hintergrund schicken: `make && make install &` läuft als ein Job in einer Subshell.
- Mit Ctrl+C wird die ganze Pipe abgebrochen, auch eine Endlosschleife wie `while true; do date; sleep 1; done | tee log`.
### Subshells
`( befehle )` führt Befehle in einer Subshell aus, einem eigenen gosh-Prozess. Änderungen an Verzeichnis, Variablen und Funktionen gelten nur dort:
```bash
(cd /tmp && tar xzf ~/paket.tar.gz) # danach ist man noch im alten Verzeichnis
(export LANG=C; sort datei)
(exit 3); echo $? # 3
(cd build && make) > build.log 2>&1
```
- `exit` beendet nur die Subshell, ihr Exit-Status ist der des letzten Befehls bzw. von `exit`.
- Die Subshell bekommt alle Variablen (in gosh sind alle exportiert), die Funktionen und die Positionsparameter. Aliase gelten dort nicht (wie in Skripten).
- `{ …; }` fasst dagegen Befehle zusammen, ohne eine Subshell zu starten.
- `(` und `)` sind Operatoren: Ein Wort wie `a(b)` ist ein Syntaxfehler, Klammern als Text brauchen Anführungszeichen oder `\` (`echo "(a)"`, `find . \( -name x \)`).
### 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"
# Die Funktion liest von stdin
zeilen() {
while read -r zeile; do echo "> $zeile"; done
}
zeilen < /etc/hostname
ls | zeilen
```
- 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 |
| `$!` | Prozess-ID des letzten Hintergrund-Jobs (`wait $!`, `kill $!`) |
```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 als einfacher Befehl in einer Pipeline verwenden (`echo a | read x`, in `sh` wirkungslos). Umleitungen funktionieren: `read x < datei`, `read x <<< "$text"`, `while read l; do …; done < datei`. In einer Schleife in einer Pipe (`… | while read l; do …; done`) geht es auch, da die ganze Schleife in einer Subshell läuft.
### Variablen ### Variablen
@@ -202,6 +385,60 @@ echo $name ${name:-standard}
- Variablen in den Argumenten werden ersetzt, bevor die Zuweisung davor gilt: `x=1 echo $x` gibt nicht `1` aus, sondern meldet `x: unbound variable` (wenn `x` vorher nicht gesetzt war). - Variablen in den Argumenten werden ersetzt, bevor die Zuweisung davor gilt: `x=1 echo $x` gibt nicht `1` aus, sondern meldet `x: unbound variable` (wenn `x` vorher nicht gesetzt war).
- In einer Pipeline (`x=1 | cat`) ändert eine Zuweisung die Shell nicht. - In einer Pipeline (`x=1 | cat`) ändert eine Zuweisung die Shell nicht.
### Parameter-Expansion
```bash
datei=/home/flo/bilder/urlaub.tar.gz
echo ${#datei} # 30 Länge
echo ${datei##*/} # urlaub.tar.gz wie basename
echo ${datei%/*} # /home/flo/bilder wie dirname
echo ${datei%%.*} # /home/flo/bilder/urlaub
echo ${datei#*.} # tar.gz
echo ${datei/flo/anna} # /home/anna/bilder/urlaub.tar.gz
echo ${datei//\//:} # :home:flo:bilder:urlaub.tar.gz
echo ${datei:0:5} # /home
echo ${port:-8080} # Standardwert, wenn port leer oder nicht gesetzt
: ${EDITOR:=vim} # Standardwert setzen
echo ${1:?datei fehlt} # Fehler, wenn kein Argument
```
| Ausdruck | ergibt |
|-----------------------|---------------------------------------------------------------------|
| `${#name}` | Länge des Werts in Zeichen |
| `${name#muster}` | Wert ohne das kürzeste passende Präfix (`##`: das längste) |
| `${name%muster}` | Wert ohne das kürzeste passende Suffix (`%%`: das längste) |
| `${name/muster/neu}` | ersetzt den ersten (längsten) Treffer, `//` alle, `/#` nur am Anfang, `/%` nur am Ende |
| `${name:anfang}`, `${name:anfang:länge}` | Teilstring; negativer Anfang zählt vom Ende (`${x: -2}`), negative Länge lässt Zeichen am Ende weg |
| `${name:-wort}` | `wort`, wenn `name` nicht gesetzt oder leer ist, sonst der Wert |
| `${name:=wort}` | wie `:-`, setzt `name` aber auch auf `wort` |
| `${name:+wort}` | `wort`, wenn `name` gesetzt und nicht leer ist, sonst nichts |
| `${name:?meldung}` | Fehler mit `meldung`, wenn `name` nicht gesetzt oder leer ist |
| `${name-wort}` usw. | ohne `:`: nur „nicht gesetzt“ zählt, ein leerer Wert nicht |
- Das Wort bzw. Muster wird expandiert (Variablen, `$(…)`, `$((…))`, Anführungszeichen, `~`), aber nicht in Wörter geteilt. Verschachteln geht: `${a:-${b:-c}}`.
- Muster sind Wildcards wie bei Dateinamen (`*`, `?`, `[…]`), nur passen `*` und `?` auch auf `/`. Zeichen in Anführungszeichen und Werte von Variablen passen nur wörtlich (wie überall in gosh): `${x%"*"}` entfernt einen `*` am Ende, `${x%$suffix}` den Text von `$suffix`.
- Mit `+`, `-`, `?` und `=` gibt es keinen Fehler für nicht gesetzte Variablen, alle anderen Formen melden `unbound variable`.
- Längen und Teilstrings zählen Zeichen, nicht Bytes (`${#x}` für `Grüße` ist 5).
- `${name:?…}` bricht nur den Befehl ab (wie eine nicht gesetzte Variable), mit `set -e` endet damit das Skript.
### Arithmetik
```bash
echo $((1 + 2 * 3)) # 7
n=$((n + 1)) # hochzählen
: $((n++)) # dasselbe mit ++
echo $(( (a + b) / 2 ))
echo $((x > 5 ? 1 : 0))
echo $((0x1F)) $((2 ** 10)) $((7 % 3))
i=0; while [ $i -lt 10 ]; do i=$((i + 1)); done
```
- `$((ausdruck))` rechnet mit ganzen 64-Bit-Zahlen. Es gibt die Operatoren aus C: `+ - * / %`, `**` (Potenz), `<< >>`, `< <= > >= == !=`, `& | ^ ~`, `! && ||`, `? :`, Zuweisungen `= += -= *= /= %= <<= >>= &= |= ^=`, `++` und `--` (davor und danach) sowie `,`.
- Variablen lassen sich mit und ohne `$` verwenden (`$((n + 1))`, `$(($n + 1))`). Ist ihr Wert selbst ein Ausdruck, wird er ausgerechnet; ein leerer Wert zählt als 0, eine nicht gesetzte Variable ist ein Fehler.
- Zahlen sind dezimal, `0x…` hexadezimal oder mit führender `0` oktal (`010` ist 8).
- Division durch 0 ist ein Fehler. Bei `&&`, `||` und `? :` wird die nicht benötigte Seite nicht ausgerechnet.
- Das Ergebnis ist ein Wort (auch ohne Anführungszeichen). `((…))` als eigener Befehl und `let` gibt es nicht, `: $((…))` tut das Gleiche.
### Befehlsersetzung ### Befehlsersetzung
`$(befehl)` wird durch die Ausgabe von `befehl` ersetzt: `$(befehl)` wird durch die Ausgabe von `befehl` ersetzt:
@@ -215,12 +452,12 @@ $ vim $(grep -l TODO *.go)
$ echo "Dateien: $(ls | wc -l)" $ 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. - 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). - 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. - Anders als in `sh` werden Wildcards in der Ausgabe nicht expandiert, und Zeichen wie `;` oder `|` darin sind normaler Text: Die Ausgabe ist immer nur Daten.
- Besteht ein Befehl nur aus einer Ersetzung (`$(false)`), ist ihr Exit-Status `$?`, sonst zählt der Status des Befehls. Ctrl+C während der Ersetzung bricht die ganze Zeile ab. - Besteht ein Befehl nur aus einer Ersetzung (`$(false)`), ist ihr Exit-Status `$?`, sonst zählt der Status des Befehls. Ctrl+C während der Ersetzung bricht die ganze Zeile ab.
- In `'...'` oder mit `\$(` wird nichts ersetzt. Die alte Schreibweise mit Backticks (`` `befehl` ``) und Arithmetik `$((1+2))` gibt es nicht. - In `'...'` oder mit `\$(` wird nichts ersetzt. Die alte Schreibweise mit Backticks (`` `befehl` ``) gibt es nicht. `$((…))` ist keine Befehlsersetzung, sondern [Arithmetik](#arithmetik); eine Subshell darin braucht ein Leerzeichen: `$( (befehl) )`.
### Befehlslisten und Exit-Status ### Befehlslisten und Exit-Status
@@ -246,6 +483,65 @@ Status 127
Schlägt ein Programm fehl, gibt gosh selbst keine Meldung mehr aus (das Programm meldet den Fehler selbst), nur bei Fehlern von gosh, z. B. wenn ein Befehl nicht gefunden wird. Schlägt ein Programm fehl, gibt gosh selbst keine Meldung mehr aus (das Programm meldet den Fehler selbst), nur bei Fehlern von gosh, z. B. wenn ein Befehl nicht gefunden wird.
### Fehlerbehandlung: set -e, set -x, trap
```bash
#!/usr/bin/gosh
set -euo pipefail # abbrechen bei Fehlern, auch in Pipes
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT # aufräumen, auch bei Fehlern oder Ctrl+C
cd "$tmp"
curl -fsSO https://example.org/paket.tar.gz
tar xzf paket.tar.gz
```
| Option | Bedeutung |
|----------------------------|-------------------------------------------------------------------|
| `set -e` (`-o errexit`) | beendet die Shell, wenn ein Befehl fehlschlägt |
| `set -x` (`-o xtrace`) | gibt jeden Befehl nach der Expansion mit `+ ` davor auf stderr aus |
| `set -o pipefail` | Status einer Pipe ist der des letzten fehlgeschlagenen Befehls |
| `set -u` (`-o nounset`) | ist in gosh immer an, `set +u` gibt es nicht |
| `set +e`, `set +x`, … | schaltet die Option wieder aus |
| `set -o` / `set +o` | zeigt die Optionen an (`set +o` als Befehle zum Wiederherstellen) |
- `set -e` gilt nicht für Bedingungen von `if`, `while` und `until`, für Befehle mit `!` und für alle Befehle einer `&&`/`||`-Liste außer dem letzten: `test -f x || echo fehlt` bricht nicht ab. Es gilt auch in Funktionen, Schleifen, Subshells und `$(…)`, ein fehlgeschlagenes `x=$(befehl)` beendet das Skript also.
- Die Optionen lassen sich auch beim Aufruf setzen (`gosh -ex skript`, `gosh -o pipefail -c '…'`) und gelten in Subshells weiter.
- `set` ohne Argumente zeigt alle Variablen an. `set a b c` und `set -e a b` setzen außerdem die Positionsparameter, `set VAR=wert` setzt wie bisher eine Variable.
`trap befehl signal …` führt `befehl` aus, wenn das Signal kommt:
| Aufruf | Bedeutung |
|----------------------------|-------------------------------------------------------------------|
| `trap 'befehl' EXIT` | beim Beenden der Shell (Ende des Skripts, `exit`, Fehler mit `set -e`, Ctrl+C, `kill`) |
| `trap 'befehl' INT TERM` | bei den Signalen (`HUP`, `INT`, `QUIT`, `TERM`, `USR1`, `USR2`, `ALRM`, `WINCH`, auch mit `SIG` davor oder als Nummer) |
| `trap '' INT` | ignoriert das Signal, auch in gestarteten Programmen |
| `trap - INT` | stellt das normale Verhalten wieder her |
| `trap`, `trap -p [sig]` | zeigt die Traps an |
| `trap -l` | listet die Signale |
- Der Befehl läuft in der Shell (er kann Variablen sehen und `exit` aufrufen), und zwar nach dem Befehl, der gerade läuft, wie in `sh`. `$?` ist im Trap der Status davor, beim `EXIT`-Trap der Exit-Status der Shell.
- Ein Skript mit `EXIT`-Trap führt ihn auch aus, wenn es durch Ctrl+C, `SIGTERM` oder `SIGHUP` beendet wird, und endet danach mit diesem Signal. Mit einem eigenen Trap für das Signal (`trap 'echo abgebrochen; exit 1' INT`) läuft stattdessen dieser Trap. Endet er nicht mit `exit`, läuft das Skript nach dem abgebrochenen Befehl weiter.
- In der interaktiven Shell geht Ctrl+C mit Job-Control nur an den laufenden Befehl, ein `INT`-Trap wird dabei nicht ausgelöst.
- Subshells (`( … )`, `$(…)`, Pipes) erben keine Traps, ignorierte Signale bleiben aber ignoriert.
### eval und exec
```bash
cmd="ls -l | head -3"
eval "$cmd" # führt den Text als Befehl aus
eval "wert_$name=42" # Variablennamen zusammensetzen
exec > build.log 2>&1 # ab hier geht alle Ausgabe in die Datei
exec < eingabe.txt # stdin der Shell umleiten
exec ssh server # gosh durch ssh ersetzen
```
- `eval` fügt seine Argumente mit Leerzeichen zusammen und führt das Ergebnis in der laufenden Shell aus, mit allen Expansionen ein zweites Mal. In einer Pipe oder im Hintergrund läuft es in einer Subshell.
- `exec befehl` ersetzt gosh durch `befehl` (gleiche Prozess-ID, Variablen vor dem Befehl gelten für ihn). Wird der Befehl nicht gefunden, beendet sich ein Skript mit Status 127, die interaktive Shell meldet nur den Fehler.
- `exec` ohne Befehl wendet seine Umleitungen dauerhaft auf die Shell an: `exec > log` leitet ab dort alle Ausgaben um, `exec >&2` bzw. `exec > /dev/tty` wieder zurück.
### Klammer-Expansion ### Klammer-Expansion
Wie in bash erzeugt `{…}` aus einem Wort mehrere Wörter: Wie in bash erzeugt `{…}` aus einem Wort mehrere Wörter:
@@ -319,13 +615,47 @@ $ grep -c error < build.log
| `2>&1` | stderr dorthin, wohin stdout gerade geht | | `2>&1` | stderr dorthin, wohin stdout gerade geht |
| `>&2` | stdout dorthin, wohin stderr gerade geht | | `>&2` | stdout dorthin, wohin stderr gerade geht |
| `&> datei` | stdout und stderr in `datei` (`&>>` hängt an) | | `&> datei` | stdout und stderr in `datei` (`&>>` hängt an) |
| `<<EOF` | Here-Doc: die folgenden Zeilen bis `EOF` sind stdin |
| `<<< wort` | Here-String: `wort` (und ein Zeilenumbruch) ist stdin |
| `> datei` | ohne Befehl: legt `datei` an bzw. leert sie | | `> datei` | ohne Befehl: legt `datei` an bzw. leert sie |
- Umleitungen werden von links nach rechts ausgewertet: `befehl > log 2>&1` schreibt beides in `log`, `befehl 2>&1 > log` schickt stderr dorthin, wo stdout vorher hinging (z. B. in eine Pipe). - Umleitungen werden von links nach rechts ausgewertet: `befehl > log 2>&1` schreibt beides in `log`, `befehl 2>&1 > log` schickt stderr dorthin, wo stdout vorher hinging (z. B. in eine Pipe).
- Unterstützt werden die Dateideskriptoren 0, 1 und 2. Vor `>` und `<` zählt nur eine Zahl ohne Anführungszeichen als Deskriptor (`2>f`, aber nicht `x2>f` oder `"2">f`). - Unterstützt werden die Dateideskriptoren 0, 1 und 2. Vor `>` und `<` zählt nur eine Zahl ohne Anführungszeichen als Deskriptor (`2>f`, aber nicht `x2>f` oder `"2">f`).
- Operatoren in Anführungszeichen oder mit `\` davor sind normale Zeichen: `echo "a|b" 'c>d' e\>f`. - Operatoren in Anführungszeichen oder mit `\` davor sind normale Zeichen: `echo "a|b" 'c>d' e\>f`.
- Der Exit-Status einer Pipeline ist der des letzten Befehls. Beendet sich ein Befehl am Ende (z. B. `head`), bekommt der schreibende Befehl davor `SIGPIPE`, `yes | head -1` endet also sofort. - Der Exit-Status einer Pipeline ist der des letzten Befehls. Beendet sich ein Befehl am Ende (z. B. `head`), bekommt der schreibende Befehl davor `SIGPIPE`, `yes | head -1` endet also sofort.
- Die Built-ins `pwd` und `help` lassen sich umleiten und in Pipes verwenden (`help | less`). `cd`, `set`, `export`, `exit` und `quit` verändern die Shell selbst und sind in einer Pipeline nicht erlaubt. - Die Built-ins `pwd` und `help` lassen sich umleiten und in Pipes verwenden (`help | less`). `cd`, `set`, `export`, `exit` und `quit` verändern die Shell selbst und sind in einer Pipeline nicht erlaubt. Umleitungen von `source datei > log` gelten für alle Befehle der Datei.
- Zu Kontrollstrukturen mit Umleitungen und in Pipes siehe oben.
### Here-Docs und Here-Strings
```bash
cat <<EOF > config.ini
[server]
host = $HOSTNAME
started = $(date +%F)
EOF
cat <<'EOF' > script.sh # 'EOF': nichts wird ersetzt
echo "$HOME bleibt wörtlich stehen"
EOF
if true; then
cat <<-EOF # <<-: Tabs am Zeilenanfang werden entfernt
eingerückt
EOF
fi
grep -c x <<< "$text" # Here-String
read a b <<< "1 2"
```
- Nach `<<WORT` folgen ab der nächsten Zeile die Eingabezeilen, bis zu einer Zeile, die genau `WORT` ist. In der interaktiven Shell zeigt gosh dafür den Fortsetzungs-Prompt `> `.
- Ohne Anführungszeichen am Wort werden `$VAR`, `${VAR}`, `$(…)` und `\` vor `$`, `` ` ``, `\` und dem Zeilenende ersetzt (wie in `"…"`, nur dass `"` ein normales Zeichen ist). Wildcards, `~` und `{a,b}` werden nicht ersetzt. Ist das Wort ganz oder teilweise in Anführungszeichen (`'EOF'`, `"EOF"`, `\EOF`), wird nichts ersetzt.
- `<<-WORT` entfernt Tabs (keine Leerzeichen) am Anfang jeder Zeile und der Endzeile, damit sich Here-Docs in Skripten einrücken lassen.
- Mehrere Here-Docs in einer Zeile (`cat <<A; cat <<B`) folgen nacheinander. Here-Docs gehen auch an Funktionen, Schleifen (`done <<EOF`) und in Pipes.
- `<<< wort` übergibt `wort` mit einem Zeilenumbruch als Eingabe. Das Wort wird wie ein Argument expandiert (Variablen, `$(…)`), aber nicht als Wildcard.
- Die Eingabe steht in einer temporären Datei, die sofort wieder gelöscht wird, deshalb sind auch große Here-Docs kein Problem.
- Ein Here-Doc in einer Befehlsersetzung (`x=$(cat <<EOF … EOF)`) funktioniert, solange der Text darin keine unpaarigen Anführungszeichen wie `it's` enthält.
### Aliase ### Aliase
@@ -390,7 +720,8 @@ $ bg %2 # oder: angehaltenen Job im Hintergrund weiterlauf
- `cd`, `set`, `export`, `exit`, `source`, `fg`, `bg` und `wait` lassen sich nicht mit `&` im Hintergrund starten. - `cd`, `set`, `export`, `exit`, `source`, `fg`, `bg` und `wait` lassen sich nicht mit `&` im Hintergrund starten.
- In Skripten und mit `gosh -c` gibt es keine Job-Control (kein Ctrl+Z, kein `fg`/`bg`), `&` und `wait` funktionieren aber. Hintergrund-Jobs lesen dort von `/dev/null`. - In Skripten und mit `gosh -c` gibt es keine Job-Control (kein Ctrl+Z, kein `fg`/`bg`), `&` und `wait` funktionieren aber. Hintergrund-Jobs lesen dort von `/dev/null`.
- Wird ein angehaltener Job von außen fortgesetzt (`kill -CONT`), zeigt `jobs` ihn weiter als `Stopped`, bis er endet oder erneut angehalten wird; `fg` und `bg` funktionieren trotzdem. - Wird ein angehaltener Job von außen fortgesetzt (`kill -CONT`), zeigt `jobs` ihn weiter als `Stopped`, bis er endet oder erneut angehalten wird; `fg` und `bg` funktionieren trotzdem.
- Einen Job beenden lässt sich mit `kill` über die Prozess-ID (z. B. `kill 12345`); `kill %1` versteht gosh noch nicht. - Einen Job beenden lässt sich mit `kill` über die Prozess-ID (z. B. `kill 12345` oder `kill $!` für den letzten Hintergrund-Job); `kill %1` versteht gosh noch nicht.
- `wait` wartet auch auf eine Prozess-ID: `befehl & pid=$!; …; wait $pid` liefert den Exit-Status von `befehl`. Eine Zahl ohne `%` ist zuerst eine Prozess-ID, sonst eine Job-Nummer.
### Prompt ### Prompt
+458
View File
@@ -0,0 +1,458 @@
package main
// Arithmetic expansion $((expression)) with 64-bit integers and the
// operators of C (and ** for powers), in the order of precedence:
//
// x++ x--, ++x --x, + - ! ~ (unary), **, * / %, + -, << >>,
// < <= > >=, == !=, &, ^, |, &&, ||, ?:, = += -= *= /= %= <<= >>= &= ^= |=, ","
//
// Variables can be used with or without $; their values are evaluated as
// expressions, an empty value is 0, an unset variable is an error (like
// everywhere in gosh). Numbers are decimal, 0x... hexadecimal or 0...
// octal.
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
)
// arithEnd returns the index of the last ) of $((...)) at s[i], or -1 if
// there is no arithmetic expansion (e.g. $( (cmd) ), a command substitution
// of a subshell).
func arithEnd(s string, i int) int {
if !strings.HasPrefix(s[i:], "$((") {
return -1
}
end := substEnd(s, i)
if end < 0 {
return -1
}
// The inner ( must end right before the outer )
if inner := substEnd(s, i+1); inner != end-1 {
return -1
}
return end
}
// evalArithText expands an arithmetic expression (variables, $(...), quotes)
// and evaluates it.
func evalArithText(text string) (int64, error) {
expr, err := expandWordText(text)
if err != nil {
return 0, err
}
return evalArith(expr, 0)
}
// maxArithDepth limits the recursion of variables whose values are
// expressions with variables.
const maxArithDepth = 32
func evalArith(expr string, depth int) (int64, error) {
if depth > maxArithDepth {
return 0, errors.New("arithmetic: expression recursion level exceeded")
}
toks, err := lexArith(expr)
if err != nil {
return 0, err
}
a := &arith{toks: toks, depth: depth, expr: expr}
if len(toks) == 0 {
return 0, nil
}
v, err := a.comma()
if err != nil {
return 0, err
}
if a.pos < len(a.toks) {
return 0, a.syntaxErr()
}
return v, nil
}
type arithToken struct {
kind byte // 'n' number, 'v' variable, 'o' operator
text string
num int64
}
var arithOps = []string{
"<<=", ">>=", "**", "++", "--", "<<", ">>", "<=", ">=", "==", "!=", "&&", "||",
"+=", "-=", "*=", "/=", "%=", "&=", "^=", "|=",
"+", "-", "*", "/", "%", "<", ">", "&", "|", "^", "!", "~", "?", ":", "=", "(", ")", ",",
}
func lexArith(s string) ([]arithToken, error) {
var toks []arithToken
for i := 0; i < len(s); {
ch := s[i]
switch {
case ch == ' ' || ch == '\t' || ch == '\n':
i++
case ch >= '0' && ch <= '9':
j := i
for j < len(s) && (isVarNameByte(s[j], false)) {
j++
}
n, err := strconv.ParseInt(s[i:j], 0, 64)
if err != nil {
return nil, fmt.Errorf("%s: value too great for base (error token is \"%s\")", strings.TrimSpace(s), s[i:j])
}
toks = append(toks, arithToken{kind: 'n', text: s[i:j], num: n})
i = j
case isVarNameByte(ch, true):
j := i
for j < len(s) && isVarNameByte(s[j], false) {
j++
}
toks = append(toks, arithToken{kind: 'v', text: s[i:j]})
i = j
default:
found := false
for _, op := range arithOps {
if strings.HasPrefix(s[i:], op) {
toks = append(toks, arithToken{kind: 'o', text: op})
i += len(op)
found = true
break
}
}
if !found {
return nil, fmt.Errorf("%s: syntax error: invalid arithmetic operator (error token is \"%s\")", strings.TrimSpace(s), sanitize(s[i:]))
}
}
}
return toks, nil
}
type arith struct {
toks []arithToken
pos int
skip int // > 0 while a branch is evaluated that has no effect (&&, ||, ?:)
depth int
expr string
}
func (a *arith) peekOp(ops ...string) string {
if a.pos < len(a.toks) && a.toks[a.pos].kind == 'o' {
for _, op := range ops {
if a.toks[a.pos].text == op {
return op
}
}
}
return ""
}
func (a *arith) syntaxErr() error {
token := "end of expression"
if a.pos < len(a.toks) {
token = a.toks[a.pos].text
}
return fmt.Errorf("%s: arithmetic syntax error (error token is \"%s\")", strings.TrimSpace(a.expr), sanitize(token))
}
// variable returns the value of a variable in an expression.
func (a *arith) variable(name string) (int64, error) {
if a.skip > 0 {
return 0, nil
}
value, ok := os.LookupEnv(name)
if !ok {
return 0, fmt.Errorf("%s: unbound variable", name)
}
if strings.TrimSpace(value) == "" {
return 0, nil
}
if n, err := strconv.ParseInt(strings.TrimSpace(value), 0, 64); err == nil {
return n, nil
}
return evalArith(value, a.depth+1)
}
func (a *arith) setVariable(name string, v int64) {
if a.skip == 0 {
os.Setenv(name, strconv.FormatInt(v, 10))
}
}
func (a *arith) comma() (int64, error) {
v, err := a.assign()
for err == nil && a.peekOp(",") != "" {
a.pos++
v, err = a.assign()
}
return v, err
}
func (a *arith) assign() (int64, error) {
if a.pos+1 < len(a.toks) && a.toks[a.pos].kind == 'v' && a.toks[a.pos+1].kind == 'o' {
name, op := a.toks[a.pos].text, a.toks[a.pos+1].text
if op == "=" || len(op) >= 2 && strings.HasSuffix(op, "=") && op != "==" && op != "!=" && op != "<=" && op != ">=" {
a.pos += 2
rhs, err := a.assign()
if err != nil {
return 0, err
}
v := rhs
if op != "=" {
old, err := a.variable(name)
if err != nil {
return 0, err
}
if v, err = a.apply(strings.TrimSuffix(op, "="), old, rhs); err != nil {
return 0, err
}
}
a.setVariable(name, v)
return v, nil
}
}
return a.ternary()
}
func (a *arith) ternary() (int64, error) {
cond, err := a.binary(0)
if err != nil || a.peekOp("?") == "" {
return cond, err
}
a.pos++
if cond == 0 {
a.skip++
}
yes, err := a.assign()
if cond == 0 {
a.skip--
}
if err != nil {
return 0, err
}
if a.peekOp(":") == "" {
return 0, a.syntaxErr()
}
a.pos++
if cond != 0 {
a.skip++
}
no, err := a.assign()
if cond != 0 {
a.skip--
}
if err != nil {
return 0, err
}
if cond != 0 {
return yes, nil
}
return no, nil
}
// binaryLevels are the binary operators from the lowest precedence on.
var binaryLevels = [][]string{
{"||"}, {"&&"}, {"|"}, {"^"}, {"&"}, {"==", "!="}, {"<", "<=", ">", ">="},
{"<<", ">>"}, {"+", "-"}, {"*", "/", "%"},
}
func (a *arith) binary(level int) (int64, error) {
if level == len(binaryLevels) {
return a.power()
}
lhs, err := a.binary(level + 1)
if err != nil {
return 0, err
}
for {
op := a.peekOp(binaryLevels[level]...)
if op == "" {
return lhs, nil
}
a.pos++
// && and || do not evaluate the right side if the result is known
short := op == "&&" && lhs == 0 || op == "||" && lhs != 0
if short {
a.skip++
}
rhs, err := a.binary(level + 1)
if short {
a.skip--
}
if err != nil {
return 0, err
}
if lhs, err = a.apply(op, lhs, rhs); err != nil {
return 0, err
}
}
}
func boolInt(b bool) int64 {
if b {
return 1
}
return 0
}
// apply applies a binary operator.
func (a *arith) apply(op string, x, y int64) (int64, error) {
switch op {
case "||":
return boolInt(x != 0 || y != 0), nil
case "&&":
return boolInt(x != 0 && y != 0), nil
case "|":
return x | y, nil
case "^":
return x ^ y, nil
case "&":
return x & y, nil
case "==":
return boolInt(x == y), nil
case "!=":
return boolInt(x != y), nil
case "<":
return boolInt(x < y), nil
case "<=":
return boolInt(x <= y), nil
case ">":
return boolInt(x > y), nil
case ">=":
return boolInt(x >= y), nil
case "<<":
return x << uint64(y&63), nil
case ">>":
return x >> uint64(y&63), nil
case "+":
return x + y, nil
case "-":
return x - y, nil
case "*":
return x * y, nil
case "/", "%":
if y == 0 {
if a.skip > 0 {
return 0, nil
}
return 0, fmt.Errorf("%s: division by 0", strings.TrimSpace(a.expr))
}
if op == "/" {
return x / y, nil
}
return x % y, nil
case "**":
if y < 0 {
if a.skip > 0 {
return 0, nil
}
return 0, fmt.Errorf("%s: exponent less than 0", strings.TrimSpace(a.expr))
}
result := int64(1)
for ; y > 0; y-- {
result *= x
}
return result, nil
}
return 0, a.syntaxErr()
}
// power parses x ** y (right associative, binds tighter than * but less
// than the unary operators).
func (a *arith) power() (int64, error) {
base, err := a.unary()
if err != nil || a.peekOp("**") == "" {
return base, err
}
a.pos++
exp, err := a.power()
if err != nil {
return 0, err
}
return a.apply("**", base, exp)
}
func (a *arith) unary() (int64, error) {
switch op := a.peekOp("+", "-", "!", "~", "++", "--"); op {
case "":
return a.postfix()
case "++", "--":
a.pos++
if a.pos >= len(a.toks) || a.toks[a.pos].kind != 'v' {
return 0, a.syntaxErr()
}
name := a.toks[a.pos].text
a.pos++
v, err := a.variable(name)
if err != nil {
return 0, err
}
if op == "++" {
v++
} else {
v--
}
a.setVariable(name, v)
return v, nil
default:
a.pos++
v, err := a.unary()
if err != nil {
return 0, err
}
switch op {
case "-":
return -v, nil
case "!":
return boolInt(v == 0), nil
case "~":
return ^v, nil
}
return v, nil
}
}
func (a *arith) postfix() (int64, error) {
if a.pos < len(a.toks) && a.toks[a.pos].kind == 'v' {
name := a.toks[a.pos].text
a.pos++
v, err := a.variable(name)
if err != nil {
return 0, err
}
if op := a.peekOp("++", "--"); op != "" {
a.pos++
n := v + 1
if op == "--" {
n = v - 1
}
a.setVariable(name, n)
}
return v, nil
}
return a.primary()
}
func (a *arith) primary() (int64, error) {
if a.pos >= len(a.toks) {
return 0, a.syntaxErr()
}
t := a.toks[a.pos]
switch {
case t.kind == 'n':
a.pos++
return t.num, nil
case t.kind == 'o' && t.text == "(":
a.pos++
v, err := a.comma()
if err != nil {
return 0, err
}
if a.peekOp(")") == "" {
return 0, a.syntaxErr()
}
a.pos++
return v, nil
}
return 0, a.syntaxErr()
}
+4 -4
View File
@@ -94,8 +94,8 @@ func wordEnd(s string, i int) int {
i = j i = j
} }
case ch == '$' && i+1 < len(s) && s[i+1] == '{': case ch == '$' && i+1 < len(s) && s[i+1] == '{':
if j := strings.IndexByte(s[i:], '}'); j >= 0 { if j := paramEnd(s, i); j >= 0 {
i += j i = j
} }
case quote == '"': case quote == '"':
if ch == '"' { if ch == '"' {
@@ -134,8 +134,8 @@ func braceScan(s string, fn func(i, depth int) bool) {
} }
continue continue
case ch == '$' && i+1 < len(s) && s[i+1] == '{': case ch == '$' && i+1 < len(s) && s[i+1] == '{':
if j := strings.IndexByte(s[i:], '}'); j >= 0 { if j := paramEnd(s, i); j >= 0 {
i += j i = j
} }
continue continue
case quote == '"': case quote == '"':
+274
View File
@@ -0,0 +1,274 @@
package main
import (
"errors"
"os"
"reflect"
"strings"
"testing"
"time"
)
func TestExec_CompoundRedirections(t *testing.T) {
resetShell(t)
for _, name := range []string{"GOSH_A", "GOSH_N"} {
os.Unsetenv(name)
defer os.Unsetenv(name)
}
tests := []struct {
input string
want string
}{
{"if true; then echo yes; fi > out", "yes\n"},
{"for x in a b; do echo $x; done > out", "a\nb\n"},
{"for x in a b; do echo $x; done >> out; echo c >> out", "a\nb\nc\n"},
{"{ echo out; echo err >&2; } > out 2>&1", "out\nerr\n"},
{"{ echo err >&2; } 2> out", "err\n"},
{"case x in x) echo case;; esac > out", "case\n"},
// The redirections apply to all commands, also nested ones
{"{ echo a; { echo b; }; } > out", "a\nb\n"},
{"{ echo a; echo b > other; echo c; } > out", "a\nc\n"},
{"f() { echo from-f; }; { f; } > out", "from-f\n"},
// Reading a file line by line in the shell itself
{"printf 'l1\\nl2 x\\n' > in; while read -r l; do echo \"<$l>\" >> out; done < in", "<l1>\n<l2 x>\n"},
{"printf 'a b\\nc d\\n' > in; while read x y; do echo $y$x >> out; done < in", "ba\ndc\n"},
{"printf '1\\n2\\n3\\n' > in; GOSH_N=0; while read l; do GOSH_N=$(expr $GOSH_N + $l); done < in; echo $GOSH_N > out", "6\n"},
// ... with the loop's variables changed in the shell
{"printf 'v\\n' > in; while read GOSH_A; do :; done < in; echo ${GOSH_A:-empty} > out", "empty\n"},
{"printf 'v\\n' > in; { read GOSH_A; } < in; echo $GOSH_A > out", "v\n"},
// Redirections are expanded
{"GOSH_A=out; { echo var; } > $GOSH_A", "var\n"},
// Files are closed and stdout is restored afterwards
{"{ echo a; } > out; echo b >> out", "a\nb\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 ioOverride != [3]*os.File{} {
t.Fatalf("%q: ioOverride not restored", tc.input)
}
}
for input, want := range map[string]string{
"{ :; } < gosh-no-such-file": "gosh-no-such-file: no such file or directory",
"{ :; } > {a,b}": "{a,b}: ambiguous redirect",
} {
if err := runInErr(t, input); err == nil || err.Error() != want {
t.Errorf("%q: err = %v, want %q", input, err, want)
}
}
}
// runInErr runs input in a temporary directory and returns its error.
func runInErr(t *testing.T, input string) error {
t.Helper()
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(t.TempDir())
return execInput(input)
}
func TestExec_CompoundPipelines(t *testing.T) {
resetShell(t)
os.Unsetenv("GOSH_A")
defer os.Unsetenv("GOSH_A")
tests := []struct {
input string
want string
}{
{"for x in 3 1 2; do echo $x; done | sort > out", "1\n2\n3\n"},
{"printf 'a b\\nc d\\n' | while read x y; do echo $y$x; done > out", "ba\ndc\n"},
{"echo hi | { read l; echo got $l; } > out", "got hi\n"},
{"if true; then echo yes; fi | tr a-z A-Z > out", "YES\n"},
{"echo x | case y in y) cat;; esac | tr x X > out", "X\n"},
{"for x in a b; do echo $x; done > /dev/null | cat > out", ""},
{"{ echo b; echo a; } | sort | head -1 > out", "a\n"},
// The parts of a pipeline run in subshells: the shell is not changed
{"echo v | while read GOSH_A; do :; done; echo ${GOSH_A:-unset} > out", "unset\n"},
// Functions and positional parameters are known in the subshell
{"f() { echo f-$1; }; set -- p; for x in 1; do f $1; done | cat > out", "f-p\n"},
// The status is the one of the last command
{"echo | while read l; do false; done; echo $? > out", "1\n"},
{"true | if false; then :; else sh -c 'exit 3'; fi; echo $? > out", "3\n"},
{"for x in 1; do sh -c 'exit 4'; done | true; echo $? > out", "0\n"},
{"! echo | while read l; do false; done; echo $? > out", "0\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_Subshells(t *testing.T) {
resetShell(t)
os.Unsetenv("GOSH_A")
defer os.Unsetenv("GOSH_A")
tests := []struct {
input string
want string
}{
{"(echo a; echo b) > out", "a\nb\n"},
{"(echo sub) | tr a-z A-Z > out", "SUB\n"},
// Changes stay in the subshell
{"mkdir d; (cd d; pwd > ../in); cd d 2>/dev/null && echo still-outside > out", "still-outside\n"},
{"GOSH_A=1; (GOSH_A=2; echo $GOSH_A > out); echo $GOSH_A >> out", "2\n1\n"},
{"(exit 3); echo $? > out", "3\n"},
{"(exit 3) || echo failed > out", "failed\n"},
{"(f() { echo in-sub; }; f) > out; f 2>> out || echo no-f >> out", "in-sub\nno-f\n"},
{"(echo a; (echo b)) > out", "a\nb\n"},
{"( echo spaced ) > out", "spaced\n"},
{"(\n echo multi\n echo line\n) > out", "multi\nline\n"},
// The subshell knows the functions and positional parameters
{"f() { echo f-$1; }; set -- p; (f $1) > out", "f-p\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_CompoundBackground(t *testing.T) {
resetShell(t)
tests := []struct {
input string
want string
}{
{"for x in a b; do echo $x; done > out & wait", "a\nb\n"},
{"true && echo and > out & wait", "and\n"},
{"false || echo or > out & wait", "or\n"},
{"(echo sub > out) & wait", "sub\n"},
{"{ echo group; } > out & wait", "group\n"},
{"! false && echo negated > out & wait", "negated\n"},
{"for x in 3 1; do echo $x; done | sort > out & wait", "1\n3\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)
}
}
// The shell does not wait for a background loop
start := time.Now()
runIn(t, "for x in 1; do sleep 1; done & echo $? > out")
if lastStatus != 0 || time.Since(start) > 500*time.Millisecond {
t.Errorf("background loop: $? = %d after %v", lastStatus, time.Since(start))
}
execInput("wait")
}
func TestExtractHeredocs(t *testing.T) {
tests := []struct {
input string
want string
}{
{"cat <<EOF\nhello\nEOF\n", "cat <<<\"hello\"\n"},
{"cat <<EOF\nhello\nEOF", "cat <<<\"hello\"\n"},
{"cat << EOF\na\nb\nEOF\necho x\n", "cat <<<\"a\nb\"\necho x\n"},
{"cat <<'EOF'\n$x \"q\" it's\nEOF\n", "cat <<<'$x \"q\" it'\\''s'\n"},
{"cat <<\"EOF\"\n$x\nEOF\n", "cat <<<'$x'\n"},
{"cat <<\\EOF\n$x\nEOF\n", "cat <<<'$x'\n"},
{"cat <<E\"O\"F\n$x\nEOF\n", "cat <<<'$x'\n"},
{"cat <<EOF\n\"q\" \\\" \\$x \\a\nEOF\n", "cat <<<\"\\\"q\\\" \\\\\\\" \\$x \\\\a\"\n"},
{"cat <<EOF\n$(echo \"a\")\nEOF\n", "cat <<<\"$(echo \"a\")\"\n"},
{"cat <<-EOF\n\t\ttabs\n\tEOF\n", "cat <<<\"tabs\"\n"},
{"cat <<EOF\nEOF\n", "cat </dev/null\n"},
{"cat <<A; cat <<B\na\nA\nb\nB\n", "cat <<<\"a\"; cat <<<\"b\"\n"},
{"cat <<EOF | sort\nb\nEOF\n", "cat <<<\"b\" | sort\n"},
// The delimiter must be the whole line
{"cat <<EOF\n EOF\nEOFX\nEOF\n", "cat <<<\" EOF\nEOFX\"\n"},
// Not here-documents
{"echo '<<EOF'\n", "echo '<<EOF'\n"},
{"echo \"<<EOF\"\n", "echo \"<<EOF\"\n"},
{"echo \\<<x\n", "echo \\<<x\n"},
{"echo # <<EOF\n", "echo # <<EOF\n"},
{"cat <<<word\n", "cat <<<word\n"},
{"echo $(echo a <<<b)\n", "echo $(echo a <<<b)\n"},
}
for _, tc := range tests {
got, err := extractHeredocs(tc.input)
if err != nil {
t.Errorf("%q: %v", tc.input, err)
continue
}
if got != tc.want {
t.Errorf("%q:\n got %q\nwant %q", tc.input, got, tc.want)
}
}
for _, input := range []string{"cat <<EOF", "cat <<EOF\n", "cat <<EOF\nbody\n", "cat <<'EOF\n"} {
if _, err := extractHeredocs(input); !errors.Is(err, errIncomplete) {
t.Errorf("%q: err = %v, want incomplete", input, err)
}
}
for _, input := range []string{"cat <<\n", "cat << ;\n"} {
if _, err := extractHeredocs(input); err == nil || errors.Is(err, errIncomplete) {
t.Errorf("%q: err = %v, want syntax error", input, err)
}
}
}
func TestExec_Heredocs(t *testing.T) {
resetShell(t)
t.Setenv("GOSH_A", "value")
tests := []struct {
input string
want string
}{
{"cat <<EOF > out\nhello\nworld\nEOF", "hello\nworld\n"},
{"cat <<EOF > out\n$GOSH_A ${GOSH_A} $(echo sub) \\$GOSH_A\nEOF", "value value sub $GOSH_A\n"},
{"cat <<'EOF' > out\n$GOSH_A $(echo sub)\nEOF", "$GOSH_A $(echo sub)\n"},
{"cat <<EOF > out\n\"quotes\" 'single' back\\slash\nEOF", "\"quotes\" 'single' back\\slash\n"},
{"cat <<EOF > out\nline \\\ncontinued\nEOF", "line continued\n"},
{"cat <<'EOF' > out\nline \\\nkept\nEOF", "line \\\nkept\n"},
{"cat <<-EOF > out\n\tindented\n\t\tmore\n\tEOF", "indented\nmore\n"},
{"cat <<EOF > out\n *.go {a,b} ~\nEOF", " *.go {a,b} ~\n"},
{"cat <<EOF > out\nEOF", ""},
{"cat <<EOF | tr a-z A-Z > out\npiped\nEOF", "PIPED\n"},
{"cat > out <<EOF\nredirect first\nEOF", "redirect first\n"},
{"cat <<A > out; cat <<B >> out\na\nA\nb\nB", "a\nb\n"},
{"while read l; do echo \"<$l>\" >> out; done <<EOF\none\ntwo\nEOF", "<one>\n<two>\n"},
{"f() { cat; }; f <<EOF > out\nto f\nEOF", "to f\n"},
{"set -- a 'b c'; cat <<EOF > out\n$# $1 $@\nEOF", "2 a a b c\n"},
{"for x in 1 2; do\n cat <<EOF >> out\nx=$x\nEOF\ndone", "x=1\nx=2\n"},
{"echo \"$(cat <<EOF\nin subst\nEOF\n)\" > out", "in subst\n"},
// Here-strings
{"cat <<<word > out", "word\n"},
{"cat <<< \"two words $GOSH_A\" > out", "two words value\n"},
{"cat <<<'*.go' > out", "*.go\n"},
{"read x y <<<'a b c'; echo $y > out", "b c\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)
}
}
}
// TestRunScript_Heredoc checks that a script reads a here-document and
// continues after it, with correct line numbers in error messages.
func TestRunScript_Heredoc(t *testing.T) {
resetShell(t)
got := runIn(t, `printf 'cat <<EOF > out\nbody\nEOF\necho after >> out\n' > s; . ./s`)
if got != "body\nafter\n" {
t.Errorf("script with here-document: out = %q", got)
}
got = runIn(t, `printf 'cat <<EOF\nbody\n' > s; . ./s 2> out`)
if !strings.Contains(got, "s:1: syntax error: unexpected end of file") {
t.Errorf("unterminated here-document: %q", got)
}
}
// TestTokenize_UTF8 checks that non-ASCII characters survive quoting and
// escaping (each byte used to be converted to a rune of its own).
func TestTokenize_UTF8(t *testing.T) {
got, err := splitWords(`echo 'wörtlich' "für" über \ä 'a'"ö"`)
want := []string{"echo", "wörtlich", "für", "über", "ä", "aö"}
if err != nil || !reflect.DeepEqual(got, want) {
t.Errorf("got %q, %v, want %q", got, err, want)
}
if got := runIn(t, "cat <<'EOF' > out\nGrüße\nEOF"); got != "Grüße\n" {
t.Errorf("here-document: %q", got)
}
}
+16
View File
@@ -0,0 +1,16 @@
package main
import (
"os"
"golang.org/x/sys/unix"
)
// dupTo makes file descriptor fd refer to f (dup2; linux/arm64 only has
// dup3).
func dupTo(f *os.File, fd int) error {
if int(f.Fd()) == fd {
return nil // dup3 fails for the same descriptor
}
return unix.Dup3(int(f.Fd()), fd, 0)
}
+14
View File
@@ -0,0 +1,14 @@
//go:build !linux
package main
import (
"os"
"golang.org/x/sys/unix"
)
// dupTo makes file descriptor fd refer to f.
func dupTo(f *os.File, fd int) error {
return unix.Dup2(int(f.Fd()), fd)
}
+458
View File
@@ -0,0 +1,458 @@
package main
// Parameter expansion ${...}: ${name}, ${#name} and the operators
//
// ${name:-word} ${name-word} word if name is unset (or empty with :)
// ${name:=word} ${name=word} ... and assign it to name
// ${name:+word} ${name+word} word if name is set (and not empty with :)
// ${name:?word} ${name?word} error if name is unset (or empty)
// ${name#pat} ${name##pat} remove the shortest/longest matching prefix
// ${name%pat} ${name%%pat} remove the shortest/longest matching suffix
// ${name/pat/rep} replace the first (//: every) match
// ${name/#pat/rep} ... at the start, /% at the end
// ${name:offset[:length]} substring (arithmetic expressions)
//
// The word is expanded (variables, $(...), $((...)), quotes, ~), but not
// split. In patterns, quoted characters and values of variables match
// literally, like everywhere in gosh.
import (
"errors"
"fmt"
"os"
"path"
"strconv"
"strings"
"unicode/utf8"
)
// paramEnd returns the index of the } that ends the ${ at s[i], or -1.
// Nested ${...}, $(...), quotes and escapes are skipped.
func paramEnd(s string, i int) int {
var quote byte
for j := i + 2; j < len(s); j++ {
ch := s[j]
switch {
case quote == '\'':
if ch == '\'' {
quote = 0
}
case ch == '\\':
j++
case isSubstStart(s, j):
end := substEnd(s, j)
if end < 0 {
return -1
}
j = end
case ch == '$' && j+1 < len(s) && s[j+1] == '{':
end := paramEnd(s, j)
if end < 0 {
return -1
}
j = end
case quote == '"':
if ch == '"' {
quote = 0
}
case ch == '"' || ch == '\'':
quote = ch
case ch == '}':
return j
}
}
return -1
}
// paramNameLen returns the length of the parameter name at the start of
// expr: a variable name, a number or a special parameter.
func paramNameLen(expr string) int {
switch {
case expr == "":
return 0
case isSpecialParam(expr[:1]):
return 1
case expr[0] >= '0' && expr[0] <= '9':
n := 1
for n < len(expr) && expr[n] >= '0' && expr[n] <= '9' {
n++
}
return n
}
n := 0
for n < len(expr) && isVarNameByte(expr[n], n == 0) {
n++
}
return n
}
// expandParam expands the inside of ${...}.
func expandParam(expr string) (string, error) {
bad := func() (string, error) { return "", fmt.Errorf("bad substitution: ${%s}", expr) }
unbound := func(name string) (string, error) { return "", fmt.Errorf("%s: unbound variable", name) }
// ${#name}: the length in characters (${#} alone is $#)
if len(expr) > 1 && expr[0] == '#' {
name := expr[1:]
if paramNameLen(name) != len(name) {
return bad()
}
value, ok := lookupParam(name)
if !ok {
return unbound(name)
}
return strconv.Itoa(utf8.RuneCountInString(value)), nil
}
n := paramNameLen(expr)
if n == 0 {
return bad()
}
name, op := expr[:n], expr[n:]
if op != "" && strings.IndexByte(":-=+?#%/", op[0]) < 0 {
return bad()
}
value, set := lookupParam(name)
if op == "" {
if !set {
return unbound(name)
}
return value, nil
}
// ${name:-word} and friends
colon := strings.HasPrefix(op, ":")
if kind := strings.TrimPrefix(op, ":"); kind != "" && strings.IndexByte("-=+?", kind[0]) >= 0 {
word := kind[1:]
missing := !set || colon && value == ""
switch kind[0] {
case '-':
if missing {
return expandWordText(word)
}
case '=':
if missing {
if !isVarName(name) {
return "", fmt.Errorf("$%s: cannot assign in this way", name)
}
w, err := expandWordText(word)
if err != nil {
return "", err
}
os.Setenv(name, w)
return w, nil
}
case '+':
if missing {
return "", nil
}
return expandWordText(word)
case '?':
if missing {
msg, err := expandWordText(word)
if err != nil {
return "", err
}
if msg == "" {
msg = "parameter null or not set"
}
return "", fmt.Errorf("%s: %s", name, msg)
}
}
return value, nil
}
if !set {
return unbound(name)
}
switch {
case colon:
return substring(value, op[1:])
case strings.HasPrefix(op, "#"), strings.HasPrefix(op, "%"):
longest := strings.HasPrefix(op[1:], op[:1])
word := op[1:]
if longest {
word = op[2:]
}
pattern, err := expandPattern(word)
if err != nil {
return "", err
}
if op[0] == '#' {
return removePrefix(value, pattern, longest), nil
}
return removeSuffix(value, pattern, longest), nil
case strings.HasPrefix(op, "/"):
spec := op[1:]
all := strings.HasPrefix(spec, "/")
var anchor byte
switch {
case all:
spec = spec[1:]
case strings.HasPrefix(spec, "#"), strings.HasPrefix(spec, "%"):
anchor, spec = spec[0], spec[1:]
}
patText, repText := spec, ""
if i := unquotedSlash(spec); i >= 0 {
patText, repText = spec[:i], spec[i+1:]
}
pattern, err := expandPattern(patText)
if err != nil {
return "", err
}
rep, err := expandWordText(repText)
if err != nil {
return "", err
}
return replacePattern(value, pattern, rep, all, anchor), nil
}
return bad()
}
// unquotedSlash returns the index of the first / in s that is not quoted,
// escaped or inside $(...) or ${...}, or -1.
func unquotedSlash(s string) int {
var quote byte
for i := 0; i < len(s); i++ {
ch := s[i]
switch {
case quote == '\'':
if ch == '\'' {
quote = 0
}
case ch == '\\':
i++
case isSubstStart(s, i):
if end := substEnd(s, i); end >= 0 {
i = end
}
case ch == '$' && i+1 < len(s) && s[i+1] == '{':
if end := paramEnd(s, i); end >= 0 {
i = end
}
case quote == '"':
if ch == '"' {
quote = 0
}
case ch == '"' || ch == '\'':
quote = ch
case ch == '/':
return i
}
}
return -1
}
// substring implements ${name:offset[:length]}; offset and length are
// arithmetic expressions, a negative offset counts from the end.
func substring(value, spec string) (string, error) {
offText, lenText, hasLen := strings.Cut(spec, ":")
runes := []rune(value)
off, err := evalArithText(offText)
if err != nil {
return "", err
}
if off < 0 {
off += int64(len(runes))
}
if off < 0 || off > int64(len(runes)) {
return "", nil
}
end := int64(len(runes))
if hasLen {
n, err := evalArithText(lenText)
if err != nil {
return "", err
}
if n < 0 {
end += n // a negative length counts from the end
} else {
end = min(off+n, end)
}
}
if end < off {
return "", errors.New(strings.TrimSpace(lenText) + ": substring expression < 0")
}
return string(runes[off:end]), nil
}
// expandWordText expands the word of a parameter expansion (or of $((...)))
// to a single string: quotes are removed, $..., $(...) and $((...)) are
// expanded and a leading ~ is replaced by the home directory.
func expandWordText(word string) (string, error) {
value, _, err := expandWordParts(word)
return value, err
}
// expandPattern expands a word like expandWordText, but returns it as a glob
// pattern: quoted characters and expanded values are escaped.
func expandPattern(word string) (string, error) {
_, pattern, err := expandWordParts(word)
return pattern, err
}
func expandWordParts(word string) (value, pattern string, err error) {
var v, p strings.Builder
literal := func(s string) {
v.WriteString(s)
p.WriteString(escapeGlob(s))
}
var quote byte
for i := 0; i < len(word); i++ {
ch := word[i]
switch {
case quote == '\'':
if ch == '\'' {
quote = 0
} else {
literal(word[i : i+1])
}
case ch == '\\' && i+1 < len(word):
i++
if quote == '"' && strings.IndexByte("$\"\\`", word[i]) < 0 {
literal("\\")
}
literal(word[i : i+1])
case ch == '$':
value, n, err := expandDollar(word[i:])
if err != nil {
return "", "", err
}
literal(value)
i += n - 1
case quote == '"':
if ch == '"' {
quote = 0
} else {
literal(word[i : i+1])
}
case ch == '"' || ch == '\'':
quote = ch
case ch == '~' && i == 0 && (len(word) == 1 || word[1] == '/'):
if home := getHomeDir(); home != "" {
literal(home)
} else {
literal("~")
}
default:
v.WriteByte(ch)
if ch == '!' && strings.HasSuffix(p.String(), "[") && !strings.HasSuffix(p.String(), "\\[") {
p.WriteByte('^') // POSIX [!...] is [^...] in Go
} else {
p.WriteByte(ch)
}
}
}
if quote != 0 {
return "", "", fmt.Errorf("unterminated %c quote", quote)
}
return v.String(), p.String(), nil
}
// expandDollar expands the $ expression at the start of s ($name, ${...},
// $(...) or $((...))) and returns its value and length.
func expandDollar(s string) (string, int, error) {
if end := arithEnd(s, 0); end >= 0 {
value, err := evalArithText(s[3 : end-1])
if err != nil {
return "", 0, err
}
return strconv.FormatInt(value, 10), end + 1, nil
}
if isSubstStart(s, 0) {
end := substEnd(s, 0)
if end < 0 {
return "", 0, errors.New("missing ) in command substitution")
}
out, err := commandSubst(s[2:end])
return out, end + 1, err
}
return expandVar(s)
}
// globMatch reports whether s matches the glob pattern (in which escaped
// characters match literally). Unlike for file names, * and ? also match a
// /. An invalid pattern matches only itself.
func globMatch(pattern, s string) bool {
// path.Match does not let * match a /; NUL can be in neither string
toNUL := strings.NewReplacer("/", "\x00")
ok, err := path.Match(toNUL.Replace(pattern), toNUL.Replace(s))
if err != nil {
return unescapeGlob(pattern) == s
}
return ok
}
// runeBounds returns the indexes in s where characters start, and len(s).
func runeBounds(s string) []int {
bounds := make([]int, 0, len(s)+1)
for i := range s {
bounds = append(bounds, i)
}
return append(bounds, len(s))
}
func removePrefix(value, pattern string, longest bool) string {
bounds := runeBounds(value)
for k := range bounds {
i := bounds[k]
if longest {
i = bounds[len(bounds)-1-k]
}
if globMatch(pattern, value[:i]) {
return value[i:]
}
}
return value
}
func removeSuffix(value, pattern string, longest bool) string {
bounds := runeBounds(value)
for k := range bounds {
i := bounds[len(bounds)-1-k]
if longest {
i = bounds[k]
}
if globMatch(pattern, value[i:]) {
return value[:i]
}
}
return value
}
// replacePattern replaces the longest match of pattern at the first (or
// every) position by rep. anchor '#' only matches at the start, '%' only at
// the end. Empty matches are not replaced.
func replacePattern(value, pattern, rep string, all bool, anchor byte) string {
if pattern == "" {
return value
}
bounds := runeBounds(value)
var b strings.Builder
for k := 0; k < len(bounds)-1; k++ {
i := bounds[k]
match := -1
if anchor != '#' || i == 0 {
for m := len(bounds) - 1; m > k; m-- {
if anchor == '%' && m != len(bounds)-1 {
break
}
if globMatch(pattern, value[i:bounds[m]]) {
match = m
break
}
}
}
if match < 0 {
b.WriteString(value[i:bounds[k+1]])
continue
}
b.WriteString(rep)
if !all {
b.WriteString(value[bounds[match]:])
return b.String()
}
k = match - 1
}
return b.String()
}
+375
View File
@@ -0,0 +1,375 @@
package main
import (
"os"
"os/exec"
"strings"
"testing"
)
func TestExpandParam(t *testing.T) {
resetShell(t)
t.Setenv("P", "/usr/local/lib/libfoo.so.1")
t.Setenv("F", "archive.tar.gz")
t.Setenv("H", "hello")
t.Setenv("E", "")
t.Setenv("U8", "Grüße")
t.Setenv("SP", "a b c")
t.Setenv("GLOB", "*.go")
os.Unsetenv("UNSET")
positional = []string{"one", "two"}
tests := []struct {
input string
want string
}{
{"${#P}", "26"},
{"${#U8}", "5"},
{"${#1}", "3"},
{"${#}", "2"},
{"${P#*/}", "usr/local/lib/libfoo.so.1"},
{"${P##*/}", "libfoo.so.1"},
{"${P%/*}", "/usr/local/lib"},
{"${P%%.*}", "/usr/local/lib/libfoo"},
{"${F%.gz}", "archive.tar"},
{"${F#*.}", "tar.gz"},
{"${F##*.}", "gz"},
{"${F%.zip}", "archive.tar.gz"},
{"${U8%e}", "Grüß"},
{"${U8#?}", "rüße"},
{"${H/l/L}", "heLlo"},
{"${H//l/L}", "heLLo"},
{"${H//l}", "heo"},
{"${H/#h/H}", "Hello"},
{"${H/#e/E}", "hello"},
{"${H/%o/O}", "hellO"},
{"${H/l*/X}", "heX"},
{"${SP// /_}", "a_b__c"},
{"${H/[aeiou]/_}", "h_llo"},
{"${H:1}", "ello"},
{"${H:1:3}", "ell"},
{"${H: -2}", "lo"},
{"${H:0:-1}", "hell"},
{"${H:1+1:2*1}", "ll"},
{"${H:10}", ""},
{"${U8:1:3}", "rüß"},
{"${UNSET-dflt}", "dflt"},
{"${UNSET:-dflt}", "dflt"},
{"${E-dflt}", ""},
{"${E:-dflt}", "dflt"},
{"${H:-dflt}", "hello"},
{"${UNSET+alt}", ""},
{"${E+alt}", "alt"},
{"${E:+alt}", ""},
{"${H:+alt}", "alt"},
{"${UNSET:-${H}}", "hello"},
{"${UNSET:-${UNSET:-deep}}", "deep"},
{`${UNSET:-"a b"}`, "a b"},
{"${UNSET:-a b}", "a b"},
{"${UNSET:-$(echo sub)}", "sub"},
{"${UNSET:-$((1+2))}", "3"},
{"${UNSET:-}", ""},
{"${2:-x}", "two"},
{"${3:-three}", "three"},
// Quoted characters and values of variables are no patterns
{"${GLOB#*.}", "go"},
{`${GLOB#"*"}`, ".go"},
{"${GLOB%$GLOB}", ""},
{"${F%$GLOB}", "archive.tar.gz"},
{`${P//\//:}`, ":usr:local:lib:libfoo.so.1"},
}
for _, tc := range tests {
got, err := splitWords(`"` + tc.input + `"`)
if err != nil {
t.Errorf("%s: %v", tc.input, err)
continue
}
if len(got) != 1 || got[0] != tc.want {
t.Errorf("%s = %q, want %q", tc.input, got, tc.want)
}
}
// := assigns
defer os.Unsetenv("GOSH_ASSIGN")
os.Unsetenv("GOSH_ASSIGN")
if got, _ := splitWords("${GOSH_ASSIGN:=set} $GOSH_ASSIGN"); strings.Join(got, " ") != "set set" {
t.Errorf(":= gives %q", got)
}
for input, want := range map[string]string{
"${UNSET}": "UNSET: unbound variable",
"${#UNSET}": "UNSET: unbound variable",
"${UNSET%x}": "UNSET: unbound variable",
"${UNSET:?is required}": "UNSET: is required",
"${E:?}": "E: parameter null or not set",
"${1x}": "bad substitution: ${1x}",
"${H!}": "bad substitution: ${H!}",
"${3:=x}": "$3: cannot assign in this way",
"${H:3:-5}": "-5: substring expression < 0",
"${H": "missing } in variable reference",
} {
if _, err := splitWords(input); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%s: err = %v, want %q", input, err, want)
}
}
}
func TestArith(t *testing.T) {
t.Setenv("N", "5")
t.Setenv("EXPR", "N*2")
t.Setenv("EMPTY", "")
os.Unsetenv("UNSET")
tests := []struct {
expr string
want int64
}{
{"1+2*3", 7},
{"(1+2)*3", 9},
{"7/2", 3},
{"-7/2", -3},
{"7%3", 1},
{"2**10", 1024},
{"-2**2", 4},
{"2**3**2", 512},
{"1<<4", 16},
{"256>>4", 16},
{"~0", -1},
{"!0", 1},
{"!5", 0},
{"3 > 2 && 2 > 1", 1},
{"0 || 0", 0},
{"5 & 3", 1},
{"5 | 3", 7},
{"5 ^ 3", 6},
{"1 ? 10 : 20", 10},
{"0 ? 10 : 20", 20},
{"0x1F", 31},
{"010", 8},
{"N", 5},
{"N * N", 25},
{"EXPR + 1", 11},
{"EMPTY + 1", 1},
{"1, 2, 3", 3},
{"", 0},
{" 42 ", 42},
// Short-circuit: no error and no assignment on the other side
{"0 && 1/0", 0},
{"1 || UNSET", 1},
{"1 ? 2 : 1/0", 2},
}
for _, tc := range tests {
got, err := evalArith(tc.expr, 0)
if err != nil || got != tc.want {
t.Errorf("$((%s)) = %d, %v, want %d", tc.expr, got, err, tc.want)
}
}
for expr, want := range map[string]string{
"1/0": "division by 0",
"1%0": "division by 0",
"2**-1": "exponent less than 0",
"UNSET+1": "UNSET: unbound variable",
"1 +": "arithmetic syntax error",
"(1": "arithmetic syntax error",
"1 2": "arithmetic syntax error",
"1 @ 2": "invalid arithmetic operator",
"09": "value too great for base",
"5 = 3": "arithmetic syntax error",
} {
if _, err := evalArith(expr, 0); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("$((%s)): err = %v, want %q", expr, err, want)
}
}
}
func TestExec_Arith(t *testing.T) {
resetShell(t)
for _, name := range []string{"I", "J"} {
os.Unsetenv(name)
defer os.Unsetenv(name)
}
tests := []struct {
input string
want string
}{
{"echo $((1 + 2)) > out", "3\n"},
{"I=5; echo $((I + 1)) $(($I * 2)) > out", "6 10\n"},
{"I=5; echo $((I++)) $I $((++I)) $((I += 10)) $I > out", "5 6 7 17 17\n"},
{"I=1; : $((I = I * 3)); echo $I > out", "3\n"},
{"I=0; while [ $I -lt 3 ]; do I=$((I + 1)); done; echo $I > out", "3\n"},
{"echo $(( $(echo 3) + ${J:-4} )) > out", "7\n"},
{`echo "$((2 * 21))" > out`, "42\n"},
{"echo x$((1+1))y > out", "x2y\n"},
{"set -- 3 4; echo $(($1 * $2)) $(($# + 1)) > out", "12 3\n"},
// $( ( ... ) ) is a command substitution of a subshell
{"echo $( (echo sub) ) > out", "sub\n"},
{"cat <<EOF > out\n$((6 * 7))\nEOF", "42\n"},
{"case $((1 + 1)) in 2) echo two > out;; esac", "two\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)
}
}
}
// goshOutput runs gosh -c script in a new process and returns its combined
// output and exit status.
func goshOutput(t *testing.T, script string, args ...string) (string, int) {
t.Helper()
cmd := exec.Command(subshellPath, append([]string{"-c", script, "gosh"}, args...)...)
cmd.Dir = t.TempDir()
out, err := cmd.CombinedOutput()
return string(out), exitStatus(err)
}
func TestShell_Errexit(t *testing.T) {
tests := []struct {
script string
want string
status int
}{
{"set -e; echo a; false; echo never", "a\n", 1},
{"set -e; sh -c 'exit 3'; echo never", "", 3},
{"set -e; gosh-no-such-cmd; echo never", "gosh: gosh-no-such-cmd: command not found\n", 127},
// Not for conditions, negated commands and && || lists (except the last)
{"set -e; if false; then :; fi; while false; do :; done; until true; do :; done; echo ok", "ok\n", 0},
{"set -e; false || echo or; false && echo no; ! true; echo ok", "or\nok\n", 0},
{"set -e; true && false; echo never", "", 1},
{"set -e; false || false; echo never", "", 1},
{"set -e; f() { false; echo never; }; if f; then :; fi; echo cond-ok", "never\ncond-ok\n", 0},
// Also in functions, loops, subshells, pipelines and $(...)
{"set -e; f() { false; echo never; }; f; echo never", "", 1},
{"set -e; for x in 1; do false; echo never; done", "", 1},
{"set -e; (false; echo never); echo never", "", 1},
{"set -e; (exit 4); echo never", "", 4},
{"set -e; true | false; echo never", "", 1},
{"set -e; x=$(false); echo never", "", 1},
{"set -e; set +e; false; echo off", "off\n", 0},
// gosh -e
{"false; echo never", "", 1},
}
for i, tc := range tests {
script := tc.script
var out string
var status int
if i == len(tests)-1 {
cmd := exec.Command(subshellPath, "-e", "-c", script)
b, err := cmd.CombinedOutput()
out, status = string(b), exitStatus(err)
} else {
out, status = goshOutput(t, script)
}
if out != tc.want || status != tc.status {
t.Errorf("%q: %q, status %d, want %q, %d", tc.script, out, status, tc.want, tc.status)
}
}
}
func TestShell_Options(t *testing.T) {
tests := []struct {
script string
want string
}{
{"set -x; x=1; echo \"a b\" $x", "+ x=1\n+ echo 'a b' 1\na b 1\n"},
{"set -x; f() { echo f; }; f", "+ f\n+ echo f\nf\n"},
// Each command once, also a function in a pipeline (in any order)
{"f() { echo f; }; (set -x; f | cat) 2>&1 | sort", "+ cat\n+ echo f\n+ f\nf\n"},
{"set -x; set +x; echo quiet", "+ set +x\nquiet\n"},
{"false | true; echo $?; set -o pipefail; false | true; echo $?; sh -c 'exit 3' | false | true; echo $?", "0\n1\n1\n"},
{"set -o pipefail; true | true; echo $?", "0\n"},
{"set -euo pipefail; set -o", "errexit on\nnounset on\npipefail on\nxtrace off\n"},
{"set -e; set +o | grep errexit", "set -o errexit\n"},
{"set -- a b; set -e x y z; echo $# $1", "3 x\n"},
{"set -e; (set -o | grep errexit)", "errexit on\n"},
{"set +u", "gosh: set: +u: unset variables are always an error in gosh\n"},
{"set -k", "gosh: set: -k: invalid option\n"},
}
for _, tc := range tests {
if out, _ := goshOutput(t, tc.script); out != tc.want {
t.Errorf("%q: %q, want %q", tc.script, out, tc.want)
}
}
}
func TestShell_EvalExec(t *testing.T) {
tests := []struct {
script string
want string
status int
}{
{`cmd="echo a; echo b"; eval "$cmd"`, "a\nb\n", 0},
{`eval x=5; echo $x`, "5\n", 0},
{`x=y; y=deep; eval echo \$$x`, "deep\n", 0},
{`eval "f() { echo from-eval; }"; f`, "from-eval\n", 0},
{`eval "echo piped" | tr a-z A-Z`, "PIPED\n", 0},
{`eval 'exit 3'; echo never`, "", 3},
{`eval; echo empty`, "empty\n", 0},
{`eval "if"`, "gosh: eval: syntax error: unexpected end of input\n", 1},
{`for i in 1 2; do eval "v$i=$i"; done; echo $v1$v2`, "12\n", 0},
{`exec echo replaced; echo never`, "replaced\n", 0},
{`X=1 exec sh -c 'echo $X'`, "1\n", 0},
{`exec > out; echo to-file; exec >&2; cat out`, "to-file\n", 0},
{`exec 2> err; ls /gosh-no-such-dir; exec 2>&1; grep -c gosh-no-such-dir err`, "1\n", 0},
{`printf 'l1\nl2\n' > in; exec < in; read a; read b; echo $b $a`, "l2 l1\n", 0},
{`exec gosh-no-such-cmd; echo never`, "gosh: gosh-no-such-cmd: command not found\n", 127},
{`exec echo a | tr a A`, "A\n", 0},
}
for _, tc := range tests {
if out, status := goshOutput(t, tc.script); out != tc.want || status != tc.status {
t.Errorf("%q: %q, status %d, want %q, %d", tc.script, out, status, tc.want, tc.status)
}
}
}
func TestShell_Trap(t *testing.T) {
tests := []struct {
script string
want string
status int
}{
{`trap "echo bye" EXIT; echo running`, "running\nbye\n", 0},
{`trap 'echo "bye $?"' EXIT; exit 3`, "bye 3\n", 3},
{`trap 'echo "bye $?"' EXIT; false`, "bye 1\n", 1},
{`trap 'echo bye; exit 5' EXIT; true`, "bye\n", 5},
{`trap 'echo exit-trap' 0; f() { exit 2; }; f`, "exit-trap\n", 2},
{`trap 'echo set-e' EXIT; set -e; false; echo never`, "set-e\n", 1},
{`trap "echo bye" EXIT; trap - EXIT; echo no-trap`, "no-trap\n", 0},
{`trap 'echo got USR1' USR1; kill -USR1 $$; sleep 0.2; echo after`, "got USR1\nafter\n", 0},
// The trap runs after the current command (the signal arrives
// asynchronously, hence the sleep)
{`trap 'echo got $?' SIGUSR2; false; kill -USR2 $$; sleep 0.2; echo status $?`, "got 0\nstatus 0\n", 0},
{`trap '' USR1; kill -USR1 $$; echo ignored`, "ignored\n", 0},
{`trap '' USR1; sh -c 'kill -USR1 $$; echo child-ignores'`, "child-ignores\n", 0},
{`trap 'echo t' INT TERM; trap - INT; trap`, "trap -- 'echo t' TERM\n", 0},
{`trap 'echo t' INT; trap -p INT | cat`, "trap -- 'echo t' INT\n", 0},
{`trap 'echo cleanup' EXIT; kill -TERM $$; echo never`, "cleanup\n", 128 + 15},
{`trap 'echo term; exit 9' TERM; kill $$; echo never`, "term\n", 9},
{`trap 'echo x' BOGUS`, "gosh: trap: BOGUS: invalid signal specification\n", 1},
{`trap -l | grep -c TERM`, "1\n", 0},
// Traps are not inherited by subshells, ignored signals are
{`trap 'echo parent' EXIT; (echo sub)`, "sub\nparent\n", 0},
}
for _, tc := range tests {
if out, status := goshOutput(t, tc.script); out != tc.want || status != tc.status {
t.Errorf("%q: %q, status %d, want %q, %d", tc.script, out, status, tc.want, tc.status)
}
}
}
func TestShell_LastBackgroundPid(t *testing.T) {
tests := []struct {
script string
want string
}{
{`sleep 0.1 & pid=$!; case $pid in *[!0-9]*) echo bad;; *) echo pid;; esac; wait $pid; echo $?`, "pid\n0\n"},
{`sh -c 'exit 7' & wait $!; echo $?`, "7\n"},
{`echo ${!:-none}`, "none\n"},
{`sleep 0.1 & sleep 0.2 & first=$!; wait %1; echo $?; wait $first; echo $?`, "0\n0\n"},
}
for _, tc := range tests {
if out, _ := goshOutput(t, tc.script); out != tc.want {
t.Errorf("%q: %q, want %q", tc.script, out, tc.want)
}
}
}
+240
View File
@@ -0,0 +1,240 @@
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
// lastBackgroundPid is $!: the process ID of the last background job
// (of its last process).
lastBackgroundPid int
// 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 == "!":
if lastBackgroundPid == 0 {
return "", false
}
return strconv.Itoa(lastBackgroundPid), 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
}
+367
View File
@@ -0,0 +1,367 @@
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"
errexit, xtrace, pipefail = false, false, false
for name := range traps {
setTrap(name, "-")
}
})
}
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"`, "<x>\n<y z>\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"}, "", "<x y>\n<z>\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)
}
}
+268
View File
@@ -0,0 +1,268 @@
package main
// Here-documents: cmd <<WORD, followed by lines up to a line WORD, is the
// same as a here-string cmd <<<"lines" (without the last newline, which the
// here-string adds). extractHeredocs rewrites the source that way before it
// is parsed, so that the rest of gosh (and subshells, which get the source
// text) only has to know here-strings.
import (
"strings"
)
// hereDoc is a here-document whose body has not been read yet.
type hereDoc struct {
seg int // the index of its placeholder in the output segments
delim string
quoted bool // the delimiter was quoted: the body is not expanded
stripTabs bool // <<-: leading tabs are removed from the lines
}
// extractHeredocs replaces every here-document operator in src by a
// here-string with its body and removes the body and delimiter lines. The
// body starts after the end of the line with the operator. If the input
// ends before the delimiter line, it returns errIncomplete (an interactive
// shell then reads more lines).
//
// The body is expanded like "..." if the delimiter is unquoted ($VAR, $(...),
// \ before $ ` \ and newline), and taken literally if any part of the
// delimiter is quoted ('EOF', "EOF", \EOF).
func extractHeredocs(src string) (string, error) {
if !strings.Contains(src, "<<") {
return src, nil
}
var (
segs []string
cur strings.Builder
pending []hereDoc
wordStart = true
)
copyTo := func(i, end int) int {
if end < 0 || end > len(src) {
end = len(src)
}
cur.WriteString(src[i:end])
return end
}
for i := 0; i < len(src); {
ch := src[i]
switch {
case ch == '\\':
i = copyTo(i, i+2)
wordStart = false
case ch == '\'':
end := strings.IndexByte(src[i+1:], '\'')
if end >= 0 {
end += i + 2
}
i = copyTo(i, end)
wordStart = false
case ch == '"':
end := dquoteEnd(src, i)
if end >= 0 {
end++
}
i = copyTo(i, end)
wordStart = false
case isSubstStart(src, i):
end := substEnd(src, i)
if end >= 0 {
end++
}
i = copyTo(i, end)
wordStart = false
case ch == '#' && wordStart:
// A comment, until the end of the line
end := strings.IndexByte(src[i:], '\n')
if end >= 0 {
end += i
}
i = copyTo(i, end)
case strings.HasPrefix(src[i:], "<<<"):
i = copyTo(i, i+3)
wordStart = false
case strings.HasPrefix(src[i:], "<<"):
h, n, err := parseHereOp(src[i:])
if err != nil {
return "", err
}
segs = append(segs, cur.String())
cur.Reset()
h.seg = len(segs)
segs = append(segs, "") // filled in when the body has been read
pending = append(pending, h)
i += n
wordStart = false
case ch == '\n':
cur.WriteByte(ch)
i++
wordStart = true
// The bodies of the here-documents of this line follow
for _, h := range pending {
body, n, ok := readHereBody(src[i:], h)
if !ok {
return "", errIncomplete
}
segs[h.seg] = hereString(body, h.quoted)
i += n
}
pending = nil
default:
cur.WriteByte(ch)
i++
wordStart = strings.IndexByte(" \t;&|()<>", ch) >= 0
}
}
if len(pending) > 0 {
return "", errIncomplete
}
segs = append(segs, cur.String())
return strings.Join(segs, ""), nil
}
// dquoteEnd returns the index of the " that ends the double-quoted string
// starting at s[i], or -1.
func dquoteEnd(s string, i int) int {
for j := i + 1; j < len(s); j++ {
switch {
case s[j] == '\\':
j++
case isSubstStart(s, j):
end := substEnd(s, j)
if end < 0 {
return -1
}
j = end
case s[j] == '"':
return j
}
}
return -1
}
// parseHereOp parses a here-document operator <<WORD or <<-WORD at the
// start of s and returns it and its length.
func parseHereOp(s string) (hereDoc, int, error) {
h := hereDoc{}
i := 2
if i < len(s) && s[i] == '-' {
h.stripTabs = true
i++
}
for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
i++
}
var delim strings.Builder
start := i
for i < len(s) && strings.IndexByte(" \t\n;&|()<>", s[i]) < 0 {
switch c := s[i]; c {
case '\'', '"':
end := strings.IndexByte(s[i+1:], c)
if end < 0 {
return h, 0, errIncomplete
}
delim.WriteString(s[i+1 : i+1+end])
i += end + 2
h.quoted = true
case '\\':
if i+1 < len(s) {
delim.WriteByte(s[i+1])
}
i += 2
h.quoted = true
default:
delim.WriteByte(c)
i++
}
}
if i == start {
if i == len(s) || s[i] == '\n' {
return h, 0, syntaxError("newline")
}
return h, 0, syntaxError(s[i : i+1])
}
h.delim = delim.String()
return h, min(i, len(s)), nil
}
// readHereBody reads the lines of a here-document from s up to the
// delimiter line. It returns the body (each line with its newline) and the
// number of bytes consumed, or ok == false if there is no delimiter line yet.
func readHereBody(s string, h hereDoc) (body string, n int, ok bool) {
var b strings.Builder
for pos := 0; ; {
end := strings.IndexByte(s[pos:], '\n')
last := end < 0
if last {
end = len(s) - pos
}
line := s[pos : pos+end]
if h.stripTabs {
line = strings.TrimLeft(line, "\t")
}
next := min(pos+end+1, len(s))
if line == h.delim {
return b.String(), next, true
}
if last {
return "", 0, false
}
b.WriteString(line)
b.WriteByte('\n')
pos = next
}
}
// hereString returns the here-string (or redirection) that has the effect of
// a here-document with the given body.
func hereString(body string, quoted bool) string {
if body == "" {
return "</dev/null"
}
body = strings.TrimSuffix(body, "\n")
if quoted {
return "<<<'" + strings.ReplaceAll(body, "'", `'\''`) + "'"
}
return `<<<"` + escapeHereBody(body) + `"`
}
// escapeHereBody quotes the body of an unquoted here-document for "...":
// " becomes \", a backslash that only escapes $ ` \ and newline in a
// here-document stays one, others become \\. $(...) and ${...} are kept as
// they are, "$@" becomes "$*" (one word, as in a here-document).
func escapeHereBody(body string) string {
var b strings.Builder
for i := 0; i < len(body); i++ {
c := body[i]
switch {
case isSubstStart(body, i):
end := substEnd(body, i)
if end < 0 {
end = len(body) - 1
}
b.WriteString(body[i : end+1])
i = end
case strings.HasPrefix(body[i:], "$@"):
b.WriteString("$*")
i++
case strings.HasPrefix(body[i:], "${"):
end := paramEnd(body, i)
if end < 0 {
end = len(body) - 1
}
b.WriteString(strings.Replace(body[i:end+1], "${@", "${*", 1))
i = end
case c == '\\' && i+1 < len(body) && strings.IndexByte("$`\\\n", body[i+1]) >= 0:
b.WriteString(body[i : i+2])
i++
case c == '\\':
b.WriteString(`\\`)
case c == '"':
b.WriteString(`\"`)
default:
b.WriteByte(c)
}
}
return b.String()
}
+35 -4
View File
@@ -286,7 +286,16 @@ func (j *job) result() error {
for _, p := range j.procs[:len(j.procs)-1] { for _, p := range j.procs[:len(j.procs)-1] {
printError(p.err) printError(p.err)
} }
return j.procs[len(j.procs)-1].err last := j.procs[len(j.procs)-1].err
if pipefail && exitStatus(last) == 0 {
// The status of the last command that failed
for i := len(j.procs) - 2; i >= 0; i-- {
if err := j.procs[i].err; exitStatus(err) != 0 {
return silent(err)
}
}
}
return last
} }
// waitForeground waits for a job in the foreground. If it is stopped, it is // waitForeground waits for a job in the foreground. If it is stopped, it is
@@ -504,9 +513,12 @@ func builtinBg(args []string, stdout io.Writer) error {
func builtinWait(args []string) error { func builtinWait(args []string) error {
jobs := append([]*job(nil), jobTable...) jobs := append([]*job(nil), jobTable...)
if len(args) > 1 { if len(args) > 1 {
j, err := findJob("wait", args) j := jobByPid(args[1])
if err != nil { if j == nil {
return err var err error
if j, err = findJob("wait", args); err != nil {
return err
}
} }
jobs = []*job{j} jobs = []*job{j}
} }
@@ -523,6 +535,23 @@ func builtinWait(args []string) error {
return err return err
} }
// jobByPid returns the job with a process with the ID in spec (wait $!), or
// nil.
func jobByPid(spec string) *job {
pid, err := strconv.Atoi(spec)
if err != nil {
return nil
}
for _, j := range jobTable {
for _, p := range j.procs {
if p.cmd != nil && p.cmd.Process.Pid == pid {
return j
}
}
}
return nil
}
// exitShell exits the shell, unless there are stopped jobs and the user was // exitShell exits the shell, unless there are stopped jobs and the user was
// not warned yet (like bash). // not warned yet (like bash).
func exitShell(code int) error { func exitShell(code int) error {
@@ -530,6 +559,8 @@ func exitShell(code int) error {
exitWarned = true exitWarned = true
return errors.New("there are stopped jobs") return errors.New("there are stopped jobs")
} }
lastStatus = code // $? in the EXIT trap
runExitTrap()
releaseTerminal() releaseTerminal()
os.Exit(code) os.Exit(code)
return nil return nil
+410 -104
View File
@@ -12,6 +12,7 @@ import (
"os/user" "os/user"
"path/filepath" "path/filepath"
"runtime/debug" "runtime/debug"
"slices"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -152,18 +153,20 @@ func tokenize(input string) ([]token, error) {
plain = true // the word so far has no quotes, escapes or expansions plain = true // the word so far has no quotes, escapes or expansions
assign bool // the word is an assignment name=value assign bool // the word is an assignment name=value
tildeOK bool // ~ may be expanded here (after = or : of an assignment) tildeOK bool // ~ may be expanded here (after = or : of an assignment)
noParams bool // the word contains "$@" without positional parameters
quote byte quote byte
escaped bool escaped bool
rawStart = -1 // start of the current word in input rawStart = -1 // start of the current word in input
) )
endWord := func() { 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}) tokens = append(tokens, token{word: b.String(), pattern: pat.String(), glob: glob, assign: assign})
} }
b.Reset() b.Reset()
pat.Reset() pat.Reset()
glob, assign, plain = false, false, true glob, assign, plain, noParams = false, false, true, false
inWord = false inWord = false
rawStart = -1 rawStart = -1
} }
@@ -188,18 +191,28 @@ func tokenize(input string) ([]token, error) {
if quote == '"' && !strings.ContainsRune("$\"\\`", rune(ch)) { if quote == '"' && !strings.ContainsRune("$\"\\`", rune(ch)) {
literal("\\") literal("\\")
} }
literal(string(ch)) literal(input[i : i+1])
inWord = true inWord = true
escaped = false escaped = false
case quote == '\'': case quote == '\'':
if ch == '\'' { if ch == '\'' {
quote = 0 quote = 0
} else { } else {
literal(string(ch)) literal(input[i : i+1])
} }
case ch == '\\': case ch == '\\':
startRaw(i) startRaw(i)
escaped = true escaped = true
case arithEnd(input, i) >= 0:
// $((expression)): the value is one word
startRaw(i)
value, n, err := expandDollar(input[i:])
if err != nil {
return nil, err
}
literal(value)
inWord = true
i += n - 1
case isSubstStart(input, i): case isSubstStart(input, i):
startRaw(i) startRaw(i)
end := substEnd(input, i) end := substEnd(input, i)
@@ -227,6 +240,20 @@ func tokenize(input string) ([]token, error) {
} }
} }
i = end 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 == '$': case ch == '$':
startRaw(i) startRaw(i)
value, n, err := expandVar(input[i:]) value, n, err := expandVar(input[i:])
@@ -246,7 +273,7 @@ func tokenize(input string) ([]token, error) {
if ch == '"' { if ch == '"' {
quote = 0 quote = 0
} else { } else {
literal(string(ch)) literal(input[i : i+1])
} }
case ch == '"' || ch == '\'': case ch == '"' || ch == '\'':
startRaw(i) startRaw(i)
@@ -289,6 +316,10 @@ func tokenize(input string) ([]token, error) {
} }
t := token{op: string(ch), fd: fd} t := token{op: string(ch), fd: fd}
switch { switch {
case strings.HasPrefix(input[i:], "<<<"):
// <<<word: the word (and a newline) is the input
t.op = "<<<"
i += 2
case ch == '>' && i+1 < len(input) && input[i+1] == '>': case ch == '>' && i+1 < len(input) && input[i+1] == '>':
t.op = ">>" t.op = ">>"
i++ i++
@@ -501,29 +532,21 @@ func splitWords(input string) ([]string, error) {
// is not followed by a variable name is returned literally. // is not followed by a variable name is returned literally.
func expandVar(s string) (string, int, error) { func expandVar(s string) (string, int, error) {
if len(s) > 1 && s[1] == '{' { if len(s) > 1 && s[1] == '{' {
end := strings.IndexByte(s, '}') end := paramEnd(s, 0)
if end < 0 { if end < 0 {
return "", 0, errors.New("missing } in variable reference") return "", 0, errors.New("missing } in variable reference")
} }
name, def, hasDef := strings.Cut(s[2:end], ":-") value, err := expandParam(s[2:end])
if name == "?" { return value, end + 1, err
return strconv.Itoa(lastStatus), end + 1, nil
}
if !isVarName(name) {
return "", 0, fmt.Errorf("bad substitution: %s", s[:end+1])
}
value, ok := os.LookupEnv(name)
if hasDef && value == "" {
return def, end + 1, nil
}
if !ok {
return "", 0, fmt.Errorf("%s: unbound variable", name)
}
return value, end + 1, nil
} }
if len(s) > 1 && s[1] == '?' { // $?, $#, $$, $!, $@, $*, $0 and $1 to $9 ($10 is $1 followed by 0)
return strconv.Itoa(lastStatus), 2, nil 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 n := 1
@@ -540,6 +563,25 @@ func expandVar(s string) (string, int, error) {
return value, n, nil 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 { func isVarName(name string) bool {
if name == "" { if name == "" {
return false return false
@@ -753,6 +795,49 @@ func useColor() bool {
return readline.IsTerminal(int(os.Stdout.Fd())) return readline.IsTerminal(int(os.Stdout.Fd()))
} }
// reportCwd tells the terminal the current working directory with the OSC 7
// escape sequence, so that new tabs or windows open in the same directory
// (VTE terminals like GNOME Terminal, kitty, WezTerm, foot, iTerm2, ...).
// Terminals without support ignore it. Nothing is written if stdout is not a
// terminal or TERM is "dumb".
func reportCwd() {
if os.Getenv("TERM") == "dumb" || !readline.IsTerminal(int(os.Stdout.Fd())) {
return
}
wd, err := os.Getwd()
if err != nil {
return
}
hostname, _ := os.Hostname()
fmt.Fprint(os.Stdout, osc7Sequence(hostname, wd))
}
// osc7Sequence returns ESC ] 7 ; file://host/path BEL. Host and path are
// percent-encoded, so control characters in a directory name cannot end the
// sequence early or inject other escape sequences.
func osc7Sequence(hostname, dir string) string {
return "\x1b]7;file://" + percentEncode(hostname, false) + percentEncode(dir, true) + "\a"
}
// percentEncode encodes every byte except unreserved URI characters (and '/'
// if keepSlash) as %XX.
func percentEncode(s string, keepSlash bool) string {
const hex = "0123456789ABCDEF"
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' ||
c == '-' || c == '.' || c == '_' || c == '~' || c == '/' && keepSlash {
b.WriteByte(c)
} else {
b.WriteByte('%')
b.WriteByte(hex[c>>4])
b.WriteByte(hex[c&15])
}
}
return b.String()
}
// shellCompleter implements readline.AutoCompleter for contextual completion // shellCompleter implements readline.AutoCompleter for contextual completion
type shellCompleter struct { type shellCompleter struct {
commands []string commands []string
@@ -760,7 +845,7 @@ type shellCompleter struct {
func newShellCompleter() *shellCompleter { func newShellCompleter() *shellCompleter {
return &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 +862,7 @@ func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) {
// ... and after if, then, do etc. // ... and after if, then, do etc.
if fields := strings.Fields(before); len(fields) > 0 { if fields := strings.Fields(before); len(fields) > 0 {
switch fields[len(fields)-1] { switch fields[len(fields)-1] {
case "if", "then", "elif", "else", "do", "while", "until", "!": case "if", "then", "elif", "else", "do", "while", "until", "!", "{", ";;":
isFirstWord = true isFirstWord = true
} }
} }
@@ -791,7 +876,7 @@ func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) {
// Complete commands // Complete commands
var candidates [][]rune var candidates [][]rune
seen := make(map[string]bool) 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 { for _, cmd := range names {
if strings.HasPrefix(cmd, word) && !seen[cmd] { if strings.HasPrefix(cmd, word) && !seen[cmd] {
seen[cmd] = true seen[cmd] = true
@@ -935,8 +1020,8 @@ func completeFilePath(line, prefix string) []string {
return matches return matches
} }
const usage = `usage: gosh [-l] [-i] [--norc] [--noprofile] [script] const usage = `usage: gosh [-l] [-i] [-ex] [-o option] [--norc] [--noprofile] [script [args ...]]
gosh [-l] [--noprofile] -c command gosh [-l] [-ex] [-o option] [--noprofile] -c command [name [args ...]]
gosh -version` gosh -version`
// sysConfDir holds the system-wide startup files. // sysConfDir holds the system-wide startup files.
@@ -949,6 +1034,7 @@ func main() {
if strings.HasPrefix(os.Args[0], "-") { if strings.HasPrefix(os.Args[0], "-") {
args = append([]string{"-l"}, args...) args = append([]string{"-l"}, args...)
} }
scriptName = os.Args[0]
os.Exit(run(args)) os.Exit(run(args))
} }
@@ -968,7 +1054,25 @@ flags:
noRC = true noRC = true
case "--noprofile": case "--noprofile":
noProfile = true noProfile = true
case "-o", "+o":
// -o pipefail etc., like set
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "gosh: -o: option requires an argument")
return 2
}
if err := setOption(0, args[1], args[0] == "-o"); err != nil {
fmt.Fprintln(os.Stderr, "gosh:", err)
return 2
}
args = args[1:]
default: default:
// -e, -x and combinations like -ex, like set
if len(args[0]) > 1 && args[0][0] == '-' && strings.Trim(args[0][1:], "ex") == "" {
for _, c := range []byte(args[0][1:]) {
setOption(c, "", true)
}
break
}
break flags break flags
} }
args = args[1:] args = args[1:]
@@ -986,21 +1090,26 @@ flags:
fmt.Println(usage) fmt.Println(usage)
return 0 return 0
case arg == "-c": case arg == "-c":
// Like sh, further arguments are allowed (they would become $0, // gosh -c command [name [args ...]]: name is $0, args are $1, ...
// $1, ...), but gosh has no positional parameters yet.
if len(args) < 2 { if len(args) < 2 {
fmt.Fprintln(os.Stderr, "gosh: -c: option requires an argument") fmt.Fprintln(os.Stderr, "gosh: -c: option requires an argument")
fmt.Fprintln(os.Stderr, usage) fmt.Fprintln(os.Stderr, usage)
return 2 return 2
} }
if len(args) > 2 {
scriptName, positional = args[2], args[3:]
}
script = func() int { return runScript(strings.NewReader(args[1]), "") } script = func() int { return runScript(strings.NewReader(args[1]), "") }
case strings.HasPrefix(arg, "-") && arg != "-": case strings.HasPrefix(arg, "-") && arg != "-":
fmt.Fprintf(os.Stderr, "gosh: %s: invalid option\n", arg) fmt.Fprintf(os.Stderr, "gosh: %s: invalid option\n", arg)
fmt.Fprintln(os.Stderr, usage) fmt.Fprintln(os.Stderr, usage)
return 2 return 2
case arg == "-": case arg == "-":
positional = args[1:]
script = func() int { return runScript(os.Stdin, "") } script = func() int { return runScript(os.Stdin, "") }
default: default:
// gosh script [args ...]
scriptName, positional = arg, args[1:]
f, err := os.Open(arg) f, err := os.Open(arg)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "gosh:", err) fmt.Fprintln(os.Stderr, "gosh:", err)
@@ -1024,9 +1133,12 @@ flags:
} }
if !interactiveMode { if !interactiveMode {
return script() status := script()
runExitTrap()
return status
} }
interactive() interactive()
runExitTrap()
return 0 return 0
} }
@@ -1064,6 +1176,8 @@ func sourceStartupFile(path string) {
return return
} }
defer f.Close() defer f.Close()
sourceDepth++
defer func() { sourceDepth-- }()
runScript(bufio.NewReader(f), path) runScript(bufio.NewReader(f), path)
} }
@@ -1127,12 +1241,19 @@ func runScript(r io.Reader, name string) int {
goto next // read the rest of the command goto next // read the rest of the command
} }
buf.Reset() buf.Reset()
if err != nil { if err == nil {
lastStatus = exitStatus(err) err = tree.exec()
report(err, startLine) var ret returnSignal
if errors.As(err, &ret) {
// return in a sourced file
lastStatus = ret.status
return lastStatus
}
} else { } else {
report(tree.exec(), startLine) lastStatus = exitStatus(err)
} }
report(err, startLine)
runPendingTraps()
} }
next: next:
if readErr != nil { if readErr != nil {
@@ -1192,6 +1313,10 @@ func exitStatus(err error) int {
if errors.As(err, &lc) { if errors.As(err, &lc) {
return 0 return 0
} }
var ret returnSignal
if errors.As(err, &ret) {
return ret.status
}
var exitErr *exec.ExitError var exitErr *exec.ExitError
if errors.As(err, &exitErr) { if errors.As(err, &exitErr) {
if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() { if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
@@ -1218,7 +1343,7 @@ func interactive() {
// Ctrl+C sends SIGINT to the whole foreground process group. Catch it so // Ctrl+C sends SIGINT to the whole foreground process group. Catch it so
// that it only stops the running command and not the shell. (signal.Ignore // that it only stops the running command and not the shell. (signal.Ignore
// would be inherited by the child processes.) // would be inherited by the child processes.)
signal.Notify(make(chan os.Signal, 1), os.Interrupt) signal.Notify(interruptChan, os.Interrupt)
histFile := historyFile() histFile := historyFile()
if histFile != "" { if histFile != "" {
@@ -1248,9 +1373,11 @@ func interactive() {
var buf strings.Builder var buf strings.Builder
for { for {
if buf.Len() == 0 { if buf.Len() == 0 {
runPendingTraps()
// Report finished and stopped background jobs // Report finished and stopped background jobs
reapJobs(true) reapJobs(true)
// Update prompt dynamically (in case directory changed) // Update prompt dynamically (in case directory changed)
reportCwd()
l.SetPrompt(getPrompt()) l.SetPrompt(getPrompt())
} else { } else {
l.SetPrompt("> ") l.SetPrompt("> ")
@@ -1339,11 +1466,12 @@ func printError(err error) {
exitErr *exec.ExitError exitErr *exec.ExitError
statusErr statusError statusErr statusError
lc loopControl 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 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. // execInput parses and executes input: a command line or a whole script.
@@ -1361,27 +1489,8 @@ func execInput(input string) error {
// execCommand executes a pipeline of simple commands with redirections, in // execCommand executes a pipeline of simple commands with redirections, in
// the background if background is set. // the background if background is set.
func execCommand(input string, background bool) error { func execCommand(input string, background bool) error {
// Brace expansion comes first, so that its results are expanded further
substStatus = 0 substStatus = 0
input, err := expandBraceWords(input) stages, err := expandStages(input)
if err != nil {
return err
}
// Split the input into words and operators, expanding variables and ~ on
// the way.
tokens, err := tokenize(input)
var (
synErr syntaxError
statusErr statusError
)
if errors.As(err, &synErr) || errors.As(err, &statusErr) {
return err
}
if err != nil {
return fmt.Errorf("invalid input: %w", err)
}
stages, err := parsePipeline(tokens)
if err != nil { if err != nil {
return err return err
} }
@@ -1399,19 +1508,62 @@ func execCommand(input string, background bool) error {
return runPipeline(stages, strings.TrimSpace(input), background) return runPipeline(stages, strings.TrimSpace(input), background)
} }
// redirect is a redirection of a simple command, e.g. 2>>file or 2>&1. // expandStages expands the text of a pipeline of simple commands and splits
type redirect struct { // it into its stages.
op string // <, >, >>, &>, &>>, >& or <& func expandStages(input string) ([]stage, error) {
fd int // the redirected file descriptor // Brace expansion comes first, so that its results are expanded further
dup int // the target file descriptor of >& and <& input, err := expandBraceWords(input)
file string // the file name for all other operators if err != nil {
return nil, err
}
// Split the input into words and operators, expanding variables and ~ on
// the way.
tokens, err := tokenize(input)
var (
synErr syntaxError
statusErr statusError
)
if errors.As(err, &synErr) || errors.As(err, &statusErr) {
return nil, err
}
if err != nil {
return nil, fmt.Errorf("invalid input: %w", err)
}
return parsePipeline(tokens)
} }
// stage is a simple command of a pipeline. // openRedirections expands and opens the redirections of a compound command
// and returns the resulting stdin, stdout and stderr, and the files to close
// afterwards.
func openRedirections(text string) ([3]*os.File, []*os.File, error) {
fds := [3]*os.File{stdio(0), stdio(1), stdio(2)}
stages, err := expandStages(text)
if err != nil {
return fds, nil, err
}
if len(stages) != 1 || len(stages[0].args) > 0 || len(stages[0].assigns) > 0 {
return fds, nil, syntaxError(text)
}
opened, err := applyRedirects(&fds, stages[0].redirects)
return fds, opened, err
}
// redirect is a redirection of a simple command, e.g. 2>>file or 2>&1.
type redirect struct {
op string // <, >, >>, &>, &>>, >&, <& or <<<
fd int // the redirected file descriptor
dup int // the target file descriptor of >& and <&
file string // the file name for all other operators (the text for <<<)
}
// stage is a simple command of a pipeline, or a script that runs in a
// subshell (a compound command in a pipeline or in the background).
type stage struct { type stage struct {
assigns []string // name=value before the command name assigns []string // name=value before the command name
args []string args []string
redirects []redirect redirects []redirect
script string
} }
// parsePipeline groups tokens into the stages of a pipeline. // parsePipeline groups tokens into the stages of a pipeline.
@@ -1448,6 +1600,11 @@ func parsePipeline(tokens []token) ([]stage, error) {
return nil, syntaxError(next.op) return nil, syntaxError(next.op)
} }
i++ i++
if t.op == "<<<" {
// A here-string is not expanded as a pattern
cur.redirects = append(cur.redirects, redirect{op: t.op, fd: t.fd, file: tokens[i].word})
continue
}
files := expandWord(tokens[i]) files := expandWord(tokens[i])
if len(files) > 1 { if len(files) > 1 {
return nil, fmt.Errorf("%s: ambiguous redirect", sanitize(tokens[i].word)) return nil, fmt.Errorf("%s: ambiguous redirect", sanitize(tokens[i].word))
@@ -1476,7 +1633,7 @@ func runPipeline(stages []stage, text string, background bool) error {
shellTTY, _ = readline.GetState(ttyFd) shellTTY, _ = readline.GetState(ttyFd)
} }
stdin := os.Stdin stdin := stdio(0)
if background && !jobControl { if background && !jobControl {
// Without job control background jobs must not read from the // Without job control background jobs must not read from the
// terminal, like in sh // terminal, like in sh
@@ -1486,12 +1643,12 @@ func runPipeline(stages []stage, text string, background bool) error {
} }
var startErr error var startErr error
for i, st := range stages { for i, st := range stages {
stdout := os.Stdout stdout := stdio(1)
var next *os.File var next *os.File
if i < len(stages)-1 { if i < len(stages)-1 {
r, w, err := os.Pipe() r, w, err := os.Pipe()
if err != nil { if err != nil {
if stdin != os.Stdin { if stdin != stdio(0) {
stdin.Close() stdin.Close()
} }
startErr = err startErr = err
@@ -1521,6 +1678,11 @@ func runPipeline(stages []stage, text string, background bool) error {
} }
fmt.Fprintf(os.Stderr, "[%d] %d\n", j.id, pid) fmt.Fprintf(os.Stderr, "[%d] %d\n", j.id, pid)
} }
for _, p := range j.procs {
if p.cmd != nil {
lastBackgroundPid = p.cmd.Process.Pid
}
}
return nil return nil
} }
return waitForeground(j) return waitForeground(j)
@@ -1530,11 +1692,26 @@ func runPipeline(stages []stage, text string, background bool) error {
// stdout. Pipe ends passed in are closed by startStage once the stage no // stdout. Pipe ends passed in are closed by startStage once the stage no
// longer needs them, so that the neighbors see EOF or get SIGPIPE. // 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 { func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background bool) *proc {
if (inPipeline || background) && st.script == "" {
// eval and exec in a pipeline or in the background run in a
// subshell, like in sh
switch firstArg(st.args) {
case "eval":
st.script, st.args = strings.Join(st.args[1:], " "), nil
case "exec":
st.args = st.args[1:]
}
}
if _, isFunc := functions[firstArg(st.args)]; xtrace && st.script == "" && !(isFunc && (inPipeline || background)) {
// (a function in a pipeline is shown by its subshell)
traceCommand(st)
}
var owned []*os.File var owned []*os.File
if stdin != os.Stdin { if stdin != stdio(0) {
owned = append(owned, stdin) owned = append(owned, stdin)
} }
if stdout != os.Stdout { if stdout != stdio(1) {
owned = append(owned, stdout) owned = append(owned, stdout)
} }
closeOwned := func() { closeOwned := func() {
@@ -1547,7 +1724,7 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
// the command would read from /dev/null, so interactive programs (su, // the command would read from /dev/null, so interactive programs (su,
// vim, ssh, ...) would get EOF right away. readline only reads from stdin // vim, ssh, ...) would get EOF right away. readline only reads from stdin
// while a line is being edited, so it does not steal input. // 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) opened, err := applyRedirects(&fds, st.redirects)
owned = append(owned, opened...) owned = append(owned, opened...)
if err == nil && len(st.args) == 0 && len(st.assigns) > 0 { if err == nil && len(st.args) == 0 && len(st.assigns) > 0 {
@@ -1564,25 +1741,39 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
err = statusError(substStatus) err = statusError(substStatus)
} }
} }
if err != nil || len(st.args) == 0 { if err != nil || len(st.args) == 0 && st.script == "" {
// A command without arguments only creates or truncates the files // A command without arguments only creates or truncates the files
closeOwned() closeOwned()
return finishedProc(err) return finishedProc(err)
} }
if builtin, ok := builtins[st.args[0]]; ok { var fn *funcDef
isFunc := false
if st.script == "" {
fn, isFunc = functions[st.args[0]]
}
if isFunc && !inPipeline && !background {
defer closeOwned()
return finishedProc(callFunction(fn, st.args, fds, st.assigns))
}
if !isFunc && st.script == "" && st.args[0] == "exec" && !inPipeline && !background {
defer closeOwned()
return finishedProc(builtinExec(st.args, fds, st.assigns))
}
if builtin, ok := builtins[firstArg(st.args)]; !isFunc && st.script == "" && ok {
switch { switch {
case inPipeline && builtin.changesState: case inPipeline && builtin.changesState && !onlyShows(st.args):
closeOwned() closeOwned()
return finishedProc(fmt.Errorf("%s: cannot be used in a pipeline", st.args[0])) return finishedProc(fmt.Errorf("%s: cannot be used in a pipeline", st.args[0]))
case background && builtin.changesState: case background && builtin.changesState && !onlyShows(st.args):
closeOwned() closeOwned()
return finishedProc(fmt.Errorf("%s: cannot be run in the background", st.args[0])) return finishedProc(fmt.Errorf("%s: cannot be run in the background", st.args[0]))
} }
shellStderr := stdio(2)
run := func() error { run := func() error {
defer closeOwned() defer closeOwned()
err := runBuiltin(st.args, fds[1]) err := runBuiltin(st.args, fds[0], fds[1])
if err != nil && fds[2] != os.Stderr { if err != nil && fds[2] != shellStderr {
// Report the error where stderr is redirected to // Report the error where stderr is redirected to
fmt.Fprintln(fds[2], "gosh:", err) fmt.Fprintln(fds[2], "gosh:", err)
return statusError(1) return statusError(1)
@@ -1590,8 +1781,12 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
return err return err
} }
if !inPipeline && !background { if !inPipeline && !background {
// name=value before a builtin applies only while it runs // name=value before a builtin applies only while it runs, its
// redirections also to the commands it runs (source file > out)
defer setTemporarily(st.assigns)() defer setTemporarily(st.assigns)()
saved := ioOverride
ioOverride = fds
defer func() { ioOverride = saved }()
return finishedProc(run()) return finishedProc(run())
} }
p := &proc{done: make(chan error, 1)} p := &proc{done: make(chan error, 1)}
@@ -1599,7 +1794,23 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
return p return p
} }
cmd := exec.Command(st.args[0], st.args[1:]...) var cmd *exec.Cmd
if st.script != "" {
cmd, err = subshellCommand(st.script, positional)
if err != nil {
closeOwned()
return finishedProc(err)
}
} else 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] cmd.Stdin, cmd.Stdout, cmd.Stderr = fds[0], fds[1], fds[2]
if len(st.assigns) > 0 { if len(st.assigns) > 0 {
// name=value before a command is only in its environment (the // name=value before a command is only in its environment (the
@@ -1623,7 +1834,7 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
// The child has its own copies of the files now // The child has its own copies of the files now
closeOwned() closeOwned()
if errors.Is(err, exec.ErrNotFound) { if errors.Is(err, exec.ErrNotFound) {
err = fmt.Errorf("%s: %w", sanitize(st.args[0]), errCommandNotFound) err = fmt.Errorf("%s: %w", sanitize(firstArg(st.args)), errCommandNotFound)
} }
if err != nil { if err != nil {
return finishedProc(err) return finishedProc(err)
@@ -1638,6 +1849,13 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
return &proc{cmd: cmd} return &proc{cmd: cmd}
} }
func firstArg(args []string) string {
if len(args) == 0 {
return ""
}
return args[0]
}
// setTemporarily sets the variables of the assignments and returns a // setTemporarily sets the variables of the assignments and returns a
// function that restores the previous values. // function that restores the previous values.
func setTemporarily(assigns []string) func() { func setTemporarily(assigns []string) func() {
@@ -1686,6 +1904,16 @@ func applyRedirects(fds *[3]*os.File, redirects []redirect) ([]*os.File, error)
continue continue
} }
if r.op == "<<<" {
f, err := hereFile(r.file + "\n")
if err != nil {
return fail(err)
}
opened = append(opened, f)
fds[r.fd] = f
continue
}
flags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC flags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC
switch r.op { switch r.op {
case "<": case "<":
@@ -1711,6 +1939,26 @@ func applyRedirects(fds *[3]*os.File, redirects []redirect) ([]*os.File, error)
return opened, nil return opened, nil
} }
// hereFile returns a file to read content from: a temporary file that is
// already removed (like bash does for here-documents), so that large inputs
// work and nothing is left behind.
func hereFile(content string) (*os.File, error) {
f, err := os.CreateTemp("", "gosh-here-*")
if err != nil {
return nil, err
}
os.Remove(f.Name())
if _, err := f.WriteString(content); err != nil {
f.Close()
return nil, err
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
f.Close()
return nil, err
}
return f, nil
}
// builtins lists the built-in commands. Commands that change the state of // builtins lists the built-in commands. Commands that change the state of
// the shell cannot be used in a pipeline, since there they would run // 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). // concurrently (sh runs them in a subshell where they have no effect).
@@ -1734,10 +1982,18 @@ var builtins = map[string]struct{ changesState bool }{
"unset": {changesState: true}, "unset": {changesState: true},
"break": {changesState: true}, "break": {changesState: true},
"continue": {changesState: true}, "continue": {changesState: true},
"return": {changesState: true},
"local": {changesState: true},
"shift": {changesState: true},
"read": {changesState: true},
"trap": {changesState: true},
"eval": {changesState: true},
"exec": {changesState: true},
} }
// runBuiltin executes a built-in command, writing its output to stdout. // runBuiltin executes a built-in command, reading from stdin and writing its
func runBuiltin(args []string, stdout io.Writer) error { // output to stdout.
func runBuiltin(args []string, stdin *os.File, stdout io.Writer) error {
switch args[0] { switch args[0] {
case "cd": case "cd":
// Save current directory before changing (for 'cd -') // Save current directory before changing (for 'cd -')
@@ -1790,17 +2046,11 @@ func runBuiltin(args []string, stdout io.Writer) error {
} }
return exitShell(lastStatus) return exitShell(lastStatus)
case "set": case "set":
// set VAR=value - set environment variable return builtinSet(args, stdout)
if len(args) < 2 { case "trap":
return fmt.Errorf("usage: set VAR=value") return builtinTrap(args, stdout)
} case "eval":
// Parse VAR=value return builtinEval(args)
parts := strings.SplitN(args[1], "=", 2)
if len(parts) != 2 {
return fmt.Errorf("usage: set VAR=value")
}
os.Setenv(parts[0], parts[1])
return nil
case "export": case "export":
// export VAR=value - same as set but also marks for child processes // export VAR=value - same as set but also marks for child processes
if len(args) < 2 { if len(args) < 2 {
@@ -1817,12 +2067,21 @@ func runBuiltin(args []string, stdout io.Writer) error {
// Does nothing, successfully (e.g. while :; do ...) // Does nothing, successfully (e.g. while :; do ...)
return nil return nil
case "unset": case "unset":
// unset NAME ... - remove variables // unset [-v] NAME ... - remove variables, unset -f NAME ... - functions
if len(args) < 2 { names := args[1:]
return fmt.Errorf("usage: unset NAME ...") 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 var err error
for _, name := range args[1:] { for _, name := range names {
if funcs {
delete(functions, name)
continue
}
if !isVarName(name) { if !isVarName(name) {
err = fmt.Errorf("unset: `%s': not a valid identifier", sanitize(name)) err = fmt.Errorf("unset: `%s': not a valid identifier", sanitize(name))
continue continue
@@ -1832,6 +2091,14 @@ func runBuiltin(args []string, stdout io.Writer) error {
return err return err
case "break", "continue": case "break", "continue":
return builtinLoopControl(args) 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": case "alias":
return builtinAlias(args, stdout) return builtinAlias(args, stdout)
case "unalias": case "unalias":
@@ -1845,9 +2112,15 @@ func runBuiltin(args []string, stdout io.Writer) error {
case "wait": case "wait":
return builtinWait(args) return builtinWait(args)
case "source", ".": 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 { 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]) return sourceFile(args[0], args[1])
case "help": case "help":
@@ -1860,18 +2133,32 @@ Built-in commands:
exit [code] - Exit shell with status code (default: $?) exit [code] - Exit shell with status code (default: $?)
quit [code] - Quit shell with status code (default: $?) quit [code] - Quit shell with status code (default: $?)
set VAR=val - Set environment variable set VAR=val - Set environment variable
set [-ex] [-o pipefail] [--] [args] - Options: -e exit when a command
fails, -x print commands, -o pipefail; args are $1, ...
(set: show variables, set -o: show options)
export VAR=val - Export environment variable export VAR=val - Export environment variable
unset VAR - Remove a variable unset VAR - Remove a variable (unset -f name: a function)
source file - Run the commands of file in this shell (also: . file) source file [args] - Run the commands of file in this shell (also: . file)
jobs - List background and stopped jobs jobs - List background and stopped jobs
fg [%n] - Continue job n (default: current job) in the foreground fg [%n] - Continue job n (default: current job) in the foreground
bg [%n] - Continue stopped job n in the background bg [%n] - Continue stopped job n in the background
wait [%n] - Wait for job n or all background jobs wait [%n|pid] - Wait for job n (or process pid, e.g. $!) or all jobs
alias [name[=value] ...] - Define or show aliases (alias ll='ls -l') alias [name[=value] ...] - Define or show aliases (alias ll='ls -l')
unalias name - Remove an alias (unalias -a: all aliases) unalias name - Remove an alias (unalias -a: all aliases)
: - Do nothing (exit status 0) : - Do nothing (exit status 0)
break [n] - Leave the innermost (or n-th) for/while/until loop break [n] - Leave the innermost (or n-th) for/while/until loop
continue [n] - Continue with the next iteration of a 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)
eval args - Run the arguments as a command in this shell
exec [cmd] - Replace the shell with cmd; without cmd, its
redirections apply to the shell (exec > log)
trap [cmd] SIG... - Run cmd on a signal (INT, TERM, HUP, USR1, ...)
or on EXIT; trap '' SIG ignores it, trap - SIG resets
help - Show this help help - Show this help
Features: Features:
@@ -1894,11 +2181,23 @@ Features:
GOSH_COLORS='user=1;33:path=36' (keys user, root, path, error; values GOSH_COLORS='user=1;33:path=36' (keys user, root, path, error; values
are ANSI SGR codes, empty = no color); disabled with $NO_COLOR, are ANSI SGR codes, empty = no color); disabled with $NO_COLOR,
TERM=dumb or when output is not a terminal TERM=dumb or when output is not a terminal
- Reports the working directory to the terminal (OSC 7) before each
prompt, so new tabs/windows open in the same directory
- Variables: name=value sets a variable (like set), name=value command - Variables: name=value sets a variable (like set), name=value command
sets it only for that command; the value is not split or expanded as sets it only for that command; the value is not split or expanded as
a pattern a pattern
- Environment variable expansion ($VAR, ${VAR}, ${VAR:-default}); - Environment variable expansion ($VAR, ${VAR}, ${VAR:-default});
using an unset variable is an error, use ${VAR:-} for an empty default using an unset variable is an error, use ${VAR:-} for an empty default
- Parameter expansion: ${#VAR} (length), ${VAR#pat} ${VAR##pat}
${VAR%pat} ${VAR%%pat} (remove prefix/suffix), ${VAR/pat/rep}
${VAR//pat/rep}, ${VAR:offset:length}, ${VAR-x} ${VAR:=x} ${VAR:+x}
${VAR:?msg}
- Arithmetic: $((expression)) with integers and the C operators
(+ - * / % ** << >> < == && || ?: = += ++ ...)
- Positional parameters: $0, $1 ... $9, ${10}, $# (count), "$@" (one
word each), "$*" (one word), $$ (process ID), $! (process ID of the
last background job); set by gosh script
args, gosh -c command name args, functions, set -- and source
- Command substitution: $(command) is replaced by the output of command - Command substitution: $(command) is replaced by the output of command
(run in a subshell); unquoted it is split into words, "$(command)" (run in a subshell); unquoted it is split into words, "$(command)"
stays one word stays one word
@@ -1906,9 +2205,16 @@ Features:
a || b (b only if a failed); $? is the exit status of the last command a || b (b only if a failed); $? is the exit status of the last command
- Control structures: if cmd; then ...; elif cmd; then ...; else ...; fi, - Control structures: if cmd; then ...; elif cmd; then ...; else ...; fi,
for x in words; do ...; done, while cmd; do ...; done, until ..., for x in words; do ...; done, while cmd; do ...; done, until ...,
! cmd negates the status; they can span several lines (prompt "> ") case word in pattern|pattern) ...;; esac, { cmd; cmd; } groups,
( cmd; cmd ) subshells; ! cmd negates the status; they can span
several lines (prompt "> "), have redirections (done < file) and can
be used in pipes (| while read l; do ...; done) and with &
- 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, - Pipes (a | b) and redirections: < file, > file, >> file, 2> file,
2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file 2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file,
<<EOF here-documents (<<-EOF strips tabs, <<'EOF' expands nothing),
<<<word here-strings
- Job control: command & runs in the background, Ctrl+Z stops the - Job control: command & runs in the background, Ctrl+Z stops the
foreground job; jobs, fg, bg and wait manage the jobs foreground job; jobs, fg, bg and wait manage the jobs
- Aliases replace the first word of a command in interactive shells - Aliases replace the first word of a command in interactive shells
+81 -13
View File
@@ -631,12 +631,11 @@ func TestExecInput_ExportEnvVar(t *testing.T) {
} }
func TestExecInput_SetNoArgs(t *testing.T) { func TestExecInput_SetNoArgs(t *testing.T) {
err := execInput("set") // set without arguments shows the variables, quoted for the shell
if err == nil { t.Setenv("GOSH_SHOWN", "a b")
t.Error("expected error for set with no args, got nil") got := runIn(t, "set > out")
} if !strings.Contains(got, "GOSH_SHOWN='a b'\n") {
if !strings.Contains(err.Error(), "usage: set VAR=value") { t.Errorf("set: GOSH_SHOWN missing in %q", got)
t.Errorf("expected usage error, got: %v", err)
} }
} }
@@ -941,7 +940,7 @@ func TestSplitWords(t *testing.T) {
{`echo x${GOSH_A}y`, []string{"echo", "xa by"}}, {`echo x${GOSH_A}y`, []string{"echo", "xa by"}},
// Escaped and lone dollar signs stay literal // Escaped and lone dollar signs stay literal
{`echo \$GOSH_A "\$GOSH_A"`, []string{"echo", "$GOSH_A", "$GOSH_A"}}, {`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 ` // Backslash in double quotes only escapes $ " \ and `
{`echo "a\nb\\c"`, []string{"echo", `a\nb\c`}}, {`echo "a\nb\\c"`, []string{"echo", `a\nb\c`}},
// Defaults // Defaults
@@ -1134,6 +1133,31 @@ func TestBuildPrompt_ColorEscapesControlCharacters(t *testing.T) {
} }
} }
func TestOSC7Sequence(t *testing.T) {
tests := []struct{ host, dir, want string }{
{"host", "/home/flow", "\x1b]7;file://host/home/flow\a"},
{"host", "/tmp/a b/ä", "\x1b]7;file://host/tmp/a%20b/%C3%A4\a"},
{"host", "/x%y#z?", "\x1b]7;file://host/x%25y%23z%3F\a"},
{"", "/", "\x1b]7;file:///\a"},
}
for _, tt := range tests {
if got := osc7Sequence(tt.host, tt.dir); got != tt.want {
t.Errorf("osc7Sequence(%q, %q) = %q, want %q", tt.host, tt.dir, got, tt.want)
}
}
}
func TestOSC7Sequence_EscapesControlCharacters(t *testing.T) {
got := osc7Sequence("ho/st\x07", "/evil\x07\x1b]0;title\x1b\\")
body := strings.TrimSuffix(strings.TrimPrefix(got, "\x1b]7;"), "\a")
if strings.ContainsAny(body, "\x07\x1b") {
t.Errorf("sequence contains control characters: %q", got)
}
if !strings.HasPrefix(body, "file://ho%2Fst%07/evil%07%1B") {
t.Errorf("unexpected encoding: %q", got)
}
}
func TestUseColor_NoColor(t *testing.T) { func TestUseColor_NoColor(t *testing.T) {
t.Setenv("NO_COLOR", "1") t.Setenv("NO_COLOR", "1")
if useColor() { if useColor() {
@@ -1366,7 +1390,7 @@ func TestRun_Options(t *testing.T) {
{[]string{"-c", "false"}, 1}, {[]string{"-c", "false"}, 1},
{[]string{"-l", "--noprofile", "-c", "sh -c 'exit 5'"}, 5}, {[]string{"-l", "--noprofile", "-c", "sh -c 'exit 5'"}, 5},
{[]string{"-c"}, 2}, {[]string{"-c"}, 2},
{[]string{"-x"}, 2}, {[]string{"-z"}, 2},
{[]string{"/gosh/no/such/script"}, 127}, {[]string{"/gosh/no/such/script"}, 127},
} }
for _, tc := range tests { for _, tc := range tests {
@@ -1437,6 +1461,26 @@ func pipelineString(pl *pipelineNode) string {
s += "while " s += "while "
} }
s += treeString(n.cond) + " do " + treeString(n.body) + " done" s += treeString(n.cond) + " do " + treeString(n.body) + " done"
case *caseNode:
s += "case " + n.word + " in"
for _, c := range n.clauses {
s += " (" + strings.Join(c.patterns, "|") + ") " + treeString(c.body) + " ;;"
}
s += " esac"
case *groupNode:
s += "{ " + treeString(n.body) + " }"
case *subshellNode:
s += "(" + n.text + ")"
case *redirNode:
s += pipelineString(&pipelineNode{cmd: n.cmd}) + " <" + n.redirs + ">"
case *pipeNode:
var elems []string
for _, e := range n.elems {
elems = append(elems, "<"+e.text+">")
}
s += strings.Join(elems, " | ")
case *funcDefNode:
s += n.def.name + "() " + pipelineString(&pipelineNode{cmd: n.def.body})
} }
return s return s
} }
@@ -1504,6 +1548,27 @@ func TestParse_ControlStructures(t *testing.T) {
{"while a; do if b; then break; fi; done", "while [a] do if [b] then [break] fi 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"}, {"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"}, {"if a; then if b; then c; fi fi", "if [a] then if [b] then [c] fi fi"},
// Compound commands in pipelines, with redirections, in the background
{"for x in a; do b; done | cat", "<for x in a; do b; done> | <cat>"},
{"a | while b; do c; done | d", "<a> | <while b; do c; done> | <d>"},
{"if a; then b; fi > out 2>&1", "if [a] then [b] fi <> out 2>&1>"},
{"while read l; do b; done < in", "while [read l] do [b] done << in>"},
{"{ a; b; } 2> err", "{ [a]; [b] } <2> err>"},
{"for x in a; do b; done &", "for x in (a) do [b] done &"},
{"a && b &", "[a] && [b] &"},
// Subshells
{"(a; b)", "(a; b)"},
{"( a ) | b", "<( a )> | <b>"},
{"(a) > f", "(a) <> f>"},
{"(a; (b)) && c", "(a; (b)) && [c]"},
// case, groups and functions
{"case $x in a|b) c;; (d) e; f;; esac", "case $x in (a|b) [c] ;; (d) [e]; [f] ;; esac"},
{"case x in\n a)\n b\n ;;\nesac", "case x in (a) [b] ;; esac"},
{"{ a; }", "{ [a] }"},
{"f() { a; }", "f() { [a] }"},
{"f ( ) { a; }", "f() { [a] }"},
{"function f { a; }", "f() { [a] }"},
{"f() (a)", "f() (a)"},
} }
for _, tc := range tests { for _, tc := range tests {
tree, err := parse(tc.input) tree, err := parse(tc.input)
@@ -1522,6 +1587,8 @@ func TestParse_Incomplete(t *testing.T) {
"if a", "if a; then", "if a; then b", "if a; then b; else", "if a; then b; elif c", "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", "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", "while a", "while a; do b", "until a; do",
"(a", "(a; b", "{ a;", "case x in", "case x in a) b;;", "f() {", "f()",
"cat <<EOF", "cat <<EOF\nbody", "cat <<'EOF'\nbody\nEO",
"a &&", "a ||", "a |", "echo 'open", `echo "open`, "echo $(a", "echo a \\", "a &&", "a ||", "a |", "echo 'open", `echo "open`, "echo $(a", "echo a \\",
} { } {
if _, err := parse(input); !errors.Is(err, errIncomplete) { if _, err := parse(input); !errors.Is(err, errIncomplete) {
@@ -1536,6 +1603,9 @@ func TestParse_SyntaxErrors(t *testing.T) {
"fi", "then", "done", "if a; then fi", "if ; then a; fi", "while a; do done", "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", "if a; then b; done", "for 1x in a; do b; done", "for x in a do b; done; x",
"a | | b", "| a", "a | | b", "| a",
"()", "a (", "a )", "echo (a)", ")", "(a) b", "if a; then b; fi c", "{ a; } b",
"case x in a b) c;; esac", "case x in ) a;; esac", "cat <<", "cat << ;",
"f() { a; } | b",
} { } {
_, err := parse(input) _, err := parse(input)
if err == nil || errors.Is(err, errIncomplete) { if err == nil || errors.Is(err, errIncomplete) {
@@ -1543,11 +1613,9 @@ func TestParse_SyntaxErrors(t *testing.T) {
} }
} }
for input, want := range map[string]string{ for input, want := range map[string]string{
"for x in a; do b; done | cat": "control structures in pipelines are not supported", "f() { a; } | b": "function definitions in pipelines are not supported",
"echo | while a; do b; done": "control structures in pipelines are not supported", "f() { a; } > out": "redirections of function definitions are not supported",
"if a; then b; fi > out": "redirections of control structures are not supported (near `>')", "if a; then b; fi c": "syntax error near unexpected token `c'",
"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 { if _, err := parse(input); err == nil || err.Error() != want {
t.Errorf("parse(%q) = %v, want %q", input, err, want) t.Errorf("parse(%q) = %v, want %q", input, err, want)
+284
View File
@@ -0,0 +1,284 @@
package main
// Shell options (set -e, set -x, set -o pipefail) and the builtins eval and
// exec.
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"slices"
"sort"
"strings"
"syscall"
)
var (
// errexit (set -e): exit when a command fails
errexit bool
// xtrace (set -x): print commands before they run
xtrace bool
// pipefail (set -o pipefail): the status of a pipeline is the one of
// the last command that failed
pipefail bool
// errexitOff is > 0 while commands run whose failure does not end the
// shell with set -e: conditions of if, while and until, commands
// negated with ! and all but the last command of && and || lists.
errexitOff int
)
// shellOptions are the long names of the options for set -o.
var shellOptions = []struct {
name string
letter byte
value *bool
}{
{"errexit", 'e', &errexit},
{"nounset", 'u', nil}, // always on in gosh
{"pipefail", 0, &pipefail},
{"xtrace", 'x', &xtrace},
}
// setOption sets an option by letter or long name.
func setOption(letter byte, name string, on bool) error {
for _, o := range shellOptions {
if letter != 0 && o.letter == letter || name != "" && o.name == name {
if o.value == nil {
if !on {
return errors.New("set: +u: unset variables are always an error in gosh")
}
return nil
}
*o.value = on
return nil
}
}
if name != "" {
return fmt.Errorf("set: %s: invalid option name", sanitize(name))
}
return fmt.Errorf("set: -%c: invalid option", letter)
}
// optionFlags returns the command line flags of gosh that pass the options
// on to a subshell.
func optionFlags() []string {
var flags []string
if errexit {
flags = append(flags, "-e")
}
if xtrace {
flags = append(flags, "-x")
}
if pipefail {
flags = append(flags, "-o", "pipefail")
}
return flags
}
// builtinSet implements set: set [-+ex] [-+o name] [--] [args], set -o and
// set +o (show the options), set without arguments (show the variables) and
// gosh's set VAR=value.
func builtinSet(args []string, stdout io.Writer) error {
args = args[1:]
if len(args) == 0 {
env := os.Environ()
sort.Strings(env)
for _, kv := range env {
name, value, _ := strings.Cut(kv, "=")
if isVarName(name) {
fmt.Fprintf(stdout, "%s=%s\n", name, shellQuote(value))
}
}
return nil
}
if len(args) == 1 && isAssignment(args[0]) {
// set VAR=value
name, value, _ := strings.Cut(args[0], "=")
os.Setenv(name, value)
return nil
}
for len(args) > 0 {
arg := args[0]
if arg == "--" {
positional = slices.Clone(args[1:])
return nil
}
if len(arg) < 2 || arg[0] != '-' && arg[0] != '+' {
break
}
on := arg[0] == '-'
args = args[1:]
if arg[1:] == "o" {
if len(args) == 0 {
printOptions(stdout, on)
return nil
}
if err := setOption(0, args[0], on); err != nil {
return err
}
args = args[1:]
continue
}
for k := 1; k < len(arg); k++ {
if arg[k] == 'o' {
// set -euo pipefail: the name is the next argument
if len(args) == 0 {
return fmt.Errorf("set: %co: option requires an argument", arg[0])
}
if err := setOption(0, args[0], on); err != nil {
return err
}
args = args[1:]
continue
}
if err := setOption(arg[k], "", on); err != nil {
return err
}
}
}
if len(args) > 0 {
positional = slices.Clone(args)
}
return nil
}
// onlyShows reports whether a builtin that changes the shell is called in a
// way that only shows something (trap -p, set -o), so that it can be used in
// a pipeline.
func onlyShows(args []string) bool {
switch args[0] {
case "trap":
return len(args) == 1 || args[1] == "-p" || args[1] == "-l"
case "set":
return len(args) == 1 || len(args) == 2 && (args[1] == "-o" || args[1] == "+o")
}
return false
}
// printOptions shows the options like set -o (human readable) or set +o
// (as commands).
func printOptions(w io.Writer, human bool) {
for _, o := range shellOptions {
on := o.value == nil || *o.value
switch {
case human && on:
fmt.Fprintf(w, "%-15s on\n", o.name)
case human:
fmt.Fprintf(w, "%-15s off\n", o.name)
case on:
fmt.Fprintf(w, "set -o %s\n", o.name)
default:
fmt.Fprintf(w, "set +o %s\n", o.name)
}
}
}
// shellQuote quotes s for the shell if it contains special characters.
func shellQuote(s string) string {
if s != "" && strings.Trim(s, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-+=.,:/@%") == "" {
return s
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// traceCommand prints a simple command for set -x.
func traceCommand(st stage) {
var words []string
for _, a := range st.assigns {
name, value, _ := strings.Cut(a, "=")
words = append(words, name+"="+shellQuote(value))
}
for _, arg := range st.args {
words = append(words, shellQuote(arg))
}
if len(words) > 0 {
fmt.Fprintln(stdio(2), "+", sanitize(strings.Join(words, " ")))
}
}
// checkErrexit ends the shell with set -e if the pipeline pl, the last one
// of its and-or list, failed. Compound commands are not checked themselves
// (the failing command in them is), but subshells and pipelines are.
func checkErrexit(pl *pipelineNode, err error) {
status := exitStatus(err)
if !errexit || errexitOff > 0 || pl.negate || status == 0 {
return
}
var (
lc loopControl
ret returnSignal
)
if errors.As(err, &lc) || errors.As(err, &ret) {
return
}
cmd := pl.cmd
if r, ok := cmd.(*redirNode); ok {
cmd = r.cmd
}
switch cmd.(type) {
case *simpleNode, *pipeNode, *subshellNode:
printError(err)
exitShell(status)
}
}
// builtinEval implements eval: the arguments, joined with blanks, run as a
// command in the shell.
func builtinEval(args []string) error {
text := strings.Join(args[1:], " ")
if strings.TrimSpace(text) == "" {
return nil
}
tree, err := parse(text)
if errors.Is(err, errIncomplete) {
return errors.New("eval: syntax error: unexpected end of input")
}
if err != nil {
return err
}
return tree.exec()
}
// builtinExec implements exec: without a command its redirections apply to
// the shell itself (exec > log), otherwise the command replaces the shell.
func builtinExec(args []string, fds [3]*os.File, assigns []string) error {
for fd, f := range fds {
if f != stdio(fd) {
if err := dupTo(f, fd); err != nil {
return fmt.Errorf("exec: %w", err)
}
}
}
if len(args) == 1 {
for _, a := range assigns {
name, value, _ := strings.Cut(a, "=")
os.Setenv(name, value)
}
return nil
}
path, err := exec.LookPath(args[1])
if err != nil {
err = fmt.Errorf("%s: %w", sanitize(args[1]), errCommandNotFound)
return execFailed(err)
}
releaseTerminal()
err = syscall.Exec(path, args[1:], append(os.Environ(), assigns...))
// Exec only returns if it failed
return execFailed(fmt.Errorf("exec: %s: %w", sanitize(args[1]), err))
}
// execFailed reports a failed exec. A non-interactive shell exits then, like
// sh.
func execFailed(err error) error {
if interactiveShell {
return err
}
printError(err)
exitShell(exitStatus(err))
return err
}
+366 -16
View File
@@ -9,13 +9,16 @@ gosh \- a simple shell written in Go
.RB [ \-i ] .RB [ \-i ]
.RB [ \-\-norc ] .RB [ \-\-norc ]
.RB [ \-\-noprofile ] .RB [ \-\-noprofile ]
.RI [ script ] .RI [ script
.RI [ args " ...]]"
.br .br
.B gosh .B gosh
.RB [ \-l ] .RB [ \-l ]
.RB [ \-\-noprofile ] .RB [ \-\-noprofile ]
.B \-c .B \-c
.I command .I command
.RI [ name
.RI [ args " ...]]"
.br .br
.B gosh .B gosh
.B \-version .B \-version
@@ -23,9 +26,10 @@ gosh \- a simple shell written in Go
.B gosh .B gosh
is a small command interpreter with a syntax close to is a small command interpreter with a syntax close to
.BR sh (1). .BR sh (1).
It supports command history, tab completion, variables, command It supports command history, tab completion, variables, positional
substitution, command lists, control structures, pipes, redirections, parameters, command substitution, command lists, control structures,
wildcards, brace expansion, aliases and job control. functions, pipes, redirections, wildcards, brace expansion, aliases and job
control.
.PP .PP
Without arguments and with a terminal on standard input, Without arguments and with a terminal on standard input,
.B gosh .B gosh
@@ -34,20 +38,44 @@ starts an interactive shell. With a
argument it runs the commands of that file, with argument it runs the commands of that file, with
.B \-c .B \-c
the given command string, and otherwise it reads commands from standard 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 .PP
.B gosh .B gosh
is a proof of concept and not meant for production use. is a proof of concept and not meant for production use.
.SH OPTIONS .SH OPTIONS
.TP .TP
.B \-c \fIcommand\fP .B \-c \fIcommand\fP [\fIname\fP [\fIargs\fP ...]]
Run Run
.I command .I command
and exit. and exit.
.I name
becomes
.BR $0 ,
the
.I args
become
.BR $1 ,
.BR $2 ", ..."
.TP .TP
.B \-i .B \-i
Force interactive mode, even if standard input is not a terminal. Force interactive mode, even if standard input is not a terminal.
.TP .TP
.BR \-e ", " \-x ", " "\-o \fIoption\fP"
Set options like
.BR set ,
e.g.
.B gosh \-e script
or
.BR "gosh \-o pipefail \-c" " ...".
.TP
.BR \-l ", " \-\-login .BR \-l ", " \-\-login
Act as a login shell and read the profile files (see Act as a login shell and read the profile files (see
.BR "STARTUP FILES" ). .BR "STARTUP FILES" ).
@@ -168,6 +196,119 @@ for an empty default.
.TP .TP
.B $? .B $?
The exit status of the last command. The exit status of the last command.
.SS Parameter expansion
.TP
.B ${#VAR}
The length of the value in characters.
.TP
.BR ${VAR#\fIpattern\fP} ", " ${VAR##\fIpattern\fP}
The value without the shortest (longest) prefix matching
.IR pattern .
.TP
.BR ${VAR%\fIpattern\fP} ", " ${VAR%%\fIpattern\fP}
The value without the shortest (longest) suffix matching
.IR pattern .
.TP
.BR ${VAR/\fIpattern\fP/\fIstring\fP} ", " ${VAR//\fIpattern\fP/\fIstring\fP}
The value with the first (every) longest match of
.I pattern
replaced by
.IR string ;
.B /#
and
.B /%
only match at the start and at the end.
.TP
.BR ${VAR:\fIoffset\fP} ", " ${VAR:\fIoffset\fP:\fIlength\fP}
A substring; offset and length are arithmetic expressions, negative values
count from the end.
.TP
.BR ${VAR:\-\fIword\fP} ", " ${VAR:=\fIword\fP} ", " ${VAR:+\fIword\fP} ", " ${VAR:?\fIword\fP}
.I word
if
.B VAR
is unset or empty (and assign it with
.BR := ),
.I word
if it is set and not empty, or an error with the message
.IR word .
Without the colon, only an unset variable counts.
.PP
The word is expanded but not split. In patterns,
.BR * ,
.B ?
and
.B [...]
are wildcards that also match
.BR / ;
quoted characters and values of variables match literally.
.SS Arithmetic expansion
.B $((\fIexpression\fP))
is replaced by the value of
.IR expression ,
computed with 64-bit integers. The operators of C are supported,
in order of precedence:
.BR "++ \-\-" ,
unary
.BR "+ \- ! ~" ,
.B **
(power),
.BR "* / %" ,
.BR "+ \-" ,
.BR "<< >>" ,
.BR "< <= > >=" ,
.BR "== !=" ,
.BR & ,
.BR ^ ,
.BR | ,
.BR && ,
.BR || ,
.BR "? :" ,
the assignments
.B = += \-= *= /= %= <<= >>= &= ^= |=
and
.BR , .
Variables can be used with or without
.BR $ ;
an unset variable is an error, an empty one is 0. Numbers can be
hexadecimal
.RB ( 0x1F )
or octal
.RB ( 017 ).
.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.
.TP
.B $!
The process ID of the last background job.
.SS Command substitution .SS Command substitution
.B $(\fIcommand\fP) .B $(\fIcommand\fP)
is replaced by the output of is replaced by the output of
@@ -175,7 +316,7 @@ is replaced by the output of
which runs in a subshell. Trailing newlines are removed. Unquoted, the which runs in a subshell. Trailing newlines are removed. Unquoted, the
output is split into words at blanks and newlines; inside double quotes it output is split into words at blanks and newlines; inside double quotes it
stays one word. Wildcards and operators in the output are not interpreted. stays one word. Wildcards and operators in the output are not interpreted.
Backticks and arithmetic expansion are not supported. Backticks are not supported.
.SS Tilde and brace expansion .SS Tilde and brace expansion
.B ~ .B ~
and and
@@ -269,6 +410,24 @@ Redirect standard output to where standard error goes.
.BR &> " \fIfile\fP, " &>> " \fIfile\fP" .BR &> " \fIfile\fP, " &>> " \fIfile\fP"
Write (or append) standard output and standard error to Write (or append) standard output and standard error to
.IR file . .IR file .
.TP
.BI << word
Here-document: the lines after the command, up to a line that is exactly
.IR word ,
are standard input. If no part of
.I word
is quoted, variables, command substitutions and backslashes before
.BR $ ,
.BR \(ga ,
.B \e
and newline are expanded in the lines.
.BI <<\- word
also removes leading tabs from the lines and the delimiter line.
.TP
.BI <<< " word"
Here-string:
.I word
and a newline are standard input.
.PP .PP
Redirections are evaluated from left to right. Only file descriptors 0, 1 Redirections are evaluated from left to right. Only file descriptors 0, 1
and 2 are supported. and 2 are supported.
@@ -278,6 +437,9 @@ and 2 are supported.
\fBfor\fP \fIname\fP \fBin\fP \fIwords\fP; \fBdo\fP \fIlist\fP; \fBdone\fP \fBfor\fP \fIname\fP \fBin\fP \fIwords\fP; \fBdo\fP \fIlist\fP; \fBdone\fP
\fBwhile\fP \fIlist\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 \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 \fIlist\fP \fB)\fP
\fB!\fP \fIcommand\fP \fB!\fP \fIcommand\fP
.fi .fi
.PP .PP
@@ -285,10 +447,64 @@ Control structures may span several lines; an incomplete command shows
the continuation prompt the continuation prompt
.BR "> " . .BR "> " .
.B ! .B !
negates the exit status of a command. Control structures cannot be used negates the exit status of a command.
in pipes, with redirections or in the background. .B for
without
.B in
loops over the positional parameters.
.PP
.B case .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.
.B (
.I list
.B )
runs the commands in a subshell, a new
.B gosh
process, so that changes of the directory, variables and functions only
apply there.
.PP
Redirections after a control structure
.RB ( "done < file" ,
.BR "fi > log 2>&1" )
apply to all commands in it, which still run in the current shell. In a
pipe and in the background
.RB ( & ),
a control structure runs in a subshell, like in
.BR sh (1).
The subshell knows the functions and positional parameters of the shell.
.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 .SS Aliases
In interactive shells, the first word of a command is replaced by its 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 alias. If the alias value ends with a blank, the next word is checked for
@@ -318,16 +534,52 @@ Exit the shell with status
.BI set " VAR" = value .BI set " VAR" = value
Set a variable. Set a variable.
.TP .TP
.BR set " [\fB\-+ex\fP] [\fB\-+o\fP \fIoption\fP] [\fB\-\-\fP] [\fIargs\fP ...]"
Set
.RB ( \- )
or unset
.RB ( + )
options and set the positional parameters to
.IR args .
.B \-e
.RB ( errexit )
exits the shell when a command fails, except in conditions of
.BR if ,
.B while
and
.BR until ,
negated commands and all but the last command of
.B &&
and
.B ||
lists.
.B \-x
.RB ( xtrace )
prints every simple command after expansion.
.B \-o pipefail
makes the status of a pipeline the one of the last command that failed.
.B \-u
.RB ( nounset )
is always on.
.B set \-o
and
.B set +o
show the options,
.B set
without arguments shows the variables.
.TP
.BI export " VAR" = value .BI export " VAR" = value
Export a variable. Export a variable.
.TP .TP
.BI unset " VAR" .BR unset " [\fB\-v\fP] \fIVAR\fP ..., " "unset \-f" " \fIname\fP ..."
Remove a variable. Remove variables, or functions.
.TP .TP
.BI source " file\fR, \fP" ". " file .BR source " \fIfile\fP [\fIargs\fP ...], " . " \fIfile\fP [\fIargs\fP ...]"
Run the commands of Run the commands of
.I file .I file
in the current shell. in the current shell, with
.I args
as positional parameters if given.
.TP .TP
.B jobs .B jobs
List background and stopped jobs. List background and stopped jobs.
@@ -342,9 +594,13 @@ Continue stopped job
.I n .I n
in the background. in the background.
.TP .TP
.BR wait " [\fB%\fP\fIn\fP]" .BR wait " [\fB%\fP\fIn\fP | \fIpid\fP]"
Wait for job Wait for job
.I n .IR n ,
the job with process
.I pid
(e.g.
.BR $! ),
or for all background jobs. or for all background jobs.
.TP .TP
.BR alias " [\fIname\fP[\fB=\fP\fIvalue\fP] ...]" .BR alias " [\fIname\fP[\fB=\fP\fIvalue\fP] ...]"
@@ -361,6 +617,81 @@ Leave the innermost (or
.IR n -th) .IR n -th)
loop, or continue with its next iteration. loop, or continue with its next iteration.
.TP .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
.BR eval " [\fIargs\fP ...]"
Join the arguments with blanks and run the result as a command in the
current shell.
.TP
.BR exec " [\fIcommand\fP [\fIargs\fP ...]]"
Replace the shell with
.IR command .
Without a command, the redirections of
.B exec
apply to the shell itself
.RB ( "exec > log 2>&1" ).
.TP
.BR trap " [\fB\-lp\fP] [[\fIaction\fP] \fIsignal\fP ...]"
Run
.I action
in the shell when
.I signal
arrives (after the running command) or, for
.B EXIT
.RB ( 0 ),
when the shell exits; a script with an EXIT trap also runs it when it is
ended by SIGINT, SIGTERM or SIGHUP. Signals:
.BR HUP ,
.BR INT ,
.BR QUIT ,
.BR TERM ,
.BR USR1 ,
.BR USR2 ,
.BR ALRM ,
.BR WINCH .
An empty
.I action
ignores the signal (also in started programs),
.B \-
restores the default.
.B trap
and
.B trap \-p
show the traps,
.B trap \-l
the signals.
.TP
.B help .B help
Show a summary of builtins and features. Show a summary of builtins and features.
.SH JOB CONTROL .SH JOB CONTROL
@@ -408,6 +739,13 @@ If the last command failed, its exit status is shown as
.BI [ N ] .BI [ N ]
before the before the
.BR $ . .BR $ .
.PP
Before each prompt, gosh reports the working directory to the terminal with
the OSC 7 escape sequence
.RB ( "ESC ] 7 ; file://" \fIhost\fP/\fIpath\fP " BEL" ),
so terminals like GNOME Terminal open new tabs and windows in the same
directory. Nothing is sent if the output is not a terminal or
.BR TERM=dumb .
.SH ENVIRONMENT .SH ENVIRONMENT
.TP .TP
.B GOSH_HISTFILE .B GOSH_HISTFILE
@@ -448,6 +786,10 @@ without argument.
.TP .TP
.B PATH .B PATH
Directories searched for commands. Directories searched for commands.
.TP
.B IFS
Field separators for
.BR read .
.SH FILES .SH FILES
.TP .TP
.I /etc/gosh/profile .I /etc/gosh/profile
@@ -493,6 +835,14 @@ cp config.yaml{,.bak}
make > build.log 2>&1 || echo "failed: $?" make > build.log 2>&1 || echo "failed: $?"
LANG=C sort file LANG=C sort file
alias ll=\(aqls \-l\(aq alias ll=\(aqls \-l\(aq
greet() { echo "Hello ${1:\-World}"; }
set \-euo pipefail; trap \(aqrm \-rf "$tmp"\(aq EXIT
echo ${file##*/} $((n + 1))
git ls\-files | while read \-r f; do wc \-l "$f"; done
while read \-r user rest; do echo "$user"; done < /etc/passwd
(cd build && make) > build.log 2>&1
case "$1" in start|stop) echo "$1ing" ;; *) echo "usage: $0 start|stop" ;; esac
read \-r \-p \(aqName? \(aq name
chsh \-s /usr/bin/gosh chsh \-s /usr/bin/gosh
.fi .fi
.SH SEE ALSO .SH SEE ALSO
+603 -79
View File
@@ -1,8 +1,8 @@
package main package main
// Parser and executor for command lines and scripts: lists (; & && || and // Parser and executor for command lines and scripts: lists (; & && || and
// newlines), pipelines, ! and the control structures if, for, while and // newlines), pipelines, ! and the compound commands if, for, while, until,
// until. Simple commands (with their pipes and redirections) are kept as // 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 // 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 // executed, so that e.g. the variable of a for loop is expanded anew in
// every iteration. // every iteration.
@@ -11,6 +11,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"slices"
"strconv" "strconv"
"strings" "strings"
"syscall" "syscall"
@@ -48,7 +49,13 @@ var aliasExpansion = true
// reserved words that end a list; they are only recognized as the first word // reserved words that end a list; they are only recognized as the first word
// of a command. // 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 // Lexer
@@ -57,7 +64,7 @@ type tokenKind int
const ( const (
tWord tokenKind = iota tWord tokenKind = iota
tOp // ; & && || | or newline tOp // ; ;; & && || | ( ) or newline
) )
type rawToken struct { type rawToken struct {
@@ -126,6 +133,9 @@ func lex(src string) ([]rawToken, error) {
switch { switch {
case ch == ' ' || ch == '\t': case ch == ' ' || ch == '\t':
i++ i++
case ch == ';' && next == ';':
op(";;", i)
i += 2
case ch == '\n' || ch == ';': case ch == '\n' || ch == ';':
op(string(ch), i) op(string(ch), i)
i++ i++
@@ -137,7 +147,7 @@ func lex(src string) ([]rawToken, error) {
case ch == '|' && next == '|', ch == '&' && next == '&': case ch == '|' && next == '|', ch == '&' && next == '&':
op(src[i:i+2], i) op(src[i:i+2], i)
i += 2 i += 2
case ch == '|', ch == '&' && next != '>': case ch == '|', ch == '&' && next != '>', ch == '(', ch == ')':
op(string(ch), i) op(string(ch), i)
i++ i++
default: default:
@@ -167,6 +177,12 @@ func lexWord(src string, i int) (int, error) {
return 0, errIncomplete return 0, errIncomplete
} }
j++ j++
if isSubstStart(src, j) {
// \$(...) is literal text, but its ( and ) belong to the word
if end := substEnd(src, j); end >= 0 {
j = end
}
}
case isSubstStart(src, j): case isSubstStart(src, j):
end := substEnd(src, j) end := substEnd(src, j)
if end < 0 { if end < 0 {
@@ -174,8 +190,8 @@ func lexWord(src string, i int) (int, error) {
} }
j = end j = end
case ch == '$' && j+1 < len(src) && src[j+1] == '{': case ch == '$' && j+1 < len(src) && src[j+1] == '{':
if k := strings.IndexByte(src[j:], '}'); k >= 0 { if end := paramEnd(src, j); end >= 0 {
j += k j = end
} }
case quote == '"': case quote == '"':
if ch == '"' { if ch == '"' {
@@ -183,7 +199,7 @@ func lexWord(src string, i int) (int, error) {
} }
case ch == '"' || ch == '\'': case ch == '"' || ch == '\'':
quote = ch quote = ch
case ch == ' ' || ch == '\t' || ch == '\n' || ch == ';' || ch == '|': case ch == ' ' || ch == '\t' || ch == '\n' || ch == ';' || ch == '|' || ch == '(' || ch == ')':
return j, nil return j, nil
case ch == '&': case ch == '&':
// &>, >& and <& are redirections, not the & operator // &>, >& and <& are redirections, not the & operator
@@ -221,6 +237,7 @@ type listEntry struct {
type andOrNode struct { type andOrNode struct {
first *pipelineNode first *pipelineNode
rest []andOrPart rest []andOrPart
text string // the source, to run it in a subshell in the background
} }
type andOrPart struct { type andOrPart struct {
@@ -228,8 +245,8 @@ type andOrPart struct {
pipeline *pipelineNode pipeline *pipelineNode
} }
// pipelineNode is a simple command (whose text may contain a pipeline) or a // pipelineNode is a pipeline (a simpleNode or pipeNode) or a single
// control structure, optionally negated with !. // compound command, optionally negated with !.
type pipelineNode struct { type pipelineNode struct {
negate bool negate bool
cmd node cmd node
@@ -240,6 +257,33 @@ type simpleNode struct {
text string text string
} }
// pipeNode is a pipeline that contains compound commands. Like all commands
// of a pipeline in sh, they run in subshells (new gosh processes), since
// the commands of a pipeline run concurrently.
type pipeNode struct {
elems []pipeElem
text string
}
type pipeElem struct {
text string // the source of the command, with its redirections
compound bool
subshell string // for ( list ): the list, which runs in the subshell directly
}
// redirNode is a compound command with redirections; they apply to all
// commands in it.
type redirNode struct {
cmd node
redirs string // the source text of the redirections
}
// subshellNode is ( list ): the list runs in a new gosh process, so that it
// cannot change the shell.
type subshellNode struct {
text string // the source of the list
}
type ifNode struct { type ifNode struct {
conds []*listNode conds []*listNode
bodies []*listNode bodies []*listNode
@@ -259,6 +303,27 @@ type whileNode struct {
body *listNode 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. // singleSimple returns the simple command if the list is just that.
func (l *listNode) singleSimple() *simpleNode { func (l *listNode) singleSimple() *simpleNode {
if len(l.items) != 1 || l.items[0].background { if len(l.items) != 1 || l.items[0].background {
@@ -284,6 +349,10 @@ type parser struct {
// parse parses a command line or script. It returns errIncomplete if the // parse parses a command line or script. It returns errIncomplete if the
// input ends inside a construct. // input ends inside a construct.
func parse(src string) (*listNode, error) { func parse(src string) (*listNode, error) {
src, err := extractHeredocs(src)
if err != nil {
return nil, err
}
src, continued := joinContinuationLines(src) src, continued := joinContinuationLines(src)
if continued { if continued {
return nil, errIncomplete return nil, errIncomplete
@@ -356,7 +425,7 @@ func (p *parser) parseList(terms map[string]bool) (*listNode, error) {
for { for {
p.skipNewlines() p.skipNewlines()
t := p.peek() t := p.peek()
if t == nil || t.kind == tWord && terms[t.text] { if t == nil || terms[t.text] {
return list, nil return list, nil
} }
ao, err := p.parseAndOr() ao, err := p.parseAndOr()
@@ -371,16 +440,12 @@ func (p *parser) parseList(terms map[string]bool) (*listNode, error) {
case t.kind == tOp && (t.text == ";" || t.text == "&" || t.text == "\n"): case t.kind == tOp && (t.text == ";" || t.text == "&" || t.text == "\n"):
p.pos++ p.pos++
entry.background = t.text == "&" entry.background = t.text == "&"
case t.kind == tWord && terms[t.text]: case terms[t.text]:
// e.g. "fi" right after the end of a nested control structure // e.g. "fi" right after the end of a nested control structure,
// or ;; after a command in a case clause
default: default:
return nil, p.unexpected(t) 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) list.items = append(list.items, entry)
} }
} }
@@ -397,6 +462,7 @@ func (p *parser) nonEmpty(list *listNode) error {
} }
func (p *parser) parseAndOr() (*andOrNode, error) { func (p *parser) parseAndOr() (*andOrNode, error) {
start := p.peek().start
first, err := p.parsePipeline() first, err := p.parsePipeline()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -405,6 +471,7 @@ func (p *parser) parseAndOr() (*andOrNode, error) {
for { for {
t := p.peek() t := p.peek()
if t == nil || t.kind != tOp || (t.text != "&&" && t.text != "||") { if t == nil || t.kind != tOp || (t.text != "&&" && t.text != "||") {
ao.text = p.src[start:p.toks[p.pos-1].end]
return ao, nil return ao, nil
} }
p.pos++ p.pos++
@@ -420,79 +487,188 @@ func (p *parser) parseAndOr() (*andOrNode, error) {
} }
} }
// parsePipeline parses [!] command [| command]... A pipeline of simple
// commands stays one simpleNode (its text is split into the commands when it
// runs); a pipeline with compound commands becomes a pipeNode.
func (p *parser) parsePipeline() (*pipelineNode, error) { func (p *parser) parsePipeline() (*pipelineNode, error) {
pl := &pipelineNode{} pl := &pipelineNode{}
if p.peekWord("!") { if p.peekWord("!") {
pl.negate = true pl.negate = true
p.pos++ p.pos++
} }
t := p.peek() var (
if t == nil { elems []pipeElem
return nil, errIncomplete cmds []node
} start = -1
if t.kind == tOp || terminators[t.text] { )
return nil, p.unexpected(t) for {
} t := p.peek()
if t == nil {
var err error return nil, errIncomplete
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)
}
} }
if t.kind == tOp && t.text != "(" || t.kind == tWord && terminators[t.text] {
return nil, p.unexpected(t)
}
if start < 0 {
start = t.start
}
cmd, compound, err := p.parseCommand()
if err != nil {
return nil, err
}
elem := pipeElem{text: p.src[t.start:p.toks[p.pos-1].end], compound: compound}
if sub, ok := cmd.(*subshellNode); ok {
elem.subshell = sub.text
}
elems = append(elems, elem)
cmds = append(cmds, cmd)
if t := p.peek(); t == nil || t.kind != tOp || t.text != "|" {
break
}
p.pos++
p.skipNewlines()
if p.peek() == nil {
return nil, errIncomplete
}
}
text := p.src[start:p.toks[p.pos-1].end]
compound := false
for i, e := range elems {
if _, ok := cmds[i].(*funcDefNode); ok && len(elems) > 1 {
return nil, errors.New("function definitions in pipelines are not supported")
}
compound = compound || e.compound
}
switch {
case len(elems) == 1:
pl.cmd = cmds[0]
case !compound:
pl.cmd = &simpleNode{text: text}
default:
pl.cmd = &pipeNode{elems: elems, text: text}
} }
return pl, nil return pl, nil
} }
// parseSimple parses a pipeline of simple commands and returns its text. // parseCommand parses a simple command, a compound command with its
// redirections or a function definition, and reports whether it is not a
// simple command.
func (p *parser) parseCommand() (node, bool, error) {
t := p.peek()
start := t.start
if name, ok := p.funcDefName(); ok {
def, err := p.parseFuncDef(name, start)
if err != nil {
return nil, true, err
}
if t := p.peek(); t != nil && t.kind == tWord && !terminators[t.text] {
return nil, true, errors.New("redirections of function definitions are not supported")
}
return def, true, nil
}
if !isCompoundStart(t) {
cmd, err := p.parseSimple()
return cmd, false, err
}
cmd, err := p.parseCompound()
if err != nil {
return nil, true, err
}
redirs, err := p.parseRedirections()
if err != nil {
return nil, true, err
}
if redirs != "" {
cmd = &redirNode{cmd: cmd, redirs: redirs}
}
return cmd, true, nil
}
// isCompoundStart reports whether a compound command starts at t.
func isCompoundStart(t *rawToken) bool {
return t.kind == tWord && compoundStarts[t.text] || t.kind == tOp && t.text == "("
}
// parseRedirections parses the redirections after a compound command and
// returns their source text (expanded when the command runs).
func (p *parser) parseRedirections() (string, error) {
start, end := -1, -1
for t := p.peek(); t != nil && t.kind == tWord && !terminators[t.text]; t = p.peek() {
needsTarget, ok := redirectionWord(t.text)
if !ok {
return "", syntaxError(t.text)
}
if start < 0 {
start = t.start
}
end = t.end
p.pos++
if needsTarget {
t = p.peek()
if t == nil || t.kind != tWord {
if t == nil {
return "", syntaxError("newline")
}
return "", p.unexpected(t)
}
end = t.end
p.pos++
}
}
if start < 0 {
return "", nil
}
return p.src[start:end], nil
}
// redirectionWord reports whether word starts with a redirection operator
// (optionally after a file descriptor), and whether the target is the next
// word (2> file instead of 2>file).
func redirectionWord(word string) (needsTarget, ok bool) {
w := strings.TrimLeft(word, "0123456789")
for _, op := range []string{"&>>", "&>", "<<<", ">>", ">&", "<&", ">", "<"} {
if strings.HasPrefix(w, op) {
if op[0] == '&' && len(w) != len(word) {
return false, false
}
return w == op, true
}
}
return false, false
}
// parseSimple parses a simple command (without pipes) and returns its text.
func (p *parser) parseSimple() (*simpleNode, error) { func (p *parser) parseSimple() (*simpleNode, error) {
start := p.toks[p.pos].start start := p.toks[p.pos].start
end := start end := start
for t := p.peek(); t != nil; t = p.peek() { for t := p.peek(); t != nil && t.kind == tWord; t = p.peek() {
if t.kind == tWord {
end = t.end
p.pos++
continue
}
if t.text != "|" {
break
}
p.pos++
end = t.end end = t.end
p.skipNewlines() p.pos++
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 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()
case "(":
return p.parseSubshell()
default: // {
return p.parseGroup()
}
}
func (p *parser) parseIf() (*ifNode, error) { func (p *parser) parseIf() (*ifNode, error) {
p.pos++ // if p.pos++ // if
n := &ifNode{} n := &ifNode{}
@@ -604,6 +780,167 @@ func (p *parser) parseWhile() (*whileNode, error) {
return n, nil 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
}
// parseSubshell parses ( list ).
func (p *parser) parseSubshell() (*subshellNode, error) {
p.pos++ // (
start := p.toks[p.pos-1].end
body, err := p.parseList(map[string]bool{")": true})
if err == nil {
err = p.nonEmpty(body)
}
if err != nil {
return nil, err
}
t := p.peek()
if t == nil {
return nil, errIncomplete
}
if t.kind != tOp || t.text != ")" {
return nil, p.unexpected(t)
}
p.pos++
return &subshellNode{text: p.src[start:t.start]}, 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
// ).
func (p *parser) parsePatterns() ([]string, error) {
if t := p.peek(); t.kind == tOp && t.text == "(" {
p.pos++ // the optional ( before the first pattern
}
var patterns []string
for {
t := p.peek()
if t == nil {
return nil, errIncomplete
}
if t.kind != tWord {
return nil, p.unexpected(t)
}
patterns = append(patterns, t.text)
p.pos++
t = p.peek()
switch {
case t == nil:
return nil, errIncomplete
case t.kind == tOp && t.text == "|":
p.pos++
case t.kind == tOp && t.text == ")":
p.pos++
return patterns, nil
default:
return nil, p.unexpected(t)
}
}
}
// funcDefName reports whether a function definition starts at the current
// token: name(), or function name [()]. It consumes the tokens up to the
// body.
func (p *parser) funcDefName() (string, bool) {
start := p.pos
if t := p.peek(); t.kind != tWord {
return "", false
} else if t.text == "function" {
p.pos++
if t := p.peek(); t == nil || t.kind != tWord || !isFuncName(t.text) {
p.pos = start
return "", false
}
}
name := p.peek().text
isOp := func(i int, text string) bool {
return i < len(p.toks) && p.toks[i].kind == tOp && p.toks[i].text == text
}
switch {
case isOp(p.pos+1, "(") && isOp(p.pos+2, ")") && isFuncName(name):
p.pos += 3
case p.pos > start:
p.pos++ // function name
default:
return "", false
}
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 !isCompoundStart(t) {
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". // parseLoopBody parses "do list done".
func (p *parser) parseLoopBody() (*listNode, error) { func (p *parser) parseLoopBody() (*listNode, error) {
if err := p.expect("do"); err != nil { if err := p.expect("do"); err != nil {
@@ -623,11 +960,14 @@ func (p *parser) parseLoopBody() (*listNode, error) {
// Execution // Execution
// isAbort reports whether err stops the execution of the enclosing lists: // isAbort reports whether err stops the execution of the enclosing lists:
// break, continue, or a command that was interrupted with Ctrl+C (like in // break, continue, return, or a command that was interrupted with Ctrl+C
// bash, Ctrl+C aborts the whole command line, e.g. a loop). // (like in bash, Ctrl+C aborts the whole command line, e.g. a loop).
func isAbort(err error) bool { func isAbort(err error) bool {
var lc loopControl var (
return errors.As(err, &lc) || exitStatus(err) == 128+int(syscall.SIGINT) 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 // silent returns an error with only the exit status of err, for errors that
@@ -644,11 +984,12 @@ func (l *listNode) exec() error {
for _, entry := range l.items { for _, entry := range l.items {
printError(err) printError(err)
if entry.background { if entry.background {
err = entry.andOr.first.cmd.(*simpleNode).run(true) err = entry.andOr.runBackground()
} else { } else {
err = entry.andOr.exec() err = entry.andOr.exec()
} }
lastStatus = exitStatus(err) lastStatus = exitStatus(err)
runPendingTraps()
if isAbort(err) { if isAbort(err) {
return err return err
} }
@@ -656,9 +997,18 @@ func (l *listNode) exec() error {
return err return err
} }
// exec runs the and-or list. With set -e, only a failure of the last
// pipeline ends the shell.
func (a *andOrNode) exec() error { func (a *andOrNode) exec() error {
last := a.first
if len(a.rest) > 0 {
errexitOff++
}
err := a.first.exec() err := a.first.exec()
for _, part := range a.rest { if len(a.rest) > 0 {
errexitOff--
}
for i, part := range a.rest {
lastStatus = exitStatus(err) lastStatus = exitStatus(err)
if isAbort(err) { if isAbort(err) {
return err return err
@@ -667,12 +1017,50 @@ func (a *andOrNode) exec() error {
continue continue
} }
printError(err) printError(err)
isLast := i == len(a.rest)-1
if !isLast {
errexitOff++
}
err = part.pipeline.exec() err = part.pipeline.exec()
if !isLast {
errexitOff--
}
last = part.pipeline
}
if last == a.lastPipeline() {
checkErrexit(last, err)
} }
return err return err
} }
func (a *andOrNode) lastPipeline() *pipelineNode {
if len(a.rest) == 0 {
return a.first
}
return a.rest[len(a.rest)-1].pipeline
}
// runBackground starts the and-or list as a background job. Pipelines run
// like in the foreground, everything else in a subshell.
func (a *andOrNode) runBackground() error {
if len(a.rest) == 0 && !a.first.negate {
switch cmd := a.first.cmd.(type) {
case *simpleNode:
return cmd.run(true)
case *pipeNode:
return cmd.run(true)
case *subshellNode:
return runPipeline([]stage{{script: cmd.text}}, a.text, true)
}
}
return runPipeline([]stage{{script: a.text}}, a.text, true)
}
func (pl *pipelineNode) exec() error { func (pl *pipelineNode) exec() error {
if pl.negate {
errexitOff++
defer func() { errexitOff-- }()
}
err := pl.cmd.exec() err := pl.cmd.exec()
if !pl.negate || isAbort(err) { if !pl.negate || isAbort(err) {
return err return err
@@ -716,7 +1104,9 @@ func (s *simpleNode) run(background bool) error {
func (n *ifNode) exec() error { func (n *ifNode) exec() error {
for i, cond := range n.conds { for i, cond := range n.conds {
errexitOff++
err := cond.exec() err := cond.exec()
errexitOff--
if isAbort(err) { if isAbort(err) {
return err return err
} }
@@ -759,7 +1149,9 @@ func (n *whileNode) exec() error {
var result error var result error
for { for {
errexitOff++
err := n.cond.exec() err := n.cond.exec()
errexitOff--
if isAbort(err) { if isAbort(err) {
return err return err
} }
@@ -779,7 +1171,8 @@ func (n *whileNode) exec() error {
} }
func (n *forNode) 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 { if n.hasIn {
var err error var err error
if words, err = expandWordList(n.words); err != nil { if words, err = expandWordList(n.words); err != nil {
@@ -804,6 +1197,137 @@ func (n *forNode) exec() error {
return result return result
} }
func (n *groupNode) exec() error {
return n.body.exec()
}
func (n *subshellNode) exec() error {
return runPipeline([]stage{{script: n.text}}, "("+strings.TrimSpace(n.text)+")", false)
}
// exec runs the compound command with stdin, stdout and stderr of the shell
// redirected.
func (n *redirNode) exec() error {
fds, opened, err := openRedirections(n.redirs)
if err != nil {
return err
}
saved := ioOverride
ioOverride = fds
defer func() {
ioOverride = saved
for _, f := range opened {
f.Close()
}
}()
return n.cmd.exec()
}
func (n *pipeNode) exec() error {
return n.run(false)
}
// run builds the stages of the pipeline: simple commands are expanded,
// compound commands run in subshells.
func (n *pipeNode) run(background bool) error {
substStatus = 0
var stages []stage
for _, e := range n.elems {
switch {
case e.subshell != "":
stages = append(stages, stage{script: e.subshell})
case e.compound:
stages = append(stages, stage{script: e.text})
default:
text := e.text
if interactiveShell && aliasExpansion {
if tree, err := parse(expandAliases(text)); err == nil {
if simple := tree.singleSimple(); simple != nil {
text = simple.text
}
}
}
st, err := expandStages(text)
if err != nil {
return err
}
if len(st) == 0 {
st = []stage{{}}
}
stages = append(stages, st...)
}
}
return runPipeline(stages, n.text, background)
}
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
}
return globMatch(strings.Join(glob, " "), word), nil
}
// expandWordList expands the words of a for loop like the arguments of a // expandWordList expands the words of a for loop like the arguments of a
// command (braces, ~, variables, $(...), wildcards). // command (braces, ~, variables, $(...), wildcards).
func expandWordList(text string) ([]string, error) { func expandWordList(text string) ([]string, error) {
+219
View File
@@ -0,0 +1,219 @@
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)
}
// Ctrl+C or a trapped signal ends a read from the terminal
line, literal, eof, err := readInputLine(stdin, raw, terminal && (interactiveShell || len(trapChans) > 0))
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:
}
if hasPendingSignal() {
return 0, statusError(128 + int(syscall.SIGINT))
}
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
}
+20 -11
View File
@@ -2,7 +2,8 @@ package main
// Command substitution: $(command) is replaced by the output of command, // 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 // 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 ( import (
"bytes" "bytes"
@@ -67,24 +68,32 @@ func substEnd(s string, i int) int {
return -1 return -1
} }
// commandSubst runs command in a subshell and returns its output without // subshellCommand returns a command that runs script in a new gosh process
// trailing newlines. stdin and stderr are those of the shell. // with the same $0, the given positional parameters and the functions of the
func commandSubst(command string) (string, error) { // shell. (Variables need not be passed: they are all in the environment.)
if strings.HasPrefix(command, "(") { func subshellCommand(script string, params []string) (*exec.Cmd, error) {
return "", errors.New("arithmetic expansion $((...)) is not supported")
}
path := subshellPath path := subshellPath
if path == "" { if path == "" {
var err error var err error
if path, err = os.Executable(); err != nil { if path, err = os.Executable(); err != nil {
return "", err return nil, err
} }
} }
args := append(optionFlags(), "-c", functionDefs()+script, scriptName)
args = append(args, 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) {
var out bytes.Buffer var out bytes.Buffer
cmd := exec.Command(path, "-c", command) cmd, err := subshellCommand(command, positional)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, &out, os.Stderr if err != nil {
err := cmd.Run() return "", err
}
cmd.Stdin, cmd.Stdout, cmd.Stderr = stdio(0), &out, stdio(2)
err = cmd.Run()
substStatus = exitStatus(err) substStatus = exitStatus(err)
if substStatus == 128+int(syscall.SIGINT) { if substStatus == 128+int(syscall.SIGINT) {
// Ctrl+C aborts the whole command, like in bash // Ctrl+C aborts the whole command, like in bash
+1 -2
View File
@@ -94,8 +94,7 @@ func TestExecInput_CommandSubstitution(t *testing.T) {
} }
for input, wantErr := range map[string]string{ for input, wantErr := range map[string]string{
"echo $(echo a": "syntax error: unexpected end of input", "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 { if err := execInput(input); err == nil || err.Error() != wantErr {
t.Errorf("%q: error %v, want %q", input, err, wantErr) t.Errorf("%q: error %v, want %q", input, err, wantErr)
+286
View File
@@ -0,0 +1,286 @@
package main
// The trap builtin. A trapped signal is only noted when it arrives; its
// command runs in the shell at the next safe point: after the running
// command has finished (like in sh, a shell waiting for a foreground command
// handles the signal afterwards).
import (
"fmt"
"io"
"os"
"os/signal"
"sort"
"strconv"
"strings"
"sync"
"syscall"
)
// trapSignals are the signals that can be trapped, besides EXIT.
var trapSignals = map[string]syscall.Signal{
"HUP": syscall.SIGHUP,
"INT": syscall.SIGINT,
"QUIT": syscall.SIGQUIT,
"TERM": syscall.SIGTERM,
"USR1": syscall.SIGUSR1,
"USR2": syscall.SIGUSR2,
"ALRM": syscall.SIGALRM,
"WINCH": syscall.SIGWINCH,
}
// fatalSignals end a non-interactive shell; with an EXIT trap the shell
// catches them to run it first.
var fatalSignals = []string{"HUP", "INT", "TERM"}
var (
// traps maps EXIT and signal names to their commands ("" = ignore).
traps = map[string]string{}
// trapChans are the channels of the signals the shell catches.
trapChans = map[string]chan os.Signal{}
pendingMu sync.Mutex
pendingSignals []string
inTrap bool
// interruptChan swallows SIGINT in an interactive shell, so that Ctrl+C
// only stops the running command.
interruptChan = make(chan os.Signal, 1)
)
// signalName returns the canonical name (EXIT, INT, ...) of a signal given
// by name (with or without SIG) or number, or "".
func signalName(spec string) string {
s := strings.ToUpper(strings.TrimPrefix(strings.ToUpper(spec), "SIG"))
if s == "EXIT" || s == "0" {
return "EXIT"
}
if _, ok := trapSignals[s]; ok {
return s
}
if n, err := strconv.Atoi(spec); err == nil {
for name, sig := range trapSignals {
if int(sig) == n {
return name
}
}
}
return ""
}
// builtinTrap implements trap [-lp] [[action] signal ...].
func builtinTrap(args []string, stdout io.Writer) error {
args = args[1:]
switch {
case len(args) == 0 || args[0] == "-p":
names := args
if len(names) > 0 {
names = names[1:]
}
return printTraps(stdout, names)
case args[0] == "-l":
names := []string{"EXIT"}
for name := range trapSignals {
names = append(names, name)
}
sort.Slice(names, func(i, j int) bool { return signalNumber(names[i]) < signalNumber(names[j]) })
for _, name := range names {
fmt.Fprintf(stdout, "%2d) %s\n", signalNumber(name), name)
}
return nil
case args[0] == "--":
args = args[1:]
}
action := "-"
if len(args) > 1 || len(args) == 1 && signalName(args[0]) == "" {
action, args = args[0], args[1:]
}
if len(args) == 0 {
return fmt.Errorf("trap: usage: trap [-lp] [[action] signal ...]")
}
var err error
for _, spec := range args {
name := signalName(spec)
if name == "" {
err = fmt.Errorf("trap: %s: invalid signal specification", sanitize(spec))
continue
}
setTrap(name, action)
}
return err
}
func signalNumber(name string) int {
if name == "EXIT" {
return 0
}
return int(trapSignals[name])
}
func printTraps(w io.Writer, only []string) error {
var names []string
for name := range traps {
names = append(names, name)
}
if len(only) > 0 {
names = nil
for _, spec := range only {
name := signalName(spec)
if name == "" {
return fmt.Errorf("trap: %s: invalid signal specification", sanitize(spec))
}
if _, ok := traps[name]; ok {
names = append(names, name)
}
}
}
sort.Slice(names, func(i, j int) bool { return signalNumber(names[i]) < signalNumber(names[j]) })
for _, name := range names {
fmt.Fprintf(w, "trap -- %s %s\n", quoteAlias(traps[name]), name)
}
return nil
}
// setTrap sets the action of a trap: "-" restores the default, "" ignores
// the signal, anything else is a command.
func setTrap(name, action string) {
if action == "-" {
delete(traps, name)
} else {
traps[name] = action
}
if name == "EXIT" {
updateFatalCatch()
return
}
sig := trapSignals[name]
stopCatching(name)
switch action {
case "-":
signal.Reset(sig)
restoreShellSignal(sig)
if _, exit := traps["EXIT"]; exit {
updateFatalCatch()
}
case "":
// Ignored signals stay ignored in the programs the shell starts
signal.Ignore(sig)
default:
catch(name)
}
}
// catch makes the shell note the signal, to run its trap later.
func catch(name string) {
if _, ok := trapChans[name]; ok {
return
}
ch := make(chan os.Signal, 4)
trapChans[name] = ch
signal.Notify(ch, trapSignals[name])
go func() {
for range ch {
pendingMu.Lock()
pendingSignals = append(pendingSignals, name)
pendingMu.Unlock()
}
}()
}
func stopCatching(name string) {
if ch, ok := trapChans[name]; ok {
signal.Stop(ch)
close(ch)
delete(trapChans, name)
}
}
// restoreShellSignal installs the handling the shell itself needs for sig
// again after signal.Reset.
func restoreShellSignal(sig syscall.Signal) {
if sig == syscall.SIGINT && interactiveShell {
signal.Notify(interruptChan, os.Interrupt)
}
}
// updateFatalCatch catches the fatal signals in a non-interactive shell with
// an EXIT trap (unless they have traps of their own), so that the EXIT trap
// runs before the shell ends.
func updateFatalCatch() {
if interactiveShell {
return
}
_, exitTrap := traps["EXIT"]
for _, name := range fatalSignals {
if _, own := traps[name]; own {
continue
}
if exitTrap {
catch(name)
} else {
stopCatching(name)
}
}
}
// hasPendingSignal reports whether a caught signal waits for its trap.
func hasPendingSignal() bool {
pendingMu.Lock()
defer pendingMu.Unlock()
return len(pendingSignals) > 0
}
// runPendingTraps runs the traps of the signals that arrived. A fatal signal
// without a trap of its own (caught for the EXIT trap) ends the shell.
func runPendingTraps() {
if inTrap {
return
}
pendingMu.Lock()
names := pendingSignals
pendingSignals = nil
pendingMu.Unlock()
for _, name := range names {
if action, ok := traps[name]; ok {
if action != "" {
runTrap(action)
}
continue
}
// Caught only for the EXIT trap: run it and die from the signal
runExitTrap()
sig := trapSignals[name]
signal.Reset(sig)
releaseTerminal()
syscall.Kill(os.Getpid(), sig)
os.Exit(128 + int(sig))
}
}
// runTrap runs the command of a trap; $? is kept.
func runTrap(action string) {
saved := lastStatus
inTrap = true
defer func() { inTrap = false }()
tree, err := parse(action)
if err == nil {
err = tree.exec()
}
printError(err)
lastStatus = saved
}
// runExitTrap runs the EXIT trap (once) before the shell exits.
func runExitTrap() {
action, ok := traps["EXIT"]
if !ok {
return
}
delete(traps, "EXIT")
if action != "" {
runTrap(action)
}
}