- 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>
449 lines
9.8 KiB
Go
449 lines
9.8 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 {
|
|
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
|
|
}
|