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() }