add parameter expansion, arithmetic, set -e/-x/pipefail, trap, eval, exec
- ${#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>
This commit is contained in:
1 parent
f66cf538c0
commit
1f507f6562
20 files changed
+2408
-93
No files matched your search
@@ -0,0 +1,458 @@
|
||||
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()
|
||||
}
|
||||
Reference in new issue
Block a user