Skip to content

Commit

Permalink
feat: Support terminated options like find -exec
Browse files Browse the repository at this point in the history
Enable option to receive arguments until specified terminator
is reached or EOL has been found. This is inspired from
``find -exec [commands..] ;`` where commands.. is treated as
arguments to -exec.

If for an option ``opt``, ``terminator`` is specified to be
; (semi-colon), in the following

    $ program [options] --opt v --w=x -- "y z" \; [more-options]

--opt will receive {"v", "--w=x", "--", "y z"} as its
argument. Note that, the -- inside will also be passed to
--opt regardless PassDoubleDash is set or not. However,
once the scope of --opt is finished, i.e. terminator ;
is reached, -- will act as before if PassDoubleDash is set.

Use tag ``terminator`` to specify the terminator for
the option related to that field.

Please note that, the specified terminator should be a
separate token, instead of being jotted with other characters.
For example,

    --opt [arguments..] ; [options..]

will be correctly parsed with terminator: ";". However,

    --opt [arguments..] arg; [options..]

will not be correctly parsed. The parser will pass "arg;",
and continue to look for the terminator in [options..].
  • Loading branch information
rebornplusplus committed Feb 22, 2023
1 parent 3927b71 commit df1e3bf
Show file tree
Hide file tree
Showing 5 changed files with 224 additions and 0 deletions.
4 changes: 4 additions & 0 deletions flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ The following is a list of tags for struct fields supported by go-flags:
Repeat this tag once for each allowable value.
e.g. `long:"animal" choice:"cat" choice:"dog"`
hidden: if non-empty, the option is not visible in the help or man page.
terminator: when specified, the option will accept a list of arguments (as a slice)
until the terminator string is found as an argument, or until the end
of the argument list. To allow the same terminated option multiple
times, use a slice of slices.
base: a base (radix) used to convert strings to integer values, the
default base is 10 (i.e. decimal) (optional)
Expand Down
9 changes: 9 additions & 0 deletions group.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,8 @@ func (g *Group) scanStruct(realval reflect.Value, sfield *reflect.StructField, h
choices := mtag.GetMany("choice")
hidden := !isStringFalsy(mtag.Get("hidden"))

terminator := mtag.Get("terminator")

option := &Option{
Description: description,
ShortName: short,
Expand All @@ -299,6 +301,7 @@ func (g *Group) scanStruct(realval reflect.Value, sfield *reflect.StructField, h
DefaultMask: defaultMask,
Choices: choices,
Hidden: hidden,
Terminator: terminator,

group: g,

Expand All @@ -313,6 +316,12 @@ func (g *Group) scanStruct(realval reflect.Value, sfield *reflect.StructField, h
option.shortAndLongName())
}

if option.isTerminated() && option.value.Kind() != reflect.Slice {
return newErrorf(ErrInvalidTag,
"terminated flag `%s' must be a slice or slice of slices",
option.shortAndLongName())
}

g.options = append(g.options, option)
}

Expand Down
73 changes: 73 additions & 0 deletions option.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,26 @@ type Option struct {
// If true, the option is not displayed in the help or man page
Hidden bool

// If not "", the option will accept a list of arguments (as a slice)
// until the terminator string is found as an argument, or until the end
// of the argument list. To allow the same terminated option multiple
// times, use a slice of slices.
//
// Inspired by "find -exec" (which uses a ';' terminator), this supports
// additional arguments after the terminator, for example:
//
// $ program [options] --terminated-opt v --w=x -- "y z" \; [more-options]
//
// In this example, --terminated-opt will receive {"v", "--w=x", "--", "y z"}.
// As with "find -exec", when using ';' as a terminator at the shell, it
// must be backslash-escaped to avoid the ';' being treated as a command
// separator by the shell.
//
// As shown, "--" between the option and the terminator won't trigger
// double-dash handling (if PassDoubleDash is set), but after the
// terminator it will.
Terminator string

// The group which the option belongs to
group *Group

Expand Down Expand Up @@ -282,6 +302,55 @@ func (option *Option) Set(value *string) error {
return convert("", option.value, option.tag)
}

func (option *Option) setTerminatedOption(value []*string) error {
tp := option.value.Type()

if tp.Kind() != reflect.Slice {
return newErrorf(ErrInvalidTag,
"terminated flag `%s' must be a slice or slice of slices",
option.shortAndLongName())
}

if len(value) == 0 {
return newErrorf(ErrExpectedArgument,
"expected argument for flag `%s'",
option.shortAndLongName())
}

if option.clearReferenceBeforeSet {
option.empty()
}

option.isSet = true
option.preventDefault = true
option.clearReferenceBeforeSet = false

elemtp := tp.Elem()

if elemtp.Kind() == reflect.Slice {
elemvalptr := reflect.New(elemtp)
elemval := reflect.Indirect(elemvalptr)

for _, val := range value {
if err := convert(*val, elemval, option.tag); err != nil {
return err
}
}

option.value.Set(reflect.Append(option.value, elemval))
} else {
option.empty()

for _, val := range value {
if err := convert(*val, option.value, option.tag); err != nil {
return err
}
}
}

return nil
}

func (option *Option) setDefault(value *string) error {
if option.preventDefault {
return nil
Expand Down Expand Up @@ -567,3 +636,7 @@ func (option *Option) isValidValue(arg string) error {
}
return nil
}

func (option *Option) isTerminated() bool {
return option.Terminator != ""
}
124 changes: 124 additions & 0 deletions options_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package flags

import (
"reflect"
"strings"
"testing"
)
Expand Down Expand Up @@ -141,3 +142,126 @@ func TestPassAfterNonOptionWithPositionalIntFail(t *testing.T) {
assertStringArray(t, ret, test.ret)
}
}

func TestTerminatedOptions(t *testing.T) {
type testOpt struct {
Slice []int `short:"s" long:"slice" terminator:"END"`
MultipleSlice [][]string `short:"m" long:"multiple" terminator:";"`
Bool bool `short:"v"`
}

tests := []struct {
summary string
parserOpts Options
args []string
expectedSlice []int
expectedMultipleSlice [][]string
expectedBool bool
expectedRest []string
shouldErr bool
}{
{
summary: "Terminators usage",
args: []string{
"-s", "1", "2", "3", "END",
"-m", "bin", "-xyz", "--foo", "bar", "-v", "foo bar", ";",
"-v",
"-m", "-xyz", "--foo",
},
expectedSlice: []int{1, 2, 3},
expectedMultipleSlice: [][]string{
{"bin", "-xyz", "--foo", "bar", "-v", "foo bar"},
{"-xyz", "--foo"},
},
expectedBool: true,
}, {
summary: "Slice overwritten",
args: []string{
"-s", "1", "2", "END",
"-s", "3", "4",
},
expectedSlice: []int{3, 4},
}, {
summary: "Terminator omitted for last opt",
args: []string{
"-s", "1", "2", "3",
},
expectedSlice: []int{1, 2, 3},
}, {
summary: "Shortnames jumbled",
args: []string{
"-vm", "--foo", "-v", "bar", ";",
"-s", "1", "2",
},
expectedSlice: []int{1, 2},
expectedMultipleSlice: [][]string{{"--foo", "-v", "bar"}},
expectedBool: true,
}, {
summary: "Terminator as a token",
args: []string{
"-m", "--foo", "-v;",
"-v",
},
expectedMultipleSlice: [][]string{{"--foo", "-v;", "-v"}},
}, {
summary: "DoubleDash",
parserOpts: PassDoubleDash,
args: []string{
"-m", "--foo", "--", "bar", ";",
"-v",
"--", "--foo", "bar",
},
expectedMultipleSlice: [][]string{{"--foo", "--", "bar"}},
expectedBool: true,
expectedRest: []string{"--foo", "bar"},
}, {
summary: "--opt=foo syntax",
args: []string{"-m=foo", "bar"},
shouldErr: true,
}, {
summary: "No args",
args: []string{"-m", ";"},
shouldErr: true,
}, {
summary: "No args, no terminator",
args: []string{"-m"},
shouldErr: true,
}, {
summary: "Nil args",
args: []string{"-m", ""},
expectedMultipleSlice: [][]string{{""}},
},
}

for _, test := range tests {
t.Run(test.summary, func(t *testing.T) {
opts := testOpt{}
p := NewParser(&opts, test.parserOpts)
rest, err := p.ParseArgs(test.args)

if err != nil {
if !test.shouldErr {
t.Errorf("Unexpected error: %v", err)
}
return
}
if test.shouldErr {
t.Errorf("Expected error")
}

if opts.Bool != test.expectedBool {
t.Errorf("Expected Bool to be %v, got %v", test.expectedBool, opts.Bool)
}

if !reflect.DeepEqual(opts.Slice, test.expectedSlice) {
t.Errorf("Expected Slice to be %v, got %v", test.expectedSlice, opts.MultipleSlice)
}

if !reflect.DeepEqual(opts.MultipleSlice, test.expectedMultipleSlice) {
t.Errorf("Expected MultipleSlice to be %v, got %v", test.expectedMultipleSlice, opts.MultipleSlice)
}

assertStringArray(t, rest, test.expectedRest)
})
}
}
14 changes: 14 additions & 0 deletions parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,20 @@ func (p *Parser) parseOption(s *parseState, name string, option *Option, canarg
}

err = option.Set(nil)
} else if option.isTerminated() {
var args []*string

if argument != nil {
return newErrorf(ErrInvalidTag, "terminated options' flag `%s' cannot use `='", option)
}
for !s.eof() {
arg := s.pop()
if arg == option.Terminator {
break
}
args = append(args, &arg)
}
err = option.setTerminatedOption(args)
} else if argument != nil || (canarg && !s.eof()) {
var arg string

Expand Down

0 comments on commit df1e3bf

Please sign in to comment.