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:
Florian WaltherandClaude Opus 5.5 committed 2026-10-04 21:15:24 +02:00
1 parent f66cf538c0
commit 1f507f6562
20 files changed
+2408 -93

No files matched your search

+458
View File
@@ -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()
}