Compare commits
No files matched your search
@@ -1,2 +1,3 @@
|
||||
gosh
|
||||
dist/
|
||||
packaging/man/gosh.1.gz
|
||||
@@ -12,6 +12,8 @@ before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- go test ./...
|
||||
# Man page with the release version, gzipped for /usr/share/man
|
||||
- sh -c 'sed "s/@VERSION@/{{ .Version }}/" packaging/man/gosh.1 | gzip -9n > packaging/man/gosh.1.gz'
|
||||
|
||||
builds:
|
||||
- id: gosh
|
||||
@@ -50,6 +52,10 @@ archives:
|
||||
- src: README.md
|
||||
info:
|
||||
mode: 0644
|
||||
- src: packaging/man/gosh.1.gz
|
||||
dst: man/gosh.1.gz
|
||||
info:
|
||||
mode: 0644
|
||||
|
||||
nfpms:
|
||||
- id: gosh
|
||||
@@ -75,6 +81,10 @@ nfpms:
|
||||
dst: /usr/share/doc/gosh/README.md
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: packaging/man/gosh.1.gz
|
||||
dst: /usr/share/man/man1/gosh.1.gz
|
||||
file_info:
|
||||
mode: 0644
|
||||
scripts:
|
||||
postinstall: packaging/scripts/postinstall.sh
|
||||
postremove: packaging/scripts/postremove.sh
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
`gosh` eine einfache Shell, geschrieben in golang.
|
||||
`gosh` eine Shell, in golang geschrieben.
|
||||
|
||||
```
|
||||
gosh - Go Shell
|
||||
Version: 0.2.1 (branch: main, commit: 03e791c)
|
||||
Version: 0.13.0 (branch: main, commit: f79fb0b)
|
||||
|
||||
Built-in commands:
|
||||
cd [dir] - Change directory (cd with no args goes to home)
|
||||
@@ -11,7 +11,32 @@ Built-in commands:
|
||||
exit [code] - Exit shell with status code (default: $?)
|
||||
quit [code] - Quit shell with status code (default: $?)
|
||||
set VAR=val - Set environment variable
|
||||
set [-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
|
||||
unset VAR - Remove a variable (unset -f name: a function)
|
||||
source file [args] - Run the commands of file in this shell (also: . file)
|
||||
jobs - List background and stopped jobs
|
||||
fg [%n] - Continue job n (default: current job) in the foreground
|
||||
bg [%n] - Continue stopped job n in the background
|
||||
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')
|
||||
unalias name - Remove an alias (unalias -a: all aliases)
|
||||
: - Do nothing (exit status 0)
|
||||
break [n] - Leave the innermost (or n-th) for/while/until loop
|
||||
continue [n] - Continue with the next iteration of a loop
|
||||
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
|
||||
|
||||
Features:
|
||||
@@ -23,26 +48,73 @@ Features:
|
||||
override with $GOSH_HISTFILE); lines starting with a space are not saved
|
||||
- Tab completion for commands and file paths
|
||||
- Tilde expansion (~, ~/path) at the start of unquoted words
|
||||
- Colored prompt (user@host green, red for root; directory blue),
|
||||
configurable with $GOSH_COLORS, e.g. GOSH_COLORS='user=1;33:path=36'
|
||||
(keys user, root, path; values are ANSI SGR codes, empty = no color);
|
||||
disabled with $NO_COLOR, TERM=dumb or when output is not a terminal
|
||||
- Brace expansion: a{b,c}d -> abd acd, {1..5}, {01..10..2}, {a..e}
|
||||
- Wildcards: * ? [abc] [a-z] [!abc] match file names (not hidden files
|
||||
unless the pattern starts with a dot); quoted wildcards and values of
|
||||
variables are not expanded, a pattern without match is kept as is
|
||||
- Prompt user@host:dir $, with [N] before the $ if the last command
|
||||
failed with exit status N
|
||||
- Colored prompt (user@host green, red for root; directory blue; exit
|
||||
status red), configurable with $GOSH_COLORS, e.g.
|
||||
GOSH_COLORS='user=1;33:path=36' (keys user, root, path, error; values
|
||||
are ANSI SGR codes, empty = no color); disabled with $NO_COLOR,
|
||||
TERM=dumb or when output is not a terminal
|
||||
- Custom prompt with $PS1 (and $PS2 for continuation lines, default
|
||||
"> ") using the escapes of bash: \u user, \h/\H host, \w/\W directory,
|
||||
\$ (# for root), \t \T \@ \A \d time/date, \j jobs, \n newline,
|
||||
\e escape, \[ \]; $VAR, $(...) and $((...)) are expanded, e.g.
|
||||
PS1='\[\e[1;32m\]\u@\h\[\e[0m\]:\w\$ '
|
||||
- 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
|
||||
sets it only for that command; the value is not split or expanded as
|
||||
a pattern
|
||||
- Environment variable expansion ($VAR, ${VAR}, ${VAR:-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
|
||||
(run in a subshell); unquoted it is split into words, "$(command)"
|
||||
stays one word
|
||||
- Command lists: a ; b (run both), a && b (b only if a succeeded),
|
||||
a || b (b only if a failed); $? is the exit status of the last command
|
||||
- Control structures: if cmd; then ...; elif cmd; then ...; else ...; fi,
|
||||
for x in words; do ...; done, while cmd; do ...; done, until ...,
|
||||
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,
|
||||
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
|
||||
foreground job; jobs, fg, bg and wait manage the jobs
|
||||
- Aliases replace the first word of a command in interactive shells
|
||||
(define them in ~/.goshrc); \name or 'name' skips the alias
|
||||
- Non-interactive use: gosh -c 'command', gosh script, command | gosh;
|
||||
the exit status is the one of the last command
|
||||
- Startup files: login shells (-l or argv[0] starting with -) read
|
||||
/etc/gosh/profile and ~/.gosh_profile, interactive shells read
|
||||
/etc/gosh/goshrc and ~/.goshrc (skip with --noprofile, --norc)
|
||||
|
||||
```
|
||||
|
||||
### Aufruf
|
||||
|
||||
```
|
||||
gosh [-l] [-i] [script]
|
||||
gosh [-l] -c command
|
||||
gosh [-l] [-i] [-ex] [-o option] [--norc] [--noprofile] [script [args ...]]
|
||||
gosh [-l] [-ex] [-o option] [--noprofile] -c command [name [args ...]]
|
||||
gosh -version
|
||||
```
|
||||
|
||||
@@ -50,15 +122,348 @@ gosh -version
|
||||
|------------------------|------------------------------------------------------------------|
|
||||
| `gosh` | interaktive Shell (wenn stdin ein Terminal ist) |
|
||||
| `gosh -c 'befehl'` | führt den Befehl aus und beendet sich |
|
||||
| `gosh skript.gosh` | führt das Skript Zeile für Zeile aus |
|
||||
| `gosh -c 'befehl' name a b` | wie oben, mit `$0` = `name`, `$1` = `a`, `$2` = `b` |
|
||||
| `gosh skript.gosh a b` | führt das Skript Zeile für Zeile aus, `$1` = `a`, `$2` = `b` |
|
||||
| `befehle \| gosh` | liest die Befehle von stdin |
|
||||
| `-i` | erzwingt den interaktiven Modus, auch ohne Terminal |
|
||||
| `-l`, `--login` | Login-Shell (wird akzeptiert, eine Startdatei gibt es noch nicht) |
|
||||
| `-l`, `--login` | Login-Shell, liest die Profil-Dateien (siehe unten) |
|
||||
| `--norc` | `goshrc`-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.
|
||||
|
||||
Damit funktionieren auch `ssh host befehl`, `scp` und `rsync`, wenn gosh als Login-Shell gesetzt ist. Befehle lassen sich mit `;`, `&&` und `||` verketten und mit Pipes verbinden, z. B. `ssh host 'cd /srv && git log | head'`.
|
||||
|
||||
### Startdateien
|
||||
|
||||
Beim Start liest gosh Startdateien und führt sie wie ein Skript in der laufenden Shell aus (Variablen, die dort gesetzt werden, bleiben also erhalten):
|
||||
|
||||
| Shell | Dateien (in dieser Reihenfolge) | abschalten mit |
|
||||
|-------------------------------|----------------------------------------------|-----------------|
|
||||
| Login-Shell | `/etc/gosh/profile`, `~/.gosh_profile` | `--noprofile` |
|
||||
| interaktive Shell | `/etc/gosh/goshrc`, `~/.goshrc` | `--norc` |
|
||||
|
||||
- Eine Login-Shell ist gosh mit `-l` oder wenn `argv[0]` mit `-` beginnt (so starten `login`, `sshd` und `su -` die Shell). Eine interaktive Login-Shell liest erst die Profil-, dann die `goshrc`-Dateien.
|
||||
- `gosh -c '…'` und Skripte lesen keine Startdateien, außer als Login-Shell (`gosh -l -c '…'`).
|
||||
- Fehlende Dateien werden übersprungen. Fehler werden mit Datei und Zeile gemeldet (`gosh: ~/.goshrc:4: foo: command not found`), die übrigen Zeilen laufen trotzdem.
|
||||
- Bash-Dateien wie `/etc/profile` oder `~/.bashrc` liest gosh bewusst nicht, weil es deren Syntax (`if`, Funktionen, …) nicht versteht.
|
||||
- Mit `source datei` (oder `. datei`) lässt sich eine Datei auch später einlesen, z. B. nach einer Änderung: `source ~/.goshrc`.
|
||||
|
||||
Beispiel für `~/.goshrc`:
|
||||
|
||||
```bash
|
||||
# Eigene Programme zuerst
|
||||
export PATH=$HOME/bin:$HOME/go/bin:$PATH
|
||||
export EDITOR=vim
|
||||
|
||||
# Aliase
|
||||
alias ll='ls -l'
|
||||
alias ..='cd ..'
|
||||
|
||||
# Prompt-Farben (Anführungszeichen wegen des ;)
|
||||
export GOSH_COLORS='user=1;38;5;208:path=38;5;205'
|
||||
|
||||
# Weitere Einstellungen nur, wenn die Datei existiert
|
||||
test -f ~/.goshrc.local && source ~/.goshrc.local
|
||||
```
|
||||
|
||||
Beispiel für `~/.gosh_profile` (wird nur von Login-Shells gelesen, z. B. beim Anmelden an der Konsole oder per `ssh host` ohne Befehl):
|
||||
|
||||
```bash
|
||||
export LANG=de_DE.UTF-8
|
||||
export PATH=$HOME/bin:$PATH
|
||||
```
|
||||
|
||||
### Kontrollstrukturen
|
||||
|
||||
gosh kennt `if`, `for`, `while`, `until`, `case`, `{ …; }` und `( … )` wie `sh`, auf einer Zeile oder über mehrere Zeilen:
|
||||
|
||||
```bash
|
||||
if [ -f ~/.goshrc ]; then
|
||||
echo "rc vorhanden"
|
||||
elif [ -d ~/.config ]; then
|
||||
echo "nur .config"
|
||||
else
|
||||
echo "nichts"
|
||||
fi
|
||||
|
||||
for f in *.log; do
|
||||
gzip "$f"
|
||||
done
|
||||
|
||||
for i in {1..5}; do echo "Durchlauf $i"; done
|
||||
|
||||
n=3
|
||||
while [ $n -gt 0 ]; do
|
||||
echo $n
|
||||
n=$((n - 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 |
|
||||
|---------------------------------------------|-------------------------------------------------------------------|
|
||||
| `if befehl; then …; elif …; then …; else …; fi` | führt den Zweig aus, dessen Bedingung den Exit-Status 0 hat |
|
||||
| `for x in wörter; do …; done` | setzt `x` nacheinander auf jedes Wort |
|
||||
| `while befehl; do …; done` | wiederholt, solange `befehl` erfolgreich ist |
|
||||
| `until befehl; do …; done` | wiederholt, bis `befehl` erfolgreich ist |
|
||||
| `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 |
|
||||
| `break [n]`, `continue [n]` | verlässt die (n-te) Schleife bzw. springt zum nächsten Durchlauf |
|
||||
| `:` | tut nichts, Exit-Status 0 (z. B. `while :; do …; done`) |
|
||||
|
||||
- Bedingungen sind normale Befehle (auch Listen mit `&&`/`||`), meist `test` bzw. `[ … ]` (z. B. `[ -f datei ]`, `[ "$a" = b ]`, `[ $n -lt 3 ]`).
|
||||
- Die Wörter nach `for … in` werden wie Argumente expandiert: `*.go`, `{1..10}`, `$VAR`, `$(befehl)`, `"$@"`. Ohne `in` läuft die Schleife über die Positionsparameter (`for a; do …` ist `for a in "$@"; do …`).
|
||||
- `case`: Die Muster sind Wildcards wie bei Dateinamen (`*`, `?`, `[…]`), nur passen `*` und `?` hier auch auf `/`. Mehrere Muster werden mit `|` getrennt, das `(` vor dem Muster ist optional, das `;;` im letzten Zweig auch. Zeichen in Anführungszeichen passen nur wörtlich (`"*")` passt nur auf `*`). Wie überall in gosh sind Werte von Variablen keine Muster: Nach `p='*.go'` passt `$p)` nur auf den Text `*.go`. Passt kein Muster, ist der Status 0.
|
||||
- `{ …; }`: `{` und `}` sind nur am Anfang eines Befehls Schlüsselwörter, vor dem `}` muss deshalb ein `;` oder ein Zeilenumbruch stehen. Die Befehle laufen in der Shell selbst (keine Subshell).
|
||||
- Variablen, `$(...)` und Wildcards im Rumpf werden bei jedem Durchlauf neu ersetzt.
|
||||
- Ist ein Befehl noch nicht vollständig (z. B. nach `if …; then` oder `for …` + Enter, nach `&&`, `|` oder einem `\` am Zeilenende), zeigt gosh den Fortsetzungs-Prompt `> `; Ctrl+C verwirft die angefangene Eingabe. Skripte und Startdateien lesen entsprechend weiter.
|
||||
- Ctrl+C bricht die ganze Befehlszeile ab, also auch eine Schleife (`while true; do sleep 1; done`).
|
||||
- Der Exit-Status ist der des zuletzt ausgeführten Befehls; ein `if` ohne passenden Zweig und eine Schleife ohne Durchlauf haben den Status 0.
|
||||
- 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
|
||||
|
||||
```bash
|
||||
name=wert # Variable setzen (wie set name=wert)
|
||||
a=1 b=2 # mehrere auf einmal
|
||||
gruss="Hallo Welt" # Leerzeichen im Wert brauchen Anführungszeichen
|
||||
heute=$(date +%F) # Ausgabe eines Befehls
|
||||
PATH=~/bin:$PATH # ~ wird auch nach = und : ersetzt
|
||||
LANG=C sort datei # nur für diesen einen Befehl
|
||||
unset name # Variable entfernen
|
||||
echo $name ${name:-standard}
|
||||
```
|
||||
|
||||
- Eine Zuweisung ist ein Wort `name=wert` am Anfang eines Befehls, bei dem `name` und `=` ohne Anführungszeichen stehen und `name` aus Buchstaben, Ziffern und `_` besteht (nicht mit einer Ziffer beginnend). `"x=1"`, `x\=1` und `echo x=1` sind keine Zuweisungen.
|
||||
- Steht danach kein Befehl, wird die Variable in der Shell gesetzt. Wie bei `set` und `export` sind in gosh alle Variablen auch für gestartete Programme sichtbar (es gibt keine reinen Shell-Variablen).
|
||||
- Steht ein Befehl danach (`LANG=C sort datei`), gilt die Zuweisung nur für diesen Befehl, die Shell behält den alten Wert.
|
||||
- Der Wert wird nicht in Wörter geteilt und nicht als Wildcard oder `{a,b}` expandiert: `x=$(ls)` speichert die ganze Ausgabe, `x=*.go` speichert `*.go`. Variablen, `$(...)` und `~` (am Anfang, nach `=` und nach `:`) werden ersetzt.
|
||||
- `$?` ist nach einer Zuweisung der Status des letzten `$(...)` darin, sonst 0.
|
||||
- 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.
|
||||
|
||||
### 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
|
||||
|
||||
`$(befehl)` wird durch die Ausgabe von `befehl` ersetzt:
|
||||
|
||||
```
|
||||
$ echo "Heute ist $(date +%A)"
|
||||
Heute ist Montag
|
||||
$ kill $(pgrep -f mein-server)
|
||||
$ cd $(git rev-parse --show-toplevel)
|
||||
$ vim $(grep -l TODO *.go)
|
||||
$ echo "Dateien: $(ls | wc -l)"
|
||||
```
|
||||
|
||||
- Der Befehl läuft in einer eigenen Subshell (einem neuen gosh-Prozess): `cd`, `set` oder `export` darin ändern die laufende Shell nicht. Die Subshell kennt die Funktionen und Positionsparameter der Shell. Er darf alles enthalten, was gosh kann (Pipes, `;`, `&&`, Umleitungen, verschachtelte `$(...)`), und liest von der gleichen Eingabe, Fehlermeldungen gehen auf stderr.
|
||||
- Abschließende Zeilenumbrüche der Ausgabe werden entfernt.
|
||||
- Ohne Anführungszeichen wird die Ausgabe an Leerzeichen, Tabs und Zeilenumbrüchen in mehrere Wörter geteilt, `"$(befehl)"` bleibt ein einziges Wort (mit den Zeilenumbrüchen darin).
|
||||
- Anders als in `sh` werden Wildcards in der Ausgabe nicht expandiert, und Zeichen wie `;` oder `|` darin sind normaler Text: Die Ausgabe ist immer nur Daten.
|
||||
- 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` ``) gibt es nicht. `$((…))` ist keine Befehlsersetzung, sondern [Arithmetik](#arithmetik); eine Subshell darin braucht ein Leerzeichen: `$( (befehl) )`.
|
||||
|
||||
### Befehlslisten und Exit-Status
|
||||
|
||||
Mehrere Befehle in einer Zeile werden wie in `sh` verknüpft:
|
||||
@@ -83,6 +488,120 @@ 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.
|
||||
|
||||
### 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
|
||||
|
||||
Wie in bash erzeugt `{…}` aus einem Wort mehrere Wörter:
|
||||
|
||||
| Ausdruck | ergibt |
|
||||
|-------------------|---------------------------------|
|
||||
| `a{b,c}d` | `abd acd` |
|
||||
| `file{,.bak}` | `file file.bak` |
|
||||
| `{a,b}{1,2}` | `a1 a2 b1 b2` |
|
||||
| `x{a,{b,c}}` | `xa xb xc` (verschachtelt) |
|
||||
| `{1..5}` | `1 2 3 4 5` |
|
||||
| `{5..1}` | `5 4 3 2 1` |
|
||||
| `{1..10..3}` | `1 4 7 10` (Schrittweite) |
|
||||
| `{01..10..4}` | `01 05 09` (mit führenden Nullen) |
|
||||
| `{a..e}` | `a b c d e` |
|
||||
|
||||
```
|
||||
$ mkdir -p projekt/{src,doc,test}
|
||||
$ cp config.yaml{,.bak} # Sicherungskopie
|
||||
$ ls *.{go,md}
|
||||
$ touch log-{01..12}.txt
|
||||
```
|
||||
|
||||
- Die Klammer-Expansion kommt zuerst, die Ergebnisse durchlaufen danach `~`, Variablen und Wildcards: `*.{go,md}` wird zu `*.go *.md`, `{${A},b}` zu `$A b`.
|
||||
- Ohne Komma (und ohne gültige Folge) bleiben Klammern stehen: `{a}`, `{}`, `{a..1}`. Auch `${VAR}` wird nicht angefasst.
|
||||
- In Anführungszeichen oder mit `\` davor sind die Zeichen normale Zeichen: `"{a,b}"`, `\{a,b\}`. Ein Komma lässt sich mit `\,` schützen: `{a\,b,c}` ergibt `a,b c`.
|
||||
- Bei einer Umleitung muss genau ein Wort herauskommen, `> {a,b}` meldet `ambiguous redirect`.
|
||||
- Ein Wort darf höchstens 100 000 Wörter erzeugen.
|
||||
|
||||
### Wildcards
|
||||
|
||||
Wörter mit `*`, `?` oder `[...]` werden durch die passenden Dateinamen ersetzt, sortiert:
|
||||
|
||||
| Muster | passt auf |
|
||||
|-----------|-----------------------------------------------------|
|
||||
| `*` | beliebig viele Zeichen (auch keine) |
|
||||
| `?` | genau ein Zeichen |
|
||||
| `[abc]` | eines der Zeichen `a`, `b`, `c` |
|
||||
| `[a-z]` | ein Zeichen aus dem Bereich |
|
||||
| `[!abc]` | ein Zeichen außer `a`, `b`, `c` (auch `[^abc]`) |
|
||||
|
||||
```
|
||||
$ ls *.go
|
||||
main.go main_test.go
|
||||
$ cat logs/2026-*/error.log
|
||||
$ ls -d */ # nur Verzeichnisse
|
||||
```
|
||||
|
||||
- Muster gelten pro Pfadteil, `*` passt nie auf `/`: `*/*.go` findet `.go`-Dateien eine Ebene tiefer.
|
||||
- Versteckte Dateien (mit `.` am Anfang) werden nur gefunden, wenn das Muster selbst mit `.` beginnt: `*` findet `.bashrc` nicht, `.*rc` schon.
|
||||
- Passt keine Datei, bleibt das Wort unverändert stehen (wie in `sh`), `ls *.xyz` meldet dann also `ls` den Fehler.
|
||||
- In Anführungszeichen oder mit `\` davor sind die Zeichen normale Zeichen: `echo "*"`, `echo '*.go'`, `echo \*`.
|
||||
- Werte von Variablen werden nicht als Muster behandelt (anders als in `sh`): Nach `P='*.go'` gibt `echo $P` wörtlich `*.go` aus. So kann ein Variablenwert nie unbemerkt auf viele Dateien passen. `echo $DIR/*.go` funktioniert dagegen, weil das `*` direkt in der Zeile steht.
|
||||
- Bei einer Umleitung muss das Muster genau eine Datei treffen (`> *.log`), sonst meldet gosh `ambiguous redirect`.
|
||||
|
||||
### Pipes und Umleitungen
|
||||
|
||||
```
|
||||
@@ -101,24 +620,140 @@ $ grep -c error < build.log
|
||||
| `2>&1` | stderr dorthin, wohin stdout gerade geht |
|
||||
| `>&2` | stdout dorthin, wohin stderr gerade geht |
|
||||
| `&> 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 |
|
||||
|
||||
- Umleitungen werden von links nach rechts ausgewertet: `befehl > log 2>&1` schreibt beides in `log`, `befehl 2>&1 > log` schickt stderr dorthin, wo stdout vorher hinging (z. B. in eine Pipe).
|
||||
- Unterstützt werden die Dateideskriptoren 0, 1 und 2. Vor `>` und `<` zählt nur eine Zahl ohne Anführungszeichen als Deskriptor (`2>f`, aber nicht `x2>f` oder `"2">f`).
|
||||
- Operatoren in Anführungszeichen oder mit `\` davor sind normale Zeichen: `echo "a|b" 'c>d' e\>f`.
|
||||
- Der Exit-Status einer Pipeline ist der des letzten Befehls. Beendet sich ein Befehl am Ende (z. B. `head`), bekommt der schreibende Befehl davor `SIGPIPE`, `yes | head -1` endet also sofort.
|
||||
- Die Built-ins `pwd` und `help` lassen sich umleiten und in Pipes verwenden (`help | less`). `cd`, `set`, `export`, `exit` und `quit` verändern die Shell selbst und sind in einer Pipeline nicht erlaubt.
|
||||
- Ein einzelnes `&` (Hintergrundprozesse) gibt es noch nicht, es wird als normales Zeichen übergeben.
|
||||
- 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
|
||||
|
||||
Ein Alias ist ein Kurzname für einen Befehl, am besten in `~/.goshrc` definiert:
|
||||
|
||||
```bash
|
||||
alias ll='ls -l'
|
||||
alias la='ls -la'
|
||||
alias ..='cd ..'
|
||||
alias gs='git status'
|
||||
alias ls='ls --color=auto' # ein Alias darf den eigenen Namen enthalten
|
||||
alias please='sudo ' # endet mit Leerzeichen: das nächste Wort wird auch ersetzt
|
||||
```
|
||||
|
||||
| Befehl | Bedeutung |
|
||||
|-------------------------|---------------------------------------------------|
|
||||
| `alias name='wert'` | definiert einen Alias (mehrere in einem Aufruf möglich) |
|
||||
| `alias` | listet alle Aliase (in einer Form, die sich wieder einlesen lässt) |
|
||||
| `alias name` | zeigt einen Alias |
|
||||
| `unalias name` | entfernt einen Alias, `unalias -a` alle |
|
||||
|
||||
- Ersetzt wird nur das erste Wort eines Befehls, also am Zeilenanfang und nach `;`, `&&`, `||`, `|` und `&`: `ll | less` funktioniert, `echo ll` gibt `ll` aus.
|
||||
- Der Wert darf selbst mehrere Befehle enthalten (`alias up='cd .. && ls'`) und wird wiederum auf Aliase geprüft. Ein Alias, der gerade ersetzt wird, wird darin nicht noch einmal ersetzt, deshalb funktioniert `alias ls='ls --color=auto'`.
|
||||
- Endet der Wert mit einem Leerzeichen, wird auch das nächste Wort als Alias geprüft: Mit den Aliasen oben wird `please ll` zu `sudo ls --color=auto -l`.
|
||||
- Mit `\ll` oder `'ll'` umgeht man den Alias und ruft das Programm direkt auf.
|
||||
- Aliase gelten nur in interaktiven Shells (wie in bash), also auch in `~/.goshrc`, aber nicht in Skripten und bei `gosh -c`. Ein Alias wirkt erst ab der nächsten Zeile, nicht in der Zeile, in der er definiert wird.
|
||||
- Erlaubt sind Namen aus Buchstaben, Ziffern und `_ - . + : , @ % ^`.
|
||||
- Aliase werden auch mit Tab vervollständigt.
|
||||
|
||||
### Job-Control
|
||||
|
||||
In einer interaktiven Shell läuft jede Befehlszeile (bzw. jede Pipeline) als eigener *Job* in einer eigenen Prozessgruppe:
|
||||
|
||||
```
|
||||
$ sleep 100 & # im Hintergrund starten
|
||||
[1] 12345
|
||||
$ vim notizen.txt # Ctrl+Z hält vim an
|
||||
^Z
|
||||
[2]+ Stopped vim notizen.txt
|
||||
$ jobs
|
||||
[1]- Running sleep 100 &
|
||||
[2]+ Stopped vim notizen.txt
|
||||
$ fg # vim wieder im Vordergrund
|
||||
$ bg %2 # oder: angehaltenen Job im Hintergrund weiterlaufen lassen
|
||||
```
|
||||
|
||||
| Befehl / Taste | Bedeutung |
|
||||
|----------------|---------------------------------------------------------------------------|
|
||||
| `befehl &` | startet den Befehl im Hintergrund, gosh wartet nicht auf ihn (`$?` ist 0) |
|
||||
| Ctrl+Z | hält den Vordergrund-Job an (`$?` wird 128+SIGTSTP, unter Linux 148) |
|
||||
| Ctrl+C | bricht nur den Vordergrund-Job ab, nicht die Shell |
|
||||
| `jobs` | listet laufende und angehaltene Jobs |
|
||||
| `fg [%n]` | holt Job `n` in den Vordergrund (und setzt ihn fort, falls angehalten) |
|
||||
| `bg [%n]` | setzt einen angehaltenen Job im Hintergrund fort |
|
||||
| `wait [%n]` | wartet auf Job `n` bzw. auf alle Hintergrund-Jobs |
|
||||
|
||||
- Ohne Angabe gilt der *aktuelle* Job (in `jobs` mit `+` markiert, der zuletzt angehaltene oder in den Hintergrund geschickte), `%-` ist der vorherige (`-`). `%n` und `n` meinen Job Nummer `n`.
|
||||
- Beendete Hintergrund-Jobs meldet gosh vor dem nächsten Prompt (`[1]+ Done sleep 100`, bei Fehlern `Exit N`).
|
||||
- Liest ein Hintergrund-Job vom Terminal, wird er angehalten (wie in bash), mit `fg` bekommt er die Eingabe.
|
||||
- Die Terminal-Einstellungen werden gesichert und wiederhergestellt: Nach dem Anhalten von `vim` oder `less` funktioniert der Prompt normal, nach `fg` bekommt das Programm seine Einstellungen zurück.
|
||||
- `exit` (oder Ctrl+D) mit angehaltenen Jobs warnt einmal (`there are stopped jobs`), ein zweites `exit` direkt danach beendet gosh.
|
||||
- `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`.
|
||||
- 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` 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
|
||||
|
||||
Der Standard-Prompt zeigt Benutzer, Rechner und Verzeichnis. Ist der letzte Befehl fehlgeschlagen, steht sein Exit-Status in eckigen Klammern davor, bis wieder ein Befehl erfolgreich war:
|
||||
|
||||
```
|
||||
flow@host:~/src $ false
|
||||
flow@host:~/src [1] $ gibtsnicht
|
||||
gosh: gibtsnicht: command not found
|
||||
flow@host:~/src [127] $ sleep 100 # mit Ctrl+C abgebrochen
|
||||
^C
|
||||
flow@host:~/src [130] $ true
|
||||
flow@host:~/src $
|
||||
```
|
||||
|
||||
Typische Werte: `1` allgemeiner Fehler, `2` Syntaxfehler, `126` nicht ausführbar, `127` Befehl nicht gefunden, `128+n` durch Signal `n` beendet (`130` = Ctrl+C, `148` = mit Ctrl+Z angehalten unter Linux). Eine leere Eingabe ändert den Status nicht.
|
||||
|
||||
### Farben
|
||||
|
||||
Der Prompt wird über die Umgebungsvariable `GOSH_COLORS` eingefärbt. Sie besteht aus durch `:` getrennten Einträgen `schlüssel=code`:
|
||||
Der Standard-Prompt wird über die Umgebungsvariable `GOSH_COLORS` eingefärbt (für einen eigenen Prompt mit `PS1` gilt sie nicht, siehe unten). Sie besteht aus durch `:` getrennten Einträgen `schlüssel=code`:
|
||||
|
||||
| Schlüssel | Teil des Prompts | Standard |
|
||||
|-----------|--------------------------------|---------------------|
|
||||
| `user` | `user@host` | `1;32` (fett grün) |
|
||||
| `root` | `user@host`, wenn man root ist | `1;31` (fett rot) |
|
||||
| `path` | aktuelles Verzeichnis | `1;34` (fett blau) |
|
||||
| `error` | Exit-Status `[N]` nach einem Fehler | `1;31` (fett rot) |
|
||||
|
||||
Die Werte sind [ANSI-SGR-Codes](https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters), mehrere werden mit `;` kombiniert:
|
||||
|
||||
@@ -156,6 +791,9 @@ GOSH_COLORS='user=1;38;2;136;192;208:path=38;2;216;222;233'
|
||||
# Ozean: türkis und kursiv blau
|
||||
GOSH_COLORS='user=1;96:path=3;94'
|
||||
|
||||
# Fehler-Status gelb auf rotem Hintergrund
|
||||
GOSH_COLORS='error=1;33;41'
|
||||
|
||||
# Root-Warnung: weiß auf rotem Hintergrund
|
||||
GOSH_COLORS='root=1;97;41'
|
||||
|
||||
@@ -163,6 +801,42 @@ GOSH_COLORS='root=1;97;41'
|
||||
GOSH_COLORS='user=:path=4'
|
||||
```
|
||||
|
||||
### Eigener Prompt: PS1 und PS2
|
||||
|
||||
Wie in bash lässt sich der Prompt mit der Variablen `PS1` frei gestalten, am besten in `~/.goshrc`. Ist `PS1` nicht gesetzt, gilt der Standard-Prompt von oben; `unset PS1` holt ihn zurück. `PS2` ist der Prompt für Folgezeilen unvollständiger Befehle (Standard `> `).
|
||||
|
||||
```bash
|
||||
PS1='\u@\h:\w\$ ' # wie der Standard, ohne Farben
|
||||
PS1='\[\e[1;32m\]\u@\h\[\e[0m\]:\[\e[1;34m\]\w\[\e[0m\]\$ ' # mit Farben
|
||||
PS1='\[\e[2m\]\t\[\e[0m\] \W \$ ' # Uhrzeit und nur der Verzeichnisname
|
||||
PS1='[$?] \w\n\$ ' # Exit-Status, Eingabe in eigener Zeile
|
||||
PS1='\w$(git branch --show-current 2>/dev/null | sed "s/.*/ (&)/") \$ ' # git-Branch
|
||||
PS2='… '
|
||||
```
|
||||
|
||||
| Escape | Bedeutung |
|
||||
|---------------|-------------------------------------------------------------|
|
||||
| `\u` | Benutzername |
|
||||
| `\h` / `\H` | Rechnername bis zum ersten `.` / vollständig |
|
||||
| `\w` / `\W` | aktuelles Verzeichnis (`~` für das Home-Verzeichnis) / nur der letzte Teil |
|
||||
| `\$` | `#` für root, sonst `$` |
|
||||
| `\t` `\T` `\@` `\A` | Uhrzeit: `14:07:09`, `02:07:09`, `02:07 PM`, `14:07` |
|
||||
| `\d` | Datum, z. B. `Mon Oct 05` |
|
||||
| `\j` | Anzahl der Jobs |
|
||||
| `\l` | Terminal (z. B. `pts/3`) |
|
||||
| `\s` / `\v` / `\V` | `gosh` / Version `0.19` / `0.19.0` |
|
||||
| `\n` `\r` `\a` `\e` | Zeilenumbruch, Wagenrücklauf, Klingel, Escape |
|
||||
| `\nnn` | Zeichen mit dem Oktalcode `nnn`, z. B. `\033` |
|
||||
| `\\` | Backslash |
|
||||
| `\[` `\]` | umschließen Escape-Sequenzen (wie in bash, siehe unten) |
|
||||
|
||||
- Danach werden `$VAR`, `${...}`, `$(...)` und `$((...))` vor jedem Prompt neu ausgewertet. Deshalb `PS1` in einfachen Anführungszeichen setzen, sonst wird z. B. `$(…)` nur einmal beim Zuweisen ausgeführt. `$?` ist der Exit-Status des letzten Befehls; Befehle im Prompt ändern ihn nicht.
|
||||
- Eine nicht gesetzte Variable im Prompt ist ein Fehler (`gosh: PS1: X: unbound variable`), dann erscheint der Standard-Prompt. `${X:-}` vermeidet das.
|
||||
- Andere Escapes (z. B. `\D{…}`, `\!`, `\#`) kennt gosh nicht, sie bleiben unverändert stehen.
|
||||
- `\[` und `\]` braucht gosh nicht, sie werden ignoriert, damit Prompts aus der `.bashrc` unverändert funktionieren. gosh erkennt Farbsequenzen (`\e[…m`) selbst; andere Steuersequenzen wie der Fenstertitel `\e]0;titel\a` werden vor dem Prompt ausgegeben.
|
||||
- Die Werte von `\w`, `\u`, `\h` usw. und aller Expansionen werden wie der Standard-Prompt bereinigt: Steuerzeichen erscheinen escaped (`\x1b`). Ein Verzeichnis- oder Branch-Name kann so keine Escape-Sequenzen ins Terminal schreiben – Farben müssen deshalb direkt in `PS1` stehen, nicht in der Ausgabe von `$(...)`.
|
||||
- Wie alle Variablen in gosh ist `PS1` eine Umgebungsvariable und wird an gestartete Programme vererbt (eine bash darin versteht dieselben Escapes).
|
||||
|
||||
### Sicherheitshinweise
|
||||
|
||||
- Die History liegt in `$XDG_STATE_HOME/gosh/history` (Standard: `~/.local/state/gosh/history`) und ist nur für den eigenen Benutzer lesbar (Verzeichnis `0700`, Datei `0600`). Mit `GOSH_HISTFILE` lässt sich ein anderer Pfad setzen, mit `GOSH_HISTFILE=` wird die History abgeschaltet.
|
||||
@@ -205,4 +879,4 @@ git push origin v0.1.0
|
||||
goreleaser release --clean
|
||||
```
|
||||
|
||||
Das Paket installiert `/usr/bin/gosh` und trägt die Shell in `/etc/shells` ein, sodass sie mit `chsh -s /usr/bin/gosh` gesetzt werden kann.
|
||||
Das Paket installiert `/usr/bin/gosh` und die Man-Page `gosh(1)` (`man gosh`, Quelle: `packaging/man/gosh.1`, lokal ansehen mit `man ./packaging/man/gosh.1`) und trägt die Shell in `/etc/shells` ein, sodass sie mit `chsh -s /usr/bin/gosh` gesetzt werden kann.
|
||||
@@ -0,0 +1,201 @@
|
||||
package main
|
||||
|
||||
// Aliases: like in bash, an alias replaces the first word of a command and
|
||||
// is only expanded in interactive shells (including the startup files), not
|
||||
// in scripts or with gosh -c.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
aliasMu sync.Mutex
|
||||
aliases = map[string]string{}
|
||||
)
|
||||
|
||||
// isAliasName reports whether s can be an alias name: letters, digits and a
|
||||
// few punctuation characters, but nothing that is special to the shell.
|
||||
func isAliasName(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
case strings.ContainsRune("_-.+:,@%^", r):
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// lookupAlias returns the value of an alias.
|
||||
func lookupAlias(name string) (string, bool) {
|
||||
aliasMu.Lock()
|
||||
defer aliasMu.Unlock()
|
||||
value, ok := aliases[name]
|
||||
return value, ok
|
||||
}
|
||||
|
||||
// aliasNames returns the names of all aliases, sorted.
|
||||
func aliasNames() []string {
|
||||
aliasMu.Lock()
|
||||
defer aliasMu.Unlock()
|
||||
names := make([]string, 0, len(aliases))
|
||||
for name := range aliases {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// expandAliases replaces aliases at the start of every command in line (at
|
||||
// the start and after ;, &, &&, || and |). The value of an alias is expanded
|
||||
// again, except for aliases that are already being expanded, so that e.g.
|
||||
// alias ls='ls --color' works. If the value ends with a blank, the next word
|
||||
// is checked for an alias too. Quoted or escaped words (\ls, 'ls') are never
|
||||
// replaced.
|
||||
func expandAliases(line string) string {
|
||||
return expandAliasesIn(line, map[string]bool{})
|
||||
}
|
||||
|
||||
func expandAliasesIn(line string, active map[string]bool) string {
|
||||
var (
|
||||
out strings.Builder
|
||||
quote byte
|
||||
escaped bool
|
||||
commandPos = true // the next word is the name of a command
|
||||
)
|
||||
|
||||
for i := 0; i < len(line); i++ {
|
||||
ch := line[i]
|
||||
if quote == 0 && !escaped && commandPos {
|
||||
if ch == ' ' || ch == '\t' || ch == '\n' {
|
||||
out.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
commandPos = false
|
||||
end := i
|
||||
for end < len(line) && strings.IndexByte(" \t\n;&|<>", line[end]) < 0 {
|
||||
end++
|
||||
}
|
||||
name := line[i:end]
|
||||
if value, ok := lookupAlias(name); ok && isAliasName(name) && !active[name] {
|
||||
active[name] = true
|
||||
out.WriteString(expandAliasesIn(value, active))
|
||||
delete(active, name)
|
||||
// A trailing blank makes the next word a command name too
|
||||
commandPos = strings.HasSuffix(value, " ") || strings.HasSuffix(value, "\t")
|
||||
i = end - 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if quote != '\'' && !escaped && isSubstStart(line, i) {
|
||||
// $(...) is copied as it is; the subshell does not expand
|
||||
// aliases
|
||||
if end := substEnd(line, i); end >= 0 {
|
||||
out.WriteString(line[i : end+1])
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
out.WriteByte(ch)
|
||||
switch {
|
||||
case escaped:
|
||||
escaped = false
|
||||
case quote == '\'':
|
||||
if ch == '\'' {
|
||||
quote = 0
|
||||
}
|
||||
case ch == '\\':
|
||||
escaped = true
|
||||
case quote == '"':
|
||||
if ch == '"' {
|
||||
quote = 0
|
||||
}
|
||||
case ch == '"' || ch == '\'':
|
||||
quote = ch
|
||||
case ch == '#' && (i == 0 || strings.IndexByte(" \t", line[i-1]) >= 0):
|
||||
// Comment until the end of the line
|
||||
out.WriteString(line[i+1:])
|
||||
return out.String()
|
||||
case ch == ';' || ch == '|':
|
||||
commandPos = true
|
||||
case ch == '&':
|
||||
// & and && separate commands, &>, >& and <& do not
|
||||
if !(i+1 < len(line) && line[i+1] == '>') && !(i > 0 && strings.IndexByte("<>", line[i-1]) >= 0) {
|
||||
commandPos = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// quoteAlias quotes a value for the output of alias, so that it can be read
|
||||
// back: 'value' with ' written as '\”.
|
||||
func quoteAlias(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
// builtinAlias defines aliases (alias name=value ...), prints them (alias
|
||||
// name ...) or lists all of them (alias).
|
||||
func builtinAlias(args []string, stdout io.Writer) error {
|
||||
if len(args) == 1 {
|
||||
for _, name := range aliasNames() {
|
||||
value, _ := lookupAlias(name)
|
||||
fmt.Fprintf(stdout, "alias %s=%s\n", name, sanitize(quoteAlias(value)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
for _, arg := range args[1:] {
|
||||
name, value, define := strings.Cut(arg, "=")
|
||||
if !isAliasName(name) {
|
||||
err = fmt.Errorf("alias: `%s': invalid alias name", sanitize(name))
|
||||
continue
|
||||
}
|
||||
if define {
|
||||
aliasMu.Lock()
|
||||
aliases[name] = value
|
||||
aliasMu.Unlock()
|
||||
continue
|
||||
}
|
||||
if value, ok := lookupAlias(name); ok {
|
||||
fmt.Fprintf(stdout, "alias %s=%s\n", name, sanitize(quoteAlias(value)))
|
||||
} else {
|
||||
err = fmt.Errorf("alias: %s: not found", sanitize(name))
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// builtinUnalias removes aliases (unalias name ...) or all of them
|
||||
// (unalias -a).
|
||||
func builtinUnalias(args []string) error {
|
||||
if len(args) < 2 {
|
||||
return fmt.Errorf("usage: unalias [-a] name ...")
|
||||
}
|
||||
aliasMu.Lock()
|
||||
defer aliasMu.Unlock()
|
||||
if args[1] == "-a" {
|
||||
aliases = map[string]string{}
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
for _, name := range args[1:] {
|
||||
if _, ok := aliases[name]; !ok {
|
||||
err = fmt.Errorf("unalias: %s: not found", sanitize(name))
|
||||
continue
|
||||
}
|
||||
delete(aliases, name)
|
||||
}
|
||||
return err
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// withAliases sets the aliases for a test and restores them afterwards.
|
||||
func withAliases(t *testing.T, defs map[string]string) {
|
||||
t.Helper()
|
||||
aliasMu.Lock()
|
||||
saved := aliases
|
||||
aliases = map[string]string{}
|
||||
for name, value := range defs {
|
||||
aliases[name] = value
|
||||
}
|
||||
aliasMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
aliasMu.Lock()
|
||||
aliases = saved
|
||||
aliasMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func TestExpandAliases(t *testing.T) {
|
||||
withAliases(t, map[string]string{
|
||||
"ll": "ls -l",
|
||||
"ls": "ls --color",
|
||||
"..": "cd ..",
|
||||
"e": "echo ",
|
||||
"w": "world",
|
||||
"a": "b",
|
||||
"b": "a",
|
||||
"multi": "cd /tmp && ls",
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
input, want string
|
||||
}{
|
||||
{"ll", "ls --color -l"},
|
||||
{" ll -a", " ls --color -l -a"},
|
||||
{"ls", "ls --color"},
|
||||
{"..", "cd .."},
|
||||
// Only the command name is replaced
|
||||
{"echo ll", "echo ll"},
|
||||
{"ll ll", "ls --color -l ll"},
|
||||
// ... also after ;, &&, ||, | and &
|
||||
{"ll; ll && ll || ll | ll & ll", "ls --color -l; ls --color -l && ls --color -l || ls --color -l | ls --color -l & ls --color -l"},
|
||||
// but not after redirections with &
|
||||
{"ll 2>&1 ll", "ls --color -l 2>&1 ll"},
|
||||
{"ll &> ll", "ls --color -l &> ll"},
|
||||
// Quoted or escaped words are not replaced
|
||||
{`\ll`, `\ll`},
|
||||
{`'ll'`, `'ll'`},
|
||||
{`"ll"`, `"ll"`},
|
||||
{`echo "a; ll"`, `echo "a; ll"`},
|
||||
{`echo 'a | ll'`, `echo 'a | ll'`},
|
||||
// A trailing blank makes the next word a command name too
|
||||
{"e w", "echo world"},
|
||||
{"e e w", "echo echo world"},
|
||||
// Recursion stops at aliases that are already being expanded
|
||||
{"a", "a"},
|
||||
{"b", "b"},
|
||||
// The value can contain several commands
|
||||
{"multi", "cd /tmp && ls --color"},
|
||||
// Comments are left alone
|
||||
{"echo x # ll", "echo x # ll"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := expandAliases(tc.input); got != tc.want {
|
||||
t.Errorf("expandAliases(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinAlias(t *testing.T) {
|
||||
withAliases(t, nil)
|
||||
|
||||
run := func(args ...string) (string, error) {
|
||||
var out bytes.Buffer
|
||||
err := builtinAlias(append([]string{"alias"}, args...), &out)
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
if _, err := run("ll=ls -l", "q=echo it's", "..=cd .."); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, _ := run()
|
||||
want := "alias ..='cd ..'\nalias ll='ls -l'\nalias q='echo it'\\''s'\n"
|
||||
if out != want {
|
||||
t.Errorf("alias = %q, want %q", out, want)
|
||||
}
|
||||
if out, _ := run("ll"); out != "alias ll='ls -l'\n" {
|
||||
t.Errorf("alias ll = %q", out)
|
||||
}
|
||||
if _, err := run("nope"); err == nil || err.Error() != "alias: nope: not found" {
|
||||
t.Errorf("alias nope: %v", err)
|
||||
}
|
||||
for _, name := range []string{"a/b", "a=b", "$x", "a b", ""} {
|
||||
if isAliasName(name) {
|
||||
t.Errorf("isAliasName(%q) = true", name)
|
||||
}
|
||||
}
|
||||
if _, err := run("a/b=x"); err == nil || !strings.Contains(err.Error(), "invalid alias name") {
|
||||
t.Errorf("alias a/b=x: %v", err)
|
||||
}
|
||||
|
||||
if err := builtinUnalias([]string{"unalias", "ll", "nope"}); err == nil || err.Error() != "unalias: nope: not found" {
|
||||
t.Errorf("unalias nope: %v", err)
|
||||
}
|
||||
if _, ok := lookupAlias("ll"); ok {
|
||||
t.Error("ll still defined after unalias")
|
||||
}
|
||||
builtinUnalias([]string{"unalias", "-a"})
|
||||
if names := aliasNames(); len(names) != 0 {
|
||||
t.Errorf("aliases after unalias -a: %q", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecInput_Aliases(t *testing.T) {
|
||||
withAliases(t, nil)
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
dir := t.TempDir()
|
||||
os.Chdir(dir)
|
||||
defer func(saved bool) { interactiveShell = saved }(interactiveShell)
|
||||
|
||||
// Aliases are expanded in interactive shells only
|
||||
interactiveShell = true
|
||||
execInput(`alias say='echo hello' to=out`)
|
||||
execInput("say world > out")
|
||||
if got, _ := os.ReadFile("out"); string(got) != "hello world\n" {
|
||||
t.Errorf("alias in interactive shell: out = %q", got)
|
||||
}
|
||||
|
||||
interactiveShell = false
|
||||
os.Remove("out")
|
||||
if err := execInput("say world > out"); err == nil {
|
||||
t.Error("alias was expanded in a non-interactive shell")
|
||||
}
|
||||
|
||||
// alias works in pipelines (listing), unalias does not
|
||||
interactiveShell = true
|
||||
execInput("alias | grep say > out")
|
||||
if got, _ := os.ReadFile(filepath.Join(dir, "out")); string(got) != "alias say='echo hello'\n" {
|
||||
t.Errorf("alias | grep = %q", got)
|
||||
}
|
||||
if err := execInput("echo | unalias say"); err == nil {
|
||||
t.Error("unalias in a pipeline must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellCompleter_Aliases(t *testing.T) {
|
||||
withAliases(t, map[string]string{"gst": "git status"})
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
got, length := completeLine("gs")
|
||||
if !reflect.DeepEqual(got, []string{"t "}) || length != 2 {
|
||||
t.Errorf("completion of gs = %q, %d", got, length)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTokenize_Assignments(t *testing.T) {
|
||||
t.Setenv("HOME", "/home/u")
|
||||
tests := []struct {
|
||||
input string
|
||||
word string
|
||||
assign bool
|
||||
}{
|
||||
{"x=1", "x=1", true},
|
||||
{"_a1=", "_a1=", true},
|
||||
{`x="a b"`, "x=a b", true},
|
||||
{"x=a=b", "x=a=b", true},
|
||||
{"P=~/bin:~/go/bin", "P=/home/u/bin:/home/u/go/bin", true},
|
||||
{"x=a~b", "x=a~b", true},
|
||||
{"x=~", "x=/home/u", true},
|
||||
// Not an assignment: quoted or escaped name or =, invalid name
|
||||
{`"x=1"`, "x=1", false},
|
||||
{`x"=1"`, "x=1", false},
|
||||
{`x\=1`, "x=1", false},
|
||||
{"1x=2", "1x=2", false},
|
||||
{"=x", "=x", false},
|
||||
{"a-b=1", "a-b=1", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
tokens, err := tokenize(tc.input)
|
||||
if err != nil || len(tokens) != 1 {
|
||||
t.Errorf("tokenize(%q) = %+v, %v", tc.input, tokens, err)
|
||||
continue
|
||||
}
|
||||
if tokens[0].word != tc.word || tokens[0].assign != tc.assign {
|
||||
t.Errorf("tokenize(%q) = %q (assign %v), want %q (assign %v)",
|
||||
tc.input, tokens[0].word, tokens[0].assign, tc.word, tc.assign)
|
||||
}
|
||||
}
|
||||
|
||||
// The output of $(...) is not split in an assignment
|
||||
tokens, _ := tokenize("x=$(echo a b) y")
|
||||
if len(tokens) != 2 || tokens[0].word != "x=a b" || !tokens[0].assign {
|
||||
t.Errorf("x=$(echo a b): %+v", tokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExec_Assignments(t *testing.T) {
|
||||
for _, name := range []string{"GOSH_A", "GOSH_B", "GOSH_C"} {
|
||||
os.Unsetenv(name)
|
||||
defer os.Unsetenv(name)
|
||||
}
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"GOSH_A=1; echo $GOSH_A > out", "1\n"},
|
||||
{"GOSH_A=1 GOSH_B=2; echo $GOSH_A$GOSH_B > out", "12\n"},
|
||||
{`GOSH_A="a b"; echo "$GOSH_A" > out`, "a b\n"},
|
||||
{"GOSH_A=$(printf 'a b\\nc'); echo \"$GOSH_A\" > out", "a b\nc\n"},
|
||||
// No wildcard or brace expansion in the value
|
||||
{"touch x.go; GOSH_A=*.go; echo \"$GOSH_A\" > out", "*.go\n"},
|
||||
{"GOSH_A={a,b}; echo $GOSH_A > out", "{a,b}\n"},
|
||||
// ... but as an argument, assignment-like words are normal words
|
||||
{"echo GOSH_A={a,b} > out", "GOSH_A=a GOSH_A=b\n"},
|
||||
// Before a command, only in its environment
|
||||
{"GOSH_C=temp sh -c 'echo $GOSH_C' > out; echo ${GOSH_C:-unset} >> out", "temp\nunset\n"},
|
||||
{"GOSH_A=old; GOSH_A=new sh -c 'echo $GOSH_A' > out; echo $GOSH_A >> out", "new\nold\n"},
|
||||
// ... and before a builtin only while it runs
|
||||
{"GOSH_C=temp pwd > /dev/null; echo ${GOSH_C:-unset} > out", "unset\n"},
|
||||
// In a pipeline or with a command they do not change the shell
|
||||
{"GOSH_C=x | cat; echo ${GOSH_C:-unset} > out", "unset\n"},
|
||||
// Redirections are applied
|
||||
{"GOSH_A=r > out", ""},
|
||||
// In loops
|
||||
{"for i in 1 2 3; do GOSH_A=$i; done; echo $GOSH_A > out", "3\n"},
|
||||
{"GOSH_B=0; while [ $GOSH_B -lt 3 ]; do echo $GOSH_B >> out; GOSH_B=$(expr $GOSH_B + 1); done", "0\n1\n2\n"},
|
||||
// Not assignments
|
||||
{`echo "GOSH_A=1" GOSH_B=2 > out`, "GOSH_A=1 GOSH_B=2\n"},
|
||||
// unset
|
||||
{"GOSH_A=1; unset GOSH_A; echo ${GOSH_A:-gone} > out", "gone\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 status of an assignment is the one of its last $(...)
|
||||
for input, want := range map[string]int{
|
||||
"GOSH_A=1": 0,
|
||||
"GOSH_A=$(false)": 1,
|
||||
"GOSH_A=$(sh -c 'exit 3')": 3,
|
||||
"GOSH_A=$(false) GOSH_B=$(true)": 0,
|
||||
} {
|
||||
runIn(t, input)
|
||||
if lastStatus != want {
|
||||
t.Errorf("%q: $? = %d, want %d", input, lastStatus, want)
|
||||
}
|
||||
}
|
||||
|
||||
if err := execInput("unset 1x"); err == nil || err.Error() != "unset: `1x': not a valid identifier" {
|
||||
t.Errorf("unset 1x: %v", err)
|
||||
}
|
||||
if err := execInput(`"GOSH_A=1"`); err == nil || exitStatus(err) != 127 {
|
||||
t.Errorf(`"GOSH_A=1" must be a command: %v`, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package main
|
||||
|
||||
// Brace expansion like in bash: a{b,c}d becomes abd acd, {1..5} becomes
|
||||
// 1 2 3 4 5. It happens first, on the text of the command line, so that the
|
||||
// resulting words go through the other expansions (~, variables, wildcards).
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// maxBraceWords limits the number of words a single word may expand to.
|
||||
const maxBraceWords = 100000
|
||||
|
||||
// expandBraceWords applies brace expansion to every word of a command (the
|
||||
// text of a pipeline). Quoted or escaped braces, ${...} and $(...) are left
|
||||
// alone.
|
||||
// A redirection target must expand to a single word.
|
||||
func expandBraceWords(input string) (string, error) {
|
||||
var (
|
||||
out strings.Builder
|
||||
afterRedirect bool // the next word is the target of a redirection
|
||||
commandStart = true
|
||||
)
|
||||
|
||||
for i := 0; i < len(input); {
|
||||
ch := input[i]
|
||||
switch {
|
||||
case ch == ' ' || ch == '\t' || ch == '\n':
|
||||
out.WriteByte(ch)
|
||||
i++
|
||||
case strings.IndexByte(";&|<>", ch) >= 0:
|
||||
if ch == '<' || ch == '>' {
|
||||
afterRedirect = true
|
||||
} else {
|
||||
commandStart = true
|
||||
}
|
||||
out.WriteByte(ch)
|
||||
i++
|
||||
case ch == '#':
|
||||
// A comment, until the end of the line
|
||||
out.WriteString(input[i:])
|
||||
return out.String(), nil
|
||||
default:
|
||||
end := wordEnd(input, i)
|
||||
if commandStart && !afterRedirect && isAssignment(input[i:end]) {
|
||||
// name=value before the command: no brace expansion, like
|
||||
// in bash
|
||||
out.WriteString(input[i:end])
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
if !afterRedirect {
|
||||
commandStart = false
|
||||
}
|
||||
words, err := expandBraces(input[i:end])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if afterRedirect && len(words) > 1 {
|
||||
return "", fmt.Errorf("%s: ambiguous redirect", sanitize(input[i:end]))
|
||||
}
|
||||
afterRedirect = false
|
||||
out.WriteString(strings.Join(words, " "))
|
||||
i = end
|
||||
}
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
// isAssignment reports whether a word starts with name= (unquoted).
|
||||
func isAssignment(word string) bool {
|
||||
name, _, ok := strings.Cut(word, "=")
|
||||
return ok && isVarName(name)
|
||||
}
|
||||
|
||||
// wordEnd returns the end of the word starting at i: the next unquoted blank
|
||||
// or operator character.
|
||||
func wordEnd(s string, i int) int {
|
||||
var quote byte
|
||||
for ; i < len(s); i++ {
|
||||
ch := s[i]
|
||||
switch {
|
||||
case quote == '\'':
|
||||
if ch == '\'' {
|
||||
quote = 0
|
||||
}
|
||||
case ch == '\\':
|
||||
i++
|
||||
case isSubstStart(s, i):
|
||||
if j := substEnd(s, i); j >= 0 {
|
||||
i = j
|
||||
}
|
||||
case ch == '$' && i+1 < len(s) && s[i+1] == '{':
|
||||
if j := paramEnd(s, i); j >= 0 {
|
||||
i = j
|
||||
}
|
||||
case quote == '"':
|
||||
if ch == '"' {
|
||||
quote = 0
|
||||
}
|
||||
case ch == '"' || ch == '\'':
|
||||
quote = ch
|
||||
case strings.IndexByte(" \t\n;&|<>", ch) >= 0:
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(s)
|
||||
}
|
||||
|
||||
// braceScan calls fn for every unquoted, unescaped character of s that is
|
||||
// not part of a ${...} reference, with its index and the brace depth.
|
||||
// Scanning stops when fn returns false.
|
||||
func braceScan(s string, fn func(i, depth int) bool) {
|
||||
var quote byte
|
||||
depth := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
ch := s[i]
|
||||
switch {
|
||||
case quote == '\'':
|
||||
if ch == '\'' {
|
||||
quote = 0
|
||||
}
|
||||
continue
|
||||
case ch == '\\':
|
||||
i++
|
||||
continue
|
||||
case isSubstStart(s, i):
|
||||
// The subshell does its own brace expansion
|
||||
if j := substEnd(s, i); j >= 0 {
|
||||
i = j
|
||||
}
|
||||
continue
|
||||
case ch == '$' && i+1 < len(s) && s[i+1] == '{':
|
||||
if j := paramEnd(s, i); j >= 0 {
|
||||
i = j
|
||||
}
|
||||
continue
|
||||
case quote == '"':
|
||||
if ch == '"' {
|
||||
quote = 0
|
||||
}
|
||||
continue
|
||||
case ch == '"' || ch == '\'':
|
||||
quote = ch
|
||||
continue
|
||||
}
|
||||
if ch == '}' {
|
||||
depth--
|
||||
}
|
||||
if !fn(i, depth) {
|
||||
return
|
||||
}
|
||||
if ch == '{' {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// expandBraces expands the first valid brace expression of a word and then
|
||||
// recursively the results: a{b,c}d -> abd acd, x{1..3} -> x1 x2 x3. A brace
|
||||
// expression is valid if it contains an unquoted comma at its top level or
|
||||
// is a sequence; other braces ({}, {a}, ${x}) are kept as they are.
|
||||
func expandBraces(word string) ([]string, error) {
|
||||
// Find the candidates: unquoted { with their matching }
|
||||
var opens []int
|
||||
pairs := map[int]int{}
|
||||
braceScan(word, func(i, depth int) bool {
|
||||
switch word[i] {
|
||||
case '{':
|
||||
opens = append(opens, i)
|
||||
case '}':
|
||||
if len(opens) > 0 {
|
||||
pairs[opens[len(opens)-1]] = i
|
||||
opens = opens[:len(opens)-1]
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
for start := 0; start < len(word); start++ {
|
||||
end, ok := pairs[start]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
inner := word[start+1 : end]
|
||||
alternatives := splitTopLevelCommas(inner)
|
||||
if len(alternatives) < 2 {
|
||||
seq, ok, err := braceSequence(inner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
alternatives = seq
|
||||
}
|
||||
|
||||
prefix, suffix := word[:start], word[end+1:]
|
||||
var words []string
|
||||
for _, alt := range alternatives {
|
||||
expanded, err := expandBraces(prefix + alt + suffix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
words = append(words, expanded...)
|
||||
if len(words) > maxBraceWords {
|
||||
return nil, errors.New("brace expansion: too many words")
|
||||
}
|
||||
}
|
||||
return words, nil
|
||||
}
|
||||
return []string{word}, nil
|
||||
}
|
||||
|
||||
// splitTopLevelCommas splits s at its unquoted commas that are not inside
|
||||
// nested braces.
|
||||
func splitTopLevelCommas(s string) []string {
|
||||
var parts []string
|
||||
last := 0
|
||||
braceScan(s, func(i, depth int) bool {
|
||||
if s[i] == ',' && depth == 0 {
|
||||
parts = append(parts, s[last:i])
|
||||
last = i + 1
|
||||
}
|
||||
return true
|
||||
})
|
||||
return append(parts, s[last:])
|
||||
}
|
||||
|
||||
// braceSequence expands a sequence expression: x..y or x..y..step with
|
||||
// integers (zero-padded to the same width if one of them has a leading zero)
|
||||
// or single letters. ok is false if s is no sequence.
|
||||
func braceSequence(s string) (words []string, ok bool, err error) {
|
||||
parts := strings.Split(s, "..")
|
||||
if len(parts) != 2 && len(parts) != 3 {
|
||||
return nil, false, nil
|
||||
}
|
||||
step := 1
|
||||
if len(parts) == 3 {
|
||||
step, err = strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
if step < 0 {
|
||||
step = -step
|
||||
}
|
||||
if step == 0 {
|
||||
step = 1
|
||||
}
|
||||
}
|
||||
|
||||
from, errFrom := strconv.Atoi(parts[0])
|
||||
to, errTo := strconv.Atoi(parts[1])
|
||||
isLetter := func(p string) bool {
|
||||
return len(p) == 1 && (p[0] >= 'a' && p[0] <= 'z' || p[0] >= 'A' && p[0] <= 'Z')
|
||||
}
|
||||
format := func(n int) string { return strconv.Itoa(n) }
|
||||
switch {
|
||||
case errFrom == nil && errTo == nil:
|
||||
// {01..10}: pad to the width of the longer number
|
||||
if hasLeadingZero(parts[0]) || hasLeadingZero(parts[1]) {
|
||||
width := max(len(parts[0]), len(parts[1]))
|
||||
format = func(n int) string {
|
||||
if n < 0 {
|
||||
return fmt.Sprintf("-%0*d", width-1, -n)
|
||||
}
|
||||
return fmt.Sprintf("%0*d", width, n)
|
||||
}
|
||||
}
|
||||
case isLetter(parts[0]) && isLetter(parts[1]):
|
||||
from, to = int(parts[0][0]), int(parts[1][0])
|
||||
format = func(n int) string { return string(rune(n)) }
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
count := (max(from, to)-min(from, to))/step + 1
|
||||
if count > maxBraceWords {
|
||||
return nil, false, errors.New("brace expansion: too many words")
|
||||
}
|
||||
if to < from {
|
||||
step = -step
|
||||
}
|
||||
for n := from; (step > 0 && n <= to) || (step < 0 && n >= to); n += step {
|
||||
words = append(words, format(n))
|
||||
}
|
||||
return words, true, nil
|
||||
}
|
||||
|
||||
// hasLeadingZero reports whether a number is written with a leading zero
|
||||
// (01, -05), which makes a sequence zero-padded.
|
||||
func hasLeadingZero(s string) bool {
|
||||
s = strings.TrimPrefix(s, "-")
|
||||
return len(s) > 1 && s[0] == '0'
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExpandBraces(t *testing.T) {
|
||||
tests := []struct {
|
||||
word string
|
||||
want []string
|
||||
}{
|
||||
{"a{b,c}d", []string{"abd", "acd"}},
|
||||
{"{a,b}{1,2}", []string{"a1", "a2", "b1", "b2"}},
|
||||
{"x{a,{b,c}}y", []string{"xay", "xby", "xcy"}},
|
||||
{"file{,.bak}", []string{"file", "file.bak"}},
|
||||
{"{1..5}", []string{"1", "2", "3", "4", "5"}},
|
||||
{"{3..1}", []string{"3", "2", "1"}},
|
||||
{"{1..10..3}", []string{"1", "4", "7", "10"}},
|
||||
{"{10..1..-4}", []string{"10", "6", "2"}},
|
||||
{"{01..10..4}", []string{"01", "05", "09"}},
|
||||
{"{8..010..1}", []string{"008", "009", "010"}},
|
||||
{"{-2..2}", []string{"-2", "-1", "0", "1", "2"}},
|
||||
{"{-01..1}", []string{"-01", "000", "001"}},
|
||||
{"{a..e}", []string{"a", "b", "c", "d", "e"}},
|
||||
{"{C..A}", []string{"C", "B", "A"}},
|
||||
{"*.{go,md}", []string{"*.go", "*.md"}},
|
||||
{`{"a b",c}`, []string{`"a b"`, "c"}},
|
||||
{`{a\,b,c}`, []string{`a\,b`, "c"}},
|
||||
{"{$HOME,~}/x", []string{"$HOME/x", "~/x"}},
|
||||
// Not expanded: quoted, escaped, no comma, ${...}, invalid sequences
|
||||
{`"{a,b}"`, []string{`"{a,b}"`}},
|
||||
{`'{a,b}'`, []string{`'{a,b}'`}},
|
||||
{`\{a,b\}`, []string{`\{a,b\}`}},
|
||||
{"{a}", []string{"{a}"}},
|
||||
{"{}", []string{"{}"}},
|
||||
{"{a,b", []string{"{a,b"}},
|
||||
{"a,b}", []string{"a,b}"}},
|
||||
{"${HOME}", []string{"${HOME}"}},
|
||||
{"${X:-a,b}", []string{"${X:-a,b}"}},
|
||||
{"{a..1}", []string{"{a..1}"}},
|
||||
{"{1..2..x}", []string{"{1..2..x}"}},
|
||||
{"{ab..cd}", []string{"{ab..cd}"}},
|
||||
// An invalid brace before a valid one
|
||||
{"{a}{b,c}", []string{"{a}b", "{a}c"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got, err := expandBraces(tc.word)
|
||||
if err != nil {
|
||||
t.Errorf("expandBraces(%q) returned error: %v", tc.word, err)
|
||||
continue
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("expandBraces(%q) = %q, want %q", tc.word, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := expandBraces("{1..1000000}"); err == nil {
|
||||
t.Error("expected an error for a too large sequence")
|
||||
}
|
||||
if _, err := expandBraces("{1..11}{1..100}{1..100}"); err == nil {
|
||||
t.Error("expected an error for too many words")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandBraceWords(t *testing.T) {
|
||||
tests := []struct {
|
||||
input, want string
|
||||
}{
|
||||
{"echo a{b,c} d", "echo ab ac d"},
|
||||
{"echo {1..3}|wc -w", "echo 1 2 3|wc -w"},
|
||||
{"echo a > {f}", "echo a > {f}"},
|
||||
{"echo {a,b} 2>&1", "echo a b 2>&1"},
|
||||
{"echo x # {a,b}", "echo x # {a,b}"},
|
||||
{`echo "{a,b} c" {d,e}`, `echo "{a,b} c" d e`},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got, err := expandBraceWords(tc.input)
|
||||
if err != nil || got != tc.want {
|
||||
t.Errorf("expandBraceWords(%q) = %q, %v; want %q", tc.input, got, err, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
for _, input := range []string{"echo a > {f,g}", "cat < x{1..2}", "echo a 2>> {a,b}"} {
|
||||
if _, err := expandBraceWords(input); err == nil || !strings.Contains(err.Error(), "ambiguous redirect") {
|
||||
t.Errorf("expandBraceWords(%q): %v, want ambiguous redirect", input, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecInput_BraceExpansion(t *testing.T) {
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
dir := t.TempDir()
|
||||
os.Chdir(dir)
|
||||
|
||||
if err := execInput("mkdir -p d/{x,y}/{1..2} && touch d/x/1/f{a,b}.txt"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{"d/x/1", "d/x/2", "d/y/1", "d/y/2", "d/x/1/fa.txt", "d/x/1/fb.txt"} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Errorf("%s was not created", path)
|
||||
}
|
||||
}
|
||||
|
||||
// Brace expansion comes before wildcards and variables
|
||||
t.Setenv("GOSH_BRACE", "v")
|
||||
execInput("echo d/x/1/*.{txt,go} {${GOSH_BRACE},w}1 > out")
|
||||
if got, _ := os.ReadFile("out"); string(got) != "d/x/1/fa.txt d/x/1/fb.txt d/x/1/*.go v1 w1\n" {
|
||||
t.Errorf("out = %q", got)
|
||||
}
|
||||
|
||||
if err := execInput("echo a > {f,g}"); err == nil || lastStatus != 1 {
|
||||
t.Errorf("ambiguous redirect: %v, $? = %d", err, lastStatus)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,4 @@ require (
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.48.0 // indirect
|
||||
require golang.org/x/sys v0.48.0
|
||||
+268
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
package main
|
||||
|
||||
// Job control: in an interactive shell every pipeline runs in its own process
|
||||
// group, which gets the terminal while it runs in the foreground. Ctrl+Z stops
|
||||
// the foreground job, "&" starts a job in the background, and the builtins
|
||||
// jobs, fg, bg and wait manage the jobs. Without a terminal (scripts, gosh -c)
|
||||
// there is no job control, but "&" and wait still work.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/chzyer/readline"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// proc is a stage of a job: a process, or a builtin or a stage that failed to
|
||||
// start (both without process).
|
||||
type proc struct {
|
||||
cmd *exec.Cmd // nil if the stage is no process
|
||||
done chan error // the result of a stage without process
|
||||
finished bool
|
||||
stopped bool
|
||||
err error
|
||||
}
|
||||
|
||||
// job is a pipeline that was started by the shell.
|
||||
type job struct {
|
||||
id int // number in the job table, 0 if not in the table
|
||||
pgid int // process group, 0 without job control or processes
|
||||
text string // the command line
|
||||
procs []*proc
|
||||
tty *readline.State // terminal modes of the job when it was stopped
|
||||
}
|
||||
|
||||
var (
|
||||
jobControl bool // job control is enabled
|
||||
interactiveShell bool // the shell reads commands from a user
|
||||
ttyFd int // the terminal of the shell
|
||||
shellPgid int // the process group of the shell
|
||||
origPgid int // the foreground process group at start
|
||||
shellTTY *readline.State // terminal modes of the shell
|
||||
jobTable []*job // background and stopped jobs
|
||||
exitWarned bool // "there are stopped jobs" was shown
|
||||
)
|
||||
|
||||
// initJobControl enables job control if stdin is a terminal: the shell moves
|
||||
// into its own process group and takes the terminal.
|
||||
func initJobControl() {
|
||||
fd := int(os.Stdin.Fd())
|
||||
if !readline.IsTerminal(fd) {
|
||||
return
|
||||
}
|
||||
pgrp, err := unix.IoctlGetInt(fd, unix.TIOCGPGRP)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
pid := os.Getpid()
|
||||
if syscall.Getpgrp() != pid {
|
||||
if err := syscall.Setpgid(0, 0); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
origPgid, ttyFd, shellPgid = pgrp, fd, pid
|
||||
if err := setForeground(pid); err != nil {
|
||||
return
|
||||
}
|
||||
// Ctrl+Z must not stop the shell itself. (signal.Ignore would be
|
||||
// inherited by the child processes.)
|
||||
signal.Notify(make(chan os.Signal, 1), syscall.SIGTSTP)
|
||||
jobControl = true
|
||||
}
|
||||
|
||||
// releaseTerminal gives the terminal back to the process group that had it
|
||||
// when the shell started.
|
||||
func releaseTerminal() {
|
||||
if jobControl && origPgid > 0 && origPgid != shellPgid {
|
||||
setForeground(origPgid)
|
||||
}
|
||||
}
|
||||
|
||||
// setForeground makes pgid the foreground process group of the terminal.
|
||||
func setForeground(pgid int) error {
|
||||
// The shell gets SIGTTOU if it does this while it is in the background
|
||||
// (when it takes the terminal back from a job), unless the signal is
|
||||
// ignored. It is only ignored for this call, since ignored signals are
|
||||
// inherited by child processes.
|
||||
signal.Ignore(syscall.SIGTTOU)
|
||||
defer signal.Reset(syscall.SIGTTOU)
|
||||
return unix.IoctlSetPointerInt(ttyFd, unix.TIOCSPGRP, pgid)
|
||||
}
|
||||
|
||||
// ttyIndex returns the index of the file in fds that is the terminal of the
|
||||
// shell, or -1.
|
||||
func ttyIndex(fds [3]*os.File) int {
|
||||
tty, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
for i, f := range fds {
|
||||
if info, err := f.Stat(); err == nil && os.SameFile(info, tty) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// finishedProc returns a proc without process that already has its result.
|
||||
func finishedProc(err error) *proc {
|
||||
p := &proc{done: make(chan error, 1)}
|
||||
p.done <- err
|
||||
return p
|
||||
}
|
||||
|
||||
// running reports whether a process of the job is running.
|
||||
func (j *job) running() bool {
|
||||
for _, p := range j.procs {
|
||||
if p.cmd != nil && !p.finished && !p.stopped {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// stopped reports whether the job is stopped: no process is running, but
|
||||
// one is stopped.
|
||||
func (j *job) stopped() bool {
|
||||
if j.running() {
|
||||
return false
|
||||
}
|
||||
for _, p := range j.procs {
|
||||
if p.stopped {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// finished reports whether all stages of the job have finished.
|
||||
func (j *job) finished() bool {
|
||||
for _, p := range j.procs {
|
||||
if !p.finished {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// update records a status change of process pid.
|
||||
func (j *job) update(pid int, ws syscall.WaitStatus) {
|
||||
for _, p := range j.procs {
|
||||
if p.cmd == nil || p.cmd.Process.Pid != pid {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case ws.Stopped():
|
||||
p.stopped = true
|
||||
case ws.Exited() || ws.Signaled():
|
||||
p.finished, p.stopped = true, false
|
||||
p.err = waitStatusError(ws)
|
||||
p.cmd.Process.Release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitStatusError converts the status of a finished process into an error:
|
||||
// nil for exit code 0, otherwise a statusError with the exit code, or 128+n
|
||||
// if the process was killed by signal n.
|
||||
func waitStatusError(ws syscall.WaitStatus) error {
|
||||
switch {
|
||||
case ws.Signaled():
|
||||
return statusError(128 + int(ws.Signal()))
|
||||
case ws.ExitStatus() != 0:
|
||||
return statusError(ws.ExitStatus())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitFlags returns the options for wait4: WUNTRACED to see stopped
|
||||
// processes (with job control) and WNOHANG to not block.
|
||||
//
|
||||
// WCONTINUED is not used: Go's syscall.WaitStatus decodes the "continued"
|
||||
// status wrongly on FreeBSD (as killed by SIGCONT) and macOS (as stopped).
|
||||
// The shell marks jobs as running itself when it continues them (fg, bg); a
|
||||
// job continued from outside (kill -CONT) stays "Stopped" in the job table
|
||||
// until it stops again or exits.
|
||||
func waitFlags(j *job, nohang bool) int {
|
||||
flags := 0
|
||||
if jobControl && j.pgid != 0 {
|
||||
flags |= syscall.WUNTRACED
|
||||
}
|
||||
if nohang {
|
||||
flags |= syscall.WNOHANG
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
// wait waits until no process of the job is running any more (all finished
|
||||
// or, with job control, stopped). With nohang it only collects the status
|
||||
// changes that are already available and does not block.
|
||||
func (j *job) wait(nohang bool) {
|
||||
flags := waitFlags(j, nohang)
|
||||
|
||||
for {
|
||||
// With job control all processes of the job are in its process
|
||||
// group, otherwise they are waited for one by one.
|
||||
target := 0
|
||||
if jobControl && j.pgid != 0 {
|
||||
if !j.running() && !(nohang && j.stopped()) {
|
||||
break
|
||||
}
|
||||
target = -j.pgid
|
||||
} else {
|
||||
if nohang {
|
||||
// Check every process once
|
||||
for _, p := range j.procs {
|
||||
if p.cmd == nil || p.finished {
|
||||
continue
|
||||
}
|
||||
var ws syscall.WaitStatus
|
||||
if pid, err := syscall.Wait4(p.cmd.Process.Pid, &ws, flags, nil); err == nil && pid > 0 {
|
||||
j.update(pid, ws)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
for _, p := range j.procs {
|
||||
if p.cmd != nil && !p.finished {
|
||||
target = p.cmd.Process.Pid
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var ws syscall.WaitStatus
|
||||
pid, err := syscall.Wait4(target, &ws, flags, nil)
|
||||
if errors.Is(err, syscall.EINTR) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
// Should not happen; do not wait forever
|
||||
for _, p := range j.procs {
|
||||
if p.cmd != nil && !p.finished {
|
||||
p.finished, p.stopped = true, false
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
if pid == 0 {
|
||||
break // nohang and nothing changed
|
||||
}
|
||||
j.update(pid, ws)
|
||||
}
|
||||
|
||||
// Stages without process (builtins in pipelines)
|
||||
for _, p := range j.procs {
|
||||
if p.cmd != nil || p.finished {
|
||||
continue
|
||||
}
|
||||
if nohang || j.stopped() {
|
||||
select {
|
||||
case p.err = <-p.done:
|
||||
p.finished = true
|
||||
default:
|
||||
}
|
||||
} else {
|
||||
p.err = <-p.done
|
||||
p.finished = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// result returns the error of the last stage of a finished job (so $? is its
|
||||
// exit status, like in sh) and prints the errors of the other stages.
|
||||
func (j *job) result() error {
|
||||
for _, p := range j.procs[:len(j.procs)-1] {
|
||||
printError(p.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
|
||||
// added to the job table.
|
||||
func waitForeground(j *job) error {
|
||||
j.wait(false)
|
||||
if jobControl && j.pgid != 0 {
|
||||
setForeground(shellPgid)
|
||||
if j.stopped() {
|
||||
j.tty, _ = readline.GetState(ttyFd)
|
||||
}
|
||||
if shellTTY != nil {
|
||||
readline.Restore(ttyFd, shellTTY)
|
||||
}
|
||||
}
|
||||
|
||||
if j.stopped() {
|
||||
addJob(j)
|
||||
fmt.Fprintln(os.Stderr)
|
||||
fmt.Fprintln(os.Stderr, j.describe("Stopped"))
|
||||
return statusError(128 + int(syscall.SIGTSTP))
|
||||
}
|
||||
removeJob(j)
|
||||
j.wait(false) // wait for the builtins, if any
|
||||
return j.result()
|
||||
}
|
||||
|
||||
// addJob adds j to the job table (if it is not there yet) and makes it the
|
||||
// current job.
|
||||
func addJob(j *job) {
|
||||
removeJob(j)
|
||||
if j.id == 0 {
|
||||
for _, other := range jobTable {
|
||||
j.id = max(j.id, other.id)
|
||||
}
|
||||
j.id++
|
||||
}
|
||||
jobTable = append(jobTable, j)
|
||||
}
|
||||
|
||||
// removeJob removes j from the job table.
|
||||
func removeJob(j *job) {
|
||||
for i, other := range jobTable {
|
||||
if other == j {
|
||||
jobTable = append(jobTable[:i], jobTable[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// describe formats a job for jobs and the notifications, like bash:
|
||||
// "[1]+ Stopped sleep 30". The current job (the last one in
|
||||
// the table, i.e. the one stopped or put into the background most recently) is
|
||||
// marked with +, the previous one with -.
|
||||
func (j *job) describe(state string) string {
|
||||
mark := ' '
|
||||
if n := len(jobTable); n > 0 && jobTable[n-1] == j {
|
||||
mark = '+'
|
||||
} else if n > 1 && jobTable[n-2] == j {
|
||||
mark = '-'
|
||||
}
|
||||
text := j.text
|
||||
if state == "Running" {
|
||||
text += " &"
|
||||
}
|
||||
return fmt.Sprintf("[%d]%c %-24s%s", j.id, mark, state, sanitize(text))
|
||||
}
|
||||
|
||||
// state returns the state of a job for jobs and the notifications.
|
||||
func (j *job) state() string {
|
||||
switch {
|
||||
case j.stopped():
|
||||
return "Stopped"
|
||||
case !j.finished():
|
||||
return "Running"
|
||||
}
|
||||
if status := exitStatus(j.procs[len(j.procs)-1].err); status != 0 {
|
||||
return "Exit " + strconv.Itoa(status)
|
||||
}
|
||||
return "Done"
|
||||
}
|
||||
|
||||
// hasStoppedJobs reports whether a job in the table is stopped.
|
||||
func hasStoppedJobs() bool {
|
||||
for _, j := range jobTable {
|
||||
if j.stopped() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// reapJobs collects the status changes of the background jobs, reports
|
||||
// finished and newly stopped jobs (if notify is set) and removes finished
|
||||
// jobs from the table.
|
||||
func reapJobs(notify bool) {
|
||||
for _, j := range append([]*job(nil), jobTable...) {
|
||||
wasStopped := j.stopped()
|
||||
j.wait(true)
|
||||
switch {
|
||||
case j.finished():
|
||||
if notify {
|
||||
for _, p := range j.procs {
|
||||
printError(p.err)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, j.describe(j.state()))
|
||||
}
|
||||
removeJob(j)
|
||||
case j.stopped() && !wasStopped:
|
||||
addJob(j) // the current job now
|
||||
if notify {
|
||||
fmt.Fprintln(os.Stderr, j.describe("Stopped"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// findJob returns the job for a job spec: %N or N for job N, %+, %% or nothing
|
||||
// for the current job, %- for the previous one.
|
||||
func findJob(cmd string, args []string) (*job, error) {
|
||||
spec := "%+"
|
||||
if len(args) > 1 {
|
||||
spec = args[1]
|
||||
}
|
||||
n := len(jobTable)
|
||||
switch spec {
|
||||
case "%+", "%%", "%":
|
||||
if n > 0 {
|
||||
return jobTable[n-1], nil
|
||||
}
|
||||
return nil, fmt.Errorf("%s: current: no such job", cmd)
|
||||
case "%-":
|
||||
if n > 1 {
|
||||
return jobTable[n-2], nil
|
||||
}
|
||||
return nil, fmt.Errorf("%s: previous: no such job", cmd)
|
||||
}
|
||||
id, err := strconv.Atoi(strings.TrimPrefix(spec, "%"))
|
||||
if err == nil {
|
||||
for _, j := range jobTable {
|
||||
if j.id == id {
|
||||
return j, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("%s: %s: no such job", cmd, sanitize(spec))
|
||||
}
|
||||
|
||||
// continueJob sends SIGCONT to a stopped job.
|
||||
func continueJob(j *job) {
|
||||
for _, p := range j.procs {
|
||||
p.stopped = false
|
||||
}
|
||||
if j.pgid != 0 {
|
||||
syscall.Kill(-j.pgid, syscall.SIGCONT)
|
||||
}
|
||||
}
|
||||
|
||||
// builtinJobs lists the jobs.
|
||||
func builtinJobs(stdout io.Writer) error {
|
||||
for _, j := range jobTable {
|
||||
j.wait(true)
|
||||
}
|
||||
for _, j := range append([]*job(nil), jobTable...) {
|
||||
fmt.Fprintln(stdout, j.describe(j.state()))
|
||||
if j.finished() {
|
||||
removeJob(j)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// builtinFg continues a job in the foreground and waits for it.
|
||||
func builtinFg(args []string, stdout io.Writer) error {
|
||||
if !jobControl {
|
||||
return errors.New("fg: no job control")
|
||||
}
|
||||
j, err := findJob("fg", args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(stdout, sanitize(j.text))
|
||||
|
||||
if j.pgid != 0 {
|
||||
shellTTY, _ = readline.GetState(ttyFd)
|
||||
if j.stopped() && j.tty != nil {
|
||||
readline.Restore(ttyFd, j.tty)
|
||||
}
|
||||
setForeground(j.pgid)
|
||||
}
|
||||
continueJob(j)
|
||||
return waitForeground(j)
|
||||
}
|
||||
|
||||
// builtinBg continues a stopped job in the background.
|
||||
func builtinBg(args []string, stdout io.Writer) error {
|
||||
if !jobControl {
|
||||
return errors.New("bg: no job control")
|
||||
}
|
||||
j, err := findJob("bg", args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !j.stopped() {
|
||||
return fmt.Errorf("bg: job %d already in background", j.id)
|
||||
}
|
||||
continueJob(j)
|
||||
addJob(j) // the current job now
|
||||
fmt.Fprintln(stdout, j.describe("Running"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// builtinWait waits for a job, or for all background jobs. Stopped jobs are
|
||||
// not waited for.
|
||||
func builtinWait(args []string) error {
|
||||
jobs := append([]*job(nil), jobTable...)
|
||||
if len(args) > 1 {
|
||||
j := jobByPid(args[1])
|
||||
if j == nil {
|
||||
var err error
|
||||
if j, err = findJob("wait", args); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
jobs = []*job{j}
|
||||
}
|
||||
|
||||
var err error
|
||||
for _, j := range jobs {
|
||||
j.wait(false)
|
||||
err = nil
|
||||
if j.finished() {
|
||||
removeJob(j)
|
||||
err = j.result()
|
||||
}
|
||||
}
|
||||
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
|
||||
// not warned yet (like bash).
|
||||
func exitShell(code int) error {
|
||||
if interactiveShell && hasStoppedJobs() && !exitWarned {
|
||||
exitWarned = true
|
||||
return errors.New("there are stopped jobs")
|
||||
}
|
||||
lastStatus = code // $? in the EXIT trap
|
||||
runExitTrap()
|
||||
releaseTerminal()
|
||||
os.Exit(code)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// openPty opens a new pseudo terminal and returns the master and slave side.
|
||||
func openPty(t *testing.T) (*os.File, *os.File) {
|
||||
t.Helper()
|
||||
master, err := os.OpenFile("/dev/ptmx", os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
t.Skipf("no pty available: %v", err)
|
||||
}
|
||||
if err := unix.IoctlSetPointerInt(int(master.Fd()), unix.TIOCSPTLCK, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, err := unix.IoctlGetInt(int(master.Fd()), unix.TIOCGPTN)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slave, err := os.OpenFile("/dev/pts/"+strconv.Itoa(n), os.O_RDWR|syscall.O_NOCTTY, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return master, slave
|
||||
}
|
||||
|
||||
// ptyShell is the shell running on a pseudo terminal.
|
||||
type ptyShell struct {
|
||||
t *testing.T
|
||||
master *os.File
|
||||
mu sync.Mutex
|
||||
out bytes.Buffer
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
func startPtyShell(t *testing.T) *ptyShell {
|
||||
bin := buildShell(t)
|
||||
master, slave := openPty(t)
|
||||
cmd := exec.Command(bin, "--norc")
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = slave, slave, slave
|
||||
cmd.Env = append(os.Environ(), "NO_COLOR=1")
|
||||
// New session with the pty as controlling terminal, like a terminal
|
||||
// emulator does
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 0}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slave.Close()
|
||||
|
||||
s := &ptyShell{t: t, master: master, cmd: cmd}
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := master.Read(buf)
|
||||
s.mu.Lock()
|
||||
s.out.Write(buf[:n])
|
||||
s.mu.Unlock()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
master.Close()
|
||||
})
|
||||
s.expect("$ ")
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *ptyShell) send(text string) {
|
||||
s.master.WriteString(text)
|
||||
}
|
||||
|
||||
// expect waits until want appears in the output after the position of the
|
||||
// last match and consumes the output up to it.
|
||||
func (s *ptyShell) expect(want string) {
|
||||
s.t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
s.mu.Lock()
|
||||
out := s.out.String()
|
||||
if i := strings.Index(out, want); i >= 0 {
|
||||
s.out.Next(i + len(want))
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.mu.Unlock()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.t.Fatalf("timeout waiting for %q, output:\n%s", want, s.out.String())
|
||||
}
|
||||
|
||||
func TestWaitFlags_NoWCONTINUED(t *testing.T) {
|
||||
defer func(saved bool) { jobControl = saved }(jobControl)
|
||||
jobControl = true
|
||||
j := &job{pgid: 1}
|
||||
for _, nohang := range []bool{false, true} {
|
||||
flags := waitFlags(j, nohang)
|
||||
if flags&syscall.WCONTINUED != 0 {
|
||||
t.Errorf("waitFlags(nohang=%v) contains WCONTINUED, which Go decodes wrongly on BSD", nohang)
|
||||
}
|
||||
if flags&syscall.WUNTRACED == 0 {
|
||||
t.Errorf("waitFlags(nohang=%v) misses WUNTRACED", nohang)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobControl(t *testing.T) {
|
||||
s := startPtyShell(t)
|
||||
|
||||
// Ctrl+Z stops the foreground job, the shell gets the terminal back
|
||||
s.send("sleep 30\n")
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
s.send("\x1a")
|
||||
s.expect("[1]+ Stopped sleep 30")
|
||||
s.send("jobs\n")
|
||||
s.expect("[1]+ Stopped sleep 30")
|
||||
|
||||
// bg continues it in the background
|
||||
s.send("bg\n")
|
||||
s.expect("[1]+ Running sleep 30 &")
|
||||
|
||||
// After bg the job keeps running and is not reported as finished
|
||||
// (regression: on FreeBSD the "continued" status was read as killed by
|
||||
// SIGCONT, "Exit 147")
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
s.send("jobs\n")
|
||||
s.expect("[1]+ Running sleep 30 &")
|
||||
|
||||
// fg brings it back, Ctrl+C reaches only the job
|
||||
s.send("fg %1\n")
|
||||
s.expect("sleep 30")
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
s.send("\x03")
|
||||
s.send("echo status=$?\n")
|
||||
s.expect("status=130")
|
||||
|
||||
// A pipeline is stopped and continued as a whole
|
||||
s.send("sleep 30 | cat\n")
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
s.send("\x1a")
|
||||
s.expect("Stopped sleep 30 | cat")
|
||||
s.send("fg\n")
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
s.send("\x03")
|
||||
s.send("echo pipeline=$?\n")
|
||||
s.expect("pipeline=130")
|
||||
|
||||
// A background job that reads from the terminal is stopped (SIGTTIN),
|
||||
// in the foreground it gets the input
|
||||
s.send("cat &\n")
|
||||
s.expect("[1] ")
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
s.send("echo next\n")
|
||||
s.expect("[1]+ Stopped cat")
|
||||
s.send("fg\n")
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
s.send("hello cat\n")
|
||||
s.expect("hello cat\r\nhello cat")
|
||||
s.send("\x04")
|
||||
s.send("echo cat=$?\n")
|
||||
s.expect("cat=0")
|
||||
|
||||
// Finished background jobs are reported before the next prompt
|
||||
s.send("sleep 0.2 &\n")
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
s.send("echo tick\n")
|
||||
s.expect("Done sleep 0.2")
|
||||
|
||||
// exit warns about stopped jobs once
|
||||
s.send("sleep 30\n")
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
s.send("\x1a")
|
||||
s.expect("Stopped")
|
||||
s.send("exit\n")
|
||||
s.expect("there are stopped jobs")
|
||||
s.send("exit 0\n")
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- s.cmd.Wait() }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Errorf("shell exited with %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("shell did not exit")
|
||||
}
|
||||
}
|
||||
+589
-56
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -226,7 +227,7 @@ func TestShell_EndToEnd(t *testing.T) {
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := exec.Command("go", "run", "main.go")
|
||||
cmd := exec.Command("go", "run", ".")
|
||||
cmd.Stdin = strings.NewReader(tc.input)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
@@ -259,7 +260,7 @@ func TestShell_EOF_ExitsGracefully(t *testing.T) {
|
||||
// Simulate EOF by sending empty input
|
||||
// Note: When using piped input, readline doesn't print prompts
|
||||
// So we just verify the shell exits cleanly
|
||||
cmd := exec.Command("go", "run", "main.go")
|
||||
cmd := exec.Command("go", "run", ".")
|
||||
cmd.Stdin = strings.NewReader("")
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
@@ -278,7 +279,7 @@ func TestShell_EOF_ExitsGracefully(t *testing.T) {
|
||||
func TestShell_EOF_AfterCommand(t *testing.T) {
|
||||
// Test that EOF after a command works
|
||||
// Note: When using piped input, readline doesn't print prompts
|
||||
cmd := exec.Command("go", "run", "main.go")
|
||||
cmd := exec.Command("go", "run", ".")
|
||||
cmd.Stdin = strings.NewReader("echo test\n")
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
@@ -302,7 +303,7 @@ func TestShell_SIGINT_HandlerSetup(t *testing.T) {
|
||||
// This test verifies that the shell doesn't panic when receiving signals
|
||||
// We can't easily test SIGINT handling from a test, but we can verify
|
||||
// the shell runs without crashing
|
||||
cmd := exec.Command("go", "run", "main.go")
|
||||
cmd := exec.Command("go", "run", ".")
|
||||
cmd.Stdin = strings.NewReader("echo test\nquit\n")
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
@@ -398,6 +399,7 @@ func TestExecInput_CdDashNoPrevious(t *testing.T) {
|
||||
// =============================================================================
|
||||
|
||||
func TestGetPrompt_Format(t *testing.T) {
|
||||
unsetPS1(t)
|
||||
prompt := getPrompt()
|
||||
|
||||
// Should contain username@hostname:path $ pattern
|
||||
@@ -413,6 +415,7 @@ func TestGetPrompt_Format(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetPrompt_IncludesHome(t *testing.T) {
|
||||
unsetPS1(t)
|
||||
// Save and restore current directory
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
@@ -458,7 +461,7 @@ func TestShell_ExitWithStatusCode(t *testing.T) {
|
||||
}
|
||||
|
||||
// Build the binary once
|
||||
build := exec.Command("go", "build", "-o", "gosh_test", "main.go")
|
||||
build := exec.Command("go", "build", "-o", "gosh_test", ".")
|
||||
if err := build.Run(); err != nil {
|
||||
t.Fatalf("failed to build: %v", err)
|
||||
}
|
||||
@@ -494,7 +497,7 @@ func TestShell_ExitWithStatusCode(t *testing.T) {
|
||||
// =============================================================================
|
||||
|
||||
func TestShell_EnvExpansion_Home(t *testing.T) {
|
||||
cmd := exec.Command("go", "run", "main.go")
|
||||
cmd := exec.Command("go", "run", ".")
|
||||
cmd.Stdin = strings.NewReader("echo $HOME\nexit\n")
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
@@ -513,7 +516,7 @@ func TestShell_EnvExpansion_Home(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestShell_EnvExpansion_Path(t *testing.T) {
|
||||
cmd := exec.Command("go", "run", "main.go")
|
||||
cmd := exec.Command("go", "run", ".")
|
||||
cmd.Stdin = strings.NewReader("echo $PATH\nexit\n")
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
@@ -532,7 +535,7 @@ func TestShell_EnvExpansion_Path(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestShell_EnvExpansion_Braces(t *testing.T) {
|
||||
cmd := exec.Command("go", "run", "main.go")
|
||||
cmd := exec.Command("go", "run", ".")
|
||||
cmd.Stdin = strings.NewReader("echo ${HOME}\nexit\n")
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
@@ -551,7 +554,7 @@ func TestShell_EnvExpansion_Braces(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestShell_EnvExpansion_CdWithVar(t *testing.T) {
|
||||
cmd := exec.Command("go", "run", "main.go")
|
||||
cmd := exec.Command("go", "run", ".")
|
||||
cmd.Stdin = strings.NewReader("cd $HOME\npwd\nexit\n")
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
@@ -570,7 +573,7 @@ func TestShell_EnvExpansion_CdWithVar(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestShell_TildeExpansion_Ls(t *testing.T) {
|
||||
cmd := exec.Command("go", "run", "main.go")
|
||||
cmd := exec.Command("go", "run", ".")
|
||||
cmd.Stdin = strings.NewReader("ls ~\nexit\n")
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
@@ -630,12 +633,11 @@ func TestExecInput_ExportEnvVar(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExecInput_SetNoArgs(t *testing.T) {
|
||||
err := execInput("set")
|
||||
if err == nil {
|
||||
t.Error("expected error for set with no args, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "usage: set VAR=value") {
|
||||
t.Errorf("expected usage error, got: %v", err)
|
||||
// set without arguments shows the variables, quoted for the shell
|
||||
t.Setenv("GOSH_SHOWN", "a b")
|
||||
got := runIn(t, "set > out")
|
||||
if !strings.Contains(got, "GOSH_SHOWN='a b'\n") {
|
||||
t.Errorf("set: GOSH_SHOWN missing in %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -841,7 +843,7 @@ func TestShellCompleter_Do(t *testing.T) {
|
||||
want []string
|
||||
length int
|
||||
}{
|
||||
{"c", []string{"d "}, 1},
|
||||
{"c", []string{"d ", "ontinue "}, 1},
|
||||
{"e", []string{"cho ", "xit ", "xport "}, 1},
|
||||
{"gi", []string{"tty "}, 2},
|
||||
{" gi", []string{"tty "}, 2},
|
||||
@@ -889,6 +891,14 @@ func TestMain(m *testing.M) {
|
||||
panic(err)
|
||||
}
|
||||
os.Setenv("GOSH_HISTFILE", filepath.Join(dir, "history"))
|
||||
// Never read the system's startup files; tests that read the user's
|
||||
// files set $HOME themselves
|
||||
sysConfDir = filepath.Join(dir, "etc")
|
||||
// Command substitution runs gosh, not the test binary
|
||||
subshellPath = filepath.Join(dir, "gosh")
|
||||
if out, err := exec.Command("go", "build", "-o", subshellPath, ".").CombinedOutput(); err != nil {
|
||||
panic(fmt.Sprintf("building gosh: %v\n%s", err, out))
|
||||
}
|
||||
code := m.Run()
|
||||
os.RemoveAll(dir)
|
||||
os.Exit(code)
|
||||
@@ -932,7 +942,7 @@ func TestSplitWords(t *testing.T) {
|
||||
{`echo x${GOSH_A}y`, []string{"echo", "xa by"}},
|
||||
// Escaped and lone dollar signs stay literal
|
||||
{`echo \$GOSH_A "\$GOSH_A"`, []string{"echo", "$GOSH_A", "$GOSH_A"}},
|
||||
{`echo $ 5$ $1`, []string{"echo", "$", "5$", "$1"}},
|
||||
{`echo $ 5$ $%`, []string{"echo", "$", "5$", "$%"}},
|
||||
// Backslash in double quotes only escapes $ " \ and `
|
||||
{`echo "a\nb\\c"`, []string{"echo", `a\nb\c`}},
|
||||
// Defaults
|
||||
@@ -1029,6 +1039,7 @@ func TestSanitize(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetPrompt_EscapesControlCharacters(t *testing.T) {
|
||||
unsetPS1(t)
|
||||
t.Setenv("NO_COLOR", "1")
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
@@ -1051,24 +1062,24 @@ func TestGetPrompt_EscapesControlCharacters(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildPrompt_Color(t *testing.T) {
|
||||
got := buildPrompt("flow", "host", "~/src", false, &defaultColors)
|
||||
got := buildPrompt("flow", "host", "~/src", false, 0, &defaultColors)
|
||||
want := "\x1b[1;32mflow@host\x1b[0m:\x1b[1;34m~/src\x1b[0m $ "
|
||||
if got != want {
|
||||
t.Errorf("buildPrompt() = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
got = buildPrompt("root", "host", "/", true, &defaultColors)
|
||||
got = buildPrompt("root", "host", "/", true, 0, &defaultColors)
|
||||
if !strings.HasPrefix(got, "\x1b[1;31mroot@host") {
|
||||
t.Errorf("expected red user for root, got %q", got)
|
||||
}
|
||||
|
||||
got = buildPrompt("flow", "host", "~/src", false, nil)
|
||||
got = buildPrompt("flow", "host", "~/src", false, 0, nil)
|
||||
if got != "flow@host:~/src $ " {
|
||||
t.Errorf("buildPrompt() without color = %q", got)
|
||||
}
|
||||
|
||||
colors := promptColors{user: "38;5;208", path: ""}
|
||||
got = buildPrompt("flow", "host", "~/src", false, &colors)
|
||||
got = buildPrompt("flow", "host", "~/src", false, 0, &colors)
|
||||
if got != "\x1b[38;5;208mflow@host\x1b[0m:~/src $ " {
|
||||
t.Errorf("buildPrompt() with custom colors = %q", got)
|
||||
}
|
||||
@@ -1080,13 +1091,14 @@ func TestParseColors(t *testing.T) {
|
||||
want promptColors
|
||||
}{
|
||||
{"", defaultColors},
|
||||
{"user=1;33", promptColors{user: "1;33", root: "1;31", path: "1;34"}},
|
||||
{"user=33:root=41;97:path=38;5;208", promptColors{user: "33", root: "41;97", path: "38;5;208"}},
|
||||
{"path=", promptColors{user: "1;32", root: "1;31", path: ""}},
|
||||
{"user=1;33", promptColors{user: "1;33", root: "1;31", path: "1;34", error: "1;31"}},
|
||||
{"user=33:root=41;97:path=38;5;208", promptColors{user: "33", root: "41;97", path: "38;5;208", error: "1;31"}},
|
||||
{"error=33", promptColors{user: "1;32", root: "1;31", path: "1;34", error: "33"}},
|
||||
{"path=", promptColors{user: "1;32", root: "1;31", path: "", error: "1;31"}},
|
||||
{"path=0m\x1b[2J", defaultColors}, // escape sequences are rejected
|
||||
{"path=red", defaultColors}, // only numeric codes
|
||||
{"foo=1:user", defaultColors}, // unknown key, missing value
|
||||
{"user=35::path=36", promptColors{user: "35", root: "1;31", path: "36"}},
|
||||
{"user=35::path=36", promptColors{user: "35", root: "1;31", path: "36", error: "1;31"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := parseColors(tc.spec); got != tc.want {
|
||||
@@ -1095,8 +1107,27 @@ func TestParseColors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrompt_Status(t *testing.T) {
|
||||
if got := buildPrompt("flow", "host", "~", false, 0, nil); got != "flow@host:~ $ " {
|
||||
t.Errorf("status 0: %q", got)
|
||||
}
|
||||
if got := buildPrompt("flow", "host", "~", false, 127, nil); got != "flow@host:~ [127] $ " {
|
||||
t.Errorf("status 127 without color: %q", got)
|
||||
}
|
||||
got := buildPrompt("flow", "host", "~", false, 1, &defaultColors)
|
||||
want := "\x1b[1;32mflow@host\x1b[0m:\x1b[1;34m~\x1b[0m \x1b[1;31m[1]\x1b[0m $ "
|
||||
if got != want {
|
||||
t.Errorf("status 1 with color = %q, want %q", got, want)
|
||||
}
|
||||
colors := defaultColors
|
||||
colors.error = ""
|
||||
if got := buildPrompt("flow", "host", "~", false, 2, &colors); !strings.HasSuffix(got, " [2] $ ") {
|
||||
t.Errorf("status without error color = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrompt_ColorEscapesControlCharacters(t *testing.T) {
|
||||
got := buildPrompt("flow", "host", "evil\x1b[2J\x07", false, &defaultColors)
|
||||
got := buildPrompt("flow", "host", "evil\x1b[2J\x07", false, 0, &defaultColors)
|
||||
if strings.Contains(got, "\x1b[2J") || strings.Contains(got, "\x07") {
|
||||
t.Errorf("prompt contains control characters from path: %q", got)
|
||||
}
|
||||
@@ -1105,6 +1136,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) {
|
||||
t.Setenv("NO_COLOR", "1")
|
||||
if useColor() {
|
||||
@@ -1183,7 +1239,7 @@ func TestShell_History(t *testing.T) {
|
||||
histFile := filepath.Join(t.TempDir(), "gosh", "history")
|
||||
workDir := t.TempDir()
|
||||
|
||||
cmd := exec.Command(bin, "-i")
|
||||
cmd := exec.Command(bin, "-i", "--norc")
|
||||
cmd.Dir = workDir
|
||||
cmd.Env = append(os.Environ(), "GOSH_HISTFILE="+histFile)
|
||||
cmd.Stdin = strings.NewReader("echo first\n set SECRET=hunter2\nexit\n")
|
||||
@@ -1215,7 +1271,7 @@ func TestShell_History(t *testing.T) {
|
||||
func TestShell_SIGINTKillsOnlyChild(t *testing.T) {
|
||||
bin := buildShell(t)
|
||||
|
||||
cmd := exec.Command(bin, "-i")
|
||||
cmd := exec.Command(bin, "-i", "--norc")
|
||||
// Own process group, so that we can signal it like the terminal does
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
stdin, err := cmd.StdinPipe()
|
||||
@@ -1313,7 +1369,7 @@ func TestRunScript(t *testing.T) {
|
||||
"touch first\n" +
|
||||
"false\n" +
|
||||
"touch second" // no trailing newline
|
||||
if got := runScript(strings.NewReader(script)); got != 0 {
|
||||
if got := runScript(strings.NewReader(script), ""); got != 0 {
|
||||
t.Errorf("runScript() = %d, want 0", got)
|
||||
}
|
||||
for _, name := range []string{"first", "second"} {
|
||||
@@ -1323,7 +1379,7 @@ func TestRunScript(t *testing.T) {
|
||||
}
|
||||
|
||||
// The status of the last command is returned
|
||||
if got := runScript(strings.NewReader("true\nsh -c 'exit 3'\n")); got != 3 {
|
||||
if got := runScript(strings.NewReader("true\nsh -c 'exit 3'\n"), ""); got != 3 {
|
||||
t.Errorf("runScript() = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
@@ -1335,9 +1391,9 @@ func TestRun_Options(t *testing.T) {
|
||||
}{
|
||||
{[]string{"-c", "true"}, 0},
|
||||
{[]string{"-c", "false"}, 1},
|
||||
{[]string{"-l", "-c", "sh -c 'exit 5'"}, 5},
|
||||
{[]string{"-l", "--noprofile", "-c", "sh -c 'exit 5'"}, 5},
|
||||
{[]string{"-c"}, 2},
|
||||
{[]string{"-x"}, 2},
|
||||
{[]string{"-z"}, 2},
|
||||
{[]string{"/gosh/no/such/script"}, 127},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
@@ -1358,41 +1414,214 @@ func TestReadLine_DoesNotReadAhead(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitList(t *testing.T) {
|
||||
// treeString formats a syntax tree for the tests: simple commands in
|
||||
// brackets, background commands with a trailing &.
|
||||
func treeString(l *listNode) string {
|
||||
var parts []string
|
||||
for _, e := range l.items {
|
||||
s := pipelineString(e.andOr.first)
|
||||
for _, r := range e.andOr.rest {
|
||||
s += " " + r.op + " " + pipelineString(r.pipeline)
|
||||
}
|
||||
if e.background {
|
||||
s += " &"
|
||||
}
|
||||
parts = append(parts, s)
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
func pipelineString(pl *pipelineNode) string {
|
||||
s := ""
|
||||
if pl.negate {
|
||||
s = "! "
|
||||
}
|
||||
switch n := pl.cmd.(type) {
|
||||
case *simpleNode:
|
||||
s += "[" + n.text + "]"
|
||||
case *ifNode:
|
||||
s += "if"
|
||||
for i := range n.conds {
|
||||
if i > 0 {
|
||||
s += " elif"
|
||||
}
|
||||
s += " " + treeString(n.conds[i]) + " then " + treeString(n.bodies[i])
|
||||
}
|
||||
if n.elseBody != nil {
|
||||
s += " else " + treeString(n.elseBody)
|
||||
}
|
||||
s += " fi"
|
||||
case *forNode:
|
||||
s += "for " + n.name
|
||||
if n.hasIn {
|
||||
s += " in (" + n.words + ")"
|
||||
}
|
||||
s += " do " + treeString(n.body) + " done"
|
||||
case *whileNode:
|
||||
if n.until {
|
||||
s += "until "
|
||||
} else {
|
||||
s += "while "
|
||||
}
|
||||
s += treeString(n.cond) + " do " + treeString(n.body) + " done"
|
||||
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
|
||||
}
|
||||
|
||||
func TestParse_Lists(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want []listItem
|
||||
want string
|
||||
}{
|
||||
{"", nil},
|
||||
{" ", nil},
|
||||
{"# only a comment", nil},
|
||||
{"ls", []listItem{{"", "ls"}}},
|
||||
{"a; b", []listItem{{"", "a"}, {";", " b"}}},
|
||||
{"a;", []listItem{{"", "a"}}},
|
||||
{"a && b || c", []listItem{{"", "a "}, {"&&", " b "}, {"||", " c"}}},
|
||||
{`echo "a;b" 'c&&d' e\;f`, []listItem{{"", `echo "a;b" 'c&&d' e\;f`}}},
|
||||
{`echo "x\"; y"`, []listItem{{"", `echo "x\"; y"`}}},
|
||||
{"echo ${X:-a;b}; c", []listItem{{"", "echo ${X:-a;b}"}, {";", " c"}}},
|
||||
{"echo a # b; c", []listItem{{"", "echo a "}}},
|
||||
{"echo a#b; c", []listItem{{"", "echo a#b"}, {";", " c"}}},
|
||||
{"echo a|b", []listItem{{"", "echo a|b"}}},
|
||||
{"", ""},
|
||||
{" ", ""},
|
||||
{"# only a comment", ""},
|
||||
{"ls", "[ls]"},
|
||||
{"a; b", "[a]; [b]"},
|
||||
{"a;", "[a]"},
|
||||
{"a\nb\n", "[a]; [b]"},
|
||||
{"a && b || c", "[a] && [b] || [c]"},
|
||||
{"a &&\n b", "[a] && [b]"},
|
||||
{"a | b|c", "[a | b|c]"},
|
||||
{"a |\n b", "[a |\n b]"},
|
||||
{`echo "a;b" 'c&&d' e\;f`, `[echo "a;b" 'c&&d' e\;f]`},
|
||||
{`echo "x\"; y"`, `[echo "x\"; y"]`},
|
||||
{"echo ${X:-a;b}; c", "[echo ${X:-a;b}]; [c]"},
|
||||
{"echo a # b; c", "[echo a]"},
|
||||
{"echo a#b; c", "[echo a#b]; [c]"},
|
||||
{"a & b", "[a] &; [b]"},
|
||||
{"a&", "[a] &"},
|
||||
{"a 2>&1 &", "[a 2>&1] &"},
|
||||
{"a &> f &", "[a &> f] &"},
|
||||
{"a >&2 && b", "[a >&2] && [b]"},
|
||||
{`echo "a & b" \&`, `[echo "a & b" \&]`},
|
||||
{"echo $(a; b && c | d) \"$(e || f)\" & g", `[echo $(a; b && c | d) "$(e || f)"] &; [g]`},
|
||||
{"! false", "! [false]"},
|
||||
{"echo a \\\n b", "[echo a b]"},
|
||||
// Reserved words are only special as the first word of a command
|
||||
{"echo if then fi do done", "[echo if then fi do done]"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got, err := splitList(tc.input)
|
||||
tree, err := parse(tc.input)
|
||||
if err != nil {
|
||||
t.Errorf("splitList(%q) returned error: %v", tc.input, err)
|
||||
t.Errorf("parse(%q) returned error: %v", tc.input, err)
|
||||
continue
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("splitList(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
if got := treeString(tree); got != tc.want {
|
||||
t.Errorf("parse(%q) = %s, want %s", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitList_SyntaxErrors(t *testing.T) {
|
||||
for _, input := range []string{"; a", "a ;; b", "&& a", "a &&", "a ||", "a && ; b", "a || && b"} {
|
||||
if _, err := splitList(input); err == nil || !strings.Contains(err.Error(), "syntax error") {
|
||||
t.Errorf("splitList(%q) = %v, want syntax error", input, err)
|
||||
func TestParse_ControlStructures(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"if a; then b; fi", "if [a] then [b] fi"},
|
||||
{"if a\nthen\n b\n c\nfi", "if [a] then [b]; [c] fi"},
|
||||
{"if a; then b; elif c; then d; else e; fi", "if [a] then [b] elif [c] then [d] else [e] fi"},
|
||||
{"if a && ! b; then c; fi; echo x", "if [a] && ! [b] then [c] fi; [echo x]"},
|
||||
{"for x in a b c; do echo $x; done", "for x in (a b c) do [echo $x] done"},
|
||||
{"for x in *.go {1..3}\ndo\n echo $x\ndone", "for x in (*.go {1..3}) do [echo $x] done"},
|
||||
{"for x; do echo; done", "for x do [echo] done"},
|
||||
{"for x in; do echo; done", "for x in () do [echo] done"},
|
||||
{"while a; do b; done", "while [a] do [b] done"},
|
||||
{"until a; do b; done", "until [a] do [b] done"},
|
||||
{"while a; do if b; then break; fi; done", "while [a] do if [b] then [break] fi done"},
|
||||
{"for i in 1 2; do for j in 3 4; do echo $i$j; done; done", "for i in (1 2) do for j in (3 4) do [echo $i$j] done done"},
|
||||
{"if a; then if b; then c; fi fi", "if [a] then if [b] then [c] fi fi"},
|
||||
// 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 {
|
||||
tree, err := parse(tc.input)
|
||||
if err != nil {
|
||||
t.Errorf("parse(%q) returned error: %v", tc.input, err)
|
||||
continue
|
||||
}
|
||||
if got := treeString(tree); got != tc.want {
|
||||
t.Errorf("parse(%q) = %s, want %s", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_Incomplete(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"if a", "if a; then", "if a; then b", "if a; then b; else", "if a; then b; elif c",
|
||||
"for x", "for x in a b", "for x in a; do", "for x in a; do b",
|
||||
"while a", "while a; do b", "until a; do",
|
||||
"(a", "(a; 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 \\",
|
||||
} {
|
||||
if _, err := parse(input); !errors.Is(err, errIncomplete) {
|
||||
t.Errorf("parse(%q) = %v, want incomplete", input, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_SyntaxErrors(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
"; a", "a ;; b", "&& a", "a && ; b", "a || && b", "& a", "a & && b", "a & & b",
|
||||
"fi", "then", "done", "if a; then fi", "if ; then a; fi", "while a; do done",
|
||||
"if a; then b; done", "for 1x in a; do b; done", "for x in a do b; done; x",
|
||||
"a | | b", "| a",
|
||||
"()", "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)
|
||||
if err == nil || errors.Is(err, errIncomplete) {
|
||||
t.Errorf("parse(%q) = %v, want syntax error", input, err)
|
||||
}
|
||||
}
|
||||
for input, want := range map[string]string{
|
||||
"f() { a; } | b": "function definitions in pipelines are not supported",
|
||||
"f() { a; } > out": "redirections of function definitions are not supported",
|
||||
"if a; then b; fi c": "syntax error near unexpected token `c'",
|
||||
} {
|
||||
if _, err := parse(input); err == nil || err.Error() != want {
|
||||
t.Errorf("parse(%q) = %v, want %q", input, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1514,6 +1743,10 @@ func TestTokenize_Operators(t *testing.T) {
|
||||
t.Errorf("tokenize(%q) returned error: %v", tc.input, err)
|
||||
continue
|
||||
}
|
||||
// Patterns are tested in TestTokenize_Patterns
|
||||
for i := range got {
|
||||
got[i].pattern, got[i].glob, got[i].assign = "", false, false
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("tokenize(%q) = %+v, want %+v", tc.input, got, tc.want)
|
||||
}
|
||||
@@ -1620,3 +1853,303 @@ func TestExecInput_RedirectionErrors(t *testing.T) {
|
||||
t.Error("file f must not be created for a bad file descriptor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenize_Patterns(t *testing.T) {
|
||||
t.Setenv("GOSH_GLOB", "*.go")
|
||||
tests := []struct {
|
||||
input string
|
||||
pattern string
|
||||
glob bool
|
||||
}{
|
||||
{"*.go", "*.go", true},
|
||||
{"a?c", "a?c", true},
|
||||
{"[ab].go", "[ab].go", true},
|
||||
{"[!a].go", "[^a].go", true},
|
||||
{"plain", "plain", false},
|
||||
// Quoted, escaped and expanded characters are literal
|
||||
{`"*.go"`, `\*.go`, false},
|
||||
{`'*'.go`, `\*.go`, false},
|
||||
{`\*.go`, `\*.go`, false},
|
||||
{`"x"*`, `x*`, true},
|
||||
{"$GOSH_GLOB", `\*.go`, false},
|
||||
{`'['!a].go`, `\[!a].go`, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
tokens, err := tokenize(tc.input)
|
||||
if err != nil || len(tokens) != 1 {
|
||||
t.Errorf("tokenize(%q) = %+v, %v", tc.input, tokens, err)
|
||||
continue
|
||||
}
|
||||
if tokens[0].pattern != tc.pattern || tokens[0].glob != tc.glob {
|
||||
t.Errorf("tokenize(%q): pattern %q, glob %v; want %q, %v",
|
||||
tc.input, tokens[0].pattern, tokens[0].glob, tc.pattern, tc.glob)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandGlob(t *testing.T) {
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
dir := t.TempDir()
|
||||
os.Chdir(dir)
|
||||
for _, d := range []string{"sub/deep", "other"} {
|
||||
os.MkdirAll(d, 0755)
|
||||
}
|
||||
for _, f := range []string{"a.go", "b.go", "c.txt", ".hidden.go", "with space.go", "[x].go", "sub/s1.go", "sub/deep/d.go", "other/o.go"} {
|
||||
os.WriteFile(f, nil, 0644)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
pattern string
|
||||
want []string
|
||||
}{
|
||||
{"*.go", []string{"[x].go", "a.go", "b.go", "with space.go"}},
|
||||
{".*.go", []string{".hidden.go"}},
|
||||
{"?.go", []string{"a.go", "b.go"}},
|
||||
{"[ab].go", []string{"a.go", "b.go"}},
|
||||
{"[^a].go", []string{"b.go"}},
|
||||
{"*/*.go", []string{"other/o.go", "sub/s1.go"}},
|
||||
{"*/", []string{"other/", "sub/"}},
|
||||
{"sub/*/*.go", []string{"sub/deep/d.go"}},
|
||||
{"./c*", []string{"./c.txt"}},
|
||||
{dir + "/c*", []string{dir + "/c.txt"}},
|
||||
{`\[x\].go`, []string{"[x].go"}},
|
||||
{"*.xyz", nil},
|
||||
{"[", nil},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := expandGlob(tc.pattern); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("expandGlob(%q) = %q, want %q", tc.pattern, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecInput_Wildcards(t *testing.T) {
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
dir := t.TempDir()
|
||||
os.Chdir(dir)
|
||||
for _, f := range []string{"a.go", "b.go", "c.txt"} {
|
||||
os.WriteFile(f, []byte(f+"\n"), 0644)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"echo *.go > out", "a.go b.go\n"},
|
||||
{"cat *.go > out", "a.go\nb.go\n"},
|
||||
// No match: the word is passed on unchanged, like in sh
|
||||
{"echo *.xyz > out", "*.xyz\n"},
|
||||
{`echo "*.go" '?.go' \*.go > out`, "*.go ?.go *.go\n"},
|
||||
// A redirection to a pattern with a single match uses that file
|
||||
{"echo hi > *.txt; cat c.txt > out", "hi\n"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if err := execInput(tc.input); err != nil {
|
||||
t.Errorf("execInput(%q) returned error: %v", tc.input, err)
|
||||
}
|
||||
if got, _ := os.ReadFile("out"); string(got) != tc.want {
|
||||
t.Errorf("%q: got %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
err := execInput("echo hi > *.go")
|
||||
if err == nil || err.Error() != "*.go: ambiguous redirect" {
|
||||
t.Errorf("expected ambiguous redirect error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartupFiles(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
sys := sysConfDir
|
||||
|
||||
tests := []struct {
|
||||
login, interactive bool
|
||||
want []string
|
||||
}{
|
||||
{false, false, nil},
|
||||
{true, false, []string{sys + "/profile", home + "/.gosh_profile"}},
|
||||
{false, true, []string{sys + "/goshrc", home + "/.goshrc"}},
|
||||
{true, true, []string{sys + "/profile", home + "/.gosh_profile", sys + "/goshrc", home + "/.goshrc"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := startupFiles(tc.login, tc.interactive); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("startupFiles(%v, %v) = %q, want %q", tc.login, tc.interactive, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_LoginReadsProfile(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
os.Unsetenv("GOSH_TEST_PROFILE")
|
||||
defer os.Unsetenv("GOSH_TEST_PROFILE")
|
||||
os.WriteFile(filepath.Join(home, ".gosh_profile"), []byte("export GOSH_TEST_PROFILE=yes\n"), 0644)
|
||||
// .goshrc is only read by interactive shells
|
||||
os.WriteFile(filepath.Join(home, ".goshrc"), []byte("exit 99\n"), 0644)
|
||||
|
||||
check := `sh -c 'test "$GOSH_TEST_PROFILE" = yes'`
|
||||
if got := run([]string{"-c", check}); got != 1 {
|
||||
t.Errorf("non-login shell: status %d, want 1 (profile must not be read)", got)
|
||||
}
|
||||
if got := run([]string{"-l", "--noprofile", "-c", check}); got != 1 {
|
||||
t.Errorf("--noprofile: status %d, want 1 (profile must not be read)", got)
|
||||
}
|
||||
if got := run([]string{"-l", "-c", check}); got != 0 {
|
||||
t.Errorf("login shell: status %d, want 0 (profile must be read)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShell_InteractiveReadsRC(t *testing.T) {
|
||||
bin := buildShell(t)
|
||||
home := t.TempDir()
|
||||
os.WriteFile(filepath.Join(home, ".goshrc"), []byte("export GOSH_TEST_RC=from-rc\n"), 0644)
|
||||
os.WriteFile(filepath.Join(home, ".gosh_profile"), []byte("export GOSH_TEST_PROFILE=from-profile\n"), 0644)
|
||||
|
||||
tests := []struct {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{[]string{"-i"}, "from-rc -\n"},
|
||||
{[]string{"-i", "--norc"}, "- -\n"},
|
||||
{[]string{"-i", "-l"}, "from-rc from-profile\n"},
|
||||
{[]string{"-i", "-l", "--norc", "--noprofile"}, "- -\n"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
cmd := exec.Command(bin, tc.args...)
|
||||
cmd.Env = append(os.Environ(), "HOME="+home)
|
||||
cmd.Stdin = strings.NewReader("echo ${GOSH_TEST_RC:--} ${GOSH_TEST_PROFILE:--}\nexit\n")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %v", tc.args, err)
|
||||
}
|
||||
if !strings.HasSuffix(string(out), tc.want) {
|
||||
t.Errorf("%v: output %q, want suffix %q", tc.args, out, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecInput_Source(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.Unsetenv("GOSH_TEST_SOURCE")
|
||||
defer os.Unsetenv("GOSH_TEST_SOURCE")
|
||||
|
||||
file := filepath.Join(dir, "vars")
|
||||
os.WriteFile(file, []byte("# comment\nexport GOSH_TEST_SOURCE=hello\nsh -c 'exit 4'\n"), 0644)
|
||||
|
||||
for _, cmd := range []string{"source", "."} {
|
||||
os.Unsetenv("GOSH_TEST_SOURCE")
|
||||
execInput(cmd + " " + file)
|
||||
if os.Getenv("GOSH_TEST_SOURCE") != "hello" {
|
||||
t.Errorf("%s: variable was not set in the current shell", cmd)
|
||||
}
|
||||
if lastStatus != 4 {
|
||||
t.Errorf("%s: $? = %d, want 4 (status of the last command)", cmd, lastStatus)
|
||||
}
|
||||
}
|
||||
|
||||
err := execInput("source " + filepath.Join(dir, "missing"))
|
||||
if err == nil || !strings.Contains(err.Error(), "source: "+dir+"/missing: no such file or directory") {
|
||||
t.Errorf("unexpected error for missing file: %v", err)
|
||||
}
|
||||
if lastStatus != 1 {
|
||||
t.Errorf("$? = %d for missing file, want 1", lastStatus)
|
||||
}
|
||||
|
||||
// A file that sources itself is stopped
|
||||
loop := filepath.Join(dir, "loop")
|
||||
os.WriteFile(loop, []byte("source "+loop+"\n"), 0644)
|
||||
if err := execInput("source " + loop); err == nil {
|
||||
t.Error("expected an error for a file that sources itself")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHomeDir_UsesHOME(t *testing.T) {
|
||||
t.Setenv("HOME", "/gosh/test/home")
|
||||
if got := getHomeDir(); got != "/gosh/test/home" {
|
||||
t.Errorf("getHomeDir() = %q, want $HOME", got)
|
||||
}
|
||||
if words, _ := splitWords("echo ~/x"); words[1] != "/gosh/test/home/x" {
|
||||
t.Errorf("~/x = %q, want it based on $HOME", words[1])
|
||||
}
|
||||
|
||||
// Without $HOME the user database is used
|
||||
t.Setenv("HOME", "")
|
||||
if got := getHomeDir(); got == "" {
|
||||
t.Error("getHomeDir() is empty without $HOME")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecInput_Background(t *testing.T) {
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
dir := t.TempDir()
|
||||
os.Chdir(dir)
|
||||
|
||||
// The shell does not wait for a background job, wait does
|
||||
start := time.Now()
|
||||
if err := execInput("sh -c 'sleep 0.3; echo done > out' &"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lastStatus != 0 {
|
||||
t.Errorf("$? = %d after starting a background job, want 0", lastStatus)
|
||||
}
|
||||
if time.Since(start) > 200*time.Millisecond {
|
||||
t.Error("the shell waited for the background job")
|
||||
}
|
||||
if _, err := os.Stat("out"); err == nil {
|
||||
t.Error("background job finished too early")
|
||||
}
|
||||
if err := execInput("wait"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := os.ReadFile("out"); string(got) != "done\n" {
|
||||
t.Errorf("out = %q after wait", got)
|
||||
}
|
||||
if len(jobTable) != 0 {
|
||||
t.Errorf("job table not empty after wait: %d jobs", len(jobTable))
|
||||
}
|
||||
|
||||
// wait %n returns the status of the job
|
||||
execInput("sh -c 'exit 3' &")
|
||||
execInput("wait %1")
|
||||
if lastStatus != 3 {
|
||||
t.Errorf("$? = %d after wait %%1, want 3", lastStatus)
|
||||
}
|
||||
|
||||
// Without job control, background jobs read from /dev/null
|
||||
execInput("cat > stdin.txt &")
|
||||
execInput("wait")
|
||||
if got, err := os.ReadFile("stdin.txt"); err != nil || len(got) != 0 {
|
||||
t.Errorf("background job read %q, %v from stdin", got, err)
|
||||
}
|
||||
|
||||
// jobs lists running jobs, also into a redirection
|
||||
execInput("sleep 0.3 &")
|
||||
execInput("jobs > jobs.txt")
|
||||
if got, _ := os.ReadFile("jobs.txt"); !strings.Contains(string(got), "Running sleep 0.3 &") {
|
||||
t.Errorf("jobs output = %q", got)
|
||||
}
|
||||
execInput("wait")
|
||||
|
||||
// Builtins that change the shell cannot run in the background
|
||||
if err := execInput("cd / &"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
execInput("wait %1")
|
||||
if wd, _ := os.Getwd(); wd != dir {
|
||||
t.Errorf("cd in the background changed the directory to %s", wd)
|
||||
}
|
||||
|
||||
// fg and bg need job control
|
||||
for _, cmd := range []string{"fg", "bg"} {
|
||||
if err := execInput(cmd); err == nil || err.Error() != cmd+": no job control" {
|
||||
t.Errorf("%s without job control: %v", cmd, err)
|
||||
}
|
||||
}
|
||||
if err := execInput("wait %9"); err == nil || err.Error() != "wait: %9: no such job" {
|
||||
t.Errorf("wait %%9: %v", err)
|
||||
}
|
||||
}
|
||||
+284
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,917 @@
|
||||
.\" Man page for gosh. View with: man ./packaging/man/gosh.1
|
||||
.\" @VERSION@ is replaced by the release version when building packages.
|
||||
.TH GOSH 1 "October 2026" "gosh @VERSION@" "User Commands"
|
||||
.SH NAME
|
||||
gosh \- a simple shell written in Go
|
||||
.SH SYNOPSIS
|
||||
.B gosh
|
||||
.RB [ \-l ]
|
||||
.RB [ \-i ]
|
||||
.RB [ \-\-norc ]
|
||||
.RB [ \-\-noprofile ]
|
||||
.RI [ script
|
||||
.RI [ args " ...]]"
|
||||
.br
|
||||
.B gosh
|
||||
.RB [ \-l ]
|
||||
.RB [ \-\-noprofile ]
|
||||
.B \-c
|
||||
.I command
|
||||
.RI [ name
|
||||
.RI [ args " ...]]"
|
||||
.br
|
||||
.B gosh
|
||||
.B \-version
|
||||
.SH DESCRIPTION
|
||||
.B gosh
|
||||
is a small command interpreter with a syntax close to
|
||||
.BR sh (1).
|
||||
It supports command history, tab completion, variables, positional
|
||||
parameters, command substitution, command lists, control structures,
|
||||
functions, pipes, redirections, wildcards, brace expansion, aliases and job
|
||||
control.
|
||||
.PP
|
||||
Without arguments and with a terminal on standard input,
|
||||
.B gosh
|
||||
starts an interactive shell. With a
|
||||
.I script
|
||||
argument it runs the commands of that file, with
|
||||
.B \-c
|
||||
the given command string, and otherwise it reads commands from standard
|
||||
input. The
|
||||
.I args
|
||||
after the script, or after the
|
||||
.I name
|
||||
of
|
||||
.BR \-c ,
|
||||
become the positional parameters
|
||||
.BR $1 ,
|
||||
.BR $2 ", ..."
|
||||
.PP
|
||||
.B gosh
|
||||
is a proof of concept and not meant for production use.
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
.B \-c \fIcommand\fP [\fIname\fP [\fIargs\fP ...]]
|
||||
Run
|
||||
.I command
|
||||
and exit.
|
||||
.I name
|
||||
becomes
|
||||
.BR $0 ,
|
||||
the
|
||||
.I args
|
||||
become
|
||||
.BR $1 ,
|
||||
.BR $2 ", ..."
|
||||
.TP
|
||||
.B \-i
|
||||
Force interactive mode, even if standard input is not a terminal.
|
||||
.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
|
||||
Act as a login shell and read the profile files (see
|
||||
.BR "STARTUP FILES" ).
|
||||
A shell whose
|
||||
.I argv[0]
|
||||
starts with
|
||||
.B \-
|
||||
(as started by
|
||||
.BR login (1),
|
||||
.BR sshd (8)
|
||||
or
|
||||
.BR "su \-" )
|
||||
is a login shell as well.
|
||||
.TP
|
||||
.B \-\-norc
|
||||
Do not read the goshrc files.
|
||||
.TP
|
||||
.B \-\-noprofile
|
||||
Do not read the profile files.
|
||||
.TP
|
||||
.BR \-version ", " \-\-version ", " \-V
|
||||
Print the version, branch and commit and exit.
|
||||
.TP
|
||||
.BR \-h ", " \-\-help
|
||||
Print a usage summary and exit.
|
||||
.TP
|
||||
.B \-
|
||||
Read commands from standard input.
|
||||
.SH NON-INTERACTIVE USE
|
||||
Scripts, command strings and commands read from a pipe are executed line
|
||||
by line. Empty lines and lines starting with
|
||||
.B #
|
||||
are skipped, so a script may start with
|
||||
.BR "#!/usr/bin/gosh" .
|
||||
A failing command does not abort the script; the exit status of
|
||||
.B gosh
|
||||
is the one of the last command.
|
||||
.PP
|
||||
Because
|
||||
.B gosh
|
||||
supports
|
||||
.BR \-c ,
|
||||
.BR ssh (1),
|
||||
.BR scp (1)
|
||||
and
|
||||
.BR rsync (1)
|
||||
work when it is used as login shell.
|
||||
.SH STARTUP FILES
|
||||
Startup files are executed in the running shell, so variables and
|
||||
aliases defined there stay in effect.
|
||||
.TP
|
||||
Login shells
|
||||
read
|
||||
.I /etc/gosh/profile
|
||||
and
|
||||
.IR ~/.gosh_profile .
|
||||
.TP
|
||||
Interactive shells
|
||||
read
|
||||
.I /etc/gosh/goshrc
|
||||
and
|
||||
.IR ~/.goshrc .
|
||||
.PP
|
||||
An interactive login shell reads the profile files first, then the goshrc
|
||||
files. Scripts and
|
||||
.B \-c
|
||||
read no startup files unless
|
||||
.B gosh
|
||||
is a login shell. Missing files are skipped; errors are reported with file
|
||||
name and line number and the remaining lines still run. Files of other
|
||||
shells such as
|
||||
.I /etc/profile
|
||||
or
|
||||
.I ~/.bashrc
|
||||
are not read.
|
||||
.SH SYNTAX
|
||||
.SS Quoting
|
||||
.TP
|
||||
.B \(aq...\(aq
|
||||
Everything is literal.
|
||||
.TP
|
||||
.B \(dq...\(dq
|
||||
Variables and command substitutions are expanded, the result stays one
|
||||
word.
|
||||
.TP
|
||||
.B \e
|
||||
Escapes the next character.
|
||||
.SS Variables
|
||||
.TP
|
||||
.I name\fB=\fPvalue
|
||||
Set a variable. All variables are exported to the environment of started
|
||||
programs. The value is not split into words and not expanded as a pattern;
|
||||
variables, command substitutions and
|
||||
.B ~
|
||||
(at the start, after
|
||||
.B =
|
||||
and after
|
||||
.BR : )
|
||||
are expanded.
|
||||
.TP
|
||||
.I name\fB=\fPvalue command
|
||||
Set the variable only for
|
||||
.IR command .
|
||||
.TP
|
||||
.BR $VAR ", " ${VAR}
|
||||
The value of
|
||||
.BR VAR .
|
||||
Using an unset variable is an error and aborts the command.
|
||||
.TP
|
||||
.B ${VAR:\-default}
|
||||
The value of
|
||||
.BR VAR ,
|
||||
or
|
||||
.I default
|
||||
if it is unset or empty. Use
|
||||
.B ${VAR:\-}
|
||||
for an empty default.
|
||||
.TP
|
||||
.B $?
|
||||
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
|
||||
.B $(\fIcommand\fP)
|
||||
is replaced by the output of
|
||||
.IR command ,
|
||||
which runs in a subshell. Trailing newlines are removed. Unquoted, the
|
||||
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.
|
||||
Backticks are not supported.
|
||||
.SS Tilde and brace expansion
|
||||
.B ~
|
||||
and
|
||||
.B ~/path
|
||||
at the start of an unquoted word are replaced by the home directory.
|
||||
.PP
|
||||
Brace expansion generates several words from one:
|
||||
.B a{b,c}d
|
||||
becomes
|
||||
.BR "abd acd" ,
|
||||
.B {1..5}
|
||||
becomes
|
||||
.BR "1 2 3 4 5" ,
|
||||
.B {01..10..4}
|
||||
becomes
|
||||
.B 01 05 09
|
||||
and
|
||||
.B {a..e}
|
||||
becomes
|
||||
.BR "a b c d e" .
|
||||
Brace expansion happens before all other expansions.
|
||||
.SS Wildcards
|
||||
.B *
|
||||
matches any string,
|
||||
.B ?
|
||||
any single character,
|
||||
.B [abc]
|
||||
and
|
||||
.B [a\-z]
|
||||
one of the given characters and
|
||||
.B [!abc]
|
||||
(or
|
||||
.BR [^abc] )
|
||||
any character except those. Matches are sorted. Hidden files are only
|
||||
matched if the pattern starts with a dot. A pattern without match is kept
|
||||
unchanged. Quoted wildcards and values of variables are never expanded.
|
||||
.SS Command lists
|
||||
.TP
|
||||
.IB a " ; " b
|
||||
Run
|
||||
.IR a ,
|
||||
then
|
||||
.IR b .
|
||||
.TP
|
||||
.IB a " && " b
|
||||
Run
|
||||
.I b
|
||||
only if
|
||||
.I a
|
||||
succeeded.
|
||||
.TP
|
||||
.IB a " || " b
|
||||
Run
|
||||
.I b
|
||||
only if
|
||||
.I a
|
||||
failed.
|
||||
.TP
|
||||
.IB command " &"
|
||||
Run
|
||||
.I command
|
||||
in the background.
|
||||
.SS Pipes and redirections
|
||||
.TP
|
||||
.IB a " | " b
|
||||
Connect standard output of
|
||||
.I a
|
||||
to standard input of
|
||||
.IR b .
|
||||
The exit status is the one of
|
||||
.IR b .
|
||||
.TP
|
||||
.BI < " file"
|
||||
Read standard input from
|
||||
.IR file .
|
||||
.TP
|
||||
.BR > " \fIfile\fP, " >> " \fIfile\fP"
|
||||
Write (or append) standard output to
|
||||
.IR file .
|
||||
.TP
|
||||
.BR 2> " \fIfile\fP, " 2>> " \fIfile\fP"
|
||||
Write (or append) standard error to
|
||||
.IR file .
|
||||
.TP
|
||||
.B 2>&1
|
||||
Redirect standard error to where standard output goes.
|
||||
.TP
|
||||
.B >&2
|
||||
Redirect standard output to where standard error goes.
|
||||
.TP
|
||||
.BR &> " \fIfile\fP, " &>> " \fIfile\fP"
|
||||
Write (or append) standard output and standard error to
|
||||
.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
|
||||
Redirections are evaluated from left to right. Only file descriptors 0, 1
|
||||
and 2 are supported.
|
||||
.SS Control structures
|
||||
.nf
|
||||
\fBif\fP \fIlist\fP; \fBthen\fP \fIlist\fP; [\fBelif\fP \fIlist\fP; \fBthen\fP \fIlist\fP;] [\fBelse\fP \fIlist\fP;] \fBfi\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
|
||||
\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
|
||||
.fi
|
||||
.PP
|
||||
Control structures may span several lines; an incomplete command shows
|
||||
the continuation prompt
|
||||
.B $PS2
|
||||
(default
|
||||
.BR "> " ).
|
||||
.B !
|
||||
negates the exit status of a command.
|
||||
.B for
|
||||
without
|
||||
.B in
|
||||
loops over the positional parameters.
|
||||
.PP
|
||||
.B case
|
||||
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
|
||||
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
|
||||
an alias as well.
|
||||
.B \e\fIname\fP
|
||||
or
|
||||
.B \(aq\fIname\fP\(aq
|
||||
skips the alias. Aliases are not used in scripts and with
|
||||
.BR \-c .
|
||||
.SH BUILTIN COMMANDS
|
||||
.TP
|
||||
.BR cd " [\fIdir\fP]"
|
||||
Change the directory; without argument to the home directory.
|
||||
.TP
|
||||
.B pwd
|
||||
Print the working directory.
|
||||
.TP
|
||||
.BR echo " [\fIargs\fP]"
|
||||
Print the arguments.
|
||||
.TP
|
||||
.BR exit " [\fIcode\fP], " quit " [\fIcode\fP]"
|
||||
Exit the shell with status
|
||||
.I code
|
||||
(default:
|
||||
.BR $? ).
|
||||
.TP
|
||||
.BI set " VAR" = value
|
||||
Set a variable.
|
||||
.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
|
||||
Export a variable.
|
||||
.TP
|
||||
.BR unset " [\fB\-v\fP] \fIVAR\fP ..., " "unset \-f" " \fIname\fP ..."
|
||||
Remove variables, or functions.
|
||||
.TP
|
||||
.BR source " \fIfile\fP [\fIargs\fP ...], " . " \fIfile\fP [\fIargs\fP ...]"
|
||||
Run the commands of
|
||||
.I file
|
||||
in the current shell, with
|
||||
.I args
|
||||
as positional parameters if given.
|
||||
.TP
|
||||
.B jobs
|
||||
List background and stopped jobs.
|
||||
.TP
|
||||
.BR fg " [\fB%\fP\fIn\fP]"
|
||||
Continue job
|
||||
.I n
|
||||
(default: the current job) in the foreground.
|
||||
.TP
|
||||
.BR bg " [\fB%\fP\fIn\fP]"
|
||||
Continue stopped job
|
||||
.I n
|
||||
in the background.
|
||||
.TP
|
||||
.BR wait " [\fB%\fP\fIn\fP | \fIpid\fP]"
|
||||
Wait for job
|
||||
.IR n ,
|
||||
the job with process
|
||||
.I pid
|
||||
(e.g.
|
||||
.BR $! ),
|
||||
or for all background jobs.
|
||||
.TP
|
||||
.BR alias " [\fIname\fP[\fB=\fP\fIvalue\fP] ...]"
|
||||
Define or show aliases.
|
||||
.TP
|
||||
.BR unalias " \fIname\fP, " "unalias \-a"
|
||||
Remove an alias, or all aliases.
|
||||
.TP
|
||||
.B :
|
||||
Do nothing, exit status 0.
|
||||
.TP
|
||||
.BR break " [\fIn\fP], " continue " [\fIn\fP]"
|
||||
Leave the innermost (or
|
||||
.IR n -th)
|
||||
loop, or continue with its next iteration.
|
||||
.TP
|
||||
.BR return " [\fIn\fP]"
|
||||
Leave a function or sourced file with status
|
||||
.I n
|
||||
(default:
|
||||
.BR $? ).
|
||||
.TP
|
||||
.BR local " \fIVAR\fP[\fB=\fP\fIvalue\fP] ..."
|
||||
In a function: the variable gets its previous value back when the function
|
||||
returns. Without a value it is unset.
|
||||
.TP
|
||||
.BR shift " [\fIn\fP]"
|
||||
Remove the first
|
||||
.I n
|
||||
(default 1) positional parameters.
|
||||
.TP
|
||||
.BR read " [\fB\-r\fP] [\fB\-p\fP \fIprompt\fP] [\fIVAR\fP ...]"
|
||||
Read a line from standard input and split it into fields at the
|
||||
characters of
|
||||
.B IFS
|
||||
(default blank, tab and newline). Each
|
||||
.I VAR
|
||||
gets one field, the last one the rest of the line; without
|
||||
.I VAR
|
||||
the whole line is stored in
|
||||
.BR REPLY .
|
||||
Without
|
||||
.BR \-r ,
|
||||
a backslash escapes the next character and continues the line at its end.
|
||||
.B \-p
|
||||
prints
|
||||
.I prompt
|
||||
if the input is a terminal. The exit status is 1 at the end of the input.
|
||||
.TP
|
||||
.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
|
||||
Show a summary of builtins and features.
|
||||
.SH JOB CONTROL
|
||||
In interactive shells, each command line runs as a job in its own process
|
||||
group.
|
||||
.B Ctrl+Z
|
||||
stops the foreground job,
|
||||
.B Ctrl+C
|
||||
interrupts it.
|
||||
.BR jobs ,
|
||||
.BR fg ,
|
||||
.B bg
|
||||
and
|
||||
.B wait
|
||||
manage the jobs;
|
||||
.B %n
|
||||
or
|
||||
.I n
|
||||
refers to job
|
||||
.IR n ,
|
||||
.B %\-
|
||||
to the previous job. Finished background jobs are reported before the
|
||||
next prompt. Exiting with stopped jobs warns once.
|
||||
.SH INTERACTIVE USE
|
||||
.TP
|
||||
.B Up/Down
|
||||
Browse the command history.
|
||||
.TP
|
||||
.B Tab
|
||||
Complete commands, aliases and file names.
|
||||
.TP
|
||||
.B Ctrl+C
|
||||
Cancel the current line or command.
|
||||
.TP
|
||||
.B Ctrl+D
|
||||
Exit the shell.
|
||||
.PP
|
||||
Lines starting with a space are not saved in the history.
|
||||
.PP
|
||||
If
|
||||
.B PS1
|
||||
is not set, the prompt shows
|
||||
.IR user @ host : dir
|
||||
followed by
|
||||
.BR $ .
|
||||
If the last command failed, its exit status is shown as
|
||||
.BI [ N ]
|
||||
before the
|
||||
.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 .
|
||||
.SS Custom prompts
|
||||
.B PS1
|
||||
and
|
||||
.B PS2
|
||||
are expanded before each prompt. They may contain the backslash escapes
|
||||
of
|
||||
.BR bash (1):
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
\fB\e\&u\fP user name
|
||||
\fB\e\&h\fP, \fB\e\&H\fP host name up to the first \(oq.\(cq, full host name
|
||||
\fB\e\&w\fP, \fB\e\&W\fP working directory (\fB~\fP for $HOME), its last component
|
||||
\fB\e\&$\fP \fB#\fP for root, otherwise \fB$\fP
|
||||
\fB\e\&t\fP, \fB\e\&T\fP time as HH:MM:SS (24 and 12 hours)
|
||||
\fB\e\&@\fP, \fB\e\&A\fP time as HH:MM AM/PM and HH:MM (24 hours)
|
||||
\fB\e\&d\fP date, e.g. Mon Oct 05
|
||||
\fB\e\&j\fP number of jobs
|
||||
\fB\e\&l\fP terminal name
|
||||
\fB\e\&s\fP, \fB\e\&v\fP, \fB\e\&V\fP shell name, version (major.minor), full version
|
||||
\fB\e\&n\fP, \fB\e\&r\fP newline, carriage return
|
||||
\fB\e\&a\fP, \fB\e\&e\fP bell, escape
|
||||
\fB\e\&\fP\fInnn\fP character with octal code \fInnn\fP
|
||||
\fB\e\e\fP backslash
|
||||
\fB\e[\fP, \fB\e]\fP ignored (needed by bash around escape sequences)
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
Unknown escapes are kept as they are. Then
|
||||
.BI $ VAR\fR,\fP
|
||||
.BR ${...} ,
|
||||
.B $(...)
|
||||
and
|
||||
.B $((...))
|
||||
are expanded;
|
||||
.B $?
|
||||
is the status of the last command. Control characters in the values of
|
||||
escapes and expansions are shown escaped, so colors must be written in
|
||||
the prompt itself, e.g.
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
PS1=\(aq\e[\ee[1;32m\e]\eu@\eh\e[\ee[0m\e]:\ew\e$ \(aq
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
If the prompt cannot be expanded (e.g. because of an unset variable), an
|
||||
error is printed and the default prompt is used. Escape sequences other
|
||||
than colors (e.g. the window title) are written before the prompt.
|
||||
.SH ENVIRONMENT
|
||||
.TP
|
||||
.B GOSH_HISTFILE
|
||||
Path of the history file. If set to an empty value, no history is saved.
|
||||
.TP
|
||||
.B XDG_STATE_HOME
|
||||
Base directory of the default history file
|
||||
.IR $XDG_STATE_HOME/gosh/history .
|
||||
.TP
|
||||
.B PS1
|
||||
Prompt of the interactive shell, see
|
||||
.BR "Custom prompts" .
|
||||
.TP
|
||||
.B PS2
|
||||
Prompt for continuation lines (default
|
||||
.BR "> " ).
|
||||
.TP
|
||||
.B GOSH_COLORS
|
||||
Colors of the default prompt (if
|
||||
.B PS1
|
||||
is not set) as colon-separated
|
||||
.IB key = code
|
||||
entries. Keys are
|
||||
.B user
|
||||
(default
|
||||
.BR 1;32 ),
|
||||
.B root
|
||||
.RB ( 1;31 ),
|
||||
.B path
|
||||
.RB ( 1;34 )
|
||||
and
|
||||
.B error
|
||||
.RB ( 1;31 );
|
||||
values are ANSI SGR codes, an empty value disables the color. Example:
|
||||
.B GOSH_COLORS=\(aquser=1;33:path=36\(aq
|
||||
.TP
|
||||
.B NO_COLOR
|
||||
If set, the prompt is not colored. Colors are also disabled with
|
||||
.B TERM=dumb
|
||||
or when the output is not a terminal.
|
||||
.TP
|
||||
.B HOME
|
||||
Used for
|
||||
.B ~
|
||||
and
|
||||
.B cd
|
||||
without argument.
|
||||
.TP
|
||||
.B PATH
|
||||
Directories searched for commands.
|
||||
.TP
|
||||
.B IFS
|
||||
Field separators for
|
||||
.BR read .
|
||||
.SH FILES
|
||||
.TP
|
||||
.I /etc/gosh/profile
|
||||
System-wide profile for login shells.
|
||||
.TP
|
||||
.I ~/.gosh_profile
|
||||
Personal profile for login shells.
|
||||
.TP
|
||||
.I /etc/gosh/goshrc
|
||||
System-wide startup file for interactive shells.
|
||||
.TP
|
||||
.I ~/.goshrc
|
||||
Personal startup file for interactive shells.
|
||||
.TP
|
||||
.I ~/.local/state/gosh/history
|
||||
Default history file (mode 0600).
|
||||
.SH EXIT STATUS
|
||||
The exit status of the last command, or the argument of
|
||||
.BR exit .
|
||||
Typical values:
|
||||
.TP
|
||||
.B 1
|
||||
General error.
|
||||
.TP
|
||||
.B 2
|
||||
Syntax error or invalid option.
|
||||
.TP
|
||||
.B 126
|
||||
Command not executable.
|
||||
.TP
|
||||
.B 127
|
||||
Command not found.
|
||||
.TP
|
||||
.BI 128+ n
|
||||
Command terminated by signal
|
||||
.I n
|
||||
(130 for Ctrl+C).
|
||||
.SH EXAMPLES
|
||||
.nf
|
||||
gosh \-c \(aqcd /srv && git log | head\(aq
|
||||
for f in *.log; do gzip "$f"; done
|
||||
cp config.yaml{,.bak}
|
||||
make > build.log 2>&1 || echo "failed: $?"
|
||||
LANG=C sort file
|
||||
alias ll=\(aqls \-l\(aq
|
||||
greet() { echo "Hello ${1:\-World}"; }
|
||||
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
|
||||
.fi
|
||||
.SH SEE ALSO
|
||||
.BR sh (1),
|
||||
.BR bash (1),
|
||||
.BR chsh (1),
|
||||
.BR shells (5)
|
||||
.SH AUTHOR
|
||||
Florian Walther <florian.walther@certainity.com>
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// runIn runs input in a temporary directory and returns the content of the
|
||||
// file "out" there.
|
||||
func runIn(t *testing.T, input string) string {
|
||||
t.Helper()
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
os.Chdir(t.TempDir())
|
||||
execInput(input)
|
||||
data, _ := os.ReadFile("out")
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestExec_If(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"if true; then echo yes >> out; fi", "yes\n"},
|
||||
{"if false; then echo yes >> out; fi", ""},
|
||||
{"if false; then echo a >> out; else echo b >> out; fi", "b\n"},
|
||||
{"if false; then echo a >> out; elif true; then echo b >> out; else echo c >> out; fi", "b\n"},
|
||||
{"if false; then echo a >> out; elif false; then echo b >> out; else echo c >> out; fi", "c\n"},
|
||||
{"if ! false; then echo negated >> out; fi", "negated\n"},
|
||||
{"if true && false; then echo a >> out; else echo b >> out; fi", "b\n"},
|
||||
{"if false || true; then echo a >> out; fi", "a\n"},
|
||||
{"if echo cond >> out; then echo body >> out; fi", "cond\nbody\n"},
|
||||
{"if true\nthen\n echo multi >> out\n echo line >> out\nfi", "multi\nline\n"},
|
||||
{"if true; then if false; then echo a >> out; else echo nested >> out; fi; fi", "nested\n"},
|
||||
{"if [ -d / ]; then echo dir >> out; fi", "dir\n"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := runIn(t, tc.input); got != tc.want {
|
||||
t.Errorf("%q: out = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExec_Loops(t *testing.T) {
|
||||
defer os.Unsetenv("GOSH_I")
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"for x in a b c; do echo $x >> out; done", "a\nb\nc\n"},
|
||||
{"for x in {1..3}; do echo $x >> out; done", "1\n2\n3\n"},
|
||||
{"for x in $(echo a b); do echo $x >> out; done", "a\nb\n"},
|
||||
{"for x in \"a b\" c; do echo $x >> out; done", "a b\nc\n"},
|
||||
{"touch f1 f2; for f in f*; do echo $f >> out; done", "f1\nf2\n"},
|
||||
{"for x in; do echo never >> out; done", ""},
|
||||
{"for x; do echo never >> out; done", ""},
|
||||
{"set GOSH_I=0; while [ $GOSH_I -lt 3 ]; do echo $GOSH_I >> out; set GOSH_I=$(expr $GOSH_I + 1); done", "0\n1\n2\n"},
|
||||
{"set GOSH_I=3; until [ $GOSH_I -eq 0 ]; do echo $GOSH_I >> out; set GOSH_I=$(expr $GOSH_I - 1); done", "3\n2\n1\n"},
|
||||
{"while false; do echo never >> out; done", ""},
|
||||
// The loop variable keeps the last value
|
||||
{"for GOSH_I in a b; do :; done; echo $GOSH_I > out", "b\n"},
|
||||
// break and continue
|
||||
{"for x in 1 2 3 4; do if [ $x = 3 ]; then break; fi; echo $x >> out; done", "1\n2\n"},
|
||||
{"for x in 1 2 3; do if [ $x = 2 ]; then continue; fi; echo $x >> out; done", "1\n3\n"},
|
||||
{"for a in 1 2; do for b in x y; do if [ $b = y ]; then continue 2; fi; echo $a$b >> out; done; done", "1x\n2x\n"},
|
||||
{"for a in 1 2; do for b in x y; do echo $a$b >> out; break 2; done; done", "1x\n"},
|
||||
{"for a in 1 2; do for b in x y; do echo $a$b >> out; break 5; done; done", "1x\n"},
|
||||
{"while true; do echo once >> out; break; done", "once\n"},
|
||||
{"for x in 1 2; do echo $x >> out; break; echo never >> out; done; echo after >> out", "1\nafter\n"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := runIn(t, tc.input); got != tc.want {
|
||||
t.Errorf("%q: out = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
if loopDepth != 0 {
|
||||
t.Errorf("loopDepth = %d after the loops", loopDepth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExec_Status(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
status int
|
||||
}{
|
||||
{"if false; then :; fi", 0},
|
||||
{"if true; then false; fi", 1},
|
||||
{"if false; then :; else sh -c 'exit 3'; fi", 3},
|
||||
{"for x in a; do false; done", 1},
|
||||
{"for x in a; do false; done; true", 0},
|
||||
{"for x in; do false; done", 0},
|
||||
{"while false; do :; done", 0},
|
||||
{"for x in a b; do sh -c 'exit 4'; break; done", 0},
|
||||
{"! true", 1},
|
||||
{"! false", 0},
|
||||
{"! gosh-no-such-command", 0},
|
||||
{":", 0},
|
||||
{"break", 1},
|
||||
{"continue", 1},
|
||||
{"for x in a; do break x; done", 1},
|
||||
{"if a; then", 2},
|
||||
{"fi", 2},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
runIn(t, tc.input)
|
||||
if lastStatus != tc.status {
|
||||
t.Errorf("%q: $? = %d, want %d", tc.input, lastStatus, tc.status)
|
||||
}
|
||||
}
|
||||
if err := execInput("break"); err == nil || err.Error() != "break: only meaningful in a loop" {
|
||||
t.Errorf("break outside a loop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunScript_MultiLine(t *testing.T) {
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
os.Chdir(t.TempDir())
|
||||
|
||||
script := `# a script with control structures
|
||||
for x in a b
|
||||
do
|
||||
if [ $x = a ]; then
|
||||
echo first >> out
|
||||
else
|
||||
echo second >> out
|
||||
fi
|
||||
done
|
||||
echo continued \
|
||||
line >> out
|
||||
`
|
||||
if status := runScript(strings.NewReader(script), ""); status != 0 {
|
||||
t.Errorf("status = %d", status)
|
||||
}
|
||||
if got, _ := os.ReadFile("out"); string(got) != "first\nsecond\ncontinued line\n" {
|
||||
t.Errorf("out = %q", got)
|
||||
}
|
||||
|
||||
// A script that ends inside a control structure is a syntax error
|
||||
if status := runScript(strings.NewReader("echo ok >> out2\nif true; then\n echo never >> out2\n"), ""); status != 2 {
|
||||
t.Errorf("status = %d for an unterminated if, want 2", status)
|
||||
}
|
||||
if got, _ := os.ReadFile("out2"); string(got) != "ok\n" {
|
||||
t.Errorf("out2 = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExec_AliasesInControlStructures(t *testing.T) {
|
||||
withAliases(t, map[string]string{"say": "echo said", "both": "echo one >> out; echo two"})
|
||||
defer func(saved bool) { interactiveShell = saved }(interactiveShell)
|
||||
interactiveShell = true
|
||||
|
||||
if got := runIn(t, "for x in a b; do say $x >> out; done"); got != "said a\nsaid b\n" {
|
||||
t.Errorf("alias in for: out = %q", got)
|
||||
}
|
||||
if got := runIn(t, "if true; then both >> out; fi"); got != "one\ntwo\n" {
|
||||
t.Errorf("alias with a list in if: out = %q", got)
|
||||
}
|
||||
if got := runIn(t, "if say x > out; then :; fi"); got != "said x\n" {
|
||||
t.Errorf("alias in condition: out = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package main
|
||||
|
||||
// Custom prompts with $PS1 and $PS2, using the backslash escapes of bash:
|
||||
//
|
||||
// \u user name \h host name up to the first '.' \H host name
|
||||
// \w working directory \W its last component (~ for home) \$ # for root, else $
|
||||
// \t time 24h HH:MM:SS \T 12h HH:MM:SS \@ 12h am/pm \A 24h HH:MM \d date
|
||||
// \j number of jobs \l terminal \s shell name \v \V version
|
||||
// \n newline \r carriage return \a bell \e escape \nnn octal \\ backslash
|
||||
// \[ ... \] begin/end of characters that take no space (escape sequences)
|
||||
//
|
||||
// After the escapes, $VAR, ${...}, $(...) and $((...)) are expanded (in the
|
||||
// same pass, so the values of escapes are not expanded again).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// promptInfo holds the values for the escapes of a prompt string.
|
||||
type promptInfo struct {
|
||||
user, host, wd, home string
|
||||
root bool
|
||||
jobs int
|
||||
tty string
|
||||
now time.Time
|
||||
}
|
||||
|
||||
// currentPromptInfo collects the promptInfo of the running shell.
|
||||
func currentPromptInfo() promptInfo {
|
||||
info := promptInfo{user: "?", host: "unknown", wd: "?", home: getHomeDir(),
|
||||
root: os.Geteuid() == 0, jobs: len(jobTable), now: time.Now()}
|
||||
if wd, err := os.Getwd(); err == nil {
|
||||
info.wd = wd
|
||||
}
|
||||
if usr, err := user.Current(); err == nil && usr.Username != "" {
|
||||
info.user = usr.Username
|
||||
}
|
||||
if host, err := os.Hostname(); err == nil {
|
||||
info.host = host
|
||||
}
|
||||
if tty, err := os.Readlink("/proc/self/fd/0"); err == nil && strings.HasPrefix(tty, "/dev/") {
|
||||
info.tty = filepath.Base(tty)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// expandPrompt expands the escapes and $ expressions of a PS1 or PS2 value.
|
||||
// The values of escapes like \w and of expansions are sanitized, so a
|
||||
// directory or git branch name cannot write escape sequences to the
|
||||
// terminal; colors have to be written as \e[...m in the prompt itself.
|
||||
func expandPrompt(ps string, info promptInfo) (string, error) {
|
||||
// $(...) in the prompt must not change $? or the status of the next
|
||||
// command line
|
||||
defer func(status int) { substStatus = status }(substStatus)
|
||||
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(ps); i++ {
|
||||
ch := ps[i]
|
||||
if ch == '$' {
|
||||
value, n, err := expandDollar(ps[i:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.WriteString(sanitize(value))
|
||||
i += n - 1
|
||||
continue
|
||||
}
|
||||
if ch != '\\' || i+1 == len(ps) {
|
||||
b.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
i++
|
||||
switch esc := ps[i]; esc {
|
||||
case 'u':
|
||||
b.WriteString(sanitize(info.user))
|
||||
case 'h':
|
||||
host, _, _ := strings.Cut(info.host, ".")
|
||||
b.WriteString(sanitize(host))
|
||||
case 'H':
|
||||
b.WriteString(sanitize(info.host))
|
||||
case 'w':
|
||||
b.WriteString(sanitize(tildePath(info.wd, info.home)))
|
||||
case 'W':
|
||||
b.WriteString(sanitize(baseDir(info.wd, info.home)))
|
||||
case '$':
|
||||
if info.root {
|
||||
b.WriteByte('#')
|
||||
} else {
|
||||
b.WriteByte('$')
|
||||
}
|
||||
case 't':
|
||||
b.WriteString(info.now.Format("15:04:05"))
|
||||
case 'T':
|
||||
b.WriteString(info.now.Format("03:04:05"))
|
||||
case '@':
|
||||
b.WriteString(info.now.Format("03:04 PM"))
|
||||
case 'A':
|
||||
b.WriteString(info.now.Format("15:04"))
|
||||
case 'd':
|
||||
b.WriteString(info.now.Format("Mon Jan 02"))
|
||||
case 'j':
|
||||
b.WriteString(strconv.Itoa(info.jobs))
|
||||
case 'l':
|
||||
b.WriteString(sanitize(info.tty))
|
||||
case 's':
|
||||
b.WriteString("gosh")
|
||||
case 'v':
|
||||
b.WriteString(shortVersion(version))
|
||||
case 'V':
|
||||
b.WriteString(sanitize(version))
|
||||
case 'n':
|
||||
b.WriteByte('\n')
|
||||
case 'r':
|
||||
b.WriteByte('\r')
|
||||
case 'a':
|
||||
b.WriteByte('\a')
|
||||
case 'e':
|
||||
b.WriteByte('\x1b')
|
||||
case '\\':
|
||||
b.WriteByte('\\')
|
||||
case '[', ']':
|
||||
// Markers for bash's readline. Our readline finds the width of
|
||||
// the prompt itself (see splitPrompt).
|
||||
default:
|
||||
if n := octalLen(ps[i:]); n > 0 {
|
||||
code, _ := strconv.ParseUint(ps[i:i+n], 8, 8)
|
||||
b.WriteByte(byte(code))
|
||||
i += n - 1
|
||||
} else {
|
||||
// Unknown escapes are kept, like in bash
|
||||
b.WriteByte('\\')
|
||||
b.WriteByte(esc)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// tildePath replaces the home directory at the start of path with ~.
|
||||
func tildePath(path, home string) string {
|
||||
if home != "" && home != "/" && (path == home || strings.HasPrefix(path, home+"/")) {
|
||||
return "~" + strings.TrimPrefix(path, home)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// baseDir returns the last component of path for \W: ~ for the home
|
||||
// directory, / for the root directory.
|
||||
func baseDir(path, home string) string {
|
||||
if home != "" && path == home {
|
||||
return "~"
|
||||
}
|
||||
if path == "/" {
|
||||
return path
|
||||
}
|
||||
return filepath.Base(path)
|
||||
}
|
||||
|
||||
// shortVersion returns major.minor of a version like v0.19.0 for \v.
|
||||
func shortVersion(v string) string {
|
||||
v = strings.TrimPrefix(v, "v")
|
||||
parts := strings.SplitN(v, ".", 3)
|
||||
if len(parts) >= 2 {
|
||||
return sanitize(parts[0] + "." + parts[1])
|
||||
}
|
||||
return sanitize(v)
|
||||
}
|
||||
|
||||
// octalLen returns the length of the octal number (up to 3 digits, at most
|
||||
// 0377) at the start of s.
|
||||
func octalLen(s string) int {
|
||||
n := 0
|
||||
for n < len(s) && n < 3 && s[n] >= '0' && s[n] <= '7' {
|
||||
n++
|
||||
}
|
||||
if n == 3 && s[0] > '3' {
|
||||
n = 2
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// splitPrompt splits an expanded prompt into what is written to the terminal
|
||||
// before readline starts and the prompt that readline shows. readline only
|
||||
// handles a single line and only knows the width of SGR sequences
|
||||
// (\e[...m), so all lines but the last and every other control sequence of
|
||||
// the last line (e.g. \e]0;title\a for the window title) go into prefix.
|
||||
func splitPrompt(prompt string) (prefix, line string) {
|
||||
var pre strings.Builder
|
||||
if i := strings.LastIndexByte(prompt, '\n'); i >= 0 {
|
||||
pre.WriteString(prompt[:i+1])
|
||||
prompt = prompt[i+1:]
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(prompt); {
|
||||
ch := prompt[i]
|
||||
switch {
|
||||
case ch == '\x1b':
|
||||
n := escapeLen(prompt[i:])
|
||||
if seq := prompt[i : i+n]; isSGRSequence(seq) {
|
||||
b.WriteString(seq)
|
||||
} else {
|
||||
pre.WriteString(seq)
|
||||
}
|
||||
i += n
|
||||
case ch < ' ' || ch == 0x7f:
|
||||
pre.WriteByte(ch)
|
||||
i++
|
||||
default:
|
||||
b.WriteByte(ch)
|
||||
i++
|
||||
}
|
||||
}
|
||||
return pre.String(), b.String()
|
||||
}
|
||||
|
||||
// escapeLen returns the length of the escape sequence at the start of s
|
||||
// (which starts with ESC): a CSI sequence ESC [ ... final byte, an OSC
|
||||
// sequence ESC ] ... BEL or ESC \, or ESC followed by one character.
|
||||
func escapeLen(s string) int {
|
||||
if len(s) < 2 {
|
||||
return len(s)
|
||||
}
|
||||
switch s[1] {
|
||||
case '[':
|
||||
for i := 2; i < len(s); i++ {
|
||||
if s[i] >= 0x40 && s[i] <= 0x7e {
|
||||
return i + 1
|
||||
}
|
||||
}
|
||||
return len(s)
|
||||
case ']', 'P', '_', '^':
|
||||
for i := 2; i < len(s); i++ {
|
||||
if s[i] == '\a' {
|
||||
return i + 1
|
||||
}
|
||||
if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '\\' {
|
||||
return i + 2
|
||||
}
|
||||
}
|
||||
return len(s)
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
// isSGRSequence reports whether seq is a complete ESC [ ... m sequence.
|
||||
func isSGRSequence(seq string) bool {
|
||||
return len(seq) >= 3 && strings.HasPrefix(seq, "\x1b[") && strings.HasSuffix(seq, "m") && isSGR(seq[2:len(seq)-1])
|
||||
}
|
||||
|
||||
// promptString returns the expanded value of the prompt variable name (PS1
|
||||
// or PS2), or ok == false if it is not set. An error in the prompt is reported and
|
||||
// the default prompt is used.
|
||||
func promptString(name string) (string, bool) {
|
||||
ps, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
prompt, err := expandPrompt(ps, currentPromptInfo())
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "gosh: %s: %s\n", name, sanitize(err.Error()))
|
||||
return "", false
|
||||
}
|
||||
return prompt, true
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// unsetPS1 removes $PS1 for the test, so that the default prompt is used.
|
||||
func unsetPS1(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("PS1", "")
|
||||
os.Unsetenv("PS1")
|
||||
}
|
||||
|
||||
var testInfo = promptInfo{
|
||||
user: "flow", host: "box.example.com", wd: "/home/flow/src/gosh", home: "/home/flow",
|
||||
jobs: 2, tty: "pts/3", now: time.Date(2026, 10, 5, 14, 7, 9, 0, time.UTC),
|
||||
}
|
||||
|
||||
func TestExpandPrompt_Escapes(t *testing.T) {
|
||||
t.Setenv("GOSH_PS_VAR", "val")
|
||||
rootInfo := testInfo
|
||||
rootInfo.root, rootInfo.wd = true, "/"
|
||||
homeInfo := testInfo
|
||||
homeInfo.wd = "/home/flow"
|
||||
tests := []struct {
|
||||
ps string
|
||||
info promptInfo
|
||||
want string
|
||||
}{
|
||||
{`\u@\h:\w\$ `, testInfo, "flow@box:~/src/gosh$ "},
|
||||
{`\u@\H \W \$ `, rootInfo, "flow@box.example.com / # "},
|
||||
{`\W`, homeInfo, "~"},
|
||||
{`\W`, testInfo, "gosh"},
|
||||
{`\t \T \@ \A \d`, testInfo, "14:07:09 02:07:09 02:07 PM 14:07 Mon Oct 05"},
|
||||
{`\j \l \s`, testInfo, "2 pts/3 gosh"},
|
||||
{`a\nb\\c`, testInfo, "a\nb\\c"},
|
||||
{`\[\e[1;32m\]x\[\033[0m\]`, testInfo, "\x1b[1;32mx\x1b[0m"},
|
||||
{`\101\400`, testInfo, "A 0"},
|
||||
{`\q\`, testInfo, `\q\`},
|
||||
{`$GOSH_PS_VAR ${GOSH_PS_VAR}-$((1+2)) $(echo sub) $?`, testInfo, "val val-3 sub 0"},
|
||||
{`'$GOSH_PS_VAR' "x"`, testInfo, `'val' "x"`},
|
||||
{`cost: 5$ \$`, testInfo, "cost: 5$ $"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got, err := expandPrompt(tt.ps, tt.info)
|
||||
if err != nil {
|
||||
t.Errorf("expandPrompt(%q): %v", tt.ps, err)
|
||||
} else if got != tt.want {
|
||||
t.Errorf("expandPrompt(%q) = %q, want %q", tt.ps, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandPrompt_Errors(t *testing.T) {
|
||||
os.Unsetenv("GOSH_PS_UNSET")
|
||||
if _, err := expandPrompt(`$GOSH_PS_UNSET`, testInfo); err == nil {
|
||||
t.Error("expected an error for an unset variable")
|
||||
}
|
||||
if got, err := expandPrompt(`${GOSH_PS_UNSET:-none}`, testInfo); err != nil || got != "none" {
|
||||
t.Errorf("got %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandPrompt_KeepsStatus(t *testing.T) {
|
||||
lastStatus, substStatus = 3, 0
|
||||
defer func() { lastStatus = 0 }()
|
||||
got, err := expandPrompt(`$(exit 7)$?`, testInfo)
|
||||
if err != nil || got != "3" {
|
||||
t.Errorf("got %q, %v", got, err)
|
||||
}
|
||||
if substStatus != 0 {
|
||||
t.Errorf("substStatus = %d, want 0", substStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandPrompt_Sanitizes(t *testing.T) {
|
||||
t.Setenv("GOSH_PS_EVIL", "a\x1b[2Jb")
|
||||
info := testInfo
|
||||
info.wd = "/tmp/evil\x1b]0;x\x07"
|
||||
got, err := expandPrompt(`\w $GOSH_PS_EVIL $(printf 'c\033[2Jd')`, info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.ContainsAny(got, "\x1b\x07") {
|
||||
t.Errorf("prompt contains control characters: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitPrompt(t *testing.T) {
|
||||
tests := []struct{ prompt, prefix, line string }{
|
||||
{"a $ ", "", "a $ "},
|
||||
{"top\n\x1b[1mbold\x1b[0m $ ", "top\n", "\x1b[1mbold\x1b[0m $ "},
|
||||
{"l1\nl2\n$ ", "l1\nl2\n", "$ "},
|
||||
{"\x1b]0;title\x07$ ", "\x1b]0;title\x07", "$ "},
|
||||
{"\x1b]0;t\x1b\\\x1b[2K$ \a", "\x1b]0;t\x1b\\\x1b[2K\a", "$ "},
|
||||
{"x\x1b", "\x1b", "x"},
|
||||
{"x\x1b[", "\x1b[", "x"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
prefix, line := splitPrompt(tt.prompt)
|
||||
if prefix != tt.prefix || line != tt.line {
|
||||
t.Errorf("splitPrompt(%q) = %q, %q, want %q, %q", tt.prompt, prefix, line, tt.prefix, tt.line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPrompt_PS1(t *testing.T) {
|
||||
t.Setenv("PS1", `[\s] \$ `)
|
||||
want := "[gosh] $ "
|
||||
if os.Geteuid() == 0 {
|
||||
want = "[gosh] # "
|
||||
}
|
||||
if got := getPrompt(); got != want {
|
||||
t.Errorf("getPrompt() = %q, want %q", got, want)
|
||||
}
|
||||
// An invalid PS1 falls back to the default prompt
|
||||
t.Setenv("PS1", `$GOSH_PS_UNSET`)
|
||||
os.Unsetenv("GOSH_PS_UNSET")
|
||||
if got := getPrompt(); !strings.Contains(got, "@") {
|
||||
t.Errorf("expected default prompt, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
// Command substitution: $(command) is replaced by the output of command,
|
||||
// which runs in a subshell (a new gosh process), so that cd, set etc. in it
|
||||
// do not change the shell. Functions also run in a subshell in pipelines and
|
||||
// in the background.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// subshellPath is the gosh binary used for command substitution; empty means
|
||||
// the running executable. Tests set it, since there the running executable is
|
||||
// the test binary.
|
||||
var subshellPath string
|
||||
|
||||
// substStatus is the exit status of the last command substitution of the
|
||||
// current command (a command that consists only of a substitution has this
|
||||
// status, like in sh).
|
||||
var substStatus int
|
||||
|
||||
// isSubstStart reports whether a command substitution $( starts at s[i].
|
||||
func isSubstStart(s string, i int) bool {
|
||||
return s[i] == '$' && i+1 < len(s) && s[i+1] == '('
|
||||
}
|
||||
|
||||
// substEnd returns the index of the ) that ends the command substitution
|
||||
// starting with $( at s[i], or -1 if it is not terminated. Quotes, escapes
|
||||
// and nested substitutions inside are taken into account.
|
||||
func substEnd(s string, i int) int {
|
||||
depth := 0
|
||||
var quote byte
|
||||
for j := i + 1; j < len(s); j++ {
|
||||
ch := s[j]
|
||||
switch {
|
||||
case quote == '\'':
|
||||
if ch == '\'' {
|
||||
quote = 0
|
||||
}
|
||||
case ch == '\\':
|
||||
j++
|
||||
case quote == '"':
|
||||
if ch == '"' {
|
||||
quote = 0
|
||||
} else if isSubstStart(s, j) {
|
||||
end := substEnd(s, j)
|
||||
if end < 0 {
|
||||
return -1
|
||||
}
|
||||
j = end
|
||||
}
|
||||
case ch == '"' || ch == '\'':
|
||||
quote = ch
|
||||
case ch == '(':
|
||||
depth++
|
||||
case ch == ')':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return j
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// subshellCommand returns a command that runs script in a new gosh process
|
||||
// with the same $0, the given positional parameters and the functions of the
|
||||
// shell. (Variables need not be passed: they are all in the environment.)
|
||||
func subshellCommand(script string, params []string) (*exec.Cmd, error) {
|
||||
path := subshellPath
|
||||
if path == "" {
|
||||
var err error
|
||||
if path, err = os.Executable(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
args := append(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
|
||||
cmd, err := subshellCommand(command, positional)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = stdio(0), &out, stdio(2)
|
||||
err = cmd.Run()
|
||||
substStatus = exitStatus(err)
|
||||
if substStatus == 128+int(syscall.SIGINT) {
|
||||
// Ctrl+C aborts the whole command, like in bash
|
||||
return "", statusError(substStatus)
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if err != nil && !errors.As(err, &exitErr) {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimRight(out.String(), "\n"), nil
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSubstEnd(t *testing.T) {
|
||||
tests := []struct {
|
||||
s string
|
||||
want int
|
||||
}{
|
||||
{"$(echo a)", 8},
|
||||
{"$(echo a) b", 8},
|
||||
{"$(echo $(date))x", 14},
|
||||
{`$(echo ")")`, 10},
|
||||
{`$(echo ')')`, 10},
|
||||
{`$(echo \))`, 9},
|
||||
{`$(echo "$(echo ")")")`, 20},
|
||||
{"$(echo (a))", 10},
|
||||
{"$(echo a", -1},
|
||||
{`$(echo ")`, -1},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := substEnd(tc.s, 0); got != tc.want {
|
||||
t.Errorf("substEnd(%q) = %d, want %d", tc.s, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandAliasesAndBraces_SkipSubstitution(t *testing.T) {
|
||||
withAliases(t, map[string]string{"ll": "ls -l"})
|
||||
if got := expandAliases("echo $(ll; ll) && ll"); got != "echo $(ll; ll) && ls -l" {
|
||||
t.Errorf("expandAliases = %q", got)
|
||||
}
|
||||
got, err := expandBraceWords("echo $(echo {a,b}) {c,d}")
|
||||
if err != nil || got != "echo $(echo {a,b}) c d" {
|
||||
t.Errorf("expandBraceWords = %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecInput_CommandSubstitution(t *testing.T) {
|
||||
origDir, _ := os.Getwd()
|
||||
defer os.Chdir(origDir)
|
||||
dir := t.TempDir()
|
||||
os.Chdir(dir)
|
||||
os.WriteFile("a.go", nil, 0644)
|
||||
os.WriteFile("b.go", nil, 0644)
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"echo $(echo hello) > out", "hello\n"},
|
||||
// Unquoted, the output is split into words
|
||||
{"printf '[%s]' $(printf 'a b\\nc\\n\\n') > out", "[a][b][c]"},
|
||||
{"printf '[%s]' x$(echo a b)y > out", "[xa][by]"},
|
||||
// Quoted, it is a single word, only trailing newlines are removed
|
||||
{`printf '[%s]' "$(printf 'a b\nc\n\n')" > out`, "[a b\nc]"},
|
||||
{`printf '[%s]' "$(true)" > out`, "[]"},
|
||||
{"printf '[%s]' $(true) x > out", "[x]"},
|
||||
// Nested, with quotes and operators inside
|
||||
{"echo $(echo $(echo nested)) > out", "nested\n"},
|
||||
{`echo "$(echo "a;b|c")" > out`, "a;b|c\n"},
|
||||
{"echo $(echo a | tr a-z A-Z) > out", "A\n"},
|
||||
// Wildcards in the output are not expanded, in the command they are
|
||||
{`echo $(echo "*") > out`, "*\n"},
|
||||
{"echo $(echo *.go) > out", "a.go b.go\n"},
|
||||
// Quoted or escaped, $( is literal
|
||||
{`echo '$(echo a)' \$(echo b) > out`, "$(echo a) $(echo b)\n"},
|
||||
// The subshell does not change the shell
|
||||
{"echo $(cd /; pwd) > out; pwd >> out", "/\n" + dir + "\n"},
|
||||
{"set GOSH_SUBST=$(echo value); echo $GOSH_SUBST > out", "value\n"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if err := execInput(tc.input); err != nil {
|
||||
t.Errorf("%q: %v", tc.input, err)
|
||||
}
|
||||
if got, _ := os.ReadFile("out"); string(got) != tc.want {
|
||||
t.Errorf("%q: out = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
os.Unsetenv("GOSH_SUBST")
|
||||
|
||||
// A command that consists only of a substitution has its status
|
||||
execInput("$(sh -c 'exit 3')")
|
||||
if lastStatus != 3 {
|
||||
t.Errorf("$? = %d after $(exit 3), want 3", lastStatus)
|
||||
}
|
||||
// Otherwise the status of the command counts
|
||||
execInput("echo $(false) > out")
|
||||
if lastStatus != 0 {
|
||||
t.Errorf("$? = %d after echo $(false), want 0", lastStatus)
|
||||
}
|
||||
|
||||
for input, wantErr := range map[string]string{
|
||||
"echo $(echo a": "syntax error: unexpected end of input",
|
||||
} {
|
||||
if err := execInput(input); err == nil || err.Error() != wantErr {
|
||||
t.Errorf("%q: error %v, want %q", input, err, wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user