diff --git a/README.md b/README.md index bbf065a..f010794 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ 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 + - 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 unless the pattern starts with a dot); quoted wildcards and values of variables are not expanded, a pattern without match is kept as is @@ -142,6 +143,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. +### 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 Wörter mit `*`, `?` oder `[...]` werden durch die passenden Dateinamen ersetzt, sortiert: diff --git a/brace.go b/brace.go new file mode 100644 index 0000000..848f816 --- /dev/null +++ b/brace.go @@ -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' +} diff --git a/brace_test.go b/brace_test.go new file mode 100644 index 0000000..3254552 --- /dev/null +++ b/brace_test.go @@ -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) + } +} diff --git a/main.go b/main.go index cdc10ba..74cabb4 100644 --- a/main.go +++ b/main.go @@ -815,7 +815,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) @@ -1354,6 +1354,12 @@ func execInput(input string) error { // execCommand executes a pipeline of simple commands with redirections, in // the background if background is set. 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 // the way. tokens, err := tokenize(input) @@ -1783,6 +1789,7 @@ 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 + - 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 unless the pattern starts with a dot); quoted wildcards and values of variables are not expanded, a pattern without match is kept as is