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

Prototype v2 #197

Open
wants to merge 19 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2e62846
drop support for multiple destination structs
alexflint Oct 4, 2022
09d28e1
split the parsing logic into ProcessEnvironment, ProcessCommandLine, …
alexflint Oct 4, 2022
22f214d
added test that library does not directly access environment variable…
alexflint Oct 4, 2022
a1e2b67
add a test to check that default values can be ignored if needed
alexflint Oct 4, 2022
4aea783
changed NewParser to take options at the end rather than config at th…
alexflint Oct 4, 2022
5ca19cd
cleaned up the test helpers parse, pparse, and parseWithEnv: now all …
alexflint Oct 4, 2022
5f0c48f
move construction logic out of parse.go into construct.go
alexflint Oct 4, 2022
2775f58
add OverwriteWithOptions, OverwriteWithCommandLine
alexflint Oct 4, 2022
64288c5
add appendToSlice, appendToMap, appendToSliceOrMap
alexflint Oct 4, 2022
b365ec0
add processSingle and make it responsible for checking whether an arg…
alexflint Oct 4, 2022
1cc263f
add processSequence and make it responsible for respecting "overwrite"
alexflint Oct 4, 2022
84b7154
add TestSliceWithEqualsSign
alexflint Oct 4, 2022
0769dd5
add tests for new Process* and OverwriteWith* functions
alexflint Oct 4, 2022
55d9025
rename "accumulatedArgs" -> "accessible"
alexflint Oct 4, 2022
60a0117
update readme for v2 (still has some TODOs)
alexflint Oct 7, 2022
47ff443
drop support for help tag inside arg tag
alexflint Oct 7, 2022
2ffe246
add mdtest command to generate and run tests from a markdown file
alexflint Oct 7, 2022
f2539d7
add go.work -- maybe remove before merge?
alexflint Oct 29, 2022
c046f49
drop go.work and add it to .gitignore
alexflint Oct 29, 2022
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: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
github.com/alexflint/go-scalar v1.1.0 h1:aaAouLLzI9TChcPXotr6gUhq+Scr8rl0P9P4PnltbhM=
github.com/alexflint/go-scalar v1.1.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o=
github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw=
github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand Down
321 changes: 321 additions & 0 deletions v2/construct.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
package arg

import (
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
)

// Argument represents a command line argument
type Argument struct {
dest path
field reflect.StructField // the struct field from which this option was created
long string // the --long form for this option, or empty if none
short string // the -s short form for this option, or empty if none
cardinality cardinality // determines how many tokens will be present (possible values: zero, one, multiple)
required bool // if true, this option must be present on the command line
positional bool // if true, this option will be looked for in the positional flags
separate bool // if true, each slice and map entry will have its own --flag
help string // the help text for this option
env string // the name of the environment variable for this option, or empty for none
defaultVal string // default value for this option
placeholder string // name of the data in help
}

// Command represents a named subcommand, or the top-level command
type Command struct {
name string
help string
dest path
args []*Argument
subcommands []*Command
parent *Command
}

// Parser represents a set of command line options with destination values
type Parser struct {
cmd *Command // the top-level command
root reflect.Value // destination struct to fill will values
version string // version from the argument struct
prologue string // prologue for help text (from the argument struct)
epilogue string // epilogue for help text (from the argument struct)

// the following fields are updated during processing of command line arguments
leaf *Command // the subcommand we processed last
accessible []*Argument // concatenation of the leaf subcommand's arguments plus all ancestors' arguments
seen map[*Argument]bool // the arguments we encountered while processing command line arguments
}

// Versioned is the interface that the destination struct should implement to
// make a version string appear at the top of the help message.
type Versioned interface {
// Version returns the version string that will be printed on a line by itself
// at the top of the help message.
Version() string
}

// Described is the interface that the destination struct should implement to
// make a description string appear at the top of the help message.
type Described interface {
// Description returns the string that will be printed on a line by itself
// at the top of the help message.
Description() string
}

// Epilogued is the interface that the destination struct should implement to
// add an epilogue string at the bottom of the help message.
type Epilogued interface {
// Epilogue returns the string that will be printed on a line by itself
// at the end of the help message.
Epilogue() string
}

