expand.go

   1// Copyright (c) 2017, Daniel MartΓ­ <mvdan@mvdan.cc>
   2// See LICENSE for licensing information
   3
   4package expand
   5
   6import (
   7	"cmp"
   8	"errors"
   9	"fmt"
  10	"io"
  11	"io/fs"
  12	"iter"
  13	"maps"
  14	"os"
  15	"os/user"
  16	"path/filepath"
  17	"regexp"
  18	"runtime"
  19	"slices"
  20	"strconv"
  21	"strings"
  22
  23	"mvdan.cc/sh/v3/pattern"
  24	"mvdan.cc/sh/v3/syntax"
  25)
  26
  27// A Config specifies details about how shell expansion should be performed. The
  28// zero value is a valid configuration.
  29type Config struct {
  30	// Env is used to get and set environment variables when performing
  31	// shell expansions. Some special parameters are also expanded via this
  32	// interface, such as:
  33	//
  34	//   * "#", "@", "*", "0"-"9" for the shell's parameters
  35	//   * "?", "$", "PPID" for the shell's status and process
  36	//   * "HOME foo" to retrieve user foo's home directory (if unset,
  37	//     os/user.Lookup will be used)
  38	//
  39	// If nil, there are no environment variables set. Use
  40	// ListEnviron(os.Environ()...) to use the system's environment
  41	// variables.
  42	Env Environ
  43
  44	// CmdSubst expands a command substitution node, writing its standard
  45	// output to the provided [io.Writer].
  46	//
  47	// If nil, encountering a command substitution will result in an
  48	// UnexpectedCommandError.
  49	CmdSubst func(io.Writer, *syntax.CmdSubst) error
  50
  51	// ProcSubst expands a process substitution node.
  52	//
  53	// Note that this feature is a work in progress, and the signature of
  54	// this field might change until #451 is completely fixed.
  55	ProcSubst func(*syntax.ProcSubst) (string, error)
  56
  57	// TODO(v4): replace ReadDir with ReadDir2.
  58
  59	// ReadDir is the older form of [ReadDir2], before io/fs.
  60	//
  61	// Deprecated: use ReadDir2 instead.
  62	ReadDir func(string) ([]fs.FileInfo, error)
  63
  64	// ReadDir2 is used for file path globbing.
  65	// If nil, and [ReadDir] is nil as well, globbing is disabled.
  66	// Use [os.ReadDir] to use the filesystem directly.
  67	ReadDir2 func(string) ([]fs.DirEntry, error)
  68
  69	// GlobStar corresponds to the shell option that allows globbing with
  70	// "**".
  71	GlobStar bool
  72
  73	// NoCaseGlob corresponds to the shell option that causes case-insensitive
  74	// pattern matching in pathname expansion.
  75	NoCaseGlob bool
  76
  77	// NullGlob corresponds to the shell option that allows globbing
  78	// patterns which match nothing to result in zero fields.
  79	NullGlob bool
  80
  81	// NoUnset corresponds to the shell option that treats unset variables
  82	// as errors.
  83	NoUnset bool
  84
  85	bufferAlloc strings.Builder
  86	fieldAlloc  [4]fieldPart
  87	fieldsAlloc [4][]fieldPart
  88
  89	ifs string
  90	// A pointer to a parameter expansion node, if we're inside one.
  91	// Necessary for ${LINENO}.
  92	curParam *syntax.ParamExp
  93}
  94
  95// UnexpectedCommandError is returned if a command substitution is encountered
  96// when [Config.CmdSubst] is nil.
  97type UnexpectedCommandError struct {
  98	Node *syntax.CmdSubst
  99}
 100
 101func (u UnexpectedCommandError) Error() string {
 102	return fmt.Sprintf("unexpected command substitution at %s", u.Node.Pos())
 103}
 104
 105var zeroConfig = &Config{}
 106
 107// TODO: note that prepareConfig is modifying the user's config in place,
 108// which doesn't feel right - we should make a copy.
 109
 110func prepareConfig(cfg *Config) *Config {
 111	cfg = cmp.Or(cfg, zeroConfig)
 112	cfg.Env = cmp.Or(cfg.Env, FuncEnviron(func(string) string { return "" }))
 113
 114	cfg.ifs = " \t\n"
 115	if vr := cfg.Env.Get("IFS"); vr.IsSet() {
 116		cfg.ifs = vr.String()
 117	}
 118
 119	if cfg.ReadDir != nil && cfg.ReadDir2 == nil {
 120		cfg.ReadDir2 = func(path string) ([]fs.DirEntry, error) {
 121			infos, err := cfg.ReadDir(path)
 122			if err != nil {
 123				return nil, err
 124			}
 125			entries := make([]fs.DirEntry, len(infos))
 126			for i, info := range infos {
 127				entries[i] = fs.FileInfoToDirEntry(info)
 128			}
 129			return entries, nil
 130		}
 131	}
 132	return cfg
 133}
 134
 135func (cfg *Config) ifsRune(r rune) bool {
 136	for _, r2 := range cfg.ifs {
 137		if r == r2 {
 138			return true
 139		}
 140	}
 141	return false
 142}
 143
 144func (cfg *Config) ifsJoin(strs []string) string {
 145	sep := ""
 146	if cfg.ifs != "" {
 147		sep = cfg.ifs[:1]
 148	}
 149	return strings.Join(strs, sep)
 150}
 151
 152func (cfg *Config) strBuilder() *strings.Builder {
 153	b := &cfg.bufferAlloc
 154	b.Reset()
 155	return b
 156}
 157
 158func (cfg *Config) envGet(name string) string {
 159	return cfg.Env.Get(name).String()
 160}
 161
 162func (cfg *Config) envSet(name, value string) error {
 163	wenv, ok := cfg.Env.(WriteEnviron)
 164	if !ok {
 165		return fmt.Errorf("environment is read-only")
 166	}
 167	return wenv.Set(name, Variable{Set: true, Kind: String, Str: value})
 168}
 169
 170// Literal expands a single shell word. It is similar to [Fields], but the result
 171// is a single string. This is the behavior when a word is used as the value in
 172// a shell variable assignment, for example.
 173//
 174// The config specifies shell expansion options; nil behaves the same as an
 175// empty config.
 176func Literal(cfg *Config, word *syntax.Word) (string, error) {
 177	if word == nil {
 178		return "", nil
 179	}
 180	cfg = prepareConfig(cfg)
 181	field, err := cfg.wordField(word.Parts, quoteNone)
 182	if err != nil {
 183		return "", err
 184	}
 185	return cfg.fieldJoin(field), nil
 186}
 187
 188// Document expands a single shell word as if it were a here-document body.
 189// It is similar to [Literal], but without brace expansion, tilde expansion, and
 190// globbing.
 191//
 192// The config specifies shell expansion options; nil behaves the same as an
 193// empty config.
 194func Document(cfg *Config, word *syntax.Word) (string, error) {
 195	if word == nil {
 196		return "", nil
 197	}
 198	cfg = prepareConfig(cfg)
 199	field, err := cfg.wordField(word.Parts, quoteSingle)
 200	if err != nil {
 201		return "", err
 202	}
 203	return cfg.fieldJoin(field), nil
 204}
 205
 206const patMode = pattern.Filenames | pattern.Braces
 207
 208// Pattern expands a single shell word as a pattern, using [pattern.QuoteMeta]
 209// on any non-quoted parts of the input word. The result can be used on
 210// [pattern.Regexp] directly.
 211//
 212// The config specifies shell expansion options; nil behaves the same as an
 213// empty config.
 214func Pattern(cfg *Config, word *syntax.Word) (string, error) {
 215	if word == nil {
 216		return "", nil
 217	}
 218	cfg = prepareConfig(cfg)
 219	field, err := cfg.wordField(word.Parts, quoteNone)
 220	if err != nil {
 221		return "", err
 222	}
 223	sb := cfg.strBuilder()
 224	for _, part := range field {
 225		if part.quote > quoteNone {
 226			sb.WriteString(pattern.QuoteMeta(part.val, patMode))
 227		} else {
 228			sb.WriteString(part.val)
 229		}
 230	}
 231	return sb.String(), nil
 232}
 233
 234// Format expands a format string with a number of arguments, following the
 235// shell's format specifications. These include printf(1), among others.
 236//
 237// The resulting string is returned, along with the number of arguments used.
 238//
 239// The config specifies shell expansion options; nil behaves the same as an
 240// empty config.
 241func Format(cfg *Config, format string, args []string) (string, int, error) {
 242	cfg = prepareConfig(cfg)
 243	sb := cfg.strBuilder()
 244
 245	consumed, err := formatInto(sb, format, args)
 246	if err != nil {
 247		return "", 0, err
 248	}
 249
 250	return sb.String(), consumed, err
 251}
 252
 253func formatInto(sb *strings.Builder, format string, args []string) (int, error) {
 254	var fmts []byte
 255	initialArgs := len(args)
 256
 257formatLoop:
 258	for i := 0; i < len(format); i++ {
 259		// readDigits reads from 0 to max digits, either octal or
 260		// hexadecimal.
 261		readDigits := func(max int, hex bool) string {
 262			j := 0
 263			for ; j < max; j++ {
 264				c := format[i+j]
 265				if (c >= '0' && c <= '9') ||
 266					(hex && c >= 'a' && c <= 'f') ||
 267					(hex && c >= 'A' && c <= 'F') {
 268					// valid octal or hex char
 269				} else {
 270					break
 271				}
 272			}
 273			digits := format[i : i+j]
 274			i += j - 1 // -1 since the outer loop does i++
 275			return digits
 276		}
 277		c := format[i]
 278		switch {
 279		case c == '\\': // escaped
 280			i++
 281			switch c = format[i]; c {
 282			case 'a': // bell
 283				sb.WriteByte('\a')
 284			case 'b': // backspace
 285				sb.WriteByte('\b')
 286			case 'e', 'E': // escape
 287				sb.WriteByte('\x1b')
 288			case 'f': // form feed
 289				sb.WriteByte('\f')
 290			case 'n': // new line
 291				sb.WriteByte('\n')
 292			case 'r': // carriage return
 293				sb.WriteByte('\r')
 294			case 't': // horizontal tab
 295				sb.WriteByte('\t')
 296			case 'v': // vertical tab
 297				sb.WriteByte('\v')
 298			case '\\', '\'', '"', '?': // just the character
 299				sb.WriteByte(c)
 300			case '0', '1', '2', '3', '4', '5', '6', '7':
 301				digits := readDigits(3, false)
 302				// if digits don't fit in 8 bits, 0xff via strconv
 303				n, _ := strconv.ParseUint(digits, 8, 8)
 304				sb.WriteByte(byte(n))
 305			case 'x', 'u', 'U':
 306				i++
 307				max := 2
 308				switch c {
 309				case 'u':
 310					max = 4
 311				case 'U':
 312					max = 8
 313				}
 314				digits := readDigits(max, true)
 315				if len(digits) > 0 {
 316					// can't error
 317					n, _ := strconv.ParseUint(digits, 16, 32)
 318					if n == 0 {
 319						// If we're about to print \x00,
 320						// stop the entire loop, like bash.
 321						break formatLoop
 322					}
 323					if c == 'x' {
 324						// always as a single byte
 325						sb.WriteByte(byte(n))
 326					} else {
 327						sb.WriteRune(rune(n))
 328					}
 329					break
 330				}
 331				fallthrough
 332			default: // no escape sequence
 333				sb.WriteByte('\\')
 334				sb.WriteByte(c)
 335			}
 336		case len(fmts) > 0:
 337			switch c {
 338			case '%':
 339				sb.WriteByte('%')
 340				fmts = nil
 341			case 'c':
 342				var b byte
 343				if len(args) > 0 {
 344					arg := ""
 345					arg, args = args[0], args[1:]
 346					if len(arg) > 0 {
 347						b = arg[0]
 348					}
 349				}
 350				sb.WriteByte(b)
 351				fmts = nil
 352			case '+', '-', ' ':
 353				if len(fmts) > 1 {
 354					return 0, fmt.Errorf("invalid format char: %c", c)
 355				}
 356				fmts = append(fmts, c)
 357			case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
 358				fmts = append(fmts, c)
 359			case 's', 'b', 'd', 'i', 'u', 'o', 'x':
 360				arg := ""
 361				if len(args) > 0 {
 362					arg, args = args[0], args[1:]
 363				}
 364				var farg any
 365				if c == 'b' {
 366					// Passing in nil for args ensures that % format
 367					// strings aren't processed; only escape sequences
 368					// will be handled.
 369					_, err := formatInto(sb, arg, nil)
 370					if err != nil {
 371						return 0, err
 372					}
 373				} else if c != 's' {
 374					n, _ := strconv.ParseInt(arg, 0, 0)
 375					if c == 'i' || c == 'd' {
 376						farg = int(n)
 377					} else {
 378						farg = uint(n)
 379					}
 380					if c == 'i' || c == 'u' {
 381						c = 'd'
 382					}
 383				} else {
 384					farg = arg
 385				}
 386				if farg != nil {
 387					fmts = append(fmts, c)
 388					fmt.Fprintf(sb, string(fmts), farg)
 389				}
 390				fmts = nil
 391			default:
 392				return 0, fmt.Errorf("invalid format char: %c", c)
 393			}
 394		case args != nil && c == '%':
 395			// if args == nil, we are not doing format
 396			// arguments
 397			fmts = []byte{c}
 398		default:
 399			sb.WriteByte(c)
 400		}
 401	}
 402	if len(fmts) > 0 {
 403		return 0, fmt.Errorf("missing format char")
 404	}
 405	return initialArgs - len(args), nil
 406}
 407
 408func (cfg *Config) fieldJoin(parts []fieldPart) string {
 409	switch len(parts) {
 410	case 0:
 411		return ""
 412	case 1: // short-cut without a string copy
 413		return parts[0].val
 414	}
 415	sb := cfg.strBuilder()
 416	for _, part := range parts {
 417		sb.WriteString(part.val)
 418	}
 419	return sb.String()
 420}
 421
 422func (cfg *Config) escapedGlobField(parts []fieldPart) (escaped string, glob bool) {
 423	sb := cfg.strBuilder()
 424	for _, part := range parts {
 425		if part.quote > quoteNone {
 426			sb.WriteString(pattern.QuoteMeta(part.val, patMode))
 427			continue
 428		}
 429		sb.WriteString(part.val)
 430		if pattern.HasMeta(part.val, patMode) {
 431			glob = true
 432		}
 433	}
 434	if glob { // only copy the string if it will be used
 435		escaped = sb.String()
 436	}
 437	return escaped, glob
 438}
 439
 440// Fields is a pre-iterators API which now wraps [FieldsSeq].
 441func Fields(cfg *Config, words ...*syntax.Word) ([]string, error) {
 442	var fields []string
 443	for s, err := range FieldsSeq(cfg, words...) {
 444		if err != nil {
 445			return nil, err
 446		}
 447		fields = append(fields, s)
 448	}
 449	return fields, nil
 450}
 451
 452// Fields expands a number of words as if they were arguments in a shell
 453// command. This includes brace expansion, tilde expansion, parameter expansion,
 454// command substitution, arithmetic expansion, and quote removal.
 455func FieldsSeq(cfg *Config, words ...*syntax.Word) iter.Seq2[string, error] {
 456	cfg = prepareConfig(cfg)
 457	dir := cfg.envGet("PWD")
 458	return func(yield func(string, error) bool) {
 459		for _, word := range words {
 460			word := *word // make a copy, since SplitBraces replaces the Parts slice
 461			afterBraces := []*syntax.Word{&word}
 462			if syntax.SplitBraces(&word) {
 463				afterBraces = Braces(&word)
 464			}
 465			for _, word2 := range afterBraces {
 466				wfields, err := cfg.wordFields(word2.Parts)
 467				if err != nil {
 468					yield("", err)
 469					return
 470				}
 471				for _, field := range wfields {
 472					path, doGlob := cfg.escapedGlobField(field)
 473					if doGlob && cfg.ReadDir2 != nil {
 474						// Note that globbing requires keeping a slice state, so it doesn't
 475						// really benefit from using an iterator.
 476						matches, err := cfg.glob(dir, path)
 477						if err != nil {
 478							// We avoid [errors.As] as it allocates,
 479							// and we know that [Config.glob] returns [pattern.Regexp] errors without wrapping.
 480							if _, ok := err.(*pattern.SyntaxError); !ok {
 481								yield("", err)
 482								return
 483							}
 484						} else if len(matches) > 0 || cfg.NullGlob {
 485							for _, m := range matches {
 486								yield(m, nil)
 487							}
 488							continue
 489						}
 490					}
 491					yield(cfg.fieldJoin(field), nil)
 492				}
 493			}
 494		}
 495	}
 496}
 497
 498type fieldPart struct {
 499	val   string
 500	quote quoteLevel
 501}
 502
 503type quoteLevel uint
 504
 505const (
 506	quoteNone quoteLevel = iota
 507	quoteDouble
 508	quoteSingle
 509)
 510
 511func (cfg *Config) wordField(wps []syntax.WordPart, ql quoteLevel) ([]fieldPart, error) {
 512	var field []fieldPart
 513	for i, wp := range wps {
 514		switch wp := wp.(type) {
 515		case *syntax.Lit:
 516			s := wp.Value
 517			if i == 0 && ql == quoteNone {
 518				if prefix, rest := cfg.expandUser(s); prefix != "" {
 519					// TODO: return two separate fieldParts,
 520					// like in wordFields?
 521					s = prefix + rest
 522				}
 523			}
 524			if ql == quoteDouble && strings.Contains(s, "\\") {
 525				sb := cfg.strBuilder()
 526				for i := 0; i < len(s); i++ {
 527					b := s[i]
 528					if b == '\\' && i+1 < len(s) {
 529						switch s[i+1] {
 530						case '"', '\\', '$', '`': // special chars
 531							i++
 532							b = s[i] // write the special char, skipping the backslash
 533						}
 534					}
 535					sb.WriteByte(b)
 536				}
 537				s = sb.String()
 538			}
 539			if i := strings.IndexByte(s, '\x00'); i >= 0 {
 540				s = s[:i]
 541			}
 542			field = append(field, fieldPart{val: s})
 543		case *syntax.SglQuoted:
 544			fp := fieldPart{quote: quoteSingle, val: wp.Value}
 545			if wp.Dollar {
 546				fp.val, _, _ = Format(cfg, fp.val, nil)
 547			}
 548			field = append(field, fp)
 549		case *syntax.DblQuoted:
 550			wfield, err := cfg.wordField(wp.Parts, quoteDouble)
 551			if err != nil {
 552				return nil, err
 553			}
 554			for _, part := range wfield {
 555				part.quote = quoteDouble
 556				field = append(field, part)
 557			}
 558		case *syntax.ParamExp:
 559			val, err := cfg.paramExp(wp)
 560			if err != nil {
 561				return nil, err
 562			}
 563			field = append(field, fieldPart{val: val})
 564		case *syntax.CmdSubst:
 565			val, err := cfg.cmdSubst(wp)
 566			if err != nil {
 567				return nil, err
 568			}
 569			field = append(field, fieldPart{val: val})
 570		case *syntax.ArithmExp:
 571			n, err := Arithm(cfg, wp.X)
 572			if err != nil {
 573				return nil, err
 574			}
 575			field = append(field, fieldPart{val: strconv.Itoa(n)})
 576		case *syntax.ProcSubst:
 577			path, err := cfg.ProcSubst(wp)
 578			if err != nil {
 579				return nil, err
 580			}
 581			field = append(field, fieldPart{val: path})
 582		default:
 583			panic(fmt.Sprintf("unhandled word part: %T", wp))
 584		}
 585	}
 586	return field, nil
 587}
 588
 589func (cfg *Config) cmdSubst(cs *syntax.CmdSubst) (string, error) {
 590	if cfg.CmdSubst == nil {
 591		return "", UnexpectedCommandError{Node: cs}
 592	}
 593	sb := cfg.strBuilder()
 594	if err := cfg.CmdSubst(sb, cs); err != nil {
 595		return "", err
 596	}
 597	out := sb.String()
 598	if strings.IndexByte(out, '\x00') >= 0 {
 599		out = strings.ReplaceAll(out, "\x00", "")
 600	}
 601	return strings.TrimRight(out, "\n"), nil
 602}
 603
 604func (cfg *Config) wordFields(wps []syntax.WordPart) ([][]fieldPart, error) {
 605	fields := cfg.fieldsAlloc[:0]
 606	curField := cfg.fieldAlloc[:0]
 607	allowEmpty := false
 608	flush := func() {
 609		if len(curField) == 0 {
 610			return
 611		}
 612		fields = append(fields, curField)
 613		curField = nil
 614	}
 615	splitAdd := func(val string) {
 616		fieldStart := -1
 617		for i, r := range val {
 618			if cfg.ifsRune(r) {
 619				if fieldStart >= 0 { // ending a field
 620					curField = append(curField, fieldPart{val: val[fieldStart:i]})
 621					fieldStart = -1
 622				}
 623				flush()
 624			} else {
 625				if fieldStart < 0 { // starting a new field
 626					fieldStart = i
 627				}
 628			}
 629		}
 630		if fieldStart >= 0 { // ending a field without IFS
 631			curField = append(curField, fieldPart{val: val[fieldStart:]})
 632		}
 633	}
 634	for i, wp := range wps {
 635		switch wp := wp.(type) {
 636		case *syntax.Lit:
 637			s := wp.Value
 638			if i == 0 {
 639				prefix, rest := cfg.expandUser(s)
 640				curField = append(curField, fieldPart{
 641					quote: quoteSingle,
 642					val:   prefix,
 643				})
 644				s = rest
 645			}
 646			if strings.Contains(s, "\\") {
 647				sb := cfg.strBuilder()
 648				for i := 0; i < len(s); i++ {
 649					b := s[i]
 650					if b == '\\' {
 651						if i++; i >= len(s) {
 652							break
 653						}
 654						b = s[i]
 655					}
 656					sb.WriteByte(b)
 657				}
 658				s = sb.String()
 659			}
 660			curField = append(curField, fieldPart{val: s})
 661		case *syntax.SglQuoted:
 662			allowEmpty = true
 663			fp := fieldPart{quote: quoteSingle, val: wp.Value}
 664			if wp.Dollar {
 665				fp.val, _, _ = Format(cfg, fp.val, nil)
 666			}
 667			curField = append(curField, fp)
 668		case *syntax.DblQuoted:
 669			if len(wp.Parts) == 1 {
 670				pe, _ := wp.Parts[0].(*syntax.ParamExp)
 671				if elems := cfg.quotedElemFields(pe); elems != nil {
 672					for i, elem := range elems {
 673						if i > 0 {
 674							flush()
 675						}
 676						curField = append(curField, fieldPart{
 677							quote: quoteDouble,
 678							val:   elem,
 679						})
 680					}
 681					continue
 682				}
 683			}
 684			allowEmpty = true
 685			wfield, err := cfg.wordField(wp.Parts, quoteDouble)
 686			if err != nil {
 687				return nil, err
 688			}
 689			for _, part := range wfield {
 690				part.quote = quoteDouble
 691				curField = append(curField, part)
 692			}
 693		case *syntax.ParamExp:
 694			val, err := cfg.paramExp(wp)
 695			if err != nil {
 696				return nil, err
 697			}
 698			splitAdd(val)
 699		case *syntax.CmdSubst:
 700			val, err := cfg.cmdSubst(wp)
 701			if err != nil {
 702				return nil, err
 703			}
 704			splitAdd(val)
 705		case *syntax.ArithmExp:
 706			n, err := Arithm(cfg, wp.X)
 707			if err != nil {
 708				return nil, err
 709			}
 710			curField = append(curField, fieldPart{val: strconv.Itoa(n)})
 711		case *syntax.ProcSubst:
 712			path, err := cfg.ProcSubst(wp)
 713			if err != nil {
 714				return nil, err
 715			}
 716			splitAdd(path)
 717		case *syntax.ExtGlob:
 718			return nil, fmt.Errorf("extended globbing is not supported")
 719		default:
 720			panic(fmt.Sprintf("unhandled word part: %T", wp))
 721		}
 722	}
 723	flush()
 724	if allowEmpty && len(fields) == 0 {
 725		fields = append(fields, curField)
 726	}
 727	return fields, nil
 728}
 729
 730// quotedElemFields returns the list of elements resulting from a quoted
 731// parameter expansion that should be treated especially, like "${foo[@]}".
 732func (cfg *Config) quotedElemFields(pe *syntax.ParamExp) []string {
 733	if pe == nil || pe.Length || pe.Width {
 734		return nil
 735	}
 736	name := pe.Param.Value
 737	if pe.Excl {
 738		switch pe.Names {
 739		case syntax.NamesPrefixWords: // "${!prefix@}"
 740			return cfg.namesByPrefix(pe.Param.Value)
 741		case syntax.NamesPrefix: // "${!prefix*}"
 742			return nil
 743		}
 744		switch nodeLit(pe.Index) {
 745		case "@": // "${!name[@]}"
 746			switch vr := cfg.Env.Get(name); vr.Kind {
 747			case Indexed:
 748				// TODO: if an indexed array only has elements 0 and 10,
 749				// we should not return all indices in between those.
 750				keys := make([]string, 0, len(vr.List))
 751				for key := range vr.List {
 752					keys = append(keys, strconv.Itoa(key))
 753				}
 754				return keys
 755			case Associative:
 756				return slices.Collect(maps.Keys(vr.Map))
 757			}
 758		}
 759		return nil
 760	}
 761	switch name {
 762	case "*": // "${*}"
 763		return []string{cfg.ifsJoin(cfg.Env.Get(name).List)}
 764	case "@": // "${@}"
 765		return cfg.Env.Get(name).List
 766	}
 767	switch nodeLit(pe.Index) {
 768	case "@": // "${name[@]}"
 769		switch vr := cfg.Env.Get(name); vr.Kind {
 770		case Indexed:
 771			return vr.List
 772		case Associative:
 773			return slices.Collect(maps.Values(vr.Map))
 774		}
 775	case "*": // "${name[*]}"
 776		if vr := cfg.Env.Get(name); vr.Kind == Indexed {
 777			return []string{cfg.ifsJoin(vr.List)}
 778		}
 779	}
 780	return nil
 781}
 782
 783func (cfg *Config) expandUser(field string) (prefix, rest string) {
 784	if len(field) == 0 || field[0] != '~' {
 785		return "", field
 786	}
 787	name := field[1:]
 788	if i := strings.Index(name, "/"); i >= 0 {
 789		rest = name[i:]
 790		name = name[:i]
 791	}
 792	if name == "" {
 793		// Current user; try via "HOME", otherwise fall back to the
 794		// system's appropriate home dir env var. Don't use os/user, as
 795		// that's overkill. We can't use [os.UserHomeDir], because we want
 796		// to use cfg.Env, and we always want to check "HOME" first.
 797
 798		if vr := cfg.Env.Get("HOME"); vr.IsSet() {
 799			return vr.String(), rest
 800		}
 801
 802		if runtime.GOOS == "windows" {
 803			if vr := cfg.Env.Get("USERPROFILE"); vr.IsSet() {
 804				return vr.String(), rest
 805			}
 806		}
 807		return "", field
 808	}
 809
 810	// Not the current user; try via "HOME <name>", otherwise fall back to
 811	// os/user. There isn't a way to lookup user home dirs without cgo.
 812
 813	if vr := cfg.Env.Get("HOME " + name); vr.IsSet() {
 814		return vr.String(), rest
 815	}
 816
 817	u, err := user.Lookup(name)
 818	if err != nil {
 819		return "", field
 820	}
 821	return u.HomeDir, rest
 822}
 823
 824func findAllIndex(pat, name string, n int) [][]int {
 825	expr, err := pattern.Regexp(pat, 0)
 826	if err != nil {
 827		return nil
 828	}
 829	rx := regexp.MustCompile(expr)
 830	return rx.FindAllStringIndex(name, n)
 831}
 832
 833var rxGlobStar = regexp.MustCompile(".*")
 834
 835// pathJoin2 is a simpler version of [filepath.Join] without cleaning the result,
 836// since that's needed for globbing.
 837func pathJoin2(elem1, elem2 string) string {
 838	if elem1 == "" {
 839		return elem2
 840	}
 841	if strings.HasSuffix(elem1, string(filepath.Separator)) {
 842		return elem1 + elem2
 843	}
 844	return elem1 + string(filepath.Separator) + elem2
 845}
 846
 847// pathSplit splits a file path into its elements, retaining empty ones. Before
 848// splitting, slashes are replaced with [filepath.Separator], so that splitting
 849// Unix paths on Windows works as well.
 850func pathSplit(path string) []string {
 851	path = filepath.FromSlash(path)
 852	return strings.Split(path, string(filepath.Separator))
 853}
 854
 855func (cfg *Config) glob(base, pat string) ([]string, error) {
 856	parts := pathSplit(pat)
 857	matches := []string{""}
 858	if filepath.IsAbs(pat) {
 859		if parts[0] == "" {
 860			// unix-like
 861			matches[0] = string(filepath.Separator)
 862		} else {
 863			// windows (for some reason it won't work without the
 864			// trailing separator)
 865			matches[0] = parts[0] + string(filepath.Separator)
 866		}
 867		parts = parts[1:]
 868	}
 869	// TODO: as an optimization, we could do chunks of the path all at once,
 870	// like doing a single stat for "/foo/bar" in "/foo/bar/*".
 871
 872	// TODO: Another optimization would be to reduce the number of ReadDir2 calls.
 873	// For example, /foo/* can end up doing one duplicate call:
 874	//
 875	//    ReadDir2("/foo") to ensure that "/foo/" exists and only matches a directory
 876	//    ReadDir2("/foo") glob "*"
 877
 878	for i, part := range parts {
 879		// Keep around for debugging.
 880		// log.Printf("matches %q part %d %q", matches, i, part)
 881
 882		wantDir := i < len(parts)-1
 883		switch {
 884		case part == "", part == ".", part == "..":
 885			for i, dir := range matches {
 886				matches[i] = pathJoin2(dir, part)
 887			}
 888			continue
 889		case !pattern.HasMeta(part, patMode):
 890			var newMatches []string
 891			for _, dir := range matches {
 892				match := dir
 893				if !filepath.IsAbs(match) {
 894					match = filepath.Join(base, match)
 895				}
 896				match = pathJoin2(match, part)
 897				// We can't use [Config.ReadDir2] on the parent and match the directory
 898				// entry by name, because short paths on Windows break that.
 899				// Our only option is to [Config.ReadDir2] on the directory entry itself,
 900				// which can be wasteful if we only want to see if it exists,
 901				// but at least it's correct in all scenarios.
 902				if _, err := cfg.ReadDir2(match); err != nil {
 903					if isWindowsErrPathNotFound(err) {
 904						// Unfortunately, [os.File.Readdir] on a regular file on
 905						// Windows returns an error that satisfies [fs.ErrNotExist].
 906						// Luckily, it returns a special "path not found" rather
 907						// than the normal "file not found" for missing files,
 908						// so we can use that knowledge to work around the bug.
 909						// See https://github.com/golang/go/issues/46734.
 910						// TODO: remove when the Go issue above is resolved.
 911					} else if errors.Is(err, fs.ErrNotExist) {
 912						continue // simply doesn't exist
 913					}
 914					if wantDir {
 915						continue // exists but not a directory
 916					}
 917				}
 918				newMatches = append(newMatches, pathJoin2(dir, part))
 919			}
 920			matches = newMatches
 921			continue
 922		case part == "**" && cfg.GlobStar:
 923			// Find all recursive matches for "**".
 924			// Note that we need the results to be in depth-first order,
 925			// and to avoid recursion, we use a slice as a stack.
 926			// Since we pop from the back, we populate the stack backwards.
 927			stack := make([]string, 0, len(matches))
 928			for _, match := range slices.Backward(matches) {
 929				// "a/**" should match "a/ a/b a/b/cfg ...";
 930				// note how the zero-match case has a trailing separator.
 931				stack = append(stack, pathJoin2(match, ""))
 932			}
 933			matches = matches[:0]
 934			var newMatches []string // to reuse its capacity
 935			for len(stack) > 0 {
 936				dir := stack[len(stack)-1]
 937				stack = stack[:len(stack)-1]
 938
 939				// Don't include the original "" match as it's not a valid path.
 940				if dir != "" {
 941					matches = append(matches, dir)
 942				}
 943
 944				// If dir is not a directory, we keep the stack as-is and continue.
 945				newMatches = newMatches[:0]
 946				newMatches, _ = cfg.globDir(base, dir, rxGlobStar, false, wantDir, newMatches)
 947				for _, match := range slices.Backward(newMatches) {
 948					stack = append(stack, match)
 949				}
 950			}
 951			continue
 952		}
 953		mode := pattern.Filenames | pattern.EntireString
 954		if cfg.NoCaseGlob {
 955			mode |= pattern.NoGlobCase
 956		}
 957		expr, err := pattern.Regexp(part, mode)
 958		if err != nil {
 959			return nil, err
 960		}
 961		rx := regexp.MustCompile(expr)
 962		matchHidden := part[0] == byte('.')
 963		var newMatches []string
 964		for _, dir := range matches {
 965			newMatches, err = cfg.globDir(base, dir, rx, matchHidden, wantDir, newMatches)
 966			if err != nil {
 967				return nil, err
 968			}
 969		}
 970		matches = newMatches
 971	}
 972	return matches, nil
 973}
 974
 975func (cfg *Config) globDir(base, dir string, rx *regexp.Regexp, matchHidden bool, wantDir bool, matches []string) ([]string, error) {
 976	fullDir := dir
 977	if !filepath.IsAbs(dir) {
 978		fullDir = filepath.Join(base, dir)
 979	}
 980	infos, err := cfg.ReadDir2(fullDir)
 981	if err != nil {
 982		// We still want to return matches, for the sake of reusing slices.
 983		return matches, err
 984	}
 985	for _, info := range infos {
 986		name := info.Name()
 987		if !wantDir {
 988			// No filtering.
 989		} else if mode := info.Type(); mode&os.ModeSymlink != 0 {
 990			// We need to know if the symlink points to a directory.
 991			// This requires an extra syscall, as [Config.ReadDir] on the parent directory
 992			// does not follow symlinks for each of the directory entries.
 993			// ReadDir is somewhat wasteful here, as we only want its error result,
 994			// but we could try to reuse its result as per the TODO in [Config.glob].
 995			if _, err := cfg.ReadDir2(filepath.Join(fullDir, info.Name())); err != nil {
 996				continue
 997			}
 998		} else if !mode.IsDir() {
 999			// Not a symlink nor a directory.
1000			continue
1001		}
1002		if !matchHidden && name[0] == '.' {
1003			continue
1004		}
1005		if rx.MatchString(name) {
1006			matches = append(matches, pathJoin2(dir, name))
1007		}
1008	}
1009	return matches, nil
1010}
1011
1012// ReadFields splits and returns n fields from s, like the "read" shell builtin.
1013// If raw is set, backslash escape sequences are not interpreted.
1014//
1015// The config specifies shell expansion options; nil behaves the same as an
1016// empty config.
1017func ReadFields(cfg *Config, s string, n int, raw bool) []string {
1018	cfg = prepareConfig(cfg)
1019	type pos struct {
1020		start, end int
1021	}
1022	var fpos []pos
1023
1024	runes := make([]rune, 0, len(s))
1025	infield := false
1026	esc := false
1027	for _, r := range s {
1028		if infield {
1029			if cfg.ifsRune(r) && (raw || !esc) {
1030				fpos[len(fpos)-1].end = len(runes)
1031				infield = false
1032			}
1033		} else {
1034			if !cfg.ifsRune(r) && (raw || !esc) {
1035				fpos = append(fpos, pos{start: len(runes), end: -1})
1036				infield = true
1037			}
1038		}
1039		if r == '\\' {
1040			if raw || esc {
1041				runes = append(runes, r)
1042			}
1043			esc = !esc
1044			continue
1045		}
1046		runes = append(runes, r)
1047		esc = false
1048	}
1049	if len(fpos) == 0 {
1050		return nil
1051	}
1052	if infield {
1053		fpos[len(fpos)-1].end = len(runes)
1054	}
1055
1056	switch {
1057	case n == 1:
1058		// include heading/trailing IFSs
1059		fpos[0].start, fpos[0].end = 0, len(runes)
1060		fpos = fpos[:1]
1061	case n != -1 && n < len(fpos):
1062		// combine to max n fields
1063		fpos[n-1].end = fpos[len(fpos)-1].end
1064		fpos = fpos[:n]
1065	}
1066
1067	fields := make([]string, len(fpos))
1068	for i, p := range fpos {
1069		fields[i] = string(runes[p.start:p.end])
1070	}
1071	return fields
1072}