- ${#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>
459 lines
11 KiB
Go
459 lines
11 KiB
Go
package main
|
|
|
|
// Parameter expansion ${...}: ${name}, ${#name} and the operators
|
|
//
|
|
// ${name:-word} ${name-word} word if name is unset (or empty with :)
|
|
// ${name:=word} ${name=word} ... and assign it to name
|
|
// ${name:+word} ${name+word} word if name is set (and not empty with :)
|
|
// ${name:?word} ${name?word} error if name is unset (or empty)
|
|
// ${name#pat} ${name##pat} remove the shortest/longest matching prefix
|
|
// ${name%pat} ${name%%pat} remove the shortest/longest matching suffix
|
|
// ${name/pat/rep} replace the first (//: every) match
|
|
// ${name/#pat/rep} ... at the start, /% at the end
|
|
// ${name:offset[:length]} substring (arithmetic expressions)
|
|
//
|
|
// The word is expanded (variables, $(...), $((...)), quotes, ~), but not
|
|
// split. In patterns, quoted characters and values of variables match
|
|
// literally, like everywhere in gosh.
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// paramEnd returns the index of the } that ends the ${ at s[i], or -1.
|
|
// Nested ${...}, $(...), quotes and escapes are skipped.
|
|
func paramEnd(s string, i int) int {
|
|
var quote byte
|
|
for j := i + 2; j < len(s); j++ {
|
|
ch := s[j]
|
|
switch {
|
|
case quote == '\'':
|
|
if ch == '\'' {
|
|
quote = 0
|
|
}
|
|
case ch == '\\':
|
|
j++
|
|
case isSubstStart(s, j):
|
|
end := substEnd(s, j)
|
|
if end < 0 {
|
|
return -1
|
|
}
|
|
j = end
|
|
case ch == '$' && j+1 < len(s) && s[j+1] == '{':
|
|
end := paramEnd(s, j)
|
|
if end < 0 {
|
|
return -1
|
|
}
|
|
j = end
|
|
case quote == '"':
|
|
if ch == '"' {
|
|
quote = 0
|
|
}
|
|
case ch == '"' || ch == '\'':
|
|
quote = ch
|
|
case ch == '}':
|
|
return j
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// paramNameLen returns the length of the parameter name at the start of
|
|
// expr: a variable name, a number or a special parameter.
|
|
func paramNameLen(expr string) int {
|
|
switch {
|
|
case expr == "":
|
|
return 0
|
|
case isSpecialParam(expr[:1]):
|
|
return 1
|
|
case expr[0] >= '0' && expr[0] <= '9':
|
|
n := 1
|
|
for n < len(expr) && expr[n] >= '0' && expr[n] <= '9' {
|
|
n++
|
|
}
|
|
return n
|
|
}
|
|
n := 0
|
|
for n < len(expr) && isVarNameByte(expr[n], n == 0) {
|
|
n++
|
|
}
|
|
return n
|
|
}
|
|
|
|
// expandParam expands the inside of ${...}.
|
|
func expandParam(expr string) (string, error) {
|
|
bad := func() (string, error) { return "", fmt.Errorf("bad substitution: ${%s}", expr) }
|
|
unbound := func(name string) (string, error) { return "", fmt.Errorf("%s: unbound variable", name) }
|
|
|
|
// ${#name}: the length in characters (${#} alone is $#)
|
|
if len(expr) > 1 && expr[0] == '#' {
|
|
name := expr[1:]
|
|
if paramNameLen(name) != len(name) {
|
|
return bad()
|
|
}
|
|
value, ok := lookupParam(name)
|
|
if !ok {
|
|
return unbound(name)
|
|
}
|
|
return strconv.Itoa(utf8.RuneCountInString(value)), nil
|
|
}
|
|
|
|
n := paramNameLen(expr)
|
|
if n == 0 {
|
|
return bad()
|
|
}
|
|
name, op := expr[:n], expr[n:]
|
|
if op != "" && strings.IndexByte(":-=+?#%/", op[0]) < 0 {
|
|
return bad()
|
|
}
|
|
value, set := lookupParam(name)
|
|
if op == "" {
|
|
if !set {
|
|
return unbound(name)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
// ${name:-word} and friends
|
|
colon := strings.HasPrefix(op, ":")
|
|
if kind := strings.TrimPrefix(op, ":"); kind != "" && strings.IndexByte("-=+?", kind[0]) >= 0 {
|
|
word := kind[1:]
|
|
missing := !set || colon && value == ""
|
|
switch kind[0] {
|
|
case '-':
|
|
if missing {
|
|
return expandWordText(word)
|
|
}
|
|
case '=':
|
|
if missing {
|
|
if !isVarName(name) {
|
|
return "", fmt.Errorf("$%s: cannot assign in this way", name)
|
|
}
|
|
w, err := expandWordText(word)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
os.Setenv(name, w)
|
|
return w, nil
|
|
}
|
|
case '+':
|
|
if missing {
|
|
return "", nil
|
|
}
|
|
return expandWordText(word)
|
|
case '?':
|
|
if missing {
|
|
msg, err := expandWordText(word)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if msg == "" {
|
|
msg = "parameter null or not set"
|
|
}
|
|
return "", fmt.Errorf("%s: %s", name, msg)
|
|
}
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
if !set {
|
|
return unbound(name)
|
|
}
|
|
switch {
|
|
case colon:
|
|
return substring(value, op[1:])
|
|
case strings.HasPrefix(op, "#"), strings.HasPrefix(op, "%"):
|
|
longest := strings.HasPrefix(op[1:], op[:1])
|
|
word := op[1:]
|
|
if longest {
|
|
word = op[2:]
|
|
}
|
|
pattern, err := expandPattern(word)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if op[0] == '#' {
|
|
return removePrefix(value, pattern, longest), nil
|
|
}
|
|
return removeSuffix(value, pattern, longest), nil
|
|
case strings.HasPrefix(op, "/"):
|
|
spec := op[1:]
|
|
all := strings.HasPrefix(spec, "/")
|
|
var anchor byte
|
|
switch {
|
|
case all:
|
|
spec = spec[1:]
|
|
case strings.HasPrefix(spec, "#"), strings.HasPrefix(spec, "%"):
|
|
anchor, spec = spec[0], spec[1:]
|
|
}
|
|
patText, repText := spec, ""
|
|
if i := unquotedSlash(spec); i >= 0 {
|
|
patText, repText = spec[:i], spec[i+1:]
|
|
}
|
|
pattern, err := expandPattern(patText)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
rep, err := expandWordText(repText)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return replacePattern(value, pattern, rep, all, anchor), nil
|
|
}
|
|
return bad()
|
|
}
|
|
|
|
// unquotedSlash returns the index of the first / in s that is not quoted,
|
|
// escaped or inside $(...) or ${...}, or -1.
|
|
func unquotedSlash(s string) int {
|
|
var quote byte
|
|
for i := 0; i < len(s); i++ {
|
|
ch := s[i]
|
|
switch {
|
|
case quote == '\'':
|
|
if ch == '\'' {
|
|
quote = 0
|
|
}
|
|
case ch == '\\':
|
|
i++
|
|
case isSubstStart(s, i):
|
|
if end := substEnd(s, i); end >= 0 {
|
|
i = end
|
|
}
|
|
case ch == '$' && i+1 < len(s) && s[i+1] == '{':
|
|
if end := paramEnd(s, i); end >= 0 {
|
|
i = end
|
|
}
|
|
case quote == '"':
|
|
if ch == '"' {
|
|
quote = 0
|
|
}
|
|
case ch == '"' || ch == '\'':
|
|
quote = ch
|
|
case ch == '/':
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// substring implements ${name:offset[:length]}; offset and length are
|
|
// arithmetic expressions, a negative offset counts from the end.
|
|
func substring(value, spec string) (string, error) {
|
|
offText, lenText, hasLen := strings.Cut(spec, ":")
|
|
runes := []rune(value)
|
|
off, err := evalArithText(offText)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if off < 0 {
|
|
off += int64(len(runes))
|
|
}
|
|
if off < 0 || off > int64(len(runes)) {
|
|
return "", nil
|
|
}
|
|
end := int64(len(runes))
|
|
if hasLen {
|
|
n, err := evalArithText(lenText)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if n < 0 {
|
|
end += n // a negative length counts from the end
|
|
} else {
|
|
end = min(off+n, end)
|
|
}
|
|
}
|
|
if end < off {
|
|
return "", errors.New(strings.TrimSpace(lenText) + ": substring expression < 0")
|
|
}
|
|
return string(runes[off:end]), nil
|
|
}
|
|
|
|
// expandWordText expands the word of a parameter expansion (or of $((...)))
|
|
// to a single string: quotes are removed, $..., $(...) and $((...)) are
|
|
// expanded and a leading ~ is replaced by the home directory.
|
|
func expandWordText(word string) (string, error) {
|
|
value, _, err := expandWordParts(word)
|
|
return value, err
|
|
}
|
|
|
|
// expandPattern expands a word like expandWordText, but returns it as a glob
|
|
// pattern: quoted characters and expanded values are escaped.
|
|
func expandPattern(word string) (string, error) {
|
|
_, pattern, err := expandWordParts(word)
|
|
return pattern, err
|
|
}
|
|
|
|
func expandWordParts(word string) (value, pattern string, err error) {
|
|
var v, p strings.Builder
|
|
literal := func(s string) {
|
|
v.WriteString(s)
|
|
p.WriteString(escapeGlob(s))
|
|
}
|
|
var quote byte
|
|
for i := 0; i < len(word); i++ {
|
|
ch := word[i]
|
|
switch {
|
|
case quote == '\'':
|
|
if ch == '\'' {
|
|
quote = 0
|
|
} else {
|
|
literal(word[i : i+1])
|
|
}
|
|
case ch == '\\' && i+1 < len(word):
|
|
i++
|
|
if quote == '"' && strings.IndexByte("$\"\\`", word[i]) < 0 {
|
|
literal("\\")
|
|
}
|
|
literal(word[i : i+1])
|
|
case ch == '$':
|
|
value, n, err := expandDollar(word[i:])
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
literal(value)
|
|
i += n - 1
|
|
case quote == '"':
|
|
if ch == '"' {
|
|
quote = 0
|
|
} else {
|
|
literal(word[i : i+1])
|
|
}
|
|
case ch == '"' || ch == '\'':
|
|
quote = ch
|
|
case ch == '~' && i == 0 && (len(word) == 1 || word[1] == '/'):
|
|
if home := getHomeDir(); home != "" {
|
|
literal(home)
|
|
} else {
|
|
literal("~")
|
|
}
|
|
default:
|
|
v.WriteByte(ch)
|
|
if ch == '!' && strings.HasSuffix(p.String(), "[") && !strings.HasSuffix(p.String(), "\\[") {
|
|
p.WriteByte('^') // POSIX [!...] is [^...] in Go
|
|
} else {
|
|
p.WriteByte(ch)
|
|
}
|
|
}
|
|
}
|
|
if quote != 0 {
|
|
return "", "", fmt.Errorf("unterminated %c quote", quote)
|
|
}
|
|
return v.String(), p.String(), nil
|
|
}
|
|
|
|
// expandDollar expands the $ expression at the start of s ($name, ${...},
|
|
// $(...) or $((...))) and returns its value and length.
|
|
func expandDollar(s string) (string, int, error) {
|
|
if end := arithEnd(s, 0); end >= 0 {
|
|
value, err := evalArithText(s[3 : end-1])
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
return strconv.FormatInt(value, 10), end + 1, nil
|
|
}
|
|
if isSubstStart(s, 0) {
|
|
end := substEnd(s, 0)
|
|
if end < 0 {
|
|
return "", 0, errors.New("missing ) in command substitution")
|
|
}
|
|
out, err := commandSubst(s[2:end])
|
|
return out, end + 1, err
|
|
}
|
|
return expandVar(s)
|
|
}
|
|
|
|
// globMatch reports whether s matches the glob pattern (in which escaped
|
|
// characters match literally). Unlike for file names, * and ? also match a
|
|
// /. An invalid pattern matches only itself.
|
|
func globMatch(pattern, s string) bool {
|
|
// path.Match does not let * match a /; NUL can be in neither string
|
|
toNUL := strings.NewReplacer("/", "\x00")
|
|
ok, err := path.Match(toNUL.Replace(pattern), toNUL.Replace(s))
|
|
if err != nil {
|
|
return unescapeGlob(pattern) == s
|
|
}
|
|
return ok
|
|
}
|
|
|
|
// runeBounds returns the indexes in s where characters start, and len(s).
|
|
func runeBounds(s string) []int {
|
|
bounds := make([]int, 0, len(s)+1)
|
|
for i := range s {
|
|
bounds = append(bounds, i)
|
|
}
|
|
return append(bounds, len(s))
|
|
}
|
|
|
|
func removePrefix(value, pattern string, longest bool) string {
|
|
bounds := runeBounds(value)
|
|
for k := range bounds {
|
|
i := bounds[k]
|
|
if longest {
|
|
i = bounds[len(bounds)-1-k]
|
|
}
|
|
if globMatch(pattern, value[:i]) {
|
|
return value[i:]
|
|
}
|
|
}
|
|
return value
|
|
}
|
|
|
|
func removeSuffix(value, pattern string, longest bool) string {
|
|
bounds := runeBounds(value)
|
|
for k := range bounds {
|
|
i := bounds[len(bounds)-1-k]
|
|
if longest {
|
|
i = bounds[k]
|
|
}
|
|
if globMatch(pattern, value[i:]) {
|
|
return value[:i]
|
|
}
|
|
}
|
|
return value
|
|
}
|
|
|
|
// replacePattern replaces the longest match of pattern at the first (or
|
|
// every) position by rep. anchor '#' only matches at the start, '%' only at
|
|
// the end. Empty matches are not replaced.
|
|
func replacePattern(value, pattern, rep string, all bool, anchor byte) string {
|
|
if pattern == "" {
|
|
return value
|
|
}
|
|
bounds := runeBounds(value)
|
|
var b strings.Builder
|
|
for k := 0; k < len(bounds)-1; k++ {
|
|
i := bounds[k]
|
|
match := -1
|
|
if anchor != '#' || i == 0 {
|
|
for m := len(bounds) - 1; m > k; m-- {
|
|
if anchor == '%' && m != len(bounds)-1 {
|
|
break
|
|
}
|
|
if globMatch(pattern, value[i:bounds[m]]) {
|
|
match = m
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if match < 0 {
|
|
b.WriteString(value[i:bounds[k+1]])
|
|
continue
|
|
}
|
|
b.WriteString(rep)
|
|
if !all {
|
|
b.WriteString(value[bounds[match]:])
|
|
return b.String()
|
|
}
|
|
k = match - 1
|
|
}
|
|
return b.String()
|
|
}
|