Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ Filters are methods on an existing pipe that also return a pipe, allowing you to
| [`Replace`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Replace) | matching text replaced with given string |
| [`ReplaceRegexp`](https://pkg.go.dev/github.com/bitfield/script#Pipe.ReplaceRegexp) | matching text replaced with given string |
| [`Shell`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Shell) | filtered through the system shell |
| [`ShellForEach`](https://pkg.go.dev/github.com/bitfield/script#Pipe.ShellForEach) | execute given command template for each line of input, via the system shell |
| [`Tee`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Tee) | input copied to supplied writers |

Note that filters run concurrently, rather than producing nothing until each stage has fully read its input. This is convenient for executing long-running commands, for example. If you do need to wait for the pipeline to complete, call [`Wait`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Wait).
Expand Down
73 changes: 73 additions & 0 deletions script.go
Original file line number Diff line number Diff line change
Expand Up @@ -1011,6 +1011,79 @@ func (p *Pipe) Shell(cmdLine string) *Pipe {
return p.ExecCommand(shell, flag, cmdLine)
}

// ShellForEach runs cmdLine for each line of input, via the operating system's standard
// shell.
//
// cmdLine is rendered as a Go template for each line of input, and the resulting command
// is passed to the shell for expansion and execution ("sh -c" on Unix-like systems, "cmd
// /C" on Windows). ShellForEach produces the combined output of all these commands in
// sequence.
//
// This is like [Pipe.ExecForEach], but because the rendered command line goes through the
// shell, you can quote template values so that arguments containing spaces or other shell
// metacharacters are handled the way the shell would handle them. For example, to touch a
// set of files whose names may contain spaces:
//
// ListFiles("*").ShellForEach("touch '{{.}}'").Wait()
//
// Note that variable syntax differs by platform: Unix shells expand variables written as
// $VAR, while cmd.exe on Windows expands variables written as %VAR%.
//
// # Environment
//
// Each command inherits the current process's environment, optionally modified by
// [Pipe.WithEnv].
//
// # Context
//
// Each command inherits the pipe's context (if any was set by [Pipe.WithContext]), and
// will be cancelled if the context is cancelled or times out.
func (p *Pipe) ShellForEach(cmdLine string) *Pipe {
tpl, err := template.New("").Parse(cmdLine)
if err != nil {
return p.WithError(err)
}
shell, flag := "sh", "-c"
if runtime.GOOS == "windows" {
shell, flag = "cmd", "/C"
}
return p.Filter(func(r io.Reader, w io.Writer) error {
scanner := newScanner(r)
for scanner.Scan() {
if p.ctx.Err() != nil {
return p.ctx.Err()
}
cmdLine := new(strings.Builder)
err := tpl.Execute(cmdLine, scanner.Text())
if err != nil {
return err
}
cmd := exec.CommandContext(p.ctx, shell, flag, cmdLine.String())
cmd.Stdout = w
cmd.Stderr = w
pipeStderr := p.stdErr()
if pipeStderr != nil {
cmd.Stderr = pipeStderr
}
pipeEnv := p.environment()
if pipeEnv != nil {
cmd.Env = pipeEnv
}
err = cmd.Start()
if err != nil {
fmt.Fprintln(cmd.Stderr, err)
continue
}
err = cmd.Wait()
if err != nil {
fmt.Fprintln(cmd.Stderr, err)
continue
}
}
return scanner.Err()
})
}

// Slice returns the pipe's contents as a slice of strings, one element per
// line, or an error.
//
Expand Down
33 changes: 33 additions & 0 deletions script_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,39 @@ func TestExecForEach_SendsStderrOutputToPipeStderr(t *testing.T) {
}
}

func TestShellForEach_ErrorsOnInvalidTemplateSyntax(t *testing.T) {
t.Parallel()
p := script.Echo("a\nb\nc\n").ShellForEach("{{invalid template syntax}}")
p.Wait()
if p.Error() == nil {
t.Error("want error with invalid template syntax")
}
}

func TestShellForEach_IsNoOpOnPipeWithExistingError(t *testing.T) {
t.Parallel()
fakeErr := errors.New("existing error")
p := script.NewPipe().WithError(fakeErr).ShellForEach("echo {{.}}")
if p.Error() != fakeErr {
t.Errorf("want existing error %v preserved, got %v", fakeErr, p.Error())
}
}

func TestShellForEach_SendsStderrOutputToPipeStderr(t *testing.T) {
t.Parallel()
buf := new(bytes.Buffer)
out, err := script.Echo("go").WithStderr(buf).ShellForEach("{{.}}").String()
if err != nil {
t.Fatal(err)
}
if out != "" {
t.Fatalf("unexpected output: %q", out)
}
if !strings.Contains(buf.String(), "Usage") {
t.Errorf("want stderr output containing the word 'Usage', got %q", buf.String())
}
}

func TestExecCommand_SendsStderrOutputToPipeStderr(t *testing.T) {
t.Parallel()
buf := new(bytes.Buffer)
Expand Down
56 changes: 56 additions & 0 deletions script_unix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,54 @@ func TestExecForEach_CorrectlyEvaluatesTemplateContainingIfStatement(t *testing.
}
}

func TestShellForEach_RunsEchoWithABCAndGetsOutputABC(t *testing.T) {
t.Parallel()
p := script.Echo("a\nb\nc\n").ShellForEach("echo {{.}}")
if p.Error() != nil {
t.Fatal(p.Error())
}
want := "a\nb\nc\n"
got, err := p.String()
if err != nil {
t.Fatal(err)
}
if want != got {
t.Error(cmp.Diff(want, got))
}
}

func TestShellForEach_PreservesQuotedArgumentContainingSpaces(t *testing.T) {
t.Parallel()
// A quoted template value stays a single argument to the command, because the shell
// handles the quoting. The equivalent ExecForEach call would split "my file" into two
// separate arguments.
p := script.Echo("my file").ShellForEach("echo '{{.}}'")
if p.Error() != nil {
t.Fatal(p.Error())
}
want := "my file\n"
got, err := p.String()
if err != nil {
t.Fatal(err)
}
if want != got {
t.Error(cmp.Diff(want, got))
}
}

func TestShellForEach_ExpandsEnvironmentVariablesSetViaWithEnv(t *testing.T) {
t.Parallel()
env := []string{"ENV1=test1", "ENV2=test2"}
got, err := script.Echo("x").WithEnv(env).ShellForEach("echo ENV1=$ENV1 ENV2=$ENV2").String()
if err != nil {
t.Fatal(err)
}
want := "ENV1=test1 ENV2=test2\n"
if want != got {
t.Error(cmp.Diff(want, got))
}
}

func TestExecCommandPipesDataToExternalCommandAndGetsExpectedOutput(t *testing.T) {
t.Parallel()
p := script.File("testdata/hello.txt").ExecCommand("cat")
Expand Down Expand Up @@ -190,6 +238,14 @@ func ExamplePipe_Shell() {
// HELLO, WORLD!
}

func ExamplePipe_ShellForEach() {
script.Echo("a\nb\nc\n").ShellForEach("echo {{.}}").Stdout()
// Output:
// a
// b
// c
}

func TestShell_ExpandsEnvironmentVariablesSetViaWithEnv(t *testing.T) {
t.Parallel()
env := []string{"ENV1=test1", "ENV2=test2"}
Expand Down