- ${#v}, ${v#p} ${v##p} ${v%p} ${v%%p}, ${v/p/r} ${v//p/r} ${v/#p/r}
${v/%p/r}, ${v:off:len}, ${v-w} ${v:=w} ${v:+w} ${v:?msg}; nested ${}
- arithmetic expansion $((...)) with the C operators, ** and assignments
- set -e, set -x, set -o pipefail (also as gosh options, passed on to
subshells); set shows variables, set -o the options
- trap for EXIT and signals; scripts with an EXIT trap run it on
SIGINT/SIGTERM/SIGHUP too
- eval, exec (also exec > file without a command), $!, wait pid
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
299 lines
7.1 KiB
Go
299 lines
7.1 KiB
Go
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
|
|
commandStart = true
|
|
)
|
|
|
|
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
|
|
} else {
|
|
commandStart = 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)
|
|
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
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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 {
|
|
var quote byte
|
|
for ; i < len(s); i++ {
|
|
ch := s[i]
|
|
switch {
|
|
case quote == '\'':
|
|
if ch == '\'' {
|
|
quote = 0
|
|
}
|
|
case ch == '\\':
|
|
i++
|
|
case isSubstStart(s, i):
|
|
if j := substEnd(s, i); j >= 0 {
|
|
i = j
|
|
}
|
|
case ch == '$' && i+1 < len(s) && s[i+1] == '{':
|
|
if j := paramEnd(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 isSubstStart(s, i):
|
|
// The subshell does its own brace expansion
|
|
if j := substEnd(s, i); j >= 0 {
|
|
i = j
|
|
}
|
|
continue
|
|
case ch == '$' && i+1 < len(s) && s[i+1] == '{':
|
|
if j := paramEnd(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'
|
|
}
|