diff --git a/README.md b/README.md index 7e6e332..e893f09 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,7 @@ These are functions that create a pipe with a given contents: | [`IfExists`](https://pkg.go.dev/github.com/bitfield/script#IfExists) | do something only if some file exists | | [`ListFiles`](https://pkg.go.dev/github.com/bitfield/script#ListFiles) | file listing (including wildcards) | | [`Post`](https://pkg.go.dev/github.com/bitfield/script#Post) | HTTP response | +| [`Shell`](https://pkg.go.dev/github.com/bitfield/script#Shell) | command output, run via the system shell | | [`Slice`](https://pkg.go.dev/github.com/bitfield/script#Slice) | slice elements, one per line | | [`Stdin`](https://pkg.go.dev/github.com/bitfield/script#Stdin) | standard input | @@ -363,6 +364,7 @@ Filters are methods on an existing pipe that also return a pipe, allowing you to | [`RejectRegexp`](https://pkg.go.dev/github.com/bitfield/script#Pipe.RejectRegexp) | lines not matching given regexp | | [`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 | | [`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 457505d..8f96ff0 100644 --- a/script.go +++ b/script.go @@ -18,6 +18,7 @@ import ( "os/exec" "path/filepath" "regexp" + "runtime" "sort" "strconv" "strings" @@ -187,6 +188,12 @@ func Post(url string) *Pipe { return NewPipe().Post(url) } +// Shell creates a pipe that runs cmdLine as a command via the system shell, +// and produces its combined output. See [Pipe.Shell] for details. +func Shell(cmdLine string) *Pipe { + return NewPipe().Shell(cmdLine) +} + // Slice creates a pipe containing each element of s, one per line. If s is // empty or nil, then the pipe is empty. func Slice(s []string) *Pipe { @@ -931,6 +938,36 @@ func (p *Pipe) SHA256Sums() *Pipe { return p.HashSums(sha256.New()) } +// Shell runs cmdLine as a command via the operating system's native shell +// ("sh -c" on Unix-like systems, "cmd /C" on Windows), sending it the +// contents of the pipe as input, and produces the command's combined +// output. Unlike calling [Pipe.Exec] directly, Shell performs no argument +// parsing of cmdLine itself; it is passed unmodified to the shell, which +// handles all parsing, quoting, and variable expansion exactly as it +// would on an interactive command line. +// +// Note that variable syntax differs by platform: Unix shells expand +// variables written as $VAR, while cmd.exe on Windows expands variables +// written as %VAR%. +// +// See [Pipe.Exec] for details on error handling, context, and +// environment variables set via [Pipe.WithEnv]. +func (p *Pipe) Shell(cmdLine string) *Pipe { + shell, flag := "sh", "-c" + if runtime.GOOS == "windows" { + shell, flag = "cmd", "/C" + } + return p.Exec(shell + " " + flag + " " + quoteShellArg(cmdLine)) +} + +// quoteShellArg quotes s using POSIX single-quoting rules, so that +// [Pipe.Exec]'s internal argument parsing treats it as a single +// argument, preserving cmdLine exactly for the shell invoked by +// [Pipe.Shell]. +func quoteShellArg(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + // Slice returns the pipe's contents as a slice of strings, one element per // line, or an error. // diff --git a/script_unix_test.go b/script_unix_test.go index ae512c4..bf80259 100644 --- a/script_unix_test.go +++ b/script_unix_test.go @@ -7,6 +7,10 @@ import ( "path/filepath" "testing" + "bytes" + "errors" + "strings" + "github.com/bitfield/script" "github.com/google/go-cmp/cmp" ) @@ -222,3 +226,110 @@ func ExamplePipe_ExecForEach() { // b // c } + +func TestShellRunsShWithEchoHelloAndGetsOutputHello(t *testing.T) { + t.Parallel() + p := script.Shell("echo hello") + if p.Error() != nil { + t.Fatal(p.Error()) + } + want := "hello\n" + got, err := p.String() + if err != nil { + t.Fatal(err) + } + if want != got { + t.Error(cmp.Diff(want, got)) + } +} + +func TestShell_ExpandsEnvironmentVariablesSetViaWithEnv(t *testing.T) { + t.Parallel() + env := []string{"ENV1=test1", "ENV2=test2"} + got, err := script.NewPipe().WithEnv(env).Shell("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 TestShellExpandsHomeVariableWithoutWithEnv(t *testing.T) { + t.Parallel() + p := script.Shell("echo $HOME") + if p.Error() != nil { + t.Fatal(p.Error()) + } + got, err := p.String() + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(got) == "" { + t.Error("want non-empty $HOME expansion, got empty string") + } +} + +func TestShellPipesDataToExternalCommandAndGetsExpectedOutput(t *testing.T) { + t.Parallel() + p := script.File("testdata/hello.txt").Shell("cat") + want := "hello world" + got, err := p.String() + if err != nil { + t.Fatal(err) + } + if want != got { + t.Error(cmp.Diff(want, got)) + } +} + +func TestShellErrorsRunningCommandThatDoesNotExist(t *testing.T) { + t.Parallel() + p := script.Shell("doesntexist_command_xyz") + p.Wait() + if p.Error() == nil { + t.Error("want error running non-existent command") + } +} + +func TestShellSendsStderrOutputToPipeStderr(t *testing.T) { + t.Parallel() + buf := new(bytes.Buffer) + out, err := script.NewPipe().WithStderr(buf).Shell("go").String() + if err == nil { + t.Fatal("want error when command returns a non-zero exit status") + } + 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 TestShellOnEmptyPipeProducesNoOutputAndNoError(t *testing.T) { + t.Parallel() + got, err := script.NewPipe().Shell("cat").String() + if err != nil { + t.Fatal(err) + } + if got != "" { + t.Errorf("want empty output, got %q", got) + } +} + +func TestShellOnPipeWithExistingErrorIsNoOp(t *testing.T) { + t.Parallel() + fakeErr := errors.New("existing error") + p := script.NewPipe().WithError(fakeErr).Shell("echo hello") + if p.Error() != fakeErr { + t.Errorf("want existing error %v preserved, got %v", fakeErr, p.Error()) + } +} + +func ExamplePipe_Shell() { + script.Echo("Hello, world!").Shell("tr a-z A-Z").Stdout() + // Output: + // HELLO, WORLD! +} diff --git a/script_windows_test.go b/script_windows_test.go index 3290d4e..defeb94 100644 --- a/script_windows_test.go +++ b/script_windows_test.go @@ -91,3 +91,41 @@ func ExamplePipe_Dirname() { // ./src // C:\ } + +func TestShellRunsCmdWithEchoHelloAndGetsOutputHello(t *testing.T) { + t.Parallel() + p := script.Shell("echo hello") + if p.Error() != nil { + t.Fatal(p.Error()) + } + want := "hello\r\n" + got, err := p.String() + if err != nil { + t.Fatal(err) + } + if want != got { + t.Errorf("want %q, got %q", want, got) + } +} + +func TestShell_ExpandsEnvironmentVariablesSetViaWithEnvOnWindows(t *testing.T) { + t.Parallel() + env := []string{"ENV1=test1"} + got, err := script.NewPipe().WithEnv(env).Shell("echo ENV1=%ENV1%").String() + if err != nil { + t.Fatal(err) + } + want := "ENV1=test1\r\n" + if want != got { + t.Errorf("want %q, got %q", want, got) + } +} + +func TestShellErrorsRunningCommandThatDoesNotExistOnWindows(t *testing.T) { + t.Parallel() + p := script.Shell("doesntexist_command_xyz") + p.Wait() + if p.Error() == nil { + t.Error("want error running non-existent command") + } +}