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:
Cametendo
2026-05-26 12:50:06 +02:00
parent eba49c46bc
commit 8c6a2ab4c2
8 changed files with 3669 additions and 869 deletions

448
internal/shell/expand.go Normal file
View 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
}