Compare commits

...
1 Commits
Author SHA1 Message Date
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
3 changed files with 304 additions and 15 deletions

No files matched your search

+29
View File
@@ -23,6 +23,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);
@@ -83,6 +86,32 @@ Status 127
Schlägt ein Programm fehl, gibt gosh selbst keine Meldung mehr aus (das Programm meldet den Fehler selbst), nur bei Fehlern von gosh, z. B. wenn ein Befehl nicht gefunden wird. Schlägt ein Programm fehl, gibt gosh selbst keine Meldung mehr aus (das Programm meldet den Fehler selbst), nur bei Fehlern von gosh, z. B. wenn ein Befehl nicht gefunden wird.
### Wildcards
Wörter mit `*`, `?` oder `[...]` werden durch die passenden Dateinamen ersetzt, sortiert:
| Muster | passt auf |
|-----------|-----------------------------------------------------|
| `*` | beliebig viele Zeichen (auch keine) |
| `?` | genau ein Zeichen |
| `[abc]` | eines der Zeichen `a`, `b`, `c` |
| `[a-z]` | ein Zeichen aus dem Bereich |
| `[!abc]` | ein Zeichen außer `a`, `b`, `c` (auch `[^abc]`) |
```
$ ls *.go
main.go main_test.go
$ cat logs/2026-*/error.log
$ ls -d */ # nur Verzeichnisse
```
- Muster gelten pro Pfadteil, `*` passt nie auf `/`: `*/*.go` findet `.go`-Dateien eine Ebene tiefer.
- Versteckte Dateien (mit `.` am Anfang) werden nur gefunden, wenn das Muster selbst mit `.` beginnt: `*` findet `.bashrc` nicht, `.*rc` schon.
- Passt keine Datei, bleibt das Wort unverändert stehen (wie in `sh`), `ls *.xyz` meldet dann also `ls` den Fehler.
- In Anführungszeichen oder mit `\` davor sind die Zeichen normale Zeichen: `echo "*"`, `echo '*.go'`, `echo \*`.
- Werte von Variablen werden nicht als Muster behandelt (anders als in `sh`): Nach `set P=*.go` gibt `echo $P` wörtlich `*.go` aus. So kann ein Variablenwert nie unbemerkt auf viele Dateien passen. `echo $DIR/*.go` funktioniert dagegen, weil das `*` direkt in der Zeile steht.
- Bei einer Umleitung muss das Muster genau eine Datei treffen (`> *.log`), sonst meldet gosh `ambiguous redirect`.
### Pipes und Umleitungen ### Pipes und Umleitungen
``` ```
+165 -15
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"
@@ -113,10 +114,12 @@ func expandTilde(path string) string {
// token is a word or an operator of a command line. // token is a word or an operator of a command line.
type token struct { type token struct {
op string // "" for a word, otherwise |, <, >, >>, >&, <&, &> or &>> op string // "" for a word, otherwise |, <, >, >>, >&, <&, &> or &>>
word string // the word, if op == "" word string // the word, if op == ""
fd int // the redirected file descriptor (for redirections) pattern string // the word as glob pattern (quoted characters escaped)
dup int // the target file descriptor of >& and <& glob bool // the word contains unquoted * ? or [ and is expanded
fd int // the redirected file descriptor (for redirections)
dup int // the target file descriptor of >& and <&
} }
// tokenize splits input into words and operators like a (very) simple POSIX // tokenize splits input into words and operators like a (very) simple POSIX
@@ -136,7 +139,9 @@ func tokenize(input string) ([]token, error) {
var ( var (
tokens []token tokens []token
b strings.Builder b strings.Builder
inWord bool // a word has been started (even if it is still empty) pat strings.Builder // the word as glob pattern, quoted parts escaped
glob bool // the word contains unquoted * ? or [
inWord bool // a word has been started (even if it is still empty)
quote byte quote byte
escaped bool escaped bool
rawStart = -1 // start of the current word in input rawStart = -1 // start of the current word in input
@@ -144,12 +149,19 @@ func tokenize(input string) ([]token, error) {
endWord := func() { endWord := func() {
if inWord { if inWord {
tokens = append(tokens, token{word: b.String()}) tokens = append(tokens, token{word: b.String(), pattern: pat.String(), glob: glob})
} }
b.Reset() b.Reset()
pat.Reset()
glob = false
inWord = false inWord = false
rawStart = -1 rawStart = -1
} }
// literal adds quoted, escaped or expanded text: it is never a pattern
literal := func(s string) {
b.WriteString(s)
pat.WriteString(escapeGlob(s))
}
startRaw := func(i int) { startRaw := func(i int) {
if rawStart < 0 { if rawStart < 0 {
rawStart = i rawStart = i
@@ -161,16 +173,16 @@ func tokenize(input string) ([]token, error) {
switch { switch {
case escaped: case escaped:
if quote == '"' && !strings.ContainsRune("$\"\\`", rune(ch)) { if quote == '"' && !strings.ContainsRune("$\"\\`", rune(ch)) {
b.WriteByte('\\') literal("\\")
} }
b.WriteByte(ch) literal(string(ch))
inWord = true inWord = true
escaped = false escaped = false
case quote == '\'': case quote == '\'':
if ch == '\'' { if ch == '\'' {
quote = 0 quote = 0
} else { } else {
b.WriteByte(ch) literal(string(ch))
} }
case ch == '\\': case ch == '\\':
startRaw(i) startRaw(i)
@@ -181,7 +193,9 @@ func tokenize(input string) ([]token, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
b.WriteString(value) // Expanded values are never patterns (and never split), so that
// e.g. a * in a variable does not match files
literal(value)
// A literal "$" counts as content, an expansion that is empty does // A literal "$" counts as content, an expansion that is empty does
// not (unless quoted), like in sh // not (unless quoted), like in sh
if n == 1 || value != "" { if n == 1 || value != "" {
@@ -192,7 +206,7 @@ func tokenize(input string) ([]token, error) {
if ch == '"' { if ch == '"' {
quote = 0 quote = 0
} else { } else {
b.WriteByte(ch) literal(string(ch))
} }
case ch == '"' || ch == '\'': case ch == '"' || ch == '\'':
startRaw(i) startRaw(i)
@@ -225,6 +239,8 @@ func tokenize(input string) ([]token, error) {
if inWord && rawStart >= 0 && isDigits(input[rawStart:i]) { if inWord && rawStart >= 0 && isDigits(input[rawStart:i]) {
fd, _ = strconv.Atoi(input[rawStart:i]) fd, _ = strconv.Atoi(input[rawStart:i])
b.Reset() b.Reset()
pat.Reset()
glob = false
inWord = false inWord = false
rawStart = -1 rawStart = -1
} else { } else {
@@ -256,11 +272,21 @@ func tokenize(input string) ([]token, error) {
if home == "" { if home == "" {
home = "~" home = "~"
} }
b.WriteString(home) literal(home)
inWord = true inWord = true
default: default:
startRaw(i) startRaw(i)
b.WriteByte(ch) b.WriteByte(ch)
switch {
case ch == '*' || ch == '?' || ch == '[':
glob = true
pat.WriteByte(ch)
case ch == '!' && strings.HasSuffix(pat.String(), "[") && !strings.HasSuffix(pat.String(), "\\["):
// POSIX [!...] is [^...] in Go
pat.WriteByte('^')
default:
pat.WriteByte(ch)
}
inWord = true inWord = true
} }
} }
@@ -275,6 +301,123 @@ func tokenize(input string) ([]token, error) {
return tokens, nil return tokens, nil
} }
// escapeGlob escapes the characters that are special in a glob pattern.
func escapeGlob(s string) string {
var b strings.Builder
for i := 0; i < len(s); i++ {
if strings.IndexByte("*?[]\\", s[i]) >= 0 {
b.WriteByte('\\')
}
b.WriteByte(s[i])
}
return b.String()
}
// hasGlobMeta reports whether pattern contains an unescaped * ? or [.
func hasGlobMeta(pattern string) bool {
for i := 0; i < len(pattern); i++ {
switch pattern[i] {
case '\\':
i++
case '*', '?', '[':
return true
}
}
return false
}
// unescapeGlob removes the escapes from a pattern without wildcards.
func unescapeGlob(pattern string) string {
var b strings.Builder
for i := 0; i < len(pattern); i++ {
if pattern[i] == '\\' && i+1 < len(pattern) {
i++
}
b.WriteByte(pattern[i])
}
return b.String()
}
// expandGlob returns the paths matching pattern, sorted. Like sh, * and ?
// do not match a leading dot (hidden files), unless the pattern component
// starts with a dot, and a trailing / only matches directories. An invalid
// pattern matches nothing.
func expandGlob(pattern string) []string {
paths := []string{""}
if strings.HasPrefix(pattern, "/") {
paths = []string{"/"}
}
components := strings.Split(strings.Trim(pattern, "/"), "/")
join := func(dir, name string) string {
if dir == "" || strings.HasSuffix(dir, "/") {
return dir + name
}
return dir + "/" + name
}
for _, comp := range components {
if comp == "" {
continue // a//b
}
var next []string
for _, dir := range paths {
if !hasGlobMeta(comp) {
p := join(dir, unescapeGlob(comp))
if _, err := os.Lstat(p); err == nil {
next = append(next, p)
}
continue
}
readDir := dir
if readDir == "" {
readDir = "."
}
entries, err := os.ReadDir(readDir)
if err != nil {
continue
}
for _, e := range entries {
name := e.Name()
if strings.HasPrefix(name, ".") && !strings.HasPrefix(comp, ".") && !strings.HasPrefix(comp, "\\.") {
continue
}
ok, err := filepath.Match(comp, name)
if err != nil {
return nil
}
if ok {
next = append(next, join(dir, name))
}
}
}
paths = next
}
if strings.HasSuffix(pattern, "/") {
var dirs []string
for _, p := range paths {
if info, err := os.Stat(p); err == nil && info.IsDir() {
dirs = append(dirs, p+"/")
}
}
paths = dirs
}
sort.Strings(paths)
return paths
}
// expandWord returns the words a token expands to: the matching paths for
// a pattern, or the word itself if it is no pattern or nothing matches (like
// in sh).
func expandWord(t token) []string {
if t.glob {
if matches := expandGlob(t.pattern); len(matches) > 0 {
return matches
}
}
return []string{t.word}
}
// isDigits reports whether s is a non-empty string of ASCII digits. // isDigits reports whether s is a non-empty string of ASCII digits.
func isDigits(s string) bool { func isDigits(s string) bool {
if s == "" { if s == "" {
@@ -668,7 +811,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)
@@ -1131,7 +1274,7 @@ func parsePipeline(tokens []token) ([]stage, error) {
t := tokens[i] t := tokens[i]
switch t.op { switch t.op {
case "": case "":
cur.args = append(cur.args, t.word) cur.args = append(cur.args, expandWord(t)...)
case "|": case "|":
if isEmpty(cur) { if isEmpty(cur) {
return nil, syntaxError("|") return nil, syntaxError("|")
@@ -1148,7 +1291,11 @@ func parsePipeline(tokens []token) ([]stage, error) {
return nil, syntaxError(next.op) return nil, syntaxError(next.op)
} }
i++ i++
cur.redirects = append(cur.redirects, redirect{op: t.op, fd: t.fd, file: tokens[i].word}) files := expandWord(tokens[i])
if len(files) > 1 {
return nil, fmt.Errorf("%s: ambiguous redirect", sanitize(tokens[i].word))
}
cur.redirects = append(cur.redirects, redirect{op: t.op, fd: t.fd, file: files[0]})
} }
} }
@@ -1430,6 +1577,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);
+110
View File
@@ -1514,6 +1514,10 @@ func TestTokenize_Operators(t *testing.T) {
t.Errorf("tokenize(%q) returned error: %v", tc.input, err) t.Errorf("tokenize(%q) returned error: %v", tc.input, err)
continue continue
} }
// Patterns are tested in TestTokenize_Patterns
for i := range got {
got[i].pattern, got[i].glob = "", false
}
if !reflect.DeepEqual(got, tc.want) { if !reflect.DeepEqual(got, tc.want) {
t.Errorf("tokenize(%q) = %+v, want %+v", tc.input, got, tc.want) t.Errorf("tokenize(%q) = %+v, want %+v", tc.input, got, tc.want)
} }
@@ -1620,3 +1624,109 @@ func TestExecInput_RedirectionErrors(t *testing.T) {
t.Error("file f must not be created for a bad file descriptor") t.Error("file f must not be created for a bad file descriptor")
} }
} }
func TestTokenize_Patterns(t *testing.T) {
t.Setenv("GOSH_GLOB", "*.go")
tests := []struct {
input string
pattern string
glob bool
}{
{"*.go", "*.go", true},
{"a?c", "a?c", true},
{"[ab].go", "[ab].go", true},
{"[!a].go", "[^a].go", true},
{"plain", "plain", false},
// Quoted, escaped and expanded characters are literal
{`"*.go"`, `\*.go`, false},
{`'*'.go`, `\*.go`, false},
{`\*.go`, `\*.go`, false},
{`"x"*`, `x*`, true},
{"$GOSH_GLOB", `\*.go`, false},
{`'['!a].go`, `\[!a].go`, false},
}
for _, tc := range tests {
tokens, err := tokenize(tc.input)
if err != nil || len(tokens) != 1 {
t.Errorf("tokenize(%q) = %+v, %v", tc.input, tokens, err)
continue
}
if tokens[0].pattern != tc.pattern || tokens[0].glob != tc.glob {
t.Errorf("tokenize(%q): pattern %q, glob %v; want %q, %v",
tc.input, tokens[0].pattern, tokens[0].glob, tc.pattern, tc.glob)
}
}
}
func TestExpandGlob(t *testing.T) {
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
dir := t.TempDir()
os.Chdir(dir)
for _, d := range []string{"sub/deep", "other"} {
os.MkdirAll(d, 0755)
}
for _, f := range []string{"a.go", "b.go", "c.txt", ".hidden.go", "with space.go", "[x].go", "sub/s1.go", "sub/deep/d.go", "other/o.go"} {
os.WriteFile(f, nil, 0644)
}
tests := []struct {
pattern string
want []string
}{
{"*.go", []string{"[x].go", "a.go", "b.go", "with space.go"}},
{".*.go", []string{".hidden.go"}},
{"?.go", []string{"a.go", "b.go"}},
{"[ab].go", []string{"a.go", "b.go"}},
{"[^a].go", []string{"b.go"}},
{"*/*.go", []string{"other/o.go", "sub/s1.go"}},
{"*/", []string{"other/", "sub/"}},
{"sub/*/*.go", []string{"sub/deep/d.go"}},
{"./c*", []string{"./c.txt"}},
{dir + "/c*", []string{dir + "/c.txt"}},
{`\[x\].go`, []string{"[x].go"}},
{"*.xyz", nil},
{"[", nil},
}
for _, tc := range tests {
if got := expandGlob(tc.pattern); !reflect.DeepEqual(got, tc.want) {
t.Errorf("expandGlob(%q) = %q, want %q", tc.pattern, got, tc.want)
}
}
}
func TestExecInput_Wildcards(t *testing.T) {
origDir, _ := os.Getwd()
defer os.Chdir(origDir)
dir := t.TempDir()
os.Chdir(dir)
for _, f := range []string{"a.go", "b.go", "c.txt"} {
os.WriteFile(f, []byte(f+"\n"), 0644)
}
tests := []struct {
input string
want string
}{
{"echo *.go > out", "a.go b.go\n"},
{"cat *.go > out", "a.go\nb.go\n"},
// No match: the word is passed on unchanged, like in sh
{"echo *.xyz > out", "*.xyz\n"},
{`echo "*.go" '?.go' \*.go > out`, "*.go ?.go *.go\n"},
// A redirection to a pattern with a single match uses that file
{"echo hi > *.txt; cat c.txt > out", "hi\n"},
}
for _, tc := range tests {
if err := execInput(tc.input); err != nil {
t.Errorf("execInput(%q) returned error: %v", tc.input, err)
}
if got, _ := os.ReadFile("out"); string(got) != tc.want {
t.Errorf("%q: got %q, want %q", tc.input, got, tc.want)
}
}
err := execInput("echo hi > *.go")
if err == nil || err.Error() != "*.go: ambiguous redirect" {
t.Errorf("expected ambiguous redirect error, got %v", err)
}
}