Compare commits

...
3 Commits
Author SHA1 Message Date
Florian WaltherandClaude Opus 5.5 dae4087a94 add job control
- in an interactive shell every pipeline runs in its own process group
  and gets the terminal while it runs in the foreground
- Ctrl+Z stops the foreground job, command & starts a job in the
  background; finished and stopped jobs are reported before the prompt
- new builtins jobs, fg, bg and wait (wait also works without job
  control, e.g. in scripts, where background jobs read from /dev/null)
- terminal modes are saved and restored when a job stops or continues
- exit and Ctrl+D warn once if there are stopped jobs
- processes are waited for with wait4 (WUNTRACED) instead of exec.Cmd.Wait
- pty based test for job control (Linux)

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:08:23 +02:00
Florian WaltherandClaude Opus 5.5 1182d8d146 add wildcards (*, ?, [...])
- unquoted * ? and [...] (including [!...]) are expanded to the
  matching file names, sorted, per path component
- hidden files only match if the pattern starts with a dot, a trailing /
  only matches directories, a pattern without match is kept as is
- quoted or escaped characters and values of variables are never
  expanded
- redirections to a pattern must match exactly one file
- completion escapes * ? [ and $ in file names

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:44:58 +02:00
6 changed files with 1597 additions and 117 deletions

No files matched your search

+116 -4
View File
@@ -12,6 +12,11 @@ Built-in commands:
quit [code] - Quit shell with status code (default: $?) quit [code] - Quit shell with status code (default: $?)
set VAR=val - Set environment variable set VAR=val - Set environment variable
export VAR=val - Export environment variable export VAR=val - Export environment variable
source file - Run the commands of file in this shell (also: . file)
jobs - List background and stopped jobs
fg [%n] - Continue job n (default: current job) in the foreground
bg [%n] - Continue stopped job n in the background
wait [%n] - Wait for job n or all background jobs
help - Show this help help - Show this help
Features: Features:
@@ -23,6 +28,9 @@ Features:
override with $GOSH_HISTFILE); lines starting with a space are not saved override with $GOSH_HISTFILE); lines starting with a space are not saved
- Tab completion for commands and file paths - Tab completion for commands and file paths
- Tilde expansion (~, ~/path) at the start of unquoted words - Tilde expansion (~, ~/path) at the start of unquoted words
- Wildcards: * ? [abc] [a-z] [!abc] match file names (not hidden files
unless the pattern starts with a dot); quoted wildcards and values of
variables are not expanded, a pattern without match is kept as is
- Colored prompt (user@host green, red for root; directory blue), - Colored prompt (user@host green, red for root; directory blue),
configurable with $GOSH_COLORS, e.g. GOSH_COLORS='user=1;33:path=36' configurable with $GOSH_COLORS, e.g. GOSH_COLORS='user=1;33:path=36'
(keys user, root, path; values are ANSI SGR codes, empty = no color); (keys user, root, path; values are ANSI SGR codes, empty = no color);
@@ -33,16 +41,21 @@ Features:
a || b (b only if a failed); $? is the exit status of the last command a || b (b only if a failed); $? is the exit status of the last command
- Pipes (a | b) and redirections: < file, > file, >> file, 2> file, - Pipes (a | b) and redirections: < file, > file, >> file, 2> file,
2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file 2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file
- Job control: command & runs in the background, Ctrl+Z stops the
foreground job; jobs, fg, bg and wait manage the jobs
- Non-interactive use: gosh -c 'command', gosh script, command | gosh; - Non-interactive use: gosh -c 'command', gosh script, command | gosh;
the exit status is the one of the last command the exit status is the one of the last command
- Startup files: login shells (-l or argv[0] starting with -) read
/etc/gosh/profile and ~/.gosh_profile, interactive shells read
/etc/gosh/goshrc and ~/.goshrc (skip with --noprofile, --norc)
``` ```
### Aufruf ### Aufruf
``` ```
gosh [-l] [-i] [script] gosh [-l] [-i] [--norc] [--noprofile] [script]
gosh [-l] -c command gosh [-l] [--noprofile] -c command
gosh -version gosh -version
``` ```
@@ -53,12 +66,50 @@ gosh -version
| `gosh skript.gosh` | führt das Skript Zeile für Zeile aus | | `gosh skript.gosh` | führt das Skript Zeile für Zeile aus |
| `befehle \| gosh` | liest die Befehle von stdin | | `befehle \| gosh` | liest die Befehle von stdin |
| `-i` | erzwingt den interaktiven Modus, auch ohne Terminal | | `-i` | erzwingt den interaktiven Modus, auch ohne Terminal |
| `-l`, `--login` | Login-Shell (wird akzeptiert, eine Startdatei gibt es noch nicht) | | `-l`, `--login` | Login-Shell, liest die Profil-Dateien (siehe unten) |
| `--norc` | `goshrc`-Dateien nicht lesen |
| `--noprofile` | `profile`-Dateien nicht lesen |
Ohne Terminal bricht gosh bei einem fehlgeschlagenen Befehl nicht ab und beendet sich mit dem Exit-Status des letzten Befehls: `127`, wenn ein Befehl nicht gefunden wurde, `126`, wenn er nicht ausführbar ist, `128+n`, wenn er durch Signal `n` beendet wurde. Leere Zeilen und Zeilen, die mit `#` beginnen, werden übersprungen, Skripte können also mit `#!/usr/bin/gosh` beginnen. Ohne Terminal bricht gosh bei einem fehlgeschlagenen Befehl nicht ab und beendet sich mit dem Exit-Status des letzten Befehls: `127`, wenn ein Befehl nicht gefunden wurde, `126`, wenn er nicht ausführbar ist, `128+n`, wenn er durch Signal `n` beendet wurde. Leere Zeilen und Zeilen, die mit `#` beginnen, werden übersprungen, Skripte können also mit `#!/usr/bin/gosh` beginnen.
Damit funktionieren auch `ssh host befehl`, `scp` und `rsync`, wenn gosh als Login-Shell gesetzt ist. Befehle lassen sich mit `;`, `&&` und `||` verketten und mit Pipes verbinden, z. B. `ssh host 'cd /srv && git log | head'`. Damit funktionieren auch `ssh host befehl`, `scp` und `rsync`, wenn gosh als Login-Shell gesetzt ist. Befehle lassen sich mit `;`, `&&` und `||` verketten und mit Pipes verbinden, z. B. `ssh host 'cd /srv && git log | head'`.
### Startdateien
Beim Start liest gosh Startdateien und führt sie wie ein Skript in der laufenden Shell aus (Variablen, die dort gesetzt werden, bleiben also erhalten):
| Shell | Dateien (in dieser Reihenfolge) | abschalten mit |
|-------------------------------|----------------------------------------------|-----------------|
| Login-Shell | `/etc/gosh/profile`, `~/.gosh_profile` | `--noprofile` |
| interaktive Shell | `/etc/gosh/goshrc`, `~/.goshrc` | `--norc` |
- Eine Login-Shell ist gosh mit `-l` oder wenn `argv[0]` mit `-` beginnt (so starten `login`, `sshd` und `su -` die Shell). Eine interaktive Login-Shell liest erst die Profil-, dann die `goshrc`-Dateien.
- `gosh -c '…'` und Skripte lesen keine Startdateien, außer als Login-Shell (`gosh -l -c '…'`).
- Fehlende Dateien werden übersprungen. Fehler werden mit Datei und Zeile gemeldet (`gosh: ~/.goshrc:4: foo: command not found`), die übrigen Zeilen laufen trotzdem.
- Bash-Dateien wie `/etc/profile` oder `~/.bashrc` liest gosh bewusst nicht, weil es deren Syntax (`if`, Funktionen, …) nicht versteht.
- Mit `source datei` (oder `. datei`) lässt sich eine Datei auch später einlesen, z. B. nach einer Änderung: `source ~/.goshrc`.
Beispiel für `~/.goshrc`:
```bash
# Eigene Programme zuerst
export PATH=$HOME/bin:$HOME/go/bin:$PATH
export EDITOR=vim
# Prompt-Farben (Anführungszeichen wegen des ;)
export GOSH_COLORS='user=1;38;5;208:path=38;5;205'
# Weitere Einstellungen nur, wenn die Datei existiert
test -f ~/.goshrc.local && source ~/.goshrc.local
```
Beispiel für `~/.gosh_profile` (wird nur von Login-Shells gelesen, z. B. beim Anmelden an der Konsole oder per `ssh host` ohne Befehl):
```bash
export LANG=de_DE.UTF-8
export PATH=$HOME/bin:$PATH
```
### Befehlslisten und Exit-Status ### Befehlslisten und Exit-Status
Mehrere Befehle in einer Zeile werden wie in `sh` verknüpft: Mehrere Befehle in einer Zeile werden wie in `sh` verknüpft:
@@ -83,6 +134,32 @@ Status 127
Schlägt ein Programm fehl, gibt gosh selbst keine Meldung mehr aus (das Programm meldet den Fehler selbst), nur bei Fehlern von gosh, z. B. wenn ein Befehl nicht gefunden wird. Schlägt ein Programm fehl, gibt gosh selbst keine Meldung mehr aus (das Programm meldet den Fehler selbst), nur bei Fehlern von gosh, z. B. wenn ein Befehl nicht gefunden wird.
### Wildcards
Wörter mit `*`, `?` oder `[...]` werden durch die passenden Dateinamen ersetzt, sortiert:
| Muster | passt auf |
|-----------|-----------------------------------------------------|
| `*` | beliebig viele Zeichen (auch keine) |
| `?` | genau ein Zeichen |
| `[abc]` | eines der Zeichen `a`, `b`, `c` |
| `[a-z]` | ein Zeichen aus dem Bereich |
| `[!abc]` | ein Zeichen außer `a`, `b`, `c` (auch `[^abc]`) |
```
$ ls *.go
main.go main_test.go
$ cat logs/2026-*/error.log
$ ls -d */ # nur Verzeichnisse
```
- Muster gelten pro Pfadteil, `*` passt nie auf `/`: `*/*.go` findet `.go`-Dateien eine Ebene tiefer.
- Versteckte Dateien (mit `.` am Anfang) werden nur gefunden, wenn das Muster selbst mit `.` beginnt: `*` findet `.bashrc` nicht, `.*rc` schon.
- Passt keine Datei, bleibt das Wort unverändert stehen (wie in `sh`), `ls *.xyz` meldet dann also `ls` den Fehler.
- In Anführungszeichen oder mit `\` davor sind die Zeichen normale Zeichen: `echo "*"`, `echo '*.go'`, `echo \*`.
- Werte von Variablen werden nicht als Muster behandelt (anders als in `sh`): Nach `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.
- Bei einer Umleitung muss das Muster genau eine Datei treffen (`> *.log`), sonst meldet gosh `ambiguous redirect`.
### Pipes und Umleitungen ### Pipes und Umleitungen
``` ```
@@ -108,7 +185,42 @@ $ grep -c error < build.log
- Operatoren in Anführungszeichen oder mit `\` davor sind normale Zeichen: `echo "a|b" 'c>d' e\>f`. - Operatoren in Anführungszeichen oder mit `\` davor sind normale Zeichen: `echo "a|b" 'c>d' e\>f`.
- Der Exit-Status einer Pipeline ist der des letzten Befehls. Beendet sich ein Befehl am Ende (z. B. `head`), bekommt der schreibende Befehl davor `SIGPIPE`, `yes | head -1` endet also sofort. - Der Exit-Status einer Pipeline ist der des letzten Befehls. Beendet sich ein Befehl am Ende (z. B. `head`), bekommt der schreibende Befehl davor `SIGPIPE`, `yes | head -1` endet also sofort.
- Die Built-ins `pwd` und `help` lassen sich umleiten und in Pipes verwenden (`help | less`). `cd`, `set`, `export`, `exit` und `quit` verändern die Shell selbst und sind in einer Pipeline nicht erlaubt. - Die Built-ins `pwd` und `help` lassen sich umleiten und in Pipes verwenden (`help | less`). `cd`, `set`, `export`, `exit` und `quit` verändern die Shell selbst und sind in einer Pipeline nicht erlaubt.
- Ein einzelnes `&` (Hintergrundprozesse) gibt es noch nicht, es wird als normales Zeichen übergeben.
### Job-Control
In einer interaktiven Shell läuft jede Befehlszeile (bzw. jede Pipeline) als eigener *Job* in einer eigenen Prozessgruppe:
```
$ sleep 100 & # im Hintergrund starten
[1] 12345
$ vim notizen.txt # Ctrl+Z hält vim an
^Z
[2]+ Stopped vim notizen.txt
$ jobs
[1]- Running sleep 100 &
[2]+ Stopped vim notizen.txt
$ fg # vim wieder im Vordergrund
$ bg %2 # oder: angehaltenen Job im Hintergrund weiterlaufen lassen
```
| Befehl / Taste | Bedeutung |
|----------------|---------------------------------------------------------------------------|
| `befehl &` | startet den Befehl im Hintergrund, gosh wartet nicht auf ihn (`$?` ist 0) |
| Ctrl+Z | hält den Vordergrund-Job an (`$?` wird 128+SIGTSTP, unter Linux 148) |
| Ctrl+C | bricht nur den Vordergrund-Job ab, nicht die Shell |
| `jobs` | listet laufende und angehaltene Jobs |
| `fg [%n]` | holt Job `n` in den Vordergrund (und setzt ihn fort, falls angehalten) |
| `bg [%n]` | setzt einen angehaltenen Job im Hintergrund fort |
| `wait [%n]` | wartet auf Job `n` bzw. auf alle Hintergrund-Jobs |
- Ohne Angabe gilt der *aktuelle* Job (in `jobs` mit `+` markiert, der zuletzt angehaltene oder in den Hintergrund geschickte), `%-` ist der vorherige (`-`). `%n` und `n` meinen Job Nummer `n`.
- Beendete Hintergrund-Jobs meldet gosh vor dem nächsten Prompt (`[1]+ Done sleep 100`, bei Fehlern `Exit N`).
- Liest ein Hintergrund-Job vom Terminal, wird er angehalten (wie in bash), mit `fg` bekommt er die Eingabe.
- Die Terminal-Einstellungen werden gesichert und wiederhergestellt: Nach dem Anhalten von `vim` oder `less` funktioniert der Prompt normal, nach `fg` bekommt das Programm seine Einstellungen zurück.
- `exit` (oder Ctrl+D) mit angehaltenen Jobs warnt einmal (`there are stopped jobs`), ein zweites `exit` direkt danach beendet gosh.
- `cd`, `set`, `export`, `exit`, `source`, `fg`, `bg` und `wait` lassen sich nicht mit `&` im Hintergrund starten.
- In Skripten und mit `gosh -c` gibt es keine Job-Control (kein Ctrl+Z, kein `fg`/`bg`), `&` und `wait` funktionieren aber. Hintergrund-Jobs lesen dort von `/dev/null`.
- Einen Job beenden lässt sich mit `kill` über die Prozess-ID (z. B. `kill 12345`); `kill %1` versteht gosh noch nicht.
### Farben ### Farben
+1 -1
View File
@@ -7,4 +7,4 @@ require (
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
) )
require golang.org/x/sys v0.48.0 // indirect require golang.org/x/sys v0.48.0
+525
View File
@@ -0,0 +1,525 @@
package main
// Job control: in an interactive shell every pipeline runs in its own process
// group, which gets the terminal while it runs in the foreground. Ctrl+Z stops
// the foreground job, "&" starts a job in the background, and the builtins
// jobs, fg, bg and wait manage the jobs. Without a terminal (scripts, gosh -c)
// there is no job control, but "&" and wait still work.
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"strconv"
"strings"
"syscall"
"github.com/chzyer/readline"
"golang.org/x/sys/unix"
)
// proc is a stage of a job: a process, or a builtin or a stage that failed to
// start (both without process).
type proc struct {
cmd *exec.Cmd // nil if the stage is no process
done chan error // the result of a stage without process
finished bool
stopped bool
err error
}
// job is a pipeline that was started by the shell.
type job struct {
id int // number in the job table, 0 if not in the table
pgid int // process group, 0 without job control or processes
text string // the command line
procs []*proc
tty *readline.State // terminal modes of the job when it was stopped
}
var (
jobControl bool // job control is enabled
interactiveShell bool // the shell reads commands from a user
ttyFd int // the terminal of the shell
shellPgid int // the process group of the shell
origPgid int // the foreground process group at start
shellTTY *readline.State // terminal modes of the shell
jobTable []*job // background and stopped jobs
exitWarned bool // "there are stopped jobs" was shown
)
// initJobControl enables job control if stdin is a terminal: the shell moves
// into its own process group and takes the terminal.
func initJobControl() {
fd := int(os.Stdin.Fd())
if !readline.IsTerminal(fd) {
return
}
pgrp, err := unix.IoctlGetInt(fd, unix.TIOCGPGRP)
if err != nil {
return
}
pid := os.Getpid()
if syscall.Getpgrp() != pid {
if err := syscall.Setpgid(0, 0); err != nil {
return
}
}
origPgid, ttyFd, shellPgid = pgrp, fd, pid
if err := setForeground(pid); err != nil {
return
}
// Ctrl+Z must not stop the shell itself. (signal.Ignore would be
// inherited by the child processes.)
signal.Notify(make(chan os.Signal, 1), syscall.SIGTSTP)
jobControl = true
}
// releaseTerminal gives the terminal back to the process group that had it
// when the shell started.
func releaseTerminal() {
if jobControl && origPgid > 0 && origPgid != shellPgid {
setForeground(origPgid)
}
}
// setForeground makes pgid the foreground process group of the terminal.
func setForeground(pgid int) error {
// The shell gets SIGTTOU if it does this while it is in the background
// (when it takes the terminal back from a job), unless the signal is
// ignored. It is only ignored for this call, since ignored signals are
// inherited by child processes.
signal.Ignore(syscall.SIGTTOU)
defer signal.Reset(syscall.SIGTTOU)
return unix.IoctlSetPointerInt(ttyFd, unix.TIOCSPGRP, pgid)
}
// ttyIndex returns the index of the file in fds that is the terminal of the
// shell, or -1.
func ttyIndex(fds [3]*os.File) int {
tty, err := os.Stdin.Stat()
if err != nil {
return -1
}
for i, f := range fds {
if info, err := f.Stat(); err == nil && os.SameFile(info, tty) {
return i
}
}
return -1
}
// finishedProc returns a proc without process that already has its result.
func finishedProc(err error) *proc {
p := &proc{done: make(chan error, 1)}
p.done <- err
return p
}
// running reports whether a process of the job is running.
func (j *job) running() bool {
for _, p := range j.procs {
if p.cmd != nil && !p.finished && !p.stopped {
return true
}
}
return false
}
// stopped reports whether the job is stopped: no process is running, but
// one is stopped.
func (j *job) stopped() bool {
if j.running() {
return false
}
for _, p := range j.procs {
if p.stopped {
return true
}
}
return false
}
// finished reports whether all stages of the job have finished.
func (j *job) finished() bool {
for _, p := range j.procs {
if !p.finished {
return false
}
}
return true
}
// update records a status change of process pid.
func (j *job) update(pid int, ws syscall.WaitStatus) {
for _, p := range j.procs {
if p.cmd == nil || p.cmd.Process.Pid != pid {
continue
}
switch {
case ws.Stopped():
p.stopped = true
case ws.Continued():
p.stopped = false
default:
p.finished, p.stopped = true, false
p.err = waitStatusError(ws)
p.cmd.Process.Release()
}
}
}
// waitStatusError converts the status of a finished process into an error:
// nil for exit code 0, otherwise a statusError with the exit code, or 128+n
// if the process was killed by signal n.
func waitStatusError(ws syscall.WaitStatus) error {
switch {
case ws.Signaled():
return statusError(128 + int(ws.Signal()))
case ws.ExitStatus() != 0:
return statusError(ws.ExitStatus())
}
return nil
}
// wait waits until no process of the job is running any more (all finished
// or, with job control, stopped). With nohang it only collects the status
// changes that are already available and does not block.
func (j *job) wait(nohang bool) {
flags := 0
if jobControl && j.pgid != 0 {
flags |= syscall.WUNTRACED | syscall.WCONTINUED
}
if nohang {
flags |= syscall.WNOHANG
}
for {
// With job control all processes of the job are in its process
// group, otherwise they are waited for one by one.
target := 0
if jobControl && j.pgid != 0 {
if !j.running() && !(nohang && j.stopped()) {
break
}
target = -j.pgid
} else {
if nohang {
// Check every process once
for _, p := range j.procs {
if p.cmd == nil || p.finished {
continue
}
var ws syscall.WaitStatus
if pid, err := syscall.Wait4(p.cmd.Process.Pid, &ws, flags, nil); err == nil && pid > 0 {
j.update(pid, ws)
}
}
break
}
for _, p := range j.procs {
if p.cmd != nil && !p.finished {
target = p.cmd.Process.Pid
break
}
}
if target == 0 {
break
}
}
var ws syscall.WaitStatus
pid, err := syscall.Wait4(target, &ws, flags, nil)
if errors.Is(err, syscall.EINTR) {
continue
}
if err != nil {
// Should not happen; do not wait forever
for _, p := range j.procs {
if p.cmd != nil && !p.finished {
p.finished, p.stopped = true, false
}
}
break
}
if pid == 0 {
break // nohang and nothing changed
}
j.update(pid, ws)
}
// Stages without process (builtins in pipelines)
for _, p := range j.procs {
if p.cmd != nil || p.finished {
continue
}
if nohang || j.stopped() {
select {
case p.err = <-p.done:
p.finished = true
default:
}
} else {
p.err = <-p.done
p.finished = true
}
}
}
// result returns the error of the last stage of a finished job (so $? is its
// exit status, like in sh) and prints the errors of the other stages.
func (j *job) result() error {
for _, p := range j.procs[:len(j.procs)-1] {
printError(p.err)
}
return j.procs[len(j.procs)-1].err
}
// waitForeground waits for a job in the foreground. If it is stopped, it is
// added to the job table.
func waitForeground(j *job) error {
j.wait(false)
if jobControl && j.pgid != 0 {
setForeground(shellPgid)
if j.stopped() {
j.tty, _ = readline.GetState(ttyFd)
}
if shellTTY != nil {
readline.Restore(ttyFd, shellTTY)
}
}
if j.stopped() {
addJob(j)
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, j.describe("Stopped"))
return statusError(128 + int(syscall.SIGTSTP))
}
removeJob(j)
j.wait(false) // wait for the builtins, if any
return j.result()
}
// addJob adds j to the job table (if it is not there yet) and makes it the
// current job.
func addJob(j *job) {
removeJob(j)
if j.id == 0 {
for _, other := range jobTable {
j.id = max(j.id, other.id)
}
j.id++
}
jobTable = append(jobTable, j)
}
// removeJob removes j from the job table.
func removeJob(j *job) {
for i, other := range jobTable {
if other == j {
jobTable = append(jobTable[:i], jobTable[i+1:]...)
return
}
}
}
// describe formats a job for jobs and the notifications, like bash:
// "[1]+ Stopped sleep 30". The current job (the last one in
// the table, i.e. the one stopped or put into the background most recently) is
// marked with +, the previous one with -.
func (j *job) describe(state string) string {
mark := ' '
if n := len(jobTable); n > 0 && jobTable[n-1] == j {
mark = '+'
} else if n > 1 && jobTable[n-2] == j {
mark = '-'
}
text := j.text
if state == "Running" {
text += " &"
}
return fmt.Sprintf("[%d]%c %-24s%s", j.id, mark, state, sanitize(text))
}
// state returns the state of a job for jobs and the notifications.
func (j *job) state() string {
switch {
case j.stopped():
return "Stopped"
case !j.finished():
return "Running"
}
if status := exitStatus(j.procs[len(j.procs)-1].err); status != 0 {
return "Exit " + strconv.Itoa(status)
}
return "Done"
}
// hasStoppedJobs reports whether a job in the table is stopped.
func hasStoppedJobs() bool {
for _, j := range jobTable {
if j.stopped() {
return true
}
}
return false
}
// reapJobs collects the status changes of the background jobs, reports
// finished and newly stopped jobs (if notify is set) and removes finished
// jobs from the table.
func reapJobs(notify bool) {
for _, j := range append([]*job(nil), jobTable...) {
wasStopped := j.stopped()
j.wait(true)
switch {
case j.finished():
if notify {
for _, p := range j.procs {
printError(p.err)
}
fmt.Fprintln(os.Stderr, j.describe(j.state()))
}
removeJob(j)
case j.stopped() && !wasStopped:
addJob(j) // the current job now
if notify {
fmt.Fprintln(os.Stderr, j.describe("Stopped"))
}
}
}
}
// findJob returns the job for a job spec: %N or N for job N, %+, %% or nothing
// for the current job, %- for the previous one.
func findJob(cmd string, args []string) (*job, error) {
spec := "%+"
if len(args) > 1 {
spec = args[1]
}
n := len(jobTable)
switch spec {
case "%+", "%%", "%":
if n > 0 {
return jobTable[n-1], nil
}
return nil, fmt.Errorf("%s: current: no such job", cmd)
case "%-":
if n > 1 {
return jobTable[n-2], nil
}
return nil, fmt.Errorf("%s: previous: no such job", cmd)
}
id, err := strconv.Atoi(strings.TrimPrefix(spec, "%"))
if err == nil {
for _, j := range jobTable {
if j.id == id {
return j, nil
}
}
}
return nil, fmt.Errorf("%s: %s: no such job", cmd, sanitize(spec))
}
// continueJob sends SIGCONT to a stopped job.
func continueJob(j *job) {
for _, p := range j.procs {
p.stopped = false
}
if j.pgid != 0 {
syscall.Kill(-j.pgid, syscall.SIGCONT)
}
}
// builtinJobs lists the jobs.
func builtinJobs(stdout io.Writer) error {
for _, j := range jobTable {
j.wait(true)
}
for _, j := range append([]*job(nil), jobTable...) {
fmt.Fprintln(stdout, j.describe(j.state()))
if j.finished() {
removeJob(j)
}
}
return nil
}
// builtinFg continues a job in the foreground and waits for it.
func builtinFg(args []string, stdout io.Writer) error {
if !jobControl {
return errors.New("fg: no job control")
}
j, err := findJob("fg", args)
if err != nil {
return err
}
fmt.Fprintln(stdout, sanitize(j.text))
if j.pgid != 0 {
shellTTY, _ = readline.GetState(ttyFd)
if j.stopped() && j.tty != nil {
readline.Restore(ttyFd, j.tty)
}
setForeground(j.pgid)
}
continueJob(j)
return waitForeground(j)
}
// builtinBg continues a stopped job in the background.
func builtinBg(args []string, stdout io.Writer) error {
if !jobControl {
return errors.New("bg: no job control")
}
j, err := findJob("bg", args)
if err != nil {
return err
}
if !j.stopped() {
return fmt.Errorf("bg: job %d already in background", j.id)
}
continueJob(j)
addJob(j) // the current job now
fmt.Fprintln(stdout, j.describe("Running"))
return nil
}
// builtinWait waits for a job, or for all background jobs. Stopped jobs are
// not waited for.
func builtinWait(args []string) error {
jobs := append([]*job(nil), jobTable...)
if len(args) > 1 {
j, err := findJob("wait", args)
if err != nil {
return err
}
jobs = []*job{j}
}
var err error
for _, j := range jobs {
j.wait(false)
err = nil
if j.finished() {
removeJob(j)
err = j.result()
}
}
return err
}
// exitShell exits the shell, unless there are stopped jobs and the user was
// not warned yet (like bash).
func exitShell(code int) error {
if interactiveShell && hasStoppedJobs() && !exitWarned {
exitWarned = true
return errors.New("there are stopped jobs")
}
releaseTerminal()
os.Exit(code)
return nil
}
+182
View File
@@ -0,0 +1,182 @@
package main
import (
"bytes"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
"golang.org/x/sys/unix"
)
// openPty opens a new pseudo terminal and returns the master and slave side.
func openPty(t *testing.T) (*os.File, *os.File) {
t.Helper()
master, err := os.OpenFile("/dev/ptmx", os.O_RDWR, 0)
if err != nil {
t.Skipf("no pty available: %v", err)
}
if err := unix.IoctlSetPointerInt(int(master.Fd()), unix.TIOCSPTLCK, 0); err != nil {
t.Fatal(err)
}
n, err := unix.IoctlGetInt(int(master.Fd()), unix.TIOCGPTN)
if err != nil {
t.Fatal(err)
}
slave, err := os.OpenFile("/dev/pts/"+strconv.Itoa(n), os.O_RDWR|syscall.O_NOCTTY, 0)
if err != nil {
t.Fatal(err)
}
return master, slave
}
// ptyShell is the shell running on a pseudo terminal.
type ptyShell struct {
t *testing.T
master *os.File
mu sync.Mutex
out bytes.Buffer
cmd *exec.Cmd
}
func startPtyShell(t *testing.T) *ptyShell {
bin := buildShell(t)
master, slave := openPty(t)
cmd := exec.Command(bin, "--norc")
cmd.Stdin, cmd.Stdout, cmd.Stderr = slave, slave, slave
cmd.Env = append(os.Environ(), "NO_COLOR=1")
// New session with the pty as controlling terminal, like a terminal
// emulator does
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 0}
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
slave.Close()
s := &ptyShell{t: t, master: master, cmd: cmd}
go func() {
buf := make([]byte, 4096)
for {
n, err := master.Read(buf)
s.mu.Lock()
s.out.Write(buf[:n])
s.mu.Unlock()
if err != nil {
return
}
}
}()
t.Cleanup(func() {
cmd.Process.Kill()
cmd.Wait()
master.Close()
})
s.expect("$ ")
return s
}
func (s *ptyShell) send(text string) {
s.master.WriteString(text)
}
// expect waits until want appears in the output after the position of the
// last match and consumes the output up to it.
func (s *ptyShell) expect(want string) {
s.t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
s.mu.Lock()
out := s.out.String()
if i := strings.Index(out, want); i >= 0 {
s.out.Next(i + len(want))
s.mu.Unlock()
return
}
s.mu.Unlock()
time.Sleep(20 * time.Millisecond)
}
s.mu.Lock()
defer s.mu.Unlock()
s.t.Fatalf("timeout waiting for %q, output:\n%s", want, s.out.String())
}
func TestJobControl(t *testing.T) {
s := startPtyShell(t)
// Ctrl+Z stops the foreground job, the shell gets the terminal back
s.send("sleep 30\n")
time.Sleep(300 * time.Millisecond)
s.send("\x1a")
s.expect("[1]+ Stopped sleep 30")
s.send("jobs\n")
s.expect("[1]+ Stopped sleep 30")
// bg continues it in the background
s.send("bg\n")
s.expect("[1]+ Running sleep 30 &")
// fg brings it back, Ctrl+C reaches only the job
s.send("fg %1\n")
s.expect("sleep 30")
time.Sleep(300 * time.Millisecond)
s.send("\x03")
s.send("echo status=$?\n")
s.expect("status=130")
// A pipeline is stopped and continued as a whole
s.send("sleep 30 | cat\n")
time.Sleep(300 * time.Millisecond)
s.send("\x1a")
s.expect("Stopped sleep 30 | cat")
s.send("fg\n")
time.Sleep(300 * time.Millisecond)
s.send("\x03")
s.send("echo pipeline=$?\n")
s.expect("pipeline=130")
// A background job that reads from the terminal is stopped (SIGTTIN),
// in the foreground it gets the input
s.send("cat &\n")
s.expect("[1] ")
time.Sleep(300 * time.Millisecond)
s.send("echo next\n")
s.expect("[1]+ Stopped cat")
s.send("fg\n")
time.Sleep(300 * time.Millisecond)
s.send("hello cat\n")
s.expect("hello cat\r\nhello cat")
s.send("\x04")
s.send("echo cat=$?\n")
s.expect("cat=0")
// Finished background jobs are reported before the next prompt
s.send("sleep 0.2 &\n")
time.Sleep(500 * time.Millisecond)
s.send("echo tick\n")
s.expect("Done sleep 0.2")
// exit warns about stopped jobs once
s.send("sleep 30\n")
time.Sleep(300 * time.Millisecond)
s.send("\x1a")
s.expect("Stopped")
s.send("exit\n")
s.expect("there are stopped jobs")
s.send("exit 0\n")
done := make(chan error, 1)
go func() { done <- s.cmd.Wait() }()
select {
case err := <-done:
if err != nil {
t.Errorf("shell exited with %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("shell did not exit")
}
}
+432 -85
View File
@@ -12,6 +12,7 @@ import (
"os/user" "os/user"
"path/filepath" "path/filepath"
"runtime/debug" "runtime/debug"
"sort"
"strconv" "strconv"
"strings" "strings"
"syscall" "syscall"
@@ -73,12 +74,15 @@ func versionInfo() string {
// previousDir tracks the previous working directory for 'cd -' support // previousDir tracks the previous working directory for 'cd -' support
var previousDir string var previousDir string
// getHomeDir returns the user's home directory // getHomeDir returns the user's home directory: $HOME like in sh, or the
// home directory from the user database if $HOME is not set
func getHomeDir() string { func getHomeDir() string {
if home := os.Getenv("HOME"); home != "" {
return home
}
usr, err := user.Current() usr, err := user.Current()
if err != nil { if err != nil {
// Fallback to HOME environment variable return ""
return os.Getenv("HOME")
} }
return usr.HomeDir return usr.HomeDir
} }
@@ -113,10 +117,12 @@ func expandTilde(path string) string {
// token is a word or an operator of a command line. // token is a word or an operator of a command line.
type token struct { type token struct {
op string // "" for a word, otherwise |, <, >, >>, >&, <&, &> or &>> op string // "" for a word, otherwise |, <, >, >>, >&, <&, &> or &>>
word string // the word, if op == "" word string // the word, if op == ""
fd int // the redirected file descriptor (for redirections) pattern string // the word as glob pattern (quoted characters escaped)
dup int // the target file descriptor of >& and <& glob bool // the word contains unquoted * ? or [ and is expanded
fd int // the redirected file descriptor (for redirections)
dup int // the target file descriptor of >& and <&
} }
// tokenize splits input into words and operators like a (very) simple POSIX // tokenize splits input into words and operators like a (very) simple POSIX
@@ -136,7 +142,9 @@ func tokenize(input string) ([]token, error) {
var ( var (
tokens []token tokens []token
b strings.Builder b strings.Builder
inWord bool // a word has been started (even if it is still empty) 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)
quote byte quote byte
escaped bool escaped bool
rawStart = -1 // start of the current word in input rawStart = -1 // start of the current word in input
@@ -144,12 +152,19 @@ func tokenize(input string) ([]token, error) {
endWord := func() { endWord := func() {
if inWord { if inWord {
tokens = append(tokens, token{word: b.String()}) tokens = append(tokens, token{word: b.String(), pattern: pat.String(), glob: glob})
} }
b.Reset() b.Reset()
pat.Reset()
glob = false
inWord = false inWord = false
rawStart = -1 rawStart = -1
} }
// literal adds quoted, escaped or expanded text: it is never a pattern
literal := func(s string) {
b.WriteString(s)
pat.WriteString(escapeGlob(s))
}
startRaw := func(i int) { startRaw := func(i int) {
if rawStart < 0 { if rawStart < 0 {
rawStart = i rawStart = i
@@ -161,16 +176,16 @@ func tokenize(input string) ([]token, error) {
switch { switch {
case escaped: case escaped:
if quote == '"' && !strings.ContainsRune("$\"\\`", rune(ch)) { if quote == '"' && !strings.ContainsRune("$\"\\`", rune(ch)) {
b.WriteByte('\\') literal("\\")
} }
b.WriteByte(ch) literal(string(ch))
inWord = true inWord = true
escaped = false escaped = false
case quote == '\'': case quote == '\'':
if ch == '\'' { if ch == '\'' {
quote = 0 quote = 0
} else { } else {
b.WriteByte(ch) literal(string(ch))
} }
case ch == '\\': case ch == '\\':
startRaw(i) startRaw(i)
@@ -181,7 +196,9 @@ func tokenize(input string) ([]token, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
b.WriteString(value) // Expanded values are never patterns (and never split), so that
// e.g. a * in a variable does not match files
literal(value)
// A literal "$" counts as content, an expansion that is empty does // A literal "$" counts as content, an expansion that is empty does
// not (unless quoted), like in sh // not (unless quoted), like in sh
if n == 1 || value != "" { if n == 1 || value != "" {
@@ -192,7 +209,7 @@ func tokenize(input string) ([]token, error) {
if ch == '"' { if ch == '"' {
quote = 0 quote = 0
} else { } else {
b.WriteByte(ch) literal(string(ch))
} }
case ch == '"' || ch == '\'': case ch == '"' || ch == '\'':
startRaw(i) startRaw(i)
@@ -225,6 +242,8 @@ func tokenize(input string) ([]token, error) {
if inWord && rawStart >= 0 && isDigits(input[rawStart:i]) { if inWord && rawStart >= 0 && isDigits(input[rawStart:i]) {
fd, _ = strconv.Atoi(input[rawStart:i]) fd, _ = strconv.Atoi(input[rawStart:i])
b.Reset() b.Reset()
pat.Reset()
glob = false
inWord = false inWord = false
rawStart = -1 rawStart = -1
} else { } else {
@@ -256,11 +275,21 @@ func tokenize(input string) ([]token, error) {
if home == "" { if home == "" {
home = "~" home = "~"
} }
b.WriteString(home) literal(home)
inWord = true inWord = true
default: default:
startRaw(i) startRaw(i)
b.WriteByte(ch) b.WriteByte(ch)
switch {
case ch == '*' || ch == '?' || ch == '[':
glob = true
pat.WriteByte(ch)
case ch == '!' && strings.HasSuffix(pat.String(), "[") && !strings.HasSuffix(pat.String(), "\\["):
// POSIX [!...] is [^...] in Go
pat.WriteByte('^')
default:
pat.WriteByte(ch)
}
inWord = true inWord = true
} }
} }
@@ -275,6 +304,123 @@ func tokenize(input string) ([]token, error) {
return tokens, nil return tokens, nil
} }
// escapeGlob escapes the characters that are special in a glob pattern.
func escapeGlob(s string) string {
var b strings.Builder
for i := 0; i < len(s); i++ {
if strings.IndexByte("*?[]\\", s[i]) >= 0 {
b.WriteByte('\\')
}
b.WriteByte(s[i])
}
return b.String()
}
// hasGlobMeta reports whether pattern contains an unescaped * ? or [.
func hasGlobMeta(pattern string) bool {
for i := 0; i < len(pattern); i++ {
switch pattern[i] {
case '\\':
i++
case '*', '?', '[':
return true
}
}
return false
}
// unescapeGlob removes the escapes from a pattern without wildcards.
func unescapeGlob(pattern string) string {
var b strings.Builder
for i := 0; i < len(pattern); i++ {
if pattern[i] == '\\' && i+1 < len(pattern) {
i++
}
b.WriteByte(pattern[i])
}
return b.String()
}
// expandGlob returns the paths matching pattern, sorted. Like sh, * and ?
// do not match a leading dot (hidden files), unless the pattern component
// starts with a dot, and a trailing / only matches directories. An invalid
// pattern matches nothing.
func expandGlob(pattern string) []string {
paths := []string{""}
if strings.HasPrefix(pattern, "/") {
paths = []string{"/"}
}
components := strings.Split(strings.Trim(pattern, "/"), "/")
join := func(dir, name string) string {
if dir == "" || strings.HasSuffix(dir, "/") {
return dir + name
}
return dir + "/" + name
}
for _, comp := range components {
if comp == "" {
continue // a//b
}
var next []string
for _, dir := range paths {
if !hasGlobMeta(comp) {
p := join(dir, unescapeGlob(comp))
if _, err := os.Lstat(p); err == nil {
next = append(next, p)
}
continue
}
readDir := dir
if readDir == "" {
readDir = "."
}
entries, err := os.ReadDir(readDir)
if err != nil {
continue
}
for _, e := range entries {
name := e.Name()
if strings.HasPrefix(name, ".") && !strings.HasPrefix(comp, ".") && !strings.HasPrefix(comp, "\\.") {
continue
}
ok, err := filepath.Match(comp, name)
if err != nil {
return nil
}
if ok {
next = append(next, join(dir, name))
}
}
}
paths = next
}
if strings.HasSuffix(pattern, "/") {
var dirs []string
for _, p := range paths {
if info, err := os.Stat(p); err == nil && info.IsDir() {
dirs = append(dirs, p+"/")
}
}
paths = dirs
}
sort.Strings(paths)
return paths
}
// expandWord returns the words a token expands to: the matching paths for
// a pattern, or the word itself if it is no pattern or nothing matches (like
// in sh).
func expandWord(t token) []string {
if t.glob {
if matches := expandGlob(t.pattern); len(matches) > 0 {
return matches
}
}
return []string{t.word}
}
// isDigits reports whether s is a non-empty string of ASCII digits. // isDigits reports whether s is a non-empty string of ASCII digits.
func isDigits(s string) bool { func isDigits(s string) bool {
if s == "" { if s == "" {
@@ -556,7 +702,7 @@ type shellCompleter struct {
func newShellCompleter() *shellCompleter { func newShellCompleter() *shellCompleter {
return &shellCompleter{ return &shellCompleter{
commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "help"}, commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "source", "jobs", "fg", "bg", "wait", "help"},
} }
} }
@@ -668,7 +814,7 @@ func currentWord(s string) (word string, start int, quote byte) {
func escapeWord(s string) string { func escapeWord(s string) string {
var b strings.Builder var b strings.Builder
for _, r := range s { for _, r := range s {
if strings.ContainsRune(" \t\\'\";|&<>", r) { if strings.ContainsRune(" \t\\'\";|&<>*?[$", r) {
b.WriteByte('\\') b.WriteByte('\\')
} }
b.WriteRune(r) b.WriteRune(r)
@@ -723,33 +869,48 @@ func completeFilePath(line, prefix string) []string {
return matches return matches
} }
const usage = `usage: gosh [-l] [-i] [script] const usage = `usage: gosh [-l] [-i] [--norc] [--noprofile] [script]
gosh [-l] -c command gosh [-l] [--noprofile] -c command
gosh -version` gosh -version`
// sysConfDir holds the system-wide startup files.
var sysConfDir = "/etc/gosh"
func main() { func main() {
os.Exit(run(os.Args[1:])) args := os.Args[1:]
// login(1), sshd and "su -" start a login shell with a leading - in
// argv[0], e.g. "-gosh"
if strings.HasPrefix(os.Args[0], "-") {
args = append([]string{"-l"}, args...)
}
os.Exit(run(args))
} }
// run parses the command line and runs gosh in the matching mode. It returns // run parses the command line and runs gosh in the matching mode. It returns
// the exit status of the shell. // the exit status of the shell.
func run(args []string) int { func run(args []string) int {
// -l/--login is accepted for compatibility (e.g. "su -l", login(1)), but var login, forceInteractive, noRC, noProfile bool
// gosh does not read a startup file yet. -i forces interactive mode even
// if stdin is not a terminal.
forceInteractive := false
flags: flags:
for len(args) > 0 { for len(args) > 0 {
switch args[0] { switch args[0] {
case "-l", "--login": case "-l", "--login":
login = true
case "-i": case "-i":
// Interactive mode even if stdin is not a terminal
forceInteractive = true forceInteractive = true
case "--norc":
noRC = true
case "--noprofile":
noProfile = true
default: default:
break flags break flags
} }
args = args[1:] args = args[1:]
} }
// Determine what to run, before the startup files are read
var script func() int
interactiveMode := false
if len(args) > 0 { if len(args) > 0 {
switch arg := args[0]; { switch arg := args[0]; {
case arg == "-version" || arg == "--version" || arg == "-V": case arg == "-version" || arg == "--version" || arg == "-V":
@@ -766,13 +927,13 @@ flags:
fmt.Fprintln(os.Stderr, usage) fmt.Fprintln(os.Stderr, usage)
return 2 return 2
} }
return runScript(strings.NewReader(args[1])) script = func() int { return runScript(strings.NewReader(args[1]), "") }
case strings.HasPrefix(arg, "-") && arg != "-": case strings.HasPrefix(arg, "-") && arg != "-":
fmt.Fprintf(os.Stderr, "gosh: %s: invalid option\n", arg) fmt.Fprintf(os.Stderr, "gosh: %s: invalid option\n", arg)
fmt.Fprintln(os.Stderr, usage) fmt.Fprintln(os.Stderr, usage)
return 2 return 2
case arg == "-": case arg == "-":
return runScript(os.Stdin) script = func() int { return runScript(os.Stdin, "") }
default: default:
f, err := os.Open(arg) f, err := os.Open(arg)
if err != nil { if err != nil {
@@ -780,30 +941,107 @@ flags:
return 127 return 127
} }
defer f.Close() defer f.Close()
return runScript(bufio.NewReader(f)) script = func() int { return runScript(bufio.NewReader(f), arg) }
} }
} else if forceInteractive || readline.IsTerminal(int(os.Stdin.Fd())) {
interactiveMode = true
} else {
// Without a terminal (e.g. "echo ls | gosh") read the commands from
// stdin
script = func() int { return runScript(os.Stdin, "") }
} }
// Without a terminal (e.g. "echo ls | gosh") read the commands from stdin. for _, file := range startupFiles(login && !noProfile, interactiveMode && !noRC) {
if !forceInteractive && !readline.IsTerminal(int(os.Stdin.Fd())) { sourceStartupFile(file)
return runScript(os.Stdin)
} }
if !interactiveMode {
return script()
}
interactive() interactive()
return 0 return 0
} }
// startupFiles returns the startup files to read, in order: for login
// shells /etc/gosh/profile and ~/.gosh_profile, for interactive shells
// /etc/gosh/goshrc and ~/.goshrc. Non-interactive shells (gosh -c, scripts)
// read no files unless they are login shells.
func startupFiles(login, interactive bool) []string {
home := getHomeDir()
var files []string
if login {
files = append(files, filepath.Join(sysConfDir, "profile"))
if home != "" {
files = append(files, filepath.Join(home, ".gosh_profile"))
}
}
if interactive {
files = append(files, filepath.Join(sysConfDir, "goshrc"))
if home != "" {
files = append(files, filepath.Join(home, ".goshrc"))
}
}
return files
}
// sourceStartupFile runs a startup file in the current shell. A missing file
// is skipped silently, other errors are reported.
func sourceStartupFile(path string) {
f, err := os.Open(path)
if errors.Is(err, fs.ErrNotExist) {
return
}
if err != nil {
fmt.Fprintln(os.Stderr, "gosh:", err)
return
}
defer f.Close()
runScript(bufio.NewReader(f), path)
}
// sourceDepth counts nested source commands, to stop a file that sources
// itself.
var sourceDepth int
// sourceFile runs the commands of a file in the current shell (the source and
// . builtins) and returns an error carrying the status of the last command.
func sourceFile(cmd, path string) error {
if sourceDepth >= 100 {
return fmt.Errorf("%s: %s: maximum nesting depth exceeded", cmd, sanitize(path))
}
f, err := os.Open(path)
if err != nil {
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
err = pathErr.Err
}
return fmt.Errorf("%s: %s: %v", cmd, sanitize(path), err)
}
defer f.Close()
sourceDepth++
defer func() { sourceDepth-- }()
if status := runScript(bufio.NewReader(f), path); status != 0 {
return statusError(status)
}
return nil
}
// runScript executes the commands from r line by line. Like sh, it does not // 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. // 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 // Empty lines and lines starting with # (e.g. a #!/usr/bin/gosh line) are
// skipped. // skipped. If name is set, errors are reported with file name and line.
func runScript(r io.Reader) int { func runScript(r io.Reader, name string) int {
status := 0 status := 0
for { for lineNo := 1; ; lineNo++ {
line, err := readLine(r) line, err := readLine(r)
if trimmed := strings.TrimSpace(line); trimmed != "" && !strings.HasPrefix(trimmed, "#") { if trimmed := strings.TrimSpace(line); trimmed != "" && !strings.HasPrefix(trimmed, "#") {
printError(execInput(line)) cmdErr := execInput(line)
status = lastStatus status = lastStatus
if name != "" && cmdErr != nil {
cmdErr = fmt.Errorf("%s:%d: %w", sanitize(name), lineNo, cmdErr)
}
printError(cmdErr)
} }
if err != nil { if err != nil {
if err != io.EOF { if err != io.EOF {
@@ -871,6 +1109,10 @@ func exitStatus(err error) int {
// interactive runs the interactive shell with line editing, history and // interactive runs the interactive shell with line editing, history and
// completion until the user exits. // completion until the user exits.
func interactive() { func interactive() {
interactiveShell = true
initJobControl()
defer releaseTerminal()
// Ctrl+C sends SIGINT to the whole foreground process group. Catch it so // Ctrl+C sends SIGINT to the whole foreground process group. Catch it so
// that it only stops the running command and not the shell. (signal.Ignore // that it only stops the running command and not the shell. (signal.Ignore
// would be inherited by the child processes.) // would be inherited by the child processes.)
@@ -901,6 +1143,9 @@ func interactive() {
// Main loop // Main loop
for { for {
// Report finished and stopped background jobs
reapJobs(true)
// Update prompt dynamically (in case directory changed) // Update prompt dynamically (in case directory changed)
l.SetPrompt(getPrompt()) l.SetPrompt(getPrompt())
@@ -913,6 +1158,11 @@ func interactive() {
// Handle EOF (Ctrl+D) // Handle EOF (Ctrl+D)
if err == io.EOF { if err == io.EOF {
fmt.Println() fmt.Println()
if hasStoppedJobs() && !exitWarned {
exitWarned = true
fmt.Fprintln(os.Stderr, "gosh: there are stopped jobs")
continue
}
break break
} }
fmt.Fprintln(os.Stderr, "Error reading input:", err) fmt.Fprintln(os.Stderr, "Error reading input:", err)
@@ -973,11 +1223,12 @@ func printError(err error) {
// listItem is a command of a command list together with the operator that // listItem is a command of a command list together with the operator that
// connects it to the previous command ("" for the first one). // connects it to the previous command ("" for the first one).
type listItem struct { type listItem struct {
op string // "", ";", "&&" or "||" op string // "", ";", "&&" or "||"
command string command string
background bool // the command ends with &
} }
// splitList splits input at unquoted ;, && and ||. Quotes, escapes, ${...} // splitList splits input at unquoted ;, &, && and ||. Quotes, escapes, ${...}
// and comments are handled like in splitWords, but nothing is expanded yet: // 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. // that happens per command when it runs, so "cd /tmp && echo $PWD" works.
func splitList(input string) ([]listItem, error) { func splitList(input string) ([]listItem, error) {
@@ -1037,6 +1288,17 @@ scan:
case ch == '#' && isWordStart: case ch == '#' && isWordStart:
end = i end = i
break scan 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 == ';', case ch == ';',
ch == '&' && i+1 < len(input) && input[i+1] == '&', ch == '&' && i+1 < len(input) && input[i+1] == '&',
ch == '|' && i+1 < len(input) && input[i+1] == '|': ch == '|' && i+1 < len(input) && input[i+1] == '|':
@@ -1077,14 +1339,15 @@ func execInput(input string) error {
continue continue
} }
printError(lastErr) printError(lastErr)
lastErr = execCommand(item.command) lastErr = execCommand(item.command, item.background)
lastStatus = exitStatus(lastErr) lastStatus = exitStatus(lastErr)
} }
return lastErr return lastErr
} }
// execCommand executes a pipeline of simple commands with redirections. // execCommand executes a pipeline of simple commands with redirections, in
func execCommand(input string) error { // the background if background is set.
func execCommand(input string, background bool) error {
// Split the input into words and operators, expanding variables and ~ on // Split the input into words and operators, expanding variables and ~ on
// the way. // the way.
tokens, err := tokenize(input) tokens, err := tokenize(input)
@@ -1102,7 +1365,11 @@ func execCommand(input string) error {
if len(stages) == 0 { if len(stages) == 0 {
return nil return nil
} }
return runPipeline(stages) // "exit" with stopped jobs only warns, unless it is repeated right away
if name := stages[0].args; len(name) == 0 || (name[0] != "exit" && name[0] != "quit") {
exitWarned = false
}
return runPipeline(stages, strings.TrimSpace(input), background)
} }
// redirect is a redirection of a simple command, e.g. 2>>file or 2>&1. // redirect is a redirection of a simple command, e.g. 2>>file or 2>&1.
@@ -1131,7 +1398,7 @@ func parsePipeline(tokens []token) ([]stage, error) {
t := tokens[i] t := tokens[i]
switch t.op { switch t.op {
case "": case "":
cur.args = append(cur.args, t.word) cur.args = append(cur.args, expandWord(t)...)
case "|": case "|":
if isEmpty(cur) { if isEmpty(cur) {
return nil, syntaxError("|") return nil, syntaxError("|")
@@ -1148,7 +1415,11 @@ func parsePipeline(tokens []token) ([]stage, error) {
return nil, syntaxError(next.op) return nil, syntaxError(next.op)
} }
i++ i++
cur.redirects = append(cur.redirects, redirect{op: t.op, fd: t.fd, file: tokens[i].word}) files := expandWord(tokens[i])
if len(files) > 1 {
return nil, fmt.Errorf("%s: ambiguous redirect", sanitize(tokens[i].word))
}
cur.redirects = append(cur.redirects, redirect{op: t.op, fd: t.fd, file: files[0]})
} }
} }
@@ -1161,49 +1432,71 @@ func parsePipeline(tokens []token) ([]stage, error) {
return append(stages, cur), nil return append(stages, cur), nil
} }
// runPipeline runs the stages of a pipeline concurrently, connecting the // runPipeline runs the stages of a pipeline concurrently as a job,
// stdout of each stage to the stdin of the next one. It returns the error of // connecting the stdout of each stage to the stdin of the next one. In the
// the last stage (so $? is its exit status, like in sh); errors of the other // foreground it waits for the job and returns the error of the last stage (so
// stages are printed. // $? is its exit status, like in sh); errors of the other stages are printed.
func runPipeline(stages []stage) error { // In the background it adds the job to the job table and returns nil.
waits := make([]func() error, len(stages)) func runPipeline(stages []stage, text string, background bool) error {
j := &job{text: text}
if jobControl && !background {
shellTTY, _ = readline.GetState(ttyFd)
}
stdin := os.Stdin stdin := os.Stdin
if background && !jobControl {
// Without job control background jobs must not read from the
// terminal, like in sh
if devNull, err := os.Open(os.DevNull); err == nil {
stdin = devNull
}
}
var startErr error
for i, st := range stages { for i, st := range stages {
stdout := os.Stdout stdout := os.Stdout
var next *os.File var next *os.File
if i < len(stages)-1 { if i < len(stages)-1 {
r, w, err := os.Pipe() r, w, err := os.Pipe()
if err != nil { if err != nil {
// Let the stages that already run finish
if stdin != os.Stdin { if stdin != os.Stdin {
stdin.Close() stdin.Close()
} }
for _, wait := range waits[:i] { startErr = err
printError(wait()) break
}
return err
} }
stdout, next = w, r stdout, next = w, r
} }
waits[i] = startStage(st, stdin, stdout, len(stages) > 1) j.procs = append(j.procs, startStage(st, stdin, stdout, j, len(stages) > 1, background))
stdin = next stdin = next
} }
if startErr != nil {
var err error // Let the stages that already run finish
for i, wait := range waits { if len(j.procs) > 0 {
err = wait() waitForeground(j)
if i < len(waits)-1 {
printError(err)
} }
return startErr
} }
return err
if background {
addJob(j)
if interactiveShell {
pid := 0
for _, p := range j.procs {
if p.cmd != nil {
pid = p.cmd.Process.Pid
}
}
fmt.Fprintf(os.Stderr, "[%d] %d\n", j.id, pid)
}
return nil
}
return waitForeground(j)
} }
// startStage starts a stage of a pipeline with the given stdin and stdout and // startStage starts a stage of the pipeline of job j with the given stdin and
// returns a function that waits for it to finish. Pipe ends passed in are // stdout. Pipe ends passed in are closed by startStage once the stage no
// closed by startStage once the stage no longer needs them, so that the // longer needs them, so that the neighbors see EOF or get SIGPIPE.
// neighbors see EOF or get SIGPIPE. func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background bool) *proc {
func startStage(st stage, stdin, stdout *os.File, inPipeline bool) func() error {
var owned []*os.File var owned []*os.File
if stdin != os.Stdin { if stdin != os.Stdin {
owned = append(owned, stdin) owned = append(owned, stdin)
@@ -1216,9 +1509,6 @@ func startStage(st stage, stdin, stdout *os.File, inPipeline bool) func() error
f.Close() f.Close()
} }
} }
result := func(err error) func() error {
return func() error { return err }
}
// Connect the command to the terminal unless redirected. Without Stdin // Connect the command to the terminal unless redirected. Without Stdin
// the command would read from /dev/null, so interactive programs (su, // the command would read from /dev/null, so interactive programs (su,
@@ -1230,13 +1520,17 @@ func startStage(st stage, stdin, stdout *os.File, inPipeline bool) func() error
if err != nil || len(st.args) == 0 { if err != nil || len(st.args) == 0 {
// A command without arguments only creates or truncates the files // A command without arguments only creates or truncates the files
closeOwned() closeOwned()
return result(err) return finishedProc(err)
} }
if builtin, ok := builtins[st.args[0]]; ok { if builtin, ok := builtins[st.args[0]]; ok {
if inPipeline && builtin.changesState { switch {
case inPipeline && builtin.changesState:
closeOwned() closeOwned()
return result(fmt.Errorf("%s: cannot be used in a pipeline", st.args[0])) return finishedProc(fmt.Errorf("%s: cannot be used in a pipeline", st.args[0]))
case background && builtin.changesState:
closeOwned()
return finishedProc(fmt.Errorf("%s: cannot be run in the background", st.args[0]))
} }
run := func() error { run := func() error {
defer closeOwned() defer closeOwned()
@@ -1248,16 +1542,29 @@ func startStage(st stage, stdin, stdout *os.File, inPipeline bool) func() error
} }
return err return err
} }
if !inPipeline { if !inPipeline && !background {
return result(run()) return finishedProc(run())
} }
done := make(chan error, 1) p := &proc{done: make(chan error, 1)}
go func() { done <- run() }() go func() { p.done <- run() }()
return func() error { return <-done } return p
} }
cmd := exec.Command(st.args[0], st.args[1:]...) cmd := exec.Command(st.args[0], st.args[1:]...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = fds[0], fds[1], fds[2] cmd.Stdin, cmd.Stdout, cmd.Stderr = fds[0], fds[1], fds[2]
if jobControl {
// All processes of the job share a process group: the one of the
// first process. In the foreground, the first process makes its
// group the foreground group of the terminal before it starts the
// program, so that it can use the terminal right away.
attr := &syscall.SysProcAttr{Setpgid: true, Pgid: j.pgid}
if j.pgid == 0 && !background {
if ctty := ttyIndex(fds); ctty >= 0 {
attr.Foreground, attr.Ctty = true, ctty
}
}
cmd.SysProcAttr = attr
}
err = cmd.Start() err = cmd.Start()
// The child has its own copies of the files now // The child has its own copies of the files now
closeOwned() closeOwned()
@@ -1265,9 +1572,16 @@ func startStage(st stage, stdin, stdout *os.File, inPipeline bool) func() error
err = fmt.Errorf("%s: %w", sanitize(st.args[0]), errCommandNotFound) err = fmt.Errorf("%s: %w", sanitize(st.args[0]), errCommandNotFound)
} }
if err != nil { if err != nil {
return result(err) return finishedProc(err)
} }
return cmd.Wait if jobControl && j.pgid == 0 {
j.pgid = cmd.Process.Pid
if !background {
// In case the process could not do it (no terminal in fds)
setForeground(j.pgid)
}
}
return &proc{cmd: cmd}
} }
// applyRedirects applies the redirections in order to fds (stdin, stdout, // applyRedirects applies the redirections in order to fds (stdin, stdout,
@@ -1329,6 +1643,12 @@ var builtins = map[string]struct{ changesState bool }{
"set": {changesState: true}, "set": {changesState: true},
"export": {changesState: true}, "export": {changesState: true},
"help": {}, "help": {},
"source": {changesState: true},
".": {changesState: true},
"jobs": {},
"fg": {changesState: true},
"bg": {changesState: true},
"wait": {changesState: true},
} }
// runBuiltin executes a built-in command, writing its output to stdout. // runBuiltin executes a built-in command, writing its output to stdout.
@@ -1373,17 +1693,17 @@ func runBuiltin(args []string, stdout io.Writer) error {
if len(args) > 1 { if len(args) > 1 {
var exitCode int var exitCode int
fmt.Sscanf(args[1], "%d", &exitCode) fmt.Sscanf(args[1], "%d", &exitCode)
os.Exit(exitCode) return exitShell(exitCode)
} }
os.Exit(lastStatus) return exitShell(lastStatus)
case "quit": case "quit":
// quit with optional status code // quit with optional status code
if len(args) > 1 { if len(args) > 1 {
var exitCode int var exitCode int
fmt.Sscanf(args[1], "%d", &exitCode) fmt.Sscanf(args[1], "%d", &exitCode)
os.Exit(exitCode) return exitShell(exitCode)
} }
os.Exit(lastStatus) return exitShell(lastStatus)
case "set": case "set":
// set VAR=value - set environment variable // set VAR=value - set environment variable
if len(args) < 2 { if len(args) < 2 {
@@ -1408,6 +1728,20 @@ func runBuiltin(args []string, stdout io.Writer) error {
} }
os.Setenv(parts[0], parts[1]) os.Setenv(parts[0], parts[1])
return nil return nil
case "jobs":
return builtinJobs(stdout)
case "fg":
return builtinFg(args, stdout)
case "bg":
return builtinBg(args, stdout)
case "wait":
return builtinWait(args)
case "source", ".":
// source file - run the commands of file in the current shell
if len(args) < 2 {
return fmt.Errorf("usage: %s file", args[0])
}
return sourceFile(args[0], args[1])
case "help": case "help":
fmt.Fprintln(stdout, "gosh - Go Shell\nVersion: " + versionInfo() + ` fmt.Fprintln(stdout, "gosh - Go Shell\nVersion: " + versionInfo() + `
@@ -1419,6 +1753,11 @@ Built-in commands:
quit [code] - Quit shell with status code (default: $?) quit [code] - Quit shell with status code (default: $?)
set VAR=val - Set environment variable set VAR=val - Set environment variable
export VAR=val - Export environment variable export VAR=val - Export environment variable
source file - Run the commands of file in this shell (also: . file)
jobs - List background and stopped jobs
fg [%n] - Continue job n (default: current job) in the foreground
bg [%n] - Continue stopped job n in the background
wait [%n] - Wait for job n or all background jobs
help - Show this help help - Show this help
Features: Features:
@@ -1430,6 +1769,9 @@ Features:
override with $GOSH_HISTFILE); lines starting with a space are not saved override with $GOSH_HISTFILE); lines starting with a space are not saved
- Tab completion for commands and file paths - Tab completion for commands and file paths
- Tilde expansion (~, ~/path) at the start of unquoted words - Tilde expansion (~, ~/path) at the start of unquoted words
- Wildcards: * ? [abc] [a-z] [!abc] match file names (not hidden files
unless the pattern starts with a dot); quoted wildcards and values of
variables are not expanded, a pattern without match is kept as is
- Colored prompt (user@host green, red for root; directory blue), - Colored prompt (user@host green, red for root; directory blue),
configurable with $GOSH_COLORS, e.g. GOSH_COLORS='user=1;33:path=36' configurable with $GOSH_COLORS, e.g. GOSH_COLORS='user=1;33:path=36'
(keys user, root, path; values are ANSI SGR codes, empty = no color); (keys user, root, path; values are ANSI SGR codes, empty = no color);
@@ -1440,8 +1782,13 @@ Features:
a || b (b only if a failed); $? is the exit status of the last command a || b (b only if a failed); $? is the exit status of the last command
- Pipes (a | b) and redirections: < file, > file, >> file, 2> file, - Pipes (a | b) and redirections: < file, > file, >> file, 2> file,
2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file 2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file
- Job control: command & runs in the background, Ctrl+Z stops the
foreground job; jobs, fg, bg and wait manage the jobs
- Non-interactive use: gosh -c 'command', gosh script, command | gosh; - Non-interactive use: gosh -c 'command', gosh script, command | gosh;
the exit status is the one of the last command`) the exit status is the one of the last command
- Startup files: login shells (-l or argv[0] starting with -) read
/etc/gosh/profile and ~/.gosh_profile, interactive shells read
/etc/gosh/goshrc and ~/.goshrc (skip with --noprofile, --norc)`)
return nil return nil
} }
return fmt.Errorf("%s: not a builtin", args[0]) return fmt.Errorf("%s: not a builtin", args[0])
+341 -27
View File
@@ -226,7 +226,7 @@ func TestShell_EndToEnd(t *testing.T) {
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
cmd := exec.Command("go", "run", "main.go") cmd := exec.Command("go", "run", ".")
cmd.Stdin = strings.NewReader(tc.input) cmd.Stdin = strings.NewReader(tc.input)
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout cmd.Stdout = &stdout
@@ -259,7 +259,7 @@ func TestShell_EOF_ExitsGracefully(t *testing.T) {
// Simulate EOF by sending empty input // Simulate EOF by sending empty input
// Note: When using piped input, readline doesn't print prompts // Note: When using piped input, readline doesn't print prompts
// So we just verify the shell exits cleanly // So we just verify the shell exits cleanly
cmd := exec.Command("go", "run", "main.go") cmd := exec.Command("go", "run", ".")
cmd.Stdin = strings.NewReader("") cmd.Stdin = strings.NewReader("")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout cmd.Stdout = &stdout
@@ -278,7 +278,7 @@ func TestShell_EOF_ExitsGracefully(t *testing.T) {
func TestShell_EOF_AfterCommand(t *testing.T) { func TestShell_EOF_AfterCommand(t *testing.T) {
// Test that EOF after a command works // Test that EOF after a command works
// Note: When using piped input, readline doesn't print prompts // Note: When using piped input, readline doesn't print prompts
cmd := exec.Command("go", "run", "main.go") cmd := exec.Command("go", "run", ".")
cmd.Stdin = strings.NewReader("echo test\n") cmd.Stdin = strings.NewReader("echo test\n")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout cmd.Stdout = &stdout
@@ -302,7 +302,7 @@ func TestShell_SIGINT_HandlerSetup(t *testing.T) {
// This test verifies that the shell doesn't panic when receiving signals // This test verifies that the shell doesn't panic when receiving signals
// We can't easily test SIGINT handling from a test, but we can verify // We can't easily test SIGINT handling from a test, but we can verify
// the shell runs without crashing // the shell runs without crashing
cmd := exec.Command("go", "run", "main.go") cmd := exec.Command("go", "run", ".")
cmd.Stdin = strings.NewReader("echo test\nquit\n") cmd.Stdin = strings.NewReader("echo test\nquit\n")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout cmd.Stdout = &stdout
@@ -458,7 +458,7 @@ func TestShell_ExitWithStatusCode(t *testing.T) {
} }
// Build the binary once // Build the binary once
build := exec.Command("go", "build", "-o", "gosh_test", "main.go") build := exec.Command("go", "build", "-o", "gosh_test", ".")
if err := build.Run(); err != nil { if err := build.Run(); err != nil {
t.Fatalf("failed to build: %v", err) t.Fatalf("failed to build: %v", err)
} }
@@ -494,7 +494,7 @@ func TestShell_ExitWithStatusCode(t *testing.T) {
// ============================================================================= // =============================================================================
func TestShell_EnvExpansion_Home(t *testing.T) { func TestShell_EnvExpansion_Home(t *testing.T) {
cmd := exec.Command("go", "run", "main.go") cmd := exec.Command("go", "run", ".")
cmd.Stdin = strings.NewReader("echo $HOME\nexit\n") cmd.Stdin = strings.NewReader("echo $HOME\nexit\n")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout cmd.Stdout = &stdout
@@ -513,7 +513,7 @@ func TestShell_EnvExpansion_Home(t *testing.T) {
} }
func TestShell_EnvExpansion_Path(t *testing.T) { func TestShell_EnvExpansion_Path(t *testing.T) {
cmd := exec.Command("go", "run", "main.go") cmd := exec.Command("go", "run", ".")
cmd.Stdin = strings.NewReader("echo $PATH\nexit\n") cmd.Stdin = strings.NewReader("echo $PATH\nexit\n")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout cmd.Stdout = &stdout
@@ -532,7 +532,7 @@ func TestShell_EnvExpansion_Path(t *testing.T) {
} }
func TestShell_EnvExpansion_Braces(t *testing.T) { func TestShell_EnvExpansion_Braces(t *testing.T) {
cmd := exec.Command("go", "run", "main.go") cmd := exec.Command("go", "run", ".")
cmd.Stdin = strings.NewReader("echo ${HOME}\nexit\n") cmd.Stdin = strings.NewReader("echo ${HOME}\nexit\n")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout cmd.Stdout = &stdout
@@ -551,7 +551,7 @@ func TestShell_EnvExpansion_Braces(t *testing.T) {
} }
func TestShell_EnvExpansion_CdWithVar(t *testing.T) { func TestShell_EnvExpansion_CdWithVar(t *testing.T) {
cmd := exec.Command("go", "run", "main.go") cmd := exec.Command("go", "run", ".")
cmd.Stdin = strings.NewReader("cd $HOME\npwd\nexit\n") cmd.Stdin = strings.NewReader("cd $HOME\npwd\nexit\n")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout cmd.Stdout = &stdout
@@ -570,7 +570,7 @@ func TestShell_EnvExpansion_CdWithVar(t *testing.T) {
} }
func TestShell_TildeExpansion_Ls(t *testing.T) { func TestShell_TildeExpansion_Ls(t *testing.T) {
cmd := exec.Command("go", "run", "main.go") cmd := exec.Command("go", "run", ".")
cmd.Stdin = strings.NewReader("ls ~\nexit\n") cmd.Stdin = strings.NewReader("ls ~\nexit\n")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout cmd.Stdout = &stdout
@@ -889,6 +889,9 @@ func TestMain(m *testing.M) {
panic(err) panic(err)
} }
os.Setenv("GOSH_HISTFILE", filepath.Join(dir, "history")) os.Setenv("GOSH_HISTFILE", filepath.Join(dir, "history"))
// Never read the system's startup files; tests that read the user's
// files set $HOME themselves
sysConfDir = filepath.Join(dir, "etc")
code := m.Run() code := m.Run()
os.RemoveAll(dir) os.RemoveAll(dir)
os.Exit(code) os.Exit(code)
@@ -1183,7 +1186,7 @@ func TestShell_History(t *testing.T) {
histFile := filepath.Join(t.TempDir(), "gosh", "history") histFile := filepath.Join(t.TempDir(), "gosh", "history")
workDir := t.TempDir() workDir := t.TempDir()
cmd := exec.Command(bin, "-i") cmd := exec.Command(bin, "-i", "--norc")
cmd.Dir = workDir cmd.Dir = workDir
cmd.Env = append(os.Environ(), "GOSH_HISTFILE="+histFile) cmd.Env = append(os.Environ(), "GOSH_HISTFILE="+histFile)
cmd.Stdin = strings.NewReader("echo first\n set SECRET=hunter2\nexit\n") cmd.Stdin = strings.NewReader("echo first\n set SECRET=hunter2\nexit\n")
@@ -1215,7 +1218,7 @@ func TestShell_History(t *testing.T) {
func TestShell_SIGINTKillsOnlyChild(t *testing.T) { func TestShell_SIGINTKillsOnlyChild(t *testing.T) {
bin := buildShell(t) bin := buildShell(t)
cmd := exec.Command(bin, "-i") cmd := exec.Command(bin, "-i", "--norc")
// Own process group, so that we can signal it like the terminal does // Own process group, so that we can signal it like the terminal does
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
stdin, err := cmd.StdinPipe() stdin, err := cmd.StdinPipe()
@@ -1313,7 +1316,7 @@ func TestRunScript(t *testing.T) {
"touch first\n" + "touch first\n" +
"false\n" + "false\n" +
"touch second" // no trailing newline "touch second" // no trailing newline
if got := runScript(strings.NewReader(script)); got != 0 { if got := runScript(strings.NewReader(script), ""); got != 0 {
t.Errorf("runScript() = %d, want 0", got) t.Errorf("runScript() = %d, want 0", got)
} }
for _, name := range []string{"first", "second"} { for _, name := range []string{"first", "second"} {
@@ -1323,7 +1326,7 @@ func TestRunScript(t *testing.T) {
} }
// The status of the last command is returned // The status of the last command is returned
if got := runScript(strings.NewReader("true\nsh -c 'exit 3'\n")); got != 3 { if got := runScript(strings.NewReader("true\nsh -c 'exit 3'\n"), ""); got != 3 {
t.Errorf("runScript() = %d, want 3", got) t.Errorf("runScript() = %d, want 3", got)
} }
} }
@@ -1335,7 +1338,7 @@ func TestRun_Options(t *testing.T) {
}{ }{
{[]string{"-c", "true"}, 0}, {[]string{"-c", "true"}, 0},
{[]string{"-c", "false"}, 1}, {[]string{"-c", "false"}, 1},
{[]string{"-l", "-c", "sh -c 'exit 5'"}, 5}, {[]string{"-l", "--noprofile", "-c", "sh -c 'exit 5'"}, 5},
{[]string{"-c"}, 2}, {[]string{"-c"}, 2},
{[]string{"-x"}, 2}, {[]string{"-x"}, 2},
{[]string{"/gosh/no/such/script"}, 127}, {[]string{"/gosh/no/such/script"}, 127},
@@ -1366,16 +1369,23 @@ func TestSplitList(t *testing.T) {
{"", nil}, {"", nil},
{" ", nil}, {" ", nil},
{"# only a comment", nil}, {"# only a comment", nil},
{"ls", []listItem{{"", "ls"}}}, {"ls", []listItem{{"", "ls", false}}},
{"a; b", []listItem{{"", "a"}, {";", " b"}}}, {"a; b", []listItem{{"", "a", false}, {";", " b", false}}},
{"a;", []listItem{{"", "a"}}}, {"a;", []listItem{{"", "a", false}}},
{"a && b || c", []listItem{{"", "a "}, {"&&", " b "}, {"||", " c"}}}, {"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`}}}, {`echo "a;b" 'c&&d' e\;f`, []listItem{{"", `echo "a;b" 'c&&d' e\;f`, false}}},
{`echo "x\"; y"`, []listItem{{"", `echo "x\"; y"`}}}, {`echo "x\"; y"`, []listItem{{"", `echo "x\"; y"`, false}}},
{"echo ${X:-a;b}; c", []listItem{{"", "echo ${X:-a;b}"}, {";", " c"}}}, {"echo ${X:-a;b}; c", []listItem{{"", "echo ${X:-a;b}", false}, {";", " c", false}}},
{"echo a # b; c", []listItem{{"", "echo a "}}}, {"echo a # b; c", []listItem{{"", "echo a ", false}}},
{"echo a#b; c", []listItem{{"", "echo a#b"}, {";", " c"}}}, {"echo a#b; c", []listItem{{"", "echo a#b", false}, {";", " c", false}}},
{"echo a|b", []listItem{{"", "echo a|b"}}}, {"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}}},
} }
for _, tc := range tests { for _, tc := range tests {
got, err := splitList(tc.input) got, err := splitList(tc.input)
@@ -1384,13 +1394,13 @@ func TestSplitList(t *testing.T) {
continue continue
} }
if !reflect.DeepEqual(got, tc.want) { if !reflect.DeepEqual(got, tc.want) {
t.Errorf("splitList(%q) = %q, want %q", tc.input, got, tc.want) t.Errorf("splitList(%q) = %+v, want %+v", tc.input, got, tc.want)
} }
} }
} }
func TestSplitList_SyntaxErrors(t *testing.T) { func TestSplitList_SyntaxErrors(t *testing.T) {
for _, input := range []string{"; a", "a ;; b", "&& a", "a &&", "a ||", "a && ; b", "a || && b"} { 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") { if _, err := splitList(input); err == nil || !strings.Contains(err.Error(), "syntax error") {
t.Errorf("splitList(%q) = %v, want syntax error", input, err) t.Errorf("splitList(%q) = %v, want syntax error", input, err)
} }
@@ -1514,6 +1524,10 @@ func TestTokenize_Operators(t *testing.T) {
t.Errorf("tokenize(%q) returned error: %v", tc.input, err) t.Errorf("tokenize(%q) returned error: %v", tc.input, err)
continue continue
} }
// Patterns are tested in TestTokenize_Patterns
for i := range got {
got[i].pattern, got[i].glob = "", false
}
if !reflect.DeepEqual(got, tc.want) { if !reflect.DeepEqual(got, tc.want) {
t.Errorf("tokenize(%q) = %+v, want %+v", tc.input, got, tc.want) t.Errorf("tokenize(%q) = %+v, want %+v", tc.input, got, tc.want)
} }
@@ -1620,3 +1634,303 @@ func TestExecInput_RedirectionErrors(t *testing.T) {
t.Error("file f must not be created for a bad file descriptor") t.Error("file f must not be created for a bad file descriptor")
} }
} }
func TestTokenize_Patterns(t *testing.T) {
t.Setenv("GOSH_GLOB", "*.go")
tests := []struct {
input string
pattern string
glob bool
}{
{"*.go", "*.go", true},
{"a?c", "a?c", true},
{"[ab].go", "[ab].go", true},
{"[!a].go", "[^a].go", true},
{"plain", "plain", false},
// Quoted, escaped and expanded characters are literal
{`"*.go"`, `\*.go`, false},
{`'*'.go`, `\*.go`, false},
{`\*.go`, `\*.go`, false},
{`"x"*`, `x*`, true},
{"$GOSH_GLOB", `\*.go`, false},
{`'['!a].go`, `\[!a].go`, false},
}
for _, tc := range tests {
tokens, err := tokenize(tc.input)
if err != nil || len(tokens) != 1 {
t.Errorf("tokenize(%q) = %+v, %v", tc.input, tokens, err)
continue
}
if tokens[0].pattern != tc.pattern || tokens[0].glob != tc.glob {
t.Errorf("tokenize(%q): pattern %q, glob %v; want %q, %v",
tc.input, tokens[0].pattern, tokens[0].glob, tc.pattern, tc.glob)
}
}
}
func TestExpandGlob(t *testing.T) {
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
dir := t.TempDir()
os.Chdir(dir)
for _, d := range []string{"sub/deep", "other"} {
os.MkdirAll(d, 0755)
}
for _, f := range []string{"a.go", "b.go", "c.txt", ".hidden.go", "with space.go", "[x].go", "sub/s1.go", "sub/deep/d.go", "other/o.go"} {
os.WriteFile(f, nil, 0644)
}
tests := []struct {
pattern string
want []string
}{
{"*.go", []string{"[x].go", "a.go", "b.go", "with space.go"}},
{".*.go", []string{".hidden.go"}},
{"?.go", []string{"a.go", "b.go"}},
{"[ab].go", []string{"a.go", "b.go"}},
{"[^a].go", []string{"b.go"}},
{"*/*.go", []string{"other/o.go", "sub/s1.go"}},
{"*/", []string{"other/", "sub/"}},
{"sub/*/*.go", []string{"sub/deep/d.go"}},
{"./c*", []string{"./c.txt"}},
{dir + "/c*", []string{dir + "/c.txt"}},
{`\[x\].go`, []string{"[x].go"}},
{"*.xyz", nil},
{"[", nil},
}
for _, tc := range tests {
if got := expandGlob(tc.pattern); !reflect.DeepEqual(got, tc.want) {
t.Errorf("expandGlob(%q) = %q, want %q", tc.pattern, got, tc.want)
}
}
}
func TestExecInput_Wildcards(t *testing.T) {
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
dir := t.TempDir()
os.Chdir(dir)
for _, f := range []string{"a.go", "b.go", "c.txt"} {
os.WriteFile(f, []byte(f+"\n"), 0644)
}
tests := []struct {
input string
want string
}{
{"echo *.go > out", "a.go b.go\n"},
{"cat *.go > out", "a.go\nb.go\n"},
// No match: the word is passed on unchanged, like in sh
{"echo *.xyz > out", "*.xyz\n"},
{`echo "*.go" '?.go' \*.go > out`, "*.go ?.go *.go\n"},
// A redirection to a pattern with a single match uses that file
{"echo hi > *.txt; cat c.txt > out", "hi\n"},
}
for _, tc := range tests {
if err := execInput(tc.input); err != nil {
t.Errorf("execInput(%q) returned error: %v", tc.input, err)
}
if got, _ := os.ReadFile("out"); string(got) != tc.want {
t.Errorf("%q: got %q, want %q", tc.input, got, tc.want)
}
}
err := execInput("echo hi > *.go")
if err == nil || err.Error() != "*.go: ambiguous redirect" {
t.Errorf("expected ambiguous redirect error, got %v", err)
}
}
func TestStartupFiles(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
sys := sysConfDir
tests := []struct {
login, interactive bool
want []string
}{
{false, false, nil},
{true, false, []string{sys + "/profile", home + "/.gosh_profile"}},
{false, true, []string{sys + "/goshrc", home + "/.goshrc"}},
{true, true, []string{sys + "/profile", home + "/.gosh_profile", sys + "/goshrc", home + "/.goshrc"}},
}
for _, tc := range tests {
if got := startupFiles(tc.login, tc.interactive); !reflect.DeepEqual(got, tc.want) {
t.Errorf("startupFiles(%v, %v) = %q, want %q", tc.login, tc.interactive, got, tc.want)
}
}
}
func TestRun_LoginReadsProfile(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
os.Unsetenv("GOSH_TEST_PROFILE")
defer os.Unsetenv("GOSH_TEST_PROFILE")
os.WriteFile(filepath.Join(home, ".gosh_profile"), []byte("export GOSH_TEST_PROFILE=yes\n"), 0644)
// .goshrc is only read by interactive shells
os.WriteFile(filepath.Join(home, ".goshrc"), []byte("exit 99\n"), 0644)
check := `sh -c 'test "$GOSH_TEST_PROFILE" = yes'`
if got := run([]string{"-c", check}); got != 1 {
t.Errorf("non-login shell: status %d, want 1 (profile must not be read)", got)
}
if got := run([]string{"-l", "--noprofile", "-c", check}); got != 1 {
t.Errorf("--noprofile: status %d, want 1 (profile must not be read)", got)
}
if got := run([]string{"-l", "-c", check}); got != 0 {
t.Errorf("login shell: status %d, want 0 (profile must be read)", got)
}
}
func TestShell_InteractiveReadsRC(t *testing.T) {
bin := buildShell(t)
home := t.TempDir()
os.WriteFile(filepath.Join(home, ".goshrc"), []byte("export GOSH_TEST_RC=from-rc\n"), 0644)
os.WriteFile(filepath.Join(home, ".gosh_profile"), []byte("export GOSH_TEST_PROFILE=from-profile\n"), 0644)
tests := []struct {
args []string
want string
}{
{[]string{"-i"}, "from-rc -\n"},
{[]string{"-i", "--norc"}, "- -\n"},
{[]string{"-i", "-l"}, "from-rc from-profile\n"},
{[]string{"-i", "-l", "--norc", "--noprofile"}, "- -\n"},
}
for _, tc := range tests {
cmd := exec.Command(bin, tc.args...)
cmd.Env = append(os.Environ(), "HOME="+home)
cmd.Stdin = strings.NewReader("echo ${GOSH_TEST_RC:--} ${GOSH_TEST_PROFILE:--}\nexit\n")
out, err := cmd.Output()
if err != nil {
t.Fatalf("%v: %v", tc.args, err)
}
if !strings.HasSuffix(string(out), tc.want) {
t.Errorf("%v: output %q, want suffix %q", tc.args, out, tc.want)
}
}
}
func TestExecInput_Source(t *testing.T) {
dir := t.TempDir()
os.Unsetenv("GOSH_TEST_SOURCE")
defer os.Unsetenv("GOSH_TEST_SOURCE")
file := filepath.Join(dir, "vars")
os.WriteFile(file, []byte("# comment\nexport GOSH_TEST_SOURCE=hello\nsh -c 'exit 4'\n"), 0644)
for _, cmd := range []string{"source", "."} {
os.Unsetenv("GOSH_TEST_SOURCE")
execInput(cmd + " " + file)
if os.Getenv("GOSH_TEST_SOURCE") != "hello" {
t.Errorf("%s: variable was not set in the current shell", cmd)
}
if lastStatus != 4 {
t.Errorf("%s: $? = %d, want 4 (status of the last command)", cmd, lastStatus)
}
}
err := execInput("source " + filepath.Join(dir, "missing"))
if err == nil || !strings.Contains(err.Error(), "source: "+dir+"/missing: no such file or directory") {
t.Errorf("unexpected error for missing file: %v", err)
}
if lastStatus != 1 {
t.Errorf("$? = %d for missing file, want 1", lastStatus)
}
// A file that sources itself is stopped
loop := filepath.Join(dir, "loop")
os.WriteFile(loop, []byte("source "+loop+"\n"), 0644)
if err := execInput("source " + loop); err == nil {
t.Error("expected an error for a file that sources itself")
}
}
func TestGetHomeDir_UsesHOME(t *testing.T) {
t.Setenv("HOME", "/gosh/test/home")
if got := getHomeDir(); got != "/gosh/test/home" {
t.Errorf("getHomeDir() = %q, want $HOME", got)
}
if words, _ := splitWords("echo ~/x"); words[1] != "/gosh/test/home/x" {
t.Errorf("~/x = %q, want it based on $HOME", words[1])
}
// Without $HOME the user database is used
t.Setenv("HOME", "")
if got := getHomeDir(); got == "" {
t.Error("getHomeDir() is empty without $HOME")
}
}
func TestExecInput_Background(t *testing.T) {
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
dir := t.TempDir()
os.Chdir(dir)
// The shell does not wait for a background job, wait does
start := time.Now()
if err := execInput("sh -c 'sleep 0.3; echo done > out' &"); err != nil {
t.Fatal(err)
}
if lastStatus != 0 {
t.Errorf("$? = %d after starting a background job, want 0", lastStatus)
}
if time.Since(start) > 200*time.Millisecond {
t.Error("the shell waited for the background job")
}
if _, err := os.Stat("out"); err == nil {
t.Error("background job finished too early")
}
if err := execInput("wait"); err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile("out"); string(got) != "done\n" {
t.Errorf("out = %q after wait", got)
}
if len(jobTable) != 0 {
t.Errorf("job table not empty after wait: %d jobs", len(jobTable))
}
// wait %n returns the status of the job
execInput("sh -c 'exit 3' &")
execInput("wait %1")
if lastStatus != 3 {
t.Errorf("$? = %d after wait %%1, want 3", lastStatus)
}
// Without job control, background jobs read from /dev/null
execInput("cat > stdin.txt &")
execInput("wait")
if got, err := os.ReadFile("stdin.txt"); err != nil || len(got) != 0 {
t.Errorf("background job read %q, %v from stdin", got, err)
}
// jobs lists running jobs, also into a redirection
execInput("sleep 0.3 &")
execInput("jobs > jobs.txt")
if got, _ := os.ReadFile("jobs.txt"); !strings.Contains(string(got), "Running sleep 0.3 &") {
t.Errorf("jobs output = %q", got)
}
execInput("wait")
// Builtins that change the shell cannot run in the background
if err := execInput("cd / &"); err != nil {
t.Fatal(err)
}
execInput("wait %1")
if wd, _ := os.Getwd(); wd != dir {
t.Errorf("cd in the background changed the directory to %s", wd)
}
// fg and bg need job control
for _, cmd := range []string{"fg", "bg"} {
if err := execInput(cmd); err == nil || err.Error() != cmd+": no job control" {
t.Errorf("%s without job control: %v", cmd, err)
}
}
if err := execInput("wait %9"); err == nil || err.Error() != "wait: %9: no such job" {
t.Errorf("wait %%9: %v", err)
}
}