Compare commits

..
8 Commits
Author SHA1 Message Date
Florian WaltherandClaude Opus 5.5 f79fb0b8d5 add control structures: if, for, while, until
- new parser (parser.go) for lists, pipelines, ! and if/elif/else/fi,
  for ... in ... do/done, while/until ... do/done; simple commands are
  kept as text and expanded when they run (in every iteration)
- break [n], continue [n] and the : builtin
- incomplete input (open control structure, trailing &&, |, quote or
  backslash) continues on the next line: prompt "> " in interactive
  shells, scripts and startup files read on
- Ctrl+C aborts the whole command line, including loops
- aliases are expanded at the start of every simple command, also inside
  control structures
- control structures in pipelines, with redirections or in the
  background are reported as not supported
- completion: commands after if, then, do etc.
- main_test.go reformatted with gofmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:05:28 +02:00
Florian WaltherandClaude Opus 5.5 e49fe6fe11 add command substitution $(...)
- $(command) is replaced by the output of command, which runs in a
  subshell (gosh -c), so cd/set in it do not change the shell
- trailing newlines are removed; unquoted the output is split into words
  at blanks and newlines, "$(...)" stays one word; wildcards in the
  output are not expanded
- nested substitutions, quotes and operators inside are handled; the
  list, alias and brace scanners skip $(...) as a whole
- a command that consists only of a substitution has its exit status,
  Ctrl+C in a substitution aborts the command
- $((...)) is reported as not supported

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:52:09 +02:00
Florian WaltherandClaude Opus 5.5 041406a340 show the exit status of a failed command in the prompt
- user@host:dir [N] $ after a command failed with exit status N,
  the plain prompt again after a successful command
- the status is colored bold red by default, configurable with the new
  GOSH_COLORS key error

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:46:11 +02:00
Florian WaltherandClaude Opus 5.5 b3311cbf94 add brace expansion
- a{b,c}d, nested braces, empty alternatives (file{,.bak})
- sequences {1..5}, {5..1}, {1..10..3}, zero-padded {01..10}, letters
  {a..e}
- happens first on the command text, the results go through ~,
  variable and wildcard expansion
- quoted/escaped braces, ${...} and braces without comma or valid
  sequence are kept; \, protects a comma
- a redirection target must expand to one word (ambiguous redirect),
  a word may expand to at most 100000 words
