Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Execv function (equivalent to Exec but with args passed as … #189

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ If you're already familiar with shell scripting and the Unix toolset, here is a
| Unix / shell | `script` equivalent |
| ------------------ | ------------------- |
| (any program name) | [`Exec`](https://pkg.go.dev/github.com/bitfield/script#Exec) |
| | [`Execv`](https://pkg.go.dev/github.com/bitfield/script#Execv) |
| `[ -f FILE ]` | [`IfExists`](https://pkg.go.dev/github.com/bitfield/script#IfExists) |
| `>` | [`WriteFile`](https://pkg.go.dev/github.com/bitfield/script#Pipe.WriteFile) |
| `>>` | [`AppendFile`](https://pkg.go.dev/github.com/bitfield/script#Pipe.AppendFile) |
Expand Down Expand Up @@ -177,6 +178,15 @@ PING 127.0.0.1 (127.0.0.1): 56 data bytes
...
```

If we are construcing Exec arguments dynamically it can be easier to use Execv where we can pass cmd and []args separately without having to escape args.

```go
args := []string{};
args = append(args,"127.0.0.1")
script.Exec("ping",args).Stdout()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would that be Execv?

Suggested change
script.Exec("ping",args).Stdout()
script.Execv("ping", args).Stdout()

```


In the `ping` example, we knew the exact arguments we wanted to send the command, and we just needed to run it once. But what if we don't know the arguments yet? We might get them from the user, for example.

We might like to be able to run the external command repeatedly, each time passing it the next line of data from the pipe as an argument. No worries:
Expand Down Expand Up @@ -271,6 +281,7 @@ These are functions that create a pipe with a given contents:
| [`Do`](https://pkg.go.dev/github.com/bitfield/script#Do) | HTTP response
| [`Echo`](https://pkg.go.dev/github.com/bitfield/script#Echo) | a string
| [`Exec`](https://pkg.go.dev/github.com/bitfield/script#Exec) | command output
| [`Execv`](https://pkg.go.dev/github.com/bitfield/script#Execv) | command output (args passed as [])
| [`File`](https://pkg.go.dev/github.com/bitfield/script#File) | file contents
| [`FindFiles`](https://pkg.go.dev/github.com/bitfield/script#FindFiles) | recursive file listing
| [`Get`](https://pkg.go.dev/github.com/bitfield/script#Get) | HTTP response
Expand All @@ -293,6 +304,7 @@ Filters are methods on an existing pipe that also return a pipe, allowing you to
| [`Do`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Do) | response to supplied HTTP request |
| [`Echo`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Echo) | all input replaced by given string |
| [`Exec`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Exec) | filtered through external command |
| [`Execv`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Execv) | filtered through external command (args passed as []) |
| [`ExecForEach`](https://pkg.go.dev/github.com/bitfield/script#Pipe.ExecForEach) | execute given command template for each line of input |
| [`Filter`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Filter) | user-supplied function filtering a reader to a writer |
| [`FilterLine`](https://pkg.go.dev/github.com/bitfield/script#Pipe.FilterLine) | user-supplied function filtering each line to a string|
Expand Down
34 changes: 34 additions & 0 deletions script.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ func Exec(cmdLine string) *Pipe {
return NewPipe().Exec(cmdLine)
}

// Execv creates a pipe that runs cmd as an external command with args []args
// and produces its combined output (interleaving standard output and standard
// error). See [Pipe.Execv] for error handling details.
func Execv(cmd string, args []string) *Pipe {
return NewPipe().Execv(cmd, args)
}

// File creates a pipe that reads from the file path.
func File(path string) *Pipe {
f, err := os.Open(path)
Expand Down Expand Up @@ -397,6 +404,33 @@ func (p *Pipe) Exec(cmdLine string) *Pipe {
})
}

// Execv behaves identically to Exec (runs external command as part of the
// pipe) however instead of accepting a single cmdLine string takes the cmd and
// []args separately. This avoids the need to quote args and reparse args
// if they are generated dynamically and any potential assciated string
// interpolation bugs
//
// # Error handling
//
// The error handling is the same as Exec
func (p *Pipe) Execv(cmd string, args []string) *Pipe {
return p.Filter(func(r io.Reader, w io.Writer) error {
cmd := exec.Command(cmd, args...)
cmd.Stdin = r
cmd.Stdout = w
cmd.Stderr = w
if p.stderr != nil {
cmd.Stderr = p.stderr
}
err := cmd.Start()
if err != nil {
Comment on lines +425 to +426

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
err := cmd.Start()
if err != nil {
if err := cmd.Start(); err != nil {

fmt.Fprintln(cmd.Stderr, err)
return err
}
return cmd.Wait()
})
}

// 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 error handling details.
Expand Down
44 changes: 44 additions & 0 deletions script_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1201,6 +1201,50 @@ func TestExecRunsGoHelpAndGetsUsageMessage(t *testing.T) {
}
}

func TestExecvErrorsWhenTheSpecifiedCommandDoesNotExist(t *testing.T) {
t.Parallel()
p := script.Execv("doesntexist", []string{"A", "B"})
p.Wait()
if p.Error() == nil {
t.Error("want error running non-existent command")
}
}

func TestExecvRunsGoWithNoArgsAndGetsUsageMessagePlusErrorExitStatus2(t *testing.T) {
t.Parallel()
// We can't make many cross-platform assumptions about what external
// commands will be available, but it seems logical that 'go' would be
// (though it may not be in the user's path)
p := script.Execv("go", []string{})
output, err := p.String()
if err == nil {
t.Fatal("want error when command returns a non-zero exit status")
}
if !strings.Contains(output, "Usage") {
t.Fatalf("want output containing the word 'Usage', got %q", output)
}
want := 2
got := p.ExitStatus()
if want != got {
t.Errorf("want exit status %d, got %d", want, got)
}
}

func TestExecvRunsGoHelpAndGetsUsageMessage(t *testing.T) {
t.Parallel()
p := script.Execv("go", []string{"help"})
if p.Error() != nil {
t.Fatal(p.Error())
}
output, err := p.String()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(output, "Usage") {
t.Fatalf("want output containing the word 'Usage', got %q", output)
}
}

func TestFileOutputsContentsOfSpecifiedFile(t *testing.T) {
t.Parallel()
want := "This is the first line in the file.\nHello, world.\nThis is another line in the file.\n"
Expand Down
79 changes: 78 additions & 1 deletion script_unix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,40 @@ func TestExecRunsShWithEchoHelloAndGetsOutputHello(t *testing.T) {
}
}

func TestExecvWithDynamicArgs(t *testing.T) {
t.Parallel()
args := []string{"-c"}
args = append(args, "echo hello")
p := script.Execv("sh", args)
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 TestExecvRunsShWithEchoHelloAndGetsOutputHello(t *testing.T) {
t.Parallel()
p := script.Execv("sh", []string{"-c", "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 TestExecRunsShWithinShWithEchoInceptionAndGetsOutputInception(t *testing.T) {
t.Parallel()
p := script.Exec("sh -c 'sh -c \"echo inception\"'")
Expand All @@ -52,6 +86,22 @@ func TestExecRunsShWithinShWithEchoInceptionAndGetsOutputInception(t *testing.T)
}
}

func TestExecvRunsShWithinShWithEchoInceptionAndGetsOutputInception(t *testing.T) {
t.Parallel()
p := script.Execv("sh", []string{"-c", "sh -c \"echo inception\""})
if p.Error() != nil {
t.Fatal(p.Error())
}
want := "inception\n"
got, err := p.String()
if err != nil {
t.Fatal(err)
}
if want != got {
t.Error(cmp.Diff(want, got))
}
}

func TestExecErrorsRunningShellCommandWithUnterminatedStringArgument(t *testing.T) {
t.Parallel()
p := script.Exec("sh -c 'echo oh no")
Expand All @@ -61,6 +111,15 @@ func TestExecErrorsRunningShellCommandWithUnterminatedStringArgument(t *testing.
}
}

func TestExecvErrorsRunningShellCommandWithUnterminatedStringArgument(t *testing.T) {
t.Parallel()
p := script.Execv("sh", []string{"-c", "'echo oh no"})
p.Wait()
if p.Error() == nil {
t.Error("want error running 'sh' command line containing unterminated string")
}
}

func TestExecForEach_RunsEchoWithABCAndGetsOutputABC(t *testing.T) {
t.Parallel()
p := script.Echo("a\nb\nc\n").ExecForEach("echo {{.}}")
Expand Down Expand Up @@ -112,6 +171,12 @@ func ExampleExec_ok() {
// Hello, world!
}

func ExampleExecv_ok() {
script.Execv("echo", []string{"Hello, world!"}).Stdout()
// Output:
// Hello, world!
}

func ExampleFindFiles() {
script.FindFiles("testdata/multiple_files_with_subdirectory").Stdout()
// Output:
Expand All @@ -129,8 +194,14 @@ func ExampleIfExists_exec() {
// hello
}

func ExampleIfExists_execv() {
script.IfExists("./testdata/hello.txt").Execv("echo", []string{"hello"}).Stdout()
// Output:
// hello
}

func ExampleIfExists_noExec() {
script.IfExists("doesntexist").Exec("echo hello").Stdout()
script.IfExists("doesntexist").Execv("echo", []string{"hello"}).Stdout()
// Output:
//
}
Expand Down Expand Up @@ -192,6 +263,12 @@ func ExamplePipe_Exec() {
// HELLO, WORLD!
}

func ExamplePipe_Execv() {
script.Echo("Hello, world!").Execv("tr", []string{"a-z", "A-Z"}).Stdout()
// Output:
// HELLO, WORLD!
}

func ExamplePipe_ExecForEach() {
script.Echo("a\nb\nc\n").ExecForEach("echo {{.}}").Stdout()
// Output:
Expand Down