459 lines
10 KiB
Go
459 lines
10 KiB
Go
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] == '|' {
|
|
// || operator — pass both chars through
|
|
current.WriteByte(c)
|
|
current.WriteByte(input[i+1])
|
|
i++
|
|
} 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
|
|
}
|
|
|
|
// parseArrayAssign detects NAME=(...) or NAME+=(...) at start of input.
|
|
func (s *Shell) parseArrayAssign(input string) (name string, appendMode bool, elements []string, ok bool) {
|
|
input = strings.TrimSpace(input)
|
|
// Read identifier
|
|
i := 0
|
|
for i < len(input) && isVarChar(input[i]) {
|
|
i++
|
|
}
|
|
if i == 0 {
|
|
return
|
|
}
|
|
name = input[:i]
|
|
if !isValidIdentifier(name) {
|
|
return
|
|
}
|
|
// Optional +
|
|
if i < len(input) && input[i] == '+' {
|
|
appendMode = true
|
|
i++
|
|
}
|
|
// Require =
|
|
if i >= len(input) || input[i] != '=' {
|
|
return
|
|
}
|
|
i++
|
|
// Require (
|
|
if i >= len(input) || input[i] != '(' {
|
|
return
|
|
}
|
|
i++
|
|
// Find matching )
|
|
depth := 1
|
|
j := i
|
|
inSingle := false
|
|
inDouble := false
|
|
for j < len(input) && depth > 0 {
|
|
c := input[j]
|
|
switch {
|
|
case c == '\'' && !inDouble:
|
|
inSingle = !inSingle
|
|
case c == '"' && !inSingle:
|
|
inDouble = !inDouble
|
|
case c == '(' && !inSingle && !inDouble:
|
|
depth++
|
|
case c == ')' && !inSingle && !inDouble:
|
|
depth--
|
|
}
|
|
if depth > 0 {
|
|
j++
|
|
}
|
|
}
|
|
content := input[i:j]
|
|
elements = s.tokenize(content)
|
|
ok = true
|
|
return
|
|
}
|
|
|
|
// executeCommand executes a single command (no pipes, no &&/||).
|
|
func (s *Shell) executeCommand(input string) error {
|
|
input = strings.TrimSpace(input)
|
|
if input == "" {
|
|
return nil
|
|
}
|
|
|
|
// Detect array assignment NAME=(...) or NAME+=(...)
|
|
if name, appendMode, elems, ok := s.parseArrayAssign(input); ok {
|
|
if appendMode {
|
|
s.appendArray(name, elems)
|
|
} else {
|
|
s.setArray(name, elems)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
tokens := s.tokenize(input)
|
|
if len(tokens) == 0 {
|
|
return nil
|
|
}
|
|
|
|
cmdArgs, redirects := s.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 (s *Shell) 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) — also handle process substitution <(...)
|
|
case tok == "<":
|
|
if i+1 < len(tokens) {
|
|
next := tokens[i+1]
|
|
// Process substitution: < <(cmd)
|
|
if strings.HasPrefix(next, "<(") && strings.HasSuffix(next, ")") {
|
|
cmd := next[2 : len(next)-1]
|
|
tmpf, err := os.CreateTemp("", "procsub*")
|
|
if err == nil {
|
|
s.withIO(nil, tmpf, nil, func() error { return s.Execute(cmd) })
|
|
tmpf.Close()
|
|
redirects = append(redirects, redirect{0, "<", tmpf.Name()})
|
|
}
|
|
i += 2
|
|
} else {
|
|
redirects = append(redirects, redirect{0, "<", next})
|
|
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, _ := s.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
|
|
}
|