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) // Process substitution <(...): don't split | inside. case c == '<' && !inSingle && !inDouble && i+1 < len(input) && input[i+1] == '(': parenDepth++ current.WriteByte('<') current.WriteByte('(') i++ 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 } // { cmd; cmd; } command group if strings.HasPrefix(input, "{") { end := strings.LastIndex(input, "}") if end > 0 { inner := strings.TrimSpace(input[1:end]) return s.Execute(inner) } } // 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++ // <<< here-string case tok == "<<<": if i+1 < len(tokens) { val := tokens[i+1] + "\n" tmpf, err := os.CreateTemp("", "herestr*") if err == nil { tmpf.WriteString(val) tmpf.Close() redirects = append(redirects, redirect{0, "<", tmpf.Name()}) } i += 2 } else { 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 i, r := range redirects { if r.dest == "/dev/null" { redirects[i].dest = os.DevNull r = redirects[i] } 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} } // If the file is a bash/sh script, run it through our own interpreter. // This avoids the CRLF shebang problem (#!/usr/bin/env bash\r) and lets // us execute scripts that have no .exe extension on Windows. if data, err := os.ReadFile(path); err == nil && isShellScript(data) { sh := New() sh.Stdin = s.Stdin sh.Stdout = s.Stdout sh.Stderr = s.Stderr sh.SetArgs(args) sh.SetVar("0", path) return sh.Execute(string(data)) } 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 } // isShellScript returns true when data begins with a #!/…bash or #!/…sh shebang. func isShellScript(data []byte) bool { if len(data) < 2 || data[0] != '#' || data[1] != '!' { return false } nl := bytes.IndexByte(data, '\n') if nl < 0 { nl = len(data) } line := strings.TrimRight(string(data[:nl]), "\r") for _, word := range strings.Fields(line[2:]) { base := filepath.Base(word) if base == "bash" || base == "sh" { return true } } return false } 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 }