- completion escapes { in file names

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:42:25 +02:00
Florian WaltherandClaude Opus 5.5 da281ca4ab fix job control on FreeBSD and macOS: do not use WCONTINUED
Go's syscall.WaitStatus decodes the "continued" status wrongly on BSD:
on FreeBSD it is read as killed by SIGCONT, so after bg the job was
reported as "Exit 147" and removed from the job table while it was
still running (fg: "no such job"); on macOS it is read as stopped.

The shell marks jobs as running itself when it continues them, so wait4
is called without WCONTINUED and only exited/signaled statuses finish a
process.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:35:25 +02:00
Florian WaltherandClaude Opus 5.5 187de30351 add aliases
- alias name=value defines aliases, alias lists or shows them in a form
  that can be read back, unalias removes them (unalias -a: all)
- the first word of every command (at the start and after ;, &, &&, ||
  and |) is replaced, the value is expanded again except for aliases
  that are already being expanded (alias ls='ls --color' works)
- a value ending with a blank makes the next word a command name too
- quoted or escaped words (\ll, 'll') skip the alias
- aliases are only expanded in interactive shells and their startup
  files, like in bash
- tab completion includes aliases

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:27:55 +02:00
Florian WaltherandClaude Opus 5.5 dae4087a94 add job control
- in an interactive shell every pipeline runs in its own process group
  and gets the terminal while it runs in the foreground
- Ctrl+Z stops the foreground job, command & starts a job in the
  background; finished and stopped jobs are reported before the prompt
- new builtins jobs, fg, bg and wait (wait also works without job
  control, e.g. in scripts, where background jobs read from /dev/null)
- terminal modes are saved and restored when a job stops or continues
- exit and Ctrl+D warn once if there are stopped jobs
- processes are waited for with wait4 (WUNTRACED) instead of exec.Cmd.Wait
- pty based test for job control (Linux)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:22:21 +02:00
Florian WaltherandClaude Opus 5.5 4b44cc6e72 add startup files and the source builtin
- login shells (-l or argv[0] starting with -) read /etc/gosh/profile
  and ~/.gosh_profile, interactive shells /etc/gosh/goshrc and ~/.goshrc;
  --noprofile and --norc skip them
- source file / . file runs a file in the current shell, nesting is
  limited to stop files that source themselves
- errors in startup files and scripts are reported with file and line
- ~ and the startup files use $HOME (like sh), the user database is
  only the fallback

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:08:23 +02:00
14 changed files with 3808 additions and 262 deletions

No files matched your search

+256 -8
View File
@@ -12,6 +12,16 @@ Built-in commands:
quit [code] - Quit shell with status code (default: $?)
set VAR=val - Set environment variable
export VAR=val - Export environment variable
source file - 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] - Wait for job n or all background jobs
alias [name[=value] ...] - Define or show aliases (alias ll='ls -l')
unalias name - Remove an alias (unalias -a: all aliases)
: - Do nothing (exit status 0)
break [n] - Leave the innermost (or n-th) for/while/until loop
continue [n] - Continue with the next iteration of a loop
help - Show this help
Features:
@@ -23,29 +33,46 @@ 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
- 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
- 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
- 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
- Environment variable expansion ($VAR, ${VAR}, ${VAR:-default});
using an unset variable is an error, use ${VAR:-} for an empty default
- 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 ...,
! cmd negates the status; they can span several lines (prompt "> ")
- Pipes (a | b) and redirections: < file, > file, >> file, 2> file,
2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file
- Job control: command & runs in the background, Ctrl+Z stops the
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] [--norc] [--noprofile] [script]
gosh [-l] [--noprofile] -c command
gosh -version
```
@@ -56,12 +83,120 @@ gosh -version
| `gosh skript.gosh` | führt das Skript Zeile für Zeile aus |
| `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 |
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` und `until` wie `sh`, auf einer Zeile oder über mehrere Zeilen:
```bash
if [ -f ~/.goshrc ]; then
echo "rc vorhanden"
elif [ -d ~/.config ]; then
echo "nur .config"
else
echo "nichts"
fi
for f in *.log; do
gzip "$f"
done
for i in {1..5}; do echo "Durchlauf $i"; done
set n=3
while [ $n -gt 0 ]; do
echo $n
set n=$(expr $n - 1)
done
until ping -c1 -W1 server >/dev/null; do sleep 1; done
```
| Konstrukt | Bedeutung |
|---------------------------------------------|-------------------------------------------------------------------|
| `if befehl; then …; elif …; then …; else …; fi` | führt den Zweig aus, dessen Bedingung den Exit-Status 0 hat |
| `for x in wörter; do …; done` | setzt `x` nacheinander auf jedes Wort |
| `while befehl; do …; done` | wiederholt, solange `befehl` erfolgreich ist |
| `until befehl; do …; done` | wiederholt, bis `befehl` erfolgreich ist |
| `! befehl` | kehrt den Exit-Status um |
| `break [n]`, `continue [n]` | verlässt die (n-te) Schleife bzw. springt zum nächsten Durchlauf |
| `:` | tut nichts, Exit-Status 0 (z. B. `while :; do …; done`) |
- Bedingungen sind normale Befehle (auch Listen mit `&&`/`||`), meist `test` bzw. `[ … ]` (z. B. `[ -f datei ]`, `[ "$a" = b ]`, `[ $n -lt 3 ]`).
- Die Wörter nach `for … in` werden wie Argumente expandiert: `*.go`, `{1..10}`, `$VAR`, `$(befehl)`. Ohne `in` läuft die Schleife nicht (Positionsparameter gibt es nicht).
- Variablen, `$(...)` und Wildcards im Rumpf werden bei jedem Durchlauf neu ersetzt.
- Ist ein Befehl noch nicht vollständig (z. B. nach `if …; then` oder `for …` + Enter, nach `&&`, `|` oder einem `\` am Zeilenende), zeigt gosh den Fortsetzungs-Prompt `> `; Ctrl+C verwirft die angefangene Eingabe. Skripte und Startdateien lesen entsprechend weiter.
- Ctrl+C bricht die ganze Befehlszeile ab, also auch eine Schleife (`while true; do sleep 1; done`).
- Der Exit-Status ist der des zuletzt ausgeführten Befehls; ein `if` ohne passenden Zweig und eine Schleife ohne Durchlauf haben den Status 0.
- Nicht unterstützt: Kontrollstrukturen in Pipes (`for …; done | sort`), mit Umleitung (`done > datei`) oder im Hintergrund (`done &`), außerdem `case`, Funktionen, `read` und Zuweisungen der Form `x=1` – Variablen setzt man mit `set x=1`, Arithmetik geht z. B. mit `$(expr $n + 1)`.
### Befehlsersetzung
`$(befehl)` wird durch die Ausgabe von `befehl` ersetzt:
```
$ 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. 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` ``) und Arithmetik `$((1+2))` gibt es nicht.
### Befehlslisten und Exit-Status
Mehrere Befehle in einer Zeile werden wie in `sh` verknüpft:
@@ -86,6 +221,35 @@ 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.
### 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:
@@ -137,7 +301,87 @@ $ grep -c error < build.log
- 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.
### 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`); `kill %1` versteht gosh noch nicht.
### Prompt
Der 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
@@ -148,6 +392,7 @@ Der Prompt wird über die Umgebungsvariable `GOSH_COLORS` eingefärbt. Sie beste
| `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:
@@ -185,6 +430,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'
+201
View File
@@ -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
View File
@@ -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)
}
}
+279
View File
@@ -0,0 +1,279 @@
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
)
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
}
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)
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
}
// 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 := strings.IndexByte(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 := strings.IndexByte(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
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
+536
View File
@@ -0,0 +1,536 @@
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)
}
return j.procs[len(j.procs)-1].err
}
// 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, err := findJob("wait", args)
if 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
}
// 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")
}
releaseTerminal()
os.Exit(code)
return nil
}
+204
View File
@@ -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")
}
}
+442 -205
View File
@@ -74,12 +74,15 @@ func versionInfo() string {
// previousDir tracks the previous working directory for 'cd -' support
var previousDir string
// getHomeDir returns the user's home directory
// getHomeDir returns the user's home directory: $HOME like in sh, or the
// home directory from the user database if $HOME is not set
func getHomeDir() string {
if home := os.Getenv("HOME"); home != "" {
return home
}
usr, err := user.Current()
if err != nil {
// Fallback to HOME environment variable
return os.Getenv("HOME")
return ""
}
return usr.HomeDir
}
@@ -128,6 +131,8 @@ type token struct {
// - '...' is taken literally, nothing is expanded inside
// - "..." expands $VAR and ${VAR}; \ only escapes $ " \ and `
// - outside quotes \ escapes any character
// - $(command) is replaced by the output of command (see commandSubst);
// unquoted, the output is split into words at blanks and newlines
// - $VAR, ${VAR} and ${VAR:-default} are expanded outside single quotes;
// an unset variable is an error, so that e.g. "rm -rf $DIR/x" never runs
// with an empty $DIR
@@ -187,6 +192,32 @@ func tokenize(input string) ([]token, error) {
case ch == '\\':
startRaw(i)
escaped = true
case isSubstStart(input, i):
startRaw(i)
end := substEnd(input, i)
if end < 0 {
return nil, errors.New("missing ) in command substitution")
}
out, err := commandSubst(input[i+2 : end])
if err != nil {
return nil, err
}
if quote == '"' {
literal(out)
inWord = true
} else {
// Unquoted, the output is split into words at blanks and
// newlines (but, unlike in sh, wildcards in it are not
// expanded)
for k, field := range strings.Fields(out) {
if k > 0 {
endWord()
}
literal(field)
inWord = true
}
}
i = end
case ch == '$':
startRaw(i)
value, n, err := expandVar(input[i:])
@@ -609,7 +640,7 @@ func getPrompt() string {
c := parseColors(os.Getenv("GOSH_COLORS"))
colors = &c
}
return buildPrompt(username, hostname, wd, os.Geteuid() == 0, colors)
return buildPrompt(username, hostname, wd, os.Geteuid() == 0, lastStatus, colors)
}
// promptColors holds the SGR parameters (e.g. "1;32") for the parts of the
@@ -618,12 +649,14 @@ type promptColors struct {
user string // user@host
root string // user@host when running as root
path string // working directory
error string // exit status of the last command, if it failed
}
// defaultColors: bold green user, bold red root, bold blue directory
var defaultColors = promptColors{user: "1;32", root: "1;31", path: "1;34"}
// defaultColors: bold green user, bold red root, bold blue directory, bold
// red exit status
var defaultColors = promptColors{user: "1;32", root: "1;31", path: "1;34", error: "1;31"}
// parseColors reads a GOSH_COLORS value like "user=1;33:root=1;31:path=36"
// parseColors reads a GOSH_COLORS value like "user=1;33:path=36:error=31"
// and overrides the corresponding defaults. Unknown keys and values that are
// not plain SGR parameters (digits and ';') are ignored, so the variable
// cannot be used to write arbitrary escape sequences to the terminal.
@@ -641,6 +674,8 @@ func parseColors(spec string) promptColors {
colors.root = value
case "path":
colors.path = value
case "error":
colors.error = value
}
}
return colors
@@ -666,20 +701,28 @@ func colorize(text, sgr string) string {
return "\x1b[" + sgr + "m" + text + "\x1b[0m"
}
// buildPrompt formats the prompt as user@host:path $. The parts are sanitized
// buildPrompt formats the prompt as user@host:path $, or user@host:path [N] $
// if the last command failed with exit status N. The parts are sanitized
// before the color codes are added, so only our own escape sequences end up
// in the prompt. colors == nil disables colors.
func buildPrompt(username, hostname, path string, root bool, colors *promptColors) string {
func buildPrompt(username, hostname, path string, root bool, status int, colors *promptColors) string {
userHost := sanitize(username + "@" + hostname)
path = sanitize(path)
var failed string
if status != 0 {
failed = " [" + strconv.Itoa(status) + "]"
}
if colors == nil {
return userHost + ":" + path + " $ "
return userHost + ":" + path + failed + " $ "
}
userColor := colors.user
if root {
userColor = colors.root
}
return colorize(userHost, userColor) + ":" + colorize(path, colors.path) + " $ "
if failed != "" {
failed = " " + colorize(failed[1:], colors.error)
}
return colorize(userHost, userColor) + ":" + colorize(path, colors.path) + failed + " $ "
}
// useColor reports whether the prompt should be colored: stdout must be a
@@ -699,7 +742,7 @@ type shellCompleter struct {
func newShellCompleter() *shellCompleter {
return &shellCompleter{
commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "help"},
commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "source", "jobs", "fg", "bg", "wait", "alias", "unalias", "break", "continue", "help"},
}
}
@@ -713,6 +756,13 @@ func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) {
before := strings.TrimSpace(string(line[:start]))
isFirstWord := before == "" || strings.HasSuffix(before, "|") ||
strings.HasSuffix(before, ";") || strings.HasSuffix(before, "&&")
// ... and after if, then, do etc.
if fields := strings.Fields(before); len(fields) > 0 {
switch fields[len(fields)-1] {
case "if", "then", "elif", "else", "do", "while", "until", "!":
isFirstWord = true
}
}
// Plain "~" -> "~/"
if word == "~" {
@@ -723,7 +773,8 @@ func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) {
// Complete commands
var candidates [][]rune
seen := make(map[string]bool)
for _, cmd := range append(c.commands, pathExecutables(word)...) {
names := append(append(append([]string(nil), c.commands...), aliasNames()...), pathExecutables(word)...)
for _, cmd := range names {
if strings.HasPrefix(cmd, word) && !seen[cmd] {
seen[cmd] = true
candidates = append(candidates, []rune(escapeWord(cmd[len(word):])+" "))
@@ -811,7 +862,7 @@ func currentWord(s string) (word string, start int, quote byte) {
func escapeWord(s string) string {
var b strings.Builder
for _, r := range s {
if strings.ContainsRune(" \t\\'\";|&<>*?[$", r) {
if strings.ContainsRune(" \t\\'\";|&<>*?[${", r) {
b.WriteByte('\\')
}
b.WriteRune(r)
@@ -866,33 +917,48 @@ func completeFilePath(line, prefix string) []string {
return matches
}
const usage = `usage: gosh [-l] [-i] [script]
gosh [-l] -c command
const usage = `usage: gosh [-l] [-i] [--norc] [--noprofile] [script]
gosh [-l] [--noprofile] -c command
gosh -version`
// sysConfDir holds the system-wide startup files.
var sysConfDir = "/etc/gosh"
func main() {
os.Exit(run(os.Args[1:]))
args := os.Args[1:]
// login(1), sshd and "su -" start a login shell with a leading - in
// argv[0], e.g. "-gosh"
if strings.HasPrefix(os.Args[0], "-") {
args = append([]string{"-l"}, args...)
}
os.Exit(run(args))
}
// run parses the command line and runs gosh in the matching mode. It returns
// the exit status of the shell.
func run(args []string) int {
// -l/--login is accepted for compatibility (e.g. "su -l", login(1)), but
// gosh does not read a startup file yet. -i forces interactive mode even
// if stdin is not a terminal.
forceInteractive := false
var login, forceInteractive, noRC, noProfile bool
flags:
for len(args) > 0 {
switch args[0] {
case "-l", "--login":
login = true
case "-i":
// Interactive mode even if stdin is not a terminal
forceInteractive = true
case "--norc":
noRC = true
case "--noprofile":
noProfile = true
default:
break flags
}
args = args[1:]
}
// Determine what to run, before the startup files are read
var script func() int
interactiveMode := false
if len(args) > 0 {
switch arg := args[0]; {
case arg == "-version" || arg == "--version" || arg == "-V":
@@ -909,13 +975,13 @@ flags:
fmt.Fprintln(os.Stderr, usage)
return 2
}
return runScript(strings.NewReader(args[1]))
script = func() int { return runScript(strings.NewReader(args[1]), "") }
case strings.HasPrefix(arg, "-") && arg != "-":
fmt.Fprintf(os.Stderr, "gosh: %s: invalid option\n", arg)
fmt.Fprintln(os.Stderr, usage)
return 2
case arg == "-":
return runScript(os.Stdin)
script = func() int { return runScript(os.Stdin, "") }
default:
f, err := os.Open(arg)
if err != nil {
@@ -923,37 +989,144 @@ flags:
return 127
}
defer f.Close()
return runScript(bufio.NewReader(f))
script = func() int { return runScript(bufio.NewReader(f), arg) }
}
} else if forceInteractive || readline.IsTerminal(int(os.Stdin.Fd())) {
interactiveMode = true
} else {
// Without a terminal (e.g. "echo ls | gosh") read the commands from
// stdin
script = func() int { return runScript(os.Stdin, "") }
}
// Without a terminal (e.g. "echo ls | gosh") read the commands from stdin.
if !forceInteractive && !readline.IsTerminal(int(os.Stdin.Fd())) {
return runScript(os.Stdin)
// Aliases are expanded in interactive shells, also in their startup files
interactiveShell = interactiveMode
for _, file := range startupFiles(login && !noProfile, interactiveMode && !noRC) {
sourceStartupFile(file)
}
if !interactiveMode {
return script()
}
interactive()
return 0
}
// runScript executes the commands from r line by line. Like sh, it does not
// stop at a failing command and returns the exit status of the last one.
// Empty lines and lines starting with # (e.g. a #!/usr/bin/gosh line) are
// skipped.
func runScript(r io.Reader) int {
status := 0
for {
line, err := readLine(r)
if trimmed := strings.TrimSpace(line); trimmed != "" && !strings.HasPrefix(trimmed, "#") {
printError(execInput(line))
status = lastStatus
// startupFiles returns the startup files to read, in order: for login
// shells /etc/gosh/profile and ~/.gosh_profile, for interactive shells
// /etc/gosh/goshrc and ~/.goshrc. Non-interactive shells (gosh -c, scripts)
// read no files unless they are login shells.
func startupFiles(login, interactive bool) []string {
home := getHomeDir()
var files []string
if login {
files = append(files, filepath.Join(sysConfDir, "profile"))
if home != "" {
files = append(files, filepath.Join(home, ".gosh_profile"))
}
}
if interactive {
files = append(files, filepath.Join(sysConfDir, "goshrc"))
if home != "" {
files = append(files, filepath.Join(home, ".goshrc"))
}
}
return files
}
// sourceStartupFile runs a startup file in the current shell. A missing file
// is skipped silently, other errors are reported.
func sourceStartupFile(path string) {
f, err := os.Open(path)
if errors.Is(err, fs.ErrNotExist) {
return
}
if err != nil {
if err != io.EOF {
fmt.Fprintln(os.Stderr, "gosh:", err)
return
}
defer f.Close()
runScript(bufio.NewReader(f), path)
}
// sourceDepth counts nested source commands, to stop a file that sources
// itself.
var sourceDepth int
// sourceFile runs the commands of a file in the current shell (the source and
// . builtins) and returns an error carrying the status of the last command.
func sourceFile(cmd, path string) error {
if sourceDepth >= 100 {
return fmt.Errorf("%s: %s: maximum nesting depth exceeded", cmd, sanitize(path))
}
f, err := os.Open(path)
if err != nil {
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
err = pathErr.Err
}
return fmt.Errorf("%s: %s: %v", cmd, sanitize(path), err)
}
defer f.Close()
sourceDepth++
defer func() { sourceDepth-- }()
if status := runScript(bufio.NewReader(f), path); status != 0 {
return statusError(status)
}
return nil
}
// runScript executes the commands from r. Each command is executed as soon
// as it is complete (a control structure can span several lines). Like sh,
// it does not stop at a failing command and returns the exit status of the
// last one. If name is set, errors are reported with file name and line.
func runScript(r io.Reader, name string) int {
var (
buf strings.Builder
startLine int
)
report := func(err error, line int) {
if name != "" && err != nil {
err = fmt.Errorf("%s:%d: %w", sanitize(name), line, err)
}
printError(err)
}
for lineNo := 1; ; lineNo++ {
line, readErr := readLine(r)
if readErr == nil || line != "" {
if buf.Len() == 0 {
if strings.TrimSpace(line) == "" {
goto next
}
startLine = lineNo
}
buf.WriteString(line)
buf.WriteByte('\n')
tree, err := parse(buf.String())
if errors.Is(err, errIncomplete) {
goto next // read the rest of the command
}
buf.Reset()
if err != nil {
lastStatus = exitStatus(err)
report(err, startLine)
} else {
report(tree.exec(), startLine)
}
}
next:
if readErr != nil {
if readErr != io.EOF {
fmt.Fprintln(os.Stderr, "gosh:", readErr)
return 1
}
return status
if buf.Len() > 0 {
lastStatus = 2
report(errors.New("syntax error: unexpected end of file"), startLine)
}
return lastStatus
}
}
}
@@ -991,10 +1164,16 @@ func exitStatus(err error) int {
if errors.As(err, &statusErr) {
return int(statusErr)
}
var synErr syntaxError
if errors.As(err, &synErr) {
var (
synErr syntaxError
lc loopControl
)
if errors.As(err, &synErr) || errors.Is(err, errIncomplete) {
return 2
}
if errors.As(err, &lc) {
return 0
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
@@ -1014,6 +1193,10 @@ func exitStatus(err error) int {
// interactive runs the interactive shell with line editing, history and
// completion until the user exits.
func interactive() {
interactiveShell = true
initJobControl()
defer releaseTerminal()
// Ctrl+C sends SIGINT to the whole foreground process group. Catch it so
// that it only stops the running command and not the shell. (signal.Ignore
// would be inherited by the child processes.)
@@ -1042,20 +1225,40 @@ func interactive() {
}
defer l.Close()
// Main loop
// Main loop. A command that is not complete yet (e.g. if without fi)
// is continued on the next line with the prompt "> ".
var buf strings.Builder
for {
if buf.Len() == 0 {
// Report finished and stopped background jobs
reapJobs(true)
// Update prompt dynamically (in case directory changed)
l.SetPrompt(getPrompt())
} else {
l.SetPrompt("> ")
}
line, err := l.Readline()
if err != nil {
// Ctrl+C discards the current line
// Ctrl+C discards the current line (and an incomplete command)
if err == readline.ErrInterrupt {
buf.Reset()
continue
}
// Handle EOF (Ctrl+D)
if err == io.EOF {
fmt.Println()
if buf.Len() > 0 {
buf.Reset()
lastStatus = 2
fmt.Fprintln(os.Stderr, "gosh: syntax error: unexpected end of file")
continue
}
if hasStoppedJobs() && !exitWarned {
exitWarned = true
fmt.Fprintln(os.Stderr, "gosh: there are stopped jobs")
continue
}
break
}
fmt.Fprintln(os.Stderr, "Error reading input:", err)
@@ -1063,7 +1266,7 @@ func interactive() {
}
// Skip empty input
if strings.TrimSpace(line) == "" {
if buf.Len() == 0 && strings.TrimSpace(line) == "" {
continue
}
@@ -1073,8 +1276,19 @@ func interactive() {
l.SaveHistory(line)
}
// Execute the input
printError(execInput(line))
buf.WriteString(line)
buf.WriteByte('\n')
tree, err := parse(buf.String())
if errors.Is(err, errIncomplete) {
continue
}
buf.Reset()
if err != nil {
lastStatus = exitStatus(err)
printError(err)
continue
}
printError(tree.exec())
}
}
@@ -1106,133 +1320,44 @@ func printError(err error) {
var (
exitErr *exec.ExitError
statusErr statusError
lc loopControl
)
if err == nil || errors.As(err, &exitErr) || errors.As(err, &statusErr) {
if err == nil || errors.As(err, &exitErr) || errors.As(err, &statusErr) || errors.As(err, &lc) {
return
}
fmt.Fprintln(os.Stderr, "gosh:", err)
}
// listItem is a command of a command list together with the operator that
// connects it to the previous command ("" for the first one).
type listItem struct {
op string // "", ";", "&&" or "||"
command string
}
// splitList splits input at unquoted ;, && and ||. Quotes, escapes, ${...}
// and comments are handled like in splitWords, but nothing is expanded yet:
// that happens per command when it runs, so "cd /tmp && echo $PWD" works.
func splitList(input string) ([]listItem, error) {
var (
items []listItem
op string
start int
quote byte
escaped bool
wordStart = true // the next character starts a word
)
end := len(input)
add := func(i int, next string) error {
command := input[start:i]
if strings.TrimSpace(command) == "" {
// "a;" is fine, but an operator needs a command on both sides
if next != "" || op == "&&" || op == "||" {
if next == "" {
next = op
}
return syntaxError(next)
}
return nil
}
items = append(items, listItem{op: op, command: command})
return nil
}
scan:
for i := 0; i < len(input); i++ {
ch := input[i]
isWordStart := wordStart
wordStart = false
switch {
case escaped:
escaped = false
case quote == '\'':
if ch == '\'' {
quote = 0
}
case ch == '\\':
escaped = true
case ch == '$' && i+1 < len(input) && input[i+1] == '{':
// Skip ${...} as a whole, like expandVar does
if j := strings.IndexByte(input[i:], '}'); j >= 0 {
i += j
}
case quote == '"':
if ch == '"' {
quote = 0
}
case ch == '"' || ch == '\'':
quote = ch
case ch == ' ' || ch == '\t' || ch == '\n':
wordStart = true
case ch == '#' && isWordStart:
end = i
break scan
case ch == ';',
ch == '&' && i+1 < len(input) && input[i+1] == '&',
ch == '|' && i+1 < len(input) && input[i+1] == '|':
next := input[i : i+1]
if ch != ';' {
next = input[i : i+2]
}
if err := add(i, next); err != nil {
return nil, err
}
i += len(next) - 1
op = next
start = i + 1
wordStart = true
}
}
// An unterminated quote or a trailing \ is reported by splitWords
if err := add(end, ""); err != nil {
return nil, err
}
return items, nil
}
// execInput executes a line of input: a list of commands separated by ;, &&
// and ||. It returns the error of the last command that was run; errors of
// earlier commands are printed. $? is updated after each command.
// execInput parses and executes input: a command line or a whole script.
// It returns the error of the last command that was run; errors of earlier
// commands are printed. $? is updated after each command.
func execInput(input string) error {
items, err := splitList(input)
tree, err := parse(input)
if err != nil {
lastStatus = exitStatus(err)
return err
}
return tree.exec()
}
var lastErr error
for _, item := range items {
if (item.op == "&&" && lastStatus != 0) || (item.op == "||" && lastStatus == 0) {
continue
}
printError(lastErr)
lastErr = execCommand(item.command)
lastStatus = exitStatus(lastErr)
}
return lastErr
// execCommand executes a pipeline of simple commands with redirections, in
// the background if background is set.
func execCommand(input string, background bool) error {
// Brace expansion comes first, so that its results are expanded further
substStatus = 0
input, err := expandBraceWords(input)
if err != nil {
return err
}
// execCommand executes a pipeline of simple commands with redirections.
func execCommand(input string) error {
// Split the input into words and operators, expanding variables and ~ on
// the way.
tokens, err := tokenize(input)
var synErr syntaxError
if errors.As(err, &synErr) {
var (
synErr syntaxError
statusErr statusError
)
if errors.As(err, &synErr) || errors.As(err, &statusErr) {
return err
}
if err != nil {
@@ -1243,9 +1368,17 @@ func execCommand(input string) error {
return err
}
if len(stages) == 0 {
// A command that consists only of $(...) has its status
if substStatus != 0 {
return statusError(substStatus)
}
return nil
}
return runPipeline(stages)
// "exit" with stopped jobs only warns, unless it is repeated right away
if name := stages[0].args; len(name) == 0 || (name[0] != "exit" && name[0] != "quit") {
exitWarned = false
}
return runPipeline(stages, strings.TrimSpace(input), background)
}
// redirect is a redirection of a simple command, e.g. 2>>file or 2>&1.
@@ -1308,49 +1441,71 @@ func parsePipeline(tokens []token) ([]stage, error) {
return append(stages, cur), nil
}
// runPipeline runs the stages of a pipeline concurrently, connecting the
// stdout of each stage to the stdin of the next one. It returns the error of
// the last stage (so $? is its exit status, like in sh); errors of the other
// stages are printed.
func runPipeline(stages []stage) error {
waits := make([]func() error, len(stages))
// runPipeline runs the stages of a pipeline concurrently as a job,
// connecting the stdout of each stage to the stdin of the next one. In the
// foreground it waits for the job and returns the error of the last stage (so
// $? is its exit status, like in sh); errors of the other stages are printed.
// In the background it adds the job to the job table and returns nil.
func runPipeline(stages []stage, text string, background bool) error {
j := &job{text: text}
if jobControl && !background {
shellTTY, _ = readline.GetState(ttyFd)
}
stdin := os.Stdin
if background && !jobControl {
// Without job control background jobs must not read from the
// terminal, like in sh
if devNull, err := os.Open(os.DevNull); err == nil {
stdin = devNull
}
}
var startErr error
for i, st := range stages {
stdout := os.Stdout
var next *os.File
if i < len(stages)-1 {
r, w, err := os.Pipe()
if err != nil {
// Let the stages that already run finish
if stdin != os.Stdin {
stdin.Close()
}
for _, wait := range waits[:i] {
printError(wait())
}
return err
startErr = err
break
}
stdout, next = w, r
}
waits[i] = startStage(st, stdin, stdout, len(stages) > 1)
j.procs = append(j.procs, startStage(st, stdin, stdout, j, len(stages) > 1, background))
stdin = next
}
var err error
for i, wait := range waits {
err = wait()
if i < len(waits)-1 {
printError(err)
if startErr != nil {
// Let the stages that already run finish
if len(j.procs) > 0 {
waitForeground(j)
}
}
return err
return startErr
}
// startStage starts a stage of a pipeline with the given stdin and stdout and
// returns a function that waits for it to finish. Pipe ends passed in are
// closed by startStage once the stage no longer needs them, so that the
// neighbors see EOF or get SIGPIPE.
func startStage(st stage, stdin, stdout *os.File, inPipeline bool) func() error {
if background {
addJob(j)
if interactiveShell {
pid := 0
for _, p := range j.procs {
if p.cmd != nil {
pid = p.cmd.Process.Pid
}
}
fmt.Fprintf(os.Stderr, "[%d] %d\n", j.id, pid)
}
return nil
}
return waitForeground(j)
}
// startStage starts a stage of the pipeline of job j with the given stdin and
// stdout. Pipe ends passed in are closed by startStage once the stage no
// longer needs them, so that the neighbors see EOF or get SIGPIPE.
func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background bool) *proc {
var owned []*os.File
if stdin != os.Stdin {
owned = append(owned, stdin)
@@ -1363,9 +1518,6 @@ func startStage(st stage, stdin, stdout *os.File, inPipeline bool) func() error
f.Close()
}
}
result := func(err error) func() error {
return func() error { return err }
}
// Connect the command to the terminal unless redirected. Without Stdin
// the command would read from /dev/null, so interactive programs (su,
@@ -1377,13 +1529,17 @@ func startStage(st stage, stdin, stdout *os.File, inPipeline bool) func() error
if err != nil || len(st.args) == 0 {
// A command without arguments only creates or truncates the files
closeOwned()
return result(err)
return finishedProc(err)
}
if builtin, ok := builtins[st.args[0]]; ok {
if inPipeline && builtin.changesState {
switch {
case inPipeline && builtin.changesState:
closeOwned()
return result(fmt.Errorf("%s: cannot be used in a pipeline", st.args[0]))
return finishedProc(fmt.Errorf("%s: cannot be used in a pipeline", st.args[0]))
case background && builtin.changesState:
closeOwned()
return finishedProc(fmt.Errorf("%s: cannot be run in the background", st.args[0]))
}
run := func() error {
defer closeOwned()
@@ -1395,16 +1551,29 @@ func startStage(st stage, stdin, stdout *os.File, inPipeline bool) func() error
}
return err
}
if !inPipeline {
return result(run())
if !inPipeline && !background {
return finishedProc(run())
}
done := make(chan error, 1)
go func() { done <- run() }()
return func() error { return <-done }
p := &proc{done: make(chan error, 1)}
go func() { p.done <- run() }()
return p
}
cmd := exec.Command(st.args[0], st.args[1:]...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = fds[0], fds[1], fds[2]
if jobControl {
// All processes of the job share a process group: the one of the
// first process. In the foreground, the first process makes its
// group the foreground group of the terminal before it starts the
// program, so that it can use the terminal right away.
attr := &syscall.SysProcAttr{Setpgid: true, Pgid: j.pgid}
if j.pgid == 0 && !background {
if ctty := ttyIndex(fds); ctty >= 0 {
attr.Foreground, attr.Ctty = true, ctty
}
}
cmd.SysProcAttr = attr
}
err = cmd.Start()
// The child has its own copies of the files now
closeOwned()
@@ -1412,9 +1581,16 @@ func startStage(st stage, stdin, stdout *os.File, inPipeline bool) func() error
err = fmt.Errorf("%s: %w", sanitize(st.args[0]), errCommandNotFound)
}
if err != nil {
return result(err)
return finishedProc(err)
}
return cmd.Wait
if jobControl && j.pgid == 0 {
j.pgid = cmd.Process.Pid
if !background {
// In case the process could not do it (no terminal in fds)
setForeground(j.pgid)
}
}
return &proc{cmd: cmd}
}
// applyRedirects applies the redirections in order to fds (stdin, stdout,
@@ -1476,6 +1652,17 @@ var builtins = map[string]struct{ changesState bool }{
"set": {changesState: true},
"export": {changesState: true},
"help": {},
"source": {changesState: true},
".": {changesState: true},
"jobs": {},
"fg": {changesState: true},
"bg": {changesState: true},
"wait": {changesState: true},
"alias": {},
"unalias": {changesState: true},
":": {},
"break": {changesState: true},
"continue": {changesState: true},
}
// runBuiltin executes a built-in command, writing its output to stdout.
@@ -1520,17 +1707,17 @@ func runBuiltin(args []string, stdout io.Writer) error {
if len(args) > 1 {
var exitCode int
fmt.Sscanf(args[1], "%d", &exitCode)
os.Exit(exitCode)
return exitShell(exitCode)
}
os.Exit(lastStatus)
return exitShell(lastStatus)
case "quit":
// quit with optional status code
if len(args) > 1 {
var exitCode int
fmt.Sscanf(args[1], "%d", &exitCode)
os.Exit(exitCode)
return exitShell(exitCode)
}
os.Exit(lastStatus)
return exitShell(lastStatus)
case "set":
// set VAR=value - set environment variable
if len(args) < 2 {
@@ -1555,6 +1742,29 @@ func runBuiltin(args []string, stdout io.Writer) error {
}
os.Setenv(parts[0], parts[1])
return nil
case ":":
// Does nothing, successfully (e.g. while :; do ...)
return nil
case "break", "continue":
return builtinLoopControl(args)
case "alias":
return builtinAlias(args, stdout)
case "unalias":
return builtinUnalias(args)
case "jobs":
return builtinJobs(stdout)
case "fg":
return builtinFg(args, stdout)
case "bg":
return builtinBg(args, stdout)
case "wait":
return builtinWait(args)
case "source", ".":
// source file - run the commands of file in the current shell
if len(args) < 2 {
return fmt.Errorf("usage: %s file", args[0])
}
return sourceFile(args[0], args[1])
case "help":
fmt.Fprintln(stdout, "gosh - Go Shell\nVersion: " + versionInfo() + `
@@ -1566,6 +1776,16 @@ Built-in commands:
quit [code] - Quit shell with status code (default: $?)
set VAR=val - Set environment variable
export VAR=val - Export environment variable
source file - 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] - Wait for job n or all background jobs
alias [name[=value] ...] - Define or show aliases (alias ll='ls -l')
unalias name - Remove an alias (unalias -a: all aliases)
: - Do nothing (exit status 0)
break [n] - Leave the innermost (or n-th) for/while/until loop
continue [n] - Continue with the next iteration of a loop
help - Show this help
Features:
@@ -1577,21 +1797,38 @@ 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
- 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
- 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
- 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
- Environment variable expansion ($VAR, ${VAR}, ${VAR:-default});
using an unset variable is an error, use ${VAR:-} for an empty default
- 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 ...,
! cmd negates the status; they can span several lines (prompt "> ")
- Pipes (a | b) and redirections: < file, > file, >> file, 2> file,
2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file
- Job control: command & runs in the background, Ctrl+Z stops the
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`)
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)`)
return nil
}
return fmt.Errorf("%s: not a builtin", args[0])
+400 -48
View File
@@ -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
@@ -458,7 +459,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 +495,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 +514,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 +533,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 +552,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 +571,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
@@ -841,7 +842,7 @@ func TestShellCompleter_Do(t *testing.T) {
want []string
length int
}{
{"c", []string{"d "}, 1},
{"c", []string{"d ", "ontinue "}, 1},
{"e", []string{"cho ", "xit ", "xport "}, 1},
{"gi", []string{"tty "}, 2},
{" gi", []string{"tty "}, 2},
@@ -889,6 +890,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)
@@ -1051,24 +1060,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 +1089,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 +1105,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)
}
@@ -1183,7 +1212,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 +1244,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 +1342,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 +1352,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,7 +1364,7 @@ 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{"/gosh/no/such/script"}, 127},
@@ -1358,41 +1387,170 @@ func TestReadLine_DoesNotReadAhead(t *testing.T) {
}
}
func TestSplitList(t *testing.T) {
// treeString formats a syntax tree for the tests: simple commands in
// brackets, background commands with a trailing &.
func treeString(l *listNode) string {
var parts []string
for _, e := range l.items {
s := pipelineString(e.andOr.first)
for _, r := range e.andOr.rest {
s += " " + r.op + " " + pipelineString(r.pipeline)
}
if e.background {
s += " &"
}
parts = append(parts, s)
}
return strings.Join(parts, "; ")
}
func pipelineString(pl *pipelineNode) string {
s := ""
if pl.negate {
s = "! "
}
switch n := pl.cmd.(type) {
case *simpleNode:
s += "[" + n.text + "]"
case *ifNode:
s += "if"
for i := range n.conds {
if i > 0 {
s += " elif"
}
s += " " + treeString(n.conds[i]) + " then " + treeString(n.bodies[i])
}
if n.elseBody != nil {
s += " else " + treeString(n.elseBody)
}
s += " fi"
case *forNode:
s += "for " + n.name
if n.hasIn {
s += " in (" + n.words + ")"
}
s += " do " + treeString(n.body) + " done"
case *whileNode:
if n.until {
s += "until "
} else {
s += "while "
}
s += treeString(n.cond) + " do " + treeString(n.body) + " done"
}
return s
}
func TestParse_Lists(t *testing.T) {
tests := []struct {
input string
want []listItem
want string
}{
{"", nil},
{" ", nil},
{"# only a comment", nil},
{"ls", []listItem{{"", "ls"}}},
{"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"},
}
for _, tc := range tests {
tree, err := parse(tc.input)
if err != nil {
t.Errorf("parse(%q) returned error: %v", tc.input, err)
continue
}
if got := treeString(tree); got != tc.want {
t.Errorf("parse(%q) = %s, want %s", tc.input, got, tc.want)
}
}
}
func TestParse_Incomplete(t *testing.T) {
for _, input := range []string{
"if a", "if a; then", "if a; then b", "if a; then b; else", "if a; then b; elif c",
"for x", "for x in a b", "for x in a; do", "for x in a; do b",
"while a", "while a; do b", "until a; do",
"a &&", "a ||", "a |", "echo 'open", `echo "open`, "echo $(a", "echo a \\",
} {
if _, err := parse(input); !errors.Is(err, errIncomplete) {
t.Errorf("parse(%q) = %v, want incomplete", input, err)
}
}
}
func TestParse_SyntaxErrors(t *testing.T) {
for _, input := range []string{
"; a", "a ;; b", "&& a", "a && ; b", "a || && b", "& a", "a & && b", "a & & b",
"fi", "then", "done", "if a; then fi", "if ; then a; fi", "while a; do done",
"if a; then b; done", "for 1x in a; do b; done", "for x in a do b; done; x",
"a | | b", "| a",
} {
_, err := parse(input)
if err == nil || errors.Is(err, errIncomplete) {
t.Errorf("parse(%q) = %v, want syntax error", input, err)
}
}
for input, want := range map[string]string{
"for x in a; do b; done | cat": "control structures in pipelines are not supported",
"echo | while a; do b; done": "control structures in pipelines are not supported",
"if a; then b; fi > out": "redirections of control structures are not supported (near `>')",
"for x in a; do b; done &": "only simple commands and pipelines can run in the background",
"a && b &": "only simple commands and pipelines can run in the background",
} {
if _, err := parse(input); err == nil || err.Error() != want {
t.Errorf("parse(%q) = %v, want %q", input, err, want)
}
}
}
@@ -1730,3 +1888,197 @@ func TestExecInput_Wildcards(t *testing.T) {
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)
}
}
+841
View File
@@ -0,0 +1,841 @@
package main
// Parser and executor for command lines and scripts: lists (; & && || and
// newlines), pipelines, ! and the control structures if, for, while and
// until. Simple commands (with their pipes and redirections) are kept as
// text; they are expanded and run by execCommand only when they are
// executed, so that e.g. the variable of a for loop is expanded anew in
// every iteration.
import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"syscall"
)
// incompleteError is returned by parse if the input ends inside a
// construct (if without fi, an open quote, a trailing && ...): an
// interactive shell then reads another line.
type incompleteError struct{}
func (incompleteError) Error() string { return "syntax error: unexpected end of input" }
var errIncomplete error = incompleteError{}
// loopControl is returned by break and continue and ends the given number of
// enclosing loops (break) or iterations (continue).
type loopControl struct {
isBreak bool
n int
}
func (c loopControl) Error() string {
if c.isBreak {
return "break"
}
return "continue"
}
// loopDepth is the number of loops that are currently running.
var loopDepth int
// aliasExpansion is false while the result of an alias expansion runs, so
// that it is not expanded again.
var aliasExpansion = true
// reserved words that end a list; they are only recognized as the first word
// of a command.
var terminators = map[string]bool{"then": true, "elif": true, "else": true, "fi": true, "do": true, "done": true}
// ---------------------------------------------------------------------------
// Lexer
type tokenKind int
const (
tWord tokenKind = iota
tOp // ; & && || | or newline
)
type rawToken struct {
kind tokenKind
text string
start, end int
}
// joinContinuationLines removes backslash-newline (outside single quotes),
// which continues a line. continued reports whether src ends with such a
// continuation (so that the command goes on in the next line).
func joinContinuationLines(src string) (joined string, continued bool) {
if !strings.Contains(src, "\\\n") {
return src, false
}
var b strings.Builder
var quote byte
for i := 0; i < len(src); i++ {
ch := src[i]
switch {
case quote == '\'':
if ch == '\'' {
quote = 0
}
case ch == '\\' && i+1 < len(src):
if src[i+1] == '\n' {
i++
continued = i == len(src)-1
continue
}
continued = false
b.WriteByte(ch)
i++
ch = src[i]
case ch == '"':
if quote == '"' {
quote = 0
} else {
quote = '"'
}
case ch == '\'' && quote == 0:
quote = '\''
}
if ch != '\n' && ch != ' ' && ch != '\t' {
continued = false
}
b.WriteByte(ch)
}
return b.String(), continued
}
// lex splits src into words and list operators. Words keep their quotes,
// escapes, $(...) and redirections; they are only split at blanks and list
// operators.
func lex(src string) ([]rawToken, error) {
var toks []rawToken
op := func(text string, i int) {
toks = append(toks, rawToken{tOp, text, i, i + len(text)})
}
for i := 0; i < len(src); {
ch := src[i]
next := byte(0)
if i+1 < len(src) {
next = src[i+1]
}
switch {
case ch == ' ' || ch == '\t':
i++
case ch == '\n' || ch == ';':
op(string(ch), i)
i++
case ch == '#':
// Comment until the end of the line
for i < len(src) && src[i] != '\n' {
i++
}
case ch == '|' && next == '|', ch == '&' && next == '&':
op(src[i:i+2], i)
i += 2
case ch == '|', ch == '&' && next != '>':
op(string(ch), i)
i++
default:
end, err := lexWord(src, i)
if err != nil {
return nil, err
}
toks = append(toks, rawToken{tWord, src[i:end], i, end})
i = end
}
}
return toks, nil
}
// lexWord returns the end of the word that starts at src[i].
func lexWord(src string, i int) (int, error) {
var quote byte
for j := i; j < len(src); j++ {
ch := src[j]
switch {
case quote == '\'':
if ch == '\'' {
quote = 0
}
case ch == '\\':
if j+1 == len(src) {
return 0, errIncomplete
}
j++
case isSubstStart(src, j):
end := substEnd(src, j)
if end < 0 {
return 0, errIncomplete
}
j = end
case ch == '$' && j+1 < len(src) && src[j+1] == '{':
if k := strings.IndexByte(src[j:], '}'); k >= 0 {
j += k
}
case quote == '"':
if ch == '"' {
quote = 0
}
case ch == '"' || ch == '\'':
quote = ch
case ch == ' ' || ch == '\t' || ch == '\n' || ch == ';' || ch == '|':
return j, nil
case ch == '&':
// &>, >& and <& are redirections, not the & operator
if !(j+1 < len(src) && src[j+1] == '>') && !(j > i && (src[j-1] == '>' || src[j-1] == '<')) {
return j, nil
}
}
}
if quote != 0 {
return 0, errIncomplete
}
return len(src), nil
}
// ---------------------------------------------------------------------------
// Syntax tree
// node is a command that can be executed: it returns the error of the last
// command it ran (so that exitStatus gives its status).
type node interface {
exec() error
}
// listNode is a list of and-or lists separated by ;, & or newlines.
type listNode struct {
items []listEntry
}
type listEntry struct {
andOr *andOrNode
background bool
}
// andOrNode is a pipeline followed by && and || pipelines.
type andOrNode struct {
first *pipelineNode
rest []andOrPart
}
type andOrPart struct {
op string // && or ||
pipeline *pipelineNode
}
// pipelineNode is a simple command (whose text may contain a pipeline) or a
// control structure, optionally negated with !.
type pipelineNode struct {
negate bool
cmd node
}
// simpleNode is the text of a pipeline of simple commands.
type simpleNode struct {
text string
}
type ifNode struct {
conds []*listNode
bodies []*listNode
elseBody *listNode
}
type forNode struct {
name string
words string // the words after "in", expanded when the loop starts
hasIn bool
body *listNode
}
type whileNode struct {
until bool
cond *listNode
body *listNode
}
// singleSimple returns the simple command if the list is just that.
func (l *listNode) singleSimple() *simpleNode {
if len(l.items) != 1 || l.items[0].background {
return nil
}
ao := l.items[0].andOr
if len(ao.rest) > 0 || ao.first.negate {
return nil
}
s, _ := ao.first.cmd.(*simpleNode)
return s
}
// ---------------------------------------------------------------------------
// Parser
type parser struct {
src string
toks []rawToken
pos int
}
// parse parses a command line or script. It returns errIncomplete if the
// input ends inside a construct.
func parse(src string) (*listNode, error) {
src, continued := joinContinuationLines(src)
if continued {
return nil, errIncomplete
}
toks, err := lex(src)
if err != nil {
return nil, err
}
p := &parser{src: src, toks: toks}
list, err := p.parseList(nil)
if err != nil {
return nil, err
}
if t := p.peek(); t != nil {
return nil, p.unexpected(t)
}
return list, nil
}
func (p *parser) peek() *rawToken {
if p.pos < len(p.toks) {
return &p.toks[p.pos]
}
return nil
}
func (p *parser) peekWord(words ...string) bool {
t := p.peek()
if t == nil || t.kind != tWord {
return false
}
for _, w := range words {
if t.text == w {
return true
}
}
return false
}
func (p *parser) skipNewlines() {
for t := p.peek(); t != nil && t.kind == tOp && t.text == "\n"; t = p.peek() {
p.pos++
}
}
func (p *parser) unexpected(t *rawToken) error {
if t.text == "\n" {
return syntaxError("newline")
}
return syntaxError(t.text)
}
// expect consumes the reserved word w.
func (p *parser) expect(w string) error {
t := p.peek()
if t == nil {
return errIncomplete
}
if t.kind != tWord || t.text != w {
return p.unexpected(t)
}
p.pos++
return nil
}
// parseList parses and-or lists until the end of the input or one of the
// reserved words in terms at the start of a command.
func (p *parser) parseList(terms map[string]bool) (*listNode, error) {
list := &listNode{}
for {
p.skipNewlines()
t := p.peek()
if t == nil || t.kind == tWord && terms[t.text] {
return list, nil
}
ao, err := p.parseAndOr()
if err != nil {
return nil, err
}
entry := listEntry{andOr: ao}
t = p.peek()
switch {
case t == nil:
case t.kind == tOp && (t.text == ";" || t.text == "&" || t.text == "\n"):
p.pos++
entry.background = t.text == "&"
case t.kind == tWord && terms[t.text]:
// e.g. "fi" right after the end of a nested control structure
default:
return nil, p.unexpected(t)
}
if entry.background {
if _, ok := ao.first.cmd.(*simpleNode); !ok || len(ao.rest) > 0 || ao.first.negate {
return nil, errors.New("only simple commands and pipelines can run in the background")
}
}
list.items = append(list.items, entry)
}
}
// nonEmpty returns an error if a list that must contain commands is empty.
func (p *parser) nonEmpty(list *listNode) error {
if len(list.items) > 0 {
return nil
}
if t := p.peek(); t != nil {
return p.unexpected(t)
}
return errIncomplete
}
func (p *parser) parseAndOr() (*andOrNode, error) {
first, err := p.parsePipeline()
if err != nil {
return nil, err
}
ao := &andOrNode{first: first}
for {
t := p.peek()
if t == nil || t.kind != tOp || (t.text != "&&" && t.text != "||") {
return ao, nil
}
p.pos++
p.skipNewlines()
if p.peek() == nil {
return nil, errIncomplete
}
next, err := p.parsePipeline()
if err != nil {
return nil, err
}
ao.rest = append(ao.rest, andOrPart{op: t.text, pipeline: next})
}
}
func (p *parser) parsePipeline() (*pipelineNode, error) {
pl := &pipelineNode{}
if p.peekWord("!") {
pl.negate = true
p.pos++
}
t := p.peek()
if t == nil {
return nil, errIncomplete
}
if t.kind == tOp || terminators[t.text] {
return nil, p.unexpected(t)
}
var err error
compound := true
switch t.text {
case "if":
pl.cmd, err = p.parseIf()
case "for":
pl.cmd, err = p.parseFor()
case "while", "until":
pl.cmd, err = p.parseWhile()
default:
compound = false
pl.cmd, err = p.parseSimple()
}
if err != nil {
return nil, err
}
if compound {
if t := p.peek(); t != nil {
switch {
case t.kind == tOp && t.text == "|":
return nil, errors.New("control structures in pipelines are not supported")
case t.kind == tWord && !terminators[t.text]:
return nil, fmt.Errorf("redirections of control structures are not supported (near `%s')", t.text)
}
}
}
return pl, nil
}
// parseSimple parses a pipeline of simple commands and returns its text.
func (p *parser) parseSimple() (*simpleNode, error) {
start := p.toks[p.pos].start
end := start
for t := p.peek(); t != nil; t = p.peek() {
if t.kind == tWord {
end = t.end
p.pos++
continue
}
if t.text != "|" {
break
}
p.pos++
end = t.end
p.skipNewlines()
next := p.peek()
switch {
case next == nil:
return nil, errIncomplete
case next.kind == tOp || terminators[next.text]:
return nil, p.unexpected(next)
case next.text == "if" || next.text == "for" || next.text == "while" || next.text == "until":
return nil, errors.New("control structures in pipelines are not supported")
}
}
return &simpleNode{text: p.src[start:end]}, nil
}
func (p *parser) parseIf() (*ifNode, error) {
p.pos++ // if
n := &ifNode{}
for {
cond, err := p.parseList(map[string]bool{"then": true})
if err == nil {
err = p.nonEmpty(cond)
}
if err == nil {
err = p.expect("then")
}
if err != nil {
return nil, err
}
body, err := p.parseList(map[string]bool{"elif": true, "else": true, "fi": true})
if err == nil {
err = p.nonEmpty(body)
}
if err != nil {
return nil, err
}
n.conds = append(n.conds, cond)
n.bodies = append(n.bodies, body)
t := p.peek()
if t == nil {
return nil, errIncomplete
}
p.pos++
switch t.text {
case "elif":
continue
case "else":
body, err := p.parseList(map[string]bool{"fi": true})
if err == nil {
err = p.nonEmpty(body)
}
if err == nil {
err = p.expect("fi")
}
if err != nil {
return nil, err
}
n.elseBody = body
}
return n, nil // fi
}
}
func (p *parser) parseFor() (*forNode, error) {
p.pos++ // for
t := p.peek()
if t == nil {
return nil, errIncomplete
}
if t.kind != tWord || !isVarName(t.text) {
return nil, fmt.Errorf("for: `%s': not a valid identifier", sanitize(t.text))
}
n := &forNode{name: t.text}
p.pos++
p.skipNewlines()
if p.peekWord("in") {
p.pos++
n.hasIn = true
var words []string
for t := p.peek(); t != nil && t.kind == tWord; t = p.peek() {
words = append(words, t.text)
p.pos++
}
n.words = strings.Join(words, " ")
}
t = p.peek()
switch {
case t == nil:
return nil, errIncomplete
case t.kind == tOp && (t.text == ";" || t.text == "\n"):
p.pos++
case !n.hasIn && t.kind == tWord && t.text == "do":
default:
return nil, p.unexpected(t)
}
p.skipNewlines()
body, err := p.parseLoopBody()
if err != nil {
return nil, err
}
n.body = body
return n, nil
}
func (p *parser) parseWhile() (*whileNode, error) {
n := &whileNode{until: p.peek().text == "until"}
p.pos++
cond, err := p.parseList(map[string]bool{"do": true})
if err == nil {
err = p.nonEmpty(cond)
}
if err != nil {
return nil, err
}
n.cond = cond
body, err := p.parseLoopBody()
if err != nil {
return nil, err
}
n.body = body
return n, nil
}
// parseLoopBody parses "do list done".
func (p *parser) parseLoopBody() (*listNode, error) {
if err := p.expect("do"); err != nil {
return nil, err
}
body, err := p.parseList(map[string]bool{"done": true})
if err == nil {
err = p.nonEmpty(body)
}
if err == nil {
err = p.expect("done")
}
return body, err
}
// ---------------------------------------------------------------------------
// Execution
// isAbort reports whether err stops the execution of the enclosing lists:
// break, continue, or a command that was interrupted with Ctrl+C (like in
// bash, Ctrl+C aborts the whole command line, e.g. a loop).
func isAbort(err error) bool {
var lc loopControl
return errors.As(err, &lc) || exitStatus(err) == 128+int(syscall.SIGINT)
}
// silent returns an error with only the exit status of err, for errors that
// were already reported.
func silent(err error) error {
if err == nil {
return nil
}
return statusError(exitStatus(err))
}
func (l *listNode) exec() error {
var err error
for _, entry := range l.items {
printError(err)
if entry.background {
err = entry.andOr.first.cmd.(*simpleNode).run(true)
} else {
err = entry.andOr.exec()
}
lastStatus = exitStatus(err)
if isAbort(err) {
return err
}
}
return err
}
func (a *andOrNode) exec() error {
err := a.first.exec()
for _, part := range a.rest {
lastStatus = exitStatus(err)
if isAbort(err) {
return err
}
if (part.op == "&&") != (lastStatus == 0) {
continue
}
printError(err)
err = part.pipeline.exec()
}
return err
}
func (pl *pipelineNode) exec() error {
err := pl.cmd.exec()
if !pl.negate || isAbort(err) {
return err
}
printError(err)
if exitStatus(err) == 0 {
return statusError(1)
}
return nil
}
func (s *simpleNode) exec() error {
return s.run(false)
}
// run executes the simple command, in the background if background is set.
// Aliases are expanded here (in interactive shells), so that they work at
// the start of every command, also inside control structures.
func (s *simpleNode) run(background bool) error {
text := s.text
if interactiveShell && aliasExpansion {
if expanded := expandAliases(text); expanded != text {
tree, err := parse(expanded)
if err != nil {
return err
}
if simple := tree.singleSimple(); simple != nil {
text = simple.text
} else {
if background {
return errors.New("an alias with a command list cannot run in the background")
}
aliasExpansion = false
defer func() { aliasExpansion = true }()
return tree.exec()
}
}
}
return execCommand(text, background)
}
func (n *ifNode) exec() error {
for i, cond := range n.conds {
err := cond.exec()
if isAbort(err) {
return err
}
printError(err)
if exitStatus(err) == 0 {
return n.bodies[i].exec()
}
}
if n.elseBody != nil {
return n.elseBody.exec()
}
lastStatus = 0
return nil
}
// runLoopBody runs one iteration of a loop. It returns whether the loop
// must stop, and the error to return from the loop.
func runLoopBody(body *listNode, result *error) (stop bool, err error) {
err = body.exec()
var lc loopControl
if errors.As(err, &lc) {
*result = nil
if lc.n > 1 {
lc.n--
return true, lc // ends an outer loop too
}
return lc.isBreak, nil
}
if isAbort(err) {
return true, err
}
printError(err)
*result = silent(err)
return false, nil
}
func (n *whileNode) exec() error {
loopDepth++
defer func() { loopDepth-- }()
var result error
for {
err := n.cond.exec()
if isAbort(err) {
return err
}
printError(err)
if (exitStatus(err) == 0) == n.until {
break
}
if stop, err := runLoopBody(n.body, &result); stop {
if err != nil {
return err
}
break
}
}
lastStatus = exitStatus(result)
return result
}
func (n *forNode) exec() error {
var words []string
if n.hasIn {
var err error
if words, err = expandWordList(n.words); err != nil {
return err
}
}
loopDepth++
defer func() { loopDepth-- }()
var result error
for _, word := range words {
os.Setenv(n.name, word)
if stop, err := runLoopBody(n.body, &result); stop {
if err != nil {
return err
}
break
}
}
lastStatus = exitStatus(result)
return result
}
// expandWordList expands the words of a for loop like the arguments of a
// command (braces, ~, variables, $(...), wildcards).
func expandWordList(text string) ([]string, error) {
text, err := expandBraceWords(text)
if err != nil {
return nil, err
}
tokens, err := tokenize(text)
if err != nil {
return nil, fmt.Errorf("invalid input: %w", err)
}
var words []string
for _, t := range tokens {
if t.op != "" {
return nil, syntaxError(t.op)
}
words = append(words, expandWord(t)...)
}
return words, nil
}
// builtinLoopControl implements break [n] and continue [n].
func builtinLoopControl(args []string) error {
n := 1
if len(args) > 1 {
var err error
if n, err = strconv.Atoi(args[1]); err != nil || n < 1 {
return fmt.Errorf("%s: %s: loop count out of range", args[0], sanitize(args[1]))
}
}
if loopDepth == 0 {
return fmt.Errorf("%s: only meaningful in a loop", args[0])
}
return loopControl{isBreak: args[0] == "break", n: min(n, loopDepth)}
}
+164
View File
@@ -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)
}
}
+98
View File
@@ -0,0 +1,98 @@
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.
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
}
// commandSubst runs command in a subshell and returns its output without
// trailing newlines. stdin and stderr are those of the shell.
func commandSubst(command string) (string, error) {
if strings.HasPrefix(command, "(") {
return "", errors.New("arithmetic expansion $((...)) is not supported")
}
path := subshellPath
if path == "" {
var err error
if path, err = os.Executable(); err != nil {
return "", err
}
}
var out bytes.Buffer
cmd := exec.Command(path, "-c", command)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, &out, os.Stderr
err := cmd.Run()
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
}
+104
View File
@@ -0,0 +1,104 @@
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",
"echo $((1 + 2))": "invalid input: arithmetic expansion $((...)) is not supported",
} {
if err := execInput(input); err == nil || err.Error() != wantErr {
t.Errorf("%q: error %v, want %q", input, err, wantErr)
}
}
}