diff --git a/README.md b/README.md index 9386e5e..085b9bd 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/script.go b/script.go index 1b32722..f87bad8 100644 --- a/script.go +++ b/script.go @@ -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. // diff --git a/script_test.go b/script_test.go index 69a6dd8..8050fe2 100644 --- a/script_test.go +++ b/script_test.go @@ -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) diff --git a/script_unix_test.go b/script_unix_test.go index cd93ae0..c277874 100644 --- a/script_unix_test.go +++ b/script_unix_test.go @@ -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") @@ -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"}