From 22ce8c00d49f8e474e6e373abfdf76a7dd6d14cb Mon Sep 17 00:00:00 2001 From: Florian Walther Date: Sun, 4 Oct 2026 00:17:38 +0200 Subject: [PATCH] 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 --- README.md | 33 +++++++++++++-- assign_test.go | 109 +++++++++++++++++++++++++++++++++++++++++++++++++ brace.go | 19 +++++++++ main.go | 107 ++++++++++++++++++++++++++++++++++++++++++++---- main_test.go | 2 +- 5 files changed, 256 insertions(+), 14 deletions(-) create mode 100644 assign_test.go diff --git a/README.md b/README.md index 90eb692..445ae0c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/assign_test.go b/assign_test.go new file mode 100644 index 0000000..86ac6a8 --- /dev/null +++ b/assign_test.go @@ -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) + } +} diff --git a/brace.go b/brace.go index a1414a6..9fa806b 100644 --- a/brace.go +++ b/brace.go @@ -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 { diff --git a/main.go b/main.go index 82a8700..1c0bfb8 100644 --- a/main.go +++ b/main.go @@ -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 diff --git a/main_test.go b/main_test.go index bb87aa7..6189884 100644 --- a/main_test.go +++ b/main_test.go @@ -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)