// Copyright (c) 2017, Daniel Martí // See LICENSE for licensing information package expand import ( "cmp" "errors" "fmt" "io" "io/fs" "iter" "maps" "os" "os/user" "path/filepath" "regexp" "runtime" "slices" "strconv" "strings" "mvdan.cc/sh/v3/pattern" "mvdan.cc/sh/v3/syntax" ) // A Config specifies details about how shell expansion should be performed. The // zero value is a valid configuration. type Config struct { // Env is used to get and set environment variables when performing // shell expansions. Some special parameters are also expanded via this // interface, such as: // // * "#", "@", "*", "0"-"9" for the shell's parameters // * "?", "$", "PPID" for the shell's status and process // * "HOME foo" to retrieve user foo's home directory (if unset, // os/user.Lookup will be used) // // If nil, there are no environment variables set. Use // ListEnviron(os.Environ()...) to use the system's environment // variables. Env Environ // CmdSubst expands a command substitution node, writing its standard // output to the provided [io.Writer]. // // If nil, encountering a command substitution will result in an // UnexpectedCommandError. CmdSubst func(io.Writer, *syntax.CmdSubst) error // ProcSubst expands a process substitution node. // // Note that this feature is a work in progress, and the signature of // this field might change until #451 is completely fixed. ProcSubst func(*syntax.ProcSubst) (string, error) // TODO(v4): replace ReadDir with ReadDir2. // ReadDir is the older form of [ReadDir2], before io/fs. // // Deprecated: use ReadDir2 instead. ReadDir func(string) ([]fs.FileInfo, error) // ReadDir2 is used for file path globbing. // If nil, and [ReadDir] is nil as well, globbing is disabled. // Use [os.ReadDir] to use the filesystem directly. ReadDir2 func(string) ([]fs.DirEntry, error) // GlobStar corresponds to the shell option that allows globbing with // "**". GlobStar bool // NoCaseGlob corresponds to the shell option that causes case-insensitive // pattern matching in pathname expansion. NoCaseGlob bool // NullGlob corresponds to the shell option that allows globbing // patterns which match nothing to result in zero fields. NullGlob bool // NoUnset corresponds to the shell option that treats unset variables // as errors. NoUnset bool bufferAlloc strings.Builder fieldAlloc [4]fieldPart fieldsAlloc [4][]fieldPart ifs string // A pointer to a parameter expansion node, if we're inside one. // Necessary for ${LINENO}. curParam *syntax.ParamExp } // UnexpectedCommandError is returned if a command substitution is encountered // when [Config.CmdSubst] is nil. type UnexpectedCommandError struct { Node *syntax.CmdSubst } func (u UnexpectedCommandError) Error() string { return fmt.Sprintf("unexpected command substitution at %s", u.Node.Pos()) } var zeroConfig = &Config{} // TODO: note that prepareConfig is modifying the user's config in place, // which doesn't feel right - we should make a copy. func prepareConfig(cfg *Config) *Config { cfg = cmp.Or(cfg, zeroConfig) cfg.Env = cmp.Or(cfg.Env, FuncEnviron(func(string) string { return "" })) cfg.ifs = " \t\n" if vr := cfg.Env.Get("IFS"); vr.IsSet() { cfg.ifs = vr.String() } if cfg.ReadDir != nil && cfg.ReadDir2 == nil { cfg.ReadDir2 = func(path string) ([]fs.DirEntry, error) { infos, err := cfg.ReadDir(path) if err != nil { return nil, err } entries := make([]fs.DirEntry, len(infos)) for i, info := range infos { entries[i] = fs.FileInfoToDirEntry(info) } return entries, nil } } return cfg } func (cfg *Config) ifsRune(r rune) bool { for _, r2 := range cfg.ifs { if r == r2 { return true } } return false } func (cfg *Config) ifsJoin(strs []string) string { sep := "" if cfg.ifs != "" { sep = cfg.ifs[:1] } return strings.Join(strs, sep) } func (cfg *Config) strBuilder() *strings.Builder { b := &cfg.bufferAlloc b.Reset() return b } func (cfg *Config) envGet(name string) string { return cfg.Env.Get(name).String() } func (cfg *Config) envSet(name, value string) error { wenv, ok := cfg.Env.(WriteEnviron) if !ok { return fmt.Errorf("environment is read-only") } return wenv.Set(name, Variable{Set: true, Kind: String, Str: value}) } // Literal expands a single shell word. It is similar to [Fields], but the result // is a single string. This is the behavior when a word is used as the value in // a shell variable assignment, for example. // // The config specifies shell expansion options; nil behaves the same as an // empty config. func Literal(cfg *Config, word *syntax.Word) (string, error) { if word == nil { return "", nil } cfg = prepareConfig(cfg) field, err := cfg.wordField(word.Parts, quoteNone) if err != nil { return "", err } return cfg.fieldJoin(field), nil } // Document expands a single shell word as if it were a here-document body. // It is similar to [Literal], but without brace expansion, tilde expansion, and // globbing. // // The config specifies shell expansion options; nil behaves the same as an // empty config. func Document(cfg *Config, word *syntax.Word) (string, error) { if word == nil { return "", nil } cfg = prepareConfig(cfg) field, err := cfg.wordField(word.Parts, quoteHeredoc) if err != nil { return "", err } return cfg.fieldJoin(field), nil } // Pattern expands a single shell word as a pattern, using [pattern.QuoteMeta] // on any non-quoted parts of the input word. The result can be used on // [pattern.Regexp] directly. // // The config specifies shell expansion options; nil behaves the same as an // empty config. func Pattern(cfg *Config, word *syntax.Word) (string, error) { if word == nil { return "", nil } cfg = prepareConfig(cfg) field, err := cfg.wordField(word.Parts, quoteNone) if err != nil { return "", err } sb := cfg.strBuilder() for _, part := range field { if part.quote > quoteNone { sb.WriteString(pattern.QuoteMeta(part.val, 0)) } else { sb.WriteString(part.val) } } return sb.String(), nil } // Format expands a format string with a number of arguments, following the // shell's format specifications. These include printf(1), among others. // // The resulting string is returned, along with the number of arguments used. // // The config specifies shell expansion options; nil behaves the same as an // empty config. func Format(cfg *Config, format string, args []string) (string, int, error) { cfg = prepareConfig(cfg) sb := cfg.strBuilder() consumed, err := formatInto(sb, format, args) if err != nil { return "", 0, err } return sb.String(), consumed, err } func formatInto(sb *strings.Builder, format string, args []string) (int, error) { var fmts []byte initialArgs := len(args) formatLoop: for i := 0; i < len(format); i++ { // readDigits reads from 0 to max digits, either octal or // hexadecimal. readDigits := func(max int, hex bool) string { j := 0 for ; j < max; j++ { c := format[i+j] if (c >= '0' && c <= '9') || (hex && c >= 'a' && c <= 'f') || (hex && c >= 'A' && c <= 'F') { // valid octal or hex char } else { break } } digits := format[i : i+j] i += j - 1 // -1 since the outer loop does i++ return digits } c := format[i] switch { case c == '\\': // escaped i++ switch c = format[i]; c { case 'a': // bell sb.WriteByte('\a') case 'b': // backspace sb.WriteByte('\b') case 'e', 'E': // escape sb.WriteByte('\x1b') case 'f': // form feed sb.WriteByte('\f') case 'n': // new line sb.WriteByte('\n') case 'r': // carriage return sb.WriteByte('\r') case 't': // horizontal tab sb.WriteByte('\t') case 'v': // vertical tab sb.WriteByte('\v') case '\\', '\'', '"', '?': // just the character sb.WriteByte(c) case '0', '1', '2', '3', '4', '5', '6', '7': digits := readDigits(3, false) // if digits don't fit in 8 bits, 0xff via strconv n, _ := strconv.ParseUint(digits, 8, 8) sb.WriteByte(byte(n)) case 'x', 'u', 'U': i++ max := 2 switch c { case 'u': max = 4 case 'U': max = 8 } digits := readDigits(max, true) if len(digits) > 0 { // can't error n, _ := strconv.ParseUint(digits, 16, 32) if n == 0 { // If we're about to print \x00, // stop the entire loop, like bash. break formatLoop } if c == 'x' { // always as a single byte sb.WriteByte(byte(n)) } else { sb.WriteRune(rune(n)) } break } fallthrough default: // no escape sequence sb.WriteByte('\\') sb.WriteByte(c) } case len(fmts) > 0: switch c { case '%': sb.WriteByte('%') fmts = nil case 'c': var b byte if len(args) > 0 { arg := "" arg, args = args[0], args[1:] if len(arg) > 0 { b = arg[0] } } sb.WriteByte(b) fmts = nil case '+', '-', ' ': if len(fmts) > 1 { return 0, fmt.Errorf("invalid format char: %c", c) } fmts = append(fmts, c) case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': fmts = append(fmts, c) case 's', 'b', 'd', 'i', 'u', 'o', 'x': arg := "" if len(args) > 0 { arg, args = args[0], args[1:] } var farg any if c == 'b' { // Passing in nil for args ensures that % format // strings aren't processed; only escape sequences // will be handled. _, err := formatInto(sb, arg, nil) if err != nil { return 0, err } } else if c != 's' { n, _ := strconv.ParseInt(arg, 0, 0) if c == 'i' || c == 'd' { farg = int(n) } else { farg = uint(n) } if c == 'i' || c == 'u' { c = 'd' } } else { farg = arg } if farg != nil { fmts = append(fmts, c) fmt.Fprintf(sb, string(fmts), farg) } fmts = nil default: return 0, fmt.Errorf("invalid format char: %c", c) } case args != nil && c == '%': // if args == nil, we are not doing format // arguments fmts = []byte{c} default: sb.WriteByte(c) } } if len(fmts) > 0 { return 0, fmt.Errorf("missing format char") } return initialArgs - len(args), nil } func (cfg *Config) fieldJoin(parts []fieldPart) string { switch len(parts) { case 0: return "" case 1: // short-cut without a string copy return parts[0].val } sb := cfg.strBuilder() for _, part := range parts { sb.WriteString(part.val) } return sb.String() } func (cfg *Config) escapedGlobField(parts []fieldPart) (escaped string, glob bool) { sb := cfg.strBuilder() for _, part := range parts { if part.quote > quoteNone { sb.WriteString(pattern.QuoteMeta(part.val, 0)) continue } sb.WriteString(part.val) if pattern.HasMeta(part.val, 0) { glob = true } } if glob { // only copy the string if it will be used escaped = sb.String() } return escaped, glob } // Fields is a pre-iterators API which now wraps [FieldsSeq]. func Fields(cfg *Config, words ...*syntax.Word) ([]string, error) { var fields []string for s, err := range FieldsSeq(cfg, words...) { if err != nil { return nil, err } fields = append(fields, s) } return fields, nil } // FieldsSeq expands a number of words as if they were arguments in a shell // command. This includes brace expansion, tilde expansion, parameter expansion, // command substitution, arithmetic expansion, and quote removal. func FieldsSeq(cfg *Config, words ...*syntax.Word) iter.Seq2[string, error] { cfg = prepareConfig(cfg) dir := cfg.envGet("PWD") return func(yield func(string, error) bool) { for _, word := range words { word := *word // make a copy, since SplitBraces replaces the Parts slice afterBraces := []*syntax.Word{&word} if syntax.SplitBraces(&word) { afterBraces = Braces(&word) } for _, word2 := range afterBraces { wfields, err := cfg.wordFields(word2.Parts) if err != nil { yield("", err) return } for _, field := range wfields { path, doGlob := cfg.escapedGlobField(field) if doGlob && cfg.ReadDir2 != nil { // Note that globbing requires keeping a slice state, so it doesn't // really benefit from using an iterator. matches, err := cfg.glob(dir, path) if err != nil { // We avoid [errors.As] as it allocates, // and we know that [Config.glob] returns [pattern.Regexp] errors without wrapping. if _, ok := err.(*pattern.SyntaxError); !ok { yield("", err) return } } else if len(matches) > 0 || cfg.NullGlob { for _, m := range matches { if !yield(m, nil) { return } } continue } } if !yield(cfg.fieldJoin(field), nil) { return } } } } } } type fieldPart struct { val string quote quoteLevel } type quoteLevel uint const ( quoteNone quoteLevel = iota quoteDouble quoteHeredoc quoteSingle ) func (cfg *Config) wordField(wps []syntax.WordPart, ql quoteLevel) ([]fieldPart, error) { var field []fieldPart for i, wp := range wps { switch wp := wp.(type) { case *syntax.Lit: s := wp.Value if i == 0 && ql == quoteNone { if prefix, rest := cfg.expandUser(s, len(wps) > 1); prefix != "" { // TODO: return two separate fieldParts, // like in wordFields? s = prefix + rest } } if (ql == quoteDouble || ql == quoteHeredoc) && strings.Contains(s, "\\") { sb := cfg.strBuilder() for i := 0; i < len(s); i++ { b := s[i] if b == '\\' && i+1 < len(s) { switch s[i+1] { case '"': if ql != quoteDouble { break } fallthrough case '\\', '$', '`': // special chars i++ b = s[i] // write the special char, skipping the backslash } } sb.WriteByte(b) } s = sb.String() } s, _, _ = strings.Cut(s, "\x00") field = append(field, fieldPart{val: s}) case *syntax.SglQuoted: fp := fieldPart{quote: quoteSingle, val: wp.Value} if wp.Dollar { fp.val, _, _ = Format(cfg, fp.val, nil) } field = append(field, fp) case *syntax.DblQuoted: wfield, err := cfg.wordField(wp.Parts, quoteDouble) if err != nil { return nil, err } for _, part := range wfield { part.quote = quoteDouble field = append(field, part) } case *syntax.ParamExp: val, err := cfg.paramExp(wp) if err != nil { return nil, err } field = append(field, fieldPart{val: val}) case *syntax.CmdSubst: val, err := cfg.cmdSubst(wp) if err != nil { return nil, err } field = append(field, fieldPart{val: val}) case *syntax.ArithmExp: n, err := Arithm(cfg, wp.X) if err != nil { return nil, err } field = append(field, fieldPart{val: strconv.Itoa(n)}) case *syntax.ProcSubst: path, err := cfg.ProcSubst(wp) if err != nil { return nil, err } field = append(field, fieldPart{val: path}) case *syntax.ExtGlob: return nil, fmt.Errorf("extended globbing is not supported") default: panic(fmt.Sprintf("unhandled word part: %T", wp)) } } return field, nil } func (cfg *Config) cmdSubst(cs *syntax.CmdSubst) (string, error) { if cfg.CmdSubst == nil { return "", UnexpectedCommandError{Node: cs} } sb := cfg.strBuilder() if err := cfg.CmdSubst(sb, cs); err != nil { return "", err } out := sb.String() out = strings.ReplaceAll(out, "\x00", "") return strings.TrimRight(out, "\n"), nil } func (cfg *Config) wordFields(wps []syntax.WordPart) ([][]fieldPart, error) { fields := cfg.fieldsAlloc[:0] curField := cfg.fieldAlloc[:0] allowEmpty := false flush := func() { if len(curField) == 0 { return } fields = append(fields, curField) curField = nil } splitAdd := func(val string) { fieldStart := -1 for i, r := range val { if cfg.ifsRune(r) { if fieldStart >= 0 { // ending a field curField = append(curField, fieldPart{val: val[fieldStart:i]}) fieldStart = -1 } flush() } else { if fieldStart < 0 { // starting a new field fieldStart = i } } } if fieldStart >= 0 { // ending a field without IFS curField = append(curField, fieldPart{val: val[fieldStart:]}) } } for i, wp := range wps { switch wp := wp.(type) { case *syntax.Lit: s := wp.Value if i == 0 { prefix, rest := cfg.expandUser(s, len(wps) > 1) curField = append(curField, fieldPart{ quote: quoteSingle, val: prefix, }) s = rest } if strings.Contains(s, "\\") { sb := cfg.strBuilder() for i := 0; i < len(s); i++ { b := s[i] if b == '\\' { if i++; i >= len(s) { break } b = s[i] } sb.WriteByte(b) } s = sb.String() } curField = append(curField, fieldPart{val: s}) case *syntax.SglQuoted: allowEmpty = true fp := fieldPart{quote: quoteSingle, val: wp.Value} if wp.Dollar { fp.val, _, _ = Format(cfg, fp.val, nil) } curField = append(curField, fp) case *syntax.DblQuoted: if len(wp.Parts) == 1 { pe, _ := wp.Parts[0].(*syntax.ParamExp) if elems := cfg.quotedElemFields(pe); elems != nil { for i, elem := range elems { if i > 0 { flush() } curField = append(curField, fieldPart{ quote: quoteDouble, val: elem, }) } continue } } allowEmpty = true wfield, err := cfg.wordField(wp.Parts, quoteDouble) if err != nil { return nil, err } for _, part := range wfield { part.quote = quoteDouble curField = append(curField, part) } case *syntax.ParamExp: val, err := cfg.paramExp(wp) if err != nil { return nil, err } splitAdd(val) case *syntax.CmdSubst: val, err := cfg.cmdSubst(wp) if err != nil { return nil, err } splitAdd(val) case *syntax.ArithmExp: n, err := Arithm(cfg, wp.X) if err != nil { return nil, err } curField = append(curField, fieldPart{val: strconv.Itoa(n)}) case *syntax.ProcSubst: path, err := cfg.ProcSubst(wp) if err != nil { return nil, err } splitAdd(path) case *syntax.ExtGlob: return nil, fmt.Errorf("extended globbing is not supported") default: panic(fmt.Sprintf("unhandled word part: %T", wp)) } } flush() if allowEmpty && len(fields) == 0 { fields = append(fields, curField) } return fields, nil } // quotedElemFields returns the list of elements resulting from a quoted // parameter expansion that should be treated especially, like "${foo[@]}". func (cfg *Config) quotedElemFields(pe *syntax.ParamExp) []string { if pe == nil || pe.Length || pe.Width { return nil } name := pe.Param.Value if pe.Excl { switch pe.Names { case syntax.NamesPrefixWords: // "${!prefix@}" return cfg.namesByPrefix(pe.Param.Value) case syntax.NamesPrefix: // "${!prefix*}" return nil } switch nodeLit(pe.Index) { case "@": // "${!name[@]}" switch vr := cfg.Env.Get(name); vr.Kind { case Indexed: // TODO: if an indexed array only has elements 0 and 10, // we should not return all indices in between those. keys := make([]string, 0, len(vr.List)) for key := range vr.List { keys = append(keys, strconv.Itoa(key)) } return keys case Associative: return slices.Collect(maps.Keys(vr.Map)) } } return nil } switch name { case "*": // "${*}" return []string{cfg.ifsJoin(cfg.Env.Get(name).List)} case "@": // "${@}" return cfg.Env.Get(name).List } switch nodeLit(pe.Index) { case "@": // "${name[@]}" switch vr := cfg.Env.Get(name); vr.Kind { case Indexed: return vr.List case Associative: return slices.Collect(maps.Values(vr.Map)) } case "*": // "${name[*]}" if vr := cfg.Env.Get(name); vr.Kind == Indexed { return []string{cfg.ifsJoin(vr.List)} } } return nil } func (cfg *Config) expandUser(field string, moreFields bool) (prefix, rest string) { name, ok := strings.CutPrefix(field, "~") if !ok { // No tilde prefix to expand, e.g. "foo". return "", field } i := strings.IndexByte(name, '/') if i < 0 && moreFields { // There is a tilde prefix, but followed by more fields, e.g. "~'foo'". // We only proceed if an unquoted slash was found in this field, e.g. "~/'foo'". return "", field } if i >= 0 { rest = name[i:] name = name[:i] } if name == "" { // Current user; try via "HOME", otherwise fall back to the // system's appropriate home dir env var. Don't use os/user, as // that's overkill. We can't use [os.UserHomeDir], because we want // to use cfg.Env, and we always want to check "HOME" first. if vr := cfg.Env.Get("HOME"); vr.IsSet() { return vr.String(), rest } if runtime.GOOS == "windows" { if vr := cfg.Env.Get("USERPROFILE"); vr.IsSet() { return vr.String(), rest } } return "", field } // Not the current user; try via "HOME ", otherwise fall back to // os/user. There isn't a way to lookup user home dirs without cgo. if vr := cfg.Env.Get("HOME " + name); vr.IsSet() { return vr.String(), rest } u, err := user.Lookup(name) if err != nil { return "", field } return u.HomeDir, rest } func findAllIndex(pat, name string, n int) [][]int { expr, err := pattern.Regexp(pat, 0) if err != nil { return nil } rx := regexp.MustCompile(expr) return rx.FindAllStringIndex(name, n) } var rxGlobStar = regexp.MustCompile(".*") // pathJoin2 is a simpler version of [filepath.Join] without cleaning the result, // since that's needed for globbing. func pathJoin2(elem1, elem2 string) string { if elem1 == "" { return elem2 } if strings.HasSuffix(elem1, string(filepath.Separator)) { return elem1 + elem2 } return elem1 + string(filepath.Separator) + elem2 } // pathSplit splits a file path into its elements, retaining empty ones. Before // splitting, slashes are replaced with [filepath.Separator], so that splitting // Unix paths on Windows works as well. func pathSplit(path string) []string { path = filepath.FromSlash(path) return strings.Split(path, string(filepath.Separator)) } func (cfg *Config) glob(base, pat string) ([]string, error) { parts := pathSplit(pat) matches := []string{""} if filepath.IsAbs(pat) { if parts[0] == "" { // unix-like matches[0] = string(filepath.Separator) } else { // windows (for some reason it won't work without the // trailing separator) matches[0] = parts[0] + string(filepath.Separator) } parts = parts[1:] } // TODO: as an optimization, we could do chunks of the path all at once, // like doing a single stat for "/foo/bar" in "/foo/bar/*". // TODO: Another optimization would be to reduce the number of ReadDir2 calls. // For example, /foo/* can end up doing one duplicate call: // // ReadDir2("/foo") to ensure that "/foo/" exists and only matches a directory // ReadDir2("/foo") glob "*" for i, part := range parts { // Keep around for debugging. // log.Printf("matches %q part %d %q", matches, i, part) wantDir := i < len(parts)-1 switch { case part == "", part == ".", part == "..": for i, dir := range matches { matches[i] = pathJoin2(dir, part) } continue case !pattern.HasMeta(part, 0): var newMatches []string for _, dir := range matches { match := dir if !filepath.IsAbs(match) { match = filepath.Join(base, match) } match = pathJoin2(match, part) // We can't use [Config.ReadDir2] on the parent and match the directory // entry by name, because short paths on Windows break that. // Our only option is to [Config.ReadDir2] on the directory entry itself, // which can be wasteful if we only want to see if it exists, // but at least it's correct in all scenarios. if _, err := cfg.ReadDir2(match); err != nil { if isWindowsErrPathNotFound(err) { // Unfortunately, [os.File.Readdir] on a regular file on // Windows returns an error that satisfies [fs.ErrNotExist]. // Luckily, it returns a special "path not found" rather // than the normal "file not found" for missing files, // so we can use that knowledge to work around the bug. // See https://github.com/golang/go/issues/46734. // TODO: remove when the Go issue above is resolved. } else if errors.Is(err, fs.ErrNotExist) { continue // simply doesn't exist } if wantDir { continue // exists but not a directory } } newMatches = append(newMatches, pathJoin2(dir, part)) } matches = newMatches continue case part == "**" && cfg.GlobStar: // Find all recursive matches for "**". // Note that we need the results to be in depth-first order, // and to avoid recursion, we use a slice as a stack. // Since we pop from the back, we populate the stack backwards. stack := make([]string, 0, len(matches)) for _, match := range slices.Backward(matches) { // "a/**" should match "a/ a/b a/b/cfg ..."; // note how the zero-match case has a trailing separator. stack = append(stack, pathJoin2(match, "")) } matches = matches[:0] var newMatches []string // to reuse its capacity for len(stack) > 0 { dir := stack[len(stack)-1] stack = stack[:len(stack)-1] // Don't include the original "" match as it's not a valid path. if dir != "" { matches = append(matches, dir) } // If dir is not a directory, we keep the stack as-is and continue. newMatches = newMatches[:0] newMatches, _ = cfg.globDir(base, dir, rxGlobStar, wantDir, newMatches) for _, match := range slices.Backward(newMatches) { stack = append(stack, match) } } continue } mode := pattern.Filenames | pattern.EntireString | pattern.NoGlobStar if cfg.NoCaseGlob { mode |= pattern.NoGlobCase } expr, err := pattern.Regexp(part, mode) if err != nil { return nil, err } rx := regexp.MustCompile(expr) var newMatches []string for _, dir := range matches { newMatches, err = cfg.globDir(base, dir, rx, wantDir, newMatches) if err != nil { return nil, err } } matches = newMatches } return matches, nil } func (cfg *Config) globDir(base, dir string, rx *regexp.Regexp, wantDir bool, matches []string) ([]string, error) { fullDir := dir if !filepath.IsAbs(dir) { fullDir = filepath.Join(base, dir) } infos, err := cfg.ReadDir2(fullDir) if err != nil { // We still want to return matches, for the sake of reusing slices. return matches, err } for _, info := range infos { name := info.Name() if !wantDir { // No filtering. } else if mode := info.Type(); mode&os.ModeSymlink != 0 { // We need to know if the symlink points to a directory. // This requires an extra syscall, as [Config.ReadDir] on the parent directory // does not follow symlinks for each of the directory entries. // ReadDir is somewhat wasteful here, as we only want its error result, // but we could try to reuse its result as per the TODO in [Config.glob]. if _, err := cfg.ReadDir2(filepath.Join(fullDir, info.Name())); err != nil { continue } } else if !mode.IsDir() { // Not a symlink nor a directory. continue } if rx.MatchString(name) { matches = append(matches, pathJoin2(dir, name)) } } return matches, nil } // ReadFields splits and returns n fields from s, like the "read" shell builtin. // If raw is set, backslash escape sequences are not interpreted. // // The config specifies shell expansion options; nil behaves the same as an // empty config. func ReadFields(cfg *Config, s string, n int, raw bool) []string { cfg = prepareConfig(cfg) type pos struct { start, end int } var fpos []pos runes := make([]rune, 0, len(s)) infield := false esc := false for _, r := range s { if infield { if cfg.ifsRune(r) && (raw || !esc) { fpos[len(fpos)-1].end = len(runes) infield = false } } else { if !cfg.ifsRune(r) && (raw || !esc) { fpos = append(fpos, pos{start: len(runes), end: -1}) infield = true } } if r == '\\' { if raw || esc { runes = append(runes, r) } esc = !esc continue } runes = append(runes, r) esc = false } if len(fpos) == 0 { return nil } if infield { fpos[len(fpos)-1].end = len(runes) } switch { case n == 1: // include heading/trailing IFSs fpos[0].start, fpos[0].end = 0, len(runes) fpos = fpos[:1] case n != -1 && n < len(fpos): // combine to max n fields fpos[n-1].end = fpos[len(fpos)-1].end fpos = fpos[:n] } fields := make([]string, len(fpos)) for i, p := range fpos { fields[i] = string(runes[p.start:p.end]) } return fields }