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

375
internal/shell/exec.go Normal file
View File

@@ -0,0 +1,375 @@
package shell
import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)
type redirect struct {
fd int // 0=stdin 1=stdout 2=stderr -1=both stdout+stderr
mode string // ">" ">>" "<"
dest string // filename or "&1" "&2"
}
// executePipeline handles background jobs (&) and pipelines (|).
func (s *Shell) executePipeline(input string) error {
input = strings.TrimSpace(input)
if input == "" {
return nil
}
// Background job: trailing & (not &&)
bg := false
if strings.HasSuffix(input, "&") && !strings.HasSuffix(input, "&&") {
bg = true
input = strings.TrimSuffix(strings.TrimSuffix(input, "&"), " ")
}
parts := splitPipe(input)
if len(parts) == 1 {
if bg {
return s.executeCommandBg(strings.TrimSpace(parts[0]))
}
return s.executeCommand(strings.TrimSpace(parts[0]))
}
return s.doPipe(parts, bg)
}
// splitPipe splits by | but not ||, respecting quotes.
func splitPipe(input string) []string {
var parts []string
current := strings.Builder{}
inSingle := false
inDouble := false
parenDepth := 0
pendingDollar := false
for i := 0; i < len(input); i++ {
c := input[i]
switch {
case c == '\'' && !inDouble && parenDepth == 0:
inSingle = !inSingle
current.WriteByte(c)
case c == '"' && !inSingle && parenDepth == 0:
inDouble = !inDouble
current.WriteByte(c)
case c == '$' && !inSingle && !inDouble && i+1 < len(input) && 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:
if i+1 < len(input) && input[i+1] == '|' {
current.WriteByte(c) // part of ||, pass through
} else {
parts = append(parts, strings.TrimSpace(current.String()))
current.Reset()
pendingDollar = false
}
default:
pendingDollar = false
current.WriteByte(c)
}
}
if current.Len() > 0 {
parts = append(parts, strings.TrimSpace(current.String()))
}
return parts
}
// executeCommand executes a single command (no pipes, no &&/||).
func (s *Shell) executeCommand(input string) error {
input = strings.TrimSpace(input)
if input == "" {
return nil
}
tokens := s.tokenize(input)
if len(tokens) == 0 {
return nil
}
cmdArgs, redirects := extractRedirects(tokens)
if len(cmdArgs) == 0 {
// Pure redirection, e.g. "> file" creates/truncates file
return s.withRedirects(redirects, func() error { return nil })
}
cmdName := cmdArgs[0]
args := cmdArgs[1:]
// Alias expansion
if alias, ok := aliases[cmdName]; ok {
full := alias
if len(args) > 0 {
full += " " + strings.Join(args, " ")
}
return s.withRedirects(redirects, func() error {
return s.Execute(full)
})
}
// Builtin
if builtin, ok := s.builtins[cmdName]; ok {
return s.withRedirects(redirects, func() error {
return builtin(args)
})
}
// External
return s.withRedirects(redirects, func() error {
return s.executeExternal(cmdName, args)
})
}
func extractRedirects(tokens []string) ([]string, []redirect) {
var args []string
var redirects []redirect
i := 0
for i < len(tokens) {
tok := tokens[i]
switch {
// 2>&1
case tok == "2>&1":
redirects = append(redirects, redirect{2, ">", "&1"})
i++
// 1>&2
case tok == "1>&2":
redirects = append(redirects, redirect{1, ">", "&2"})
i++
// &> or &>> (both stdout+stderr)
case tok == "&>" || tok == "&>>":
if i+1 < len(tokens) {
redirects = append(redirects, redirect{-1, strings.TrimPrefix(tok, "&"), tokens[i+1]})
i += 2
} else {
i++
}
case strings.HasPrefix(tok, "&>"):
mode := ">"
dest := tok[2:]
if strings.HasPrefix(dest, ">") {
mode = ">>"
dest = dest[1:]
}
redirects = append(redirects, redirect{-1, mode, dest})
i++
// 2> 2>> 2>file
case tok == "2>" || tok == "2>>":
if i+1 < len(tokens) {
redirects = append(redirects, redirect{2, tok[1:], tokens[i+1]})
i += 2
} else {
i++
}
case strings.HasPrefix(tok, "2>>"):
redirects = append(redirects, redirect{2, ">>", tok[3:]})
i++
case strings.HasPrefix(tok, "2>"):
redirects = append(redirects, redirect{2, ">", tok[2:]})
i++
// > >>
case tok == ">" || tok == ">>":
if i+1 < len(tokens) {
redirects = append(redirects, redirect{1, tok, tokens[i+1]})
i += 2
} else {
i++
}
case strings.HasPrefix(tok, ">>") && len(tok) > 2:
redirects = append(redirects, redirect{1, ">>", tok[2:]})
i++
case strings.HasPrefix(tok, ">") && len(tok) > 1:
redirects = append(redirects, redirect{1, ">", tok[1:]})
i++
// < (stdin)
case tok == "<":
if i+1 < len(tokens) {
redirects = append(redirects, redirect{0, "<", tokens[i+1]})
i += 2
} else {
i++
}
case strings.HasPrefix(tok, "<") && len(tok) > 1 && tok[1] != '<':
redirects = append(redirects, redirect{0, "<", tok[1:]})
i++
default:
args = append(args, tok)
i++
}
}
return args, redirects
}
func (s *Shell) withRedirects(redirects []redirect, fn func() error) error {
if len(redirects) == 0 {
return fn()
}
oldIn, oldOut, oldErr := s.Stdin, s.Stdout, s.Stderr
var toClose []io.Closer
defer func() {
s.Stdin, s.Stdout, s.Stderr = oldIn, oldOut, oldErr
for _, c := range toClose {
c.Close()
}
}()
for _, r := range redirects {
switch r.mode {
case ">":
if r.dest == "&1" {
s.Stderr = s.Stdout
} else if r.dest == "&2" {
s.Stdout = s.Stderr
} else {
f, err := os.Create(r.dest)
if err != nil {
return fmt.Errorf("cannot open %s: %v", r.dest, err)
}
toClose = append(toClose, f)
if r.fd == 1 || r.fd == -1 {
s.Stdout = f
}
if r.fd == 2 || r.fd == -1 {
s.Stderr = f
}
}
case ">>":
f, err := os.OpenFile(r.dest, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("cannot open %s: %v", r.dest, err)
}
toClose = append(toClose, f)
if r.fd == 1 || r.fd == -1 {
s.Stdout = f
}
if r.fd == 2 || r.fd == -1 {
s.Stderr = f
}
case "<":
f, err := os.Open(r.dest)
if err != nil {
return fmt.Errorf("cannot open %s: %v", r.dest, err)
}
toClose = append(toClose, f)
s.Stdin = f
}
}
return fn()
}
func (s *Shell) executeExternal(cmdName string, args []string) error {
path := findExecutable(cmdName)
if path == "" {
fmt.Fprintf(s.Stderr, "%s: command not found\n", cmdName)
return exitCodeErr{127}
}
cmd := exec.Command(path, args...)
cmd.Stdin = s.Stdin
cmd.Stdout = s.Stdout
cmd.Stderr = s.Stderr
err := cmd.Run()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return exitCodeErr{exitErr.ExitCode()}
}
}
return err
}
func (s *Shell) executeCommandBg(input string) error {
tokens := s.tokenize(input)
if len(tokens) == 0 {
return nil
}
cmdArgs, _ := extractRedirects(tokens)
if len(cmdArgs) == 0 {
return nil
}
path := findExecutable(cmdArgs[0])
if path == "" {
return fmt.Errorf("%s: command not found", cmdArgs[0])
}
cmd := exec.Command(path, cmdArgs[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return err
}
pid := cmd.Process.Pid
s.vars["!"] = fmt.Sprintf("%d", pid)
fmt.Fprintf(s.Stderr, "[1] %d\n", pid)
go func() { cmd.Wait() }()
return nil
}
func findExecutable(name string) string {
// Direct path
if strings.ContainsAny(name, "/\\") {
if info, err := os.Stat(name); err == nil && !info.IsDir() {
abs, _ := filepath.Abs(name)
return abs
}
return ""
}
path := os.Getenv("PATH")
for _, dir := range filepath.SplitList(path) {
for _, candidate := range []string{
filepath.Join(dir, name),
filepath.Join(dir, name+".exe"),
filepath.Join(dir, name+".cmd"),
filepath.Join(dir, name+".bat"),
} {
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate
}
}
}
return ""
}
// doPipe executes a pipeline where each stage feeds into the next.
func (s *Shell) doPipe(commands []string, bg bool) error {
_ = bg // background pipe support would require goroutines; skip for now
var prevBuf []byte
for i, cmd := range commands {
isLast := i == len(commands)-1
var stdinReader io.Reader
if i == 0 {
stdinReader = s.Stdin
} else {
stdinReader = bytes.NewReader(prevBuf)
}
if isLast {
return s.withIO(stdinReader, nil, nil, func() error {
return s.executeCommand(cmd)
})
}
var buf bytes.Buffer
s.withIO(stdinReader, &buf, nil, func() error {
return s.executeCommand(cmd)
})
prevBuf = buf.Bytes()
}
return nil
}