// the ParserOption interface matches options for the parser constructor
type ParserOption interface {
parserOption()
}

type programNameParserOption struct {
s string
}

func (programNameParserOption) parserOption() {}

// WithProgramName overrides the name of the program as displayed in help test
func WithProgramName(name string) ParserOption {
return programNameParserOption{s: name}
}

// NewParser constructs a parser from a list of destination structs
func NewParser(dest interface{}, options ...ParserOption) (*Parser, error) {
// check the destination type
t := reflect.TypeOf(dest)
if t.Kind() != reflect.Ptr {
panic(fmt.Sprintf("%s is not a pointer (did you forget an ampersand?)", t))
}

// pick a program name for help text and usage output
program := "program"
if len(os.Args) > 0 {
program = filepath.Base(os.Args[0])
}

// apply the options
for _, opt := range options {
switch opt := opt.(type) {
case programNameParserOption:
program = opt.s
}
}

// build the root command from the struct
cmd, err := cmdFromStruct(program, path{}, t)
if err != nil {
return nil, err
}

// construct the parser
p := Parser{
seen: make(map[*Argument]bool),
root: reflect.ValueOf(dest),
cmd: cmd,
}
// copy the args for the root command into "accessible", which will
// grow each time we open up a subcommand
p.accessible = make([]*Argument, len(p.cmd.args))
copy(p.accessible, p.cmd.args)

// check for version, prologue, and epilogue
if dest, ok := dest.(Versioned); ok {
p.version = dest.Version()
}
if dest, ok := dest.(Described); ok {
p.prologue = dest.Description()
}
if dest, ok := dest.(Epilogued); ok {
p.epilogue = dest.Epilogue()
}

return &p, nil
}

