Compare commits

...
6 Commits
Author SHA1 Message Date
Florian WaltherandClaude Opus 5.5 443367f66f add functions, positional parameters, case and read
- functions: name() { ...; }, function name { ...; }, return, local,
  unset -f; in pipes, & and $(...) they run in a subshell that gets all
  function definitions and the positional parameters
- positional parameters $0, $1 ... ${10}, $#, "$@", "$*", $$; set by
  gosh script args, gosh -c command name args, function calls,
  source file args and set --; shift; for x without "in"
- case word in pattern|pattern) ...;; esac and { list; } groups
- read [-r] [-p prompt] [name ...] with $IFS splitting and $REPLY

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:20:59 +02:00
Florian WaltherandClaude Opus 5.5 9fe8b06fba add man page gosh(1) and include it in the deb/rpm packages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:59:09 +02:00
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
16 changed files with 3989 additions and 268 deletions

No files matched your search

+1
View File
@@ -1,2 +1,3 @@
gosh
dist/
packaging/man/gosh.1.gz
+10
View File
@@ -12,6 +12,8 @@ before:
hooks:
- go mod tidy
- go test ./...
# Man page with the release version, gzipped for /usr/share/man
- sh -c 'sed "s/@VERSION@/{{ .Version }}/" packaging/man/gosh.1 | gzip -9n > packaging/man/gosh.1.gz'
builds:
- id: gosh
@@ -50,6 +52,10 @@ archives:
- src: README.md
info:
mode: 0644
- src: packaging/man/gosh.1.gz
dst: man/gosh.1.gz
info:
mode: 0644
nfpms:
- id: gosh
@@ -75,6 +81,10 @@ nfpms:
dst: /usr/share/doc/gosh/README.md
file_info:
mode: 0644
- src: packaging/man/gosh.1.gz
dst: /usr/share/man/man1/gosh.1.gz
file_info:
mode: 0644
scripts:
postinstall: packaging/scripts/postinstall.sh
postremove: packaging/scripts/postremove.sh
+224 -8
View File
@@ -1,8 +1,8 @@
`gosh` eine einfache Shell, geschrieben in golang.
`gosh` eine Shell, in golang geschrieben.
```
gosh - Go Shell
Version: 0.2.1 (branch: main, commit: 03e791c)
Version: 0.13.0 (branch: main, commit: f79fb0b)
Built-in commands:
cd [dir] - Change directory (cd with no args goes to home)
@@ -11,14 +11,25 @@ Built-in commands:
exit [code] - Exit shell with status code (default: $?)
quit [code] - Quit shell with status code (default: $?)
set VAR=val - Set environment variable
set -- args - Set the positional parameters $1, $2, ...
export VAR=val - Export environment variable
source file - Run the commands of file in this shell (also: . file)
unset VAR - Remove a variable (unset -f name: a function)
source file [args] - Run the commands of file in this shell (also: . file)
jobs - List background and stopped jobs
fg [%n] - Continue job n (default: current job) in the foreground
bg [%n] - Continue stopped job n in the background
wait [%n] - Wait for job n or all background jobs
alias [name[=value] ...] - Define or show aliases (alias ll='ls -l')
unalias name - Remove an alias (unalias -a: all aliases)
: - Do nothing (exit status 0)
break [n] - Leave the innermost (or n-th) for/while/until loop
continue [n] - Continue with the next iteration of a loop
return [n] - Leave a function or sourced file with status n
local VAR[=val] - Variable that gets its old value back when the
function returns
shift [n] - Remove the first n positional parameters (default 1)
read [-r] [-p prompt] [VAR ...] - Read a line into variables (split
at $IFS; the last one gets the rest; default $REPLY)
help - Show this help
Features:
@@ -41,10 +52,25 @@ Features:
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});
using an unset variable is an error, use ${VAR:-} for an empty default
- Positional parameters: $0, $1 ... $9, ${10}, $# (count), "$@" (one
word each), "$*" (one word), $$ (process ID); set by gosh script
args, gosh -c command name args, functions, set -- and source
- Command substitution: $(command) is replaced by the output of command
(run in a subshell); unquoted it is split into words, "$(command)"
stays one word
- Command lists: a ; b (run both), a && b (b only if a succeeded),
a || b (b only if a failed); $? is the exit status of the last command
- Control structures: if cmd; then ...; elif cmd; then ...; else ...; fi,
for x in words; do ...; done, while cmd; do ...; done, until ...,
case word in pattern|pattern) ...;; esac, { cmd; cmd; } groups;
! cmd negates the status; they can span several lines (prompt "> ")
- Functions: name() { ...; } (or function name { ...; }); the arguments
are $1, $2, ...; in pipes, & and $(...) they run in a subshell
- Pipes (a | b) and redirections: < file, > file, >> file, 2> file,
2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file
- Job control: command & runs in the background, Ctrl+Z stops the
@@ -62,8 +88,8 @@ Features:
### Aufruf
```
gosh [-l] [-i] [--norc] [--noprofile] [script]
gosh [-l] [--noprofile] -c command
gosh [-l] [-i] [--norc] [--noprofile] [script [args ...]]
gosh [-l] [--noprofile] -c command [name [args ...]]
gosh -version
```
@@ -71,7 +97,8 @@ gosh -version
|------------------------|------------------------------------------------------------------|
| `gosh` | interaktive Shell (wenn stdin ein Terminal ist) |
| `gosh -c 'befehl'` | führt den Befehl aus und beendet sich |
| `gosh skript.gosh` | führt das Skript Zeile für Zeile aus |
| `gosh -c 'befehl' name a b` | wie oben, mit `$0` = `name`, `$1` = `a`, `$2` = `b` |
| `gosh skript.gosh a b` | führt das Skript Zeile für Zeile aus, `$1` = `a`, `$2` = `b` |
| `befehle \| gosh` | liest die Befehle von stdin |
| `-i` | erzwingt den interaktiven Modus, auch ohne Terminal |
| `-l`, `--login` | Login-Shell, liest die Profil-Dateien (siehe unten) |
@@ -122,6 +149,195 @@ export LANG=de_DE.UTF-8
export PATH=$HOME/bin:$PATH
```
### Kontrollstrukturen
gosh kennt `if`, `for`, `while`, `until`, `case` und `{ …; }` wie `sh`, auf einer Zeile oder über mehrere Zeilen:
```bash
if [ -f ~/.goshrc ]; then
echo "rc vorhanden"
elif [ -d ~/.config ]; then
echo "nur .config"
else
echo "nichts"
fi
for f in *.log; do
gzip "$f"
done
for i in {1..5}; do echo "Durchlauf $i"; done
n=3
while [ $n -gt 0 ]; do
echo $n
n=$(expr $n - 1)
done
until ping -c1 -W1 server >/dev/null; do sleep 1; done
case "$1" in
start|run) echo "starte" ;;
stop) echo "stoppe" ;;
*.tar.gz) tar xzf "$1" ;;
*) echo "unbekannt: $1" ;;
esac
test -d build || { echo "kein build-Verzeichnis"; exit 1; }
```
| Konstrukt | Bedeutung |
|---------------------------------------------|-------------------------------------------------------------------|
| `if befehl; then …; elif …; then …; else …; fi` | führt den Zweig aus, dessen Bedingung den Exit-Status 0 hat |
| `for x in wörter; do …; done` | setzt `x` nacheinander auf jedes Wort |
| `while befehl; do …; done` | wiederholt, solange `befehl` erfolgreich ist |
| `until befehl; do …; done` | wiederholt, bis `befehl` erfolgreich ist |
| `case wort in muster\|muster) …;; esac` | führt den ersten Zweig aus, dessen Muster auf `wort` passt |
| `{ befehl; befehl; }` | fasst Befehle zusammen (z. B. nach `\|\|`) |
| `! befehl` | kehrt den Exit-Status um |
| `break [n]`, `continue [n]` | verlässt die (n-te) Schleife bzw. springt zum nächsten Durchlauf |
| `:` | tut nichts, Exit-Status 0 (z. B. `while :; do …; done`) |
- Bedingungen sind normale Befehle (auch Listen mit `&&`/`||`), meist `test` bzw. `[ … ]` (z. B. `[ -f datei ]`, `[ "$a" = b ]`, `[ $n -lt 3 ]`).
- Die Wörter nach `for … in` werden wie Argumente expandiert: `*.go`, `{1..10}`, `$VAR`, `$(befehl)`, `"$@"`. Ohne `in` läuft die Schleife über die Positionsparameter (`for a; do …` ist `for a in "$@"; do …`).
- `case`: Die Muster sind Wildcards wie bei Dateinamen (`*`, `?`, `[…]`), nur passen `*` und `?` hier auch auf `/`. Mehrere Muster werden mit `|` getrennt, das `(` vor dem Muster ist optional, das `;;` im letzten Zweig auch. Zeichen in Anführungszeichen passen nur wörtlich (`"*")` passt nur auf `*`). Wie überall in gosh sind Werte von Variablen keine Muster: Nach `p='*.go'` passt `$p)` nur auf den Text `*.go`. Passt kein Muster, ist der Status 0.
- `{ …; }`: `{` und `}` sind nur am Anfang eines Befehls Schlüsselwörter, vor dem `}` muss deshalb ein `;` oder ein Zeilenumbruch stehen. Die Befehle laufen in der Shell selbst (keine Subshell).
- Variablen, `$(...)` und Wildcards im Rumpf werden bei jedem Durchlauf neu ersetzt.
- Ist ein Befehl noch nicht vollständig (z. B. nach `if …; then` oder `for …` + Enter, nach `&&`, `|` oder einem `\` am Zeilenende), zeigt gosh den Fortsetzungs-Prompt `> `; Ctrl+C verwirft die angefangene Eingabe. Skripte und Startdateien lesen entsprechend weiter.
- Ctrl+C bricht die ganze Befehlszeile ab, also auch eine Schleife (`while true; do sleep 1; done`).
- Der Exit-Status ist der des zuletzt ausgeführten Befehls; ein `if` ohne passenden Zweig und eine Schleife ohne Durchlauf haben den Status 0.
- Nicht unterstützt: Kontrollstrukturen in Pipes (`for …; done | sort`, `… | while read line`), mit Umleitung (`done > datei`, `done < datei`) oder im Hintergrund (`done &`), außerdem `( … )`-Subshells. Funktionen lassen sich dagegen in Pipes und mit Umleitungen verwenden (siehe unten), damit lässt sich das oft umgehen. Arithmetik geht z. B. mit `n=$(expr $n + 1)`.
### Funktionen
```bash
# Definition (auch: function gruss { … })
gruss() {
local name=${1:-Welt} # local: nur in der Funktion
echo "Hallo $name"
}
gruss # Hallo Welt
gruss Flo # Hallo Flo
gruss Flo > datei # Umleitungen gelten für alle Befehle der Funktion
gruss Flo | tr a-z A-Z # auch in Pipes
x=$(gruss Flo)
ist_root() {
[ "$(id -u)" = 0 ] && return 0
return 1
}
ist_root || echo "kein root"
# Zeilen einer Datei verarbeiten: die Funktion liest von stdin
zeilen() {
while read -r zeile; do echo "> $zeile"; done
}
zeilen < /etc/hostname
```
- Eine Funktion wird mit `name() { …; }`, `name () { …; }` oder `function name { …; }` definiert. Der Rumpf ist ein zusammengesetzter Befehl, meist `{ …; }`, aber auch `if`, `for`, `while` oder `case` sind möglich. Die Definition darf über mehrere Zeilen gehen.
- Die Argumente des Aufrufs sind in der Funktion `$1`, `$2`, …, `$#` und `"$@"`; danach haben die Positionsparameter wieder ihre alten Werte. `$0` bleibt der Name des Skripts.
- `return [n]` beendet die Funktion mit dem Status `n` (ohne `n`: der Status des letzten Befehls). Ohne `return` ist der Status der des letzten Befehls.
- Funktionen laufen in der Shell selbst: Variablen, die sie setzen, und `cd` gelten danach weiter. `local name[=wert]` macht eine Variable lokal, sie bekommt beim Verlassen der Funktion ihren alten Wert zurück (Funktionen, die von dort aufgerufen werden, sehen den lokalen Wert).
- `name=wert funktion` setzt die Variable nur während des Aufrufs.
- In einer Pipeline, im Hintergrund (`funktion &`) und in `$(...)` läuft eine Funktion in einer Subshell, Änderungen an Variablen gehen dort also verloren (wie in `sh`). Die Subshell kennt alle Funktionen.
- Eine Funktion hat Vorrang vor Built-ins und Programmen gleichen Namens. Weil es noch kein `command` gibt, kann eine Funktion `ls` das Programm `ls` aber nicht aufrufen. Mit `\ls` umgeht man nur Aliase. Besser ist ein anderer Name, z. B. `ll() { ls -l "$@"; }`.
- `unset -f name` entfernt eine Funktion. Rekursion ist erlaubt, aber nur bis zu einer Tiefe von 1000.
- Namen dürfen aus Buchstaben, Ziffern, `_`, `-` und `.` bestehen (nicht mit einer Ziffer oder `-` beginnend). Funktionen werden auch mit Tab vervollständigt.
### Positionsparameter
| Parameter | Bedeutung |
|--------------------|------------------------------------------------------------------|
| `$0` | Name des Skripts (bzw. `name` bei `gosh -c befehl name`) |
| `$1` … `$9`, `${10}` | die Argumente |
| `$#` | Anzahl der Argumente |
| `"$@"` | alle Argumente, jedes als eigenes Wort |
| `"$*"` | alle Argumente als ein Wort, durch Leerzeichen getrennt |
| `$$` | Prozess-ID der Shell |
```bash
#!/usr/bin/gosh
# aufruf: skript.gosh [-v] datei ...
if [ $# -eq 0 ]; then
echo "usage: $0 [-v] datei ..." >&2
exit 2
fi
verbose=no
case $1 in
-v) verbose=yes; shift ;;
esac
for f in "$@"; do
test $verbose = yes && echo "verarbeite $f"
wc -l "$f"
done
```
- Gesetzt werden sie von `gosh skript a b`, `gosh -c befehl name a b`, beim Aufruf einer Funktion, mit `source datei a b` (nur während die Datei läuft) und mit `set -- a b c`.
- `shift [n]` entfernt die ersten `n` (Standard: 1) Parameter, `$2` wird zu `$1` usw.
- Wie bei Variablen ist ein nicht gesetzter Parameter ein Fehler: `$1` ohne Argumente meldet `1: unbound variable`. Für optionale Argumente `${1:-}` bzw. `${1:-standard}` verwenden oder vorher `$#` prüfen.
- `"$@"` ohne Argumente ergibt kein Wort (auch nicht das leere). Ohne Anführungszeichen verhalten sich `$@` und `$*` wie `"$@"`, da gosh Werte nie an Leerzeichen teilt.
### read
`read` liest eine Zeile von stdin und verteilt sie auf Variablen:
```bash
read -p "Name? " name # Eingabe vom Terminal mit Prompt
read vorname nachname < name.txt # erste Zeile einer Datei
read -r zeile # Backslashes nicht auswerten
while read -r zeile; do echo "$zeile"; done # in einem Skript: alle Zeilen von stdin
```
- `read name1 name2 …` teilt die Zeile an den Zeichen von `$IFS` (Standard: Leerzeichen, Tab, Zeilenumbruch). Jede Variable bekommt ein Feld, die letzte den Rest der Zeile. Bei zu wenigen Feldern werden die übrigen Variablen leer. Leerzeichen am Anfang und Ende werden entfernt. Andere `IFS`-Zeichen trennen genau ein Feld (`IFS=:` macht aus `a::b` die Felder `a`, ``, `b`).
- Ohne Namen landet die ganze Zeile unverändert in `$REPLY`.
- Ohne `-r` maskiert `\` das nächste Zeichen (`a\ b` bleibt ein Feld), und ein `\` am Zeilenende setzt die Zeile fort. `-p prompt` gibt den Prompt auf stderr aus, wenn stdin ein Terminal ist.
- Der Status ist 0, oder 1 am Ende der Eingabe (die Variablen werden trotzdem mit dem Rest gesetzt). So endet `while read …` nach der letzten Zeile.
- `read` liest Byte für Byte und damit nie mehr als eine Zeile, den Rest bekommen die nächsten Befehle. Ctrl+C bricht `read` in der interaktiven Shell ab (Status 130).
- `read` verändert die Shell und lässt sich deshalb nicht in einer Pipeline verwenden (`echo a | read x`, in `sh` wirkungslos). Umleitungen funktionieren: `read x < datei`.
### Variablen
```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. Die Subshell kennt die Funktionen und Positionsparameter der Shell. Er darf alles enthalten, was gosh kann (Pipes, `;`, `&&`, Umleitungen, verschachtelte `$(...)`), und liest von der gleichen Eingabe, Fehlermeldungen gehen auf stderr.
- Abschließende Zeilenumbrüche der Ausgabe werden entfernt.
- Ohne Anführungszeichen wird die Ausgabe an Leerzeichen, Tabs und Zeilenumbrüchen in mehrere Wörter geteilt, `"$(befehl)"` bleibt ein einziges Wort (mit den Zeilenumbrüchen darin).
- Anders als in `sh` werden Wildcards in der Ausgabe nicht expandiert, und Zeichen wie `;` oder `|` darin sind normaler Text: Die Ausgabe ist immer nur Daten.
- Besteht ein Befehl nur aus einer Ersetzung (`$(false)`), ist ihr Exit-Status `$?`, sonst zählt der Status des Befehls. Ctrl+C während der Ersetzung bricht die ganze Zeile ab.
- In `'...'` oder mit `\$(` wird nichts ersetzt. Die alte Schreibweise mit Backticks (`` `befehl` ``) und Arithmetik `$((1+2))` gibt es nicht.
### Befehlslisten und Exit-Status
Mehrere Befehle in einer Zeile werden wie in `sh` verknüpft:
@@ -198,7 +414,7 @@ $ ls -d */ # nur Verzeichnisse
- Versteckte Dateien (mit `.` am Anfang) werden nur gefunden, wenn das Muster selbst mit `.` beginnt: `*` findet `.bashrc` nicht, `.*rc` schon.
- Passt keine Datei, bleibt das Wort unverändert stehen (wie in `sh`), `ls *.xyz` meldet dann also `ls` den Fehler.
- In Anführungszeichen oder mit `\` davor sind die Zeichen normale Zeichen: `echo "*"`, `echo '*.go'`, `echo \*`.
- Werte von Variablen werden nicht als Muster behandelt (anders als in `sh`): Nach `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`.
### Pipes und Umleitungen
@@ -407,4 +623,4 @@ git push origin v0.1.0
goreleaser release --clean
```
Das Paket installiert `/usr/bin/gosh` und trägt die Shell in `/etc/shells` ein, sodass sie mit `chsh -s /usr/bin/gosh` gesetzt werden kann.
Das Paket installiert `/usr/bin/gosh` und die Man-Page `gosh(1)` (`man gosh`, Quelle: `packaging/man/gosh.1`, lokal ansehen mit `man ./packaging/man/gosh.1`) und trägt die Shell in `/etc/shells` ein, sodass sie mit `chsh -s /usr/bin/gosh` gesetzt werden kann.
+11 -1
View File
@@ -75,7 +75,7 @@ func expandAliasesIn(line string, active map[string]bool) string {
for i := 0; i < len(line); i++ {
ch := line[i]
if quote == 0 && !escaped && commandPos {
if ch == ' ' || ch == '\t' {
if ch == ' ' || ch == '\t' || ch == '\n' {
out.WriteByte(ch)
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)
switch {
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)
}
}
+31 -1
View File
@@ -15,12 +15,14 @@ import (
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.
// 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); {
@@ -32,6 +34,8 @@ func expandBraceWords(input string) (string, error) {
case strings.IndexByte(";&|<>", ch) >= 0:
if ch == '<' || ch == '>' {
afterRedirect = true
} else {
commandStart = true
}
out.WriteByte(ch)
i++
@@ -41,6 +45,16 @@ func expandBraceWords(input string) (string, error) {
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
@@ -56,6 +70,12 @@ func expandBraceWords(input string) (string, error) {
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 {
@@ -69,6 +89,10 @@ func wordEnd(s string, i int) int {
}
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
@@ -103,6 +127,12 @@ func braceScan(s string, fn func(i, depth int) bool) {
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
+231
View File
@@ -0,0 +1,231 @@
package main
// Functions and positional parameters. A function runs in the shell itself
// (it can change variables and the directory), with its arguments as the
// positional parameters $1, $2, ... In a pipeline, in the background and in
// $(...) it runs in a subshell, which gets the definitions of all functions.
import (
"errors"
"fmt"
"os"
"slices"
"sort"
"strconv"
"strings"
)
// funcDef is a function defined with name() { ...; }.
type funcDef struct {
name string
body node
text string // the source of the definition, passed to subshells
}
var (
functions = map[string]*funcDef{}
// scriptName is $0: the script, the name given after gosh -c command,
// or the name gosh was started with.
scriptName = "gosh"
// positional are the positional parameters $1, $2, ...
positional []string
// funcDepth is the number of functions that are currently running.
funcDepth int
// localFrames holds, for every running function, the previous values
// of the variables it declared with local.
localFrames []map[string]savedVar
// ioOverride replaces stdin, stdout and stderr of the shell while a
// function with redirections runs (f > file), so that the commands in
// it use the redirected files.
ioOverride [3]*os.File
)
// maxFuncDepth limits the nesting of function calls, so that endless
// recursion ends with an error.
const maxFuncDepth = 1000
type savedVar struct {
value string
ok bool
}
// returnSignal is returned by the return builtin and ends the running
// function or sourced file with the given status.
type returnSignal struct {
status int
}
func (r returnSignal) Error() string { return "return" }
// stdio returns stdin (0), stdout (1) or stderr (2) of the shell, which are
// redirected while a function with redirections runs.
func stdio(fd int) *os.File {
if f := ioOverride[fd]; f != nil {
return f
}
return [3]*os.File{os.Stdin, os.Stdout, os.Stderr}[fd]
}
// isFuncName reports whether name can be the name of a function: letters,
// digits, _, - and ., not starting with a digit or -.
func isFuncName(name string) bool {
if name == "" || name[0] == '-' || name[0] >= '0' && name[0] <= '9' || terminators[name] || compoundStarts[name] {
return false
}
for i := 0; i < len(name); i++ {
if c := name[i]; !isVarNameByte(c, false) && c != '-' && c != '.' {
return false
}
}
return true
}
func defineFunction(def *funcDef) {
functions[def.name] = def
}
// functionNames returns the names of all functions, sorted.
func functionNames() []string {
names := make([]string, 0, len(functions))
for name := range functions {
names = append(names, name)
}
sort.Strings(names)
return names
}
// functionDefs returns the definitions of all functions as source text, to
// run them in a subshell.
func functionDefs() string {
var b strings.Builder
for _, name := range functionNames() {
b.WriteString(functions[name].text)
b.WriteByte('\n')
}
return b.String()
}
// callFunction runs a function in the shell with args[1:] as positional
// parameters, the files fds as stdin, stdout and stderr and the assignments
// that came before the call set while it runs.
func callFunction(fn *funcDef, args []string, fds [3]*os.File, assigns []string) error {
if funcDepth >= maxFuncDepth {
return fmt.Errorf("%s: maximum function nesting level exceeded (%d)", sanitize(fn.name), maxFuncDepth)
}
defer setTemporarily(assigns)()
savedIO, savedPositional, savedLoopDepth := ioOverride, positional, loopDepth
ioOverride, positional, loopDepth = fds, slices.Clone(args[1:]), 0
funcDepth++
localFrames = append(localFrames, map[string]savedVar{})
defer func() {
// Restore the variables declared with local
for name, v := range localFrames[len(localFrames)-1] {
if v.ok {
os.Setenv(name, v.value)
} else {
os.Unsetenv(name)
}
}
localFrames = localFrames[:len(localFrames)-1]
funcDepth--
ioOverride, positional, loopDepth = savedIO, savedPositional, savedLoopDepth
}()
err := fn.body.exec()
var ret returnSignal
if errors.As(err, &ret) {
if ret.status == 0 {
return nil
}
return statusError(ret.status)
}
return err
}
// lookupParam returns the value of a special or positional parameter ($?,
// $#, $$, $0, $1, ..., $@ and $* joined with blanks) or of a variable.
func lookupParam(name string) (string, bool) {
switch {
case name == "?":
return strconv.Itoa(lastStatus), true
case name == "#":
return strconv.Itoa(len(positional)), true
case name == "$":
return strconv.Itoa(os.Getpid()), true
case name == "@" || name == "*":
return strings.Join(positional, " "), true
case name == "0":
return scriptName, true
case isDigits(name):
n, err := strconv.Atoi(name)
if err != nil || n > len(positional) {
return "", false
}
return positional[n-1], true
}
return os.LookupEnv(name)
}
// builtinReturn implements return [n].
func builtinReturn(args []string) error {
if funcDepth == 0 && sourceDepth == 0 {
return errors.New("return: can only `return' from a function or sourced script")
}
status := lastStatus
if len(args) > 1 {
n, err := strconv.Atoi(args[1])
if err != nil {
return fmt.Errorf("return: %s: numeric argument required", sanitize(args[1]))
}
status = n & 0xff
}
return returnSignal{status}
}
// builtinLocal implements local name[=value] ...: the variables get their
// previous values back when the function returns. Without a value the
// variable is unset in the function.
func builtinLocal(args []string) error {
if funcDepth == 0 {
return errors.New("local: can only be used in a function")
}
frame := localFrames[len(localFrames)-1]
var err error
for _, arg := range args[1:] {
name, value, hasValue := strings.Cut(arg, "=")
if !isVarName(name) {
err = fmt.Errorf("local: `%s': not a valid identifier", sanitize(arg))
continue
}
if _, saved := frame[name]; !saved {
old, ok := os.LookupEnv(name)
frame[name] = savedVar{old, ok}
}
if hasValue {
os.Setenv(name, value)
} else {
os.Unsetenv(name)
}
}
return err
}
// builtinShift implements shift [n].
func builtinShift(args []string) error {
n := 1
if len(args) > 1 {
var err error
if n, err = strconv.Atoi(args[1]); err != nil || n < 0 {
return fmt.Errorf("shift: %s: numeric argument required", sanitize(args[1]))
}
}
if n > len(positional) {
return fmt.Errorf("shift: %d: shift count out of range", n)
}
positional = positional[n:]
return nil
}
+363
View File
@@ -0,0 +1,363 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
)
// resetShell removes functions and positional parameters after a test.
func resetShell(t *testing.T) {
t.Helper()
t.Cleanup(func() {
functions = map[string]*funcDef{}
positional, scriptName = nil, "gosh"
})
}
func TestTokenize_PositionalParameters(t *testing.T) {
resetShell(t)
scriptName, positional = "prog", []string{"a", "b c", ""}
tests := []struct {
input string
want []string
}{
{"echo $0 $1 $2", []string{"echo", "prog", "a", "b c"}},
{"echo $# ${#}", []string{"echo", "3", "3"}},
{`echo "$@"`, []string{"echo", "a", "b c", ""}},
{`echo x"$@"y`, []string{"echo", "xa", "b c", "y"}},
{`echo $@`, []string{"echo", "a", "b c", ""}},
{`echo "$*"`, []string{"echo", "a b c "}},
{`echo ${2}x $10`, []string{"echo", "b cx", "a0"}},
{`echo ${4:-four}`, []string{"echo", "four"}},
{`echo '$1' \$1`, []string{"echo", "$1", "$1"}},
}
for _, tc := range tests {
got, err := splitWords(tc.input)
if err != nil {
t.Errorf("%q: %v", tc.input, err)
continue
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("%q: got %q, want %q", tc.input, got, tc.want)
}
}
// An unset positional parameter is an error, like an unset variable
if _, err := splitWords("echo $4"); err == nil || !strings.Contains(err.Error(), "4: unbound variable") {
t.Errorf("$4: err = %v", err)
}
// "$@" without parameters is no word at all
positional = nil
for input, want := range map[string][]string{
`echo "$@"`: {"echo"},
`echo "$@" x`: {"echo", "x"},
`echo "x$@"`: {"echo", "x"},
`echo "$*"`: {"echo", ""},
`echo $#`: {"echo", "0"},
} {
if got, _ := splitWords(input); !reflect.DeepEqual(got, want) {
t.Errorf("%q without parameters: got %q, want %q", input, got, want)
}
}
}
func TestExec_Functions(t *testing.T) {
resetShell(t)
for _, name := range []string{"GOSH_A", "GOSH_L"} {
os.Unsetenv(name)
defer os.Unsetenv(name)
}
tests := []struct {
input string
want string
}{
{"f() { echo hi > out; }; f", "hi\n"},
{"f () { echo $1-$2-$# >> out; }; f a b", "a-b-2\n"},
{"f(){ echo braces >> out; }; f", "braces\n"},
{"function f { echo keyword >> out; }; f", "keyword\n"},
{"function f() { echo both >> out; }; f", "both\n"},
{"f() {\n echo multi >> out\n echo line >> out\n}\nf", "multi\nline\n"},
{"f()\n{\n echo next-line >> out\n}\nf", "next-line\n"},
{"f() if true; then echo if-body >> out; fi; f", "if-body\n"},
{`f() { for a in "$@"; do echo "<$a>" >> out; done; }; f x "y z"`, "<x>\n<y z>\n"},
// The positional parameters are restored after the call
{`set -- outer; f() { echo $1 >> out; }; f inner; echo $1 >> out`, "inner\nouter\n"},
// Functions change the shell (variables, directory)
{"f() { GOSH_A=set; }; f; echo $GOSH_A > out", "set\n"},
// return
{"f() { echo a >> out; return; echo b >> out; }; f", "a\n"},
{"f() { return 3; }; f; echo $? > out", "3\n"},
{"f() { false; return; }; f; echo $? > out", "1\n"},
{"f() { for x in 1 2 3; do if [ $x = 2 ]; then return 7; fi; echo $x >> out; done; }; f; echo $? >> out", "1\n7\n"},
{"f() { sh -c 'exit 4'; }; f; echo $? > out", "4\n"},
// local
{"GOSH_L=global; f() { local GOSH_L=local; echo $GOSH_L >> out; }; f; echo $GOSH_L >> out", "local\nglobal\n"},
{"f() { local GOSH_L; GOSH_L=x; }; f; echo ${GOSH_L:-unset} > out", "unset\n"},
{"f() { local GOSH_L=1; g; echo $GOSH_L >> out; }; g() { GOSH_L=2; }; f", "2\n"},
// Assignments before a call apply only while it runs
{"f() { echo $GOSH_A >> out; }; GOSH_A=temp f; echo ${GOSH_A:-unset} >> out", "temp\nunset\n"},
// Redirections of a call apply to all commands in the function
{"f() { echo one; echo two; }; f > out", "one\ntwo\n"},
{"f() { echo err >&2; }; f 2> out", "err\n"},
{"echo input > in; f() { cat; }; f < in > out", "input\n"},
{"f() { echo nested > out; }; g() { f; }; g", "nested\n"},
// Functions run before builtins and commands of the same name
{"echo() { printf 'own\\n' > out; }; echo x; unset -f echo", "own\n"},
{"f() { echo x > out; }; unset -f f; f", ""},
// In pipelines, in the background and in $(...) they run in a subshell
{"f() { echo piped; }; f | tr a-z A-Z > out", "PIPED\n"},
{"f() { tr a-z A-Z; }; echo lower | f > out", "LOWER\n"},
{"f() { echo bg > out; }; f & wait", "bg\n"},
{`f() { echo "sub $1"; }; echo "$(f arg)" > out`, "sub arg\n"},
{`f() { g; }; g() { echo g-in-sub; }; echo $(f) > out`, "g-in-sub\n"},
{`set -- p1; echo "$(echo $1)" > out`, "p1\n"},
{"f() { GOSH_A=changed; }; f | cat; echo ${GOSH_A:-unchanged} > out", "unchanged\n"},
}
for _, tc := range tests {
os.Unsetenv("GOSH_A")
os.Unsetenv("GOSH_L")
if got := runIn(t, tc.input); got != tc.want {
t.Errorf("%q: out = %q, want %q", tc.input, got, tc.want)
}
if funcDepth != 0 || len(localFrames) != 0 || ioOverride != [3]*os.File{} {
t.Fatalf("%q: function state not restored", tc.input)
}
}
for input, want := range map[string]string{
"return": "return: can only `return' from a function or sourced script",
"local x": "local: can only be used in a function",
"f() { f; }; f": "f: maximum function nesting level exceeded (1000)",
"f() echo x": "f: the body of a function must be a compound command like { ...; }",
"f() { return x; }; f": "return: x: numeric argument required",
} {
if err := execInput(input); err == nil || err.Error() != want {
t.Errorf("%q: err = %v, want %q", input, err, want)
}
}
}
func TestExec_Case(t *testing.T) {
resetShell(t)
tests := []struct {
input string
want string
}{
{"case abc in a*) echo a > out;; b*) echo b > out;; esac", "a\n"},
{"case bcd in a*) echo a > out;; b*) echo b > out;; esac", "b\n"},
{"case xyz in a*) echo a > out;; esac", ""},
{"case stop in start|run) echo s > out;; stop|halt) echo h > out;; esac", "h\n"},
{"case x in (x) echo paren > out;; esac", "paren\n"},
{"case x in x)echo tight > out;;esac", "tight\n"},
{"case x in x ) echo blank > out ;; esac", "blank\n"},
{"case x in\n y) echo y > out ;;\n x)\n echo x1 >> out\n echo x2 >> out\n ;;\nesac", "x1\nx2\n"},
// The last clause needs no ;;
{"case x in x) echo last > out; esac", "last\n"},
{"case x in x) ;; esac; echo empty > out", "empty\n"},
{"case x in esac; echo none > out", "none\n"},
// Patterns: wildcards, also across /, quoted characters are literal
{"case a/b/c in *) echo star > out;; esac", "star\n"},
{"case a/b in a?b) echo q > out;; esac", "q\n"},
{"case b in [abc]) echo class > out;; esac", "class\n"},
{"case d in [!abc]) echo neg > out;; esac", "neg\n"},
{`case '*' in "*") echo quoted > out;; *) echo glob > out;; esac`, "quoted\n"},
{`case abc in "*") echo quoted > out;; *) echo glob > out;; esac`, "glob\n"},
{`case 'a b' in "a b") echo space > out;; esac`, "space\n"},
{`case 'x)' in 'x)') echo paren-in-quotes > out;; esac`, "paren-in-quotes\n"},
// Variables in the word and in patterns
{`set -- --help; case $1 in -h|--help) echo help > out;; esac`, "help\n"},
// Values of variables are no patterns (like everywhere in gosh)
{`P='b*'; case bar in $P) echo pattern > out;; *) echo literal > out;; esac`, "literal\n"},
{`P='b*'; case 'b*' in $P) echo same > out;; esac`, "same\n"},
{`case $(echo hi) in hi) echo subst > out;; esac`, "subst\n"},
{`case "" in "") echo empty-word > out;; esac`, "empty-word\n"},
// Only the first matching clause runs
{"case a in a) echo 1 >> out;; *) echo 2 >> out;; esac", "1\n"},
// Nested in other structures
{"for x in a b; do case $x in a) echo A >> out;; *) echo other >> out;; esac; done", "A\nother\n"},
{"f() { case $1 in y) return 0;; *) return 1;; esac; }; f y && echo yes > out", "yes\n"},
}
for _, tc := range tests {
if got := runIn(t, tc.input); got != tc.want {
t.Errorf("%q: out = %q, want %q", tc.input, got, tc.want)
}
}
for input, status := range map[string]int{
"case x in y) false;; esac": 0,
"case x in x) false;; esac": 1,
"case x in x) true;; esac": 0,
"case x in x) echo;": 2,
"case x y in x) ;; esac": 2,
"echo a;; echo b": 2,
} {
runIn(t, input)
if lastStatus != status {
t.Errorf("%q: $? = %d, want %d", input, lastStatus, status)
}
}
}
func TestExec_Group(t *testing.T) {
tests := []struct {
input string
want string
}{
{"{ echo a >> out; echo b >> out; }", "a\nb\n"},
{"{\n echo multi >> out\n}", "multi\n"},
{"false || { echo fallback >> out; }", "fallback\n"},
{"echo { } > out", "{ }\n"},
}
for _, tc := range tests {
if got := runIn(t, tc.input); got != tc.want {
t.Errorf("%q: out = %q, want %q", tc.input, got, tc.want)
}
}
}
func TestExec_PositionalBuiltins(t *testing.T) {
resetShell(t)
tests := []struct {
input string
want string
}{
{"set -- a b c; echo $# $1 $3 > out", "3 a c\n"},
{"set -- a b c; shift; echo $# $1 > out", "2 b\n"},
{"set -- a b c; shift 2; echo $# $1 > out", "1 c\n"},
{"set -- a b; shift 3; echo $# $? > out", "2 1\n"},
{"set --; echo $# > out", "0\n"},
{"set -- a b; for x; do echo $x >> out; done", "a\nb\n"},
{"set -- 1 2 3; while [ $# -gt 0 ]; do echo $1 >> out; shift; done", "1\n2\n3\n"},
// source with arguments
{"echo 'echo $# $1 > out' > lib; set -- x; . ./lib a b", "2 a\n"},
{"echo 'echo $1 > out' > lib; set -- x; . ./lib a b; echo $1 >> out", "a\nx\n"},
// return ends a sourced file
{"printf 'echo one >> out\\nreturn 3\\necho two >> out\\n' > lib; . ./lib; echo $? >> out", "one\n3\n"},
}
for _, tc := range tests {
positional = nil
if got := runIn(t, tc.input); got != tc.want {
t.Errorf("%q: out = %q, want %q", tc.input, got, tc.want)
}
}
}
func TestRead(t *testing.T) {
for _, name := range []string{"GOSH_A", "GOSH_B", "GOSH_C", "REPLY", "IFS"} {
os.Unsetenv(name)
defer os.Unsetenv(name)
}
tests := []struct {
input string
data string
want string
}{
{"read GOSH_A", "hello world\n", "[hello world]"},
{"read GOSH_A GOSH_B", " one two three \n", "[one][two three]"},
{"read GOSH_A GOSH_B GOSH_C", "one\n", "[one][][]"},
{"read GOSH_A", "no newline", "[no newline]"},
{"read GOSH_A GOSH_B", `a\ b c` + "\n", "[a b][c]"},
{"read -r GOSH_A GOSH_B", `a\ b c` + "\n", `[a\][b c]`},
{"read GOSH_A", "con\\\ntinued\n", "[continued]"},
{"read", " reply \n", "[ reply ]"},
{"IFS=:; read GOSH_A GOSH_B GOSH_C", "a::b:c\n", "[a][][b:c]"},
{"IFS=:; read GOSH_A GOSH_B", "a b:c d\n", "[a b][c d]"},
{"IFS=; read GOSH_A", " keep \n", "[ keep ]"},
{"read GOSH_A; read GOSH_B", "first\nsecond\n", "[first][second]"},
}
for _, tc := range tests {
for _, name := range []string{"GOSH_A", "GOSH_B", "GOSH_C", "REPLY", "IFS"} {
os.Unsetenv(name)
}
dir := t.TempDir()
in := filepath.Join(dir, "in")
os.WriteFile(in, []byte(tc.data), 0644)
f, _ := os.Open(in)
saved := os.Stdin
os.Stdin = f
execInput(tc.input)
os.Stdin = saved
f.Close()
var got strings.Builder
names := []string{"GOSH_A", "GOSH_B", "GOSH_C"}
if !strings.Contains(tc.input, "GOSH_") {
names = []string{"REPLY"}
}
for _, name := range names {
if v, ok := os.LookupEnv(name); ok {
got.WriteString("[" + v + "]")
}
}
if got.String() != tc.want {
t.Errorf("%q with %q: got %s, want %s", tc.input, tc.data, got.String(), tc.want)
}
}
// Status 1 at the end of the input, also after a last line without newline
for data, want := range map[string]int{"line\n": 0, "partial": 1, "": 1} {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "in"), []byte(data), 0644)
runIn(t, "read GOSH_A < "+filepath.Join(dir, "in"))
if lastStatus != want {
t.Errorf("read from %q: $? = %d, want %d", data, lastStatus, want)
}
}
if err := execInput("read 1x"); err == nil || err.Error() != "read: `1x': not a valid identifier" {
t.Errorf("read 1x: %v", err)
}
if err := execInput("echo a | read GOSH_A"); err == nil || !strings.Contains(err.Error(), "cannot be used in a pipeline") {
t.Errorf("read in a pipeline: %v", err)
}
}
// TestShell_Arguments checks $0 and the positional parameters of scripts and
// gosh -c, and that a script reads lines from stdin with read.
func TestShell_Arguments(t *testing.T) {
dir := t.TempDir()
script := filepath.Join(dir, "script.gosh")
os.WriteFile(script, []byte(`echo "$# [$1] [$2]"
usage() {
echo "usage: $1" >&2
return 2
}
case ${1:-} in
-h) usage x; exit $? ;;
esac
while read -r line; do
echo "line: $line"
done
`), 0644)
tests := []struct {
args []string
stdin string
want string
}{
{[]string{"-c", `echo $0 $# "$@"`}, "", subshellPath + " 0\n"},
{[]string{"-c", `echo $0 $# "$@"`, "name", "a", "b c"}, "", "name 2 a b c\n"},
{[]string{"-c", `for a; do echo "<$a>"; done`, "n", "x y", "z"}, "", "<x y>\n<z>\n"},
{[]string{script, "one", "two words"}, "l1\nl2\n", "2 [one] [two words]\nline: l1\nline: l2\n"},
}
for _, tc := range tests {
cmd := exec.Command(subshellPath, tc.args...)
cmd.Stdin = strings.NewReader(tc.stdin)
out, _ := cmd.Output()
if string(out) != tc.want {
t.Errorf("gosh %q: got %q, want %q", tc.args, out, tc.want)
}
}
cmd := exec.Command(subshellPath, script, "-h")
out, err := cmd.CombinedOutput()
if exitStatus(err) != 2 || !strings.Contains(string(out), "usage: x") {
t.Errorf("script -h: status %d, output %q", exitStatus(err), out)
}
}
+415 -206
View File
@@ -12,6 +12,7 @@ import (
"os/user"
"path/filepath"
"runtime/debug"
"slices"
"sort"
"strconv"
"strings"
@@ -121,6 +122,7 @@ type token struct {
word string // the word, if op == ""
pattern string // the word as glob pattern (quoted characters escaped)
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)
dup int // the target file descriptor of >& and <&
}
@@ -131,10 +133,13 @@ type token struct {
// - '...' is taken literally, nothing is expanded inside
// - "..." expands $VAR and ${VAR}; \ only escapes $ " \ and `
// - outside quotes \ escapes any character
// - $(command) is replaced by the output of command (see commandSubst);
// unquoted, the output is split into words at blanks and newlines
// - $VAR, ${VAR} and ${VAR:-default} are expanded outside single quotes;
// an unset variable is an error, so that e.g. "rm -rf $DIR/x" never runs
// with an empty $DIR
// - ~ 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
//
// Expanded values are never split into several words.
@@ -145,23 +150,29 @@ func tokenize(input string) ([]token, error) {
pat strings.Builder // the word as glob pattern, quoted parts escaped
glob bool // the word contains unquoted * ? or [
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)
noParams bool // the word contains "$@" without positional parameters
quote byte
escaped bool
rawStart = -1 // start of the current word in input
)
endWord := func() {
if inWord {
tokens = append(tokens, token{word: b.String(), pattern: pat.String(), glob: glob})
// "$@" without parameters is no word at all, unlike ""
if inWord && !(noParams && b.Len() == 0) {
tokens = append(tokens, token{word: b.String(), pattern: pat.String(), glob: glob, assign: assign})
}
b.Reset()
pat.Reset()
glob = false
glob, assign, plain, noParams = false, false, true, false
inWord = false
rawStart = -1
}
// literal adds quoted, escaped or expanded text: it is never a pattern
literal := func(s string) {
plain = false
b.WriteString(s)
pat.WriteString(escapeGlob(s))
}
@@ -173,6 +184,8 @@ func tokenize(input string) ([]token, error) {
for i := 0; i < len(input); i++ {
ch := input[i]
afterAssignSep := tildeOK
tildeOK = false
switch {
case escaped:
if quote == '"' && !strings.ContainsRune("$\"\\`", rune(ch)) {
@@ -190,6 +203,47 @@ func tokenize(input string) ([]token, error) {
case ch == '\\':
startRaw(i)
escaped = true
case isSubstStart(input, i):
startRaw(i)
end := substEnd(input, i)
if end < 0 {
return nil, errors.New("missing ) in command substitution")
}
out, err := commandSubst(input[i+2 : end])
if err != nil {
return nil, err
}
if quote == '"' || 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 == '$' && !assign && positionalWords(input[i:], quote == '"') > 0:
// $@ and "$@": one word per positional parameter
startRaw(i)
if len(positional) == 0 && quote == '"' {
noParams = true
}
for k, param := range positional {
if k > 0 {
endWord()
}
literal(param)
inWord = true
}
i += positionalWords(input[i:], quote == '"') - 1
case ch == '$':
startRaw(i)
value, n, err := expandVar(input[i:])
@@ -213,6 +267,7 @@ func tokenize(input string) ([]token, error) {
}
case ch == '"' || ch == '\'':
startRaw(i)
plain = false
quote = ch
inWord = true
case ch == ' ' || ch == '\t' || ch == '\n':
@@ -268,8 +323,8 @@ func tokenize(input string) ([]token, error) {
i = j - 1
}
tokens = append(tokens, t)
case ch == '~' && !inWord && b.Len() == 0 &&
(i+1 == len(input) || strings.IndexByte("/ \t\n|<>", input[i+1]) >= 0):
case ch == '~' && (!inWord && b.Len() == 0 || afterAssignSep) &&
(i+1 == len(input) || strings.IndexByte("/ \t\n|<>:", input[i+1]) >= 0):
startRaw(i)
home := getHomeDir()
if home == "" {
@@ -279,6 +334,14 @@ func tokenize(input string) ([]token, error) {
inWord = true
default:
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)
switch {
case ch == '*' || ch == '?' || ch == '[':
@@ -460,13 +523,10 @@ func expandVar(s string) (string, int, error) {
return "", 0, errors.New("missing } in variable reference")
}
name, def, hasDef := strings.Cut(s[2:end], ":-")
if name == "?" {
return strconv.Itoa(lastStatus), end + 1, nil
}
if !isVarName(name) {
if !isVarName(name) && !isDigits(name) && !isSpecialParam(name) {
return "", 0, fmt.Errorf("bad substitution: %s", s[:end+1])
}
value, ok := os.LookupEnv(name)
value, ok := lookupParam(name)
if hasDef && value == "" {
return def, end + 1, nil
}
@@ -476,8 +536,13 @@ func expandVar(s string) (string, int, error) {
return value, end + 1, nil
}
if len(s) > 1 && s[1] == '?' {
return strconv.Itoa(lastStatus), 2, nil
// $?, $#, $$, $@, $*, $0 and $1 to $9 ($10 is $1 followed by 0)
if len(s) > 1 && (isSpecialParam(s[1:2]) || isDigits(s[1:2])) {
value, ok := lookupParam(s[1:2])
if !ok {
return "", 0, fmt.Errorf("%s: unbound variable", s[1:2])
}
return value, 2, nil
}
n := 1
@@ -494,6 +559,25 @@ func expandVar(s string) (string, int, error) {
return value, n, nil
}
// isSpecialParam reports whether name is one of the special parameters ?,
// #, $, @ and *.
func isSpecialParam(name string) bool {
return len(name) == 1 && strings.Contains("?#$@*", name)
}
// positionalWords returns the number of bytes of $@ or ${@} at the start of
// s, which expands to one word per positional parameter (also in double
// quotes). Unquoted, $* and ${*} do the same (gosh never splits values).
// It returns 0 for anything else.
func positionalWords(s string, quoted bool) int {
for _, p := range []string{"$@", "${@}", "$*", "${*}"} {
if strings.HasPrefix(s, p) && (strings.Contains(p, "@") || !quoted) {
return len(p)
}
}
return 0
}
func isVarName(name string) bool {
if name == "" {
return false
@@ -714,7 +798,7 @@ type shellCompleter struct {
func newShellCompleter() *shellCompleter {
return &shellCompleter{
commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "source", "jobs", "fg", "bg", "wait", "alias", "unalias", "help"},
commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "source", "jobs", "fg", "bg", "wait", "alias", "unalias", "break", "continue", "unset", "help", "return", "local", "shift", "read"},
}
}
@@ -728,6 +812,13 @@ func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) {
before := strings.TrimSpace(string(line[:start]))
isFirstWord := before == "" || strings.HasSuffix(before, "|") ||
strings.HasSuffix(before, ";") || strings.HasSuffix(before, "&&")
// ... and after if, then, do etc.
if fields := strings.Fields(before); len(fields) > 0 {
switch fields[len(fields)-1] {
case "if", "then", "elif", "else", "do", "while", "until", "!", "{", ";;":
isFirstWord = true
}
}
// Plain "~" -> "~/"
if word == "~" {
@@ -738,7 +829,7 @@ func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) {
// Complete commands
var candidates [][]rune
seen := make(map[string]bool)
names := append(append(append([]string(nil), c.commands...), aliasNames()...), pathExecutables(word)...)
names := append(append(append(append([]string(nil), c.commands...), aliasNames()...), functionNames()...), pathExecutables(word)...)
for _, cmd := range names {
if strings.HasPrefix(cmd, word) && !seen[cmd] {
seen[cmd] = true
@@ -882,8 +973,8 @@ func completeFilePath(line, prefix string) []string {
return matches
}
const usage = `usage: gosh [-l] [-i] [--norc] [--noprofile] [script]
gosh [-l] [--noprofile] -c command
const usage = `usage: gosh [-l] [-i] [--norc] [--noprofile] [script [args ...]]
gosh [-l] [--noprofile] -c command [name [args ...]]
gosh -version`
// sysConfDir holds the system-wide startup files.
@@ -896,6 +987,7 @@ func main() {
if strings.HasPrefix(os.Args[0], "-") {
args = append([]string{"-l"}, args...)
}
scriptName = os.Args[0]
os.Exit(run(args))
}
@@ -933,21 +1025,26 @@ flags:
fmt.Println(usage)
return 0
case arg == "-c":
// Like sh, further arguments are allowed (they would become $0,
// $1, ...), but gosh has no positional parameters yet.
// gosh -c command [name [args ...]]: name is $0, args are $1, ...
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "gosh: -c: option requires an argument")
fmt.Fprintln(os.Stderr, usage)
return 2
}
if len(args) > 2 {
scriptName, positional = args[2], args[3:]
}
script = func() int { return runScript(strings.NewReader(args[1]), "") }
case strings.HasPrefix(arg, "-") && arg != "-":
fmt.Fprintf(os.Stderr, "gosh: %s: invalid option\n", arg)
fmt.Fprintln(os.Stderr, usage)
return 2
case arg == "-":
positional = args[1:]
script = func() int { return runScript(os.Stdin, "") }
default:
// gosh script [args ...]
scriptName, positional = arg, args[1:]
f, err := os.Open(arg)
if err != nil {
fmt.Fprintln(os.Stderr, "gosh:", err)
@@ -1011,6 +1108,8 @@ func sourceStartupFile(path string) {
return
}
defer f.Close()
sourceDepth++
defer func() { sourceDepth-- }()
runScript(bufio.NewReader(f), path)
}
@@ -1042,28 +1141,62 @@ func sourceFile(cmd, path string) error {
return nil
}
// runScript executes the commands from r line by line. Like sh, it does not
// stop at a failing command and returns the exit status of the last one.
// Empty lines and lines starting with # (e.g. a #!/usr/bin/gosh line) are
// skipped. If name is set, errors are reported with file name and line.
// runScript executes the commands from r. Each command is executed as soon
// as it is complete (a control structure can span several lines). Like sh,
// it does not stop at a failing command and returns the exit status of the
// last one. If name is set, errors are reported with file name and line.
func runScript(r io.Reader, name string) int {
status := 0
for lineNo := 1; ; lineNo++ {
line, err := readLine(r)
if trimmed := strings.TrimSpace(line); trimmed != "" && !strings.HasPrefix(trimmed, "#") {
cmdErr := execInput(line)
status = lastStatus
if name != "" && cmdErr != nil {
cmdErr = fmt.Errorf("%s:%d: %w", sanitize(name), lineNo, cmdErr)
}
printError(cmdErr)
var (
buf strings.Builder
startLine int
)
report := func(err error, line int) {
if name != "" && err != nil {
err = fmt.Errorf("%s:%d: %w", sanitize(name), line, err)
}
if err != nil {
if err != io.EOF {
fmt.Fprintln(os.Stderr, "gosh:", err)
printError(err)
}
for lineNo := 1; ; lineNo++ {
line, readErr := readLine(r)
if readErr == nil || line != "" {
if buf.Len() == 0 {
if strings.TrimSpace(line) == "" {
goto next
}
startLine = lineNo
}
buf.WriteString(line)
buf.WriteByte('\n')
tree, err := parse(buf.String())
if errors.Is(err, errIncomplete) {
goto next // read the rest of the command
}
buf.Reset()
if err == nil {
err = tree.exec()
var ret returnSignal
if errors.As(err, &ret) {
// return in a sourced file
lastStatus = ret.status
return lastStatus
}
} else {
lastStatus = exitStatus(err)
}
report(err, startLine)
}
next:
if readErr != nil {
if readErr != io.EOF {
fmt.Fprintln(os.Stderr, "gosh:", readErr)
return 1
}
return status
if buf.Len() > 0 {
lastStatus = 2
report(errors.New("syntax error: unexpected end of file"), startLine)
}
return lastStatus
}
}
}
@@ -1101,10 +1234,20 @@ func exitStatus(err error) int {
if errors.As(err, &statusErr) {
return int(statusErr)
}
var synErr syntaxError
if errors.As(err, &synErr) {
var (
synErr syntaxError
lc loopControl
)
if errors.As(err, &synErr) || errors.Is(err, errIncomplete) {
return 2
}
if errors.As(err, &lc) {
return 0
}
var ret returnSignal
if errors.As(err, &ret) {
return ret.status
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
@@ -1156,23 +1299,35 @@ func interactive() {
}
defer l.Close()
// Main loop
// Main loop. A command that is not complete yet (e.g. if without fi)
// is continued on the next line with the prompt "> ".
var buf strings.Builder
for {
// Report finished and stopped background jobs
reapJobs(true)
if buf.Len() == 0 {
// Report finished and stopped background jobs
reapJobs(true)
// Update prompt dynamically (in case directory changed)
l.SetPrompt(getPrompt())
} else {
l.SetPrompt("> ")
}
// Update prompt dynamically (in case directory changed)
l.SetPrompt(getPrompt())
line, err := l.Readline()
if err != nil {
// Ctrl+C discards the current line
// Ctrl+C discards the current line (and an incomplete command)
if err == readline.ErrInterrupt {
buf.Reset()
continue
}
// Handle EOF (Ctrl+D)
if err == io.EOF {
fmt.Println()
if buf.Len() > 0 {
buf.Reset()
lastStatus = 2
fmt.Fprintln(os.Stderr, "gosh: syntax error: unexpected end of file")
continue
}
if hasStoppedJobs() && !exitWarned {
exitWarned = true
fmt.Fprintln(os.Stderr, "gosh: there are stopped jobs")
@@ -1185,7 +1340,7 @@ func interactive() {
}
// Skip empty input
if strings.TrimSpace(line) == "" {
if buf.Len() == 0 && strings.TrimSpace(line) == "" {
continue
}
@@ -1195,8 +1350,19 @@ func interactive() {
l.SaveHistory(line)
}
// Execute the input
printError(execInput(line))
buf.WriteString(line)
buf.WriteByte('\n')
tree, err := parse(buf.String())
if errors.Is(err, errIncomplete) {
continue
}
buf.Reset()
if err != nil {
lastStatus = exitStatus(err)
printError(err)
continue
}
printError(tree.exec())
}
}
@@ -1228,145 +1394,32 @@ func printError(err error) {
var (
exitErr *exec.ExitError
statusErr statusError
lc loopControl
ret returnSignal
)
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) || errors.As(err, &ret) {
return
}
fmt.Fprintln(os.Stderr, "gosh:", err)
fmt.Fprintln(stdio(2), "gosh:", err)
}
// listItem is a command of a command list together with the operator that
// connects it to the previous command ("" for the first one).
type listItem struct {
op string // "", ";", "&&" or "||"
command string
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.
// execInput parses and executes input: a command line or a whole script.
// It returns the error of the last command that was run; errors of earlier
// commands are printed. $? is updated after each command.
func execInput(input string) error {
if interactiveShell {
input = expandAliases(input)
}
items, err := splitList(input)
tree, err := parse(input)
if err != nil {
lastStatus = exitStatus(err)
return err
}
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
return tree.exec()
}
// execCommand executes a pipeline of simple commands with redirections, in
// the background if background is set.
func execCommand(input string, background bool) error {
// Brace expansion comes first, so that its results are expanded further
substStatus = 0
input, err := expandBraceWords(input)
if err != nil {
return err
@@ -1375,8 +1428,11 @@ func execCommand(input string, background bool) error {
// Split the input into words and operators, expanding variables and ~ on
// the way.
tokens, err := tokenize(input)
var synErr syntaxError
if errors.As(err, &synErr) {
var (
synErr syntaxError
statusErr statusError
)
if errors.As(err, &synErr) || errors.As(err, &statusErr) {
return err
}
if err != nil {
@@ -1387,6 +1443,10 @@ func execCommand(input string, background bool) error {
return err
}
if len(stages) == 0 {
// A command that consists only of $(...) has its status
if substStatus != 0 {
return statusError(substStatus)
}
return nil
}
// "exit" with stopped jobs only warns, unless it is repeated right away
@@ -1406,6 +1466,7 @@ type redirect struct {
// stage is a simple command of a pipeline.
type stage struct {
assigns []string // name=value before the command name
args []string
redirects []redirect
}
@@ -1416,13 +1477,18 @@ func parsePipeline(tokens []token) ([]stage, error) {
stages []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++ {
t := tokens[i]
switch t.op {
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 "|":
if isEmpty(cur) {
return nil, syntaxError("|")
@@ -1467,7 +1533,7 @@ func runPipeline(stages []stage, text string, background bool) error {
shellTTY, _ = readline.GetState(ttyFd)
}
stdin := os.Stdin
stdin := stdio(0)
if background && !jobControl {
// Without job control background jobs must not read from the
// terminal, like in sh
@@ -1477,12 +1543,12 @@ func runPipeline(stages []stage, text string, background bool) error {
}
var startErr error
for i, st := range stages {
stdout := os.Stdout
stdout := stdio(1)
var next *os.File
if i < len(stages)-1 {
r, w, err := os.Pipe()
if err != nil {
if stdin != os.Stdin {
if stdin != stdio(0) {
stdin.Close()
}
startErr = err
@@ -1522,10 +1588,10 @@ func runPipeline(stages []stage, text string, background bool) error {
// longer needs them, so that the neighbors see EOF or get SIGPIPE.
func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background bool) *proc {
var owned []*os.File
if stdin != os.Stdin {
if stdin != stdio(0) {
owned = append(owned, stdin)
}
if stdout != os.Stdout {
if stdout != stdio(1) {
owned = append(owned, stdout)
}
closeOwned := func() {
@@ -1538,16 +1604,35 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
// the command would read from /dev/null, so interactive programs (su,
// vim, ssh, ...) would get EOF right away. readline only reads from stdin
// while a line is being edited, so it does not steal input.
fds := [3]*os.File{stdin, stdout, os.Stderr}
fds := [3]*os.File{stdin, stdout, stdio(2)}
opened, err := applyRedirects(&fds, st.redirects)
owned = append(owned, opened...)
if err == nil && len(st.args) == 0 && len(st.assigns) > 0 {
// 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 {
// A command without arguments only creates or truncates the files
closeOwned()
return finishedProc(err)
}
if builtin, ok := builtins[st.args[0]]; ok {
fn, isFunc := functions[st.args[0]]
if isFunc && !inPipeline && !background {
defer closeOwned()
return finishedProc(callFunction(fn, st.args, fds, st.assigns))
}
if builtin, ok := builtins[st.args[0]]; !isFunc && ok {
switch {
case inPipeline && builtin.changesState:
closeOwned()
@@ -1558,8 +1643,8 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
}
run := func() error {
defer closeOwned()
err := runBuiltin(st.args, fds[1])
if err != nil && fds[2] != os.Stderr {
err := runBuiltin(st.args, fds[0], fds[1])
if err != nil && fds[2] != stdio(2) {
// Report the error where stderr is redirected to
fmt.Fprintln(fds[2], "gosh:", err)
return statusError(1)
@@ -1567,6 +1652,8 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
return err
}
if !inPipeline && !background {
// name=value before a builtin applies only while it runs
defer setTemporarily(st.assigns)()
return finishedProc(run())
}
p := &proc{done: make(chan error, 1)}
@@ -1574,8 +1661,23 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
return p
}
cmd := exec.Command(st.args[0], st.args[1:]...)
var cmd *exec.Cmd
if isFunc {
// A function in a pipeline or in the background runs in a subshell
cmd, err = subshellCommand(`"$@"`, st.args)
if err != nil {
closeOwned()
return finishedProc(err)
}
} else {
cmd = exec.Command(st.args[0], st.args[1:]...)
}
cmd.Stdin, cmd.Stdout, cmd.Stderr = fds[0], fds[1], fds[2]
if len(st.assigns) > 0 {
// name=value before a command is only in its environment (the
// last value of a name wins)
cmd.Env = append(os.Environ(), st.assigns...)
}
if jobControl {
// All processes of the job share a process group: the one of the
// first process. In the foreground, the first process makes its
@@ -1608,6 +1710,31 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
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,
// stderr) and returns the files it opened.
func applyRedirects(fds *[3]*os.File, redirects []redirect) ([]*os.File, error) {
@@ -1660,25 +1787,34 @@ func applyRedirects(fds *[3]*os.File, redirects []redirect) ([]*os.File, error)
// 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).
var builtins = map[string]struct{ changesState bool }{
"cd": {changesState: true},
"pwd": {},
"exit": {changesState: true},
"quit": {changesState: true},
"set": {changesState: true},
"export": {changesState: true},
"help": {},
"source": {changesState: true},
".": {changesState: true},
"jobs": {},
"fg": {changesState: true},
"bg": {changesState: true},
"wait": {changesState: true},
"alias": {},
"unalias": {changesState: true},
"cd": {changesState: true},
"pwd": {},
"exit": {changesState: true},
"quit": {changesState: true},
"set": {changesState: true},
"export": {changesState: true},
"help": {},
"source": {changesState: true},
".": {changesState: true},
"jobs": {},
"fg": {changesState: true},
"bg": {changesState: true},
"wait": {changesState: true},
"alias": {},
"unalias": {changesState: true},
":": {},
"unset": {changesState: true},
"break": {changesState: true},
"continue": {changesState: true},
"return": {changesState: true},
"local": {changesState: true},
"shift": {changesState: true},
"read": {changesState: true},
}
// runBuiltin executes a built-in command, writing its output to stdout.
func runBuiltin(args []string, stdout io.Writer) error {
// runBuiltin executes a built-in command, reading from stdin and writing its
// output to stdout.
func runBuiltin(args []string, stdin *os.File, stdout io.Writer) error {
switch args[0] {
case "cd":
// Save current directory before changing (for 'cd -')
@@ -1731,6 +1867,11 @@ func runBuiltin(args []string, stdout io.Writer) error {
}
return exitShell(lastStatus)
case "set":
// set -- args: set the positional parameters
if len(args) > 1 && args[1] == "--" {
positional = slices.Clone(args[2:])
return nil
}
// set VAR=value - set environment variable
if len(args) < 2 {
return fmt.Errorf("usage: set VAR=value")
@@ -1754,6 +1895,42 @@ func runBuiltin(args []string, stdout io.Writer) error {
}
os.Setenv(parts[0], parts[1])
return nil
case ":":
// Does nothing, successfully (e.g. while :; do ...)
return nil
case "unset":
// unset [-v] NAME ... - remove variables, unset -f NAME ... - functions
names := args[1:]
funcs := len(names) > 0 && names[0] == "-f"
if len(names) > 0 && (names[0] == "-f" || names[0] == "-v") {
names = names[1:]
}
if len(names) == 0 {
return fmt.Errorf("usage: unset [-f|-v] NAME ...")
}
var err error
for _, name := range names {
if funcs {
delete(functions, name)
continue
}
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 "return":
return builtinReturn(args)
case "local":
return builtinLocal(args)
case "shift":
return builtinShift(args)
case "read":
return builtinRead(args, stdin)
case "alias":
return builtinAlias(args, stdout)
case "unalias":
@@ -1767,9 +1944,15 @@ func runBuiltin(args []string, stdout io.Writer) error {
case "wait":
return builtinWait(args)
case "source", ".":
// source file - run the commands of file in the current shell
// source file [args] - run the commands of file in the current shell
if len(args) < 2 {
return fmt.Errorf("usage: %s file", args[0])
return fmt.Errorf("usage: %s file [args ...]", args[0])
}
if len(args) > 2 {
// The arguments are the positional parameters while it runs
saved := positional
positional = slices.Clone(args[2:])
defer func() { positional = saved }()
}
return sourceFile(args[0], args[1])
case "help":
@@ -1782,14 +1965,25 @@ Built-in commands:
exit [code] - Exit shell with status code (default: $?)
quit [code] - Quit shell with status code (default: $?)
set VAR=val - Set environment variable
set -- args - Set the positional parameters $1, $2, ...
export VAR=val - Export environment variable
source file - Run the commands of file in this shell (also: . file)
unset VAR - Remove a variable (unset -f name: a function)
source file [args] - Run the commands of file in this shell (also: . file)
jobs - List background and stopped jobs
fg [%n] - Continue job n (default: current job) in the foreground
bg [%n] - Continue stopped job n in the background
wait [%n] - Wait for job n or all background jobs
alias [name[=value] ...] - Define or show aliases (alias ll='ls -l')
unalias name - Remove an alias (unalias -a: all aliases)
: - Do nothing (exit status 0)
break [n] - Leave the innermost (or n-th) for/while/until loop
continue [n] - Continue with the next iteration of a loop
return [n] - Leave a function or sourced file with status n
local VAR[=val] - Variable that gets its old value back when the
function returns
shift [n] - Remove the first n positional parameters (default 1)
read [-r] [-p prompt] [VAR ...] - Read a line into variables (split
at $IFS; the last one gets the rest; default $REPLY)
help - Show this help
Features:
@@ -1812,10 +2006,25 @@ Features:
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});
using an unset variable is an error, use ${VAR:-} for an empty default
- Positional parameters: $0, $1 ... $9, ${10}, $# (count), "$@" (one
word each), "$*" (one word), $$ (process ID); set by gosh script
args, gosh -c command name args, functions, set -- and source
- Command substitution: $(command) is replaced by the output of command
(run in a subshell); unquoted it is split into words, "$(command)"
stays one word
- Command lists: a ; b (run both), a && b (b only if a succeeded),
a || b (b only if a failed); $? is the exit status of the last command
- Control structures: if cmd; then ...; elif cmd; then ...; else ...; fi,
for x in words; do ...; done, while cmd; do ...; done, until ...,
case word in pattern|pattern) ...;; esac, { cmd; cmd; } groups;
! cmd negates the status; they can span several lines (prompt "> ")
- Functions: name() { ...; } (or function name { ...; }); the arguments
are $1, $2, ...; in pipes, & and $(...) they run in a subshell
- Pipes (a | b) and redirections: < file, > file, >> file, 2> file,
2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file
- Job control: command & runs in the background, Ctrl+Z stops the
+180 -52
View File
@@ -3,6 +3,7 @@ package main
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"os/exec"
@@ -169,12 +170,12 @@ func TestExecInput_CdNoArgs(t *testing.T) {
// Save current directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
err := execInput("cd")
if err != nil {
t.Errorf("cd with no args should go to home, got error: %v", err)
}
// Verify we changed to home directory
home := getHomeDir()
newDir, _ := os.Getwd()
@@ -288,7 +289,7 @@ func TestShell_EOF_AfterCommand(t *testing.T) {
if err != nil {
t.Logf("Shell exited with: %v", err)
}
output := stdout.String()
// Should contain the echo output
if !strings.Contains(output, "test") {
@@ -312,7 +313,7 @@ func TestShell_SIGINT_HandlerSetup(t *testing.T) {
if err != nil {
t.Logf("Shell exited with: %v, stderr: %s", err, stderr.String())
}
output := stdout.String()
if !strings.Contains(output, "test") {
t.Errorf("expected 'test' in output, got: %s", output)
@@ -332,7 +333,7 @@ func TestExecInput_CdToHomeWithTilde(t *testing.T) {
if err != nil {
t.Errorf("cd ~ failed: %v", err)
}
home := getHomeDir()
newDir, _ := os.Getwd()
if newDir != home {
@@ -363,13 +364,13 @@ func TestExecInput_CdDash(t *testing.T) {
if err != nil {
t.Fatalf("cd to tmpDir failed: %v", err)
}
// Now cd - should go back
err = execInput("cd -")
if err != nil {
t.Fatalf("cd - failed: %v", err)
}
// Verify we're back in origDir
newDir, _ := os.Getwd()
if newDir != origDir {
@@ -382,7 +383,7 @@ func TestExecInput_CdDashNoPrevious(t *testing.T) {
origPrevDir := previousDir
defer func() { previousDir = origPrevDir }()
previousDir = ""
// cd - with no previous directory should return an error
err := execInput("cd -")
if err == nil {
@@ -399,7 +400,7 @@ func TestExecInput_CdDashNoPrevious(t *testing.T) {
func TestGetPrompt_Format(t *testing.T) {
prompt := getPrompt()
// Should contain username@hostname:path $ pattern
if !strings.Contains(prompt, "@") {
t.Errorf("expected @ in prompt, got: %s", prompt)
@@ -416,10 +417,10 @@ func TestGetPrompt_IncludesHome(t *testing.T) {
// Save and restore current directory
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
// Change to home directory
os.Chdir(getHomeDir())
prompt := getPrompt()
// When in home, should show ~
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
// So we just verify the function doesn't panic with a status code
// The actual exit behavior is tested in integration tests
// This test just documents the expected behavior
// Actual testing requires subprocess
_ = true // Placeholder - integration tests handle this
@@ -456,19 +457,19 @@ func TestShell_ExitWithStatusCode(t *testing.T) {
{"quit 0\n", 0},
{"quit 5\n", 5},
}
// Build the binary once
build := exec.Command("go", "build", "-o", "gosh_test", ".")
if err := build.Run(); err != nil {
t.Fatalf("failed to build: %v", err)
}
defer os.Remove("gosh_test")
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
cmd := exec.Command("./gosh_test")
cmd.Stdin = strings.NewReader(tc.input)
err := cmd.Run()
if err == nil && tc.expected != 0 {
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
orig := os.Getenv("GOSH_TEST_VAR")
defer os.Setenv("GOSH_TEST_VAR", orig)
// Set a new variable
err := execInput("set GOSH_TEST_VAR=test_value")
if err != nil {
t.Errorf("set failed: %v", err)
}
// Verify it was set
val := os.Getenv("GOSH_TEST_VAR")
if val != "test_value" {
@@ -615,13 +616,13 @@ func TestExecInput_ExportEnvVar(t *testing.T) {
// Save original value
orig := os.Getenv("GOSH_TEST_VAR2")
defer os.Setenv("GOSH_TEST_VAR2", orig)
// Export a new variable
err := execInput("export GOSH_TEST_VAR2=exported_value")
if err != nil {
t.Errorf("export failed: %v", err)
}
// Verify it was set
val := os.Getenv("GOSH_TEST_VAR2")
if val != "exported_value" {
@@ -841,7 +842,7 @@ func TestShellCompleter_Do(t *testing.T) {
want []string
length int
}{
{"c", []string{"d "}, 1},
{"c", []string{"d ", "ontinue "}, 1},
{"e", []string{"cho ", "xit ", "xport "}, 1},
{"gi", []string{"tty "}, 2},
{" gi", []string{"tty "}, 2},
@@ -892,6 +893,11 @@ func TestMain(m *testing.M) {
// Never read the system's startup files; tests that read the user's
// files set $HOME themselves
sysConfDir = filepath.Join(dir, "etc")
// Command substitution runs gosh, not the test binary
subshellPath = filepath.Join(dir, "gosh")
if out, err := exec.Command("go", "build", "-o", subshellPath, ".").CombinedOutput(); err != nil {
panic(fmt.Sprintf("building gosh: %v\n%s", err, out))
}
code := m.Run()
os.RemoveAll(dir)
os.Exit(code)
@@ -935,7 +941,7 @@ func TestSplitWords(t *testing.T) {
{`echo x${GOSH_A}y`, []string{"echo", "xa by"}},
// Escaped and lone dollar signs stay literal
{`echo \$GOSH_A "\$GOSH_A"`, []string{"echo", "$GOSH_A", "$GOSH_A"}},
{`echo $ 5$ $1`, []string{"echo", "$", "5$", "$1"}},
{`echo $ 5$ $%`, []string{"echo", "$", "5$", "$%"}},
// Backslash in double quotes only escapes $ " \ and `
{`echo "a\nb\\c"`, []string{"echo", `a\nb\c`}},
// Defaults
@@ -1381,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 {
input string
want []listItem
want string
}{
{"", nil},
{" ", nil},
{"# only a comment", nil},
{"ls", []listItem{{"", "ls", false}}},
{"a; b", []listItem{{"", "a", false}, {";", " b", false}}},
{"a;", []listItem{{"", "a", false}}},
{"a && b || c", []listItem{{"", "a ", false}, {"&&", " b ", false}, {"||", " c", false}}},
{`echo "a;b" 'c&&d' e\;f`, []listItem{{"", `echo "a;b" 'c&&d' e\;f`, false}}},
{`echo "x\"; y"`, []listItem{{"", `echo "x\"; y"`, false}}},
{"echo ${X:-a;b}; c", []listItem{{"", "echo ${X:-a;b}", false}, {";", " c", false}}},
{"echo a # b; c", []listItem{{"", "echo a ", false}}},
{"echo a#b; c", []listItem{{"", "echo a#b", false}, {";", " c", false}}},
{"echo a|b", []listItem{{"", "echo a|b", false}}},
{"a & b", []listItem{{"", "a ", true}, {";", " b", false}}},
{"a &", []listItem{{"", "a ", true}}},
{"a&", []listItem{{"", "a", true}}},
{"a 2>&1 &", []listItem{{"", "a 2>&1 ", true}}},
{"a &> f &", []listItem{{"", "a &> f ", true}}},
{"a >&2 && b", []listItem{{"", "a >&2 ", false}, {"&&", " b", false}}},
{`echo "a & b" \&`, []listItem{{"", `echo "a & b" \&`, false}}},
{"", ""},
{" ", ""},
{"# only a comment", ""},
{"ls", "[ls]"},
{"a; b", "[a]; [b]"},
{"a;", "[a]"},
{"a\nb\n", "[a]; [b]"},
{"a && b || c", "[a] && [b] || [c]"},
{"a &&\n b", "[a] && [b]"},
{"a | b|c", "[a | b|c]"},
{"a |\n b", "[a |\n b]"},
{`echo "a;b" 'c&&d' e\;f`, `[echo "a;b" 'c&&d' e\;f]`},
{`echo "x\"; y"`, `[echo "x\"; y"]`},
{"echo ${X:-a;b}; c", "[echo ${X:-a;b}]; [c]"},
{"echo a # b; c", "[echo a]"},
{"echo a#b; c", "[echo a#b]; [c]"},
{"a & b", "[a] &; [b]"},
{"a&", "[a] &"},
{"a 2>&1 &", "[a 2>&1] &"},
{"a &> f &", "[a &> f] &"},
{"a >&2 && b", "[a >&2] && [b]"},
{`echo "a & b" \&`, `[echo "a & b" \&]`},
{"echo $(a; b && c | d) \"$(e || f)\" & g", `[echo $(a; b && c | d) "$(e || f)"] &; [g]`},
{"! false", "! [false]"},
{"echo a \\\n b", "[echo a b]"},
// Reserved words are only special as the first word of a command
{"echo if then fi do done", "[echo if then fi do done]"},
}
for _, tc := range tests {
got, err := splitList(tc.input)
tree, err := parse(tc.input)
if err != nil {
t.Errorf("splitList(%q) returned error: %v", tc.input, err)
t.Errorf("parse(%q) returned error: %v", tc.input, err)
continue
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("splitList(%q) = %+v, want %+v", tc.input, got, tc.want)
if got := treeString(tree); got != tc.want {
t.Errorf("parse(%q) = %s, want %s", tc.input, got, tc.want)
}
}
}
func TestSplitList_SyntaxErrors(t *testing.T) {
for _, input := range []string{"; a", "a ;; b", "&& a", "a &&", "a ||", "a && ; b", "a || && b", "& a", "a & && b", "a & & b"} {
if _, err := splitList(input); err == nil || !strings.Contains(err.Error(), "syntax error") {
t.Errorf("splitList(%q) = %v, want syntax error", input, err)
func TestParse_ControlStructures(t *testing.T) {
tests := []struct {
input string
want string
}{
{"if a; then b; fi", "if [a] then [b] fi"},
{"if a\nthen\n b\n c\nfi", "if [a] then [b]; [c] fi"},
{"if a; then b; elif c; then d; else e; fi", "if [a] then [b] elif [c] then [d] else [e] fi"},
{"if a && ! b; then c; fi; echo x", "if [a] && ! [b] then [c] fi; [echo x]"},
{"for x in a b c; do echo $x; done", "for x in (a b c) do [echo $x] done"},
{"for x in *.go {1..3}\ndo\n echo $x\ndone", "for x in (*.go {1..3}) do [echo $x] done"},
{"for x; do echo; done", "for x do [echo] done"},
{"for x in; do echo; done", "for x in () do [echo] done"},
{"while a; do b; done", "while [a] do [b] done"},
{"until a; do b; done", "until [a] do [b] done"},
{"while a; do if b; then break; fi; done", "while [a] do if [b] then [break] fi done"},
{"for i in 1 2; do for j in 3 4; do echo $i$j; done; done", "for i in (1 2) do for j in (3 4) do [echo $i$j] done done"},
{"if a; then if b; then c; fi fi", "if [a] then if [b] then [c] fi fi"},
}
for _, tc := range tests {
tree, err := parse(tc.input)
if err != nil {
t.Errorf("parse(%q) returned error: %v", tc.input, err)
continue
}
if got := treeString(tree); got != tc.want {
t.Errorf("parse(%q) = %s, want %s", tc.input, got, tc.want)
}
}
}
func TestParse_Incomplete(t *testing.T) {
for _, input := range []string{
"if a", "if a; then", "if a; then b", "if a; then b; else", "if a; then b; elif c",
"for x", "for x in a b", "for x in a; do", "for x in a; do b",
"while a", "while a; do b", "until a; do",
"a &&", "a ||", "a |", "echo 'open", `echo "open`, "echo $(a", "echo a \\",
} {
if _, err := parse(input); !errors.Is(err, errIncomplete) {
t.Errorf("parse(%q) = %v, want incomplete", input, err)
}
}
}
func TestParse_SyntaxErrors(t *testing.T) {
for _, input := range []string{
"; a", "a ;; b", "&& a", "a && ; b", "a || && b", "& a", "a & && b", "a & & b",
"fi", "then", "done", "if a; then fi", "if ; then a; fi", "while a; do done",
"if a; then b; done", "for 1x in a; do b; done", "for x in a do b; done; x",
"a | | b", "| a",
} {
_, err := parse(input)
if err == nil || errors.Is(err, errIncomplete) {
t.Errorf("parse(%q) = %v, want syntax error", input, err)
}
}
for input, want := range map[string]string{
"for x in a; do b; done | cat": "control structures in pipelines are not supported",
"echo | while a; do b; done": "control structures in pipelines are not supported",
"if a; then b; fi > out": "redirections of control structures are not supported (near `>')",
"for x in a; do b; done &": "only simple commands and pipelines can run in the background",
"a && b &": "only simple commands and pipelines can run in the background",
} {
if _, err := parse(input); err == nil || err.Error() != want {
t.Errorf("parse(%q) = %v, want %q", input, err, want)
}
}
}
@@ -1546,7 +1674,7 @@ func TestTokenize_Operators(t *testing.T) {
}
// Patterns are tested in TestTokenize_Patterns
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) {
t.Errorf("tokenize(%q) = %+v, want %+v", tc.input, got, tc.want)
+644
View File
@@ -0,0 +1,644 @@
.\" Man page for gosh. View with: man ./packaging/man/gosh.1
.\" @VERSION@ is replaced by the release version when building packages.
.TH GOSH 1 "October 2026" "gosh @VERSION@" "User Commands"
.SH NAME
gosh \- a simple shell written in Go
.SH SYNOPSIS
.B gosh
.RB [ \-l ]
.RB [ \-i ]
.RB [ \-\-norc ]
.RB [ \-\-noprofile ]
.RI [ script
.RI [ args " ...]]"
.br
.B gosh
.RB [ \-l ]
.RB [ \-\-noprofile ]
.B \-c
.I command
.RI [ name
.RI [ args " ...]]"
.br
.B gosh
.B \-version
.SH DESCRIPTION
.B gosh
is a small command interpreter with a syntax close to
.BR sh (1).
It supports command history, tab completion, variables, positional
parameters, command substitution, command lists, control structures,
functions, pipes, redirections, wildcards, brace expansion, aliases and job
control.
.PP
Without arguments and with a terminal on standard input,
.B gosh
starts an interactive shell. With a
.I script
argument it runs the commands of that file, with
.B \-c
the given command string, and otherwise it reads commands from standard
input. The
.I args
after the script, or after the
.I name
of
.BR \-c ,
become the positional parameters
.BR $1 ,
.BR $2 ", ..."
.PP
.B gosh
is a proof of concept and not meant for production use.
.SH OPTIONS
.TP
.B \-c \fIcommand\fP [\fIname\fP [\fIargs\fP ...]]
Run
.I command
and exit.
.I name
becomes
.BR $0 ,
the
.I args
become
.BR $1 ,
.BR $2 ", ..."
.TP
.B \-i
Force interactive mode, even if standard input is not a terminal.
.TP
.BR \-l ", " \-\-login
Act as a login shell and read the profile files (see
.BR "STARTUP FILES" ).
A shell whose
.I argv[0]
starts with
.B \-
(as started by
.BR login (1),
.BR sshd (8)
or
.BR "su \-" )
is a login shell as well.
.TP
.B \-\-norc
Do not read the goshrc files.
.TP
.B \-\-noprofile
Do not read the profile files.
.TP
.BR \-version ", " \-\-version ", " \-V
Print the version, branch and commit and exit.
.TP
.BR \-h ", " \-\-help
Print a usage summary and exit.
.TP
.B \-
Read commands from standard input.
.SH NON-INTERACTIVE USE
Scripts, command strings and commands read from a pipe are executed line
by line. Empty lines and lines starting with
.B #
are skipped, so a script may start with
.BR "#!/usr/bin/gosh" .
A failing command does not abort the script; the exit status of
.B gosh
is the one of the last command.
.PP
Because
.B gosh
supports
.BR \-c ,
.BR ssh (1),
.BR scp (1)
and
.BR rsync (1)
work when it is used as login shell.
.SH STARTUP FILES
Startup files are executed in the running shell, so variables and
aliases defined there stay in effect.
.TP
Login shells
read
.I /etc/gosh/profile
and
.IR ~/.gosh_profile .
.TP
Interactive shells
read
.I /etc/gosh/goshrc
and
.IR ~/.goshrc .
.PP
An interactive login shell reads the profile files first, then the goshrc
files. Scripts and
.B \-c
read no startup files unless
.B gosh
is a login shell. Missing files are skipped; errors are reported with file
name and line number and the remaining lines still run. Files of other
shells such as
.I /etc/profile
or
.I ~/.bashrc
are not read.
.SH SYNTAX
.SS Quoting
.TP
.B \(aq...\(aq
Everything is literal.
.TP
.B \(dq...\(dq
Variables and command substitutions are expanded, the result stays one
word.
.TP
.B \e
Escapes the next character.
.SS Variables
.TP
.I name\fB=\fPvalue
Set a variable. All variables are exported to the environment of started
programs. The value is not split into words and not expanded as a pattern;
variables, command substitutions and
.B ~
(at the start, after
.B =
and after
.BR : )
are expanded.
.TP
.I name\fB=\fPvalue command
Set the variable only for
.IR command .
.TP
.BR $VAR ", " ${VAR}
The value of
.BR VAR .
Using an unset variable is an error and aborts the command.
.TP
.B ${VAR:\-default}
The value of
.BR VAR ,
or
.I default
if it is unset or empty. Use
.B ${VAR:\-}
for an empty default.
.TP
.B $?
The exit status of the last command.
.SS Positional parameters
.TP
.B $0
The name of the script, or the
.I name
given after
.BR "gosh \-c" .
.TP
.BR $1 " ... " $9 ", " ${10}
The arguments of the script, of
.BR "gosh \-c" ,
of a function call or of
.BR source ,
or the words set with
.BR "set \-\-" .
An unset parameter is an error like an unset variable; use
.B ${1:\-}
for an optional argument.
.TP
.B $#
The number of positional parameters.
.TP
.B \(dq$@\(dq
All positional parameters, each as a separate word. Without parameters it
expands to no word at all.
.TP
.B \(dq$*\(dq
All positional parameters as one word, separated by blanks.
.TP
.B $$
The process ID of the shell.
.SS Command substitution
.B $(\fIcommand\fP)
is replaced by the output of
.IR command ,
which runs in a subshell. Trailing newlines are removed. Unquoted, the
output is split into words at blanks and newlines; inside double quotes it
stays one word. Wildcards and operators in the output are not interpreted.
Backticks and arithmetic expansion are not supported.
.SS Tilde and brace expansion
.B ~
and
.B ~/path
at the start of an unquoted word are replaced by the home directory.
.PP
Brace expansion generates several words from one:
.B a{b,c}d
becomes
.BR "abd acd" ,
.B {1..5}
becomes
.BR "1 2 3 4 5" ,
.B {01..10..4}
becomes
.B 01 05 09
and
.B {a..e}
becomes
.BR "a b c d e" .
Brace expansion happens before all other expansions.
.SS Wildcards
.B *
matches any string,
.B ?
any single character,
.B [abc]
and
.B [a\-z]
one of the given characters and
.B [!abc]
(or
.BR [^abc] )
any character except those. Matches are sorted. Hidden files are only
matched if the pattern starts with a dot. A pattern without match is kept
unchanged. Quoted wildcards and values of variables are never expanded.
.SS Command lists
.TP
.IB a " ; " b
Run
.IR a ,
then
.IR b .
.TP
.IB a " && " b
Run
.I b
only if
.I a
succeeded.
.TP
.IB a " || " b
Run
.I b
only if
.I a
failed.
.TP
.IB command " &"
Run
.I command
in the background.
.SS Pipes and redirections
.TP
.IB a " | " b
Connect standard output of
.I a
to standard input of
.IR b .
The exit status is the one of
.IR b .
.TP
.BI < " file"
Read standard input from
.IR file .
.TP
.BR > " \fIfile\fP, " >> " \fIfile\fP"
Write (or append) standard output to
.IR file .
.TP
.BR 2> " \fIfile\fP, " 2>> " \fIfile\fP"
Write (or append) standard error to
.IR file .
.TP
.B 2>&1
Redirect standard error to where standard output goes.
.TP
.B >&2
Redirect standard output to where standard error goes.
.TP
.BR &> " \fIfile\fP, " &>> " \fIfile\fP"
Write (or append) standard output and standard error to
.IR file .
.PP
Redirections are evaluated from left to right. Only file descriptors 0, 1
and 2 are supported.
.SS Control structures
.nf
\fBif\fP \fIlist\fP; \fBthen\fP \fIlist\fP; [\fBelif\fP \fIlist\fP; \fBthen\fP \fIlist\fP;] [\fBelse\fP \fIlist\fP;] \fBfi\fP
\fBfor\fP \fIname\fP \fBin\fP \fIwords\fP; \fBdo\fP \fIlist\fP; \fBdone\fP
\fBwhile\fP \fIlist\fP; \fBdo\fP \fIlist\fP; \fBdone\fP
\fBuntil\fP \fIlist\fP; \fBdo\fP \fIlist\fP; \fBdone\fP
\fBcase\fP \fIword\fP \fBin\fP [\fB(\fP]\fIpattern\fP[\fB|\fP\fIpattern\fP]...\fB)\fP \fIlist\fP \fB;;\fP ... \fBesac\fP
\fB{\fP \fIlist\fP; \fB}\fP
\fB!\fP \fIcommand\fP
.fi
.PP
Control structures may span several lines; an incomplete command shows
the continuation prompt
.BR "> " .
.B !
negates the exit status of a command.
.B for
without
.B in
loops over the positional parameters.
.PP
.B case
runs the list of the first clause with a pattern that matches
.IR word .
Patterns are wildcards as for file names, but
.B *
and
.B ?
also match a
.BR / ;
quoted characters match literally, and values of variables are not
patterns. The
.B ;;
of the last clause may be omitted.
.PP
.B {
.IR list ;
.B }
groups commands; they run in the current shell.
.PP
Control structures cannot be used in pipes, with redirections or in the
background, and there are no
.B ( ... )
subshells.
.SS Functions
.nf
\fIname\fP\fB() {\fP \fIlist\fP; \fB}\fP
\fBfunction\fP \fIname\fP \fB{\fP \fIlist\fP; \fB}\fP
.fi
.PP
defines a function; the body can be any compound command. When the
function is called, its arguments are the positional parameters, which get
their old values back afterwards. Functions run in the current shell, so
variables they set stay set; use
.B local
for variables that belong to the function. Redirections of a call apply to
all commands of the function. In pipes, in the background and in
.B $(...)
a function runs in a subshell that knows all functions. Functions take
precedence over builtins and programs of the same name. Calls can be nested
up to a depth of 1000.
.SS Aliases
In interactive shells, the first word of a command is replaced by its
alias. If the alias value ends with a blank, the next word is checked for
an alias as well.
.B \e\fIname\fP
or
.B \(aq\fIname\fP\(aq
skips the alias. Aliases are not used in scripts and with
.BR \-c .
.SH BUILTIN COMMANDS
.TP
.BR cd " [\fIdir\fP]"
Change the directory; without argument to the home directory.
.TP
.B pwd
Print the working directory.
.TP
.BR echo " [\fIargs\fP]"
Print the arguments.
.TP
.BR exit " [\fIcode\fP], " quit " [\fIcode\fP]"
Exit the shell with status
.I code
(default:
.BR $? ).
.TP
.BI set " VAR" = value
Set a variable.
.TP
.BR "set \-\-" " [\fIargs\fP ...]"
Set the positional parameters.
.TP
.BI export " VAR" = value
Export a variable.
.TP
.BR unset " [\fB\-v\fP] \fIVAR\fP ..., " "unset \-f" " \fIname\fP ..."
Remove variables, or functions.
.TP
.BR source " \fIfile\fP [\fIargs\fP ...], " . " \fIfile\fP [\fIargs\fP ...]"
Run the commands of
.I file
in the current shell, with
.I args
as positional parameters if given.
.TP
.B jobs
List background and stopped jobs.
.TP
.BR fg " [\fB%\fP\fIn\fP]"
Continue job
.I n
(default: the current job) in the foreground.
.TP
.BR bg " [\fB%\fP\fIn\fP]"
Continue stopped job
.I n
in the background.
.TP
.BR wait " [\fB%\fP\fIn\fP]"
Wait for job
.I n
or for all background jobs.
.TP
.BR alias " [\fIname\fP[\fB=\fP\fIvalue\fP] ...]"
Define or show aliases.
.TP
.BR unalias " \fIname\fP, " "unalias \-a"
Remove an alias, or all aliases.
.TP
.B :
Do nothing, exit status 0.
.TP
.BR break " [\fIn\fP], " continue " [\fIn\fP]"
Leave the innermost (or
.IR n -th)
loop, or continue with its next iteration.
.TP
.BR return " [\fIn\fP]"
Leave a function or sourced file with status
.I n
(default:
.BR $? ).
.TP
.BR local " \fIVAR\fP[\fB=\fP\fIvalue\fP] ..."
In a function: the variable gets its previous value back when the function
returns. Without a value it is unset.
.TP
.BR shift " [\fIn\fP]"
Remove the first
.I n
(default 1) positional parameters.
.TP
.BR read " [\fB\-r\fP] [\fB\-p\fP \fIprompt\fP] [\fIVAR\fP ...]"
Read a line from standard input and split it into fields at the
characters of
.B IFS
(default blank, tab and newline). Each
.I VAR
gets one field, the last one the rest of the line; without
.I VAR
the whole line is stored in
.BR REPLY .
Without
.BR \-r ,
a backslash escapes the next character and continues the line at its end.
.B \-p
prints
.I prompt
if the input is a terminal. The exit status is 1 at the end of the input.
.TP
.B help
Show a summary of builtins and features.
.SH JOB CONTROL
In interactive shells, each command line runs as a job in its own process
group.
.B Ctrl+Z
stops the foreground job,
.B Ctrl+C
interrupts it.
.BR jobs ,
.BR fg ,
.B bg
and
.B wait
manage the jobs;
.B %n
or
.I n
refers to job
.IR n ,
.B %\-
to the previous job. Finished background jobs are reported before the
next prompt. Exiting with stopped jobs warns once.
.SH INTERACTIVE USE
.TP
.B Up/Down
Browse the command history.
.TP
.B Tab
Complete commands, aliases and file names.
.TP
.B Ctrl+C
Cancel the current line or command.
.TP
.B Ctrl+D
Exit the shell.
.PP
Lines starting with a space are not saved in the history.
.PP
The prompt shows
.IR user @ host : dir
followed by
.BR $ .
If the last command failed, its exit status is shown as
.BI [ N ]
before the
.BR $ .
.SH ENVIRONMENT
.TP
.B GOSH_HISTFILE
Path of the history file. If set to an empty value, no history is saved.
.TP
.B XDG_STATE_HOME
Base directory of the default history file
.IR $XDG_STATE_HOME/gosh/history .
.TP
.B GOSH_COLORS
Prompt colors as colon-separated
.IB key = code
entries. Keys are
.B user
(default
.BR 1;32 ),
.B root
.RB ( 1;31 ),
.B path
.RB ( 1;34 )
and
.B error
.RB ( 1;31 );
values are ANSI SGR codes, an empty value disables the color. Example:
.B GOSH_COLORS=\(aquser=1;33:path=36\(aq
.TP
.B NO_COLOR
If set, the prompt is not colored. Colors are also disabled with
.B TERM=dumb
or when the output is not a terminal.
.TP
.B HOME
Used for
.B ~
and
.B cd
without argument.
.TP
.B PATH
Directories searched for commands.
.TP
.B IFS
Field separators for
.BR read .
.SH FILES
.TP
.I /etc/gosh/profile
System-wide profile for login shells.
.TP
.I ~/.gosh_profile
Personal profile for login shells.
.TP
.I /etc/gosh/goshrc
System-wide startup file for interactive shells.
.TP
.I ~/.goshrc
Personal startup file for interactive shells.
.TP
.I ~/.local/state/gosh/history
Default history file (mode 0600).
.SH EXIT STATUS
The exit status of the last command, or the argument of
.BR exit .
Typical values:
.TP
.B 1
General error.
.TP
.B 2
Syntax error or invalid option.
.TP
.B 126
Command not executable.
.TP
.B 127
Command not found.
.TP
.BI 128+ n
Command terminated by signal
.I n
(130 for Ctrl+C).
.SH EXAMPLES
.nf
gosh \-c \(aqcd /srv && git log | head\(aq
for f in *.log; do gzip "$f"; done
cp config.yaml{,.bak}
make > build.log 2>&1 || echo "failed: $?"
LANG=C sort file
alias ll=\(aqls \-l\(aq
greet() { echo "Hello ${1:\-World}"; }
case "$1" in start|stop) echo "$1ing" ;; *) echo "usage: $0 start|stop" ;; esac
read \-r \-p \(aqName? \(aq name
chsh \-s /usr/bin/gosh
.fi
.SH SEE ALSO
.BR sh (1),
.BR bash (1),
.BR chsh (1),
.BR shells (5)
.SH AUTHOR
Florian Walther <florian.walther@certainity.com>
+1178
View File
File diff suppressed because it is too large. Load diff
+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)
}
}
+215
View File
@@ -0,0 +1,215 @@
package main
// The read builtin: read [-r] [-p prompt] [name ...] reads a line and splits
// it into fields at the characters of $IFS (default: blank, tab, newline).
import (
"errors"
"fmt"
"io"
"os"
"os/signal"
"strings"
"syscall"
"github.com/chzyer/readline"
"golang.org/x/sys/unix"
)
// builtinRead implements read. Each name gets one field, the last one the
// rest of the line; without names the whole line is stored in $REPLY. The
// status is 1 at the end of the input.
func builtinRead(args []string, stdin *os.File) error {
raw := false
prompt := ""
args = args[1:]
for len(args) > 0 && strings.HasPrefix(args[0], "-") && args[0] != "-" {
opt := args[0]
args = args[1:]
if opt == "--" {
break
}
for k := 1; k < len(opt); k++ {
switch opt[k] {
case 'r':
raw = true
case 'p':
switch {
case k+1 < len(opt):
prompt = opt[k+1:]
case len(args) > 0:
prompt, args = args[0], args[1:]
default:
return errors.New("read: -p: option requires an argument")
}
k = len(opt)
default:
return fmt.Errorf("read: -%c: invalid option\nusage: read [-r] [-p prompt] [name ...]", opt[k])
}
}
}
for _, name := range args {
if !isVarName(name) {
return fmt.Errorf("read: `%s': not a valid identifier", sanitize(name))
}
}
terminal := readline.IsTerminal(int(stdin.Fd()))
if prompt != "" && terminal {
fmt.Fprint(stdio(2), prompt)
}
line, literal, eof, err := readInputLine(stdin, raw, interactiveShell && terminal)
if err != nil {
return err
}
if len(args) == 0 {
os.Setenv("REPLY", line)
} else {
ifs, ok := os.LookupEnv("IFS")
if !ok {
ifs = " \t\n"
}
fields := splitFields(line, literal, ifs, len(args))
for i, name := range args {
value := ""
if i < len(fields) {
value = fields[i]
}
os.Setenv(name, value)
}
}
if eof {
return statusError(1)
}
return nil
}
// readInputLine reads a line from f, byte by byte so that nothing after the
// line is consumed. Unless raw is set, a backslash escapes the next
// character (literal marks the escaped characters, which never split
// fields) and backslash-newline continues the line. eof reports that the
// input ended before a newline. If interruptible is set, Ctrl+C aborts the
// read with status 130.
func readInputLine(f *os.File, raw, interruptible bool) (line string, literal []bool, eof bool, err error) {
readByte := plainByteReader(f)
if interruptible {
intr := make(chan os.Signal, 1)
signal.Notify(intr, os.Interrupt)
defer signal.Stop(intr)
readByte = interruptibleByteReader(f, intr)
}
var b []byte
for {
c, err := readByte()
if err == io.EOF {
return string(b), literal, true, nil
}
if err != nil {
return "", nil, false, err
}
if c == '\n' {
return string(b), literal, false, nil
}
if c == '\\' && !raw {
c, err = readByte()
if err == io.EOF {
return string(b), literal, true, nil
}
if err != nil {
return "", nil, false, err
}
if c == '\n' {
continue // the line goes on
}
b = append(b, c)
literal = append(literal, true)
continue
}
b = append(b, c)
literal = append(literal, false)
}
}
func plainByteReader(f *os.File) func() (byte, error) {
buf := make([]byte, 1)
return func() (byte, error) {
for {
n, err := f.Read(buf)
if n == 1 {
return buf[0], nil
}
if err != nil {
return 0, err
}
}
}
}
// interruptibleByteReader reads from the terminal f only when input is
// available, so that a SIGINT on intr can end the read in between.
func interruptibleByteReader(f *os.File, intr chan os.Signal) func() (byte, error) {
next := plainByteReader(f)
fds := []unix.PollFd{{Fd: int32(f.Fd()), Events: unix.POLLIN}}
return func() (byte, error) {
for {
select {
case <-intr:
fmt.Fprintln(stdio(2))
return 0, statusError(128 + int(syscall.SIGINT))
default:
}
n, err := unix.Poll(fds, 100)
if err != nil && !errors.Is(err, unix.EINTR) {
return 0, err
}
if n > 0 {
return next()
}
}
}
}
// splitFields splits line into at most n fields like read does: IFS
// whitespace (blank, tab, newline in ifs) around fields is removed, other IFS
// characters separate fields (also empty ones). The last field is the rest of
// the line. Characters marked in literal are never separators.
func splitFields(line string, literal []bool, ifs string, n int) []string {
isSep := func(i int) bool { return !literal[i] && strings.IndexByte(ifs, line[i]) >= 0 }
isSpace := func(i int) bool { return isSep(i) && strings.IndexByte(" \t\n", line[i]) >= 0 }
start, end := 0, len(line)
for start < end && isSpace(start) {
start++
}
for end > start && isSpace(end-1) {
end--
}
var fields []string
i := start
for i < end && len(fields) < n-1 {
j := i
for j < end && !isSep(j) {
j++
}
fields = append(fields, line[i:j])
// The separator: whitespace, at most one other IFS character,
// whitespace
for j < end && isSpace(j) {
j++
}
if j < end && isSep(j) && !isSpace(j) {
j++
for j < end && isSpace(j) {
j++
}
}
i = j
}
if i < end {
fields = append(fields, line[i:end])
}
return fields
}
+109
View File
@@ -0,0 +1,109 @@
package main
// Command substitution: $(command) is replaced by the output of command,
// which runs in a subshell (a new gosh process), so that cd, set etc. in it
// do not change the shell. Functions also run in a subshell in pipelines and
// in the background.
import (
"bytes"
"errors"
"os"
"os/exec"
"strings"
"syscall"
)
// subshellPath is the gosh binary used for command substitution; empty means
// the running executable. Tests set it, since there the running executable is
// the test binary.
var subshellPath string
// substStatus is the exit status of the last command substitution of the
// current command (a command that consists only of a substitution has this
// status, like in sh).
var substStatus int
// isSubstStart reports whether a command substitution $( starts at s[i].
func isSubstStart(s string, i int) bool {
return s[i] == '$' && i+1 < len(s) && s[i+1] == '('
}
// substEnd returns the index of the ) that ends the command substitution
// starting with $( at s[i], or -1 if it is not terminated. Quotes, escapes
// and nested substitutions inside are taken into account.
func substEnd(s string, i int) int {
depth := 0
var quote byte
for j := i + 1; j < len(s); j++ {
ch := s[j]
switch {
case quote == '\'':
if ch == '\'' {
quote = 0
}
case ch == '\\':
j++
case quote == '"':
if ch == '"' {
quote = 0
} else if isSubstStart(s, j) {
end := substEnd(s, j)
if end < 0 {
return -1
}
j = end
}
case ch == '"' || ch == '\'':
quote = ch
case ch == '(':
depth++
case ch == ')':
depth--
if depth == 0 {
return j
}
}
}
return -1
}
// subshellCommand returns a command that runs script in a new gosh process
// with the same $0, the given positional parameters and the functions of the
// shell. (Variables need not be passed: they are all in the environment.)
func subshellCommand(script string, params []string) (*exec.Cmd, error) {
path := subshellPath
if path == "" {
var err error
if path, err = os.Executable(); err != nil {
return nil, err
}
}
args := append([]string{"-c", functionDefs() + script, scriptName}, params...)
return exec.Command(path, args...), nil
}
// commandSubst runs command in a subshell and returns its output without
// trailing newlines. stdin and stderr are those of the shell.
func commandSubst(command string) (string, error) {
if strings.HasPrefix(command, "(") {
return "", errors.New("arithmetic expansion $((...)) is not supported")
}
var out bytes.Buffer
cmd, err := subshellCommand(command, positional)
if err != nil {
return "", err
}
cmd.Stdin, cmd.Stdout, cmd.Stderr = stdio(0), &out, stdio(2)
err = cmd.Run()
substStatus = exitStatus(err)
if substStatus == 128+int(syscall.SIGINT) {
// Ctrl+C aborts the whole command, like in bash
return "", statusError(substStatus)
}
var exitErr *exec.ExitError
if err != nil && !errors.As(err, &exitErr) {
return "", err
}
return strings.TrimRight(out.String(), "\n"), nil
}
+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)
}
}
}