Files
bash-for-windows/internal/shell/expand.go
2026-05-28 21:00:34 +02:00

654 lines
15 KiB
Go

package shell
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
func isVarChar(c byte) bool {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'
}
// expandWord expands $VAR, ${VAR}, $(...), $((...)) in a single token.
// Quote characters (single/double) are interpreted here and stripped from output.
func (s *Shell) expandWord(word string) string {
// Tilde expansion (only when not quoted)
if strings.HasPrefix(word, "~") {
home := s.GetVar("HOME")
if home == "" {
home = os.Getenv("USERPROFILE")
}
if len(word) == 1 {
return home
}
if word[1] == '/' || word[1] == '\\' {
return home + word[1:]
}
}
var result strings.Builder
inSingle := false
inDouble := false
i := 0
for i < len(word) {
ch := word[i]
switch {
case ch == '\'' && !inDouble:
inSingle = !inSingle
i++
case ch == '"' && !inSingle:
inDouble = !inDouble
i++
case ch == '\\' && !inSingle:
if i+1 < len(word) {
next := word[i+1]
if inDouble {
// In double quotes, only certain chars are escaped
switch next {
case '$', '`', '"', '\\', '\n':
result.WriteByte(next)
i += 2
default:
result.WriteByte('\\')
result.WriteByte(next)
i += 2
}
} else {
// Only consume the backslash when escaping a shell
// metacharacter. Before regular path characters (letters,
// digits, etc.) keep it literal so Windows paths like
// C:\workspace work unquoted.
const metachars = " \t\n$*?[\"'\\|&;()<>{}!#~`"
if strings.ContainsRune(metachars, rune(next)) {
result.WriteByte(next)
} else {
result.WriteByte('\\')
result.WriteByte(next)
}
i += 2
}
} else {
i++
}
case ch == '$' && !inSingle:
i++ // skip $
if i >= len(word) {
result.WriteByte('$')
break
}
// $'...' ANSI C string
if word[i] == '\'' {
i++ // skip opening '
for i < len(word) && word[i] != '\'' {
if word[i] == '\\' && i+1 < len(word) {
i++ // skip backslash, now at escape char
switch word[i] {
case 'n':
result.WriteByte('\n')
case 't':
result.WriteByte('\t')
case 'r':
result.WriteByte('\r')
case '\\':
result.WriteByte('\\')
case '\'':
result.WriteByte('\'')
case '"':
result.WriteByte('"')
case 'a':
result.WriteByte('\a')
case 'b':
result.WriteByte('\b')
case 'f':
result.WriteByte('\f')
case 'v':
result.WriteByte('\v')
case 'e', 'E':
result.WriteByte(0x1b)
case '0', '1', '2', '3', '4', '5', '6', '7':
// Octal \NNN — up to 3 digits
oct := 0
for k := 0; k < 3 && i < len(word) && word[i] >= '0' && word[i] <= '7'; k++ {
oct = oct*8 + int(word[i]-'0')
i++
}
result.WriteByte(byte(oct))
continue
case 'x':
// Hex \xNN — up to 2 digits
i++ // skip 'x'
hexv := 0
for k := 0; k < 2 && i < len(word); k++ {
d := word[i]
if d >= '0' && d <= '9' {
hexv = hexv*16 + int(d-'0')
i++
} else if d >= 'a' && d <= 'f' {
hexv = hexv*16 + int(d-'a'+10)
i++
} else if d >= 'A' && d <= 'F' {
hexv = hexv*16 + int(d-'A'+10)
i++
} else {
break
}
}
result.WriteByte(byte(hexv))
continue
default:
result.WriteByte('\\')
result.WriteByte(word[i])
}
i++
} else {
result.WriteByte(word[i])
i++
}
}
if i < len(word) {
i++ // skip closing '
}
continue
}
switch word[i] {
case '(':
if i+1 < len(word) && word[i+1] == '(' {
// $(( arithmetic ))
j := i + 2
depth := 2
for j < len(word) {
if word[j] == '(' {
depth++
}
if word[j] == ')' {
depth--
if depth == 0 {
j++
break
}
}
j++
}
expr := word[i+2 : j-2]
result.WriteString(strconv.Itoa(s.evalArith(expr)))
i = j
} else {
// $( command substitution )
j := i + 1
depth := 1
for j < len(word) {
if word[j] == '(' {
depth++
}
if word[j] == ')' {
depth--
if depth == 0 {
break
}
}
j++
}
cmd := word[i+1 : j]
out := s.captureCommand(cmd)
result.WriteString(strings.TrimRight(out, "\n"))
i = j + 1
}
case '{':
j := i + 1
depth := 1
for j < len(word) {
if word[j] == '{' {
depth++
}
if word[j] == '}' {
depth--
if depth == 0 {
break
}
}
j++
}
varExpr := word[i+1 : j]
result.WriteString(s.evalVarExpr(varExpr))
i = j + 1
case '?':
result.WriteString(s.vars["?"])
i++
case '$':
result.WriteString(fmt.Sprintf("%d", os.Getpid()))
i++
case '!':
result.WriteString(s.vars["!"])
i++
case '#':
result.WriteString(s.vars["#"])
i++
case '@':
result.WriteString(strings.Join(s.args, "\x01"))
i++
case '*':
result.WriteString(s.vars["*"])
i++
default:
j := i
for j < len(word) && isVarChar(word[j]) {
j++
}
if j == i {
result.WriteByte('$')
} else {
result.WriteString(s.getVar(word[i:j]))
i = j
}
}
default:
result.WriteByte(ch)
i++
}
}
return result.String()
}
func (s *Shell) getVar(name string) string {
// Resolve nameref chain
resolved := s.resolveNR(name)
if v, ok := s.vars[resolved]; ok {
return v
}
return os.Getenv(resolved)
}
func (s *Shell) evalVarExpr(expr string) string {
// ${#arr[@]} or ${#arr[*]} → length of array
if strings.HasPrefix(expr, "#") {
rest := expr[1:]
if strings.HasSuffix(rest, "[@]") || strings.HasSuffix(rest, "[*]") {
arrName := rest[:len(rest)-3]
return strconv.Itoa(len(s.getArray(arrName)))
}
// ${#VAR} — string length
return strconv.Itoa(len(s.getVar(rest)))
}
// Array indexing: ${arr[@]}, ${arr[*]}, ${arr[N]}
if bracketIdx := strings.Index(expr, "["); bracketIdx >= 0 && strings.HasSuffix(expr, "]") {
// Make sure there's no operator before the bracket
prefix := expr[:bracketIdx]
hasOp := strings.ContainsAny(prefix, ":-:=:+%#")
if !hasOp {
arrName := prefix
idx := expr[bracketIdx+1 : len(expr)-1]
if idx == "@" {
arr := s.getArray(arrName)
return strings.Join(arr, "\x01")
}
if idx == "*" {
arr := s.getArray(arrName)
return strings.Join(arr, " ")
}
// Numeric index
n := s.evalArith(idx)
arr := s.getArray(arrName)
if n >= 0 && n < len(arr) {
return arr[n]
}
return ""
}
}
// ${VAR:-default}
if idx := strings.Index(expr, ":-"); idx >= 0 {
varName := expr[:idx]
if v := s.getVar(varName); v != "" {
return v
}
return s.expandWord(expr[idx+2:])
}
// ${VAR:=default}
if idx := strings.Index(expr, ":="); idx >= 0 {
varName := expr[:idx]
if v := s.getVar(varName); v != "" {
return v
}
expanded := s.expandWord(expr[idx+2:])
s.vars[varName] = expanded
return expanded
}
// ${VAR:+alt}
if idx := strings.Index(expr, ":+"); idx >= 0 {
varName := expr[:idx]
if v := s.getVar(varName); v != "" {
return s.expandWord(expr[idx+2:])
}
return ""
}
// ${VAR%pattern} — strip shortest suffix
if idx := strings.Index(expr, "%"); idx >= 0 {
varName := expr[:idx]
pattern := expr[idx+1:]
v := s.getVar(varName)
if strings.HasSuffix(v, pattern) {
return v[:len(v)-len(pattern)]
}
return v
}
// ${VAR#pattern} — strip shortest prefix
if idx := strings.Index(expr, "#"); idx >= 0 {
varName := expr[:idx]
pattern := expr[idx+1:]
v := s.getVar(varName)
if strings.HasPrefix(v, pattern) {
return v[len(pattern):]
}
return v
}
return s.getVar(expr)
}
// captureCommand runs a command and returns its stdout as a string.
func (s *Shell) captureCommand(cmd string) string {
var buf bytes.Buffer
s.withIO(nil, &buf, nil, func() error {
return s.Execute(cmd)
})
return buf.String()
}
// evalArith evaluates a shell arithmetic expression.
func (s *Shell) evalArith(expr string) int {
expr = strings.TrimSpace(s.expandWord(expr))
// Expand bare variable names (e.g. i+1 → value_of_i + 1)
expr = s.expandArithVars(expr)
return evalArithExpr(expr)
}
// expandArithVars replaces bare identifier names with their shell variable values.
func (s *Shell) expandArithVars(expr string) string {
var result strings.Builder
i := 0
for i < len(expr) {
c := expr[i]
// Identifier start (letter or _), but not a digit
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' {
j := i
for j < len(expr) && isVarChar(expr[j]) {
j++
}
varName := expr[i:j]
val := s.getVar(varName)
if val == "" {
val = "0"
}
result.WriteString(val)
i = j
} else {
result.WriteByte(c)
i++
}
}
return result.String()
}
func evalArithExpr(expr string) int {
expr = strings.TrimSpace(expr)
if n, err := strconv.Atoi(expr); err == nil {
return n
}
// Strip outer parens
if strings.HasPrefix(expr, "(") && strings.HasSuffix(expr, ")") {
return evalArithExpr(expr[1 : len(expr)-1])
}
// Comparison operators (lowest precedence) — multi-char first
for _, op := range []string{"<=", ">=", "==", "!=", "<", ">"} {
if idx := findBinaryOpStr(expr, op); idx >= 0 {
left := evalArithExpr(expr[:idx])
right := evalArithExpr(expr[idx+len(op):])
switch op {
case "<":
if left < right {
return 1
}
return 0
case ">":
if left > right {
return 1
}
return 0
case "<=":
if left <= right {
return 1
}
return 0
case ">=":
if left >= right {
return 1
}
return 0
case "==":
if left == right {
return 1
}
return 0
case "!=":
if left != right {
return 1
}
return 0
}
}
}
// Arithmetic operators in precedence order (lowest first so we split on last occurrence)
for _, op := range []string{"+", "-", "*", "/", "%"} {
if idx := findBinaryOp(expr, op); idx >= 0 {
left := evalArithExpr(expr[:idx])
right := evalArithExpr(expr[idx+1:])
switch op {
case "+":
return left + right
case "-":
return left - right
case "*":
return left * right
case "/":
if right == 0 {
return 0
}
return left / right
case "%":
if right == 0 {
return 0
}
return left % right
}
}
}
return 0
}
// findBinaryOpStr finds the rightmost occurrence of a multi-character binary operator.
func findBinaryOpStr(expr, op string) int {
depth := 0
// Search right-to-left
for i := len(expr) - len(op); i >= 0; i-- {
switch expr[i] {
case ')':
depth++
case '(':
depth--
}
if depth != 0 {
continue
}
if expr[i:i+len(op)] == op {
// Make sure it's not part of a longer operator
// e.g. don't match < in <=
if len(op) == 1 {
// For < and >, make sure next char is not =
if i+1 < len(expr) && (expr[i+1] == '=' || (op == "<" && expr[i+1] == '<') || (op == ">" && expr[i+1] == '>')) {
continue
}
}
// For single char ops, make sure previous char is not the same op (e.g. << or >>)
if len(op) == 1 && i > 0 && expr[i-1] == expr[i] {
continue
}
return i
}
}
return -1
}
func findBinaryOp(expr, op string) int {
depth := 0
for i := len(expr) - 1; i >= 0; i-- {
switch expr[i] {
case ')':
depth++
case '(':
depth--
}
if depth != 0 {
continue
}
if expr[i:i+1] == op {
if (op == "-" || op == "+") && i == 0 {
continue
}
return i
}
}
return -1
}
// expandGlob expands glob patterns; returns original if no match.
func (s *Shell) expandGlob(word string) []string {
if !strings.ContainsAny(word, "*?[") {
return []string{word}
}
matches, err := filepath.Glob(word)
if err != nil || len(matches) == 0 {
return []string{word}
}
return matches
}
// tokenize splits input into tokens, expands variables, handles quotes and globs.
func (s *Shell) tokenize(input string) []string {
var rawTokens []string
current := strings.Builder{}
inSingle := false
inDouble := false
parenDepth := 0 // nesting depth inside $(...) or $((...))
pendingDollar := false // true after $ when next char is (
wasQuoted := false
flush := func() {
if current.Len() > 0 {
tok := current.String()
if wasQuoted {
tok = "\x00q" + tok
}
rawTokens = append(rawTokens, tok)
current.Reset()
wasQuoted = false
pendingDollar = false
}
}
for i := 0; i < len(input); i++ {
c := input[i]
switch {
case c == '\'' && !inDouble && parenDepth == 0:
inSingle = !inSingle
wasQuoted = true
current.WriteByte(c)
case c == '"' && !inSingle && parenDepth == 0:
inDouble = !inDouble
wasQuoted = true
current.WriteByte(c)
case c == '$' && !inSingle && i+1 < len(input) && (input[i+1] == '(' || input[i+1] == '{'):
// Mark that the next ( opens a substitution — don't increment depth here
if input[i+1] == '(' {
pendingDollar = true
}
current.WriteByte(c)
case c == '(' && !inSingle && !inDouble && (parenDepth > 0 || pendingDollar):
parenDepth++
pendingDollar = false
current.WriteByte(c)
case c == ')' && !inSingle && !inDouble && parenDepth > 0:
parenDepth--
current.WriteByte(c)
case c == '{' && !inSingle && !inDouble && parenDepth > 0:
parenDepth++
current.WriteByte(c)
case c == '}' && !inSingle && !inDouble && parenDepth > 0:
parenDepth--
current.WriteByte(c)
case (c == ' ' || c == '\t') && !inSingle && !inDouble && parenDepth == 0:
flush()
case c == '#' && !inSingle && !inDouble && parenDepth == 0 && current.Len() == 0:
// Inline comment: # at start of a new token — discard the rest of the input
goto doneTokenizing
default:
pendingDollar = false
current.WriteByte(c)
}
}
doneTokenizing:
flush()
// Handle variable assignment on token[0]: FOO=bar
if len(rawTokens) > 0 {
tok := rawTokens[0]
clean := strings.TrimPrefix(tok, "\x00q")
if eqIdx := strings.Index(clean, "="); eqIdx > 0 {
name := clean[:eqIdx]
if isValidIdentifier(name) && !strings.Contains(clean[:eqIdx], "$") {
value := strings.ReplaceAll(s.expandWord(clean[eqIdx+1:]), "\x01", " ")
s.vars[name] = value
os.Setenv(name, value)
rawTokens = rawTokens[1:]
if len(rawTokens) == 0 {
return nil
}
}
}
}
var result []string
for _, tok := range rawTokens {
quoted := strings.HasPrefix(tok, "\x00q")
if quoted {
tok = tok[2:]
}
expanded := s.expandWord(tok)
// Handle multi-word expansion from $@ and ${arr[@]}
if strings.Contains(expanded, "\x01") {
parts := strings.Split(expanded, "\x01")
for _, p := range parts {
if p != "" {
result = append(result, p)
}
}
continue
}
if !quoted && strings.ContainsAny(expanded, "*?[") {
result = append(result, s.expandGlob(expanded)...)
} else {
result = append(result, expanded)
}
}
return result
}