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 }