func cmdFromStruct(name string, dest path, t reflect.Type) (*Command, error) {
// commands can only be created from pointers to structs
if t.Kind() != reflect.Ptr {
return nil, fmt.Errorf("subcommands must be pointers to structs but %s is a %s",
dest, t.Kind())
}

t = t.Elem()
if t.Kind() != reflect.Struct {
return nil, fmt.Errorf("subcommands must be pointers to structs but %s is a pointer to %s",
dest, t.Kind())
}

cmd := Command{
name: name,
dest: dest,
}

var errs []string
walkFields(t, func(field reflect.StructField, t reflect.Type) bool {
// check for the ignore switch in the tag
tag := field.Tag.Get("arg")
if tag == "-" {
return false
}

// if this is an embedded struct then recurse into its fields, even if
// it is unexported, because exported fields on unexported embedded
// structs are still writable
if field.Anonymous && field.Type.Kind() == reflect.Struct {
return true
}

// ignore any other unexported field
if !isExported(field.Name) {
return false
}

// create a new destination path for this field
subdest := dest.Child(field)
arg := Argument{
dest: subdest,
field: field,
long: strings.ToLower(field.Name),
}

help, exists := field.Tag.Lookup("help")
if exists {
arg.help = help
}

defaultVal, hasDefault := field.Tag.Lookup("default")
if hasDefault {
arg.defaultVal = defaultVal
}

// Look at the tag
var isSubcommand bool // tracks whether this field is a subcommand
for _, key := range strings.Split(tag, ",") {
if key == "" {
continue
}
key = strings.TrimLeft(key, " ")
var value string
if pos := strings.Index(key, ":"); pos != -1 {
value = key[pos+1:]
key = key[:pos]
}

switch {
case strings.HasPrefix(key, "---"):
errs = append(errs, fmt.Sprintf("%s.%s: too many hyphens", t.Name(), field.Name))
case strings.HasPrefix(key, "--"):
arg.long = key[2:]
case strings.HasPrefix(key, "-"):
if len(key) != 2 {
errs = append(errs, fmt.Sprintf("%s.%s: short arguments must be one character only",
t.Name(), field.Name))
return false
}
arg.short = key[1:]
case key == "required":
if hasDefault {
errs = append(errs, fmt.Sprintf("%s.%s: 'required' cannot be used when a default value is specified",
t.Name(), field.Name))
return false
}
arg.required = true
case key == "positional":
arg.positional = true
case key == "separate":
arg.separate = true
case key == "help": // deprecated
arg.help = value
case key == "env":
// Use override name if provided
if value != "" {
arg.env = value
} else {
arg.env = strings.ToUpper(field.Name)
}
case key == "subcommand":
// decide on a name for the subcommand
cmdname := value
if cmdname == "" {
cmdname = strings.ToLower(field.Name)
}

// parse the subcommand recursively
subcmd, err := cmdFromStruct(cmdname, subdest, field.Type)
if err != nil {
errs = append(errs, err.Error())
return false
}

subcmd.parent = &cmd
subcmd.help = field.Tag.Get("help")

cmd.subcommands = append(cmd.subcommands, subcmd)
isSubcommand = true
default:
errs = append(errs, fmt.Sprintf("unrecognized tag '%s' on field %s", key, tag))
return false
}
}

placeholder, hasPlaceholder := field.Tag.Lookup("placeholder")
if hasPlaceholder {
arg.placeholder = placeholder
} else if arg.long != "" {
arg.placeholder = strings.ToUpper(arg.long)
} else {
arg.placeholder = strings.ToUpper(arg.field.Name)
}

// Check whether this field is supported. It's good to do this here rather than
// wait until ParseValue because it means that a program with invalid argument
// fields will always fail regardless of whether the arguments it received
// exercised those fields.
if !isSubcommand {
cmd.args = append(cmd.args, &arg)

var err error
arg.cardinality, err = cardinalityOf(field.Type)
if err != nil {
errs = append(errs, fmt.Sprintf("%s.%s: %s fields are not supported",
t.Name(), field.Name, field.Type.String()))
return false
}
if arg.cardinality == multiple && hasDefault {
errs = append(errs, fmt.Sprintf("%s.%s: default values are not supported for slice or map fields",
t.Name(), field.Name))
return false
}
}

// if this was an embedded field then we already returned true up above
return false
})

if len(errs) > 0 {
return nil, errors.New(strings.Join(errs, "\n"))
}

// check that we don't have both positionals and subcommands
var hasPositional bool
for _, arg := range cmd.args {
if arg.positional {
hasPositional = true
}
}
if hasPositional && len(cmd.subcommands) > 0 {
return nil, fmt.Errorf("%s cannot have both subcommands and positional arguments", dest)
}

return &cmd, nil
}
25 changes: 25 additions & 0 deletions v2/construct_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package arg

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestInvalidTag(t *testing.T) {
var args struct {
Foo string `arg:"this_is_not_valid"`
}
_, err := NewParser(&args)
assert.Error(t, err)
}

func TestUnexportedFieldsSkipped(t *testing.T) {
var args struct {
unexported struct{}
}

_, err := NewParser(&args)
require.NoError(t, err)
}
39 changes: 39 additions & 0 deletions v2/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Package arg parses command line arguments using the fields from a struct.
//
// For example,
//
// var args struct {
// Iter int
// Debug bool
// }
// arg.MustParse(&args)
//
// defines two command line arguments, which can be set using any of
//
// ./example --iter=1 --debug // debug is a boolean flag so its value is set to true
// ./example -iter 1 // debug defaults to its zero value (false)
// ./example --debug=true // iter defaults to its zero value (zero)
Copy link
Contributor

Choose a reason for hiding this comment

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

You've got the comments after the example swapped around. Also --iter on the line above?

//
// The fastest way to see how to use go-arg is to read the examples below.
//
// Fields can be bool, string, any float type, or any signed or unsigned integer type.
// They can also be slices of any of the above, or slices of pointers to any of the above.
//
// Tags can be specified using the `arg` and `help` tag names:
//
// var args struct {
// Input string `arg:"positional"`
// Log string `arg:"positional,required"`
// Debug bool `arg:"-d" help:"turn on debug mode"`
// RealMode bool `arg:"--real"
// Wr io.Writer `arg:"-"`
// }
//
// Any tag string that starts with a single hyphen is the short form for an argument
// (e.g. `./example -d`), and any tag string that starts with two hyphens is the long
// form for the argument (instead of the field name).
//
// Other valid tag strings are `positional` and `required`.
//
// Fields can be excluded from processing with `arg:"-"`.
package arg
Loading