-
-
Notifications
You must be signed in to change notification settings - Fork 102
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
alexflint
wants to merge
19
commits into
master
Choose a base branch
from
prototype-v2
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Prototype v2 #197
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 09d28e1
split the parsing logic into ProcessEnvironment, ProcessCommandLine, …
alexflint 22f214d
added test that library does not directly access environment variable…
alexflint a1e2b67
add a test to check that default values can be ignored if needed
alexflint 4aea783
changed NewParser to take options at the end rather than config at th…
alexflint 5ca19cd
cleaned up the test helpers parse, pparse, and parseWithEnv: now all …
alexflint 5f0c48f
move construction logic out of parse.go into construct.go
alexflint 2775f58
add OverwriteWithOptions, OverwriteWithCommandLine
alexflint 64288c5
add appendToSlice, appendToMap, appendToSliceOrMap
alexflint b365ec0
add processSingle and make it responsible for checking whether an arg…
alexflint 1cc263f
add processSequence and make it responsible for respecting "overwrite"
alexflint 84b7154
add TestSliceWithEqualsSign
alexflint 0769dd5
add tests for new Process* and OverwriteWith* functions
alexflint 55d9025
rename "accumulatedArgs" -> "accessible"
alexflint 60a0117
update readme for v2 (still has some TODOs)
alexflint 47ff443
drop support for help tag inside arg tag
alexflint 2ffe246
add mdtest command to generate and run tests from a markdown file
alexflint f2539d7
add go.work -- maybe remove before merge?
alexflint c046f49
drop go.work and add it to .gitignore
alexflint File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
// | ||
// 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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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?