Compare commits

..
3 Commits
Author SHA1 Message Date
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
Florian WaltherandClaude Opus 5.5 c46903e3d2 add pipes and redirections
- tokenizer recognizes unquoted |, <, >, >>, N>, N>>, N>&M, N<&M,
  &> and &>>; only a word of unquoted digits is a file descriptor
- pipelines run concurrently, the status is the one of the last command,
  errors of earlier stages are printed
- redirections are applied left to right (fds 0-2), a redirection
  without a command creates/truncates the file
- pwd and help can be redirected and piped; cd, set, export, exit and
  quit are rejected in pipelines
- syntax errors have exit status 2
- completion: commands after |, ;, && and ||, files after redirections;
  operator characters are escaped in completed names

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:39:38 +02:00
3 changed files with 1165 additions and 75 deletions

No files matched your search

+104 -4
View File
@@ -12,6 +12,7 @@ Built-in commands:
quit [code] - Quit shell with status code (default: $?) quit [code] - Quit shell with status code (default: $?)
set VAR=val - Set environment variable set VAR=val - Set environment variable
export VAR=val - Export environment variable export VAR=val - Export environment variable
source file - Run the commands of file in this shell (also: . file)
help - Show this help help - Show this help
Features: Features:
@@ -23,6 +24,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);
@@ -31,16 +35,21 @@ Features:
using an unset variable is an error, use ${VAR:-} for an empty default using an unset variable is an error, use ${VAR:-} for an empty default
- Command lists: a ; b (run both), a && b (b only if a succeeded), - Command lists: a ; b (run both), a && b (b only if a succeeded),
a || b (b only if a failed); $? is the exit status of the last command a || b (b only if a failed); $? is the exit status of the last command
- Pipes (a | b) and redirections: < file, > file, >> file, 2> file,
2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file
- 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
``` ```
@@ -51,11 +60,49 @@ 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 (z. B. `ssh host 'cd /srv && git pull'`), Pipes und Umleitungen kennt gosh aber noch nicht. 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
@@ -81,6 +128,59 @@ 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
```
$ ls | sort -r | head -3
$ make > build.log 2>&1
$ grep -c error < build.log
```
| Syntax | Bedeutung |
|-----------------|------------------------------------------------------------|
| `a \| b` | stdout von `a` wird stdin von `b` |
| `< datei` | stdin aus `datei` lesen |
| `> datei` | stdout in `datei` schreiben (wird überschrieben) |
| `>> datei` | stdout an `datei` anhängen |
| `2> datei` | stderr in `datei` schreiben (`2>>` hängt an) |
| `2>&1` | stderr dorthin, wohin stdout gerade geht |
| `>&2` | stdout dorthin, wohin stderr gerade geht |
| `&> datei` | stdout und stderr in `datei` (`&>>` hängt an) |
| `> datei` | ohne Befehl: legt `datei` an bzw. leert sie |
- Umleitungen werden von links nach rechts ausgewertet: `befehl > log 2>&1` schreibt beides in `log`, `befehl 2>&1 > log` schickt stderr dorthin, wo stdout vorher hinging (z. B. in eine Pipe).
- Unterstützt werden die Dateideskriptoren 0, 1 und 2. Vor `>` und `<` zählt nur eine Zahl ohne Anführungszeichen als Deskriptor (`2>f`, aber nicht `x2>f` oder `"2">f`).
- Operatoren in Anführungszeichen oder mit `\` davor sind normale Zeichen: `echo "a|b" 'c>d' e\>f`.
- Der Exit-Status einer Pipeline ist der des letzten Befehls. Beendet sich ein Befehl am Ende (z. B. `head`), bekommt der schreibende Befehl davor `SIGPIPE`, `yes | head -1` endet also sofort.
- Die Built-ins `pwd` und `help` lassen sich umleiten und in Pipes verwenden (`help | less`). `cd`, `set`, `export`, `exit` und `quit` verändern die Shell selbst und sind in einer Pipeline nicht erlaubt.
- Ein einzelnes `&` (Hintergrundprozesse) gibt es noch nicht, es wird als normales Zeichen übergeben.
### Farben ### Farben
Der Prompt wird über die Umgebungsvariable `GOSH_COLORS` eingefärbt. Sie besteht aus durch `:` getrennten Einträgen `schlüssel=code`: Der Prompt wird über die Umgebungsvariable `GOSH_COLORS` eingefärbt. Sie besteht aus durch `:` getrennten Einträgen `schlüssel=code`:
+672 -66
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
} }
@@ -111,7 +115,18 @@ func expandTilde(path string) string {
return path return path
} }
// splitWords splits input into words like a (very) simple POSIX shell: // token is a word or an operator of a command line.
type token struct {
op string // "" for a word, otherwise |, <, >, >>, >&, <&, &> or &>>
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
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
// shell:
// - words are separated by unquoted blanks, an unquoted # starts a comment // - words are separated by unquoted blanks, an unquoted # starts a comment
// - '...' is taken literally, nothing is expanded inside // - '...' is taken literally, nothing is expanded inside
// - "..." expands $VAR and ${VAR}; \ only escapes $ " \ and ` // - "..." expands $VAR and ${VAR}; \ only escapes $ " \ and `
@@ -120,23 +135,40 @@ func expandTilde(path string) string {
// an unset variable is an error, so that e.g. "rm -rf $DIR/x" never runs // an unset variable is an error, so that e.g. "rm -rf $DIR/x" never runs
// with an empty $DIR // with an empty $DIR
// - ~ and ~/... are expanded at the start of an unquoted word // - ~ and ~/... are expanded at the start of an unquoted word
// - unquoted |, <, >, >>, N<, N>, N>>, N>&M, N<&M, &> and &>> are operators
// //
// Expanded values are never split into several words. // Expanded values are never split into several words.
func splitWords(input string) ([]string, error) { func tokenize(input string) ([]token, error) {
var ( var (
words []string tokens []token
b strings.Builder b strings.Builder
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) 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
) )
endWord := func() { endWord := func() {
if inWord { if inWord {
words = append(words, 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
}
// 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) {
if rawStart < 0 {
rawStart = i
}
} }
for i := 0; i < len(input); i++ { for i := 0; i < len(input); i++ {
@@ -144,25 +176,29 @@ func splitWords(input string) ([]string, 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)
escaped = true escaped = true
case ch == '$': case ch == '$':
startRaw(i)
value, n, err := expandVar(input[i:]) value, n, err := expandVar(input[i:])
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 != "" {
@@ -173,9 +209,10 @@ func splitWords(input string) ([]string, 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)
quote = ch quote = ch
inWord = true inWord = true
case ch == ' ' || ch == '\t' || ch == '\n': case ch == ' ' || ch == '\t' || ch == '\n':
@@ -183,16 +220,76 @@ func splitWords(input string) ([]string, error) {
case ch == '#' && !inWord && b.Len() == 0: case ch == '#' && !inWord && b.Len() == 0:
// Comment until the end of the line // Comment until the end of the line
i = len(input) i = len(input)
case ch == '|':
endWord()
tokens = append(tokens, token{op: "|"})
case ch == '&' && i+1 < len(input) && input[i+1] == '>':
// &> and &>> redirect stdout and stderr
endWord()
op := "&>"
if i+2 < len(input) && input[i+2] == '>' {
op = "&>>"
}
tokens = append(tokens, token{op: op, fd: 1})
i += len(op) - 1
case ch == '<' || ch == '>':
// A word consisting only of unquoted digits right before the
// operator is the file descriptor, e.g. 2>file
fd := 0
if ch == '>' {
fd = 1
}
if inWord && rawStart >= 0 && isDigits(input[rawStart:i]) {
fd, _ = strconv.Atoi(input[rawStart:i])
b.Reset()
pat.Reset()
glob = false
inWord = false
rawStart = -1
} else {
endWord()
}
t := token{op: string(ch), fd: fd}
switch {
case ch == '>' && i+1 < len(input) && input[i+1] == '>':
t.op = ">>"
i++
case i+1 < len(input) && input[i+1] == '&':
// N>&M and N<&M duplicate file descriptor M
j := i + 2
for j < len(input) && input[j] >= '0' && input[j] <= '9' {
j++
}
if j == i+2 {
return nil, syntaxError(input[i : i+2])
}
t.op = string(ch) + "&"
t.dup, _ = strconv.Atoi(input[i+2 : j])
i = j - 1
}
tokens = append(tokens, t)
case ch == '~' && !inWord && b.Len() == 0 && case ch == '~' && !inWord && b.Len() == 0 &&
(i+1 == len(input) || strings.IndexByte("/ \t\n", input[i+1]) >= 0): (i+1 == len(input) || strings.IndexByte("/ \t\n|<>", input[i+1]) >= 0):
startRaw(i)
home := getHomeDir() home := getHomeDir()
if home == "" { if home == "" {
home = "~" home = "~"
} }
b.WriteString(home) literal(home)
inWord = true inWord = true
default: default:
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
} }
} }
@@ -204,6 +301,152 @@ func splitWords(input string) ([]string, error) {
return nil, fmt.Errorf("unterminated %c quote", quote) return nil, fmt.Errorf("unterminated %c quote", quote)
} }
endWord() endWord()
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.
func isDigits(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}
// splitWords splits input into words (see tokenize). Operators are an error.
func splitWords(input string) ([]string, error) {
tokens, err := tokenize(input)
if err != nil {
return nil, err
}
var words []string
for _, t := range tokens {
if t.op != "" {
return nil, fmt.Errorf("unexpected operator %s", t.op)
}
words = append(words, t.word)
}
return words, nil return words, nil
} }
@@ -459,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", "help"},
} }
} }
@@ -469,7 +712,10 @@ func newShellCompleter() *shellCompleter {
// runes of the current word that are already typed. // runes of the current word that are already typed.
func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) { func (c *shellCompleter) Do(line []rune, pos int) ([][]rune, int) {
word, start, quote := currentWord(string(line[:pos])) word, start, quote := currentWord(string(line[:pos]))
isFirstWord := strings.TrimSpace(string(line[:start])) == "" // A command is expected at the start and after |, ;, && and ||
before := strings.TrimSpace(string(line[:start]))
isFirstWord := before == "" || strings.HasSuffix(before, "|") ||
strings.HasSuffix(before, ";") || strings.HasSuffix(before, "&&")
// Plain "~" -> "~/" // Plain "~" -> "~/"
if word == "~" { if word == "~" {
@@ -553,7 +799,8 @@ func currentWord(s string) (word string, start int, quote byte) {
} }
case ch == '"' || ch == '\'': case ch == '"' || ch == '\'':
quote = ch quote = ch
case ch == ' ' || ch == '\t': case ch == ' ' || ch == '\t' || strings.IndexByte(";|&<>", ch) >= 0:
// Blanks and operators end a word
b.Reset() b.Reset()
start = i + 1 start = i + 1
default: default:
@@ -567,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)
@@ -622,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":
@@ -665,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 {
@@ -679,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 {
@@ -743,6 +1082,14 @@ func exitStatus(err error) int {
if err == nil { if err == nil {
return 0 return 0
} }
var statusErr statusError
if errors.As(err, &statusErr) {
return int(statusErr)
}
var synErr syntaxError
if errors.As(err, &synErr) {
return 2
}
var exitErr *exec.ExitError var exitErr *exec.ExitError
if errors.As(err, &exitErr) { if errors.As(err, &exitErr) {
if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() { if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
@@ -829,6 +1176,21 @@ func interactive() {
// lastStatus is the exit status of the last command ($?). // lastStatus is the exit status of the last command ($?).
var lastStatus int var lastStatus int
// statusError is an error that only carries an exit status; the problem has
// already been reported.
type statusError int
func (e statusError) Error() string {
return "exit status " + strconv.Itoa(int(e))
}
// syntaxError is a syntax error in the input (exit status 2, like sh).
type syntaxError string
func (e syntaxError) Error() string {
return "syntax error near unexpected token `" + string(e) + "'"
}
// errCommandNotFound is returned when an external command does not exist. // errCommandNotFound is returned when an external command does not exist.
var errCommandNotFound = errors.New("command not found") var errCommandNotFound = errors.New("command not found")
@@ -836,8 +1198,11 @@ var errCommandNotFound = errors.New("command not found")
// failed have already reported the problem themselves, only their exit // failed have already reported the problem themselves, only their exit
// status is kept. // status is kept.
func printError(err error) { func printError(err error) {
var exitErr *exec.ExitError var (
if err == nil || errors.As(err, &exitErr) { exitErr *exec.ExitError
statusErr statusError
)
if err == nil || errors.As(err, &exitErr) || errors.As(err, &statusErr) {
return return
} }
fmt.Fprintln(os.Stderr, "gosh:", err) fmt.Fprintln(os.Stderr, "gosh:", err)
@@ -872,7 +1237,7 @@ func splitList(input string) ([]listItem, error) {
if next == "" { if next == "" {
next = op next = op
} }
return fmt.Errorf("syntax error near unexpected token `%s'", next) return syntaxError(next)
} }
return nil return nil
} }
@@ -940,7 +1305,7 @@ scan:
func execInput(input string) error { func execInput(input string) error {
items, err := splitList(input) items, err := splitList(input)
if err != nil { if err != nil {
lastStatus = 2 lastStatus = exitStatus(err)
return err return err
} }
@@ -956,19 +1321,262 @@ func execInput(input string) error {
return lastErr return lastErr
} }
// execCommand executes a single simple command. // execCommand executes a pipeline of simple commands with redirections.
func execCommand(input string) error { func execCommand(input string) error {
// Split the input into the command and its arguments, expanding // Split the input into words and operators, expanding variables and ~ on
// variables and ~ on the way. // the way.
args, err := splitWords(input) tokens, err := tokenize(input)
var synErr syntaxError
if errors.As(err, &synErr) {
return err
}
if err != nil { if err != nil {
return fmt.Errorf("invalid input: %w", err) return fmt.Errorf("invalid input: %w", err)
} }
if len(args) == 0 { stages, err := parsePipeline(tokens)
if err != nil {
return err
}
if len(stages) == 0 {
return nil return nil
} }
return runPipeline(stages)
}
// Check for built-in commands. // redirect is a redirection of a simple command, e.g. 2>>file or 2>&1.
type redirect struct {
op string // <, >, >>, &>, &>>, >& or <&
fd int // the redirected file descriptor
dup int // the target file descriptor of >& and <&
file string // the file name for all other operators
}
// stage is a simple command of a pipeline.
type stage struct {
args []string
redirects []redirect
}
// parsePipeline groups tokens into the stages of a pipeline.
func parsePipeline(tokens []token) ([]stage, error) {
var (
stages []stage
cur stage
)
isEmpty := func(s stage) bool { return 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)...)
case "|":
if isEmpty(cur) {
return nil, syntaxError("|")
}
stages = append(stages, cur)
cur = stage{}
case ">&", "<&":
cur.redirects = append(cur.redirects, redirect{op: t.op, fd: t.fd, dup: t.dup})
default:
if i+1 == len(tokens) {
return nil, syntaxError("newline")
}
if next := tokens[i+1]; next.op != "" {
return nil, syntaxError(next.op)
}
i++
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]})
}
}
if isEmpty(cur) {
if len(stages) > 0 {
return nil, syntaxError("|")
}
return nil, nil
}
return append(stages, cur), nil
}
// runPipeline runs the stages of a pipeline concurrently, connecting the
// stdout of each stage to the stdin of the next one. It returns the error of
// the last stage (so $? is its exit status, like in sh); errors of the other
// stages are printed.
func runPipeline(stages []stage) error {
waits := make([]func() error, len(stages))
stdin := os.Stdin
for i, st := range stages {
stdout := os.Stdout
var next *os.File
if i < len(stages)-1 {
r, w, err := os.Pipe()
if err != nil {
// Let the stages that already run finish
if stdin != os.Stdin {
stdin.Close()
}
for _, wait := range waits[:i] {
printError(wait())
}
return err
}
stdout, next = w, r
}
waits[i] = startStage(st, stdin, stdout, len(stages) > 1)
stdin = next
}
var err error
for i, wait := range waits {
err = wait()
if i < len(waits)-1 {
printError(err)
}
}
return err
}
// startStage starts a stage of a pipeline with the given stdin and stdout and
// returns a function that waits for it to finish. Pipe ends passed in are
// closed by startStage once the stage no longer needs them, so that the
// neighbors see EOF or get SIGPIPE.
func startStage(st stage, stdin, stdout *os.File, inPipeline bool) func() error {
var owned []*os.File
if stdin != os.Stdin {
owned = append(owned, stdin)
}
if stdout != os.Stdout {
owned = append(owned, stdout)
}
closeOwned := func() {
for _, f := range owned {
f.Close()
}
}
result := func(err error) func() error {
return func() error { return err }
}
// Connect the command to the terminal unless redirected. Without Stdin
// the command would read from /dev/null, so interactive programs (su,
// 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}
opened, err := applyRedirects(&fds, st.redirects)
owned = append(owned, opened...)
if err != nil || len(st.args) == 0 {
// A command without arguments only creates or truncates the files
closeOwned()
return result(err)
}
if builtin, ok := builtins[st.args[0]]; ok {
if inPipeline && builtin.changesState {
closeOwned()
return result(fmt.Errorf("%s: cannot be used in a pipeline", st.args[0]))
}
run := func() error {
defer closeOwned()
err := runBuiltin(st.args, fds[1])
if err != nil && fds[2] != os.Stderr {
// Report the error where stderr is redirected to
fmt.Fprintln(fds[2], "gosh:", err)
return statusError(1)
}
return err
}
if !inPipeline {
return result(run())
}
done := make(chan error, 1)
go func() { done <- run() }()
return func() error { return <-done }
}
cmd := exec.Command(st.args[0], st.args[1:]...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = fds[0], fds[1], fds[2]
err = cmd.Start()
// The child has its own copies of the files now
closeOwned()
if errors.Is(err, exec.ErrNotFound) {
err = fmt.Errorf("%s: %w", sanitize(st.args[0]), errCommandNotFound)
}
if err != nil {
return result(err)
}
return cmd.Wait
}
// 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) {
var opened []*os.File
fail := func(err error) ([]*os.File, error) {
for _, f := range opened {
f.Close()
}
return nil, err
}
for _, r := range redirects {
if r.fd > 2 {
return fail(fmt.Errorf("%d: bad file descriptor", r.fd))
}
if r.op == ">&" || r.op == "<&" {
if r.dup > 2 {
return fail(fmt.Errorf("%d: bad file descriptor", r.dup))
}
fds[r.fd] = fds[r.dup]
continue
}
flags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC
switch r.op {
case "<":
flags = os.O_RDONLY
case ">>", "&>>":
flags = os.O_WRONLY | os.O_CREATE | os.O_APPEND
}
f, err := os.OpenFile(r.file, flags, 0666)
if err != nil {
// "file: No such file or directory" with exit status 1, like sh
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
err = fmt.Errorf("%s: %v", sanitize(r.file), pathErr.Err)
}
return fail(err)
}
opened = append(opened, f)
fds[r.fd] = f
if r.op == "&>" || r.op == "&>>" {
fds[2] = f
}
}
return opened, nil
}
// builtins lists the built-in commands. Commands that change the state of
// 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},
}
// runBuiltin executes a built-in command, writing its output to stdout.
func runBuiltin(args []string, stdout io.Writer) error {
switch args[0] { switch args[0] {
case "cd": case "cd":
// Save current directory before changing (for 'cd -') // Save current directory before changing (for 'cd -')
@@ -1002,7 +1610,7 @@ func execCommand(input string) error {
return os.Chdir(path) return os.Chdir(path)
case "pwd": case "pwd":
wd, err := os.Getwd() wd, err := os.Getwd()
fmt.Println(wd) fmt.Fprintln(stdout, wd)
return err return err
case "exit": case "exit":
// exit with optional status code // exit with optional status code
@@ -1044,8 +1652,14 @@ func execCommand(input string) error {
} }
os.Setenv(parts[0], parts[1]) os.Setenv(parts[0], parts[1])
return nil return nil
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.Println("gosh - Go Shell\nVersion: " + versionInfo() + ` fmt.Fprintln(stdout, "gosh - Go Shell\nVersion: " + versionInfo() + `
Built-in commands: Built-in commands:
cd [dir] - Change directory (cd with no args goes to home) cd [dir] - Change directory (cd with no args goes to home)
@@ -1055,6 +1669,7 @@ Built-in commands:
quit [code] - Quit shell with status code (default: $?) quit [code] - Quit shell with status code (default: $?)
set VAR=val - Set environment variable set VAR=val - Set environment variable
export VAR=val - Export environment variable export VAR=val - Export environment variable
source file - Run the commands of file in this shell (also: . file)
help - Show this help help - Show this help
Features: Features:
@@ -1066,6 +1681,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);
@@ -1074,26 +1692,14 @@ Features:
using an unset variable is an error, use ${VAR:-} for an empty default using an unset variable is an error, use ${VAR:-} for an empty default
- Command lists: a ; b (run both), a && b (b only if a succeeded), - Command lists: a ; b (run both), a && b (b only if a succeeded),
a || b (b only if a failed); $? is the exit status of the last command a || b (b only if a failed); $? is the exit status of the last command
- Pipes (a | b) and redirections: < file, > file, >> file, 2> file,
2>> file, 2>&1, >&2, &> file (stdout and stderr), &>> file
- 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])
// Prepare the command to execute.
cmd := exec.Command(args[0], args[1:]...)
// Connect the command to the terminal. Without Stdin 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 from the command.
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// Execute the command return the error.
err = cmd.Run()
if errors.Is(err, exec.ErrNotFound) {
return fmt.Errorf("%s: %w", sanitize(args[0]), errCommandNotFound)
}
return err
} }
+389 -5
View File
@@ -859,6 +859,15 @@ func TestShellCompleter_Do(t *testing.T) {
{"ls " + tmpDir + "/ma", []string{"in.go"}, 2}, {"ls " + tmpDir + "/ma", []string{"in.go"}, 2},
{"ls xyz", nil, 3}, {"ls xyz", nil, 3},
{"ls ~", []string{"/"}, 1}, {"ls ~", []string{"/"}, 1},
// Commands are completed after |, ;, && and ||, files after redirections
{"ls | gi", []string{"tty "}, 2},
{"ls|gi", []string{"tty "}, 2},
{"true; gi", []string{"tty "}, 2},
{"true && gi", []string{"tty "}, 2},
{"false || gi", []string{"tty "}, 2},
{"ls > ma", []string{"in.go"}, 2},
{"ls 2>ma", []string{"in.go"}, 2},
{"cat <ma", []string{"in.go"}, 2},
} }
for _, tt := range tests { for _, tt := range tests {
got, length := completeLine(tt.line) got, length := completeLine(tt.line)
@@ -880,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)
@@ -1174,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")
@@ -1206,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()
@@ -1304,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"} {
@@ -1314,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)
} }
} }
@@ -1326,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},
@@ -1471,3 +1483,375 @@ func TestExecInput_CommandNotFound(t *testing.T) {
t.Errorf("$? = %d, want 127", lastStatus) t.Errorf("$? = %d, want 127", lastStatus)
} }
} }
func TestTokenize_Operators(t *testing.T) {
tests := []struct {
input string
want []token
}{
{"a | b", []token{{word: "a"}, {op: "|"}, {word: "b"}}},
{"a|b", []token{{word: "a"}, {op: "|"}, {word: "b"}}},
{"a > f", []token{{word: "a"}, {op: ">", fd: 1}, {word: "f"}}},
{"a >>f", []token{{word: "a"}, {op: ">>", fd: 1}, {word: "f"}}},
{"a < f", []token{{word: "a"}, {op: "<", fd: 0}, {word: "f"}}},
{"a 2>f", []token{{word: "a"}, {op: ">", fd: 2}, {word: "f"}}},
{"a 2>>f", []token{{word: "a"}, {op: ">>", fd: 2}, {word: "f"}}},
{"a 2>&1", []token{{word: "a"}, {op: ">&", fd: 2, dup: 1}}},
{"a >&2", []token{{word: "a"}, {op: ">&", fd: 1, dup: 2}}},
{"a 0<&2", []token{{word: "a"}, {op: "<&", fd: 0, dup: 2}}},
{"a &>f", []token{{word: "a"}, {op: "&>", fd: 1}, {word: "f"}}},
{"a &>>f", []token{{word: "a"}, {op: "&>>", fd: 1}, {word: "f"}}},
// Only a word of unquoted digits is a file descriptor
{"a x2>f", []token{{word: "a"}, {word: "x2"}, {op: ">", fd: 1}, {word: "f"}}},
{`a "2">f`, []token{{word: "a"}, {word: "2"}, {op: ">", fd: 1}, {word: "f"}}},
{"a 2 >f", []token{{word: "a"}, {word: "2"}, {op: ">", fd: 1}, {word: "f"}}},
// Quoted and escaped operators are words
{`echo "a|b" 'c>d' e\<f`, []token{{word: "echo"}, {word: "a|b"}, {word: "c>d"}, {word: "e<f"}}},
// A lone & is a word
{"a & b", []token{{word: "a"}, {word: "&"}, {word: "b"}}},
{"cat ~|x", []token{{word: "cat"}, {word: getHomeDir()}, {op: "|"}, {word: "x"}}},
}
for _, tc := range tests {
got, err := tokenize(tc.input)
if err != nil {
t.Errorf("tokenize(%q) returned error: %v", tc.input, err)
continue
}
// Patterns are tested in TestTokenize_Patterns
for i := range got {
got[i].pattern, got[i].glob = "", false
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("tokenize(%q) = %+v, want %+v", tc.input, got, tc.want)
}
}
}
func TestExecInput_SyntaxErrors(t *testing.T) {
for _, input := range []string{"echo a |", "| echo a", "echo a | | b", "echo a >", "echo a > | b", "echo a >&", "echo a 2>&x", "; a"} {
err := execInput(input)
if err == nil || !strings.Contains(err.Error(), "syntax error") {
t.Errorf("execInput(%q) = %v, want syntax error", input, err)
}
if lastStatus != 2 {
t.Errorf("execInput(%q): $? = %d, want 2", input, lastStatus)
}
}
}
func TestExecInput_PipesAndRedirections(t *testing.T) {
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
dir := t.TempDir()
os.Chdir(dir)
read := func(name string) string {
data, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
t.Errorf("reading %s: %v", name, err)
}
return string(data)
}
tests := []struct {
input string
file string
want string
status int
}{
{`printf 'b\na\nc\n' | sort | head -n 2 > out`, "out", "a\nb\n", 0},
{"echo one > f1; echo two >> f1", "f1", "one\ntwo\n", 0},
{"echo one > f2; echo two > f2", "f2", "two\n", 0},
{"tr a-z A-Z < f1 > f3", "f3", "ONE\nTWO\n", 0},
{"sh -c 'echo out; echo err >&2' > f4 2>&1", "f4", "out\nerr\n", 0},
{"sh -c 'echo out; echo err >&2' &> f5", "f5", "out\nerr\n", 0},
{"sh -c 'echo err >&2' 2> f6", "f6", "err\n", 0},
// 2>&1 before >file sends stderr to the old stdout (the pipe)
{"sh -c 'echo out; echo err >&2' 2>&1 > /dev/null | cat > f7", "f7", "err\n", 0},
{"sh -c 'echo err >&2' 2>&1 | tr a-z A-Z > f8", "f8", "ERR\n", 0},
// Builtins write to redirections and pipes
{"pwd > f9", "f9", dir + "\n", 0},
{"pwd | cat > f10", "f10", dir + "\n", 0},
{"help | head -n 1 > f11", "f11", "gosh - Go Shell\n", 0},
// Errors of builtins go to the redirected stderr
{"cd /gosh/no/such/dir 2> f12", "f12", "gosh: chdir /gosh/no/such/dir: no such file or directory\n", 1},
// The status of a pipeline is the one of the last command
{"false | true > f13", "f13", "", 0},
{"true | false > f14", "f14", "", 1},
// A redirection without a command creates the file
{"> f15", "f15", "", 0},
// SIGPIPE ends the writer when the reader exits
{"yes | head -n 1 > f16", "f16", "y\n", 0},
// A command that is not found does not stop the pipeline
{"gosh-no-such-command | echo after > f17", "f17", "after\n", 0},
{"echo before | gosh-no-such-command 2> f18", "f18", "", 127},
}
for _, tc := range tests {
execInput(tc.input)
if lastStatus != tc.status {
t.Errorf("%q: $? = %d, want %d", tc.input, lastStatus, tc.status)
}
if got := read(tc.file); got != tc.want {
t.Errorf("%q: %s = %q, want %q", tc.input, tc.file, got, tc.want)
}
}
}
func TestExecInput_RedirectionErrors(t *testing.T) {
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
os.Chdir(t.TempDir())
tests := []struct {
input string
wantErr string
}{
{"cat < gosh-no-such-file", "gosh-no-such-file: no such file or directory"},
{"echo a > gosh-no-such-dir/f", "gosh-no-such-dir/f: no such file or directory"},
{"echo a 3> f", "3: bad file descriptor"},
{"echo a 2>&5", "5: bad file descriptor"},
{"echo a | cd /", "cd: cannot be used in a pipeline"},
{"echo a | exit 3", "exit: cannot be used in a pipeline"},
}
for _, tc := range tests {
err := execInput(tc.input)
if err == nil || err.Error() != tc.wantErr {
t.Errorf("execInput(%q) = %v, want %q", tc.input, err, tc.wantErr)
}
if lastStatus != 1 {
t.Errorf("execInput(%q): $? = %d, want 1", tc.input, lastStatus)
}
}
// The command is not run if a redirection fails
if _, err := os.Stat("f"); err == nil {
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")
}
}