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
66 changes: 49 additions & 17 deletions script.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,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,6 +423,27 @@ func (p *Pipe) Error() error {
return p.err
}

// runCmd configures and runs cmd, connecting it to the pipe's streams and environment.
func (p *Pipe) runCmd(cmd *exec.Cmd, r io.Reader, w io.Writer) error {
cmd.Stdin = r
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)
return err
}
return cmd.Wait()
}

// 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
Expand Down Expand Up @@ -452,23 +479,7 @@ func (p *Pipe) Exec(cmdLine string) *Pipe {
return err
}
cmd := exec.CommandContext(p.ctx, args[0], args[1:]...)
cmd.Stdin = r
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)
return err
}
return cmd.Wait()
return p.runCmd(cmd, r, w)
})
}

Expand Down Expand Up @@ -931,6 +942,27 @@ 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 [Pipe.Exec], Shell performs no argument parsing of its
// own; the entire command line 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 and environment variables
// set via [Pipe.WithEnv].
func (p *Pipe) Shell(cmdLine string) *Pipe {
return p.Filter(func(r io.Reader, w io.Writer) error {
cmd := shellCommand(p.ctx, cmdLine)
return p.runCmd(cmd, r, w)
})
}

// Slice returns the pipe's contents as a slice of strings, one element per
// line, or an error.
//
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")
}
}
14 changes: 14 additions & 0 deletions shell_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//go:build !windows

package script

import (
"context"
"os/exec"
)

// shellCommand returns the command used to run cmdLine via the system
// shell on Unix-like systems.
func shellCommand(ctx context.Context, cmdLine string) *exec.Cmd {
return exec.CommandContext(ctx, "sh", "-c", cmdLine)
}
14 changes: 14 additions & 0 deletions shell_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//go:build windows

package script

import (
"context"
"os/exec"
)

// shellCommand returns the command used to run cmdLine via the system
// shell on Windows.
func shellCommand(ctx context.Context, cmdLine string) *exec.Cmd {
return exec.CommandContext(ctx, "cmd", "/C", cmdLine)
}