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:
375
internal/shell/exec.go
Normal file
375
internal/shell/exec.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user