Files
zzg 2fe43c74ac
ci / test (push) Canceled after 0s
initial commit
2026-09-25 22:02:49 -07:00

415 lines
9.8 KiB
Go

// Package shell executes parsed commands: a user interface for access to an
// operating system's services.
package shell
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"github.com/zg/sh/parser"
)
// Shell holds the state a shell needs between commands.
type Shell struct {
Dir string // working directory, kept per-shell rather than per-process
LastStatus int
Stdin io.Reader
Stdout io.Writer
Stderr io.Writer
mu sync.RWMutex
vars map[string]string
// exit is set by the exit builtin; the REPL stops when it is true.
exit bool
exitCode int
jobs []*Job
jobsMu sync.Mutex
}
// Job is a background pipeline.
type Job struct {
ID int
Cmd string
Proc *os.Process
Done bool
}
// New returns a shell with the process's environment and working directory.
func New() *Shell {
dir, err := os.Getwd()
if err != nil {
dir = "/"
}
s := &Shell{
Dir: dir,
Stdin: os.Stdin,
Stdout: os.Stdout,
Stderr: os.Stderr,
vars: map[string]string{},
}
for _, kv := range os.Environ() {
if k, v, ok := strings.Cut(kv, "="); ok {
s.vars[k] = v
}
}
return s
}
// Getenv reads a variable.
func (s *Shell) Getenv(name string) string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.vars[name]
}
// Setenv sets a variable.
func (s *Shell) Setenv(name, value string) {
s.mu.Lock()
defer s.mu.Unlock()
s.vars[name] = value
}
// Unsetenv removes a variable.
func (s *Shell) Unsetenv(name string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.vars, name)
}
// Environ returns the variables as KEY=value, sorted for determinism.
func (s *Shell) Environ() []string {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]string, 0, len(s.vars))
for k, v := range s.vars {
out = append(out, k+"="+v)
}
sort.Strings(out)
return out
}
func (s *Shell) variable(name string) string { return s.Getenv(name) }
// ShouldExit reports whether the exit builtin has run, and with what code.
func (s *Shell) ShouldExit() (bool, int) { return s.exit, s.exitCode }
// Run parses and executes a line, returning the final exit status.
func (s *Shell) Run(ctx context.Context, input string) int {
list, err := parser.Parse(input)
if err != nil {
fmt.Fprintln(s.Stderr, "sh:", err)
s.LastStatus = 2
return 2
}
return s.RunList(ctx, list)
}
// RunList executes a parsed list, honouring && and || short-circuiting.
func (s *Shell) RunList(ctx context.Context, list *parser.List) int {
status := 0
for i := 0; i < len(list.Items); i++ {
item := list.Items[i]
status = s.runPipeline(ctx, item.Pipeline)
s.LastStatus = status
if s.exit {
return status
}
// && runs the next only on success; || only on failure. Skipping
// must respect chains, so skip until an item whose predecessor's
// operator admits the current status.
switch item.Next {
case parser.OpAnd:
if status != 0 {
i = skipChain(list, i, parser.OpAnd)
}
case parser.OpOr:
if status == 0 {
i = skipChain(list, i, parser.OpOr)
}
}
}
return status
}
// skipChain advances past the items that the given operator short-circuits.
func skipChain(list *parser.List, i int, op parser.Operator) int {
for i < len(list.Items)-1 && list.Items[i].Next == op {
i++
}
return i
}
func (s *Shell) runPipeline(ctx context.Context, p *parser.Pipeline) int {
if len(p.Commands) == 0 {
return 0
}
if len(p.Commands) == 1 {
return s.runSingle(ctx, p.Commands[0], p.Background)
}
return s.runPiped(ctx, p)
}
// runSingle executes one command, which may be a builtin.
func (s *Shell) runSingle(ctx context.Context, c *parser.Command, background bool) int {
argv := s.expandCommand(c)
// A command that is only assignments sets shell variables.
if len(argv) == 0 {
for _, a := range c.Assigns {
if k, v, ok := strings.Cut(a, "="); ok {
s.Setenv(k, s.expandVariables(v))
}
}
return 0
}
stdin, stdout, stderr, cleanup, err := s.applyRedirects(c)
if err != nil {
fmt.Fprintln(s.Stderr, "sh:", err)
return 1
}
defer cleanup()
if fn, ok := builtins[argv[0]]; ok {
return fn(s, argv, stdin, stdout, stderr)
}
return s.runExternal(ctx, c, argv, stdin, stdout, stderr, background)
}
func (s *Shell) expandCommand(c *parser.Command) []string {
var argv []string
for _, w := range c.Words {
argv = append(argv, s.expandWord(w)...)
}
return argv
}
// applyRedirects opens the files a command redirects to, returning the
// streams to use and a cleanup function.
func (s *Shell) applyRedirects(c *parser.Command) (io.Reader, io.Writer, io.Writer, func(), error) {
stdin, stdout, stderr := s.Stdin, s.Stdout, s.Stderr
var toClose []io.Closer
cleanup := func() {
for _, f := range toClose {
f.Close()
}
}
for _, r := range c.Redirects {
// The filename is itself subject to expansion.
names := s.expandWord(parser.Word{Value: r.File, Quoted: parser.Unquoted})
if len(names) != 1 {
cleanup()
return nil, nil, nil, func() {}, fmt.Errorf("ambiguous redirect: %s", r.File)
}
path := s.resolve(names[0])
switch r.Type {
case parser.TokenRedirIn:
f, err := os.Open(path)
if err != nil {
cleanup()
return nil, nil, nil, func() {}, err
}
toClose = append(toClose, f)
stdin = f
case parser.TokenRedirOut, parser.TokenRedirErr:
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
cleanup()
return nil, nil, nil, func() {}, err
}
toClose = append(toClose, f)
if r.Type == parser.TokenRedirOut {
stdout = f
} else {
stderr = f
}
case parser.TokenRedirAppend, parser.TokenRedirErrApp:
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644)
if err != nil {
cleanup()
return nil, nil, nil, func() {}, err
}
toClose = append(toClose, f)
if r.Type == parser.TokenRedirAppend {
stdout = f
} else {
stderr = f
}
}
}
return stdin, stdout, stderr, cleanup, nil
}
// resolve makes a path absolute relative to the shell's directory, which is
// not necessarily the process's.
func (s *Shell) resolve(path string) string {
if filepath.IsAbs(path) {
return path
}
return filepath.Join(s.Dir, path)
}
func (s *Shell) runExternal(ctx context.Context, c *parser.Command, argv []string,
stdin io.Reader, stdout, stderr io.Writer, background bool) int {
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
cmd.Dir = s.Dir
cmd.Stdin = stdin
cmd.Stdout = stdout
cmd.Stderr = stderr
// Assignments preceding a command apply to that command only, not to
// the shell.
env := s.Environ()
for _, a := range c.Assigns {
if k, v, ok := strings.Cut(a, "="); ok {
env = append(env, k+"="+s.expandVariables(v))
}
}
cmd.Env = env
if background {
if err := cmd.Start(); err != nil {
fmt.Fprintf(s.Stderr, "sh: %s: %v\n", argv[0], err)
return 127
}
job := s.addJob(strings.Join(argv, " "), cmd.Process)
fmt.Fprintf(s.Stderr, "[%d] %d\n", job.ID, cmd.Process.Pid)
// Reap asynchronously so a finished background job does not linger
// as a zombie.
go func() {
cmd.Wait()
s.markDone(job)
}()
return 0
}
if err := cmd.Run(); err != nil {
return exitStatus(err, s.Stderr, argv[0])
}
return 0
}
// runPiped wires a pipeline together and runs every stage concurrently.
func (s *Shell) runPiped(ctx context.Context, p *parser.Pipeline) int {
n := len(p.Commands)
// Each stage's stdout is the next stage's stdin.
readers := make([]io.Reader, n)
writers := make([]io.Writer, n)
var closers []io.Closer
readers[0] = s.Stdin
writers[n-1] = s.Stdout
for i := range n - 1 {
pr, pw := io.Pipe()
writers[i] = pw
readers[i+1] = pr
closers = append(closers, pw, pr)
}
statuses := make([]int, n)
var wg sync.WaitGroup
for i, c := range p.Commands {
wg.Add(1)
go func(i int, c *parser.Command) {
defer wg.Done()
// Closing this stage's write end is what signals EOF to the
// next stage; without it the pipeline deadlocks.
if pw, ok := writers[i].(*io.PipeWriter); ok {
defer pw.Close()
}
sub := s.forStage(readers[i], writers[i])
statuses[i] = sub.runSingle(ctx, c, false)
}(i, c)
}
wg.Wait()
for _, c := range closers {
c.Close()
}
// A pipeline's status is its last command's, as in POSIX shells.
return statuses[n-1]
}
// forStage returns a shallow shell sharing variables but with its own
// streams, for one stage of a pipeline.
func (s *Shell) forStage(stdin io.Reader, stdout io.Writer) *Shell {
sub := &Shell{
Dir: s.Dir,
LastStatus: s.LastStatus,
Stdin: stdin,
Stdout: stdout,
Stderr: s.Stderr,
vars: s.vars, // shared map: a pipeline stage sees the same variables
}
return sub
}
func exitStatus(err error, stderr io.Writer, name string) int {
var ee *exec.ExitError
if errors.As(err, &ee) {
return ee.ExitCode()
}
if errors.Is(err, exec.ErrNotFound) || os.IsNotExist(err) {
fmt.Fprintf(stderr, "sh: %s: command not found\n", name)
return 127
}
fmt.Fprintf(stderr, "sh: %s: %v\n", name, err)
return 126
}
func (s *Shell) addJob(cmd string, proc *os.Process) *Job {
s.jobsMu.Lock()
defer s.jobsMu.Unlock()
job := &Job{ID: len(s.jobs) + 1, Cmd: cmd, Proc: proc}
s.jobs = append(s.jobs, job)
return job
}
func (s *Shell) markDone(job *Job) {
s.jobsMu.Lock()
defer s.jobsMu.Unlock()
job.Done = true
}
// Jobs returns a snapshot of background jobs.
func (s *Shell) Jobs() []Job {
s.jobsMu.Lock()
defer s.jobsMu.Unlock()
out := make([]Job, 0, len(s.jobs))
for _, j := range s.jobs {
out = append(out, *j)
}
return out
}
// Prompt returns the interactive prompt, showing the working directory with
// the home directory abbreviated to ~.
func (s *Shell) Prompt() string {
dir := s.Dir
if home := s.Getenv("HOME"); home != "" && strings.HasPrefix(dir, home) {
dir = "~" + dir[len(home):]
}
status := ""
if s.LastStatus != 0 {
status = fmt.Sprintf("[%d] ", s.LastStatus)
}
return fmt.Sprintf("%s%s $ ", status, dir)
}