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>
This commit is contained in:
Florian WaltherandClaude Opus 5.5 committed 2026-10-03 20:44:58 +02:00
1 parent c46903e3d2
commit 1182d8d146
3 files changed
+304 -15

No files matched your search

+165 -15
View File
@@ -12,6 +12,7 @@ import (
"os/user"
"path/filepath"
"runtime/debug"
"sort"
"strconv"
"strings"
"syscall"
@@ -113,10 +114,12 @@ func expandTilde(path string) string {
// token is a word or an operator of a command line.
type token struct {
op string // "" for a word, otherwise |, <, >, >>, >&, <&, &> or &>>
word string // the word, if op == ""
fd int // the redirected file descriptor (for redirections)
dup int // the target file descriptor of >& and <&
op string // "" for a word, otherwise |, <, >, >>, >&, <&, &> or &>>
word string // the word, if op == ""
pattern string // the word as glob pattern (quoted characters escaped)
glob bool // the word contains unquoted * ? or [ and is expanded
fd int // the redirected file descriptor (for redirections)
dup int // the target file descriptor of >& and <&
}
// tokenize splits input into words and operators like a (very) simple POSIX
@@ -136,7 +139,9 @@ func tokenize(input string) ([]token, error) {
var (
tokens []token
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
escaped bool
rawStart = -1 // start of the current word in input
@@ -144,12 +149,19 @@ func tokenize(input string) ([]token, error) {
endWord := func() {
if inWord {
tokens = append(tokens, token{word: b.String()})
tokens = append(tokens, token{word: b.String(), pattern: pat.String(), glob: glob})
}
b.Reset()
pat.Reset()
glob = false
inWord = false
rawStart = -1
}
// literal adds quoted, escaped or expanded text: it is never a pattern
literal := func(s string) {
b.WriteString(s)
pat.WriteString(escapeGlob(s))
}
startRaw := func(i int) {
if rawStart < 0 {
rawStart = i
@@ -161,16 +173,16 @@ func tokenize(input string) ([]token, error) {
switch {
case escaped:
if quote == '"' && !strings.ContainsRune("$\"\\`", rune(ch)) {
b.WriteByte('\\')
literal("\\")
}
b.WriteByte(ch)
literal(string(ch))
inWord = true
escaped = false
case quote == '\'':
if ch == '\'' {
quote = 0
} else {
b.WriteByte(ch)
literal(string(ch))
}
case ch == '\\':
startRaw(i)
@@ -181,7 +193,9 @@ func tokenize(input string) ([]token, error) {
if err != nil {
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
// not (unless quoted), like in sh
if n == 1 || value != "" {
@@ -192,7 +206,7 @@ func tokenize(input string) ([]token, error) {
if ch == '"' {
quote = 0
} else {
b.WriteByte(ch)
literal(string(ch))
}
case ch == '"' || ch == '\'':
startRaw(i)
@@ -225,6 +239,8 @@ func tokenize(input string) ([]token, error) {
if inWord && rawStart >= 0 && isDigits(input[rawStart:i]) {
fd, _ = strconv.Atoi(input[rawStart:i])
b.Reset()
pat.Reset()
glob = false
inWord = false
rawStart = -1
} else {
@@ -256,11 +272,21 @@ func tokenize(input string) ([]token, error) {
if home == "" {
home = "~"
}
b.WriteString(home)
literal(home)
inWord = true
default:
startRaw(i)
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
}
}
@@ -275,6 +301,123 @@ func tokenize(input string) ([]token, error) {
return tokens, nil
}
// escapeGlob escapes the characters that are special in a glob pattern.
func escapeGlob(s string) string {
var b strings.Builder
for i := 0; i < len(s); i++ {
if strings.IndexByte("*?[]\\", s[i]) >= 0 {
b.WriteByte('\\')
}
b.WriteByte(s[i])
}
return b.String()
}
// hasGlobMeta reports whether pattern contains an unescaped * ? or [.
func hasGlobMeta(pattern string) bool {
for i := 0; i < len(pattern); i++ {
switch pattern[i] {
case '\\':
i++
case '*', '?', '[':
return true
}
}
return false
}
// unescapeGlob removes the escapes from a pattern without wildcards.
func unescapeGlob(pattern string) string {
var b strings.Builder
for i := 0; i < len(pattern); i++ {
if pattern[i] == '\\' && i+1 < len(pattern) {
i++
}
b.WriteByte(pattern[i])
}
return b.String()
}
// expandGlob returns the paths matching pattern, sorted. Like sh, * and ?
// do not match a leading dot (hidden files), unless the pattern component
// starts with a dot, and a trailing / only matches directories. An invalid
// pattern matches nothing.
func expandGlob(pattern string) []string {
paths := []string{""}
if strings.HasPrefix(pattern, "/") {
paths = []string{"/"}
}
components := strings.Split(strings.Trim(pattern, "/"), "/")
join := func(dir, name string) string {
if dir == "" || strings.HasSuffix(dir, "/") {
return dir + name
}
return dir + "/" + name
}
for _, comp := range components {
if comp == "" {
continue // a//b
}
var next []string
for _, dir := range paths {
if !hasGlobMeta(comp) {
p := join(dir, unescapeGlob(comp))
if _, err := os.Lstat(p); err == nil {
next = append(next, p)
}
continue
}
readDir := dir
if readDir == "" {
readDir = "."
}
entries, err := os.ReadDir(readDir)
if err != nil {
continue
}
for _, e := range entries {
name := e.Name()
if strings.HasPrefix(name, ".") && !strings.HasPrefix(comp, ".") && !strings.HasPrefix(comp, "\\.") {
continue
}
ok, err := filepath.Match(comp, name)
if err != nil {
return nil
}
if ok {
next = append(next, join(dir, name))
}
}
}
paths = next
}
if strings.HasSuffix(pattern, "/") {
var dirs []string
for _, p := range paths {
if info, err := os.Stat(p); err == nil && info.IsDir() {
dirs = append(dirs, p+"/")
}
}
paths = dirs
}
sort.Strings(paths)
return paths
}
// expandWord returns the words a token expands to: the matching paths for
// a pattern, or the word itself if it is no pattern or nothing matches (like
// in sh).
func expandWord(t token) []string {
if t.glob {
if matches := expandGlob(t.pattern); len(matches) > 0 {
return matches
}
}
return []string{t.word}
}
// isDigits reports whether s is a non-empty string of ASCII digits.
func isDigits(s string) bool {
if s == "" {
@@ -668,7 +811,7 @@ func currentWord(s string) (word string, start int, quote byte) {
func escapeWord(s string) string {
var b strings.Builder
for _, r := range s {
if strings.ContainsRune(" \t\\'\";|&<>", r) {
if strings.ContainsRune(" \t\\'\";|&<>*?[$", r) {
b.WriteByte('\\')
}
b.WriteRune(r)
@@ -1131,7 +1274,7 @@ func parsePipeline(tokens []token) ([]stage, error) {
t := tokens[i]
switch t.op {
case "":
cur.args = append(cur.args, t.word)
cur.args = append(cur.args, expandWord(t)...)
case "|":
if isEmpty(cur) {
return nil, syntaxError("|")
@@ -1148,7 +1291,11 @@ func parsePipeline(tokens []token) ([]stage, error) {
return nil, syntaxError(next.op)
}
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
- Tab completion for commands and file paths
- 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),
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);