builtin.go

   1// Copyright (c) 2017, Daniel MartΓ­ <mvdan@mvdan.cc>
   2// See LICENSE for licensing information
   3
   4package interp
   5
   6import (
   7	"bufio"
   8	"bytes"
   9	"cmp"
  10	"context"
  11	"errors"
  12	"fmt"
  13	"os"
  14	"path/filepath"
  15	"slices"
  16	"strconv"
  17	"strings"
  18	"syscall"
  19	"time"
  20
  21	"golang.org/x/term"
  22
  23	"mvdan.cc/sh/v3/expand"
  24	"mvdan.cc/sh/v3/syntax"
  25)
  26
  27func isBuiltin(name string) bool {
  28	switch name {
  29	case "true", ":", "false", "exit", "set", "shift", "unset",
  30		"echo", "printf", "break", "continue", "pwd", "cd",
  31		"wait", "builtin", "trap", "type", "source", ".", "command",
  32		"dirs", "pushd", "popd", "umask", "alias", "unalias",
  33		"fg", "bg", "getopts", "eval", "test", "[", "exec",
  34		"return", "read", "mapfile", "readarray", "shopt":
  35		return true
  36	}
  37	return false
  38}
  39
  40// TODO: oneIf and atoi are duplicated in the expand package.
  41
  42func oneIf(b bool) int {
  43	if b {
  44		return 1
  45	}
  46	return 0
  47}
  48
  49// atoi is like [strconv.Atoi], but it ignores errors and trims whitespace.
  50func atoi(s string) int {
  51	s = strings.TrimSpace(s)
  52	n, _ := strconv.Atoi(s)
  53	return n
  54}
  55
  56func (r *Runner) builtinCode(ctx context.Context, pos syntax.Pos, name string, args []string) int {
  57	switch name {
  58	case "true", ":":
  59	case "false":
  60		return 1
  61	case "exit":
  62		exit := 0
  63		switch len(args) {
  64		case 0:
  65			exit = r.lastExit
  66		case 1:
  67			n, err := strconv.Atoi(args[0])
  68			if err != nil {
  69				r.errf("invalid exit status code: %q\n", args[0])
  70				return 2
  71			}
  72			exit = n
  73		default:
  74			r.errf("exit cannot take multiple arguments\n")
  75			return 1
  76		}
  77		r.exitShell(ctx, exit)
  78		return exit
  79	case "set":
  80		if err := Params(args...)(r); err != nil {
  81			r.errf("set: %v\n", err)
  82			return 2
  83		}
  84		r.updateExpandOpts()
  85	case "shift":
  86		n := 1
  87		switch len(args) {
  88		case 0:
  89		case 1:
  90			if n2, err := strconv.Atoi(args[0]); err == nil {
  91				n = n2
  92				break
  93			}
  94			fallthrough
  95		default:
  96			r.errf("usage: shift [n]\n")
  97			return 2
  98		}
  99		if n >= len(r.Params) {
 100			r.Params = nil
 101		} else {
 102			r.Params = r.Params[n:]
 103		}
 104	case "unset":
 105		vars := true
 106		funcs := true
 107	unsetOpts:
 108		for i, arg := range args {
 109			switch arg {
 110			case "-v":
 111				funcs = false
 112			case "-f":
 113				vars = false
 114			default:
 115				args = args[i:]
 116				break unsetOpts
 117			}
 118		}
 119
 120		for _, arg := range args {
 121			if vars && r.lookupVar(arg).IsSet() {
 122				r.delVar(arg)
 123			} else if _, ok := r.Funcs[arg]; ok && funcs {
 124				delete(r.Funcs, arg)
 125			}
 126		}
 127	case "echo":
 128		newline, doExpand := true, false
 129	echoOpts:
 130		for len(args) > 0 {
 131			switch args[0] {
 132			case "-n":
 133				newline = false
 134			case "-e":
 135				doExpand = true
 136			case "-E": // default
 137			default:
 138				break echoOpts
 139			}
 140			args = args[1:]
 141		}
 142		for i, arg := range args {
 143			if i > 0 {
 144				r.out(" ")
 145			}
 146			if doExpand {
 147				arg, _, _ = expand.Format(r.ecfg, arg, nil)
 148			}
 149			r.out(arg)
 150		}
 151		if newline {
 152			r.out("\n")
 153		}
 154	case "printf":
 155		if len(args) == 0 {
 156			r.errf("usage: printf format [arguments]\n")
 157			return 2
 158		}
 159		format, args := args[0], args[1:]
 160		for {
 161			s, n, err := expand.Format(r.ecfg, format, args)
 162			if err != nil {
 163				r.errf("%v\n", err)
 164				return 1
 165			}
 166			r.out(s)
 167			args = args[n:]
 168			if n == 0 || len(args) == 0 {
 169				break
 170			}
 171		}
 172	case "break", "continue":
 173		if !r.inLoop {
 174			r.errf("%s is only useful in a loop\n", name)
 175			break
 176		}
 177		enclosing := &r.breakEnclosing
 178		if name == "continue" {
 179			enclosing = &r.contnEnclosing
 180		}
 181		switch len(args) {
 182		case 0:
 183			*enclosing = 1
 184		case 1:
 185			if n, err := strconv.Atoi(args[0]); err == nil {
 186				*enclosing = n
 187				break
 188			}
 189			fallthrough
 190		default:
 191			r.errf("usage: %s [n]\n", name)
 192			return 2
 193		}
 194	case "pwd":
 195		evalSymlinks := false
 196		for len(args) > 0 {
 197			switch args[0] {
 198			case "-L":
 199				evalSymlinks = false
 200			case "-P":
 201				evalSymlinks = true
 202			default:
 203				r.errf("invalid option: %q\n", args[0])
 204				return 2
 205			}
 206			args = args[1:]
 207		}
 208		pwd := r.envGet("PWD")
 209		if evalSymlinks {
 210			var err error
 211			pwd, err = filepath.EvalSymlinks(pwd)
 212			if err != nil {
 213				r.setErr(err)
 214				return 1
 215			}
 216		}
 217		r.outf("%s\n", pwd)
 218	case "cd":
 219		var path string
 220		switch len(args) {
 221		case 0:
 222			path = r.envGet("HOME")
 223		case 1:
 224			path = args[0]
 225
 226			// replicate the commonly implemented behavior of `cd -`
 227			// ref: https://www.man7.org/linux/man-pages/man1/cd.1p.html#OPERANDS
 228			if path == "-" {
 229				path = r.envGet("OLDPWD")
 230				r.outf("%s\n", path)
 231			}
 232		default:
 233			r.errf("usage: cd [dir]\n")
 234			return 2
 235		}
 236		return r.changeDir(ctx, path)
 237	case "wait":
 238		if len(args) > 0 {
 239			panic("wait with args not handled yet")
 240		}
 241		// Note that "wait" without arguments always returns exit status zero.
 242		r.bgShells.Wait()
 243	case "builtin":
 244		if len(args) < 1 {
 245			break
 246		}
 247		if !isBuiltin(args[0]) {
 248			return 1
 249		}
 250		return r.builtinCode(ctx, pos, args[0], args[1:])
 251	case "type":
 252		anyNotFound := false
 253		mode := ""
 254		fp := flagParser{remaining: args}
 255		for fp.more() {
 256			switch flag := fp.flag(); flag {
 257			case "-a", "-f", "-P", "--help":
 258				r.errf("command: NOT IMPLEMENTED\n")
 259				return 3
 260			case "-p", "-t":
 261				mode = flag
 262			default:
 263				r.errf("command: invalid option %q\n", flag)
 264				return 2
 265			}
 266		}
 267		args := fp.args()
 268		for _, arg := range args {
 269			if mode == "-p" {
 270				if path, err := LookPathDir(r.Dir, r.writeEnv, arg); err == nil {
 271					r.outf("%s\n", path)
 272				} else {
 273					anyNotFound = true
 274				}
 275				continue
 276			}
 277			if syntax.IsKeyword(arg) {
 278				if mode == "-t" {
 279					r.out("keyword\n")
 280				} else {
 281					r.outf("%s is a shell keyword\n", arg)
 282				}
 283				continue
 284			}
 285			if als, ok := r.alias[arg]; ok && r.opts[optExpandAliases] {
 286				var buf bytes.Buffer
 287				if len(als.args) > 0 {
 288					printer := syntax.NewPrinter()
 289					printer.Print(&buf, &syntax.CallExpr{
 290						Args: als.args,
 291					})
 292				}
 293				if als.blank {
 294					buf.WriteByte(' ')
 295				}
 296				if mode == "-t" {
 297					r.out("alias\n")
 298				} else {
 299					r.outf("%s is aliased to `%s'\n", arg, &buf)
 300				}
 301				continue
 302			}
 303			if _, ok := r.Funcs[arg]; ok {
 304				if mode == "-t" {
 305					r.out("function\n")
 306				} else {
 307					r.outf("%s is a function\n", arg)
 308				}
 309				continue
 310			}
 311			if isBuiltin(arg) {
 312				if mode == "-t" {
 313					r.out("builtin\n")
 314				} else {
 315					r.outf("%s is a shell builtin\n", arg)
 316				}
 317				continue
 318			}
 319			if path, err := LookPathDir(r.Dir, r.writeEnv, arg); err == nil {
 320				if mode == "-t" {
 321					r.out("file\n")
 322				} else {
 323					r.outf("%s is %s\n", arg, path)
 324				}
 325				continue
 326			}
 327			if mode != "-t" {
 328				r.errf("type: %s: not found\n", arg)
 329			}
 330			anyNotFound = true
 331		}
 332		if anyNotFound {
 333			return 1
 334		}
 335	case "eval":
 336		src := strings.Join(args, " ")
 337		p := syntax.NewParser()
 338		file, err := p.Parse(strings.NewReader(src), "")
 339		if err != nil {
 340			r.errf("eval: %v\n", err)
 341			return 1
 342		}
 343		r.stmts(ctx, file.Stmts)
 344		return r.exit
 345	case "source", ".":
 346		if len(args) < 1 {
 347			r.errf("%v: source: need filename\n", pos)
 348			return 2
 349		}
 350		path, err := scriptFromPathDir(r.Dir, r.writeEnv, args[0])
 351		if err != nil {
 352			// If the script was not found in PATH or there was any error, pass
 353			// the source path to the open handler so it has a chance to look
 354			// at files it manages (eg: virtual filesystem), and also allow
 355			// it to look for the sourced script in the current directory.
 356			path = args[0]
 357		}
 358		f, err := r.open(ctx, path, os.O_RDONLY, 0, false)
 359		if err != nil {
 360			r.errf("source: %v\n", err)
 361			return 1
 362		}
 363		defer f.Close()
 364		p := syntax.NewParser()
 365		file, err := p.Parse(f, path)
 366		if err != nil {
 367			r.errf("source: %v\n", err)
 368			return 1
 369		}
 370
 371		// Keep the current versions of some fields we might modify.
 372		oldParams := r.Params
 373		oldSourceSetParams := r.sourceSetParams
 374		oldInSource := r.inSource
 375
 376		// If we run "source file args...", set said args as parameters.
 377		// Otherwise, keep the current parameters.
 378		sourceArgs := len(args[1:]) > 0
 379		if sourceArgs {
 380			r.Params = args[1:]
 381			r.sourceSetParams = false
 382		}
 383		// We want to track if the sourced file explicitly sets the
 384		// parameters.
 385		r.sourceSetParams = false
 386		r.inSource = true // know that we're inside a sourced script.
 387		r.stmts(ctx, file.Stmts)
 388
 389		// If we modified the parameters and the sourced file didn't
 390		// explicitly set them, we restore the old ones.
 391		if sourceArgs && !r.sourceSetParams {
 392			r.Params = oldParams
 393		}
 394		r.sourceSetParams = oldSourceSetParams
 395		r.inSource = oldInSource
 396
 397		if code, ok := r.err.(returnStatus); ok {
 398			r.err = nil
 399			return int(code)
 400		}
 401		return r.exit
 402	case "[":
 403		if len(args) == 0 || args[len(args)-1] != "]" {
 404			r.errf("%v: [: missing matching ]\n", pos)
 405			return 2
 406		}
 407		args = args[:len(args)-1]
 408		fallthrough
 409	case "test":
 410		parseErr := false
 411		p := testParser{
 412			rem: args,
 413			err: func(err error) {
 414				r.errf("%v: %v\n", pos, err)
 415				parseErr = true
 416			},
 417		}
 418		p.next()
 419		expr := p.classicTest("[", false)
 420		if parseErr {
 421			return 2
 422		}
 423		return oneIf(r.bashTest(ctx, expr, true) == "")
 424	case "exec":
 425		// TODO: Consider unix.Exec, i.e. actually replacing
 426		// the process. It's in theory what a shell should do,
 427		// but in practice it would kill the entire Go process
 428		// and it's not available on Windows.
 429		if len(args) == 0 {
 430			r.keepRedirs = true
 431			break
 432		}
 433		r.exitShell(ctx, 1)
 434		r.exec(ctx, args)
 435		return r.exit
 436	case "command":
 437		show := false
 438		fp := flagParser{remaining: args}
 439		for fp.more() {
 440			switch flag := fp.flag(); flag {
 441			case "-v":
 442				show = true
 443			default:
 444				r.errf("command: invalid option %q\n", flag)
 445				return 2
 446			}
 447		}
 448		args := fp.args()
 449		if len(args) == 0 {
 450			break
 451		}
 452		if !show {
 453			if isBuiltin(args[0]) {
 454				return r.builtinCode(ctx, pos, args[0], args[1:])
 455			}
 456			r.exec(ctx, args)
 457			return r.exit
 458		}
 459		last := 0
 460		for _, arg := range args {
 461			last = 0
 462			if r.Funcs[arg] != nil || isBuiltin(arg) {
 463				r.outf("%s\n", arg)
 464			} else if path, err := LookPathDir(r.Dir, r.writeEnv, arg); err == nil {
 465				r.outf("%s\n", path)
 466			} else {
 467				last = 1
 468			}
 469		}
 470		return last
 471	case "dirs":
 472		for i, dir := range slices.Backward(r.dirStack) {
 473			r.outf("%s", dir)
 474			if i > 0 {
 475				r.out(" ")
 476			}
 477		}
 478		r.out("\n")
 479	case "pushd":
 480		change := true
 481		if len(args) > 0 && args[0] == "-n" {
 482			change = false
 483			args = args[1:]
 484		}
 485		swap := func() string {
 486			oldtop := r.dirStack[len(r.dirStack)-1]
 487			top := r.dirStack[len(r.dirStack)-2]
 488			r.dirStack[len(r.dirStack)-1] = top
 489			r.dirStack[len(r.dirStack)-2] = oldtop
 490			return top
 491		}
 492		switch len(args) {
 493		case 0:
 494			if !change {
 495				break
 496			}
 497			if len(r.dirStack) < 2 {
 498				r.errf("pushd: no other directory\n")
 499				return 1
 500			}
 501			newtop := swap()
 502			if code := r.changeDir(ctx, newtop); code != 0 {
 503				return code
 504			}
 505			r.builtinCode(ctx, syntax.Pos{}, "dirs", nil)
 506		case 1:
 507			if change {
 508				if code := r.changeDir(ctx, args[0]); code != 0 {
 509					return code
 510				}
 511				r.dirStack = append(r.dirStack, r.Dir)
 512			} else {
 513				r.dirStack = append(r.dirStack, args[0])
 514				swap()
 515			}
 516			r.builtinCode(ctx, syntax.Pos{}, "dirs", nil)
 517		default:
 518			r.errf("pushd: too many arguments\n")
 519			return 2
 520		}
 521	case "popd":
 522		change := true
 523		if len(args) > 0 && args[0] == "-n" {
 524			change = false
 525			args = args[1:]
 526		}
 527		switch len(args) {
 528		case 0:
 529			if len(r.dirStack) < 2 {
 530				r.errf("popd: directory stack empty\n")
 531				return 1
 532			}
 533			oldtop := r.dirStack[len(r.dirStack)-1]
 534			r.dirStack = r.dirStack[:len(r.dirStack)-1]
 535			if change {
 536				newtop := r.dirStack[len(r.dirStack)-1]
 537				if code := r.changeDir(ctx, newtop); code != 0 {
 538					return code
 539				}
 540			} else {
 541				r.dirStack[len(r.dirStack)-1] = oldtop
 542			}
 543			r.builtinCode(ctx, syntax.Pos{}, "dirs", nil)
 544		default:
 545			r.errf("popd: invalid argument\n")
 546			return 2
 547		}
 548	case "return":
 549		if !r.inFunc && !r.inSource {
 550			r.errf("return: can only be done from a func or sourced script\n")
 551			return 1
 552		}
 553		code := 0
 554		switch len(args) {
 555		case 0:
 556		case 1:
 557			code = atoi(args[0])
 558		default:
 559			r.errf("return: too many arguments\n")
 560			return 2
 561		}
 562		r.setErr(returnStatus(code))
 563	case "read":
 564		var prompt string
 565		raw := false
 566		silent := false
 567		fp := flagParser{remaining: args}
 568		for fp.more() {
 569			switch flag := fp.flag(); flag {
 570			case "-s":
 571				silent = true
 572			case "-r":
 573				raw = true
 574			case "-p":
 575				prompt = fp.value()
 576				if prompt == "" {
 577					r.errf("read: -p: option requires an argument\n")
 578					return 2
 579				}
 580			default:
 581				r.errf("read: invalid option %q\n", flag)
 582				return 2
 583			}
 584		}
 585
 586		args := fp.args()
 587		for _, name := range args {
 588			if !syntax.ValidName(name) {
 589				r.errf("read: invalid identifier %q\n", name)
 590				return 2
 591			}
 592		}
 593
 594		if prompt != "" {
 595			r.out(prompt)
 596		}
 597
 598		var line []byte
 599		var err error
 600		if silent {
 601			line, err = term.ReadPassword(int(syscall.Stdin))
 602		} else {
 603			line, err = r.readLine(ctx, raw)
 604		}
 605		if len(args) == 0 {
 606			args = append(args, shellReplyVar)
 607		}
 608
 609		values := expand.ReadFields(r.ecfg, string(line), len(args), raw)
 610		for i, name := range args {
 611			val := ""
 612			if i < len(values) {
 613				val = values[i]
 614			}
 615			r.setVarString(name, val)
 616		}
 617
 618		// We can get data back from readLine and an error at the same time, so
 619		// check err after we process the data.
 620		if err != nil {
 621			return 1
 622		}
 623
 624		return 0
 625
 626	case "getopts":
 627		if len(args) < 2 {
 628			r.errf("getopts: usage: getopts optstring name [arg ...]\n")
 629			return 2
 630		}
 631		optind, _ := strconv.Atoi(r.envGet("OPTIND"))
 632		if optind-1 != r.optState.argidx {
 633			if optind < 1 {
 634				optind = 1
 635			}
 636			r.optState = getopts{argidx: optind - 1}
 637		}
 638		optstr := args[0]
 639		name := args[1]
 640		if !syntax.ValidName(name) {
 641			r.errf("getopts: invalid identifier: %q\n", name)
 642			return 2
 643		}
 644		args = args[2:]
 645		if len(args) == 0 {
 646			args = r.Params
 647		}
 648		diagnostics := !strings.HasPrefix(optstr, ":")
 649
 650		opt, optarg, done := r.optState.next(optstr, args)
 651
 652		r.setVarString(name, string(opt))
 653		r.delVar("OPTARG")
 654		switch {
 655		case opt == '?' && diagnostics && !done:
 656			r.errf("getopts: illegal option -- %q\n", optarg)
 657		case opt == ':' && diagnostics:
 658			r.errf("getopts: option requires an argument -- %q\n", optarg)
 659		default:
 660			if optarg != "" {
 661				r.setVarString("OPTARG", optarg)
 662			}
 663		}
 664		if optind-1 != r.optState.argidx {
 665			r.setVarString("OPTIND", strconv.FormatInt(int64(r.optState.argidx+1), 10))
 666		}
 667
 668		return oneIf(done)
 669
 670	case "shopt":
 671		mode := ""
 672		posixOpts := false
 673		fp := flagParser{remaining: args}
 674		for fp.more() {
 675			switch flag := fp.flag(); flag {
 676			case "-s", "-u":
 677				mode = flag
 678			case "-o":
 679				posixOpts = true
 680			case "-p", "-q":
 681				panic(fmt.Sprintf("unhandled shopt flag: %s", flag))
 682			default:
 683				r.errf("shopt: invalid option %q\n", flag)
 684				return 2
 685			}
 686		}
 687		args := fp.args()
 688		bash := !posixOpts
 689		if len(args) == 0 {
 690			if bash {
 691				for i, opt := range bashOptsTable {
 692					r.printOptLine(opt.name, r.opts[len(shellOptsTable)+i], opt.supported)
 693				}
 694				break
 695			}
 696			for i, opt := range &shellOptsTable {
 697				r.printOptLine(opt.name, r.opts[i], true)
 698			}
 699			break
 700		}
 701		for _, arg := range args {
 702			i, opt := r.optByName(arg, bash)
 703			if opt == nil {
 704				r.errf("shopt: invalid option name %q\n", arg)
 705				return 1
 706			}
 707
 708			var (
 709				bo        *bashOpt
 710				supported = true // default for shell options
 711			)
 712			if bash {
 713				bo = &bashOptsTable[i-len(shellOptsTable)]
 714				supported = bo.supported
 715			}
 716
 717			switch mode {
 718			case "-s", "-u":
 719				if bash && !supported {
 720					r.errf("shopt: invalid option name %q %q (%q not supported)\n", arg, r.optStatusText(bo.defaultState), r.optStatusText(!bo.defaultState))
 721					return 1
 722				}
 723				*opt = mode == "-s"
 724			default: // ""
 725				r.printOptLine(arg, *opt, supported)
 726			}
 727		}
 728		r.updateExpandOpts()
 729
 730	case "alias":
 731		show := func(name string, als alias) {
 732			var buf bytes.Buffer
 733			if len(als.args) > 0 {
 734				printer := syntax.NewPrinter()
 735				printer.Print(&buf, &syntax.CallExpr{
 736					Args: als.args,
 737				})
 738			}
 739			if als.blank {
 740				buf.WriteByte(' ')
 741			}
 742			r.outf("alias %s='%s'\n", name, &buf)
 743		}
 744
 745		if len(args) == 0 {
 746			for name, als := range r.alias {
 747				show(name, als)
 748			}
 749		}
 750	argsLoop:
 751		for _, name := range args {
 752			i := strings.IndexByte(name, '=')
 753			if i < 1 { // don't save an empty name
 754				als, ok := r.alias[name]
 755				if !ok {
 756					r.errf("alias: %q not found\n", name)
 757					continue
 758				}
 759				show(name, als)
 760				continue
 761			}
 762
 763			// TODO: parse any CallExpr perhaps, or even any Stmt
 764			parser := syntax.NewParser()
 765			var words []*syntax.Word
 766			src := name[i+1:]
 767			for w, err := range parser.WordsSeq(strings.NewReader(src)) {
 768				if err != nil {
 769					r.errf("alias: could not parse %q: %v\n", src, err)
 770					continue argsLoop
 771				}
 772				words = append(words, w)
 773			}
 774
 775			name = name[:i]
 776			if r.alias == nil {
 777				r.alias = make(map[string]alias)
 778			}
 779			r.alias[name] = alias{
 780				args:  words,
 781				blank: strings.TrimRight(src, " \t") != src,
 782			}
 783		}
 784	case "unalias":
 785		for _, name := range args {
 786			delete(r.alias, name)
 787		}
 788
 789	case "trap":
 790		fp := flagParser{remaining: args}
 791		callback := "-"
 792		for fp.more() {
 793			switch flag := fp.flag(); flag {
 794			case "-l", "-p":
 795				r.errf("trap: %q: NOT IMPLEMENTED flag\n", flag)
 796				return 2
 797			case "-":
 798				// default signal
 799			default:
 800				r.errf("trap: %q: invalid option\n", flag)
 801				r.errf("trap: usage: trap [-lp] [[arg] signal_spec ...]\n")
 802				return 2
 803			}
 804		}
 805		args := fp.args()
 806		switch len(args) {
 807		case 0:
 808			// Print non-default signals
 809			if r.callbackExit != "" {
 810				r.outf("trap -- %q EXIT\n", r.callbackExit)
 811			}
 812			if r.callbackErr != "" {
 813				r.outf("trap -- %q ERR\n", r.callbackErr)
 814			}
 815		case 1:
 816			// assume it's a signal, the default will be restored
 817		default:
 818			callback = args[0]
 819			args = args[1:]
 820		}
 821		// For now, treat both empty and - the same since ERR and EXIT have no
 822		// default callback.
 823		if callback == "-" {
 824			callback = ""
 825		}
 826		for _, arg := range args {
 827			switch arg {
 828			case "ERR":
 829				r.callbackErr = callback
 830			case "EXIT":
 831				r.callbackExit = callback
 832			default:
 833				r.errf("trap: %s: invalid signal specification\n", arg)
 834				return 2
 835			}
 836		}
 837
 838	case "readarray", "mapfile":
 839		dropDelim := false
 840		delim := "\n"
 841		fp := flagParser{remaining: args}
 842		for fp.more() {
 843			switch flag := fp.flag(); flag {
 844			case "-t":
 845				// Remove the delim from each line read
 846				dropDelim = true
 847			case "-d":
 848				if len(fp.remaining) == 0 {
 849					r.errf("%s: -d: option requires an argument\n", name)
 850					return 2
 851				}
 852				delim = fp.value()
 853				if delim == "" {
 854					// Bash sets the delim to an ASCII NUL if provided with an empty
 855					// string.
 856					delim = "\x00"
 857				}
 858			default:
 859				r.errf("%s: invalid option %q\n", name, flag)
 860				return 2
 861			}
 862		}
 863
 864		args := fp.args()
 865		var arrayName string
 866		switch len(args) {
 867		case 0:
 868			arrayName = "MAPFILE"
 869		case 1:
 870			if !syntax.ValidName(args[0]) {
 871				r.errf("%s: invalid identifier %q\n", name, args[0])
 872				return 2
 873			}
 874			arrayName = args[0]
 875		default:
 876			r.errf("%s: Only one array name may be specified, %v\n", name, args)
 877			return 2
 878		}
 879
 880		var vr expand.Variable
 881		vr.Kind = expand.Indexed
 882		scanner := bufio.NewScanner(r.stdin)
 883		scanner.Split(mapfileSplit(delim[0], dropDelim))
 884		for scanner.Scan() {
 885			vr.List = append(vr.List, scanner.Text())
 886		}
 887		if err := scanner.Err(); err != nil {
 888			r.errf("%s: unable to read, %v\n", name, err)
 889			return 2
 890		}
 891		r.setVar(arrayName, vr)
 892
 893		return 0
 894
 895	default:
 896		// "umask", "fg", "bg",
 897		r.errf("%s: unimplemented builtin\n", name)
 898		return 2
 899	}
 900	return 0
 901}
 902
 903// mapfileSplit returns a suitable Split function for a [bufio.Scanner];
 904// the code is mostly stolen from [bufio.ScanLines].
 905func mapfileSplit(delim byte, dropDelim bool) bufio.SplitFunc {
 906	return func(data []byte, atEOF bool) (advance int, token []byte, err error) {
 907		if atEOF && len(data) == 0 {
 908			return 0, nil, nil
 909		}
 910		if i := bytes.IndexByte(data, delim); i >= 0 {
 911			// We have a full newline-terminated line.
 912			if dropDelim {
 913				return i + 1, data[0:i], nil
 914			} else {
 915				return i + 1, data[0 : i+1], nil
 916			}
 917		}
 918		// If we're at EOF, we have a final, non-terminated line. Return it.
 919		if atEOF {
 920			return len(data), data, nil
 921		}
 922		// Request more data.
 923		return 0, nil, nil
 924	}
 925}
 926
 927func (r *Runner) printOptLine(name string, enabled, supported bool) {
 928	state := r.optStatusText(enabled)
 929	if supported {
 930		r.outf("%s\t%s\n", name, state)
 931		return
 932	}
 933	r.outf("%s\t%s\t(%q not supported)\n", name, state, r.optStatusText(!enabled))
 934}
 935
 936func (r *Runner) readLine(ctx context.Context, raw bool) ([]byte, error) {
 937	if r.stdin == nil {
 938		return nil, errors.New("interp: can't read, there's no stdin")
 939	}
 940
 941	var line []byte
 942	esc := false
 943
 944	stopc := make(chan struct{})
 945	stop := context.AfterFunc(ctx, func() {
 946		r.stdin.SetReadDeadline(time.Now())
 947		close(stopc)
 948	})
 949	defer func() {
 950		if !stop() {
 951			// The AfterFunc was started.
 952			// Wait for it to complete, and reset the file's deadline.
 953			<-stopc
 954			r.stdin.SetReadDeadline(time.Time{})
 955		}
 956	}()
 957	for {
 958		var buf [1]byte
 959		n, err := r.stdin.Read(buf[:])
 960		if n > 0 {
 961			b := buf[0]
 962			switch {
 963			case !raw && b == '\\':
 964				line = append(line, b)
 965				esc = !esc
 966			case !raw && b == '\n' && esc:
 967				// line continuation
 968				line = line[len(line)-1:]
 969				esc = false
 970			case b == '\n':
 971				return line, nil
 972			default:
 973				line = append(line, b)
 974				esc = false
 975			}
 976		}
 977		if err != nil {
 978			return line, err
 979		}
 980	}
 981}
 982
 983func (r *Runner) changeDir(ctx context.Context, path string) int {
 984	path = cmp.Or(path, ".")
 985	path = r.absPath(path)
 986	info, err := r.stat(ctx, path)
 987	if err != nil || !info.IsDir() {
 988		return 1
 989	}
 990	if r.access(ctx, path, access_X_OK) != nil {
 991		return 1
 992	}
 993	r.Dir = path
 994	r.setVarString("OLDPWD", r.envGet("PWD"))
 995	r.setVarString("PWD", path)
 996	return 0
 997}
 998
 999func absPath(dir, path string) string {
1000	if path == "" {
1001		return ""
1002	}
1003	if !filepath.IsAbs(path) {
1004		path = filepath.Join(dir, path)
1005	}
1006	return filepath.Clean(path) // TODO: this clean is likely unnecessary
1007}
1008
1009func (r *Runner) absPath(path string) string {
1010	return absPath(r.Dir, path)
1011}
1012
1013// flagParser is used to parse builtin flags.
1014//
1015// It's similar to the getopts implementation, but with some key differences.
1016// First, the API is designed for Go loops, making it easier to use directly.
1017// Second, it doesn't require the awkward ":ab" syntax that getopts uses.
1018// Third, it supports "-a" flags as well as "+a".
1019type flagParser struct {
1020	current   string
1021	remaining []string
1022}
1023
1024func (p *flagParser) more() bool {
1025	if p.current != "" {
1026		// We're still parsing part of "-ab".
1027		return true
1028	}
1029	if len(p.remaining) == 0 {
1030		// Nothing left.
1031		p.remaining = nil
1032		return false
1033	}
1034	arg := p.remaining[0]
1035	if arg == "--" {
1036		// We explicitly stop parsing flags.
1037		p.remaining = p.remaining[1:]
1038		return false
1039	}
1040	if len(arg) == 0 || (arg[0] != '-' && arg[0] != '+') {
1041		// The next argument is not a flag.
1042		return false
1043	}
1044	// More flags to come.
1045	return true
1046}
1047
1048func (p *flagParser) flag() string {
1049	arg := p.current
1050	if arg == "" {
1051		arg = p.remaining[0]
1052		p.remaining = p.remaining[1:]
1053	} else {
1054		p.current = ""
1055	}
1056	if len(arg) > 2 {
1057		// We have "-ab", so return "-a" and keep "-b".
1058		p.current = arg[:1] + arg[2:]
1059		arg = arg[:2]
1060	}
1061	return arg
1062}
1063
1064func (p *flagParser) value() string {
1065	if len(p.remaining) == 0 {
1066		return ""
1067	}
1068	arg := p.remaining[0]
1069	p.remaining = p.remaining[1:]
1070	return arg
1071}
1072
1073func (p *flagParser) args() []string { return p.remaining }
1074
1075type getopts struct {
1076	argidx  int
1077	runeidx int
1078}
1079
1080func (g *getopts) next(optstr string, args []string) (opt rune, optarg string, done bool) {
1081	if len(args) == 0 || g.argidx >= len(args) {
1082		return '?', "", true
1083	}
1084	arg := []rune(args[g.argidx])
1085	if len(arg) < 2 || arg[0] != '-' || arg[1] == '-' {
1086		return '?', "", true
1087	}
1088
1089	opts := arg[1:]
1090	opt = opts[g.runeidx]
1091	if g.runeidx+1 < len(opts) {
1092		g.runeidx++
1093	} else {
1094		g.argidx++
1095		g.runeidx = 0
1096	}
1097
1098	i := strings.IndexRune(optstr, opt)
1099	if i < 0 {
1100		// invalid option
1101		return '?', string(opt), false
1102	}
1103
1104	if i+1 < len(optstr) && optstr[i+1] == ':' {
1105		if g.argidx >= len(args) {
1106			// missing argument
1107			return ':', string(opt), false
1108		}
1109		optarg = args[g.argidx]
1110		g.argidx++
1111		g.runeidx = 0
1112	}
1113
1114	return opt, optarg, false
1115}
1116
1117// optStatusText returns a shell option's status text display
1118func (r *Runner) optStatusText(status bool) string {
1119	if status {
1120		return "on"
1121	}
1122	return "off"
1123}