Compare commits

..
7 Commits
Author SHA1 Message Date
Florian WaltherandClaude Opus 5.5 22ce8c00d4 add assignments name=value and unset
- name=value (one or more) without a command sets variables of the shell
- name=value before a command sets them only in its environment (for
  builtins only while they run)
- an assignment needs an unquoted name and =; the value is not split
  and not expanded as wildcard or brace pattern, ~ is expanded after =
  and :
- the status of an assignment is the one of its last $(...)
- new builtin unset

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:17:38 +02:00
Florian Walther fb94e98da5 updated README 2026-10-04 00:16:29 +02:00
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
13 changed files with 2459 additions and 269 deletions

No files matched your search

+161 -7
View File
@@ -1,8 +1,8 @@
`gosh` eine einfache Shell, geschrieben in golang. `gosh` eine Shell, in golang geschrieben.
``` ```
gosh - Go Shell gosh - Go Shell
Version: 0.2.1 (branch: main, commit: 03e791c) Version: 0.13.0 (branch: main, commit: f79fb0b)
Built-in commands: Built-in commands:
cd [dir] - Change directory (cd with no args goes to home) cd [dir] - Change directory (cd with no args goes to home)
@@ -12,6 +12,7 @@ Built-in commands:
quit [code] - Quit shell with status code (default: $?) quit [code] - Quit shell with status code (default: $?)
set VAR=val - Set environment variable set VAR=val - Set environment variable
export VAR=val - Export environment variable export VAR=val - Export environment variable
unset VAR - Remove a variable
source file - Run the commands of file in this shell (also: . file) source file - Run the commands of file in this shell (also: . file)
jobs - List background and stopped jobs jobs - List background and stopped jobs
fg [%n] - Continue job n (default: current job) in the foreground fg [%n] - Continue job n (default: current job) in the foreground
@@ -19,6 +20,9 @@ Built-in commands:
wait [%n] - Wait for job n or all background jobs wait [%n] - Wait for job n or all background jobs
alias [name[=value] ...] - Define or show aliases (alias ll='ls -l') alias [name[=value] ...] - Define or show aliases (alias ll='ls -l')
unalias name - Remove an alias (unalias -a: all aliases) unalias name - Remove an alias (unalias -a: all aliases)
: - Do nothing (exit status 0)
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 help - Show this help
Features: Features:
@@ -30,17 +34,30 @@ Features:
override with $GOSH_HISTFILE); lines starting with a space are not saved override with $GOSH_HISTFILE); lines starting with a space are not saved
- Tab completion for commands and file paths - Tab completion for commands and file paths
- Tilde expansion (~, ~/path) at the start of unquoted words - 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 - Wildcards: * ? [abc] [a-z] [!abc] match file names (not hidden files
unless the pattern starts with a dot); quoted wildcards and values of unless the pattern starts with a dot); quoted wildcards and values of
variables are not expanded, a pattern without match is kept as is variables are not expanded, a pattern without match is kept as is
- Colored prompt (user@host green, red for root; directory blue), - Prompt user@host:dir $, with [N] before the $ if the last command
configurable with $GOSH_COLORS, e.g. GOSH_COLORS='user=1;33:path=36' failed with exit status N
(keys user, root, path; values are ANSI SGR codes, empty = no color); - Colored prompt (user@host green, red for root; directory blue; exit
disabled with $NO_COLOR, TERM=dumb or when output is not a terminal 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
- Variables: name=value sets a variable (like set), name=value command
sets it only for that command; the value is not split or expanded as
a pattern
- Environment variable expansion ($VAR, ${VAR}, ${VAR:-default}); - Environment variable expansion ($VAR, ${VAR}, ${VAR:-default});
using an unset variable is an error, use ${VAR:-} for an empty default using an unset variable is an error, use ${VAR:-} for an empty default
- 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), - 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 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, - Pipes (a | b) and redirections: < file, > file, >> file, 2> file,
2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file 2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file
- Job control: command & runs in the background, Ctrl+Z stops the - Job control: command & runs in the background, Ctrl+Z stops the
@@ -118,6 +135,93 @@ export LANG=de_DE.UTF-8
export PATH=$HOME/bin:$PATH 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
n=3
while [ $n -gt 0 ]; do
echo $n
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 und `read`. Arithmetik geht z. B. mit `n=$(expr $n + 1)`.
### Variablen
```bash
name=wert # Variable setzen (wie set name=wert)
a=1 b=2 # mehrere auf einmal
gruss="Hallo Welt" # Leerzeichen im Wert brauchen Anführungszeichen
heute=$(date +%F) # Ausgabe eines Befehls
PATH=~/bin:$PATH # ~ wird auch nach = und : ersetzt
LANG=C sort datei # nur für diesen einen Befehl
unset name # Variable entfernen
echo $name ${name:-standard}
```
- Eine Zuweisung ist ein Wort `name=wert` am Anfang eines Befehls, bei dem `name` und `=` ohne Anführungszeichen stehen und `name` aus Buchstaben, Ziffern und `_` besteht (nicht mit einer Ziffer beginnend). `"x=1"`, `x\=1` und `echo x=1` sind keine Zuweisungen.
- Steht danach kein Befehl, wird die Variable in der Shell gesetzt. Wie bei `set` und `export` sind in gosh alle Variablen auch für gestartete Programme sichtbar (es gibt keine reinen Shell-Variablen).
- Steht ein Befehl danach (`LANG=C sort datei`), gilt die Zuweisung nur für diesen Befehl, die Shell behält den alten Wert.
- Der Wert wird nicht in Wörter geteilt und nicht als Wildcard oder `{a,b}` expandiert: `x=$(ls)` speichert die ganze Ausgabe, `x=*.go` speichert `*.go`. Variablen, `$(...)` und `~` (am Anfang, nach `=` und nach `:`) werden ersetzt.
- `$?` ist nach einer Zuweisung der Status des letzten `$(...)` darin, sonst 0.
- Variablen in den Argumenten werden ersetzt, bevor die Zuweisung davor gilt: `x=1 echo $x` gibt nicht `1` aus, sondern meldet `x: unbound variable` (wenn `x` vorher nicht gesetzt war).
- In einer Pipeline (`x=1 | cat`) ändert eine Zuweisung die Shell nicht.
### 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 ### Befehlslisten und Exit-Status
Mehrere Befehle in einer Zeile werden wie in `sh` verknüpft: Mehrere Befehle in einer Zeile werden wie in `sh` verknüpft:
@@ -142,6 +246,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. 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 ### Wildcards
Wörter mit `*`, `?` oder `[...]` werden durch die passenden Dateinamen ersetzt, sortiert: Wörter mit `*`, `?` oder `[...]` werden durch die passenden Dateinamen ersetzt, sortiert:
@@ -165,7 +298,7 @@ $ ls -d */ # nur Verzeichnisse
- Versteckte Dateien (mit `.` am Anfang) werden nur gefunden, wenn das Muster selbst mit `.` beginnt: `*` findet `.bashrc` nicht, `.*rc` schon. - Versteckte Dateien (mit `.` am Anfang) werden nur gefunden, wenn das Muster selbst mit `.` beginnt: `*` findet `.bashrc` nicht, `.*rc` schon.
- Passt keine Datei, bleibt das Wort unverändert stehen (wie in `sh`), `ls *.xyz` meldet dann also `ls` den Fehler. - Passt keine Datei, bleibt das Wort unverändert stehen (wie in `sh`), `ls *.xyz` meldet dann also `ls` den Fehler.
- In Anführungszeichen oder mit `\` davor sind die Zeichen normale Zeichen: `echo "*"`, `echo '*.go'`, `echo \*`. - In Anführungszeichen oder mit `\` davor sind die Zeichen normale Zeichen: `echo "*"`, `echo '*.go'`, `echo \*`.
- Werte von Variablen werden nicht als Muster behandelt (anders als in `sh`): Nach `set P=*.go` gibt `echo $P` wörtlich `*.go` aus. So kann ein Variablenwert nie unbemerkt auf viele Dateien passen. `echo $DIR/*.go` funktioniert dagegen, weil das `*` direkt in der Zeile steht. - Werte von Variablen werden nicht als Muster behandelt (anders als in `sh`): Nach `P='*.go'` gibt `echo $P` wörtlich `*.go` aus. So kann ein Variablenwert nie unbemerkt auf viele Dateien passen. `echo $DIR/*.go` funktioniert dagegen, weil das `*` direkt in der Zeile steht.
- Bei einer Umleitung muss das Muster genau eine Datei treffen (`> *.log`), sonst meldet gosh `ambiguous redirect`. - Bei einer Umleitung muss das Muster genau eine Datei treffen (`> *.log`), sonst meldet gosh `ambiguous redirect`.
### Pipes und Umleitungen ### Pipes und Umleitungen
@@ -256,8 +389,25 @@ $ bg %2 # oder: angehaltenen Job im Hintergrund weiterlauf
- `exit` (oder Ctrl+D) mit angehaltenen Jobs warnt einmal (`there are stopped jobs`), ein zweites `exit` direkt danach beendet gosh. - `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. - `cd`, `set`, `export`, `exit`, `source`, `fg`, `bg` und `wait` lassen sich nicht mit `&` im Hintergrund starten.
- In Skripten und mit `gosh -c` gibt es keine Job-Control (kein Ctrl+Z, kein `fg`/`bg`), `&` und `wait` funktionieren aber. Hintergrund-Jobs lesen dort von `/dev/null`. - In Skripten und mit `gosh -c` gibt es keine Job-Control (kein Ctrl+Z, kein `fg`/`bg`), `&` und `wait` funktionieren aber. Hintergrund-Jobs lesen dort von `/dev/null`.
- Wird ein angehaltener Job von außen fortgesetzt (`kill -CONT`), zeigt `jobs` ihn weiter als `Stopped`, bis er endet oder erneut angehalten wird; `fg` und `bg` funktionieren trotzdem.
- Einen Job beenden lässt sich mit `kill` über die Prozess-ID (z. B. `kill 12345`); `kill %1` versteht gosh noch nicht. - Einen Job beenden lässt sich mit `kill` über die Prozess-ID (z. B. `kill 12345`); `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 ### Farben
Der Prompt wird über die Umgebungsvariable `GOSH_COLORS` eingefärbt. Sie besteht aus durch `:` getrennten Einträgen `schlüssel=code`: Der Prompt wird über die Umgebungsvariable `GOSH_COLORS` eingefärbt. Sie besteht aus durch `:` getrennten Einträgen `schlüssel=code`:
@@ -267,6 +417,7 @@ Der Prompt wird über die Umgebungsvariable `GOSH_COLORS` eingefärbt. Sie beste
| `user` | `user@host` | `1;32` (fett grün) | | `user` | `user@host` | `1;32` (fett grün) |
| `root` | `user@host`, wenn man root ist | `1;31` (fett rot) | | `root` | `user@host`, wenn man root ist | `1;31` (fett rot) |
| `path` | aktuelles Verzeichnis | `1;34` (fett blau) | | `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: Die Werte sind [ANSI-SGR-Codes](https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters), mehrere werden mit `;` kombiniert:
@@ -304,6 +455,9 @@ GOSH_COLORS='user=1;38;2;136;192;208:path=38;2;216;222;233'
# Ozean: türkis und kursiv blau # Ozean: türkis und kursiv blau
GOSH_COLORS='user=1;96:path=3;94' 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 # Root-Warnung: weiß auf rotem Hintergrund
GOSH_COLORS='root=1;97;41' GOSH_COLORS='root=1;97;41'
+11 -1
View File
@@ -75,7 +75,7 @@ func expandAliasesIn(line string, active map[string]bool) string {
for i := 0; i < len(line); i++ { for i := 0; i < len(line); i++ {
ch := line[i] ch := line[i]
if quote == 0 && !escaped && commandPos { if quote == 0 && !escaped && commandPos {
if ch == ' ' || ch == '\t' { if ch == ' ' || ch == '\t' || ch == '\n' {
out.WriteByte(ch) out.WriteByte(ch)
continue continue
} }
@@ -96,6 +96,16 @@ func expandAliasesIn(line string, active map[string]bool) string {
} }
} }
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) out.WriteByte(ch)
switch { switch {
case escaped: case escaped:
+109
View File
@@ -0,0 +1,109 @@
package main
import (
"os"
"testing"
)
func TestTokenize_Assignments(t *testing.T) {
t.Setenv("HOME", "/home/u")
tests := []struct {
input string
word string
assign bool
}{
{"x=1", "x=1", true},
{"_a1=", "_a1=", true},
{`x="a b"`, "x=a b", true},
{"x=a=b", "x=a=b", true},
{"P=~/bin:~/go/bin", "P=/home/u/bin:/home/u/go/bin", true},
{"x=a~b", "x=a~b", true},
{"x=~", "x=/home/u", true},
// Not an assignment: quoted or escaped name or =, invalid name
{`"x=1"`, "x=1", false},
{`x"=1"`, "x=1", false},
{`x\=1`, "x=1", false},
{"1x=2", "1x=2", false},
{"=x", "=x", false},
{"a-b=1", "a-b=1", false},
}
for _, tc := range tests {
tokens, err := tokenize(tc.input)
if err != nil || len(tokens) != 1 {
t.Errorf("tokenize(%q) = %+v, %v", tc.input, tokens, err)
continue
}
if tokens[0].word != tc.word || tokens[0].assign != tc.assign {
t.Errorf("tokenize(%q) = %q (assign %v), want %q (assign %v)",
tc.input, tokens[0].word, tokens[0].assign, tc.word, tc.assign)
}
}
// The output of $(...) is not split in an assignment
tokens, _ := tokenize("x=$(echo a b) y")
if len(tokens) != 2 || tokens[0].word != "x=a b" || !tokens[0].assign {
t.Errorf("x=$(echo a b): %+v", tokens)
}
}
func TestExec_Assignments(t *testing.T) {
for _, name := range []string{"GOSH_A", "GOSH_B", "GOSH_C"} {
os.Unsetenv(name)
defer os.Unsetenv(name)
}
tests := []struct {
input string
want string
}{
{"GOSH_A=1; echo $GOSH_A > out", "1\n"},
{"GOSH_A=1 GOSH_B=2; echo $GOSH_A$GOSH_B > out", "12\n"},
{`GOSH_A="a b"; echo "$GOSH_A" > out`, "a b\n"},
{"GOSH_A=$(printf 'a b\\nc'); echo \"$GOSH_A\" > out", "a b\nc\n"},
// No wildcard or brace expansion in the value
{"touch x.go; GOSH_A=*.go; echo \"$GOSH_A\" > out", "*.go\n"},
{"GOSH_A={a,b}; echo $GOSH_A > out", "{a,b}\n"},
// ... but as an argument, assignment-like words are normal words
{"echo GOSH_A={a,b} > out", "GOSH_A=a GOSH_A=b\n"},
// Before a command, only in its environment
{"GOSH_C=temp sh -c 'echo $GOSH_C' > out; echo ${GOSH_C:-unset} >> out", "temp\nunset\n"},
{"GOSH_A=old; GOSH_A=new sh -c 'echo $GOSH_A' > out; echo $GOSH_A >> out", "new\nold\n"},
// ... and before a builtin only while it runs
{"GOSH_C=temp pwd > /dev/null; echo ${GOSH_C:-unset} > out", "unset\n"},
// In a pipeline or with a command they do not change the shell
{"GOSH_C=x | cat; echo ${GOSH_C:-unset} > out", "unset\n"},
// Redirections are applied
{"GOSH_A=r > out", ""},
// In loops
{"for i in 1 2 3; do GOSH_A=$i; done; echo $GOSH_A > out", "3\n"},
{"GOSH_B=0; while [ $GOSH_B -lt 3 ]; do echo $GOSH_B >> out; GOSH_B=$(expr $GOSH_B + 1); done", "0\n1\n2\n"},
// Not assignments
{`echo "GOSH_A=1" GOSH_B=2 > out`, "GOSH_A=1 GOSH_B=2\n"},
// unset
{"GOSH_A=1; unset GOSH_A; echo ${GOSH_A:-gone} > out", "gone\n"},
}
for _, tc := range tests {
if got := runIn(t, tc.input); got != tc.want {
t.Errorf("%q: out = %q, want %q", tc.input, got, tc.want)
}
}
// The status of an assignment is the one of its last $(...)
for input, want := range map[string]int{
"GOSH_A=1": 0,
"GOSH_A=$(false)": 1,
"GOSH_A=$(sh -c 'exit 3')": 3,
"GOSH_A=$(false) GOSH_B=$(true)": 0,
} {
runIn(t, input)
if lastStatus != want {
t.Errorf("%q: $? = %d, want %d", input, lastStatus, want)
}
}
if err := execInput("unset 1x"); err == nil || err.Error() != "unset: `1x': not a valid identifier" {
t.Errorf("unset 1x: %v", err)
}
if err := execInput(`"GOSH_A=1"`); err == nil || exitStatus(err) != 127 {
t.Errorf(`"GOSH_A=1" must be a command: %v`, err)
}
}
+298
View File
@@ -0,0 +1,298 @@
package main
// Brace expansion like in bash: a{b,c}d becomes abd acd, {1..5} becomes
// 1 2 3 4 5. It happens first, on the text of the command line, so that the
// resulting words go through the other expansions (~, variables, wildcards).
import (
"errors"
"fmt"
"strconv"
"strings"
)
// maxBraceWords limits the number of words a single word may expand to.
const maxBraceWords = 100000
// expandBraceWords applies brace expansion to every word of a command (the
// text of a pipeline). Quoted or escaped braces, ${...} and $(...) are left
// alone.
// A redirection target must expand to a single word.
func expandBraceWords(input string) (string, error) {
var (
out strings.Builder
afterRedirect bool // the next word is the target of a redirection
commandStart = true
)
for i := 0; i < len(input); {
ch := input[i]
switch {
case ch == ' ' || ch == '\t' || ch == '\n':
out.WriteByte(ch)
i++
case strings.IndexByte(";&|<>", ch) >= 0:
if ch == '<' || ch == '>' {
afterRedirect = true
} else {
commandStart = true
}
out.WriteByte(ch)
i++
case ch == '#':
// A comment, until the end of the line
out.WriteString(input[i:])
return out.String(), nil
default:
end := wordEnd(input, i)
if commandStart && !afterRedirect && isAssignment(input[i:end]) {
// name=value before the command: no brace expansion, like
// in bash
out.WriteString(input[i:end])
i = end
continue
}
if !afterRedirect {
commandStart = false
}
words, err := expandBraces(input[i:end])
if err != nil {
return "", err
}
if afterRedirect && len(words) > 1 {
return "", fmt.Errorf("%s: ambiguous redirect", sanitize(input[i:end]))
}
afterRedirect = false
out.WriteString(strings.Join(words, " "))
i = end
}
}
return out.String(), nil
}
// isAssignment reports whether a word starts with name= (unquoted).
func isAssignment(word string) bool {
name, _, ok := strings.Cut(word, "=")
return ok && isVarName(name)
}
// wordEnd returns the end of the word starting at i: the next unquoted blank
// or operator character.
func wordEnd(s string, i int) int {
var quote byte
for ; i < len(s); i++ {
ch := s[i]
switch {
case quote == '\'':
if ch == '\'' {
quote = 0
}
case ch == '\\':
i++
case isSubstStart(s, i):
if j := substEnd(s, i); j >= 0 {
i = j
}
case ch == '$' && i+1 < len(s) && s[i+1] == '{':
if j := 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)
}
}
+19 -8
View File
@@ -162,9 +162,7 @@ func (j *job) update(pid int, ws syscall.WaitStatus) {
switch { switch {
case ws.Stopped(): case ws.Stopped():
p.stopped = true p.stopped = true
case ws.Continued(): case ws.Exited() || ws.Signaled():
p.stopped = false
default:
p.finished, p.stopped = true, false p.finished, p.stopped = true, false
p.err = waitStatusError(ws) p.err = waitStatusError(ws)
p.cmd.Process.Release() p.cmd.Process.Release()
@@ -185,17 +183,30 @@ func waitStatusError(ws syscall.WaitStatus) error {
return nil return nil
} }
// wait waits until no process of the job is running any more (all finished // waitFlags returns the options for wait4: WUNTRACED to see stopped
// or, with job control, stopped). With nohang it only collects the status // processes (with job control) and WNOHANG to not block.
// changes that are already available and does not block. //
func (j *job) wait(nohang bool) { // 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 flags := 0
if jobControl && j.pgid != 0 { if jobControl && j.pgid != 0 {
flags |= syscall.WUNTRACED | syscall.WCONTINUED flags |= syscall.WUNTRACED
} }
if nohang { if nohang {
flags |= syscall.WNOHANG 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 { for {
// With job control all processes of the job are in its process // With job control all processes of the job are in its process
+22
View File
@@ -105,6 +105,21 @@ func (s *ptyShell) expect(want string) {
s.t.Fatalf("timeout waiting for %q, output:\n%s", want, s.out.String()) 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) { func TestJobControl(t *testing.T) {
s := startPtyShell(t) s := startPtyShell(t)
@@ -120,6 +135,13 @@ func TestJobControl(t *testing.T) {
s.send("bg\n") s.send("bg\n")
s.expect("[1]+ Running sleep 30 &") 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 // fg brings it back, Ctrl+C reaches only the job
s.send("fg %1\n") s.send("fg %1\n")
s.expect("sleep 30") s.expect("sleep 30")
+306 -193
View File
@@ -121,6 +121,7 @@ type token struct {
word string // the word, if op == "" word string // the word, if op == ""
pattern string // the word as glob pattern (quoted characters escaped) pattern string // the word as glob pattern (quoted characters escaped)
glob bool // the word contains unquoted * ? or [ and is expanded glob bool // the word contains unquoted * ? or [ and is expanded
assign bool // the word is an assignment name=value (unquoted name and =)
fd int // the redirected file descriptor (for redirections) fd int // the redirected file descriptor (for redirections)
dup int // the target file descriptor of >& and <& dup int // the target file descriptor of >& and <&
} }
@@ -131,10 +132,13 @@ type token struct {
// - '...' is taken literally, nothing is expanded inside // - '...' is taken literally, nothing is expanded inside
// - "..." expands $VAR and ${VAR}; \ only escapes $ " \ and ` // - "..." expands $VAR and ${VAR}; \ only escapes $ " \ and `
// - outside quotes \ escapes any character // - 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; // - $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 // an unset variable is an error, so that e.g. "rm -rf $DIR/x" never runs
// with an empty $DIR // with an empty $DIR
// - ~ and ~/... are expanded at the start of an unquoted word // - ~ and ~/... are expanded at the start of an unquoted word, and after
// the = and : of an assignment (PATH=~/bin:~/go/bin)
// - unquoted |, <, >, >>, N<, N>, N>>, N>&M, N<&M, &> and &>> are operators // - unquoted |, <, >, >>, N<, N>, N>>, N>&M, N<&M, &> and &>> are operators
// //
// Expanded values are never split into several words. // Expanded values are never split into several words.
@@ -145,6 +149,9 @@ func tokenize(input string) ([]token, error) {
pat strings.Builder // the word as glob pattern, quoted parts escaped pat strings.Builder // the word as glob pattern, quoted parts escaped
glob bool // the word contains unquoted * ? or [ glob bool // the word contains unquoted * ? or [
inWord bool // a word has been started (even if it is still empty) inWord bool // a word has been started (even if it is still empty)
plain = true // the word so far has no quotes, escapes or expansions
assign bool // the word is an assignment name=value
tildeOK bool // ~ may be expanded here (after = or : of an assignment)
quote byte quote byte
escaped bool escaped bool
rawStart = -1 // start of the current word in input rawStart = -1 // start of the current word in input
@@ -152,16 +159,17 @@ func tokenize(input string) ([]token, error) {
endWord := func() { endWord := func() {
if inWord { if inWord {
tokens = append(tokens, token{word: b.String(), pattern: pat.String(), glob: glob}) tokens = append(tokens, token{word: b.String(), pattern: pat.String(), glob: glob, assign: assign})
} }
b.Reset() b.Reset()
pat.Reset() pat.Reset()
glob = false glob, assign, plain = false, false, true
inWord = false inWord = false
rawStart = -1 rawStart = -1
} }
// literal adds quoted, escaped or expanded text: it is never a pattern // literal adds quoted, escaped or expanded text: it is never a pattern
literal := func(s string) { literal := func(s string) {
plain = false
b.WriteString(s) b.WriteString(s)
pat.WriteString(escapeGlob(s)) pat.WriteString(escapeGlob(s))
} }
@@ -173,6 +181,8 @@ func tokenize(input string) ([]token, error) {
for i := 0; i < len(input); i++ { for i := 0; i < len(input); i++ {
ch := input[i] ch := input[i]
afterAssignSep := tildeOK
tildeOK = false
switch { switch {
case escaped: case escaped:
if quote == '"' && !strings.ContainsRune("$\"\\`", rune(ch)) { if quote == '"' && !strings.ContainsRune("$\"\\`", rune(ch)) {
@@ -190,6 +200,33 @@ func tokenize(input string) ([]token, error) {
case ch == '\\': case ch == '\\':
startRaw(i) startRaw(i)
escaped = true 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 == '"' || assign {
// Quoted or in an assignment, the output is one word
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 == '$': case ch == '$':
startRaw(i) startRaw(i)
value, n, err := expandVar(input[i:]) value, n, err := expandVar(input[i:])
@@ -213,6 +250,7 @@ func tokenize(input string) ([]token, error) {
} }
case ch == '"' || ch == '\'': case ch == '"' || ch == '\'':
startRaw(i) startRaw(i)
plain = false
quote = ch quote = ch
inWord = true inWord = true
case ch == ' ' || ch == '\t' || ch == '\n': case ch == ' ' || ch == '\t' || ch == '\n':
@@ -268,8 +306,8 @@ func tokenize(input string) ([]token, error) {
i = j - 1 i = j - 1
} }
tokens = append(tokens, t) tokens = append(tokens, t)
case ch == '~' && !inWord && b.Len() == 0 && case ch == '~' && (!inWord && b.Len() == 0 || afterAssignSep) &&
(i+1 == len(input) || strings.IndexByte("/ \t\n|<>", input[i+1]) >= 0): (i+1 == len(input) || strings.IndexByte("/ \t\n|<>:", input[i+1]) >= 0):
startRaw(i) startRaw(i)
home := getHomeDir() home := getHomeDir()
if home == "" { if home == "" {
@@ -279,6 +317,14 @@ func tokenize(input string) ([]token, error) {
inWord = true inWord = true
default: default:
startRaw(i) startRaw(i)
switch {
case ch == '=' && plain && !assign && isVarName(b.String()):
// name=value; ~ may follow
assign, tildeOK = true, true
case ch == ':' && assign:
// PATH=~/bin:~/go/bin
tildeOK = true
}
b.WriteByte(ch) b.WriteByte(ch)
switch { switch {
case ch == '*' || ch == '?' || ch == '[': case ch == '*' || ch == '?' || ch == '[':
@@ -612,21 +658,23 @@ func getPrompt() string {
c := parseColors(os.Getenv("GOSH_COLORS")) c := parseColors(os.Getenv("GOSH_COLORS"))
colors = &c 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 // promptColors holds the SGR parameters (e.g. "1;32") for the parts of the
// prompt. An empty value means no color for that part. // prompt. An empty value means no color for that part.
type promptColors struct { type promptColors struct {
user string // user@host user string // user@host
root string // user@host when running as root root string // user@host when running as root
path string // working directory 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 // defaultColors: bold green user, bold red root, bold blue directory, bold
var defaultColors = promptColors{user: "1;32", root: "1;31", path: "1;34"} // 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 // and overrides the corresponding defaults. Unknown keys and values that are
// not plain SGR parameters (digits and ';') are ignored, so the variable // not plain SGR parameters (digits and ';') are ignored, so the variable
// cannot be used to write arbitrary escape sequences to the terminal. // cannot be used to write arbitrary escape sequences to the terminal.
@@ -644,6 +692,8 @@ func parseColors(spec string) promptColors {
colors.root = value colors.root = value
case "path": case "path":
colors.path = value colors.path = value
case "error":
colors.error = value
} }
} }
return colors return colors
@@ -669,20 +719,28 @@ func colorize(text, sgr string) string {
return "\x1b[" + sgr + "m" + text + "\x1b[0m" 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 // before the color codes are added, so only our own escape sequences end up
// in the prompt. colors == nil disables colors. // 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) userHost := sanitize(username + "@" + hostname)
path = sanitize(path) path = sanitize(path)
var failed string
if status != 0 {
failed = " [" + strconv.Itoa(status) + "]"
}
if colors == nil { if colors == nil {
return userHost + ":" + path + " $ " return userHost + ":" + path + failed + " $ "
} }
userColor := colors.user userColor := colors.user
if root { if root {
userColor = colors.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 // useColor reports whether the prompt should be colored: stdout must be a
@@ -702,7 +760,7 @@ type shellCompleter struct {
func newShellCompleter() *shellCompleter { func newShellCompleter() *shellCompleter {
return &shellCompleter{ return &shellCompleter{
commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "source", "jobs", "fg", "bg", "wait", "alias", "unalias", "help"}, commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "source", "jobs", "fg", "bg", "wait", "alias", "unalias", "break", "continue", "unset", "help"},
} }
} }
@@ -716,6 +774,13 @@ func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) {
before := strings.TrimSpace(string(line[:start])) before := strings.TrimSpace(string(line[:start]))
isFirstWord := before == "" || strings.HasSuffix(before, "|") || isFirstWord := before == "" || strings.HasSuffix(before, "|") ||
strings.HasSuffix(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 "~" -> "~/" // Plain "~" -> "~/"
if word == "~" { if word == "~" {
@@ -815,7 +880,7 @@ func currentWord(s string) (word string, start int, quote byte) {
func escapeWord(s string) string { func escapeWord(s string) string {
var b strings.Builder var b strings.Builder
for _, r := range s { for _, r := range s {
if strings.ContainsRune(" \t\\'\";|&<>*?[$", r) { if strings.ContainsRune(" \t\\'\";|&<>*?[${", r) {
b.WriteByte('\\') b.WriteByte('\\')
} }
b.WriteRune(r) b.WriteRune(r)
@@ -1030,28 +1095,56 @@ func sourceFile(cmd, path string) error {
return nil return nil
} }
// runScript executes the commands from r line by line. Like sh, it does not // runScript executes the commands from r. Each command is executed as soon
// stop at a failing command and returns the exit status of the last one. // as it is complete (a control structure can span several lines). Like sh,
// Empty lines and lines starting with # (e.g. a #!/usr/bin/gosh line) are // it does not stop at a failing command and returns the exit status of the
// skipped. If name is set, errors are reported with file name and line. // last one. If name is set, errors are reported with file name and line.
func runScript(r io.Reader, name string) int { func runScript(r io.Reader, name string) int {
status := 0 var (
for lineNo := 1; ; lineNo++ { buf strings.Builder
line, err := readLine(r) startLine int
if trimmed := strings.TrimSpace(line); trimmed != "" && !strings.HasPrefix(trimmed, "#") { )
cmdErr := execInput(line) report := func(err error, line int) {
status = lastStatus if name != "" && err != nil {
if name != "" && cmdErr != nil { err = fmt.Errorf("%s:%d: %w", sanitize(name), line, err)
cmdErr = fmt.Errorf("%s:%d: %w", sanitize(name), lineNo, cmdErr)
}
printError(cmdErr)
} }
if err != nil { printError(err)
if err != io.EOF { }
fmt.Fprintln(os.Stderr, "gosh:", 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 1
} }
return status if buf.Len() > 0 {
lastStatus = 2
report(errors.New("syntax error: unexpected end of file"), startLine)
}
return lastStatus
} }
} }
} }
@@ -1089,10 +1182,16 @@ func exitStatus(err error) int {
if errors.As(err, &statusErr) { if errors.As(err, &statusErr) {
return int(statusErr) return int(statusErr)
} }
var synErr syntaxError var (
if errors.As(err, &synErr) { synErr syntaxError
lc loopControl
)
if errors.As(err, &synErr) || errors.Is(err, errIncomplete) {
return 2 return 2
} }
if errors.As(err, &lc) {
return 0
}
var exitErr *exec.ExitError var exitErr *exec.ExitError
if errors.As(err, &exitErr) { if errors.As(err, &exitErr) {
if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() { if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
@@ -1144,23 +1243,35 @@ func interactive() {
} }
defer l.Close() 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 { for {
// Report finished and stopped background jobs if buf.Len() == 0 {
reapJobs(true) // Report finished and stopped background jobs
reapJobs(true)
// Update prompt dynamically (in case directory changed)
l.SetPrompt(getPrompt())
} else {
l.SetPrompt("> ")
}
// Update prompt dynamically (in case directory changed)
l.SetPrompt(getPrompt())
line, err := l.Readline() line, err := l.Readline()
if err != nil { if err != nil {
// Ctrl+C discards the current line // Ctrl+C discards the current line (and an incomplete command)
if err == readline.ErrInterrupt { if err == readline.ErrInterrupt {
buf.Reset()
continue continue
} }
// Handle EOF (Ctrl+D) // Handle EOF (Ctrl+D)
if err == io.EOF { if err == io.EOF {
fmt.Println() 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 { if hasStoppedJobs() && !exitWarned {
exitWarned = true exitWarned = true
fmt.Fprintln(os.Stderr, "gosh: there are stopped jobs") fmt.Fprintln(os.Stderr, "gosh: there are stopped jobs")
@@ -1173,7 +1284,7 @@ func interactive() {
} }
// Skip empty input // Skip empty input
if strings.TrimSpace(line) == "" { if buf.Len() == 0 && strings.TrimSpace(line) == "" {
continue continue
} }
@@ -1183,8 +1294,19 @@ func interactive() {
l.SaveHistory(line) l.SaveHistory(line)
} }
// Execute the input buf.WriteString(line)
printError(execInput(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())
} }
} }
@@ -1216,149 +1338,44 @@ func printError(err error) {
var ( var (
exitErr *exec.ExitError exitErr *exec.ExitError
statusErr statusError 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 return
} }
fmt.Fprintln(os.Stderr, "gosh:", err) fmt.Fprintln(os.Stderr, "gosh:", err)
} }
// listItem is a command of a command list together with the operator that // execInput parses and executes input: a command line or a whole script.
// connects it to the previous command ("" for the first one). // It returns the error of the last command that was run; errors of earlier
type listItem struct { // commands are printed. $? is updated after each command.
op string // "", ";", "&&" or "||"
command string
background bool // the command ends with &
}
// splitList splits input at unquoted ;, &, && and ||. Quotes, escapes, ${...}
// and comments are handled like in splitWords, but nothing is expanded yet:
// that happens per command when it runs, so "cd /tmp && echo $PWD" works.
func splitList(input string) ([]listItem, error) {
var (
items []listItem
op string
start int
quote byte
escaped bool
wordStart = true // the next character starts a word
)
end := len(input)
add := func(i int, next string) error {
command := input[start:i]
if strings.TrimSpace(command) == "" {
// "a;" is fine, but an operator needs a command on both sides
if next != "" || op == "&&" || op == "||" {
if next == "" {
next = op
}
return syntaxError(next)
}
return nil
}
items = append(items, listItem{op: op, command: command})
return nil
}
scan:
for i := 0; i < len(input); i++ {
ch := input[i]
isWordStart := wordStart
wordStart = false
switch {
case escaped:
escaped = false
case quote == '\'':
if ch == '\'' {
quote = 0
}
case ch == '\\':
escaped = true
case ch == '$' && i+1 < len(input) && input[i+1] == '{':
// Skip ${...} as a whole, like expandVar does
if j := strings.IndexByte(input[i:], '}'); j >= 0 {
i += j
}
case quote == '"':
if ch == '"' {
quote = 0
}
case ch == '"' || ch == '\'':
quote = ch
case ch == ' ' || ch == '\t' || ch == '\n':
wordStart = true
case ch == '#' && isWordStart:
end = i
break scan
case ch == '&' && !(i+1 < len(input) && strings.IndexByte("&>", input[i+1]) >= 0) &&
!(i > 0 && strings.IndexByte("<>", input[i-1]) >= 0):
// A single & (not &&, &>, >& or <&) runs the command in the
// background
if err := add(i, "&"); err != nil {
return nil, err
}
items[len(items)-1].background = true
op = ";"
start = i + 1
wordStart = true
case ch == ';',
ch == '&' && i+1 < len(input) && input[i+1] == '&',
ch == '|' && i+1 < len(input) && input[i+1] == '|':
next := input[i : i+1]
if ch != ';' {
next = input[i : i+2]
}
if err := add(i, next); err != nil {
return nil, err
}
i += len(next) - 1
op = next
start = i + 1
wordStart = true
}
}
// An unterminated quote or a trailing \ is reported by splitWords
if err := add(end, ""); err != nil {
return nil, err
}
return items, nil
}
// execInput executes a line of input: a list of commands separated by ;, &&
// and ||. It returns the error of the last command that was run; errors of
// earlier commands are printed. $? is updated after each command.
func execInput(input string) error { func execInput(input string) error {
if interactiveShell { tree, err := parse(input)
input = expandAliases(input)
}
items, err := splitList(input)
if err != nil { if err != nil {
lastStatus = exitStatus(err) lastStatus = exitStatus(err)
return 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, item.background)
lastStatus = exitStatus(lastErr)
}
return lastErr
} }
// execCommand executes a pipeline of simple commands with redirections, in // execCommand executes a pipeline of simple commands with redirections, in
// the background if background is set. // the background if background is set.
func execCommand(input string, background bool) error { func execCommand(input string, background bool) error {
// Brace expansion comes first, so that its results are expanded further
substStatus = 0
input, err := expandBraceWords(input)
if err != nil {
return err
}
// Split the input into words and operators, expanding variables and ~ on // Split the input into words and operators, expanding variables and ~ on
// the way. // the way.
tokens, err := tokenize(input) tokens, err := tokenize(input)
var synErr syntaxError var (
if errors.As(err, &synErr) { synErr syntaxError
statusErr statusError
)
if errors.As(err, &synErr) || errors.As(err, &statusErr) {
return err return err
} }
if err != nil { if err != nil {
@@ -1369,6 +1386,10 @@ func execCommand(input string, background bool) error {
return err return err
} }
if len(stages) == 0 { if len(stages) == 0 {
// A command that consists only of $(...) has its status
if substStatus != 0 {
return statusError(substStatus)
}
return nil return nil
} }
// "exit" with stopped jobs only warns, unless it is repeated right away // "exit" with stopped jobs only warns, unless it is repeated right away
@@ -1388,6 +1409,7 @@ type redirect struct {
// stage is a simple command of a pipeline. // stage is a simple command of a pipeline.
type stage struct { type stage struct {
assigns []string // name=value before the command name
args []string args []string
redirects []redirect redirects []redirect
} }
@@ -1398,13 +1420,18 @@ func parsePipeline(tokens []token) ([]stage, error) {
stages []stage stages []stage
cur stage cur stage
) )
isEmpty := func(s stage) bool { return len(s.args) == 0 && len(s.redirects) == 0 } isEmpty := func(s stage) bool { return len(s.assigns) == 0 && len(s.args) == 0 && len(s.redirects) == 0 }
for i := 0; i < len(tokens); i++ { for i := 0; i < len(tokens); i++ {
t := tokens[i] t := tokens[i]
switch t.op { switch t.op {
case "": case "":
cur.args = append(cur.args, expandWord(t)...) if t.assign && len(cur.args) == 0 {
// The value is not split and not expanded as a pattern
cur.assigns = append(cur.assigns, t.word)
} else {
cur.args = append(cur.args, expandWord(t)...)
}
case "|": case "|":
if isEmpty(cur) { if isEmpty(cur) {
return nil, syntaxError("|") return nil, syntaxError("|")
@@ -1523,6 +1550,20 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
fds := [3]*os.File{stdin, stdout, os.Stderr} fds := [3]*os.File{stdin, stdout, os.Stderr}
opened, err := applyRedirects(&fds, st.redirects) opened, err := applyRedirects(&fds, st.redirects)
owned = append(owned, opened...) owned = append(owned, opened...)
if err == nil && len(st.args) == 0 && len(st.assigns) > 0 {
// Assignments without a command set variables of the shell (not in
// a pipeline or in the background, where sh runs them in a
// subshell). The status is the one of the last $(...) in them.
if !inPipeline && !background {
for _, a := range st.assigns {
name, value, _ := strings.Cut(a, "=")
os.Setenv(name, value)
}
}
if substStatus != 0 {
err = statusError(substStatus)
}
}
if err != nil || len(st.args) == 0 { if err != nil || len(st.args) == 0 {
// A command without arguments only creates or truncates the files // A command without arguments only creates or truncates the files
closeOwned() closeOwned()
@@ -1549,6 +1590,8 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
return err return err
} }
if !inPipeline && !background { if !inPipeline && !background {
// name=value before a builtin applies only while it runs
defer setTemporarily(st.assigns)()
return finishedProc(run()) return finishedProc(run())
} }
p := &proc{done: make(chan error, 1)} p := &proc{done: make(chan error, 1)}
@@ -1558,6 +1601,11 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
cmd := exec.Command(st.args[0], st.args[1:]...) cmd := exec.Command(st.args[0], st.args[1:]...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = fds[0], fds[1], fds[2] cmd.Stdin, cmd.Stdout, cmd.Stderr = fds[0], fds[1], fds[2]
if len(st.assigns) > 0 {
// name=value before a command is only in its environment (the
// last value of a name wins)
cmd.Env = append(os.Environ(), st.assigns...)
}
if jobControl { if jobControl {
// All processes of the job share a process group: the one of the // All processes of the job share a process group: the one of the
// first process. In the foreground, the first process makes its // first process. In the foreground, the first process makes its
@@ -1590,6 +1638,31 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
return &proc{cmd: cmd} return &proc{cmd: cmd}
} }
// setTemporarily sets the variables of the assignments and returns a
// function that restores the previous values.
func setTemporarily(assigns []string) func() {
type saved struct {
name, value string
ok bool
}
var restore []saved
for _, a := range assigns {
name, value, _ := strings.Cut(a, "=")
old, ok := os.LookupEnv(name)
restore = append(restore, saved{name, old, ok})
os.Setenv(name, value)
}
return func() {
for i := len(restore) - 1; i >= 0; i-- {
if r := restore[i]; r.ok {
os.Setenv(r.name, r.value)
} else {
os.Unsetenv(r.name)
}
}
}
}
// applyRedirects applies the redirections in order to fds (stdin, stdout, // applyRedirects applies the redirections in order to fds (stdin, stdout,
// stderr) and returns the files it opened. // stderr) and returns the files it opened.
func applyRedirects(fds *[3]*os.File, redirects []redirect) ([]*os.File, error) { func applyRedirects(fds *[3]*os.File, redirects []redirect) ([]*os.File, error) {
@@ -1642,21 +1715,25 @@ func applyRedirects(fds *[3]*os.File, redirects []redirect) ([]*os.File, error)
// the shell cannot be used in a pipeline, since there they would run // the shell cannot be used in a pipeline, since there they would run
// concurrently (sh runs them in a subshell where they have no effect). // concurrently (sh runs them in a subshell where they have no effect).
var builtins = map[string]struct{ changesState bool }{ var builtins = map[string]struct{ changesState bool }{
"cd": {changesState: true}, "cd": {changesState: true},
"pwd": {}, "pwd": {},
"exit": {changesState: true}, "exit": {changesState: true},
"quit": {changesState: true}, "quit": {changesState: true},
"set": {changesState: true}, "set": {changesState: true},
"export": {changesState: true}, "export": {changesState: true},
"help": {}, "help": {},
"source": {changesState: true}, "source": {changesState: true},
".": {changesState: true}, ".": {changesState: true},
"jobs": {}, "jobs": {},
"fg": {changesState: true}, "fg": {changesState: true},
"bg": {changesState: true}, "bg": {changesState: true},
"wait": {changesState: true}, "wait": {changesState: true},
"alias": {}, "alias": {},
"unalias": {changesState: true}, "unalias": {changesState: true},
":": {},
"unset": {changesState: true},
"break": {changesState: true},
"continue": {changesState: true},
} }
// runBuiltin executes a built-in command, writing its output to stdout. // runBuiltin executes a built-in command, writing its output to stdout.
@@ -1736,6 +1813,25 @@ func runBuiltin(args []string, stdout io.Writer) error {
} }
os.Setenv(parts[0], parts[1]) os.Setenv(parts[0], parts[1])
return nil return nil
case ":":
// Does nothing, successfully (e.g. while :; do ...)
return nil
case "unset":
// unset NAME ... - remove variables
if len(args) < 2 {
return fmt.Errorf("usage: unset NAME ...")
}
var err error
for _, name := range args[1:] {
if !isVarName(name) {
err = fmt.Errorf("unset: `%s': not a valid identifier", sanitize(name))
continue
}
os.Unsetenv(name)
}
return err
case "break", "continue":
return builtinLoopControl(args)
case "alias": case "alias":
return builtinAlias(args, stdout) return builtinAlias(args, stdout)
case "unalias": case "unalias":
@@ -1765,6 +1861,7 @@ Built-in commands:
quit [code] - Quit shell with status code (default: $?) quit [code] - Quit shell with status code (default: $?)
set VAR=val - Set environment variable set VAR=val - Set environment variable
export VAR=val - Export environment variable export VAR=val - Export environment variable
unset VAR - Remove a variable
source file - Run the commands of file in this shell (also: . file) source file - Run the commands of file in this shell (also: . file)
jobs - List background and stopped jobs jobs - List background and stopped jobs
fg [%n] - Continue job n (default: current job) in the foreground fg [%n] - Continue job n (default: current job) in the foreground
@@ -1772,6 +1869,9 @@ Built-in commands:
wait [%n] - Wait for job n or all background jobs wait [%n] - Wait for job n or all background jobs
alias [name[=value] ...] - Define or show aliases (alias ll='ls -l') alias [name[=value] ...] - Define or show aliases (alias ll='ls -l')
unalias name - Remove an alias (unalias -a: all aliases) unalias name - Remove an alias (unalias -a: all aliases)
: - Do nothing (exit status 0)
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 help - Show this help
Features: Features:
@@ -1783,17 +1883,30 @@ Features:
override with $GOSH_HISTFILE); lines starting with a space are not saved override with $GOSH_HISTFILE); lines starting with a space are not saved
- Tab completion for commands and file paths - Tab completion for commands and file paths
- Tilde expansion (~, ~/path) at the start of unquoted words - 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 - Wildcards: * ? [abc] [a-z] [!abc] match file names (not hidden files
unless the pattern starts with a dot); quoted wildcards and values of unless the pattern starts with a dot); quoted wildcards and values of
variables are not expanded, a pattern without match is kept as is variables are not expanded, a pattern without match is kept as is
- Colored prompt (user@host green, red for root; directory blue), - Prompt user@host:dir $, with [N] before the $ if the last command
configurable with $GOSH_COLORS, e.g. GOSH_COLORS='user=1;33:path=36' failed with exit status N
(keys user, root, path; values are ANSI SGR codes, empty = no color); - Colored prompt (user@host green, red for root; directory blue; exit
disabled with $NO_COLOR, TERM=dumb or when output is not a terminal 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
- Variables: name=value sets a variable (like set), name=value command
sets it only for that command; the value is not split or expanded as
a pattern
- Environment variable expansion ($VAR, ${VAR}, ${VAR:-default}); - Environment variable expansion ($VAR, ${VAR}, ${VAR:-default});
using an unset variable is an error, use ${VAR:-} for an empty default using an unset variable is an error, use ${VAR:-} for an empty default
- 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), - 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 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, - Pipes (a | b) and redirections: < file, > file, >> file, 2> file,
2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file 2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file
- Job control: command & runs in the background, Ctrl+Z stops the - Job control: command & runs in the background, Ctrl+Z stops the
+208 -60
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"bytes" "bytes"
"errors" "errors"
"fmt"
"io" "io"
"os" "os"
"os/exec" "os/exec"
@@ -169,12 +170,12 @@ func TestExecInput_CdNoArgs(t *testing.T) {
// Save current directory // Save current directory
origDir, _ := os.Getwd() origDir, _ := os.Getwd()
defer os.Chdir(origDir) defer os.Chdir(origDir)
err := execInput("cd") err := execInput("cd")
if err != nil { if err != nil {
t.Errorf("cd with no args should go to home, got error: %v", err) t.Errorf("cd with no args should go to home, got error: %v", err)
} }
// Verify we changed to home directory // Verify we changed to home directory
home := getHomeDir() home := getHomeDir()
newDir, _ := os.Getwd() newDir, _ := os.Getwd()
@@ -288,7 +289,7 @@ func TestShell_EOF_AfterCommand(t *testing.T) {
if err != nil { if err != nil {
t.Logf("Shell exited with: %v", err) t.Logf("Shell exited with: %v", err)
} }
output := stdout.String() output := stdout.String()
// Should contain the echo output // Should contain the echo output
if !strings.Contains(output, "test") { if !strings.Contains(output, "test") {
@@ -312,7 +313,7 @@ func TestShell_SIGINT_HandlerSetup(t *testing.T) {
if err != nil { if err != nil {
t.Logf("Shell exited with: %v, stderr: %s", err, stderr.String()) t.Logf("Shell exited with: %v, stderr: %s", err, stderr.String())
} }
output := stdout.String() output := stdout.String()
if !strings.Contains(output, "test") { if !strings.Contains(output, "test") {
t.Errorf("expected 'test' in output, got: %s", output) t.Errorf("expected 'test' in output, got: %s", output)
@@ -332,7 +333,7 @@ func TestExecInput_CdToHomeWithTilde(t *testing.T) {
if err != nil { if err != nil {
t.Errorf("cd ~ failed: %v", err) t.Errorf("cd ~ failed: %v", err)
} }
home := getHomeDir() home := getHomeDir()
newDir, _ := os.Getwd() newDir, _ := os.Getwd()
if newDir != home { if newDir != home {
@@ -363,13 +364,13 @@ func TestExecInput_CdDash(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("cd to tmpDir failed: %v", err) t.Fatalf("cd to tmpDir failed: %v", err)
} }
// Now cd - should go back // Now cd - should go back
err = execInput("cd -") err = execInput("cd -")
if err != nil { if err != nil {
t.Fatalf("cd - failed: %v", err) t.Fatalf("cd - failed: %v", err)
} }
// Verify we're back in origDir // Verify we're back in origDir
newDir, _ := os.Getwd() newDir, _ := os.Getwd()
if newDir != origDir { if newDir != origDir {
@@ -382,7 +383,7 @@ func TestExecInput_CdDashNoPrevious(t *testing.T) {
origPrevDir := previousDir origPrevDir := previousDir
defer func() { previousDir = origPrevDir }() defer func() { previousDir = origPrevDir }()
previousDir = "" previousDir = ""
// cd - with no previous directory should return an error // cd - with no previous directory should return an error
err := execInput("cd -") err := execInput("cd -")
if err == nil { if err == nil {
@@ -399,7 +400,7 @@ func TestExecInput_CdDashNoPrevious(t *testing.T) {
func TestGetPrompt_Format(t *testing.T) { func TestGetPrompt_Format(t *testing.T) {
prompt := getPrompt() prompt := getPrompt()
// Should contain username@hostname:path $ pattern // Should contain username@hostname:path $ pattern
if !strings.Contains(prompt, "@") { if !strings.Contains(prompt, "@") {
t.Errorf("expected @ in prompt, got: %s", prompt) t.Errorf("expected @ in prompt, got: %s", prompt)
@@ -416,10 +417,10 @@ func TestGetPrompt_IncludesHome(t *testing.T) {
// Save and restore current directory // Save and restore current directory
origDir, _ := os.Getwd() origDir, _ := os.Getwd()
defer os.Chdir(origDir) defer os.Chdir(origDir)
// Change to home directory // Change to home directory
os.Chdir(getHomeDir()) os.Chdir(getHomeDir())
prompt := getPrompt() prompt := getPrompt()
// When in home, should show ~ // When in home, should show ~
if !strings.Contains(prompt, "~") { if !strings.Contains(prompt, "~") {
@@ -435,7 +436,7 @@ func TestExecInput_ExitWithStatusCode(t *testing.T) {
// We can't easily test os.Exit from a unit test // We can't easily test os.Exit from a unit test
// So we just verify the function doesn't panic with a status code // So we just verify the function doesn't panic with a status code
// The actual exit behavior is tested in integration tests // The actual exit behavior is tested in integration tests
// This test just documents the expected behavior // This test just documents the expected behavior
// Actual testing requires subprocess // Actual testing requires subprocess
_ = true // Placeholder - integration tests handle this _ = true // Placeholder - integration tests handle this
@@ -456,19 +457,19 @@ func TestShell_ExitWithStatusCode(t *testing.T) {
{"quit 0\n", 0}, {"quit 0\n", 0},
{"quit 5\n", 5}, {"quit 5\n", 5},
} }
// Build the binary once // Build the binary once
build := exec.Command("go", "build", "-o", "gosh_test", ".") build := exec.Command("go", "build", "-o", "gosh_test", ".")
if err := build.Run(); err != nil { if err := build.Run(); err != nil {
t.Fatalf("failed to build: %v", err) t.Fatalf("failed to build: %v", err)
} }
defer os.Remove("gosh_test") defer os.Remove("gosh_test")
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) { t.Run(tc.input, func(t *testing.T) {
cmd := exec.Command("./gosh_test") cmd := exec.Command("./gosh_test")
cmd.Stdin = strings.NewReader(tc.input) cmd.Stdin = strings.NewReader(tc.input)
err := cmd.Run() err := cmd.Run()
if err == nil && tc.expected != 0 { if err == nil && tc.expected != 0 {
t.Errorf("expected non-zero exit code %d, got 0", tc.expected) t.Errorf("expected non-zero exit code %d, got 0", tc.expected)
@@ -597,13 +598,13 @@ func TestExecInput_SetEnvVar(t *testing.T) {
// Save original value // Save original value
orig := os.Getenv("GOSH_TEST_VAR") orig := os.Getenv("GOSH_TEST_VAR")
defer os.Setenv("GOSH_TEST_VAR", orig) defer os.Setenv("GOSH_TEST_VAR", orig)
// Set a new variable // Set a new variable
err := execInput("set GOSH_TEST_VAR=test_value") err := execInput("set GOSH_TEST_VAR=test_value")
if err != nil { if err != nil {
t.Errorf("set failed: %v", err) t.Errorf("set failed: %v", err)
} }
// Verify it was set // Verify it was set
val := os.Getenv("GOSH_TEST_VAR") val := os.Getenv("GOSH_TEST_VAR")
if val != "test_value" { if val != "test_value" {
@@ -615,13 +616,13 @@ func TestExecInput_ExportEnvVar(t *testing.T) {
// Save original value // Save original value
orig := os.Getenv("GOSH_TEST_VAR2") orig := os.Getenv("GOSH_TEST_VAR2")
defer os.Setenv("GOSH_TEST_VAR2", orig) defer os.Setenv("GOSH_TEST_VAR2", orig)
// Export a new variable // Export a new variable
err := execInput("export GOSH_TEST_VAR2=exported_value") err := execInput("export GOSH_TEST_VAR2=exported_value")
if err != nil { if err != nil {
t.Errorf("export failed: %v", err) t.Errorf("export failed: %v", err)
} }
// Verify it was set // Verify it was set
val := os.Getenv("GOSH_TEST_VAR2") val := os.Getenv("GOSH_TEST_VAR2")
if val != "exported_value" { if val != "exported_value" {
@@ -841,7 +842,7 @@ func TestShellCompleter_Do(t *testing.T) {
want []string want []string
length int length int
}{ }{
{"c", []string{"d "}, 1}, {"c", []string{"d ", "ontinue "}, 1},
{"e", []string{"cho ", "xit ", "xport "}, 1}, {"e", []string{"cho ", "xit ", "xport "}, 1},
{"gi", []string{"tty "}, 2}, {"gi", []string{"tty "}, 2},
{" gi", []string{"tty "}, 2}, {" gi", []string{"tty "}, 2},
@@ -892,6 +893,11 @@ func TestMain(m *testing.M) {
// Never read the system's startup files; tests that read the user's // Never read the system's startup files; tests that read the user's
// files set $HOME themselves // files set $HOME themselves
sysConfDir = filepath.Join(dir, "etc") 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() code := m.Run()
os.RemoveAll(dir) os.RemoveAll(dir)
os.Exit(code) os.Exit(code)
@@ -1054,24 +1060,24 @@ func TestGetPrompt_EscapesControlCharacters(t *testing.T) {
} }
func TestBuildPrompt_Color(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 $ " want := "\x1b[1;32mflow@host\x1b[0m:\x1b[1;34m~/src\x1b[0m $ "
if got != want { if got != want {
t.Errorf("buildPrompt() = %q, want %q", 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") { if !strings.HasPrefix(got, "\x1b[1;31mroot@host") {
t.Errorf("expected red user for root, got %q", got) 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 $ " { if got != "flow@host:~/src $ " {
t.Errorf("buildPrompt() without color = %q", got) t.Errorf("buildPrompt() without color = %q", got)
} }
colors := promptColors{user: "38;5;208", path: ""} 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 $ " { if got != "\x1b[38;5;208mflow@host\x1b[0m:~/src $ " {
t.Errorf("buildPrompt() with custom colors = %q", got) t.Errorf("buildPrompt() with custom colors = %q", got)
} }
@@ -1083,13 +1089,14 @@ func TestParseColors(t *testing.T) {
want promptColors want promptColors
}{ }{
{"", defaultColors}, {"", defaultColors},
{"user=1;33", promptColors{user: "1;33", root: "1;31", path: "1;34"}}, {"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"}}, {"user=33:root=41;97:path=38;5;208", promptColors{user: "33", root: "41;97", path: "38;5;208", error: "1;31"}},
{"path=", promptColors{user: "1;32", root: "1;31", path: ""}}, {"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=0m\x1b[2J", defaultColors}, // escape sequences are rejected
{"path=red", defaultColors}, // only numeric codes {"path=red", defaultColors}, // only numeric codes
{"foo=1:user", defaultColors}, // unknown key, missing value {"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 { for _, tc := range tests {
if got := parseColors(tc.spec); got != tc.want { if got := parseColors(tc.spec); got != tc.want {
@@ -1098,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) { 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") { if strings.Contains(got, "\x1b[2J") || strings.Contains(got, "\x07") {
t.Errorf("prompt contains control characters from path: %q", got) t.Errorf("prompt contains control characters from path: %q", got)
} }
@@ -1361,48 +1387,170 @@ func TestReadLine_DoesNotReadAhead(t *testing.T) {
} }
} }
func TestSplitList(t *testing.T) { // treeString formats a syntax tree for the tests: simple commands in
// brackets, background commands with a trailing &.
func treeString(l *listNode) string {
var parts []string
for _, e := range l.items {
s := pipelineString(e.andOr.first)
for _, r := range e.andOr.rest {
s += " " + r.op + " " + pipelineString(r.pipeline)
}
if e.background {
s += " &"
}
parts = append(parts, s)
}
return strings.Join(parts, "; ")
}
func pipelineString(pl *pipelineNode) string {
s := ""
if pl.negate {
s = "! "
}
switch n := pl.cmd.(type) {
case *simpleNode:
s += "[" + n.text + "]"
case *ifNode:
s += "if"
for i := range n.conds {
if i > 0 {
s += " elif"
}
s += " " + treeString(n.conds[i]) + " then " + treeString(n.bodies[i])
}
if n.elseBody != nil {
s += " else " + treeString(n.elseBody)
}
s += " fi"
case *forNode:
s += "for " + n.name
if n.hasIn {
s += " in (" + n.words + ")"
}
s += " do " + treeString(n.body) + " done"
case *whileNode:
if n.until {
s += "until "
} else {
s += "while "
}
s += treeString(n.cond) + " do " + treeString(n.body) + " done"
}
return s
}
func TestParse_Lists(t *testing.T) {
tests := []struct { tests := []struct {
input string input string
want []listItem want string
}{ }{
{"", nil}, {"", ""},
{" ", nil}, {" ", ""},
{"# only a comment", nil}, {"# only a comment", ""},
{"ls", []listItem{{"", "ls", false}}}, {"ls", "[ls]"},
{"a; b", []listItem{{"", "a", false}, {";", " b", false}}}, {"a; b", "[a]; [b]"},
{"a;", []listItem{{"", "a", false}}}, {"a;", "[a]"},
{"a && b || c", []listItem{{"", "a ", false}, {"&&", " b ", false}, {"||", " c", false}}}, {"a\nb\n", "[a]; [b]"},
{`echo "a;b" 'c&&d' e\;f`, []listItem{{"", `echo "a;b" 'c&&d' e\;f`, false}}}, {"a && b || c", "[a] && [b] || [c]"},
{`echo "x\"; y"`, []listItem{{"", `echo "x\"; y"`, false}}}, {"a &&\n b", "[a] && [b]"},
{"echo ${X:-a;b}; c", []listItem{{"", "echo ${X:-a;b}", false}, {";", " c", false}}}, {"a | b|c", "[a | b|c]"},
{"echo a # b; c", []listItem{{"", "echo a ", false}}}, {"a |\n b", "[a |\n b]"},
{"echo a#b; c", []listItem{{"", "echo a#b", false}, {";", " c", false}}}, {`echo "a;b" 'c&&d' e\;f`, `[echo "a;b" 'c&&d' e\;f]`},
{"echo a|b", []listItem{{"", "echo a|b", false}}}, {`echo "x\"; y"`, `[echo "x\"; y"]`},
{"a & b", []listItem{{"", "a ", true}, {";", " b", false}}}, {"echo ${X:-a;b}; c", "[echo ${X:-a;b}]; [c]"},
{"a &", []listItem{{"", "a ", true}}}, {"echo a # b; c", "[echo a]"},
{"a&", []listItem{{"", "a", true}}}, {"echo a#b; c", "[echo a#b]; [c]"},
{"a 2>&1 &", []listItem{{"", "a 2>&1 ", true}}}, {"a & b", "[a] &; [b]"},
{"a &> f &", []listItem{{"", "a &> f ", true}}}, {"a&", "[a] &"},
{"a >&2 && b", []listItem{{"", "a >&2 ", false}, {"&&", " b", false}}}, {"a 2>&1 &", "[a 2>&1] &"},
{`echo "a & b" \&`, []listItem{{"", `echo "a & b" \&`, false}}}, {"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 { for _, tc := range tests {
got, err := splitList(tc.input) tree, err := parse(tc.input)
if err != nil { if err != nil {
t.Errorf("splitList(%q) returned error: %v", tc.input, err) t.Errorf("parse(%q) returned error: %v", tc.input, err)
continue continue
} }
if !reflect.DeepEqual(got, tc.want) { if got := treeString(tree); got != tc.want {
t.Errorf("splitList(%q) = %+v, want %+v", tc.input, got, tc.want) t.Errorf("parse(%q) = %s, want %s", tc.input, got, tc.want)
} }
} }
} }
func TestSplitList_SyntaxErrors(t *testing.T) { func TestParse_ControlStructures(t *testing.T) {
for _, input := range []string{"; a", "a ;; b", "&& a", "a &&", "a ||", "a && ; b", "a || && b", "& a", "a & && b", "a & & b"} { tests := []struct {
if _, err := splitList(input); err == nil || !strings.Contains(err.Error(), "syntax error") { input string
t.Errorf("splitList(%q) = %v, want syntax error", input, err) 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)
} }
} }
} }
@@ -1526,7 +1674,7 @@ func TestTokenize_Operators(t *testing.T) {
} }
// Patterns are tested in TestTokenize_Patterns // Patterns are tested in TestTokenize_Patterns
for i := range got { for i := range got {
got[i].pattern, got[i].glob = "", false got[i].pattern, got[i].glob, got[i].assign = "", false, false
} }
if !reflect.DeepEqual(got, tc.want) { if !reflect.DeepEqual(got, tc.want) {
t.Errorf("tokenize(%q) = %+v, want %+v", tc.input, got, tc.want) t.Errorf("tokenize(%q) = %+v, want %+v", tc.input, got, tc.want)
+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)
}
}
}