Add control flow, I/O redirection, functions, coreutils, history/completion
- I/O redirection: >, >>, <, 2>, 2>&1, &>
- Job control: background & operator
- if/elif/else/fi, for/do/done, while/until loops
- Shell functions with local/declare, positional param save/restore
- Exit code propagation via exitCodeErr sentinel
- Arithmetic expansion $((expr)) with bare variable names
- Command substitution $(cmd) with pipeline support
- Glob expansion, tilde expansion, ${VAR:-default} and other forms
- Tab completion and command history via chzyer/readline
- Inline comment stripping (# outside quotes)
- Builtins: test/[, read, printf, tr, sed, cut, tail, tee, xargs,
basename, dirname, date, sleep, uniq, sort, wc, head, grep, find,
true, false, break, continue, return, shift, set, unset, export,
declare/local, source, alias, jobs, command, which, env
- Bug fixes: tokenizer parenDepth double-count for $((,
splitPipe not paren-aware (broke pipelines in $()),
local/declare TrimLeft stripping valid var name chars,
parseBlocks missing nested keywords after do/then/else
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
448
internal/shell/expand.go
Normal file
448
internal/shell/expand.go
Normal file
@@ -0,0 +1,448 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user