From 87d5c3bd576f51123e64b3c1a9410834ea7d3c49 Mon Sep 17 00:00:00 2001 From: datadog-bits-staging <264369727+datadog-bits-staging@users.noreply.github.com> Date: Mon, 9 Mar 2026 09:21:58 +0000 Subject: [PATCH] Split interp/runner.go into focused modules Co-authored-by: AlexandreYang <49917914+AlexandreYang@users.noreply.github.com> --- interp/runner.go | 434 ---------------------------------------- interp/runner_exec.go | 221 ++++++++++++++++++++ interp/runner_expand.go | 88 ++++++++ interp/runner_redir.go | 155 ++++++++++++++ 4 files changed, 464 insertions(+), 434 deletions(-) create mode 100644 interp/runner_exec.go create mode 100644 interp/runner_expand.go create mode 100644 interp/runner_redir.go diff --git a/interp/runner.go b/interp/runner.go index 5f4ba266..ff0a16f2 100644 --- a/interp/runner.go +++ b/interp/runner.go @@ -1,98 +1,14 @@ package interp import ( - "bytes" "context" - "errors" "fmt" "io" - "io/fs" "os" - "strings" - "sync" - "mvdan.cc/sh/v3/expand" "mvdan.cc/sh/v3/syntax" - - "github.com/DataDog/rshell/interp/builtins" ) -func (r *Runner) fillExpandConfig(ctx context.Context) { - r.ectx = ctx - r.ecfg = &expand.Config{ - Env: expandEnv{r}, - // CmdSubst is intentionally nil: command substitution is blocked - // at the AST validation level, and a nil handler causes the expand - // package to return UnexpectedCommandError as defense in depth. - } - r.updateExpandOpts() -} - -func (r *Runner) updateExpandOpts() { - r.ecfg.ReadDir2 = func(s string) ([]fs.DirEntry, error) { - return r.readDirHandler(r.handlerCtx(r.ectx, todoPos), s) - } -} - -func (r *Runner) expandErr(err error) { - if err == nil { - return - } - errMsg := err.Error() - fmt.Fprintln(r.stderr, errMsg) - switch { - case errors.As(err, &expand.UnsetParameterError{}): - case errMsg == "invalid indirect expansion": - // TODO: These errors are treated as fatal by bash. - // Make the error type reflect that. - case strings.HasSuffix(errMsg, "not supported"): - // TODO: This "has suffix" is a temporary measure until the expand - // package supports all syntax nodes like extended globbing. - default: - return // other cases do not exit - } - r.exit.code = 1 - r.exit.exiting = true -} - -func (r *Runner) fields(words ...*syntax.Word) []string { - strs, err := expand.Fields(r.ecfg, words...) - r.expandErr(err) - return strs -} - -func (r *Runner) literal(word *syntax.Word) string { - str, err := expand.Literal(r.ecfg, word) - r.expandErr(err) - return str -} - -func (r *Runner) document(word *syntax.Word) string { - str, err := expand.Document(r.ecfg, word) - r.expandErr(err) - return str -} - -// expandEnviron exposes [Runner]'s variables to the expand package. -type expandEnv struct { - r *Runner -} - -var _ expand.WriteEnviron = expandEnv{} - -func (e expandEnv) Get(name string) expand.Variable { - return e.r.lookupVar(name) -} - -func (e expandEnv) Set(name string, vr expand.Variable) error { - e.r.setVar(name, vr) - return nil // TODO: return any errors -} - -func (e expandEnv) Each(fn func(name string, vr expand.Variable) bool) { - e.r.writeEnv.Each(fn) -} - var todoPos syntax.Pos // for handlerCtx callers where we don't yet have a position func (r *Runner) handlerCtx(ctx context.Context, pos syntax.Pos) context.Context { @@ -124,356 +40,6 @@ func (r *Runner) stop(ctx context.Context) bool { return false } -func (r *Runner) stmt(ctx context.Context, st *syntax.Stmt) { - if r.stop(ctx) { - return - } - r.exit = exitStatus{} - r.stmtSync(ctx, st) - r.lastExit = r.exit -} - -func (r *Runner) stmtSync(ctx context.Context, st *syntax.Stmt) { - oldIn, oldOut, oldErr := r.stdin, r.stdout, r.stderr - for _, rd := range st.Redirs { - cls, err := r.redir(ctx, rd) - if err != nil { - r.exit.code = 1 - break - } - if cls != nil { - defer cls.Close() - } - } - if r.exit.ok() && st.Cmd != nil { - r.cmd(ctx, st.Cmd) - } - if st.Negated && !r.exit.exiting { - wasOk := r.exit.ok() - r.exit = exitStatus{} - r.exit.oneIf(wasOk) - } - r.stdin, r.stdout, r.stderr = oldIn, oldOut, oldErr -} - -func (r *Runner) cmd(ctx context.Context, cm syntax.Command) { - if r.stop(ctx) { - return - } - - switch cm := cm.(type) { - case *syntax.Block: - r.stmts(ctx, cm.Stmts) - case *syntax.CallExpr: - args := cm.Args - r.lastExpandExit = exitStatus{} - fields := r.fields(args...) - if len(fields) == 0 { - for _, as := range cm.Assigns { - prev := r.lookupVar(as.Name.Value) - prev.Local = false - - vr := r.assignVal(prev, as, "") - r.setVarWithIndex(prev, as.Name.Value, as.Index, vr) - } - // If interpreting the last expansion like $(foo) failed, - // and the expansion and assignments otherwise succeeded, - // we need to surface that last exit code. - if r.exit.ok() { - r.exit = r.lastExpandExit - } - break - } - - type restoreVar struct { - name string - vr expand.Variable - } - var restores []restoreVar - - for _, as := range cm.Assigns { - name := as.Name.Value - prev := r.lookupVar(name) - - vr := r.assignVal(prev, as, "") - // Inline command vars are always exported. - vr.Exported = true - - restores = append(restores, restoreVar{name, prev}) - - r.setVar(name, vr) - } - - r.call(ctx, cm.Args[0].Pos(), fields) - for _, restore := range restores { - r.setVar(restore.name, restore.vr) - } - case *syntax.BinaryCmd: - switch cm.Op { - case syntax.AndStmt, syntax.OrStmt: - r.stmt(ctx, cm.X) - if r.breakEnclosing > 0 || r.contnEnclosing > 0 || r.exit.exiting { - break - } - if r.exit.ok() == (cm.Op == syntax.AndStmt) { - r.stmt(ctx, cm.Y) - } - case syntax.Pipe: - pr, pw, err := os.Pipe() - if err != nil { - r.exit.fatal(err) // not being able to create a pipe is rare but critical - return - } - rLeft := r.subshell(true) - rLeft.stdout = pw - rLeft.stderr = r.stderr - rRight := r.subshell(true) - rRight.stdin = pr - var wg sync.WaitGroup - wg.Add(1) - go func() { - defer func() { - if rec := recover(); rec != nil { - rLeft.exit.fatal(fmt.Errorf("internal error: %v", rec)) - } - pw.Close() - wg.Done() - }() - rLeft.stmt(ctx, cm.X) - rLeft.exit.exiting = false - }() - rRight.stmt(ctx, cm.Y) - r.exit = rRight.exit - r.exit.exiting = false - pr.Close() - wg.Wait() - if rLeft.exit.fatalExit { - r.exit.fatal(rLeft.exit.err) - } - } - case *syntax.ForClause: - switch y := cm.Loop.(type) { - case *syntax.WordIter: - name := y.Name.Value - items := r.Params // for i; do ... - - inToken := y.InPos.IsValid() - if inToken { - items = r.fields(y.Items...) // for i in ...; do ... - } - - for _, field := range items { - r.setVarString(name, field) - if r.loopStmtsBroken(ctx, cm.Do) { - break - } - } - default: - r.exit.fatal(fmt.Errorf("unsupported loop type: %T", cm.Loop)) - } - default: - r.exit.fatal(fmt.Errorf("unsupported command node: %T", cm)) - } -} - -func (r *Runner) stmts(ctx context.Context, stmts []*syntax.Stmt) { - for _, stmt := range stmts { - r.stmt(ctx, stmt) - } -} - -// isQuotedHdoc reports whether the heredoc delimiter contains any quoting. -// Per POSIX, if any part of the delimiter is quoted, the heredoc body -// must not undergo expansion or backslash processing. -func isQuotedHdoc(rd *syntax.Redirect) bool { - for _, part := range rd.Word.Parts { - switch p := part.(type) { - case *syntax.SglQuoted, *syntax.DblQuoted: - return true - case *syntax.Lit: - if strings.ContainsRune(p.Value, '\\') { - return true - } - } - } - return false -} - -// hdocLiteral reconstructs the literal (unexpanded) text of a heredoc body. -// This is used for quoted delimiters where no expansion should occur. -func hdocLiteral(word *syntax.Word) string { - var buf strings.Builder - for _, part := range word.Parts { - hdocLiteralPart(&buf, part) - } - return buf.String() -} - -func hdocLiteralPart(buf *strings.Builder, part syntax.WordPart) { - switch x := part.(type) { - case *syntax.Lit: - buf.WriteString(x.Value) - case *syntax.ParamExp: - buf.WriteByte('$') - if !x.Short { - buf.WriteByte('{') - buf.WriteString(x.Param.Value) - buf.WriteByte('}') - } else { - buf.WriteString(x.Param.Value) - } - case *syntax.SglQuoted: - buf.WriteString(x.Value) - case *syntax.DblQuoted: - for _, p := range x.Parts { - hdocLiteralPart(buf, p) - } - } -} - -func (r *Runner) hdocReader(rd *syntax.Redirect) (*os.File, error) { - pr, pw, err := os.Pipe() - if err != nil { - return nil, err - } - // We write to the pipe in a new goroutine, - // as pipe writes may block once the buffer gets full. - // We still construct and buffer the entire heredoc first, - // as doing it concurrently would lead to different semantics and be racy. - quoted := isQuotedHdoc(rd) - expandWord := func(w *syntax.Word) string { - if quoted { - return hdocLiteral(w) - } - return r.document(w) - } - if rd.Op != syntax.DashHdoc { - hdoc := expandWord(rd.Hdoc) - go func() { - pw.WriteString(hdoc) - pw.Close() - }() - return pr, nil - } - var buf bytes.Buffer - var cur []syntax.WordPart - flushLine := func() { - if buf.Len() > 0 { - buf.WriteByte('\n') - } - buf.WriteString(expandWord(&syntax.Word{Parts: cur})) - cur = cur[:0] - } - for _, wp := range rd.Hdoc.Parts { - lit, ok := wp.(*syntax.Lit) - if !ok { - cur = append(cur, wp) - continue - } - for i, part := range strings.Split(lit.Value, "\n") { - if i > 0 { - flushLine() - cur = cur[:0] - } - part = strings.TrimLeft(part, "\t") - cur = append(cur, &syntax.Lit{Value: part}) - } - } - flushLine() - go func() { - pw.Write(buf.Bytes()) - pw.Close() - }() - return pr, nil -} - -func (r *Runner) redir(ctx context.Context, rd *syntax.Redirect) (io.Closer, error) { - if rd.Hdoc != nil { - pr, err := r.hdocReader(rd) - if err != nil { - return nil, err - } - r.stdin = pr - return pr, nil - } - if rd.Op == syntax.Hdoc || rd.Op == syntax.DashHdoc { - pr, pw, err := os.Pipe() - if err != nil { - return nil, err - } - go func() { pw.Close() }() - r.stdin = pr - return pr, nil - } - - arg := r.literal(rd.Word) - switch rd.Op { - case syntax.RdrIn: - // done further below - default: - return nil, fmt.Errorf("unhandled redirect op: %v", rd.Op) - } - f, err := r.open(ctx, arg, os.O_RDONLY, 0, true) - if err != nil { - return nil, err - } - stdin, err := stdinFile(f) - if err != nil { - return nil, err - } - r.stdin = stdin - return f, nil -} - -func (r *Runner) loopStmtsBroken(ctx context.Context, stmts []*syntax.Stmt) bool { - oldInLoop := r.inLoop - r.inLoop = true - defer func() { r.inLoop = oldInLoop }() - for _, stmt := range stmts { - r.stmt(ctx, stmt) - if r.contnEnclosing > 0 { - r.contnEnclosing-- - return r.contnEnclosing > 0 - } - if r.breakEnclosing > 0 { - r.breakEnclosing-- - return true - } - } - return false -} - -func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { - if r.stop(ctx) { - return - } - name := args[0] - if fn, ok := builtins.Lookup(name); ok { - call := &builtins.CallContext{ - Stdout: r.stdout, - Stderr: r.stderr, - Stdin: r.stdin, - InLoop: r.inLoop, - LastExitCode: r.lastExit.code, - OpenFile: func(ctx context.Context, path string, flags int, mode os.FileMode) (io.ReadWriteCloser, error) { - return r.open(ctx, path, flags, mode, false) - }, - PortableErr: portableErrMsg, - } - result := fn(ctx, call, args[1:]) - r.exit.code = result.Code - r.exit.exiting = result.Exiting - r.breakEnclosing = result.BreakN - r.contnEnclosing = result.ContinueN - return - } - r.exec(ctx, pos, args) -} - -func (r *Runner) exec(ctx context.Context, pos syntax.Pos, args []string) { - r.exit.fromHandlerError(r.execHandler(r.handlerCtx(ctx, pos), args)) -} - func (r *Runner) open(ctx context.Context, path string, flags int, mode os.FileMode, print bool) (io.ReadWriteCloser, error) { f, err := r.openHandler(r.handlerCtx(ctx, todoPos), path, flags, mode) // TODO: support wrapped PathError returned from openHandler. diff --git a/interp/runner_exec.go b/interp/runner_exec.go new file mode 100644 index 00000000..d410351d --- /dev/null +++ b/interp/runner_exec.go @@ -0,0 +1,221 @@ +package interp + +import ( + "context" + "fmt" + "io" + "os" + "sync" + + "mvdan.cc/sh/v3/expand" + "mvdan.cc/sh/v3/syntax" + + "github.com/DataDog/rshell/interp/builtins" +) + +func (r *Runner) stmt(ctx context.Context, st *syntax.Stmt) { + if r.stop(ctx) { + return + } + r.exit = exitStatus{} + r.stmtSync(ctx, st) + r.lastExit = r.exit +} + +func (r *Runner) stmtSync(ctx context.Context, st *syntax.Stmt) { + oldIn, oldOut, oldErr := r.stdin, r.stdout, r.stderr + for _, rd := range st.Redirs { + cls, err := r.redir(ctx, rd) + if err != nil { + r.exit.code = 1 + break + } + if cls != nil { + defer cls.Close() + } + } + if r.exit.ok() && st.Cmd != nil { + r.cmd(ctx, st.Cmd) + } + if st.Negated && !r.exit.exiting { + wasOk := r.exit.ok() + r.exit = exitStatus{} + r.exit.oneIf(wasOk) + } + r.stdin, r.stdout, r.stderr = oldIn, oldOut, oldErr +} + +func (r *Runner) cmd(ctx context.Context, cm syntax.Command) { + if r.stop(ctx) { + return + } + + switch cm := cm.(type) { + case *syntax.Block: + r.stmts(ctx, cm.Stmts) + case *syntax.CallExpr: + args := cm.Args + r.lastExpandExit = exitStatus{} + fields := r.fields(args...) + if len(fields) == 0 { + for _, as := range cm.Assigns { + prev := r.lookupVar(as.Name.Value) + prev.Local = false + + vr := r.assignVal(prev, as, "") + r.setVarWithIndex(prev, as.Name.Value, as.Index, vr) + } + // If interpreting the last expansion like $(foo) failed, + // and the expansion and assignments otherwise succeeded, + // we need to surface that last exit code. + if r.exit.ok() { + r.exit = r.lastExpandExit + } + break + } + + type restoreVar struct { + name string + vr expand.Variable + } + var restores []restoreVar + + for _, as := range cm.Assigns { + name := as.Name.Value + prev := r.lookupVar(name) + + vr := r.assignVal(prev, as, "") + // Inline command vars are always exported. + vr.Exported = true + + restores = append(restores, restoreVar{name, prev}) + + r.setVar(name, vr) + } + + r.call(ctx, cm.Args[0].Pos(), fields) + for _, restore := range restores { + r.setVar(restore.name, restore.vr) + } + case *syntax.BinaryCmd: + switch cm.Op { + case syntax.AndStmt, syntax.OrStmt: + r.stmt(ctx, cm.X) + if r.breakEnclosing > 0 || r.contnEnclosing > 0 || r.exit.exiting { + break + } + if r.exit.ok() == (cm.Op == syntax.AndStmt) { + r.stmt(ctx, cm.Y) + } + case syntax.Pipe: + pr, pw, err := os.Pipe() + if err != nil { + r.exit.fatal(err) // not being able to create a pipe is rare but critical + return + } + rLeft := r.subshell(true) + rLeft.stdout = pw + rLeft.stderr = r.stderr + rRight := r.subshell(true) + rRight.stdin = pr + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer func() { + if rec := recover(); rec != nil { + rLeft.exit.fatal(fmt.Errorf("internal error: %v", rec)) + } + pw.Close() + wg.Done() + }() + rLeft.stmt(ctx, cm.X) + rLeft.exit.exiting = false + }() + rRight.stmt(ctx, cm.Y) + r.exit = rRight.exit + r.exit.exiting = false + pr.Close() + wg.Wait() + if rLeft.exit.fatalExit { + r.exit.fatal(rLeft.exit.err) + } + } + case *syntax.ForClause: + switch y := cm.Loop.(type) { + case *syntax.WordIter: + name := y.Name.Value + items := r.Params // for i; do ... + + inToken := y.InPos.IsValid() + if inToken { + items = r.fields(y.Items...) // for i in ...; do ... + } + + for _, field := range items { + r.setVarString(name, field) + if r.loopStmtsBroken(ctx, cm.Do) { + break + } + } + default: + r.exit.fatal(fmt.Errorf("unsupported loop type: %T", cm.Loop)) + } + default: + r.exit.fatal(fmt.Errorf("unsupported command node: %T", cm)) + } +} + +func (r *Runner) stmts(ctx context.Context, stmts []*syntax.Stmt) { + for _, stmt := range stmts { + r.stmt(ctx, stmt) + } +} + +func (r *Runner) loopStmtsBroken(ctx context.Context, stmts []*syntax.Stmt) bool { + oldInLoop := r.inLoop + r.inLoop = true + defer func() { r.inLoop = oldInLoop }() + for _, stmt := range stmts { + r.stmt(ctx, stmt) + if r.contnEnclosing > 0 { + r.contnEnclosing-- + return r.contnEnclosing > 0 + } + if r.breakEnclosing > 0 { + r.breakEnclosing-- + return true + } + } + return false +} + +func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { + if r.stop(ctx) { + return + } + name := args[0] + if fn, ok := builtins.Lookup(name); ok { + call := &builtins.CallContext{ + Stdout: r.stdout, + Stderr: r.stderr, + Stdin: r.stdin, + InLoop: r.inLoop, + LastExitCode: r.lastExit.code, + OpenFile: func(ctx context.Context, path string, flags int, mode os.FileMode) (io.ReadWriteCloser, error) { + return r.open(ctx, path, flags, mode, false) + }, + PortableErr: portableErrMsg, + } + result := fn(ctx, call, args[1:]) + r.exit.code = result.Code + r.exit.exiting = result.Exiting + r.breakEnclosing = result.BreakN + r.contnEnclosing = result.ContinueN + return + } + r.exec(ctx, pos, args) +} + +func (r *Runner) exec(ctx context.Context, pos syntax.Pos, args []string) { + r.exit.fromHandlerError(r.execHandler(r.handlerCtx(ctx, pos), args)) +} diff --git a/interp/runner_expand.go b/interp/runner_expand.go new file mode 100644 index 00000000..ff95d077 --- /dev/null +++ b/interp/runner_expand.go @@ -0,0 +1,88 @@ +package interp + +import ( + "context" + "errors" + "fmt" + "io/fs" + "strings" + + "mvdan.cc/sh/v3/expand" + "mvdan.cc/sh/v3/syntax" +) + +func (r *Runner) fillExpandConfig(ctx context.Context) { + r.ectx = ctx + r.ecfg = &expand.Config{ + Env: expandEnv{r}, + // CmdSubst is intentionally nil: command substitution is blocked + // at the AST validation level, and a nil handler causes the expand + // package to return UnexpectedCommandError as defense in depth. + } + r.updateExpandOpts() +} + +func (r *Runner) updateExpandOpts() { + r.ecfg.ReadDir2 = func(s string) ([]fs.DirEntry, error) { + return r.readDirHandler(r.handlerCtx(r.ectx, todoPos), s) + } +} + +func (r *Runner) expandErr(err error) { + if err == nil { + return + } + errMsg := err.Error() + fmt.Fprintln(r.stderr, errMsg) + switch { + case errors.As(err, &expand.UnsetParameterError{}): + case errMsg == "invalid indirect expansion": + // TODO: These errors are treated as fatal by bash. + // Make the error type reflect that. + case strings.HasSuffix(errMsg, "not supported"): + // TODO: This "has suffix" is a temporary measure until the expand + // package supports all syntax nodes like extended globbing. + default: + return // other cases do not exit + } + r.exit.code = 1 + r.exit.exiting = true +} + +func (r *Runner) fields(words ...*syntax.Word) []string { + strs, err := expand.Fields(r.ecfg, words...) + r.expandErr(err) + return strs +} + +func (r *Runner) literal(word *syntax.Word) string { + str, err := expand.Literal(r.ecfg, word) + r.expandErr(err) + return str +} + +func (r *Runner) document(word *syntax.Word) string { + str, err := expand.Document(r.ecfg, word) + r.expandErr(err) + return str +} + +// expandEnv exposes [Runner]'s variables to the expand package. +type expandEnv struct { + r *Runner +} + +var _ expand.WriteEnviron = expandEnv{} + +func (e expandEnv) Get(name string) expand.Variable { + return e.r.lookupVar(name) +} + +func (e expandEnv) Set(name string, vr expand.Variable) error { + e.r.setVar(name, vr) + return nil // TODO: return any errors +} + +func (e expandEnv) Each(fn func(name string, vr expand.Variable) bool) { + e.r.writeEnv.Each(fn) +} diff --git a/interp/runner_redir.go b/interp/runner_redir.go new file mode 100644 index 00000000..9ec928a7 --- /dev/null +++ b/interp/runner_redir.go @@ -0,0 +1,155 @@ +package interp + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "strings" + + "mvdan.cc/sh/v3/syntax" +) + +// isQuotedHdoc reports whether the heredoc delimiter contains any quoting. +// Per POSIX, if any part of the delimiter is quoted, the heredoc body +// must not undergo expansion or backslash processing. +func isQuotedHdoc(rd *syntax.Redirect) bool { + for _, part := range rd.Word.Parts { + switch p := part.(type) { + case *syntax.SglQuoted, *syntax.DblQuoted: + return true + case *syntax.Lit: + if strings.ContainsRune(p.Value, '\\') { + return true + } + } + } + return false +} + +// hdocLiteral reconstructs the literal (unexpanded) text of a heredoc body. +// This is used for quoted delimiters where no expansion should occur. +func hdocLiteral(word *syntax.Word) string { + var buf strings.Builder + for _, part := range word.Parts { + hdocLiteralPart(&buf, part) + } + return buf.String() +} + +func hdocLiteralPart(buf *strings.Builder, part syntax.WordPart) { + switch x := part.(type) { + case *syntax.Lit: + buf.WriteString(x.Value) + case *syntax.ParamExp: + buf.WriteByte('$') + if !x.Short { + buf.WriteByte('{') + buf.WriteString(x.Param.Value) + buf.WriteByte('}') + } else { + buf.WriteString(x.Param.Value) + } + case *syntax.SglQuoted: + buf.WriteString(x.Value) + case *syntax.DblQuoted: + for _, p := range x.Parts { + hdocLiteralPart(buf, p) + } + } +} + +func (r *Runner) hdocReader(rd *syntax.Redirect) (*os.File, error) { + pr, pw, err := os.Pipe() + if err != nil { + return nil, err + } + // We write to the pipe in a new goroutine, + // as pipe writes may block once the buffer gets full. + // We still construct and buffer the entire heredoc first, + // as doing it concurrently would lead to different semantics and be racy. + quoted := isQuotedHdoc(rd) + expandWord := func(w *syntax.Word) string { + if quoted { + return hdocLiteral(w) + } + return r.document(w) + } + if rd.Op != syntax.DashHdoc { + hdoc := expandWord(rd.Hdoc) + go func() { + pw.WriteString(hdoc) + pw.Close() + }() + return pr, nil + } + var buf bytes.Buffer + var cur []syntax.WordPart + flushLine := func() { + if buf.Len() > 0 { + buf.WriteByte('\n') + } + buf.WriteString(expandWord(&syntax.Word{Parts: cur})) + cur = cur[:0] + } + for _, wp := range rd.Hdoc.Parts { + lit, ok := wp.(*syntax.Lit) + if !ok { + cur = append(cur, wp) + continue + } + for i, part := range strings.Split(lit.Value, "\n") { + if i > 0 { + flushLine() + cur = cur[:0] + } + part = strings.TrimLeft(part, "\t") + cur = append(cur, &syntax.Lit{Value: part}) + } + } + flushLine() + go func() { + pw.Write(buf.Bytes()) + pw.Close() + }() + return pr, nil +} + +func (r *Runner) redir(ctx context.Context, rd *syntax.Redirect) (io.Closer, error) { + if rd.Hdoc != nil { + pr, err := r.hdocReader(rd) + if err != nil { + return nil, err + } + r.stdin = pr + return pr, nil + } + if rd.Op == syntax.Hdoc || rd.Op == syntax.DashHdoc { + pr, pw, err := os.Pipe() + if err != nil { + return nil, err + } + go func() { pw.Close() }() + r.stdin = pr + return pr, nil + } + + arg := r.literal(rd.Word) + switch rd.Op { + case syntax.RdrIn: + // done further below + default: + return nil, fmt.Errorf("unhandled redirect op: %v", rd.Op) + } + f, err := r.open(ctx, arg, os.O_RDONLY, 0, true) + if err != nil { + return nil, err + } + stdin, err := stdinFile(f) + if err != nil { + return nil, err + } + r.stdin = stdin + return f, nil +}