Compare commits

...
3 Commits
Author SHA1 Message Date
Florian WaltherandClaude Opus 5.5 041406a340 show the exit status of a failed command in the prompt
- user@host:dir [N] $ after a command failed with exit status N,
  the plain prompt again after a successful command
- the status is colored bold red by default, configurable with the new
  GOSH_COLORS key error

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:46:11 +02:00
Florian WaltherandClaude Opus 5.5 b3311cbf94 add brace expansion
- a{b,c}d, nested braces, empty alternatives (file{,.bak})
- sequences {1..5}, {5..1}, {1..10..3}, zero-padded {01..10}, letters
  {a..e}
- happens first on the command text, the results go through ~,
  variable and wildcard expansion
- quoted/escaped braces, ${...} and braces without comma or valid
  sequence are kept; \, protects a comma
- a redirection target must expand to one word (ambiguous redirect),
  a word may expand to at most 100000 words
- completion escapes { in file names

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:42:25 +02:00
Florian WaltherandClaude Opus 5.5 da281ca4ab fix job control on FreeBSD and macOS: do not use WCONTINUED
Go's syscall.WaitStatus decodes the "continued" status wrongly on BSD:
on FreeBSD it is read as killed by SIGCONT, so after bg the job was
reported as "Exit 147" and removed from the job table while it was
still running (fg: "no such job"); on macOS it is read as stopped.

The shell marks jobs as running itself when it continues them, so wait4
is called without WCONTINUED and only exited/signaled statuses finish a
process.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:35:25 +02:00
7 changed files with 549 additions and 34 deletions

No files matched your search

+58 -4
View File
@@ -30,13 +30,17 @@ 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
- Brace expansion: a{b,c}d -> abd acd, {1..5}, {01..10..2}, {a..e}
- Wildcards: * ? [abc] [a-z] [!abc] match file names (not hidden files - Wildcards: * ? [abc] [a-z] [!abc] match file names (not hidden files
unless the pattern starts with a dot); quoted wildcards and values of unless the pattern starts with a dot); quoted wildcards and values of
variables are not expanded, a pattern without match is kept as is variables are not expanded, a pattern without match is kept as is
- Colored prompt (user@host green, red for root; directory blue), - Prompt user@host:dir $, with [N] before the $ if the last command
configurable with $GOSH_COLORS, e.g. GOSH_COLORS='user=1;33:path=36' failed with exit status N
(keys user, root, path; values are ANSI SGR codes, empty = no color); - Colored prompt (user@host green, red for root; directory blue; exit
disabled with $NO_COLOR, TERM=dumb or when output is not a terminal status red), configurable with $GOSH_COLORS, e.g.
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
- Environment variable expansion ($VAR, ${VAR}, ${VAR:-default}); - Environment variable expansion ($VAR, ${VAR}, ${VAR:-default});
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),
@@ -142,6 +146,35 @@ 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.
### Klammer-Expansion
Wie in bash erzeugt `{…}` aus einem Wort mehrere Wörter:
| Ausdruck | ergibt |
|-------------------|---------------------------------|
| `a{b,c}d` | `abd acd` |
| `file{,.bak}` | `file file.bak` |
| `{a,b}{1,2}` | `a1 a2 b1 b2` |
| `x{a,{b,c}}` | `xa xb xc` (verschachtelt) |
| `{1..5}` | `1 2 3 4 5` |
| `{5..1}` | `5 4 3 2 1` |
| `{1..10..3}` | `1 4 7 10` (Schrittweite) |
| `{01..10..4}` | `01 05 09` (mit führenden Nullen) |
| `{a..e}` | `a b c d e` |
```
$ mkdir -p projekt/{src,doc,test}
$ cp config.yaml{,.bak} # Sicherungskopie
$ ls *.{go,md}
$ touch log-{01..12}.txt
```
- Die Klammer-Expansion kommt zuerst, die Ergebnisse durchlaufen danach `~`, Variablen und Wildcards: `*.{go,md}` wird zu `*.go *.md`, `{${A},b}` zu `$A b`.
- Ohne Komma (und ohne gültige Folge) bleiben Klammern stehen: `{a}`, `{}`, `{a..1}`. Auch `${VAR}` wird nicht angefasst.
- In Anführungszeichen oder mit `\` davor sind die Zeichen normale Zeichen: `"{a,b}"`, `\{a,b\}`. Ein Komma lässt sich mit `\,` schützen: `{a\,b,c}` ergibt `a,b c`.
- Bei einer Umleitung muss genau ein Wort herauskommen, `> {a,b}` meldet `ambiguous redirect`.
- Ein Wort darf höchstens 100 000 Wörter erzeugen.
### Wildcards ### Wildcards
Wörter mit `*`, `?` oder `[...]` werden durch die passenden Dateinamen ersetzt, sortiert: Wörter mit `*`, `?` oder `[...]` werden durch die passenden Dateinamen ersetzt, sortiert:
@@ -256,8 +289,25 @@ $ bg %2 # oder: angehaltenen Job im Hintergrund weiterlauf
- `exit` (oder Ctrl+D) mit angehaltenen Jobs warnt einmal (`there are stopped jobs`), ein zweites `exit` direkt danach beendet gosh. - `exit` (oder Ctrl+D) mit angehaltenen Jobs warnt einmal (`there are stopped jobs`), ein zweites `exit` direkt danach beendet gosh.
- `cd`, `set`, `export`, `exit`, `source`, `fg`, `bg` und `wait` lassen sich nicht mit `&` im Hintergrund starten. - `cd`, `set`, `export`, `exit`, `source`, `fg`, `bg` und `wait` lassen sich nicht mit `&` im Hintergrund starten.
- In Skripten und mit `gosh -c` gibt es keine Job-Control (kein Ctrl+Z, kein `fg`/`bg`), `&` und `wait` funktionieren aber. Hintergrund-Jobs lesen dort von `/dev/null`. - In Skripten und mit `gosh -c` gibt es keine Job-Control (kein Ctrl+Z, kein `fg`/`bg`), `&` und `wait` funktionieren aber. Hintergrund-Jobs lesen dort von `/dev/null`.
- Wird ein angehaltener Job von außen fortgesetzt (`kill -CONT`), zeigt `jobs` ihn weiter als `Stopped`, bis er endet oder erneut angehalten wird; `fg` und `bg` funktionieren trotzdem.
- Einen Job beenden lässt sich mit `kill` über die Prozess-ID (z. B. `kill 12345`); `kill %1` versteht gosh noch nicht. - Einen Job beenden lässt sich mit `kill` über die Prozess-ID (z. B. `kill 12345`); `kill %1` versteht gosh noch nicht.
### Prompt
Der Prompt zeigt Benutzer, Rechner und Verzeichnis. Ist der letzte Befehl fehlgeschlagen, steht sein Exit-Status in eckigen Klammern davor, bis wieder ein Befehl erfolgreich war:
```
flow@host:~/src $ false
flow@host:~/src [1] $ gibtsnicht
gosh: gibtsnicht: command not found
flow@host:~/src [127] $ sleep 100 # mit Ctrl+C abgebrochen
^C
flow@host:~/src [130] $ true
flow@host:~/src $
```
Typische Werte: `1` allgemeiner Fehler, `2` Syntaxfehler, `126` nicht ausführbar, `127` Befehl nicht gefunden, `128+n` durch Signal `n` beendet (`130` = Ctrl+C, `148` = mit Ctrl+Z angehalten unter Linux). Eine leere Eingabe ändert den Status nicht.
### 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`:
@@ -267,6 +317,7 @@ Der Prompt wird über die Umgebungsvariable `GOSH_COLORS` eingefärbt. Sie beste
| `user` | `user@host` | `1;32` (fett grün) | | `user` | `user@host` | `1;32` (fett grün) |
| `root` | `user@host`, wenn man root ist | `1;31` (fett rot) | | `root` | `user@host`, wenn man root ist | `1;31` (fett rot) |
| `path` | aktuelles Verzeichnis | `1;34` (fett blau) | | `path` | aktuelles Verzeichnis | `1;34` (fett blau) |
| `error` | Exit-Status `[N]` nach einem Fehler | `1;31` (fett rot) |
Die Werte sind [ANSI-SGR-Codes](https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters), mehrere werden mit `;` kombiniert: Die Werte sind [ANSI-SGR-Codes](https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters), mehrere werden mit `;` kombiniert:
@@ -304,6 +355,9 @@ GOSH_COLORS='user=1;38;2;136;192;208:path=38;2;216;222;233'
# Ozean: türkis und kursiv blau # Ozean: türkis und kursiv blau
GOSH_COLORS='user=1;96:path=3;94' GOSH_COLORS='user=1;96:path=3;94'
# Fehler-Status gelb auf rotem Hintergrund
GOSH_COLORS='error=1;33;41'
# Root-Warnung: weiß auf rotem Hintergrund # Root-Warnung: weiß auf rotem Hintergrund
GOSH_COLORS='root=1;97;41' GOSH_COLORS='root=1;97;41'
+268
View File
@@ -0,0 +1,268 @@
package main
// Brace expansion like in bash: a{b,c}d becomes abd acd, {1..5} becomes
// 1 2 3 4 5. It happens first, on the text of the command line, so that the
// resulting words go through the other expansions (~, variables, wildcards).
import (
"errors"
"fmt"
"strconv"
"strings"
)
// maxBraceWords limits the number of words a single word may expand to.
const maxBraceWords = 100000
// expandBraceWords applies brace expansion to every word of a command (the
// text of a pipeline). Quoted or escaped braces and ${...} are left alone.
// A redirection target must expand to a single word.
func expandBraceWords(input string) (string, error) {
var (
out strings.Builder
afterRedirect bool // the next word is the target of a redirection
)
for i := 0; i < len(input); {
ch := input[i]
switch {
case ch == ' ' || ch == '\t' || ch == '\n':
out.WriteByte(ch)
i++
case strings.IndexByte(";&|<>", ch) >= 0:
if ch == '<' || ch == '>' {
afterRedirect = true
}
out.WriteByte(ch)
i++
case ch == '#':
// A comment, until the end of the line
out.WriteString(input[i:])
return out.String(), nil
default:
end := wordEnd(input, i)
words, err := expandBraces(input[i:end])
if err != nil {
return "", err
}
if afterRedirect && len(words) > 1 {
return "", fmt.Errorf("%s: ambiguous redirect", sanitize(input[i:end]))
}
afterRedirect = false
out.WriteString(strings.Join(words, " "))
i = end
}
}
return out.String(), nil
}
// wordEnd returns the end of the word starting at i: the next unquoted blank
// or operator character.
func wordEnd(s string, i int) int {
var quote byte
for ; i < len(s); i++ {
ch := s[i]
switch {
case quote == '\'':
if ch == '\'' {
quote = 0
}
case ch == '\\':
i++
case ch == '$' && i+1 < len(s) && s[i+1] == '{':
if j := strings.IndexByte(s[i:], '}'); j >= 0 {
i += j
}
case quote == '"':
if ch == '"' {
quote = 0
}
case ch == '"' || ch == '\'':
quote = ch
case strings.IndexByte(" \t\n;&|<>", ch) >= 0:
return i
}
}
return len(s)
}
// braceScan calls fn for every unquoted, unescaped character of s that is
// not part of a ${...} reference, with its index and the brace depth.
// Scanning stops when fn returns false.
func braceScan(s string, fn func(i, depth int) bool) {
var quote byte
depth := 0
for i := 0; i < len(s); i++ {
ch := s[i]
switch {
case quote == '\'':
if ch == '\'' {
quote = 0
}
continue
case ch == '\\':
i++
continue
case ch == '$' && i+1 < len(s) && s[i+1] == '{':
if j := strings.IndexByte(s[i:], '}'); j >= 0 {
i += j
}
continue
case quote == '"':
if ch == '"' {
quote = 0
}
continue
case ch == '"' || ch == '\'':
quote = ch
continue
}
if ch == '}' {
depth--
}
if !fn(i, depth) {
return
}
if ch == '{' {
depth++
}
}
}
// expandBraces expands the first valid brace expression of a word and then
// recursively the results: a{b,c}d -> abd acd, x{1..3} -> x1 x2 x3. A brace
// expression is valid if it contains an unquoted comma at its top level or
// is a sequence; other braces ({}, {a}, ${x}) are kept as they are.
func expandBraces(word string) ([]string, error) {
// Find the candidates: unquoted { with their matching }
var opens []int
pairs := map[int]int{}
braceScan(word, func(i, depth int) bool {
switch word[i] {
case '{':
opens = append(opens, i)
case '}':
if len(opens) > 0 {
pairs[opens[len(opens)-1]] = i
opens = opens[:len(opens)-1]
}
}
return true
})
for start := 0; start < len(word); start++ {
end, ok := pairs[start]
if !ok {
continue
}
inner := word[start+1 : end]
alternatives := splitTopLevelCommas(inner)
if len(alternatives) < 2 {
seq, ok, err := braceSequence(inner)
if err != nil {
return nil, err
}
if !ok {
continue
}
alternatives = seq
}
prefix, suffix := word[:start], word[end+1:]
var words []string
for _, alt := range alternatives {
expanded, err := expandBraces(prefix + alt + suffix)
if err != nil {
return nil, err
}
words = append(words, expanded...)
if len(words) > maxBraceWords {
return nil, errors.New("brace expansion: too many words")
}
}
return words, nil
}
return []string{word}, nil
}
// splitTopLevelCommas splits s at its unquoted commas that are not inside
// nested braces.
func splitTopLevelCommas(s string) []string {
var parts []string
last := 0
braceScan(s, func(i, depth int) bool {
if s[i] == ',' && depth == 0 {
parts = append(parts, s[last:i])
last = i + 1
}
return true
})
return append(parts, s[last:])
}
// braceSequence expands a sequence expression: x..y or x..y..step with
// integers (zero-padded to the same width if one of them has a leading zero)
// or single letters. ok is false if s is no sequence.
func braceSequence(s string) (words []string, ok bool, err error) {
parts := strings.Split(s, "..")
if len(parts) != 2 && len(parts) != 3 {
return nil, false, nil
}
step := 1
if len(parts) == 3 {
step, err = strconv.Atoi(parts[2])
if err != nil {
return nil, false, nil
}
if step < 0 {
step = -step
}
if step == 0 {
step = 1
}
}
from, errFrom := strconv.Atoi(parts[0])
to, errTo := strconv.Atoi(parts[1])
isLetter := func(p string) bool {
return len(p) == 1 && (p[0] >= 'a' && p[0] <= 'z' || p[0] >= 'A' && p[0] <= 'Z')
}
format := func(n int) string { return strconv.Itoa(n) }
switch {
case errFrom == nil && errTo == nil:
// {01..10}: pad to the width of the longer number
if hasLeadingZero(parts[0]) || hasLeadingZero(parts[1]) {
width := max(len(parts[0]), len(parts[1]))
format = func(n int) string {
if n < 0 {
return fmt.Sprintf("-%0*d", width-1, -n)
}
return fmt.Sprintf("%0*d", width, n)
}
}
case isLetter(parts[0]) && isLetter(parts[1]):
from, to = int(parts[0][0]), int(parts[1][0])
format = func(n int) string { return string(rune(n)) }
default:
return nil, false, nil
}
count := (max(from, to)-min(from, to))/step + 1
if count > maxBraceWords {
return nil, false, errors.New("brace expansion: too many words")
}
if to < from {
step = -step
}
for n := from; (step > 0 && n <= to) || (step < 0 && n >= to); n += step {
words = append(words, format(n))
}
return words, true, nil
}
// hasLeadingZero reports whether a number is written with a leading zero
// (01, -05), which makes a sequence zero-padded.
func hasLeadingZero(s string) bool {
s = strings.TrimPrefix(s, "-")
return len(s) > 1 && s[0] == '0'
}
+118
View File
@@ -0,0 +1,118 @@
package main
import (
"os"
"reflect"
"strings"
"testing"
)
func TestExpandBraces(t *testing.T) {
tests := []struct {
word string
want []string
}{
{"a{b,c}d", []string{"abd", "acd"}},
{"{a,b}{1,2}", []string{"a1", "a2", "b1", "b2"}},
{"x{a,{b,c}}y", []string{"xay", "xby", "xcy"}},
{"file{,.bak}", []string{"file", "file.bak"}},
{"{1..5}", []string{"1", "2", "3", "4", "5"}},
{"{3..1}", []string{"3", "2", "1"}},
{"{1..10..3}", []string{"1", "4", "7", "10"}},
{"{10..1..-4}", []string{"10", "6", "2"}},
{"{01..10..4}", []string{"01", "05", "09"}},
{"{8..010..1}", []string{"008", "009", "010"}},
{"{-2..2}", []string{"-2", "-1", "0", "1", "2"}},
{"{-01..1}", []string{"-01", "000", "001"}},
{"{a..e}", []string{"a", "b", "c", "d", "e"}},
{"{C..A}", []string{"C", "B", "A"}},
{"*.{go,md}", []string{"*.go", "*.md"}},
{`{"a b",c}`, []string{`"a b"`, "c"}},
{`{a\,b,c}`, []string{`a\,b`, "c"}},
{"{$HOME,~}/x", []string{"$HOME/x", "~/x"}},
// Not expanded: quoted, escaped, no comma, ${...}, invalid sequences
{`"{a,b}"`, []string{`"{a,b}"`}},
{`'{a,b}'`, []string{`'{a,b}'`}},
{`\{a,b\}`, []string{`\{a,b\}`}},
{"{a}", []string{"{a}"}},
{"{}", []string{"{}"}},
{"{a,b", []string{"{a,b"}},
{"a,b}", []string{"a,b}"}},
{"${HOME}", []string{"${HOME}"}},
{"${X:-a,b}", []string{"${X:-a,b}"}},
{"{a..1}", []string{"{a..1}"}},
{"{1..2..x}", []string{"{1..2..x}"}},
{"{ab..cd}", []string{"{ab..cd}"}},
// An invalid brace before a valid one
{"{a}{b,c}", []string{"{a}b", "{a}c"}},
}
for _, tc := range tests {
got, err := expandBraces(tc.word)
if err != nil {
t.Errorf("expandBraces(%q) returned error: %v", tc.word, err)
continue
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("expandBraces(%q) = %q, want %q", tc.word, got, tc.want)
}
}
if _, err := expandBraces("{1..1000000}"); err == nil {
t.Error("expected an error for a too large sequence")
}
if _, err := expandBraces("{1..11}{1..100}{1..100}"); err == nil {
t.Error("expected an error for too many words")
}
}
func TestExpandBraceWords(t *testing.T) {
tests := []struct {
input, want string
}{
{"echo a{b,c} d", "echo ab ac d"},
{"echo {1..3}|wc -w", "echo 1 2 3|wc -w"},
{"echo a > {f}", "echo a > {f}"},
{"echo {a,b} 2>&1", "echo a b 2>&1"},
{"echo x # {a,b}", "echo x # {a,b}"},
{`echo "{a,b} c" {d,e}`, `echo "{a,b} c" d e`},
}
for _, tc := range tests {
got, err := expandBraceWords(tc.input)
if err != nil || got != tc.want {
t.Errorf("expandBraceWords(%q) = %q, %v; want %q", tc.input, got, err, tc.want)
}
}
for _, input := range []string{"echo a > {f,g}", "cat < x{1..2}", "echo a 2>> {a,b}"} {
if _, err := expandBraceWords(input); err == nil || !strings.Contains(err.Error(), "ambiguous redirect") {
t.Errorf("expandBraceWords(%q): %v, want ambiguous redirect", input, err)
}
}
}
func TestExecInput_BraceExpansion(t *testing.T) {
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
dir := t.TempDir()
os.Chdir(dir)
if err := execInput("mkdir -p d/{x,y}/{1..2} && touch d/x/1/f{a,b}.txt"); err != nil {
t.Fatal(err)
}
for _, path := range []string{"d/x/1", "d/x/2", "d/y/1", "d/y/2", "d/x/1/fa.txt", "d/x/1/fb.txt"} {
if _, err := os.Stat(path); err != nil {
t.Errorf("%s was not created", path)
}
}
// Brace expansion comes before wildcards and variables
t.Setenv("GOSH_BRACE", "v")
execInput("echo d/x/1/*.{txt,go} {${GOSH_BRACE},w}1 > out")
if got, _ := os.ReadFile("out"); string(got) != "d/x/1/fa.txt d/x/1/fb.txt d/x/1/*.go v1 w1\n" {
t.Errorf("out = %q", got)
}
if err := execInput("echo a > {f,g}"); err == nil || lastStatus != 1 {
t.Errorf("ambiguous redirect: %v, $? = %d", err, lastStatus)
}
}
+19 -8
View File
@@ -162,9 +162,7 @@ func (j *job) update(pid int, ws syscall.WaitStatus) {
switch { switch {
case ws.Stopped(): case ws.Stopped():
p.stopped = true p.stopped = true
case ws.Continued(): case ws.Exited() || ws.Signaled():
p.stopped = false
default:
p.finished, p.stopped = true, false p.finished, p.stopped = true, false
p.err = waitStatusError(ws) p.err = waitStatusError(ws)
p.cmd.Process.Release() p.cmd.Process.Release()
@@ -185,17 +183,30 @@ func waitStatusError(ws syscall.WaitStatus) error {
return nil return nil
} }
// wait waits until no process of the job is running any more (all finished // waitFlags returns the options for wait4: WUNTRACED to see stopped
// or, with job control, stopped). With nohang it only collects the status // processes (with job control) and WNOHANG to not block.
// changes that are already available and does not block. //
func (j *job) wait(nohang bool) { // WCONTINUED is not used: Go's syscall.WaitStatus decodes the "continued"
// status wrongly on FreeBSD (as killed by SIGCONT) and macOS (as stopped).
// The shell marks jobs as running itself when it continues them (fg, bg); a
// job continued from outside (kill -CONT) stays "Stopped" in the job table
// until it stops again or exits.
func waitFlags(j *job, nohang bool) int {
flags := 0 flags := 0
if jobControl && j.pgid != 0 { if jobControl && j.pgid != 0 {
flags |= syscall.WUNTRACED | syscall.WCONTINUED flags |= syscall.WUNTRACED
} }
if nohang { if nohang {
flags |= syscall.WNOHANG flags |= syscall.WNOHANG
} }
return flags
}
// wait waits until no process of the job is running any more (all finished
// or, with job control, stopped). With nohang it only collects the status
// changes that are already available and does not block.
func (j *job) wait(nohang bool) {
flags := waitFlags(j, nohang)
for { for {
// With job control all processes of the job are in its process // With job control all processes of the job are in its process
+22
View File
@@ -105,6 +105,21 @@ func (s *ptyShell) expect(want string) {
s.t.Fatalf("timeout waiting for %q, output:\n%s", want, s.out.String()) s.t.Fatalf("timeout waiting for %q, output:\n%s", want, s.out.String())
} }
func TestWaitFlags_NoWCONTINUED(t *testing.T) {
defer func(saved bool) { jobControl = saved }(jobControl)
jobControl = true
j := &job{pgid: 1}
for _, nohang := range []bool{false, true} {
flags := waitFlags(j, nohang)
if flags&syscall.WCONTINUED != 0 {
t.Errorf("waitFlags(nohang=%v) contains WCONTINUED, which Go decodes wrongly on BSD", nohang)
}
if flags&syscall.WUNTRACED == 0 {
t.Errorf("waitFlags(nohang=%v) misses WUNTRACED", nohang)
}
}
}
func TestJobControl(t *testing.T) { func TestJobControl(t *testing.T) {
s := startPtyShell(t) s := startPtyShell(t)
@@ -120,6 +135,13 @@ func TestJobControl(t *testing.T) {
s.send("bg\n") s.send("bg\n")
s.expect("[1]+ Running sleep 30 &") s.expect("[1]+ Running sleep 30 &")
// After bg the job keeps running and is not reported as finished
// (regression: on FreeBSD the "continued" status was read as killed by
// SIGCONT, "Exit 147")
time.Sleep(300 * time.Millisecond)
s.send("jobs\n")
s.expect("[1]+ Running sleep 30 &")
// fg brings it back, Ctrl+C reaches only the job // fg brings it back, Ctrl+C reaches only the job
s.send("fg %1\n") s.send("fg %1\n")
s.expect("sleep 30") s.expect("sleep 30")
+35 -13
View File
@@ -612,7 +612,7 @@ func getPrompt() string {
c := parseColors(os.Getenv("GOSH_COLORS")) c := parseColors(os.Getenv("GOSH_COLORS"))
colors = &c colors = &c
} }
return buildPrompt(username, hostname, wd, os.Geteuid() == 0, colors) return buildPrompt(username, hostname, wd, os.Geteuid() == 0, lastStatus, colors)
} }
// promptColors holds the SGR parameters (e.g. "1;32") for the parts of the // promptColors holds the SGR parameters (e.g. "1;32") for the parts of the
@@ -621,12 +621,14 @@ type promptColors struct {
user string // user@host user string // user@host
root string // user@host when running as root root string // user@host when running as root
path string // working directory path string // working directory
error string // exit status of the last command, if it failed
} }
// defaultColors: bold green user, bold red root, bold blue directory // defaultColors: bold green user, bold red root, bold blue directory, bold
var defaultColors = promptColors{user: "1;32", root: "1;31", path: "1;34"} // red exit status
var defaultColors = promptColors{user: "1;32", root: "1;31", path: "1;34", error: "1;31"}
// parseColors reads a GOSH_COLORS value like "user=1;33:root=1;31:path=36" // parseColors reads a GOSH_COLORS value like "user=1;33:path=36:error=31"
// and overrides the corresponding defaults. Unknown keys and values that are // and overrides the corresponding defaults. Unknown keys and values that are
// not plain SGR parameters (digits and ';') are ignored, so the variable // not plain SGR parameters (digits and ';') are ignored, so the variable
// cannot be used to write arbitrary escape sequences to the terminal. // cannot be used to write arbitrary escape sequences to the terminal.
@@ -644,6 +646,8 @@ func parseColors(spec string) promptColors {
colors.root = value colors.root = value
case "path": case "path":
colors.path = value colors.path = value
case "error":
colors.error = value
} }
} }
return colors return colors
@@ -669,20 +673,28 @@ func colorize(text, sgr string) string {
return "\x1b[" + sgr + "m" + text + "\x1b[0m" return "\x1b[" + sgr + "m" + text + "\x1b[0m"
} }
// buildPrompt formats the prompt as user@host:path $. The parts are sanitized // buildPrompt formats the prompt as user@host:path $, or user@host:path [N] $
// if the last command failed with exit status N. The parts are sanitized
// before the color codes are added, so only our own escape sequences end up // before the color codes are added, so only our own escape sequences end up
// in the prompt. colors == nil disables colors. // in the prompt. colors == nil disables colors.
func buildPrompt(username, hostname, path string, root bool, colors *promptColors) string { func buildPrompt(username, hostname, path string, root bool, status int, colors *promptColors) string {
userHost := sanitize(username + "@" + hostname) userHost := sanitize(username + "@" + hostname)
path = sanitize(path) path = sanitize(path)
var failed string
if status != 0 {
failed = " [" + strconv.Itoa(status) + "]"
}
if colors == nil { if colors == nil {
return userHost + ":" + path + " $ " return userHost + ":" + path + failed + " $ "
} }
userColor := colors.user userColor := colors.user
if root { if root {
userColor = colors.root userColor = colors.root
} }
return colorize(userHost, userColor) + ":" + colorize(path, colors.path) + " $ " if failed != "" {
failed = " " + colorize(failed[1:], colors.error)
}
return colorize(userHost, userColor) + ":" + colorize(path, colors.path) + failed + " $ "
} }
// useColor reports whether the prompt should be colored: stdout must be a // useColor reports whether the prompt should be colored: stdout must be a
@@ -815,7 +827,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)
@@ -1354,6 +1366,12 @@ func execInput(input string) error {
// execCommand executes a pipeline of simple commands with redirections, in // execCommand executes a pipeline of simple commands with redirections, in
// the background if background is set. // the background if background is set.
func execCommand(input string, background bool) error { func execCommand(input string, background bool) error {
// Brace expansion comes first, so that its results are expanded further
input, err := expandBraceWords(input)
if err != nil {
return err
}
// Split the input into words and operators, expanding variables and ~ on // Split the input into words and operators, expanding variables and ~ on
// the way. // the way.
tokens, err := tokenize(input) tokens, err := tokenize(input)
@@ -1783,13 +1801,17 @@ 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
- Brace expansion: a{b,c}d -> abd acd, {1..5}, {01..10..2}, {a..e}
- Wildcards: * ? [abc] [a-z] [!abc] match file names (not hidden files - Wildcards: * ? [abc] [a-z] [!abc] match file names (not hidden files
unless the pattern starts with a dot); quoted wildcards and values of unless the pattern starts with a dot); quoted wildcards and values of
variables are not expanded, a pattern without match is kept as is variables are not expanded, a pattern without match is kept as is
- Colored prompt (user@host green, red for root; directory blue), - Prompt user@host:dir $, with [N] before the $ if the last command
configurable with $GOSH_COLORS, e.g. GOSH_COLORS='user=1;33:path=36' failed with exit status N
(keys user, root, path; values are ANSI SGR codes, empty = no color); - Colored prompt (user@host green, red for root; directory blue; exit
disabled with $NO_COLOR, TERM=dumb or when output is not a terminal status red), configurable with $GOSH_COLORS, e.g.
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
- Environment variable expansion ($VAR, ${VAR}, ${VAR:-default}); - Environment variable expansion ($VAR, ${VAR}, ${VAR:-default});
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),
+29 -9
View File
@@ -1054,24 +1054,24 @@ func TestGetPrompt_EscapesControlCharacters(t *testing.T) {
} }
func TestBuildPrompt_Color(t *testing.T) { func TestBuildPrompt_Color(t *testing.T) {
got := buildPrompt("flow", "host", "~/src", false, &defaultColors) got := buildPrompt("flow", "host", "~/src", false, 0, &defaultColors)
want := "\x1b[1;32mflow@host\x1b[0m:\x1b[1;34m~/src\x1b[0m $ " want := "\x1b[1;32mflow@host\x1b[0m:\x1b[1;34m~/src\x1b[0m $ "
if got != want { if got != want {
t.Errorf("buildPrompt() = %q, want %q", got, want) t.Errorf("buildPrompt() = %q, want %q", got, want)
} }
got = buildPrompt("root", "host", "/", true, &defaultColors) got = buildPrompt("root", "host", "/", true, 0, &defaultColors)
if !strings.HasPrefix(got, "\x1b[1;31mroot@host") { if !strings.HasPrefix(got, "\x1b[1;31mroot@host") {
t.Errorf("expected red user for root, got %q", got) t.Errorf("expected red user for root, got %q", got)
} }
got = buildPrompt("flow", "host", "~/src", false, nil) got = buildPrompt("flow", "host", "~/src", false, 0, nil)
if got != "flow@host:~/src $ " { if got != "flow@host:~/src $ " {
t.Errorf("buildPrompt() without color = %q", got) t.Errorf("buildPrompt() without color = %q", got)
} }
colors := promptColors{user: "38;5;208", path: ""} colors := promptColors{user: "38;5;208", path: ""}
got = buildPrompt("flow", "host", "~/src", false, &colors) got = buildPrompt("flow", "host", "~/src", false, 0, &colors)
if got != "\x1b[38;5;208mflow@host\x1b[0m:~/src $ " { if got != "\x1b[38;5;208mflow@host\x1b[0m:~/src $ " {
t.Errorf("buildPrompt() with custom colors = %q", got) t.Errorf("buildPrompt() with custom colors = %q", got)
} }
@@ -1083,13 +1083,14 @@ func TestParseColors(t *testing.T) {
want promptColors want promptColors
}{ }{
{"", defaultColors}, {"", defaultColors},
{"user=1;33", promptColors{user: "1;33", root: "1;31", path: "1;34"}}, {"user=1;33", promptColors{user: "1;33", root: "1;31", path: "1;34", error: "1;31"}},
{"user=33:root=41;97:path=38;5;208", promptColors{user: "33", root: "41;97", path: "38;5;208"}}, {"user=33:root=41;97:path=38;5;208", promptColors{user: "33", root: "41;97", path: "38;5;208", error: "1;31"}},
{"path=", promptColors{user: "1;32", root: "1;31", path: ""}}, {"error=33", promptColors{user: "1;32", root: "1;31", path: "1;34", error: "33"}},
{"path=", promptColors{user: "1;32", root: "1;31", path: "", error: "1;31"}},
{"path=0m\x1b[2J", defaultColors}, // escape sequences are rejected {"path=0m\x1b[2J", defaultColors}, // escape sequences are rejected
{"path=red", defaultColors}, // only numeric codes {"path=red", defaultColors}, // only numeric codes
{"foo=1:user", defaultColors}, // unknown key, missing value {"foo=1:user", defaultColors}, // unknown key, missing value
{"user=35::path=36", promptColors{user: "35", root: "1;31", path: "36"}}, {"user=35::path=36", promptColors{user: "35", root: "1;31", path: "36", error: "1;31"}},
} }
for _, tc := range tests { for _, tc := range tests {
if got := parseColors(tc.spec); got != tc.want { if got := parseColors(tc.spec); got != tc.want {
@@ -1098,8 +1099,27 @@ func TestParseColors(t *testing.T) {
} }
} }
func TestBuildPrompt_Status(t *testing.T) {
if got := buildPrompt("flow", "host", "~", false, 0, nil); got != "flow@host:~ $ " {
t.Errorf("status 0: %q", got)
}
if got := buildPrompt("flow", "host", "~", false, 127, nil); got != "flow@host:~ [127] $ " {
t.Errorf("status 127 without color: %q", got)
}
got := buildPrompt("flow", "host", "~", false, 1, &defaultColors)
want := "\x1b[1;32mflow@host\x1b[0m:\x1b[1;34m~\x1b[0m \x1b[1;31m[1]\x1b[0m $ "
if got != want {
t.Errorf("status 1 with color = %q, want %q", got, want)
}
colors := defaultColors
colors.error = ""
if got := buildPrompt("flow", "host", "~", false, 2, &colors); !strings.HasSuffix(got, " [2] $ ") {
t.Errorf("status without error color = %q", got)
}
}
func TestBuildPrompt_ColorEscapesControlCharacters(t *testing.T) { func TestBuildPrompt_ColorEscapesControlCharacters(t *testing.T) {
got := buildPrompt("flow", "host", "evil\x1b[2J\x07", false, &defaultColors) got := buildPrompt("flow", "host", "evil\x1b[2J\x07", false, 0, &defaultColors)
if strings.Contains(got, "\x1b[2J") || strings.Contains(got, "\x07") { if strings.Contains(got, "\x1b[2J") || strings.Contains(got, "\x07") {
t.Errorf("prompt contains control characters from path: %q", got) t.Errorf("prompt contains control characters from path: %q", got)
} }