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 { result.WriteByte(next) i += 2 } } else { i++ } case ch == '$' && !inSingle: i++ // skip $ if i >= len(word) { result.WriteByte('$') break } 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(s.vars["@"]) 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 { if v, ok := s.vars[name]; ok { return v } return os.Getenv(name) } func (s *Shell) evalVarExpr(expr string) string { // ${#VAR} — string length if strings.HasPrefix(expr, "#") { return strconv.Itoa(len(s.getVar(expr[1:]))) } // ${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]) } // 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 } 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 := s.expandWord(clean[eqIdx+1:]) 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) if !quoted && strings.ContainsAny(expanded, "*?[") { result = append(result, s.expandGlob(expanded)...) } else { result = append(result, expanded) } } return result }