diff --git a/command.go b/command.go index 08b926f997..8ed1489cc3 100644 --- a/command.go +++ b/command.go @@ -1,6 +1,7 @@ package cli import ( + "bufio" "context" "flag" "fmt" @@ -10,6 +11,7 @@ import ( "reflect" "sort" "strings" + "unicode" ) const ( @@ -125,6 +127,9 @@ type Command struct { MutuallyExclusiveFlags []MutuallyExclusiveFlags // Arguments to parse for this command Arguments []Argument + // Whether to read arguments from stdin + // applicable to root command only + ReadArgsFromStdin bool // categories contains the categorized commands and is populated on app startup categories CommandCategories @@ -340,6 +345,83 @@ func (cmd *Command) ensureHelp() { } } +func (cmd *Command) parseArgsFromStdin() ([]string, error) { + type state int + const ( + STATE_SEARCH_FOR_TOKEN state = -1 + STATE_IN_STRING state = 0 + ) + + st := STATE_SEARCH_FOR_TOKEN + linenum := 1 + token := "" + args := []string{} + + breader := bufio.NewReader(cmd.Reader) + +outer: + for { + ch, _, err := breader.ReadRune() + if err == io.EOF { + switch st { + case STATE_SEARCH_FOR_TOKEN: + if token != "--" { + args = append(args, token) + } + case STATE_IN_STRING: + // make sure string is not empty + for _, t := range token { + if !unicode.IsSpace(t) { + args = append(args, token) + } + } + } + break outer + } + if err != nil { + return nil, err + } + switch st { + case STATE_SEARCH_FOR_TOKEN: + if unicode.IsSpace(ch) || ch == '"' { + if ch == '\n' { + linenum++ + } + if token != "" { + // end the processing here + if token == "--" { + break outer + } + args = append(args, token) + token = "" + } + if ch == '"' { + st = STATE_IN_STRING + } + continue + } + token += string(ch) + case STATE_IN_STRING: + if ch != '"' { + token += string(ch) + } else { + if token != "" { + args = append(args, token) + token = "" + } + /*else { + //TODO. Should we pass in empty strings ? + }*/ + st = STATE_SEARCH_FOR_TOKEN + } + } + } + + tracef("parsed stdin args as %v (cmd=%[2]q)", args, cmd.Name) + + return args, nil +} + // Run is the entry point to the command graph. The positional // arguments are parsed according to the Flag and Command // definitions and the matching Action functions are run. @@ -353,6 +435,13 @@ func (cmd *Command) Run(ctx context.Context, osArgs []string) (deferErr error) { } if cmd.parent == nil { + if cmd.ReadArgsFromStdin { + if args, err := cmd.parseArgsFromStdin(); err != nil { + return err + } else { + osArgs = append(osArgs, args...) + } + } // handle the completion flag separately from the flagset since // completion could be attempted after a flag, but before its value was put // on the command line. this causes the flagset to interpret the completion diff --git a/command_test.go b/command_test.go index 82d9a4faea..cce834eaf2 100644 --- a/command_test.go +++ b/command_test.go @@ -3862,3 +3862,168 @@ func TestCommand_ParentCommand_Set(t *testing.T) { t.Errorf("expect nil. set parent context flag return err: %s", err) } } + +func TestCommandReadArgsFromStdIn(t *testing.T) { + + tests := []struct { + name string + input string + args []string + expectedInt int64 + expectedFloat float64 + expectedSlice []string + expectError bool + }{ + { + name: "empty", + input: "", + args: []string{"foo"}, + expectedInt: 0, + expectedFloat: 0.0, + expectedSlice: []string{}, + }, + { + name: "empty2", + input: ` + + `, + args: []string{"foo"}, + expectedInt: 0, + expectedFloat: 0.0, + expectedSlice: []string{}, + }, + { + name: "intflag-from-input", + input: "--if=100", + args: []string{"foo"}, + expectedInt: 100, + expectedFloat: 0.0, + expectedSlice: []string{}, + }, + { + name: "intflag-from-input2", + input: ` + --if + + 100`, + args: []string{"foo"}, + expectedInt: 100, + expectedFloat: 0.0, + expectedSlice: []string{}, + }, + { + name: "multiflag-from-input", + input: ` + --if + + 100 + --ff 100.1 + + --ssf hello + --ssf + + "hello + 123 +44" + `, + args: []string{"foo"}, + expectedInt: 100, + expectedFloat: 100.1, + expectedSlice: []string{"hello", "hello\t\n 123\n44"}, + }, + { + name: "end-args", + input: ` + --if + + 100 + -- + --ff 100.1 + + --ssf hello + --ssf + + hell02 + `, + args: []string{"foo"}, + expectedInt: 100, + expectedFloat: 0, + expectedSlice: []string{}, + }, + { + name: "invalid string", + input: ` + " + `, + args: []string{"foo"}, + expectedInt: 0, + expectedFloat: 0, + expectedSlice: []string{}, + }, + { + name: "invalid string2", + input: ` + --if + " + `, + args: []string{"foo"}, + expectError: true, + }, + { + name: "incomplete string", + input: ` + --ssf + " + hello + `, + args: []string{"foo"}, + expectedSlice: []string{"hello"}, + }, + } + + for _, tst := range tests { + t.Run(tst.name, func(t *testing.T) { + r := require.New(t) + + fp, err := os.CreateTemp("", "readargs") + r.NoError(err) + _, err = fp.Write([]byte(tst.input)) + r.NoError(err) + fp.Close() + + cmd := buildMinimalTestCommand() + cmd.ReadArgsFromStdin = true + cmd.Reader, err = os.Open(fp.Name()) + r.NoError(err) + cmd.Flags = []Flag{ + &IntFlag{ + Name: "if", + }, + &FloatFlag{ + Name: "ff", + }, + &StringSliceFlag{ + Name: "ssf", + }, + } + + actionCalled := false + cmd.Action = func(ctx context.Context, c *Command) error { + r.Equal(tst.expectedInt, c.Int("if")) + r.Equal(tst.expectedFloat, c.Float("ff")) + r.Equal(tst.expectedSlice, c.StringSlice("ssf")) + actionCalled = true + return nil + } + + err = cmd.Run(context.Background(), tst.args) + if !tst.expectError { + r.NoError(err) + r.True(actionCalled) + } else { + r.Error(err) + } + + }) + } +} diff --git a/godoc-current.txt b/godoc-current.txt index d406b9f268..bb6f4c2e9d 100644 --- a/godoc-current.txt +++ b/godoc-current.txt @@ -394,6 +394,9 @@ type Command struct { MutuallyExclusiveFlags []MutuallyExclusiveFlags // Arguments to parse for this command Arguments []Argument + // Whether to read arguments from stdin + // applicable to root command only + ReadArgsFromStdin bool // Has unexported fields. } diff --git a/testdata/godoc-v3.x.txt b/testdata/godoc-v3.x.txt index d406b9f268..bb6f4c2e9d 100644 --- a/testdata/godoc-v3.x.txt +++ b/testdata/godoc-v3.x.txt @@ -394,6 +394,9 @@ type Command struct { MutuallyExclusiveFlags []MutuallyExclusiveFlags // Arguments to parse for this command Arguments []Argument + // Whether to read arguments from stdin + // applicable to root command only + ReadArgsFromStdin bool // Has unexported fields. }