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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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).
Expand Down
96 changes: 71 additions & 25 deletions script.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -417,41 +424,41 @@ func (p *Pipe) Error() error {
return p.err
}

// Exec runs cmdLine as an external command, sending it the contents of the
// pipe as input, and produces the command's standard output (see below for
// error output). The effect of this is to filter the contents of the pipe
// through the external command.
// ExecCommand runs name as an external command with the supplied args,
// sending it the contents of the pipe as input, and produces the
// command's combined output (interleaving standard output and standard
// error). Unlike [Pipe.Exec], ExecCommand performs no parsing of its
// own: name and args are passed directly to the operating system,
// exactly as supplied, with no shell involved.
//
// # Environment
//
// The command inherits the current process's environment, optionally modified
// by [Pipe.WithEnv].
// The command inherits the current process's environment, optionally
// modified by [Pipe.WithEnv].
//
// # Context
//
// The 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.
// The 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.
//
// # Error handling
//
// If the command had a non-zero exit status, the pipe's error status will also
// be set to the string exit status X, where X is the integer exit status.
// Even in the event of a non-zero exit status, the command's output will still
// be available in the pipe. This is often helpful for debugging. However,
// because [Pipe.String] is a no-op if the pipe's error status is set, if you
// want output you will need to reset the error status before calling
// [Pipe.String].
// If the command had a non-zero exit status, the pipe's error status
// will also be set to the string "exit status X", where X is the
// integer exit status. Even in the event of a non-zero exit status,
// the command's output will still be available in the pipe. This is
// often helpful for debugging. However, because [Pipe.String] is a
// no-op if the pipe's error status is set, if you want output you
// will need to reset the error status before calling [Pipe.String].
//
// If the command writes to its standard error stream, this will also go to the
// pipe, along with its standard output. However, the standard error text can
// instead be redirected to a supplied writer, using [Pipe.WithStderr].
func (p *Pipe) Exec(cmdLine string) *Pipe {
// If the command writes to its standard error stream, this will also
// go to the pipe, along with its standard output. However, the
// standard error text can instead be redirected to a supplied writer,
// using [Pipe.WithStderr].
func (p *Pipe) ExecCommand(name string, args ...string) *Pipe {
return p.Filter(func(r io.Reader, w io.Writer) error {
args, err := shell.Fields(cmdLine, nil)
if err != nil {
return err
}
cmd := exec.CommandContext(p.ctx, args[0], args[1:]...)
cmd := exec.CommandContext(p.ctx, name, args...)
cmd.Stdin = r
cmd.Stdout = w
cmd.Stderr = w
Expand All @@ -463,7 +470,7 @@ func (p *Pipe) Exec(cmdLine string) *Pipe {
if pipeEnv != nil {
cmd.Env = pipeEnv
}
err = cmd.Start()
err := cmd.Start()
if err != nil {
fmt.Fprintln(cmd.Stderr, err)
return err
Expand All @@ -472,6 +479,22 @@ func (p *Pipe) Exec(cmdLine string) *Pipe {
})
}

// Exec runs cmdLine as an external command, sending it the contents of
// the pipe as input, and produces the command's standard output (see
// below for error output). The effect of this is to filter the
// contents of the pipe through the external command. cmdLine is split
// into a program name and arguments using shell.Fields-style parsing.
//
// See [Pipe.ExecCommand] for details on error handling, context, and
// environment variables.
func (p *Pipe) Exec(cmdLine string) *Pipe {
args, err := shell.Fields(cmdLine, nil)
if err != nil {
return p.WithError(err)
}
return p.ExecCommand(args[0], args[1:]...)
}

// ExecForEach renders cmdLine as a Go template for each line of input, running
// the resulting command, and produces the combined output of all these
// commands in sequence. See [Pipe.Exec] for details on error handling and
Expand Down Expand Up @@ -931,6 +954,29 @@ 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. cmdLine is passed to the shell completely unmodified
// as a single argument; unlike [Pipe.Exec], Shell performs no parsing
// of cmdLine at all, so the shell alone is responsible for
// interpreting quoting, variable expansion, and other syntax, 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.ExecCommand] 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.ExecCommand(shell, flag, cmdLine)
}

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

func TestExec_SetsErrorImmediatelyOnInvalidCommandLineSyntax(t *testing.T) {
t.Parallel()
p := script.Exec("echo \"unterminated")
if p.Error() == nil {
t.Error("want error to be set immediately after Exec with invalid syntax, before reading the pipe")
}
}

func TestExecRunsGoWithNoArgsAndGetsUsageMessagePlusErrorExitStatus2(t *testing.T) {
t.Parallel()
// We can't make many cross-platform assumptions about what external
Expand Down
111 changes: 111 additions & 0 deletions script_unix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import (
"path/filepath"
"testing"

"bytes"
"errors"
"strings"

"github.com/bitfield/script"
"github.com/google/go-cmp/cmp"
)
Expand Down Expand Up @@ -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!
}
38 changes: 38 additions & 0 deletions script_windows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}