runner.go

   1// Copyright (c) 2017, Daniel MartΓ­ <mvdan@mvdan.cc>
   2// See LICENSE for licensing information
   3
   4package interp
   5
   6import (
   7	"bytes"
   8	"context"
   9	"errors"
  10	"fmt"
  11	"io"
  12	"io/fs"
  13	"math"
  14	"math/rand"
  15	"os"
  16	"path/filepath"
  17	"regexp"
  18	"runtime"
  19	"strconv"
  20	"strings"
  21	"sync"
  22	"time"
  23
  24	"mvdan.cc/sh/v3/expand"
  25	"mvdan.cc/sh/v3/pattern"
  26	"mvdan.cc/sh/v3/syntax"
  27)
  28
  29const (
  30	// shellReplyPS3Var, or PS3, is a special variable in Bash used by the select command,
  31	// while the shell is awaiting for input. the default value is [shellDefaultPS3]
  32	shellReplyPS3Var = "PS3"
  33	// shellDefaultPS3, or #?, is PS3's default value
  34	shellDefaultPS3 = "#? "
  35	// shellReplyVar, or REPLY, is a special variable in Bash that is used to store the result of
  36	// the select command or of the read command, when no variable name is specified
  37	shellReplyVar = "REPLY"
  38
  39	fifoNamePrefix = "sh-interp-"
  40)
  41
  42func (r *Runner) fillExpandConfig(ctx context.Context) {
  43	r.ectx = ctx
  44	r.ecfg = &expand.Config{
  45		Env: expandEnv{r},
  46		CmdSubst: func(w io.Writer, cs *syntax.CmdSubst) error {
  47			switch len(cs.Stmts) {
  48			case 0: // nothing to do
  49				return nil
  50			case 1: // $(<file)
  51				word := catShortcutArg(cs.Stmts[0])
  52				if word == nil {
  53					break
  54				}
  55				path := r.literal(word)
  56				f, err := r.open(ctx, path, os.O_RDONLY, 0, true)
  57				if err != nil {
  58					return err
  59				}
  60				_, err = io.Copy(w, f)
  61				f.Close()
  62				return err
  63			}
  64			r2 := r.Subshell()
  65			r2.stdout = w
  66			r2.stmts(ctx, cs.Stmts)
  67			r.lastExpandExit = r2.exit
  68			return r2.err
  69		},
  70		ProcSubst: func(ps *syntax.ProcSubst) (string, error) {
  71			if runtime.GOOS == "windows" {
  72				return "", fmt.Errorf("TODO: support process substitution on Windows")
  73			}
  74			if len(ps.Stmts) == 0 { // nothing to do
  75				return os.DevNull, nil
  76			}
  77
  78			if r.rand == nil {
  79				r.rand = rand.New(rand.NewSource(time.Now().UnixNano()))
  80			}
  81
  82			// We can't atomically create a random unused temporary FIFO.
  83			// Similar to [os.CreateTemp],
  84			// keep trying new random paths until one does not exist.
  85			// We use a uint64 because a uint32 easily runs into retries.
  86			var path string
  87			try := 0
  88			for {
  89				path = filepath.Join(r.tempDir, fifoNamePrefix+strconv.FormatUint(r.rand.Uint64(), 16))
  90				err := mkfifo(path, 0o666)
  91				if err == nil {
  92					break
  93				}
  94				if !os.IsExist(err) {
  95					return "", fmt.Errorf("cannot create fifo: %v", err)
  96				}
  97				if try++; try > 100 {
  98					return "", fmt.Errorf("giving up at creating fifo: %v", err)
  99				}
 100			}
 101
 102			r2 := r.Subshell()
 103			stdout := r.origStdout
 104			// TODO: note that `man bash` mentions that `wait` only waits for the last
 105			// process substitution; the logic here would mean we wait for all of them.
 106			r.bgShells.Add(1)
 107			go func() {
 108				defer r.bgShells.Done()
 109				switch ps.Op {
 110				case syntax.CmdIn:
 111					f, err := os.OpenFile(path, os.O_WRONLY, 0)
 112					if err != nil {
 113						r.errf("cannot open fifo for stdout: %v\n", err)
 114						return
 115					}
 116					r2.stdout = f
 117					defer func() {
 118						if err := f.Close(); err != nil {
 119							r.errf("closing stdout fifo: %v\n", err)
 120						}
 121						os.Remove(path)
 122					}()
 123				default: // syntax.CmdOut
 124					f, err := os.OpenFile(path, os.O_RDONLY, 0)
 125					if err != nil {
 126						r.errf("cannot open fifo for stdin: %v\n", err)
 127						return
 128					}
 129					r2.stdin = f
 130					r2.stdout = stdout
 131
 132					defer func() {
 133						f.Close()
 134						os.Remove(path)
 135					}()
 136				}
 137				r2.stmts(ctx, ps.Stmts)
 138			}()
 139			return path, nil
 140		},
 141	}
 142	r.updateExpandOpts()
 143}
 144
 145// catShortcutArg checks if a statement is of the form "$(<file)". The redirect
 146// word is returned if there's a match, and nil otherwise.
 147func catShortcutArg(stmt *syntax.Stmt) *syntax.Word {
 148	if stmt.Cmd != nil || stmt.Negated || stmt.Background || stmt.Coprocess {
 149		return nil
 150	}
 151	if len(stmt.Redirs) != 1 {
 152		return nil
 153	}
 154	redir := stmt.Redirs[0]
 155	if redir.Op != syntax.RdrIn {
 156		return nil
 157	}
 158	return redir.Word
 159}
 160
 161func (r *Runner) updateExpandOpts() {
 162	if r.opts[optNoGlob] {
 163		r.ecfg.ReadDir2 = nil
 164	} else {
 165		r.ecfg.ReadDir2 = func(s string) ([]fs.DirEntry, error) {
 166			return r.readDirHandler(r.handlerCtx(context.Background()), s)
 167		}
 168	}
 169	r.ecfg.GlobStar = r.opts[optGlobStar]
 170	r.ecfg.NoCaseGlob = r.opts[optNoCaseGlob]
 171	r.ecfg.NullGlob = r.opts[optNullGlob]
 172	r.ecfg.NoUnset = r.opts[optNoUnset]
 173}
 174
 175func (r *Runner) expandErr(err error) {
 176	if err != nil {
 177		errMsg := err.Error()
 178		fmt.Fprintln(r.stderr, errMsg)
 179		switch {
 180		case errors.As(err, &expand.UnsetParameterError{}):
 181		case errMsg == "invalid indirect expansion":
 182			// TODO: These errors are treated as fatal by bash.
 183			// Make the error type reflect that.
 184		case strings.HasSuffix(errMsg, "not supported"):
 185			// TODO: This "has suffix" is a temporary measure until the expand
 186			// package supports all syntax nodes like extended globbing.
 187		default:
 188			return // other cases do not exit
 189		}
 190		r.exitShell(context.TODO(), 1)
 191	}
 192}
 193
 194func (r *Runner) arithm(expr syntax.ArithmExpr) int {
 195	n, err := expand.Arithm(r.ecfg, expr)
 196	r.expandErr(err)
 197	return n
 198}
 199
 200func (r *Runner) fields(words ...*syntax.Word) []string {
 201	strs, err := expand.Fields(r.ecfg, words...)
 202	r.expandErr(err)
 203	return strs
 204}
 205
 206func (r *Runner) literal(word *syntax.Word) string {
 207	str, err := expand.Literal(r.ecfg, word)
 208	r.expandErr(err)
 209	return str
 210}
 211
 212func (r *Runner) document(word *syntax.Word) string {
 213	str, err := expand.Document(r.ecfg, word)
 214	r.expandErr(err)
 215	return str
 216}
 217
 218func (r *Runner) pattern(word *syntax.Word) string {
 219	str, err := expand.Pattern(r.ecfg, word)
 220	r.expandErr(err)
 221	return str
 222}
 223
 224// expandEnviron exposes [Runner]'s variables to the expand package.
 225type expandEnv struct {
 226	r *Runner
 227}
 228
 229var _ expand.WriteEnviron = expandEnv{}
 230
 231func (e expandEnv) Get(name string) expand.Variable {
 232	return e.r.lookupVar(name)
 233}
 234
 235func (e expandEnv) Set(name string, vr expand.Variable) error {
 236	e.r.setVar(name, vr)
 237	return nil // TODO: return any errors
 238}
 239
 240func (e expandEnv) Each(fn func(name string, vr expand.Variable) bool) {
 241	e.r.writeEnv.Each(fn)
 242}
 243
 244func (r *Runner) handlerCtx(ctx context.Context) context.Context {
 245	hc := HandlerContext{
 246		Env:    &overlayEnviron{parent: r.writeEnv},
 247		Dir:    r.Dir,
 248		Stdout: r.stdout,
 249		Stderr: r.stderr,
 250	}
 251	if r.stdin != nil { // do not leave hc.Stdin as a typed nil
 252		hc.Stdin = r.stdin
 253	}
 254	return context.WithValue(ctx, handlerCtxKey{}, hc)
 255}
 256
 257func (r *Runner) setErr(err error) {
 258	if r.err == nil {
 259		r.err = err
 260	}
 261}
 262
 263func (r *Runner) out(s string) {
 264	io.WriteString(r.stdout, s)
 265}
 266
 267func (r *Runner) outf(format string, a ...any) {
 268	fmt.Fprintf(r.stdout, format, a...)
 269}
 270
 271func (r *Runner) errf(format string, a ...any) {
 272	fmt.Fprintf(r.stderr, format, a...)
 273}
 274
 275func (r *Runner) stop(ctx context.Context) bool {
 276	if r.err != nil || r.Exited() {
 277		return true
 278	}
 279	if err := ctx.Err(); err != nil {
 280		r.err = err
 281		return true
 282	}
 283	if r.opts[optNoExec] {
 284		return true
 285	}
 286	return false
 287}
 288
 289func (r *Runner) stmt(ctx context.Context, st *syntax.Stmt) {
 290	if r.stop(ctx) {
 291		return
 292	}
 293	r.exit = 0
 294	if st.Background {
 295		r2 := r.Subshell()
 296		st2 := *st
 297		st2.Background = false
 298		r.bgShells.Add(1)
 299		go func() {
 300			r2.Run(ctx, &st2)
 301			r.bgShells.Done()
 302		}()
 303	} else {
 304		r.stmtSync(ctx, st)
 305	}
 306	r.lastExit = r.exit
 307}
 308
 309func (r *Runner) stmtSync(ctx context.Context, st *syntax.Stmt) {
 310	oldIn, oldOut, oldErr := r.stdin, r.stdout, r.stderr
 311	for _, rd := range st.Redirs {
 312		cls, err := r.redir(ctx, rd)
 313		if err != nil {
 314			r.exit = 1
 315			break
 316		}
 317		if cls != nil {
 318			defer cls.Close()
 319		}
 320	}
 321	if r.exit == 0 && st.Cmd != nil {
 322		r.cmd(ctx, st.Cmd)
 323	}
 324	if st.Negated {
 325		r.exit = oneIf(r.exit == 0)
 326	} else if _, ok := st.Cmd.(*syntax.CallExpr); !ok {
 327	} else if r.exit != 0 && !r.noErrExit && r.opts[optErrExit] {
 328		// If the "errexit" option is set and a simple command failed,
 329		// exit the shell. Exceptions:
 330		//
 331		//   conditions (if <cond>, while <cond>, etc)
 332		//   part of && or || lists
 333		//   preceded by !
 334		r.exitShell(ctx, r.exit)
 335	} else if r.exit != 0 && !r.noErrExit {
 336		r.trapCallback(ctx, r.callbackErr, "error")
 337	}
 338	if !r.keepRedirs {
 339		r.stdin, r.stdout, r.stderr = oldIn, oldOut, oldErr
 340	}
 341}
 342
 343func (r *Runner) cmd(ctx context.Context, cm syntax.Command) {
 344	if r.stop(ctx) {
 345		return
 346	}
 347
 348	tracingEnabled := r.opts[optXTrace]
 349	trace := r.tracer()
 350
 351	switch cm := cm.(type) {
 352	case *syntax.Block:
 353		r.stmts(ctx, cm.Stmts)
 354	case *syntax.Subshell:
 355		r2 := r.Subshell()
 356		r2.stmts(ctx, cm.Stmts)
 357		r.exit = r2.exit
 358		r.setErr(r2.err)
 359	case *syntax.CallExpr:
 360		// Use a new slice, to not modify the slice in the alias map.
 361		var args []*syntax.Word
 362		left := cm.Args
 363		for len(left) > 0 && r.opts[optExpandAliases] {
 364			als, ok := r.alias[left[0].Lit()]
 365			if !ok {
 366				break
 367			}
 368			args = append(args, als.args...)
 369			left = left[1:]
 370			if !als.blank {
 371				break
 372			}
 373		}
 374		args = append(args, left...)
 375		r.lastExpandExit = 0
 376		fields := r.fields(args...)
 377		if len(fields) == 0 {
 378			for _, as := range cm.Assigns {
 379				prev := r.lookupVar(as.Name.Value)
 380				vr := r.assignVal(prev, as, "")
 381				r.setVarWithIndex(prev, as.Name.Value, as.Index, vr)
 382
 383				if !tracingEnabled {
 384					continue
 385				}
 386
 387				// Strangely enough, it seems like Bash prints original
 388				// source for arrays, but the expanded value otherwise.
 389				// TODO: add test cases for x[i]=y and x+=y.
 390				if as.Array != nil {
 391					trace.expr(as)
 392				} else if as.Value != nil {
 393					val, err := syntax.Quote(vr.String(), syntax.LangBash)
 394					if err != nil { // should never happen
 395						panic(err)
 396					}
 397					trace.stringf("%s=%s", as.Name.Value, val)
 398				}
 399				trace.newLineFlush()
 400			}
 401			// If interpreting the last expansion like $(foo) failed,
 402			// and the expansion and assignments otherwise succeeded,
 403			// we need to surface that last exit code.
 404			if r.exit == 0 {
 405				r.exit = r.lastExpandExit
 406			}
 407			break
 408		}
 409
 410		type restoreVar struct {
 411			name string
 412			vr   expand.Variable
 413		}
 414		var restores []restoreVar
 415
 416		for _, as := range cm.Assigns {
 417			name := as.Name.Value
 418			prev := r.lookupVar(name)
 419
 420			vr := r.assignVal(prev, as, "")
 421			// Inline command vars are always exported.
 422			vr.Exported = true
 423
 424			restores = append(restores, restoreVar{name, prev})
 425
 426			r.setVar(name, vr)
 427		}
 428
 429		trace.call(fields[0], fields[1:]...)
 430		trace.newLineFlush()
 431
 432		r.call(ctx, cm.Args[0].Pos(), fields)
 433		for _, restore := range restores {
 434			r.setVar(restore.name, restore.vr)
 435		}
 436	case *syntax.BinaryCmd:
 437		switch cm.Op {
 438		case syntax.AndStmt, syntax.OrStmt:
 439			oldNoErrExit := r.noErrExit
 440			r.noErrExit = true
 441			r.stmt(ctx, cm.X)
 442			r.noErrExit = oldNoErrExit
 443			if (r.exit == 0) == (cm.Op == syntax.AndStmt) {
 444				r.stmt(ctx, cm.Y)
 445			}
 446		case syntax.Pipe, syntax.PipeAll:
 447			pr, pw, err := os.Pipe()
 448			if err != nil {
 449				r.setErr(err)
 450				return
 451			}
 452			r2 := r.Subshell()
 453			r2.stdout = pw
 454			if cm.Op == syntax.PipeAll {
 455				r2.stderr = pw
 456			} else {
 457				r2.stderr = r.stderr
 458			}
 459			r.stdin = pr
 460			var wg sync.WaitGroup
 461			wg.Add(1)
 462			go func() {
 463				r2.stmt(ctx, cm.X)
 464				pw.Close()
 465				wg.Done()
 466			}()
 467			r.stmt(ctx, cm.Y)
 468			pr.Close()
 469			wg.Wait()
 470			if r.opts[optPipeFail] && r2.exit != 0 && r.exit == 0 {
 471				r.exit = r2.exit
 472				r.shellExited = r2.shellExited
 473			}
 474			r.setErr(r2.err)
 475		}
 476	case *syntax.IfClause:
 477		oldNoErrExit := r.noErrExit
 478		r.noErrExit = true
 479		r.stmts(ctx, cm.Cond)
 480		r.noErrExit = oldNoErrExit
 481
 482		if r.exit == 0 {
 483			r.stmts(ctx, cm.Then)
 484			break
 485		}
 486		r.exit = 0
 487		if cm.Else != nil {
 488			r.cmd(ctx, cm.Else)
 489		}
 490	case *syntax.WhileClause:
 491		for !r.stop(ctx) {
 492			oldNoErrExit := r.noErrExit
 493			r.noErrExit = true
 494			r.stmts(ctx, cm.Cond)
 495			r.noErrExit = oldNoErrExit
 496
 497			stop := (r.exit == 0) == cm.Until
 498			r.exit = 0
 499			if stop || r.loopStmtsBroken(ctx, cm.Do) {
 500				break
 501			}
 502		}
 503	case *syntax.ForClause:
 504		switch y := cm.Loop.(type) {
 505		case *syntax.WordIter:
 506			name := y.Name.Value
 507			items := r.Params // for i; do ...
 508
 509			inToken := y.InPos.IsValid()
 510			if inToken {
 511				items = r.fields(y.Items...) // for i in ...; do ...
 512			}
 513
 514			if cm.Select {
 515				ps3 := shellDefaultPS3
 516				if e := r.envGet(shellReplyPS3Var); e != "" {
 517					ps3 = e
 518				}
 519
 520				prompt := func() []byte {
 521					// display menu
 522					for i, word := range items {
 523						r.errf("%d) %v\n", i+1, word)
 524					}
 525					r.errf("%s", ps3)
 526
 527					line, err := r.readLine(ctx, true)
 528					if err != nil {
 529						r.exit = 1
 530						return nil
 531					}
 532					return line
 533				}
 534
 535			retry:
 536				choice := prompt()
 537				if len(choice) == 0 {
 538					goto retry // no reply; try again
 539				}
 540
 541				reply := string(choice)
 542				r.setVarString(shellReplyVar, reply)
 543
 544				c, _ := strconv.Atoi(reply)
 545				if c > 0 && c <= len(items) {
 546					r.setVarString(name, items[c-1])
 547				}
 548
 549				// execute commands until break or return is encountered
 550				if r.loopStmtsBroken(ctx, cm.Do) {
 551					break
 552				}
 553			}
 554
 555			for _, field := range items {
 556				r.setVarString(name, field)
 557				trace.stringf("for %s in", y.Name.Value)
 558				if inToken {
 559					for _, item := range y.Items {
 560						trace.string(" ")
 561						trace.expr(item)
 562					}
 563				} else {
 564					trace.string(` "$@"`)
 565				}
 566				trace.newLineFlush()
 567				if r.loopStmtsBroken(ctx, cm.Do) {
 568					break
 569				}
 570			}
 571		case *syntax.CStyleLoop:
 572			if y.Init != nil {
 573				r.arithm(y.Init)
 574			}
 575			for y.Cond == nil || r.arithm(y.Cond) != 0 {
 576				if r.exit != 0 || r.loopStmtsBroken(ctx, cm.Do) {
 577					break
 578				}
 579				if y.Post != nil {
 580					r.arithm(y.Post)
 581				}
 582			}
 583		}
 584	case *syntax.FuncDecl:
 585		r.setFunc(cm.Name.Value, cm.Body)
 586	case *syntax.ArithmCmd:
 587		r.exit = oneIf(r.arithm(cm.X) == 0)
 588	case *syntax.LetClause:
 589		var val int
 590		for _, expr := range cm.Exprs {
 591			val = r.arithm(expr)
 592
 593			if !tracingEnabled {
 594				continue
 595			}
 596
 597			switch expr := expr.(type) {
 598			case *syntax.Word:
 599				qs, err := syntax.Quote(r.literal(expr), syntax.LangBash)
 600				if err != nil {
 601					return
 602				}
 603				trace.stringf("let %v", qs)
 604			case *syntax.BinaryArithm, *syntax.UnaryArithm:
 605				trace.expr(cm)
 606			case *syntax.ParenArithm:
 607				// TODO
 608			}
 609		}
 610
 611		trace.newLineFlush()
 612		r.exit = oneIf(val == 0)
 613	case *syntax.CaseClause:
 614		trace.string("case ")
 615		trace.expr(cm.Word)
 616		trace.string(" in")
 617		trace.newLineFlush()
 618		str := r.literal(cm.Word)
 619		for _, ci := range cm.Items {
 620			for _, word := range ci.Patterns {
 621				pattern := r.pattern(word)
 622				if match(pattern, str) {
 623					r.stmts(ctx, ci.Stmts)
 624					return
 625				}
 626			}
 627		}
 628	case *syntax.TestClause:
 629		if r.bashTest(ctx, cm.X, false) == "" && r.exit == 0 {
 630			// to preserve exit status code 2 for regex errors, etc
 631			r.exit = 1
 632		}
 633	case *syntax.DeclClause:
 634		local, global := false, false
 635		var modes []string
 636		valType := ""
 637		switch cm.Variant.Value {
 638		case "declare":
 639			// When used in a function, "declare" acts as "local"
 640			// unless the "-g" option is used.
 641			local = r.inFunc
 642		case "local":
 643			if !r.inFunc {
 644				r.errf("local: can only be used in a function\n")
 645				r.exit = 1
 646				return
 647			}
 648			local = true
 649		case "export":
 650			modes = append(modes, "-x")
 651		case "readonly":
 652			modes = append(modes, "-r")
 653		case "nameref":
 654			valType = "-n"
 655		}
 656		for _, as := range cm.Args {
 657			for _, as := range r.flattenAssign(as) {
 658				name := as.Name.Value
 659				if strings.HasPrefix(name, "-") {
 660					switch name {
 661					case "-x", "-r":
 662						modes = append(modes, name)
 663					case "-a", "-A", "-n":
 664						valType = name
 665					case "-g":
 666						global = true
 667					default:
 668						r.errf("declare: invalid option %q\n", name)
 669						r.exit = 2
 670						return
 671					}
 672					continue
 673				}
 674				if !syntax.ValidName(name) {
 675					r.errf("declare: invalid name %q\n", name)
 676					r.exit = 1
 677					return
 678				}
 679				vr := r.lookupVar(as.Name.Value)
 680				if as.Naked {
 681					if valType == "-A" {
 682						vr.Kind = expand.Associative
 683					} else {
 684						vr.Kind = expand.KeepValue
 685					}
 686				} else {
 687					vr = r.assignVal(vr, as, valType)
 688				}
 689				if global {
 690					vr.Local = false
 691				} else if local {
 692					vr.Local = true
 693				}
 694				for _, mode := range modes {
 695					switch mode {
 696					case "-x":
 697						vr.Exported = true
 698					case "-r":
 699						vr.ReadOnly = true
 700					}
 701				}
 702				r.setVar(name, vr)
 703			}
 704		}
 705	case *syntax.TimeClause:
 706		start := time.Now()
 707		if cm.Stmt != nil {
 708			r.stmt(ctx, cm.Stmt)
 709		}
 710		format := "%s\t%s\n"
 711		if cm.PosixFormat {
 712			format = "%s %s\n"
 713		} else {
 714			r.outf("\n")
 715		}
 716		real := time.Since(start)
 717		r.outf(format, "real", elapsedString(real, cm.PosixFormat))
 718		// TODO: can we do these?
 719		r.outf(format, "user", elapsedString(0, cm.PosixFormat))
 720		r.outf(format, "sys", elapsedString(0, cm.PosixFormat))
 721	default:
 722		panic(fmt.Sprintf("unhandled command node: %T", cm))
 723	}
 724}
 725
 726func (r *Runner) trapCallback(ctx context.Context, callback, name string) {
 727	if callback == "" {
 728		return // nothing to do
 729	}
 730	if r.handlingTrap {
 731		return // don't recurse, as that could lead to cycles
 732	}
 733	r.handlingTrap = true
 734
 735	p := syntax.NewParser()
 736	// TODO: do this parsing when "trap" is called?
 737	file, err := p.Parse(strings.NewReader(callback), name+" trap")
 738	if err != nil {
 739		r.errf(name+"trap: %v\n", err)
 740		// ignore errors in the callback
 741		return
 742	}
 743	r.stmts(ctx, file.Stmts)
 744
 745	r.handlingTrap = false
 746}
 747
 748// exitShell exits the current shell session with the given status code.
 749func (r *Runner) exitShell(ctx context.Context, status int) {
 750	if status != 0 {
 751		r.trapCallback(ctx, r.callbackErr, "error")
 752	}
 753	r.trapCallback(ctx, r.callbackExit, "exit")
 754
 755	r.shellExited = true
 756	// Restore the original exit status. We ignore the callbacks.
 757	r.exit = status
 758}
 759
 760func (r *Runner) flattenAssign(as *syntax.Assign) []*syntax.Assign {
 761	// Convert "declare $x" into "declare value".
 762	// Don't use syntax.Parser here, as we only want the basic
 763	// splitting by '='.
 764	if as.Name != nil {
 765		return []*syntax.Assign{as} // nothing to do
 766	}
 767	var asgns []*syntax.Assign
 768	for _, field := range r.fields(as.Value) {
 769		as := &syntax.Assign{}
 770		parts := strings.SplitN(field, "=", 2)
 771		as.Name = &syntax.Lit{Value: parts[0]}
 772		if len(parts) == 1 {
 773			as.Naked = true
 774		} else {
 775			as.Value = &syntax.Word{Parts: []syntax.WordPart{
 776				&syntax.Lit{Value: parts[1]},
 777			}}
 778		}
 779		asgns = append(asgns, as)
 780	}
 781	return asgns
 782}
 783
 784func match(pat, name string) bool {
 785	expr, err := pattern.Regexp(pat, pattern.EntireString)
 786	if err != nil {
 787		return false
 788	}
 789	rx := regexp.MustCompile(expr)
 790	return rx.MatchString(name)
 791}
 792
 793func elapsedString(d time.Duration, posix bool) string {
 794	if posix {
 795		return fmt.Sprintf("%.2f", d.Seconds())
 796	}
 797	min := int(d.Minutes())
 798	sec := math.Mod(d.Seconds(), 60.0)
 799	return fmt.Sprintf("%dm%.3fs", min, sec)
 800}
 801
 802func (r *Runner) stmts(ctx context.Context, stmts []*syntax.Stmt) {
 803	for _, stmt := range stmts {
 804		r.stmt(ctx, stmt)
 805	}
 806}
 807
 808func (r *Runner) hdocReader(rd *syntax.Redirect) (*os.File, error) {
 809	pr, pw, err := os.Pipe()
 810	if err != nil {
 811		return nil, err
 812	}
 813	// We write to the pipe in a new goroutine,
 814	// as pipe writes may block once the buffer gets full.
 815	// We still construct and buffer the entire heredoc first,
 816	// as doing it concurrently would lead to different semantics and be racy.
 817	if rd.Op != syntax.DashHdoc {
 818		hdoc := r.document(rd.Hdoc)
 819		go func() {
 820			pw.WriteString(hdoc)
 821			pw.Close()
 822		}()
 823		return pr, nil
 824	}
 825	var buf bytes.Buffer
 826	var cur []syntax.WordPart
 827	flushLine := func() {
 828		if buf.Len() > 0 {
 829			buf.WriteByte('\n')
 830		}
 831		buf.WriteString(r.document(&syntax.Word{Parts: cur}))
 832		cur = cur[:0]
 833	}
 834	for _, wp := range rd.Hdoc.Parts {
 835		lit, ok := wp.(*syntax.Lit)
 836		if !ok {
 837			cur = append(cur, wp)
 838			continue
 839		}
 840		for i, part := range strings.Split(lit.Value, "\n") {
 841			if i > 0 {
 842				flushLine()
 843				cur = cur[:0]
 844			}
 845			part = strings.TrimLeft(part, "\t")
 846			cur = append(cur, &syntax.Lit{Value: part})
 847		}
 848	}
 849	flushLine()
 850	go func() {
 851		pw.Write(buf.Bytes())
 852		pw.Close()
 853	}()
 854	return pr, nil
 855}
 856
 857func (r *Runner) redir(ctx context.Context, rd *syntax.Redirect) (io.Closer, error) {
 858	if rd.Hdoc != nil {
 859		pr, err := r.hdocReader(rd)
 860		if err != nil {
 861			return nil, err
 862		}
 863		r.stdin = pr
 864		return pr, nil
 865	}
 866
 867	orig := &r.stdout
 868	if rd.N != nil {
 869		switch rd.N.Value {
 870		case "0":
 871			// Note that the input redirects below always use stdin (0)
 872			// because we don't support anything else right now.
 873		case "1":
 874			// The default for the output redirects below.
 875		case "2":
 876			orig = &r.stderr
 877		default:
 878			panic(fmt.Sprintf("unsupported redirect fd: %v", rd.N.Value))
 879		}
 880	}
 881	arg := r.literal(rd.Word)
 882	switch rd.Op {
 883	case syntax.WordHdoc:
 884		pr, pw, err := os.Pipe()
 885		if err != nil {
 886			return nil, err
 887		}
 888		r.stdin = pr
 889		// We write to the pipe in a new goroutine,
 890		// as pipe writes may block once the buffer gets full.
 891		go func() {
 892			pw.WriteString(arg)
 893			pw.WriteString("\n")
 894			pw.Close()
 895		}()
 896		return pr, nil
 897	case syntax.DplOut:
 898		switch arg {
 899		case "1":
 900			*orig = r.stdout
 901		case "2":
 902			*orig = r.stderr
 903		case "-":
 904			*orig = io.Discard // closing the output writer
 905		default:
 906			panic(fmt.Sprintf("unhandled %v arg: %q", rd.Op, arg))
 907		}
 908		return nil, nil
 909	case syntax.RdrIn, syntax.RdrOut, syntax.AppOut,
 910		syntax.RdrAll, syntax.AppAll:
 911		// done further below
 912	case syntax.DplIn:
 913		switch arg {
 914		case "-":
 915			r.stdin = nil // closing the input file
 916		default:
 917			panic(fmt.Sprintf("unhandled %v arg: %q", rd.Op, arg))
 918		}
 919		return nil, nil
 920	default:
 921		panic(fmt.Sprintf("unhandled redirect op: %v", rd.Op))
 922	}
 923	mode := os.O_RDONLY
 924	switch rd.Op {
 925	case syntax.AppOut, syntax.AppAll:
 926		mode = os.O_WRONLY | os.O_CREATE | os.O_APPEND
 927	case syntax.RdrOut, syntax.RdrAll:
 928		mode = os.O_WRONLY | os.O_CREATE | os.O_TRUNC
 929	}
 930	f, err := r.open(ctx, arg, mode, 0o644, true)
 931	if err != nil {
 932		return nil, err
 933	}
 934	switch rd.Op {
 935	case syntax.RdrIn:
 936		stdin, err := stdinFile(f)
 937		if err != nil {
 938			return nil, err
 939		}
 940		r.stdin = stdin
 941	case syntax.RdrOut, syntax.AppOut:
 942		*orig = f
 943	case syntax.RdrAll, syntax.AppAll:
 944		r.stdout = f
 945		r.stderr = f
 946	default:
 947		panic(fmt.Sprintf("unhandled redirect op: %v", rd.Op))
 948	}
 949	return f, nil
 950}
 951
 952func (r *Runner) loopStmtsBroken(ctx context.Context, stmts []*syntax.Stmt) bool {
 953	oldInLoop := r.inLoop
 954	r.inLoop = true
 955	defer func() { r.inLoop = oldInLoop }()
 956	for _, stmt := range stmts {
 957		r.stmt(ctx, stmt)
 958		if r.contnEnclosing > 0 {
 959			r.contnEnclosing--
 960			return r.contnEnclosing > 0
 961		}
 962		if r.breakEnclosing > 0 {
 963			r.breakEnclosing--
 964			return true
 965		}
 966	}
 967	return false
 968}
 969
 970type returnStatus uint8
 971
 972func (s returnStatus) Error() string { return fmt.Sprintf("return status %d", s) }
 973
 974func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) {
 975	if r.stop(ctx) {
 976		return
 977	}
 978	if r.callHandler != nil {
 979		var err error
 980		args, err = r.callHandler(r.handlerCtx(ctx), args)
 981		if err != nil {
 982			// handler's custom fatal error
 983			r.setErr(err)
 984			return
 985		}
 986	}
 987	name := args[0]
 988	if body := r.Funcs[name]; body != nil {
 989		// stack them to support nested func calls
 990		oldParams := r.Params
 991		r.Params = args[1:]
 992		oldInFunc := r.inFunc
 993		r.inFunc = true
 994
 995		// Functions run in a nested scope.
 996		// Note that [Runner.exec] below does something similar.
 997		origEnv := r.writeEnv
 998		r.writeEnv = &overlayEnviron{parent: r.writeEnv, funcScope: true}
 999
1000		r.stmt(ctx, body)
1001
1002		r.writeEnv = origEnv
1003
1004		r.Params = oldParams
1005		r.inFunc = oldInFunc
1006		if code, ok := r.err.(returnStatus); ok {
1007			r.err = nil
1008			r.exit = int(code)
1009		}
1010		return
1011	}
1012	if isBuiltin(name) {
1013		r.exit = r.builtinCode(ctx, pos, name, args[1:])
1014		return
1015	}
1016	r.exec(ctx, args)
1017}
1018
1019func (r *Runner) exec(ctx context.Context, args []string) {
1020	err := r.execHandler(r.handlerCtx(ctx), args)
1021	if status, ok := IsExitStatus(err); ok {
1022		r.exit = int(status)
1023		return
1024	}
1025	if err != nil {
1026		// handler's custom fatal error
1027		r.setErr(err)
1028		return
1029	}
1030	r.exit = 0
1031}
1032
1033func (r *Runner) open(ctx context.Context, path string, flags int, mode os.FileMode, print bool) (io.ReadWriteCloser, error) {
1034	// If we are opening a FIFO temporary file created by the interpreter itself,
1035	// don't pass this along to the open handler as it will not work at all
1036	// unless [os.OpenFile] is used directly with it.
1037	// Matching by directory and basename prefix isn't perfect, but works.
1038	//
1039	// If we want FIFOs to use a handler in the future, they probably
1040	// need their own separate handler API matching Unix-like semantics.
1041	dir, name := filepath.Split(path)
1042	dir = strings.TrimSuffix(dir, "/")
1043	if dir == r.tempDir && strings.HasPrefix(name, fifoNamePrefix) {
1044		return os.OpenFile(path, flags, mode)
1045	}
1046
1047	f, err := r.openHandler(r.handlerCtx(ctx), path, flags, mode)
1048	// TODO: support wrapped PathError returned from openHandler.
1049	switch err.(type) {
1050	case nil:
1051		return f, nil
1052	case *os.PathError:
1053		if print {
1054			r.errf("%v\n", err)
1055		}
1056	default: // handler's custom fatal error
1057		r.setErr(err)
1058	}
1059	return nil, err
1060}
1061
1062func (r *Runner) stat(ctx context.Context, name string) (fs.FileInfo, error) {
1063	path := absPath(r.Dir, name)
1064	return r.statHandler(ctx, path, true)
1065}
1066
1067func (r *Runner) lstat(ctx context.Context, name string) (fs.FileInfo, error) {
1068	path := absPath(r.Dir, name)
1069	return r.statHandler(ctx, path, false)
1070}