Compare commits

..
3 Commits
Author SHA1 Message Date
Florian WaltherandClaude Opus 5.5 9fe8b06fba add man page gosh(1) and include it in the deb/rpm packages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:59:09 +02:00
Florian WaltherandClaude Opus 5.5 22ce8c00d4 add assignments name=value and unset
- name=value (one or more) without a command sets variables of the shell
- name=value before a command sets them only in its environment (for
  builtins only while they run)
- an assignment needs an unquoted name and =; the value is not split
  and not expanded as wildcard or brace pattern, ~ is expanded after =
  and :
- the status of an assignment is the one of its last $(...)
- new builtin unset

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:17:38 +02:00
Florian Walther fb94e98da5 updated README 2026-10-04 00:16:29 +02:00
8 changed files with 774 additions and 17 deletions

No files matched your search

+1
View File
@@ -1,2 +1,3 @@
gosh
dist/
packaging/man/gosh.1.gz
+10
View File
@@ -12,6 +12,8 @@ before:
hooks:
- go mod tidy
- go test ./...
# Man page with the release version, gzipped for /usr/share/man
- sh -c 'sed "s/@VERSION@/{{ .Version }}/" packaging/man/gosh.1 | gzip -9n > packaging/man/gosh.1.gz'
builds:
- id: gosh
@@ -50,6 +52,10 @@ archives:
- src: README.md
info:
mode: 0644
- src: packaging/man/gosh.1.gz
dst: man/gosh.1.gz
info:
mode: 0644
nfpms:
- id: gosh
@@ -75,6 +81,10 @@ nfpms:
dst: /usr/share/doc/gosh/README.md
file_info:
mode: 0644
- src: packaging/man/gosh.1.gz
dst: /usr/share/man/man1/gosh.1.gz
file_info:
mode: 0644
scripts:
postinstall: packaging/scripts/postinstall.sh
postremove: packaging/scripts/postremove.sh
+32 -7
View File
@@ -1,8 +1,8 @@
`gosh` eine einfache Shell, geschrieben in golang.
`gosh` eine Shell, in golang geschrieben.
```
gosh - Go Shell
Version: 0.2.1 (branch: main, commit: 03e791c)
Version: 0.13.0 (branch: main, commit: f79fb0b)
Built-in commands:
cd [dir] - Change directory (cd with no args goes to home)
@@ -12,6 +12,7 @@ Built-in commands:
quit [code] - Quit shell with status code (default: $?)
set VAR=val - Set environment variable
export VAR=val - Export environment variable
unset VAR - Remove a variable
source file - Run the commands of file in this shell (also: . file)
jobs - List background and stopped jobs
fg [%n] - Continue job n (default: current job) in the foreground
@@ -44,6 +45,9 @@ Features:
GOSH_COLORS='user=1;33:path=36' (keys user, root, path, error; values
are ANSI SGR codes, empty = no color); disabled with $NO_COLOR,
TERM=dumb or when output is not a terminal
- Variables: name=value sets a variable (like set), name=value command
sets it only for that command; the value is not split or expanded as
a pattern
- Environment variable expansion ($VAR, ${VAR}, ${VAR:-default});
using an unset variable is an error, use ${VAR:-} for an empty default
- Command substitution: $(command) is replaced by the output of command
@@ -150,10 +154,10 @@ done
for i in {1..5}; do echo "Durchlauf $i"; done
set n=3
n=3
while [ $n -gt 0 ]; do
echo $n
set n=$(expr $n - 1)
n=$(expr $n - 1)
done
until ping -c1 -W1 server >/dev/null; do sleep 1; done
@@ -175,7 +179,28 @@ until ping -c1 -W1 server >/dev/null; do sleep 1; done
- Ist ein Befehl noch nicht vollständig (z. B. nach `if …; then` oder `for …` + Enter, nach `&&`, `|` oder einem `\` am Zeilenende), zeigt gosh den Fortsetzungs-Prompt `> `; Ctrl+C verwirft die angefangene Eingabe. Skripte und Startdateien lesen entsprechend weiter.
- Ctrl+C bricht die ganze Befehlszeile ab, also auch eine Schleife (`while true; do sleep 1; done`).
- Der Exit-Status ist der des zuletzt ausgeführten Befehls; ein `if` ohne passenden Zweig und eine Schleife ohne Durchlauf haben den Status 0.
- Nicht unterstützt: Kontrollstrukturen in Pipes (`for …; done | sort`), mit Umleitung (`done > datei`) oder im Hintergrund (`done &`), außerdem `case`, Funktionen, `read` und Zuweisungen der Form `x=1` – Variablen setzt man mit `set x=1`, Arithmetik geht z. B. mit `$(expr $n + 1)`.
- Nicht unterstützt: Kontrollstrukturen in Pipes (`for …; done | sort`), mit Umleitung (`done > datei`) oder im Hintergrund (`done &`), außerdem `case`, Funktionen und `read`. Arithmetik geht z. B. mit `n=$(expr $n + 1)`.
### Variablen
```bash
name=wert # Variable setzen (wie set name=wert)
a=1 b=2 # mehrere auf einmal
gruss="Hallo Welt" # Leerzeichen im Wert brauchen Anführungszeichen
heute=$(date +%F) # Ausgabe eines Befehls
PATH=~/bin:$PATH # ~ wird auch nach = und : ersetzt
LANG=C sort datei # nur für diesen einen Befehl
unset name # Variable entfernen
echo $name ${name:-standard}
```
- Eine Zuweisung ist ein Wort `name=wert` am Anfang eines Befehls, bei dem `name` und `=` ohne Anführungszeichen stehen und `name` aus Buchstaben, Ziffern und `_` besteht (nicht mit einer Ziffer beginnend). `"x=1"`, `x\=1` und `echo x=1` sind keine Zuweisungen.
- Steht danach kein Befehl, wird die Variable in der Shell gesetzt. Wie bei `set` und `export` sind in gosh alle Variablen auch für gestartete Programme sichtbar (es gibt keine reinen Shell-Variablen).
- Steht ein Befehl danach (`LANG=C sort datei`), gilt die Zuweisung nur für diesen Befehl, die Shell behält den alten Wert.
- Der Wert wird nicht in Wörter geteilt und nicht als Wildcard oder `{a,b}` expandiert: `x=$(ls)` speichert die ganze Ausgabe, `x=*.go` speichert `*.go`. Variablen, `$(...)` und `~` (am Anfang, nach `=` und nach `:`) werden ersetzt.
- `$?` ist nach einer Zuweisung der Status des letzten `$(...)` darin, sonst 0.
- Variablen in den Argumenten werden ersetzt, bevor die Zuweisung davor gilt: `x=1 echo $x` gibt nicht `1` aus, sondern meldet `x: unbound variable` (wenn `x` vorher nicht gesetzt war).
- In einer Pipeline (`x=1 | cat`) ändert eine Zuweisung die Shell nicht.
### Befehlsersetzung
@@ -273,7 +298,7 @@ $ ls -d */ # nur Verzeichnisse
- Versteckte Dateien (mit `.` am Anfang) werden nur gefunden, wenn das Muster selbst mit `.` beginnt: `*` findet `.bashrc` nicht, `.*rc` schon.
- Passt keine Datei, bleibt das Wort unverändert stehen (wie in `sh`), `ls *.xyz` meldet dann also `ls` den Fehler.
- In Anführungszeichen oder mit `\` davor sind die Zeichen normale Zeichen: `echo "*"`, `echo '*.go'`, `echo \*`.
- Werte von Variablen werden nicht als Muster behandelt (anders als in `sh`): Nach `set P=*.go` gibt `echo $P` wörtlich `*.go` aus. So kann ein Variablenwert nie unbemerkt auf viele Dateien passen. `echo $DIR/*.go` funktioniert dagegen, weil das `*` direkt in der Zeile steht.
- Werte von Variablen werden nicht als Muster behandelt (anders als in `sh`): Nach `P='*.go'` gibt `echo $P` wörtlich `*.go` aus. So kann ein Variablenwert nie unbemerkt auf viele Dateien passen. `echo $DIR/*.go` funktioniert dagegen, weil das `*` direkt in der Zeile steht.
- Bei einer Umleitung muss das Muster genau eine Datei treffen (`> *.log`), sonst meldet gosh `ambiguous redirect`.
### Pipes und Umleitungen
@@ -482,4 +507,4 @@ git push origin v0.1.0
goreleaser release --clean
```
Das Paket installiert `/usr/bin/gosh` und trägt die Shell in `/etc/shells` ein, sodass sie mit `chsh -s /usr/bin/gosh` gesetzt werden kann.
Das Paket installiert `/usr/bin/gosh` und die Man-Page `gosh(1)` (`man gosh`, Quelle: `packaging/man/gosh.1`, lokal ansehen mit `man ./packaging/man/gosh.1`) und trägt die Shell in `/etc/shells` ein, sodass sie mit `chsh -s /usr/bin/gosh` gesetzt werden kann.
+109
View File
@@ -0,0 +1,109 @@
package main
import (
"os"
"testing"
)
func TestTokenize_Assignments(t *testing.T) {
t.Setenv("HOME", "/home/u")
tests := []struct {
input string
word string
assign bool
}{
{"x=1", "x=1", true},
{"_a1=", "_a1=", true},
{`x="a b"`, "x=a b", true},
{"x=a=b", "x=a=b", true},
{"P=~/bin:~/go/bin", "P=/home/u/bin:/home/u/go/bin", true},
{"x=a~b", "x=a~b", true},
{"x=~", "x=/home/u", true},
// Not an assignment: quoted or escaped name or =, invalid name
{`"x=1"`, "x=1", false},
{`x"=1"`, "x=1", false},
{`x\=1`, "x=1", false},
{"1x=2", "1x=2", false},
{"=x", "=x", false},
{"a-b=1", "a-b=1", false},
}
for _, tc := range tests {
tokens, err := tokenize(tc.input)
if err != nil || len(tokens) != 1 {
t.Errorf("tokenize(%q) = %+v, %v", tc.input, tokens, err)
continue
}
if tokens[0].word != tc.word || tokens[0].assign != tc.assign {
t.Errorf("tokenize(%q) = %q (assign %v), want %q (assign %v)",
tc.input, tokens[0].word, tokens[0].assign, tc.word, tc.assign)
}
}
// The output of $(...) is not split in an assignment
tokens, _ := tokenize("x=$(echo a b) y")
if len(tokens) != 2 || tokens[0].word != "x=a b" || !tokens[0].assign {
t.Errorf("x=$(echo a b): %+v", tokens)
}
}
func TestExec_Assignments(t *testing.T) {
for _, name := range []string{"GOSH_A", "GOSH_B", "GOSH_C"} {
os.Unsetenv(name)
defer os.Unsetenv(name)
}
tests := []struct {
input string
want string
}{
{"GOSH_A=1; echo $GOSH_A > out", "1\n"},
{"GOSH_A=1 GOSH_B=2; echo $GOSH_A$GOSH_B > out", "12\n"},
{`GOSH_A="a b"; echo "$GOSH_A" > out`, "a b\n"},
{"GOSH_A=$(printf 'a b\\nc'); echo \"$GOSH_A\" > out", "a b\nc\n"},
// No wildcard or brace expansion in the value
{"touch x.go; GOSH_A=*.go; echo \"$GOSH_A\" > out", "*.go\n"},
{"GOSH_A={a,b}; echo $GOSH_A > out", "{a,b}\n"},
// ... but as an argument, assignment-like words are normal words
{"echo GOSH_A={a,b} > out", "GOSH_A=a GOSH_A=b\n"},
// Before a command, only in its environment
{"GOSH_C=temp sh -c 'echo $GOSH_C' > out; echo ${GOSH_C:-unset} >> out", "temp\nunset\n"},
{"GOSH_A=old; GOSH_A=new sh -c 'echo $GOSH_A' > out; echo $GOSH_A >> out", "new\nold\n"},
// ... and before a builtin only while it runs
{"GOSH_C=temp pwd > /dev/null; echo ${GOSH_C:-unset} > out", "unset\n"},
// In a pipeline or with a command they do not change the shell
{"GOSH_C=x | cat; echo ${GOSH_C:-unset} > out", "unset\n"},
// Redirections are applied
{"GOSH_A=r > out", ""},
// In loops
{"for i in 1 2 3; do GOSH_A=$i; done; echo $GOSH_A > out", "3\n"},
{"GOSH_B=0; while [ $GOSH_B -lt 3 ]; do echo $GOSH_B >> out; GOSH_B=$(expr $GOSH_B + 1); done", "0\n1\n2\n"},
// Not assignments
{`echo "GOSH_A=1" GOSH_B=2 > out`, "GOSH_A=1 GOSH_B=2\n"},
// unset
{"GOSH_A=1; unset GOSH_A; echo ${GOSH_A:-gone} > out", "gone\n"},
}
for _, tc := range tests {
if got := runIn(t, tc.input); got != tc.want {
t.Errorf("%q: out = %q, want %q", tc.input, got, tc.want)
}
}
// The status of an assignment is the one of its last $(...)
for input, want := range map[string]int{
"GOSH_A=1": 0,
"GOSH_A=$(false)": 1,
"GOSH_A=$(sh -c 'exit 3')": 3,
"GOSH_A=$(false) GOSH_B=$(true)": 0,
} {
runIn(t, input)
if lastStatus != want {
t.Errorf("%q: $? = %d, want %d", input, lastStatus, want)
}
}
if err := execInput("unset 1x"); err == nil || err.Error() != "unset: `1x': not a valid identifier" {
t.Errorf("unset 1x: %v", err)
}
if err := execInput(`"GOSH_A=1"`); err == nil || exitStatus(err) != 127 {
t.Errorf(`"GOSH_A=1" must be a command: %v`, err)
}
}
+19
View File
@@ -22,6 +22,7 @@ func expandBraceWords(input string) (string, error) {
var (
out strings.Builder
afterRedirect bool // the next word is the target of a redirection
commandStart = true
)
for i := 0; i < len(input); {
@@ -33,6 +34,8 @@ func expandBraceWords(input string) (string, error) {
case strings.IndexByte(";&|<>", ch) >= 0:
if ch == '<' || ch == '>' {
afterRedirect = true
} else {
commandStart = true
}
out.WriteByte(ch)
i++
@@ -42,6 +45,16 @@ func expandBraceWords(input string) (string, error) {
return out.String(), nil
default:
end := wordEnd(input, i)
if commandStart && !afterRedirect && isAssignment(input[i:end]) {
// name=value before the command: no brace expansion, like
// in bash
out.WriteString(input[i:end])
i = end
continue
}
if !afterRedirect {
commandStart = false
}
words, err := expandBraces(input[i:end])
if err != nil {
return "", err
@@ -57,6 +70,12 @@ func expandBraceWords(input string) (string, error) {
return out.String(), nil
}
// isAssignment reports whether a word starts with name= (unquoted).
func isAssignment(word string) bool {
name, _, ok := strings.Cut(word, "=")
return ok && isVarName(name)
}
// wordEnd returns the end of the word starting at i: the next unquoted blank
// or operator character.
func wordEnd(s string, i int) int {
+98 -9
View File
@@ -121,6 +121,7 @@ type token struct {
word string // the word, if op == ""
pattern string // the word as glob pattern (quoted characters escaped)
glob bool // the word contains unquoted * ? or [ and is expanded
assign bool // the word is an assignment name=value (unquoted name and =)
fd int // the redirected file descriptor (for redirections)
dup int // the target file descriptor of >& and <&
}
@@ -136,7 +137,8 @@ type token struct {
// - $VAR, ${VAR} and ${VAR:-default} are expanded outside single quotes;
// an unset variable is an error, so that e.g. "rm -rf $DIR/x" never runs
// with an empty $DIR
// - ~ and ~/... are expanded at the start of an unquoted word
// - ~ and ~/... are expanded at the start of an unquoted word, and after
// the = and : of an assignment (PATH=~/bin:~/go/bin)
// - unquoted |, <, >, >>, N<, N>, N>>, N>&M, N<&M, &> and &>> are operators
//
// Expanded values are never split into several words.
@@ -147,6 +149,9 @@ func tokenize(input string) ([]token, error) {
pat strings.Builder // the word as glob pattern, quoted parts escaped
glob bool // the word contains unquoted * ? or [
inWord bool // a word has been started (even if it is still empty)
plain = true // the word so far has no quotes, escapes or expansions
assign bool // the word is an assignment name=value
tildeOK bool // ~ may be expanded here (after = or : of an assignment)
quote byte
escaped bool
rawStart = -1 // start of the current word in input
@@ -154,16 +159,17 @@ func tokenize(input string) ([]token, error) {
endWord := func() {
if inWord {
tokens = append(tokens, token{word: b.String(), pattern: pat.String(), glob: glob})
tokens = append(tokens, token{word: b.String(), pattern: pat.String(), glob: glob, assign: assign})
}
b.Reset()
pat.Reset()
glob = false
glob, assign, plain = false, false, true
inWord = false
rawStart = -1
}
// literal adds quoted, escaped or expanded text: it is never a pattern
literal := func(s string) {
plain = false
b.WriteString(s)
pat.WriteString(escapeGlob(s))
}
@@ -175,6 +181,8 @@ func tokenize(input string) ([]token, error) {
for i := 0; i < len(input); i++ {
ch := input[i]
afterAssignSep := tildeOK
tildeOK = false
switch {
case escaped:
if quote == '"' && !strings.ContainsRune("$\"\\`", rune(ch)) {
@@ -202,7 +210,8 @@ func tokenize(input string) ([]token, error) {
if err != nil {
return nil, err
}
if quote == '"' {
if quote == '"' || assign {
// Quoted or in an assignment, the output is one word
literal(out)
inWord = true
} else {
@@ -241,6 +250,7 @@ func tokenize(input string) ([]token, error) {
}
case ch == '"' || ch == '\'':
startRaw(i)
plain = false
quote = ch
inWord = true
case ch == ' ' || ch == '\t' || ch == '\n':
@@ -296,8 +306,8 @@ func tokenize(input string) ([]token, error) {
i = j - 1
}
tokens = append(tokens, t)
case ch == '~' && !inWord && b.Len() == 0 &&
(i+1 == len(input) || strings.IndexByte("/ \t\n|<>", input[i+1]) >= 0):
case ch == '~' && (!inWord && b.Len() == 0 || afterAssignSep) &&
(i+1 == len(input) || strings.IndexByte("/ \t\n|<>:", input[i+1]) >= 0):
startRaw(i)
home := getHomeDir()
if home == "" {
@@ -307,6 +317,14 @@ func tokenize(input string) ([]token, error) {
inWord = true
default:
startRaw(i)
switch {
case ch == '=' && plain && !assign && isVarName(b.String()):
// name=value; ~ may follow
assign, tildeOK = true, true
case ch == ':' && assign:
// PATH=~/bin:~/go/bin
tildeOK = true
}
b.WriteByte(ch)
switch {
case ch == '*' || ch == '?' || ch == '[':
@@ -742,7 +760,7 @@ type shellCompleter struct {
func newShellCompleter() *shellCompleter {
return &shellCompleter{
commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "source", "jobs", "fg", "bg", "wait", "alias", "unalias", "break", "continue", "help"},
commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "source", "jobs", "fg", "bg", "wait", "alias", "unalias", "break", "continue", "unset", "help"},
}
}
@@ -1391,6 +1409,7 @@ type redirect struct {
// stage is a simple command of a pipeline.
type stage struct {
assigns []string // name=value before the command name
args []string
redirects []redirect
}
@@ -1401,13 +1420,18 @@ func parsePipeline(tokens []token) ([]stage, error) {
stages []stage
cur stage
)
isEmpty := func(s stage) bool { return len(s.args) == 0 && len(s.redirects) == 0 }
isEmpty := func(s stage) bool { return len(s.assigns) == 0 && len(s.args) == 0 && len(s.redirects) == 0 }
for i := 0; i < len(tokens); i++ {
t := tokens[i]
switch t.op {
case "":
cur.args = append(cur.args, expandWord(t)...)
if t.assign && len(cur.args) == 0 {
// The value is not split and not expanded as a pattern
cur.assigns = append(cur.assigns, t.word)
} else {
cur.args = append(cur.args, expandWord(t)...)
}
case "|":
if isEmpty(cur) {
return nil, syntaxError("|")
@@ -1526,6 +1550,20 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
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 && len(st.assigns) > 0 {
// Assignments without a command set variables of the shell (not in
// a pipeline or in the background, where sh runs them in a
// subshell). The status is the one of the last $(...) in them.
if !inPipeline && !background {
for _, a := range st.assigns {
name, value, _ := strings.Cut(a, "=")
os.Setenv(name, value)
}
}
if substStatus != 0 {
err = statusError(substStatus)
}
}
if err != nil || len(st.args) == 0 {
// A command without arguments only creates or truncates the files
closeOwned()
@@ -1552,6 +1590,8 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
return err
}
if !inPipeline && !background {
// name=value before a builtin applies only while it runs
defer setTemporarily(st.assigns)()
return finishedProc(run())
}
p := &proc{done: make(chan error, 1)}
@@ -1561,6 +1601,11 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
cmd := exec.Command(st.args[0], st.args[1:]...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = fds[0], fds[1], fds[2]
if len(st.assigns) > 0 {
// name=value before a command is only in its environment (the
// last value of a name wins)
cmd.Env = append(os.Environ(), st.assigns...)
}
if jobControl {
// All processes of the job share a process group: the one of the
// first process. In the foreground, the first process makes its
@@ -1593,6 +1638,31 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
return &proc{cmd: cmd}
}
// setTemporarily sets the variables of the assignments and returns a
// function that restores the previous values.
func setTemporarily(assigns []string) func() {
type saved struct {
name, value string
ok bool
}
var restore []saved
for _, a := range assigns {
name, value, _ := strings.Cut(a, "=")
old, ok := os.LookupEnv(name)
restore = append(restore, saved{name, old, ok})
os.Setenv(name, value)
}
return func() {
for i := len(restore) - 1; i >= 0; i-- {
if r := restore[i]; r.ok {
os.Setenv(r.name, r.value)
} else {
os.Unsetenv(r.name)
}
}
}
}
// applyRedirects applies the redirections in order to fds (stdin, stdout,
// stderr) and returns the files it opened.
func applyRedirects(fds *[3]*os.File, redirects []redirect) ([]*os.File, error) {
@@ -1661,6 +1731,7 @@ var builtins = map[string]struct{ changesState bool }{
"alias": {},
"unalias": {changesState: true},
":": {},
"unset": {changesState: true},
"break": {changesState: true},
"continue": {changesState: true},
}
@@ -1745,6 +1816,20 @@ func runBuiltin(args []string, stdout io.Writer) error {
case ":":
// Does nothing, successfully (e.g. while :; do ...)
return nil
case "unset":
// unset NAME ... - remove variables
if len(args) < 2 {
return fmt.Errorf("usage: unset NAME ...")
}
var err error
for _, name := range args[1:] {
if !isVarName(name) {
err = fmt.Errorf("unset: `%s': not a valid identifier", sanitize(name))
continue
}
os.Unsetenv(name)
}
return err
case "break", "continue":
return builtinLoopControl(args)
case "alias":
@@ -1776,6 +1861,7 @@ Built-in commands:
quit [code] - Quit shell with status code (default: $?)
set VAR=val - Set environment variable
export VAR=val - Export environment variable
unset VAR - Remove a variable
source file - Run the commands of file in this shell (also: . file)
jobs - List background and stopped jobs
fg [%n] - Continue job n (default: current job) in the foreground
@@ -1808,6 +1894,9 @@ Features:
GOSH_COLORS='user=1;33:path=36' (keys user, root, path, error; values
are ANSI SGR codes, empty = no color); disabled with $NO_COLOR,
TERM=dumb or when output is not a terminal
- Variables: name=value sets a variable (like set), name=value command
sets it only for that command; the value is not split or expanded as
a pattern
- Environment variable expansion ($VAR, ${VAR}, ${VAR:-default});
using an unset variable is an error, use ${VAR:-} for an empty default
- Command substitution: $(command) is replaced by the output of command
+1 -1
View File
@@ -1674,7 +1674,7 @@ func TestTokenize_Operators(t *testing.T) {
}
// Patterns are tested in TestTokenize_Patterns
for i := range got {
got[i].pattern, got[i].glob = "", false
got[i].pattern, got[i].glob, got[i].assign = "", false, false
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("tokenize(%q) = %+v, want %+v", tc.input, got, tc.want)
+504
View File
@@ -0,0 +1,504 @@
.\" Man page for gosh. View with: man ./packaging/man/gosh.1
.\" @VERSION@ is replaced by the release version when building packages.
.TH GOSH 1 "October 2026" "gosh @VERSION@" "User Commands"
.SH NAME
gosh \- a simple shell written in Go
.SH SYNOPSIS
.B gosh
.RB [ \-l ]
.RB [ \-i ]
.RB [ \-\-norc ]
.RB [ \-\-noprofile ]
.RI [ script ]
.br
.B gosh
.RB [ \-l ]
.RB [ \-\-noprofile ]
.B \-c
.I command
.br
.B gosh
.B \-version
.SH DESCRIPTION
.B gosh
is a small command interpreter with a syntax close to
.BR sh (1).
It supports command history, tab completion, variables, command
substitution, command lists, control structures, pipes, redirections,
wildcards, brace expansion, aliases and job control.
.PP
Without arguments and with a terminal on standard input,
.B gosh
starts an interactive shell. With a
.I script
argument it runs the commands of that file, with
.B \-c
the given command string, and otherwise it reads commands from standard
input.
.PP
.B gosh
is a proof of concept and not meant for production use.
.SH OPTIONS
.TP
.B \-c \fIcommand\fP
Run
.I command
and exit.
.TP
.B \-i
Force interactive mode, even if standard input is not a terminal.
.TP
.BR \-l ", " \-\-login
Act as a login shell and read the profile files (see
.BR "STARTUP FILES" ).
A shell whose
.I argv[0]
starts with
.B \-
(as started by
.BR login (1),
.BR sshd (8)
or
.BR "su \-" )
is a login shell as well.
.TP
.B \-\-norc
Do not read the goshrc files.
.TP
.B \-\-noprofile
Do not read the profile files.
.TP
.BR \-version ", " \-\-version ", " \-V
Print the version, branch and commit and exit.
.TP
.BR \-h ", " \-\-help
Print a usage summary and exit.
.TP
.B \-
Read commands from standard input.
.SH NON-INTERACTIVE USE
Scripts, command strings and commands read from a pipe are executed line
by line. Empty lines and lines starting with
.B #
are skipped, so a script may start with
.BR "#!/usr/bin/gosh" .
A failing command does not abort the script; the exit status of
.B gosh
is the one of the last command.
.PP
Because
.B gosh
supports
.BR \-c ,
.BR ssh (1),
.BR scp (1)
and
.BR rsync (1)
work when it is used as login shell.
.SH STARTUP FILES
Startup files are executed in the running shell, so variables and
aliases defined there stay in effect.
.TP
Login shells
read
.I /etc/gosh/profile
and
.IR ~/.gosh_profile .
.TP
Interactive shells
read
.I /etc/gosh/goshrc
and
.IR ~/.goshrc .
.PP
An interactive login shell reads the profile files first, then the goshrc
files. Scripts and
.B \-c
read no startup files unless
.B gosh
is a login shell. Missing files are skipped; errors are reported with file
name and line number and the remaining lines still run. Files of other
shells such as
.I /etc/profile
or
.I ~/.bashrc
are not read.
.SH SYNTAX
.SS Quoting
.TP
.B \(aq...\(aq
Everything is literal.
.TP
.B \(dq...\(dq
Variables and command substitutions are expanded, the result stays one
word.
.TP
.B \e
Escapes the next character.
.SS Variables
.TP
.I name\fB=\fPvalue
Set a variable. All variables are exported to the environment of started
programs. The value is not split into words and not expanded as a pattern;
variables, command substitutions and
.B ~
(at the start, after
.B =
and after
.BR : )
are expanded.
.TP
.I name\fB=\fPvalue command
Set the variable only for
.IR command .
.TP
.BR $VAR ", " ${VAR}
The value of
.BR VAR .
Using an unset variable is an error and aborts the command.
.TP
.B ${VAR:\-default}
The value of
.BR VAR ,
or
.I default
if it is unset or empty. Use
.B ${VAR:\-}
for an empty default.
.TP
.B $?
The exit status of the last command.
.SS Command substitution
.B $(\fIcommand\fP)
is replaced by the output of
.IR command ,
which runs in a subshell. Trailing newlines are removed. Unquoted, the
output is split into words at blanks and newlines; inside double quotes it
stays one word. Wildcards and operators in the output are not interpreted.
Backticks and arithmetic expansion are not supported.
.SS Tilde and brace expansion
.B ~
and
.B ~/path
at the start of an unquoted word are replaced by the home directory.
.PP
Brace expansion generates several words from one:
.B a{b,c}d
becomes
.BR "abd acd" ,
.B {1..5}
becomes
.BR "1 2 3 4 5" ,
.B {01..10..4}
becomes
.B 01 05 09
and
.B {a..e}
becomes
.BR "a b c d e" .
Brace expansion happens before all other expansions.
.SS Wildcards
.B *
matches any string,
.B ?
any single character,
.B [abc]
and
.B [a\-z]
one of the given characters and
.B [!abc]
(or
.BR [^abc] )
any character except those. Matches are sorted. Hidden files are only
matched if the pattern starts with a dot. A pattern without match is kept
unchanged. Quoted wildcards and values of variables are never expanded.
.SS Command lists
.TP
.IB a " ; " b
Run
.IR a ,
then
.IR b .
.TP
.IB a " && " b
Run
.I b
only if
.I a
succeeded.
.TP
.IB a " || " b
Run
.I b
only if
.I a
failed.
.TP
.IB command " &"
Run
.I command
in the background.
.SS Pipes and redirections
.TP
.IB a " | " b
Connect standard output of
.I a
to standard input of
.IR b .
The exit status is the one of
.IR b .
.TP
.BI < " file"
Read standard input from
.IR file .
.TP
.BR > " \fIfile\fP, " >> " \fIfile\fP"
Write (or append) standard output to
.IR file .
.TP
.BR 2> " \fIfile\fP, " 2>> " \fIfile\fP"
Write (or append) standard error to
.IR file .
.TP
.B 2>&1
Redirect standard error to where standard output goes.
.TP
.B >&2
Redirect standard output to where standard error goes.
.TP
.BR &> " \fIfile\fP, " &>> " \fIfile\fP"
Write (or append) standard output and standard error to
.IR file .
.PP
Redirections are evaluated from left to right. Only file descriptors 0, 1
and 2 are supported.
.SS Control structures
.nf
\fBif\fP \fIlist\fP; \fBthen\fP \fIlist\fP; [\fBelif\fP \fIlist\fP; \fBthen\fP \fIlist\fP;] [\fBelse\fP \fIlist\fP;] \fBfi\fP
\fBfor\fP \fIname\fP \fBin\fP \fIwords\fP; \fBdo\fP \fIlist\fP; \fBdone\fP
\fBwhile\fP \fIlist\fP; \fBdo\fP \fIlist\fP; \fBdone\fP
\fBuntil\fP \fIlist\fP; \fBdo\fP \fIlist\fP; \fBdone\fP
\fB!\fP \fIcommand\fP
.fi
.PP
Control structures may span several lines; an incomplete command shows
the continuation prompt
.BR "> " .
.B !
negates the exit status of a command. Control structures cannot be used
in pipes, with redirections or in the background.
.B case
and functions are not supported.
.SS Aliases
In interactive shells, the first word of a command is replaced by its
alias. If the alias value ends with a blank, the next word is checked for
an alias as well.
.B \e\fIname\fP
or
.B \(aq\fIname\fP\(aq
skips the alias. Aliases are not used in scripts and with
.BR \-c .
.SH BUILTIN COMMANDS
.TP
.BR cd " [\fIdir\fP]"
Change the directory; without argument to the home directory.
.TP
.B pwd
Print the working directory.
.TP
.BR echo " [\fIargs\fP]"
Print the arguments.
.TP
.BR exit " [\fIcode\fP], " quit " [\fIcode\fP]"
Exit the shell with status
.I code
(default:
.BR $? ).
.TP
.BI set " VAR" = value
Set a variable.
.TP
.BI export " VAR" = value
Export a variable.
.TP
.BI unset " VAR"
Remove a variable.
.TP
.BI source " file\fR, \fP" ". " file
Run the commands of
.I file
in the current shell.
.TP
.B jobs
List background and stopped jobs.
.TP
.BR fg " [\fB%\fP\fIn\fP]"
Continue job
.I n
(default: the current job) in the foreground.
.TP
.BR bg " [\fB%\fP\fIn\fP]"
Continue stopped job
.I n
in the background.
.TP
.BR wait " [\fB%\fP\fIn\fP]"
Wait for job
.I n
or for all background jobs.
.TP
.BR alias " [\fIname\fP[\fB=\fP\fIvalue\fP] ...]"
Define or show aliases.
.TP
.BR unalias " \fIname\fP, " "unalias \-a"
Remove an alias, or all aliases.
.TP
.B :
Do nothing, exit status 0.
.TP
.BR break " [\fIn\fP], " continue " [\fIn\fP]"
Leave the innermost (or
.IR n -th)
loop, or continue with its next iteration.
.TP
.B help
Show a summary of builtins and features.
.SH JOB CONTROL
In interactive shells, each command line runs as a job in its own process
group.
.B Ctrl+Z
stops the foreground job,
.B Ctrl+C
interrupts it.
.BR jobs ,
.BR fg ,
.B bg
and
.B wait
manage the jobs;
.B %n
or
.I n
refers to job
.IR n ,
.B %\-
to the previous job. Finished background jobs are reported before the
next prompt. Exiting with stopped jobs warns once.
.SH INTERACTIVE USE
.TP
.B Up/Down
Browse the command history.
.TP
.B Tab
Complete commands, aliases and file names.
.TP
.B Ctrl+C
Cancel the current line or command.
.TP
.B Ctrl+D
Exit the shell.
.PP
Lines starting with a space are not saved in the history.
.PP
The prompt shows
.IR user @ host : dir
followed by
.BR $ .
If the last command failed, its exit status is shown as
.BI [ N ]
before the
.BR $ .
.SH ENVIRONMENT
.TP
.B GOSH_HISTFILE
Path of the history file. If set to an empty value, no history is saved.
.TP
.B XDG_STATE_HOME
Base directory of the default history file
.IR $XDG_STATE_HOME/gosh/history .
.TP
.B GOSH_COLORS
Prompt colors as colon-separated
.IB key = code
entries. Keys are
.B user
(default
.BR 1;32 ),
.B root
.RB ( 1;31 ),
.B path
.RB ( 1;34 )
and
.B error
.RB ( 1;31 );
values are ANSI SGR codes, an empty value disables the color. Example:
.B GOSH_COLORS=\(aquser=1;33:path=36\(aq
.TP
.B NO_COLOR
If set, the prompt is not colored. Colors are also disabled with
.B TERM=dumb
or when the output is not a terminal.
.TP
.B HOME
Used for
.B ~
and
.B cd
without argument.
.TP
.B PATH
Directories searched for commands.
.SH FILES
.TP
.I /etc/gosh/profile
System-wide profile for login shells.
.TP
.I ~/.gosh_profile
Personal profile for login shells.
.TP
.I /etc/gosh/goshrc
System-wide startup file for interactive shells.
.TP
.I ~/.goshrc
Personal startup file for interactive shells.
.TP
.I ~/.local/state/gosh/history
Default history file (mode 0600).
.SH EXIT STATUS
The exit status of the last command, or the argument of
.BR exit .
Typical values:
.TP
.B 1
General error.
.TP
.B 2
Syntax error or invalid option.
.TP
.B 126
Command not executable.
.TP
.B 127
Command not found.
.TP
.BI 128+ n
Command terminated by signal
.I n
(130 for Ctrl+C).
.SH EXAMPLES
.nf
gosh \-c \(aqcd /srv && git log | head\(aq
for f in *.log; do gzip "$f"; done
cp config.yaml{,.bak}
make > build.log 2>&1 || echo "failed: $?"
LANG=C sort file
alias ll=\(aqls \-l\(aq
chsh \-s /usr/bin/gosh
.fi
.SH SEE ALSO
.BR sh (1),
.BR bash (1),
.BR chsh (1),
.BR shells (5)
.SH AUTHOR
Florian Walther <florian.walther@certainity.com>