diff --git a/app.go b/app.go index 221b6ad..95ea686 100644 --- a/app.go +++ b/app.go @@ -191,7 +191,7 @@ func (a *App) Setup() { flag.VisitAll(func(f *flag.Flag) { // skip test flags if !strings.HasPrefix(f.Name, ignoreFlagPrefix) { - a.Flags = append(a.Flags, &extFlag{f}) + a.Flags = append(a.Flags, &extFlag{f: f}) } }) } @@ -274,10 +274,6 @@ func (a *App) newRootCommand() *Command { } } -func (a *App) newFlagSet() (*flag.FlagSet, error) { - return flagSet(a.Name, a.Flags) -} - func (a *App) useShortOptionHandling() bool { return a.UseShortOptionHandling } diff --git a/go.mod b/go.mod index d277426..561af1a 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,9 @@ require ( ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/stretchr/testify v1.8.1 // indirect golang.org/x/text v0.3.7 // indirect ) diff --git a/go.sum b/go.sum index 96058c7..94f9c5d 100644 --- a/go.sum +++ b/go.sum @@ -2,13 +2,26 @@ github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/argh/argh.go b/internal/argh/argh.go new file mode 100644 index 0000000..805c45c --- /dev/null +++ b/internal/argh/argh.go @@ -0,0 +1,37 @@ +package argh + +import ( + "errors" + "fmt" + "log" + "os" + "path/filepath" + "runtime" +) + +var ( + tracingEnabled = os.Getenv("ARGH_TRACING") == "enabled" + traceLogger *log.Logger + + Error = errors.New("argh error") +) + +func init() { + if !tracingEnabled { + return + } + + traceLogger = log.New(os.Stderr, "ARGH TRACING: ", 0) +} + +func tracef(format string, v ...any) { + if !tracingEnabled { + return + } + + if _, file, line, ok := runtime.Caller(1); ok { + format = fmt.Sprintf("%v:%v ", filepath.Base(file), line) + format + } + + traceLogger.Printf(format, v...) +} diff --git a/internal/argh/argh_test.go b/internal/argh/argh_test.go new file mode 100644 index 0000000..445d2a7 --- /dev/null +++ b/internal/argh/argh_test.go @@ -0,0 +1,181 @@ +package argh_test + +import ( + "flag" + "fmt" + "strconv" + "testing" + "time" + + "github.com/urfave/cli/v3/internal/argh" +) + +func ptrTo[T any](v T) *T { + return &v +} + +func ptrFrom[T any](v *T) T { + if v != nil { + return *v + } + + var zero T + return zero +} + +func BenchmarkStdlibFlag(b *testing.B) { + for i := 0; i < b.N; i++ { + func() { + fl := flag.NewFlagSet("bench", flag.PanicOnError) + okFlag := fl.Bool("ok", false, "") + durFlag := fl.Duration("dur", time.Second, "") + f64Flag := fl.Float64("f64", float64(42.0), "") + iFlag := fl.Int("i", -11, "") + i64Flag := fl.Int64("i64", -111111111111, "") + sFlag := fl.String("s", "hello", "") + uFlag := fl.Uint("u", 11, "") + u64Flag := fl.Uint64("u64", 11111111111111111111, "") + + _ = fl.Parse([]string{ + "-ok", + "-dur", "42h42m10s", + "-f64", "4242424242.42", + "-i", "-42", + "-i64", "-4242424242", + "-s", "the answer", + "-u", "42", + "-u64", "4242424242", + }) + _ = fmt.Sprint( + "fl", fl, + "okFlag", *okFlag, + "durFlag", *durFlag, + "f64Flag", *f64Flag, + "iFlag", *iFlag, + "i64Flag", *i64Flag, + "sFlag", *sFlag, + "uFlag", *uFlag, + "u64Flag", *u64Flag, + ) + }() + } +} + +func BenchmarkArgh(b *testing.B) { + for i := 0; i < b.N; i++ { + func() { + var ( + okFlag *bool + durFlag *time.Duration + f64Flag *float64 + iFlag *int + i64Flag *int64 + sFlag *string + uFlag *uint + u64Flag *uint64 + ) + + pCfg := argh.NewParserConfig() + pCfg.Prog = &argh.CommandConfig{ + Flags: &argh.Flags{ + Map: map[string]argh.FlagConfig{ + "ok": { + On: func(fl argh.CommandFlag) { + okFlag = ptrTo(true) + }, + }, + "dur": { + NValue: 1, + On: func(fl argh.CommandFlag) { + if v, ok := fl.Values["0"]; ok { + if pt, err := time.ParseDuration(v); err != nil { + durFlag = ptrTo(pt) + } + } + }, + }, + "f64": { + NValue: 1, + On: func(fl argh.CommandFlag) { + if v, ok := fl.Values["0"]; ok { + if f, err := strconv.ParseFloat(v, 64); err == nil { + f64Flag = ptrTo(f) + } + } + }, + }, + "i": { + NValue: 1, + On: func(fl argh.CommandFlag) { + if v, ok := fl.Values["0"]; ok { + if i, err := strconv.ParseInt(v, 10, 64); err == nil { + iFlag = ptrTo(int(i)) + } + } + }, + }, + "i64": { + NValue: 1, + On: func(fl argh.CommandFlag) { + if v, ok := fl.Values["0"]; ok { + if i, err := strconv.ParseInt(v, 10, 64); err == nil { + i64Flag = ptrTo(i) + } + } + }, + }, + "s": { + NValue: 1, + On: func(fl argh.CommandFlag) { + if v, ok := fl.Values["0"]; ok { + sFlag = ptrTo(v) + } + }, + }, + "u": { + NValue: 1, + On: func(fl argh.CommandFlag) { + if v, ok := fl.Values["0"]; ok { + if u, err := strconv.ParseUint(v, 10, 64); err == nil { + uFlag = ptrTo(uint(u)) + } + } + }, + }, + "u64": { + NValue: 1, + On: func(fl argh.CommandFlag) { + if v, ok := fl.Values["0"]; ok { + if u, err := strconv.ParseUint(v, 10, 64); err == nil { + u64Flag = ptrTo(u) + } + } + }, + }, + }, + }, + } + + _, _ = argh.ParseArgs([]string{ + "--ok", + "--dur", "42h42m10s", + "--f64", "4242424242.42", + "-i", "-42", + "--i64", "-4242424242", + "-s", "the answer", + "-u", "42", + "--u64", "4242424242", + }, pCfg) + _ = fmt.Sprint( + "okFlag", ptrFrom(okFlag), + "durFlag", ptrFrom(durFlag), + "f64Flag", ptrFrom(f64Flag), + "iFlag", ptrFrom(iFlag), + "i64Flag", ptrFrom(i64Flag), + "sFlag", ptrFrom(sFlag), + "uFlag", ptrFrom(uFlag), + "u64Flag", ptrFrom(u64Flag), + ) + }() + } +} diff --git a/internal/argh/ast.go b/internal/argh/ast.go new file mode 100644 index 0000000..2be8802 --- /dev/null +++ b/internal/argh/ast.go @@ -0,0 +1,50 @@ +package argh + +// ToAST accepts a slice of nodes as expected from ParseArgs and +// returns an AST with parse-time artifacts dropped and reorganized +// where applicable. +func ToAST(parseTree []Node) []Node { + ret := []Node{} + + for i, node := range parseTree { + tracef("ToAST i=%d node type=%T", i, node) + + if _, ok := node.(*ArgDelimiter); ok { + continue + } + + if _, ok := node.(*StopFlag); ok { + continue + } + + if v, ok := node.(*CompoundShortFlag); ok { + if v.Nodes != nil { + ret = append(ret, ToAST(v.Nodes)...) + } + + continue + } + + if v, ok := node.(*CommandFlag); ok { + astNodes := ToAST(v.Nodes) + + if len(astNodes) == 0 { + astNodes = nil + } + + ret = append( + ret, + &CommandFlag{ + Name: v.Name, + Values: v.Values, + Nodes: astNodes, + }) + + continue + } + + ret = append(ret, node) + } + + return ret +} diff --git a/internal/argh/node.go b/internal/argh/node.go new file mode 100644 index 0000000..95f8902 --- /dev/null +++ b/internal/argh/node.go @@ -0,0 +1,42 @@ +package argh + +type Node interface{} + +type TypedNode struct { + Type string + Node Node +} + +type PassthroughArgs struct { + Nodes []Node +} + +type CompoundShortFlag struct { + Nodes []Node +} + +type Ident struct { + Literal string +} + +type BadArg struct { + Literal string + From Pos + To Pos +} + +// CommandFlag is a Node with a name, a slice of child Nodes, and +// potentially a map of named values derived from the child Nodes +type CommandFlag struct { + Name string + Values map[string]string + Nodes []Node +} + +type StdinFlag struct{} + +type StopFlag struct{} + +type ArgDelimiter struct{} + +type Assign struct{} diff --git a/internal/argh/nvalue_string.go b/internal/argh/nvalue_string.go new file mode 100644 index 0000000..d9d1f06 --- /dev/null +++ b/internal/argh/nvalue_string.go @@ -0,0 +1,26 @@ +// Code generated by "stringer -type NValue"; DO NOT EDIT. + +package argh + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[OneOrMoreValue - -2] + _ = x[ZeroOrMoreValue - -1] + _ = x[ZeroValue-0] +} + +const _NValue_name = "OneOrMoreValueZeroOrMoreValueZeroValue" + +var _NValue_index = [...]uint8{0, 14, 29, 38} + +func (i NValue) String() string { + i -= -2 + if i < 0 || i >= NValue(len(_NValue_index)-1) { + return "NValue(" + strconv.FormatInt(int64(i+-2), 10) + ")" + } + return _NValue_name[_NValue_index[i]:_NValue_index[i+1]] +} diff --git a/internal/argh/parser.go b/internal/argh/parser.go new file mode 100644 index 0000000..35d2160 --- /dev/null +++ b/internal/argh/parser.go @@ -0,0 +1,392 @@ +package argh + +import ( + "fmt" + "io" + "strings" +) + +type parser struct { + s *Scanner + + cfg *ParserConfig + + errors ParserErrorList + + tok Token + lit string + pos Pos + + buffered bool +} + +type ParseTree struct { + Nodes []Node `json:"nodes"` +} + +func ParseArgs(args []string, pCfg *ParserConfig) (*ParseTree, error) { + p := &parser{} + + if err := p.init( + strings.NewReader(strings.Join(args, string(nul))), + pCfg, + ); err != nil { + return nil, err + } + + tracef("ParseArgs(...) parser=%+#v", p) + + return p.parseArgs() +} + +func (p *parser) addError(msg string) { + p.errors.Add(Position{Column: int(p.pos)}, msg) +} + +func (p *parser) init(r io.Reader, pCfg *ParserConfig) error { + p.errors = ParserErrorList{} + + if pCfg == nil { + return fmt.Errorf("nil parser config: %w", Error) + } + + p.cfg = pCfg + + p.s = NewScanner(r, pCfg.ScannerConfig) + + p.next() + + return nil +} + +func (p *parser) parseArgs() (*ParseTree, error) { + if p.errors.Len() != 0 { + tracef("parseArgs() bailing due to initial error") + return nil, p.errors.Err() + } + + tracef("parseArgs() parsing %q as program command; cfg=%+#v", p.lit, p.cfg.Prog) + prog := p.parseCommand(p.cfg.Prog) + + tracef("parseArgs() top level node is %T", prog) + + nodes := []Node{prog} + if v := p.parsePassthrough(); v != nil { + tracef("parseArgs() appending passthrough argument %v", v) + nodes = append(nodes, v) + } + + tracef("parseArgs() returning ParseTree") + + return &ParseTree{Nodes: nodes}, p.errors.Err() +} + +func (p *parser) next() { + tracef("next() before scan: %v %q %v", p.tok, p.lit, p.pos) + + p.tok, p.lit, p.pos = p.s.Scan() + + tracef("next() after scan: %v %q %v", p.tok, p.lit, p.pos) +} + +func (p *parser) parseCommand(cCfg *CommandConfig) Node { + tracef("parseCommand(%+#v)", cCfg) + + node := &CommandFlag{ + Name: p.lit, + } + values := map[string]string{} + nodes := []Node{} + + identIndex := 0 + + for i := 0; p.tok != EOL; i++ { + if !p.buffered { + tracef("parseCommand(...) buffered=false; scanning next") + p.next() + } + + p.buffered = false + + tracef("parseCommand(...) for=%d values=%+#v", i, values) + tracef("parseCommand(...) for=%d nodes=%+#v", i, nodes) + tracef("parseCommand(...) for=%d tok=%s lit=%q pos=%v", i, p.tok, p.lit, p.pos) + + tracef("parseCommand(...) cCfg=%+#v", cCfg) + + if subCfg, ok := cCfg.GetCommandConfig(p.lit); ok { + subCommand := p.lit + + nodes = append(nodes, p.parseCommand(&subCfg)) + + tracef("parseCommand(...) breaking after sub-command=%v", subCommand) + break + } + + switch p.tok { + case ARG_DELIMITER: + tracef("parseCommand(...) handling %s", p.tok) + + nodes = append(nodes, &ArgDelimiter{}) + + continue + case IDENT, STDIN_FLAG: + tracef("parseCommand(...) handling %s", p.tok) + + if cCfg.NValue.Contains(identIndex) { + name := fmt.Sprintf("%d", identIndex) + + tracef("parseCommand(...) checking for name of identIndex=%d", identIndex) + + if len(cCfg.ValueNames) > identIndex { + name = cCfg.ValueNames[identIndex] + tracef("parseCommand(...) setting name=%s from config value names", name) + } else if len(cCfg.ValueNames) == 1 && (cCfg.NValue == OneOrMoreValue || cCfg.NValue == ZeroOrMoreValue) { + name = fmt.Sprintf("%s.%d", cCfg.ValueNames[0], identIndex) + tracef("parseCommand(...) setting name=%s from repeating value name", name) + } + + values[name] = p.lit + } + + if p.tok == STDIN_FLAG { + nodes = append(nodes, &StdinFlag{}) + } else { + nodes = append(nodes, &Ident{Literal: p.lit}) + } + + identIndex++ + case LONG_FLAG, SHORT_FLAG, COMPOUND_SHORT_FLAG: + tok := p.tok + + flagNode := p.parseFlag(cCfg.Flags) + + tracef("parseCommand(...) appending %s node=%+#v", tok, flagNode) + + nodes = append(nodes, flagNode) + case ASSIGN: + tracef("parseCommand(...) error on bare %s", p.tok) + + p.addError("invalid bare assignment") + + break + default: + tracef("parseCommand(...) breaking on %s", p.tok) + break + } + } + + if len(nodes) > 0 { + node.Nodes = nodes + } + + if len(values) > 0 { + node.Values = values + } + + if cCfg.On != nil { + tracef("parseCommand(...) calling command config handler for node=%+#v", node) + cCfg.On(*node) + } else { + tracef("parseCommand(...) no command config handler for node=%+#v", node) + } + + tracef("parseCommand(...) returning node=%+#v", node) + return node +} + +func (p *parser) parseIdent() Node { + node := &Ident{Literal: p.lit} + return node +} + +func (p *parser) parseFlag(flags *Flags) Node { + switch p.tok { + case SHORT_FLAG: + tracef("parseFlag(...) parsing short flag with config=%+#v", flags) + return p.parseShortFlag(flags) + case LONG_FLAG: + tracef("parseFlag(...) parsing long flag with config=%+#v", flags) + return p.parseLongFlag(flags) + case COMPOUND_SHORT_FLAG: + tracef("parseFlag(...) parsing compound short flag with config=%+#v", flags) + return p.parseCompoundShortFlag(flags) + } + + panic(fmt.Sprintf("token %v cannot be parsed as flag", p.tok)) +} + +func (p *parser) parseShortFlag(flags *Flags) Node { + node := &CommandFlag{Name: string(p.lit[1])} + + flCfg, ok := flags.Get(node.Name) + if !ok { + p.addError(fmt.Sprintf("unknown flag %[1]q", node.Name)) + + return node + } + + return p.parseConfiguredFlag(node, flCfg, nil) +} + +func (p *parser) parseLongFlag(flags *Flags) Node { + node := &CommandFlag{Name: string(p.lit[2:])} + + flCfg, ok := flags.Get(node.Name) + if !ok { + p.addError(fmt.Sprintf("unknown flag %[1]q", node.Name)) + + return node + } + + return p.parseConfiguredFlag(node, flCfg, nil) +} + +func (p *parser) parseCompoundShortFlag(flags *Flags) Node { + unparsedFlags := []*CommandFlag{} + unparsedFlagConfigs := []FlagConfig{} + + withoutFlagPrefix := p.lit[1:] + + for _, r := range withoutFlagPrefix { + node := &CommandFlag{Name: string(r)} + + flCfg, ok := flags.Get(node.Name) + if !ok { + p.addError(fmt.Sprintf("unknown flag %[1]q", node.Name)) + + continue + } + + unparsedFlags = append(unparsedFlags, node) + unparsedFlagConfigs = append(unparsedFlagConfigs, flCfg) + } + + flagNodes := []Node{} + + for i, node := range unparsedFlags { + flCfg := unparsedFlagConfigs[i] + + if i != len(unparsedFlags)-1 { + // NOTE: if a compound short flag is configured to accept + // more than zero values but is not the last flag in the + // group, it will be parsed with an override NValue of + // ZeroValue so that it does not consume the next token. + if flCfg.NValue.Required() { + p.addError( + fmt.Sprintf( + "short flag %[1]q before end of compound group expects value", + node.Name, + ), + ) + } + + flagNodes = append( + flagNodes, + p.parseConfiguredFlag(node, flCfg, zeroValuePtr), + ) + + continue + } + + flagNodes = append(flagNodes, p.parseConfiguredFlag(node, flCfg, nil)) + } + + return &CompoundShortFlag{Nodes: flagNodes} +} + +func (p *parser) parseConfiguredFlag(node *CommandFlag, flCfg FlagConfig, nValueOverride *NValue) Node { + values := map[string]string{} + nodes := []Node{} + + atExit := func() *CommandFlag { + if len(nodes) > 0 { + node.Nodes = nodes + } + + if len(values) > 0 { + node.Values = values + } + + if flCfg.On != nil { + tracef("parseConfiguredFlag(...) calling flag config handler for node=%+#[1]v", node) + flCfg.On(*node) + } else { + tracef("parseConfiguredFlag(...) no flag config handler for node=%+#[1]v", node) + } + + return node + } + + identIndex := 0 + + for i := 0; p.tok != EOL; i++ { + if nValueOverride != nil && !(*nValueOverride).Contains(identIndex) { + tracef("parseConfiguredFlag(...) identIndex=%d exceeds expected=%v; breaking", identIndex, *nValueOverride) + break + } + + if !flCfg.NValue.Contains(identIndex) { + tracef("parseConfiguredFlag(...) identIndex=%d exceeds expected=%v; breaking", identIndex, flCfg.NValue) + break + } + + p.next() + + switch p.tok { + case ARG_DELIMITER: + nodes = append(nodes, &ArgDelimiter{}) + + continue + case ASSIGN: + nodes = append(nodes, &Assign{}) + + continue + case IDENT, STDIN_FLAG: + name := fmt.Sprintf("%d", identIndex) + + tracef("parseConfiguredFlag(...) checking for name of identIndex=%d", identIndex) + + if len(flCfg.ValueNames) > identIndex { + name = flCfg.ValueNames[identIndex] + tracef("parseConfiguredFlag(...) setting name=%s from config value names", name) + } else if len(flCfg.ValueNames) == 1 && (flCfg.NValue == OneOrMoreValue || flCfg.NValue == ZeroOrMoreValue) { + name = fmt.Sprintf("%s.%d", flCfg.ValueNames[0], identIndex) + tracef("parseConfiguredFlag(...) setting name=%s from repeating value name", name) + } else { + tracef("parseConfiguredFlag(...) setting name=%s", name) + } + + values[name] = p.lit + + if p.tok == STDIN_FLAG { + nodes = append(nodes, &StdinFlag{}) + } else { + nodes = append(nodes, &Ident{Literal: p.lit}) + } + + identIndex++ + default: + tracef("parseConfiguredFlag(...) breaking on %s %q %v; setting buffered=true", p.tok, p.lit, p.pos) + p.buffered = true + + return atExit() + } + } + + return atExit() +} + +func (p *parser) parsePassthrough() Node { + nodes := []Node{} + + for ; p.tok != EOL; p.next() { + nodes = append(nodes, &Ident{Literal: p.lit}) + } + + if len(nodes) == 0 { + return nil + } + + return &PassthroughArgs{Nodes: nodes} +} diff --git a/internal/argh/parser_config.go b/internal/argh/parser_config.go new file mode 100644 index 0000000..eb3adb7 --- /dev/null +++ b/internal/argh/parser_config.go @@ -0,0 +1,214 @@ +package argh + +import "sync" + +const ( + OneOrMoreValue NValue = -2 + ZeroOrMoreValue NValue = -1 + ZeroValue NValue = 0 +) + +var ( + zeroValuePtr = func() *NValue { + v := ZeroValue + return &v + }() +) + +type NValue int + +func (nv NValue) Required() bool { + if nv == OneOrMoreValue { + return true + } + + return int(nv) >= 1 +} + +func (nv NValue) Contains(i int) bool { + tracef("NValue.Contains(%v)", i) + + if i < int(ZeroValue) { + return false + } + + if nv == OneOrMoreValue || nv == ZeroOrMoreValue { + return true + } + + return int(nv) > i +} + +type ParserConfig struct { + Prog *CommandConfig + + ScannerConfig *ScannerConfig +} + +type ParserOption func(*ParserConfig) + +func NewParserConfig(opts ...ParserOption) *ParserConfig { + pCfg := &ParserConfig{} + + for _, opt := range opts { + if opt != nil { + opt(pCfg) + } + } + + if pCfg.Prog == nil { + pCfg.Prog = &CommandConfig{} + pCfg.Prog.init() + } + + if pCfg.ScannerConfig == nil { + pCfg.ScannerConfig = POSIXyScannerConfig + } + + return pCfg +} + +type CommandConfig struct { + NValue NValue + ValueNames []string + Flags *Flags + Commands *Commands + + On func(CommandFlag) +} + +func (cCfg *CommandConfig) init() { + if cCfg.ValueNames == nil { + cCfg.ValueNames = []string{} + } + + if cCfg.Flags == nil { + cCfg.Flags = &Flags{} + } + + if cCfg.Commands == nil { + cCfg.Commands = &Commands{} + } +} + +func (cCfg *CommandConfig) GetCommandConfig(name string) (CommandConfig, bool) { + tracef("CommandConfig.GetCommandConfig(%q)", name) + + if cCfg.Commands == nil { + cCfg.Commands = &Commands{Map: map[string]CommandConfig{}} + } + + return cCfg.Commands.Get(name) +} + +func (cCfg *CommandConfig) GetFlagConfig(name string) (FlagConfig, bool) { + tracef("CommandConfig.GetFlagConfig(%q)", name) + + if cCfg.Flags == nil { + cCfg.Flags = &Flags{Map: map[string]FlagConfig{}} + } + + return cCfg.Flags.Get(name) +} + +func (cCfg *CommandConfig) SetFlagConfig(name string, flCfg FlagConfig) { + tracef("CommandConfig.SetFlagConfig(%q, ...)", name) + + if cCfg.Flags == nil { + cCfg.Flags = &Flags{Map: map[string]FlagConfig{}} + } + + cCfg.Flags.Set(name, flCfg) +} + +func (cCfg *CommandConfig) SetDefaultFlagConfig(name string, flCfg FlagConfig) { + tracef("CommandConfig.SetDefaultFlagConfig(%q, ...)", name) + + if cCfg.Flags == nil { + cCfg.Flags = &Flags{Map: map[string]FlagConfig{}} + } + + cCfg.Flags.SetDefault(name, flCfg) +} + +type FlagConfig struct { + NValue NValue + Persist bool + ValueNames []string + + On func(CommandFlag) +} + +type Flags struct { + Parent *Flags + Map map[string]FlagConfig + + Automatic bool + + m sync.Mutex +} + +func (fl *Flags) Get(name string) (FlagConfig, bool) { + tracef("Flags.Get(%q)", name) + + if fl.Map == nil { + fl.Map = map[string]FlagConfig{} + } + + flCfg, ok := fl.Map[name] + if !ok { + if fl.Automatic { + return FlagConfig{}, true + } + + if fl.Parent != nil { + flCfg, ok = fl.Parent.Get(name) + return flCfg, ok && flCfg.Persist + } + } + + return flCfg, ok +} + +func (fl *Flags) Set(name string, flCfg FlagConfig) { + tracef("Flags.Set(%q, ...)", name) + + fl.m.Lock() + defer fl.m.Unlock() + + if fl.Map == nil { + fl.Map = map[string]FlagConfig{} + } + + fl.Map[name] = flCfg +} + +func (fl *Flags) SetDefault(name string, flCfg FlagConfig) { + tracef("Flags.SetDefault(%q, ...)", name) + + fl.m.Lock() + defer fl.m.Unlock() + + if fl.Map == nil { + fl.Map = map[string]FlagConfig{} + } + + if _, ok := fl.Map[name]; !ok { + fl.Map[name] = flCfg + } +} + +type Commands struct { + Map map[string]CommandConfig +} + +func (cmd *Commands) Get(name string) (CommandConfig, bool) { + tracef("Commands.Get(%q)", name) + + if cmd.Map == nil { + cmd.Map = map[string]CommandConfig{} + } + + cmdCfg, ok := cmd.Map[name] + return cmdCfg, ok +} diff --git a/internal/argh/parser_error.go b/internal/argh/parser_error.go new file mode 100644 index 0000000..62ae3b7 --- /dev/null +++ b/internal/argh/parser_error.go @@ -0,0 +1,88 @@ +package argh + +import ( + "fmt" + "io" + "sort" +) + +// ParserError is largely borrowed from go/scanner.Error +type ParserError struct { + Pos Position + Msg string +} + +func (e ParserError) Error() string { + if e.Pos.IsValid() { + return e.Pos.String() + ":" + e.Msg + } + + return e.Msg +} + +// ParserErrorList is largely borrowed from go/scanner.ErrorList +type ParserErrorList []*ParserError + +func (el *ParserErrorList) Add(pos Position, msg string) { + *el = append(*el, &ParserError{Pos: pos, Msg: msg}) +} + +func (el *ParserErrorList) Reset() { *el = (*el)[0:0] } + +func (el ParserErrorList) Len() int { return len(el) } + +func (el ParserErrorList) Swap(i, j int) { el[i], el[j] = el[j], el[i] } + +func (el ParserErrorList) Less(i, j int) bool { + e := &el[i].Pos + f := &el[j].Pos + + if e.Column != f.Column { + return e.Column < f.Column + } + + return el[i].Msg < el[j].Msg +} + +func (el ParserErrorList) Sort() { + sort.Sort(el) +} + +func (el ParserErrorList) Error() string { + switch len(el) { + case 0: + return "no errors" + case 1: + return el[0].Error() + } + return fmt.Sprintf("%s (and %d more errors)", el[0], len(el)-1) +} + +func (el ParserErrorList) Err() error { + if len(el) == 0 { + return nil + } + return el +} + +func (el ParserErrorList) Is(other error) bool { + if _, ok := other.(ParserErrorList); ok { + return el.Error() == other.Error() + } + + if v, ok := other.(*ParserErrorList); ok { + return el.Error() == (*v).Error() + } + + return false +} + +func PrintParserError(w io.Writer, err error) { + if list, ok := err.(ParserErrorList); ok { + for _, e := range list { + fmt.Fprintf(w, "%s\n", e) + } + } else if err != nil { + fmt.Fprintf(w, "%s\n", err) + } +} diff --git a/internal/argh/parser_test.go b/internal/argh/parser_test.go new file mode 100644 index 0000000..584bd70 --- /dev/null +++ b/internal/argh/parser_test.go @@ -0,0 +1,1058 @@ +package argh_test + +import ( + "testing" + + "github.com/davecgh/go-spew/spew" + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v3/internal/argh" +) + +func TestParser(t *testing.T) { + traceOnCommandFlag := func(cmd argh.CommandFlag) { + t.Logf("CommandFlag.On: %+#[1]v", cmd) + } + + for _, tc := range []struct { + name string + args []string + cfg *argh.ParserConfig + expErr error + expPT []argh.Node + expAST []argh.Node + skip bool + }{ + { + name: "basic", + args: []string{ + "pies", "-eat", "--wat", "hello", "mario", + }, + cfg: &argh.ParserConfig{ + Prog: &argh.CommandConfig{ + Flags: &argh.Flags{ + Map: map[string]argh.FlagConfig{ + "e": {On: traceOnCommandFlag}, + "a": {On: traceOnCommandFlag}, + "t": {On: traceOnCommandFlag}, + "wat": {On: traceOnCommandFlag}, + }, + }, + Commands: &argh.Commands{ + Map: map[string]argh.CommandConfig{ + "hello": argh.CommandConfig{ + NValue: 1, + ValueNames: []string{"name"}, + On: traceOnCommandFlag, + }, + }, + }, + On: traceOnCommandFlag, + }, + }, + expPT: []argh.Node{ + &argh.CommandFlag{ + Name: "pies", + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.CompoundShortFlag{ + Nodes: []argh.Node{ + &argh.CommandFlag{Name: "e"}, + &argh.CommandFlag{Name: "a"}, + &argh.CommandFlag{Name: "t"}, + }, + }, + &argh.ArgDelimiter{}, + &argh.CommandFlag{Name: "wat"}, + &argh.ArgDelimiter{}, + &argh.CommandFlag{ + Name: "hello", + Values: map[string]string{ + "name": "mario", + }, + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.Ident{Literal: "mario"}, + }, + }, + }, + }, + }, + expAST: []argh.Node{ + &argh.CommandFlag{ + Name: "pies", + Nodes: []argh.Node{ + &argh.CommandFlag{Name: "e"}, + &argh.CommandFlag{Name: "a"}, + &argh.CommandFlag{Name: "t"}, + &argh.CommandFlag{Name: "wat"}, + &argh.CommandFlag{ + Name: "hello", + Values: map[string]string{ + "name": "mario", + }, + Nodes: []argh.Node{ + &argh.Ident{Literal: "mario"}, + }, + }, + }, + }, + }, + }, + { + name: "persistent flags", + args: []string{ + "pies", "--wat", "hello", "mario", "-eat", + }, + cfg: &argh.ParserConfig{ + Prog: func() *argh.CommandConfig { + cmdCfg := &argh.CommandConfig{ + Flags: &argh.Flags{ + Map: map[string]argh.FlagConfig{ + "e": {Persist: true, On: traceOnCommandFlag}, + "a": {Persist: true, On: traceOnCommandFlag}, + "t": {Persist: true, On: traceOnCommandFlag}, + "wat": {On: traceOnCommandFlag}, + }, + }, + On: traceOnCommandFlag, + } + + cmdCfg.Commands = &argh.Commands{ + Map: map[string]argh.CommandConfig{ + "hello": argh.CommandConfig{ + NValue: 1, + ValueNames: []string{"name"}, + Flags: &argh.Flags{ + Parent: cmdCfg.Flags, + Map: map[string]argh.FlagConfig{}, + }, + On: traceOnCommandFlag, + }, + }, + } + + return cmdCfg + }(), + }, + expPT: []argh.Node{ + &argh.CommandFlag{ + Name: "pies", + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.CommandFlag{Name: "wat"}, + &argh.ArgDelimiter{}, + &argh.CommandFlag{ + Name: "hello", + Values: map[string]string{ + "name": "mario", + }, + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.Ident{Literal: "mario"}, + &argh.ArgDelimiter{}, + &argh.CompoundShortFlag{ + Nodes: []argh.Node{ + &argh.CommandFlag{Name: "e"}, + &argh.CommandFlag{Name: "a"}, + &argh.CommandFlag{Name: "t"}, + }, + }, + }, + }, + }, + }, + }, + expAST: []argh.Node{ + &argh.CommandFlag{ + Name: "pies", + Nodes: []argh.Node{ + &argh.CommandFlag{Name: "wat"}, + &argh.CommandFlag{ + Name: "hello", + Values: map[string]string{ + "name": "mario", + }, + Nodes: []argh.Node{ + &argh.Ident{Literal: "mario"}, + &argh.CommandFlag{Name: "e"}, + &argh.CommandFlag{Name: "a"}, + &argh.CommandFlag{Name: "t"}, + }, + }, + }, + }, + }, + }, + { + name: "bare", + args: []string{"pizzas"}, + expPT: []argh.Node{ + &argh.CommandFlag{ + Name: "pizzas", + }, + }, + expAST: []argh.Node{ + &argh.CommandFlag{ + Name: "pizzas", + }, + }, + }, + { + name: "one positional arg", + args: []string{"pizzas", "excel"}, + cfg: &argh.ParserConfig{ + Prog: &argh.CommandConfig{NValue: 1, On: traceOnCommandFlag}, + }, + expPT: []argh.Node{ + &argh.CommandFlag{ + Name: "pizzas", + Values: map[string]string{"0": "excel"}, + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.Ident{Literal: "excel"}, + }, + }, + }, + expAST: []argh.Node{ + &argh.CommandFlag{ + Name: "pizzas", + Values: map[string]string{"0": "excel"}, + Nodes: []argh.Node{ + &argh.Ident{Literal: "excel"}, + }, + }, + }, + }, + { + name: "many positional args", + args: []string{"pizzas", "excel", "wildly", "when", "feral"}, + cfg: &argh.ParserConfig{ + Prog: &argh.CommandConfig{ + NValue: argh.OneOrMoreValue, + ValueNames: []string{"word"}, + On: traceOnCommandFlag, + }, + }, + expPT: []argh.Node{ + &argh.CommandFlag{ + Name: "pizzas", + Values: map[string]string{ + "word": "excel", + "word.1": "wildly", + "word.2": "when", + "word.3": "feral", + }, + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.Ident{Literal: "excel"}, + &argh.ArgDelimiter{}, + &argh.Ident{Literal: "wildly"}, + &argh.ArgDelimiter{}, + &argh.Ident{Literal: "when"}, + &argh.ArgDelimiter{}, + &argh.Ident{Literal: "feral"}, + }, + }, + }, + expAST: []argh.Node{ + &argh.CommandFlag{ + Name: "pizzas", + Values: map[string]string{ + "word": "excel", + "word.1": "wildly", + "word.2": "when", + "word.3": "feral", + }, + Nodes: []argh.Node{ + &argh.Ident{Literal: "excel"}, + &argh.Ident{Literal: "wildly"}, + &argh.Ident{Literal: "when"}, + &argh.Ident{Literal: "feral"}, + }, + }, + }, + }, + { + name: "long value-less flags", + args: []string{"pizzas", "--tasty", "--fresh", "--super-hot-right-now"}, + cfg: &argh.ParserConfig{ + Prog: &argh.CommandConfig{ + Flags: &argh.Flags{ + Map: map[string]argh.FlagConfig{ + "tasty": {On: traceOnCommandFlag}, + "fresh": {On: traceOnCommandFlag}, + "super-hot-right-now": {On: traceOnCommandFlag}, + }, + }, + On: traceOnCommandFlag, + }, + }, + expPT: []argh.Node{ + &argh.CommandFlag{ + Name: "pizzas", + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.CommandFlag{Name: "tasty"}, + &argh.ArgDelimiter{}, + &argh.CommandFlag{Name: "fresh"}, + &argh.ArgDelimiter{}, + &argh.CommandFlag{Name: "super-hot-right-now"}, + }, + }, + }, + expAST: []argh.Node{ + &argh.CommandFlag{ + Name: "pizzas", + Nodes: []argh.Node{ + &argh.CommandFlag{Name: "tasty"}, + &argh.CommandFlag{Name: "fresh"}, + &argh.CommandFlag{Name: "super-hot-right-now"}, + }, + }, + }, + }, + { + name: "long flags mixed", + args: []string{ + "pizzas", + "--tasty", + "--fresh", "soon", + "--super-hot-right-now", + "--box", "square", "shaped", "hot", + "--please", + }, + cfg: &argh.ParserConfig{ + Prog: &argh.CommandConfig{ + Commands: &argh.Commands{Map: map[string]argh.CommandConfig{}}, + Flags: &argh.Flags{ + Map: map[string]argh.FlagConfig{ + "tasty": {On: traceOnCommandFlag}, + "fresh": argh.FlagConfig{NValue: 1, On: traceOnCommandFlag}, + "super-hot-right-now": {On: traceOnCommandFlag}, + "box": argh.FlagConfig{NValue: argh.OneOrMoreValue, On: traceOnCommandFlag}, + "please": {On: traceOnCommandFlag}, + }, + }, + On: traceOnCommandFlag, + }, + }, + expPT: []argh.Node{ + &argh.CommandFlag{ + Name: "pizzas", + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.CommandFlag{Name: "tasty"}, + &argh.ArgDelimiter{}, + &argh.CommandFlag{ + Name: "fresh", + Values: map[string]string{"0": "soon"}, + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.Ident{Literal: "soon"}, + }, + }, + &argh.ArgDelimiter{}, + &argh.CommandFlag{Name: "super-hot-right-now"}, + &argh.ArgDelimiter{}, + &argh.CommandFlag{ + Name: "box", + Values: map[string]string{"0": "square", "1": "shaped", "2": "hot"}, + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.Ident{Literal: "square"}, + &argh.ArgDelimiter{}, + &argh.Ident{Literal: "shaped"}, + &argh.ArgDelimiter{}, + &argh.Ident{Literal: "hot"}, + &argh.ArgDelimiter{}, + }, + }, + &argh.CommandFlag{Name: "please"}, + }, + }, + }, + expAST: []argh.Node{ + &argh.CommandFlag{ + Name: "pizzas", + Nodes: []argh.Node{ + &argh.CommandFlag{Name: "tasty"}, + &argh.CommandFlag{ + Name: "fresh", + Values: map[string]string{"0": "soon"}, + Nodes: []argh.Node{ + &argh.Ident{Literal: "soon"}, + }, + }, + &argh.CommandFlag{Name: "super-hot-right-now"}, + &argh.CommandFlag{ + Name: "box", + Values: map[string]string{"0": "square", "1": "shaped", "2": "hot"}, + Nodes: []argh.Node{ + &argh.Ident{Literal: "square"}, + &argh.Ident{Literal: "shaped"}, + &argh.Ident{Literal: "hot"}, + }, + }, + &argh.CommandFlag{Name: "please"}, + }, + }, + }, + }, + { + name: "short value-less flags", + args: []string{"pizzas", "-t", "-f", "-s"}, + cfg: &argh.ParserConfig{ + Prog: &argh.CommandConfig{ + Flags: &argh.Flags{ + Map: map[string]argh.FlagConfig{ + "t": {On: traceOnCommandFlag}, + "f": {On: traceOnCommandFlag}, + "s": {On: traceOnCommandFlag}, + }, + }, + On: traceOnCommandFlag, + }, + }, + expPT: []argh.Node{ + &argh.CommandFlag{ + Name: "pizzas", + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.CommandFlag{Name: "t"}, + &argh.ArgDelimiter{}, + &argh.CommandFlag{Name: "f"}, + &argh.ArgDelimiter{}, + &argh.CommandFlag{Name: "s"}, + }, + }, + }, + expAST: []argh.Node{ + &argh.CommandFlag{ + Name: "pizzas", + Nodes: []argh.Node{ + &argh.CommandFlag{Name: "t"}, + &argh.CommandFlag{Name: "f"}, + &argh.CommandFlag{Name: "s"}, + }, + }, + }, + }, + { + name: "compound short flags", + args: []string{"pizzas", "-aca", "-blol"}, + cfg: &argh.ParserConfig{ + Prog: &argh.CommandConfig{ + Flags: &argh.Flags{ + Map: map[string]argh.FlagConfig{ + "a": {On: traceOnCommandFlag}, + "b": {On: traceOnCommandFlag}, + "c": {On: traceOnCommandFlag}, + "l": {On: traceOnCommandFlag}, + "o": {On: traceOnCommandFlag}, + }, + }, + On: traceOnCommandFlag, + }, + }, + expPT: []argh.Node{ + &argh.CommandFlag{ + Name: "pizzas", + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.CompoundShortFlag{ + Nodes: []argh.Node{ + &argh.CommandFlag{Name: "a"}, + &argh.CommandFlag{Name: "c"}, + &argh.CommandFlag{Name: "a"}, + }, + }, + &argh.ArgDelimiter{}, + &argh.CompoundShortFlag{ + Nodes: []argh.Node{ + &argh.CommandFlag{Name: "b"}, + &argh.CommandFlag{Name: "l"}, + &argh.CommandFlag{Name: "o"}, + &argh.CommandFlag{Name: "l"}, + }, + }, + }, + }, + }, + expAST: []argh.Node{ + &argh.CommandFlag{ + Name: "pizzas", + Nodes: []argh.Node{ + &argh.CommandFlag{Name: "a"}, + &argh.CommandFlag{Name: "c"}, + &argh.CommandFlag{Name: "a"}, + &argh.CommandFlag{Name: "b"}, + &argh.CommandFlag{Name: "l"}, + &argh.CommandFlag{Name: "o"}, + &argh.CommandFlag{Name: "l"}, + }, + }, + }, + }, + { + name: "mixed long short value flags", + args: []string{"pizzas", "-a", "--ca", "-b", "1312", "-lol"}, + cfg: &argh.ParserConfig{ + Prog: &argh.CommandConfig{ + Commands: &argh.Commands{Map: map[string]argh.CommandConfig{}}, + Flags: &argh.Flags{ + Map: map[string]argh.FlagConfig{ + "a": {On: traceOnCommandFlag}, + "b": argh.FlagConfig{NValue: 1, On: traceOnCommandFlag}, + "ca": {On: traceOnCommandFlag}, + "l": {On: traceOnCommandFlag}, + "o": {On: traceOnCommandFlag}, + }, + }, + On: traceOnCommandFlag, + }, + }, + expPT: []argh.Node{ + &argh.CommandFlag{ + Name: "pizzas", + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.CommandFlag{Name: "a"}, + &argh.ArgDelimiter{}, + &argh.CommandFlag{Name: "ca"}, + &argh.ArgDelimiter{}, + &argh.CommandFlag{ + Name: "b", + Values: map[string]string{"0": "1312"}, + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.Ident{Literal: "1312"}, + }, + }, + &argh.ArgDelimiter{}, + &argh.CompoundShortFlag{ + Nodes: []argh.Node{ + &argh.CommandFlag{Name: "l"}, + &argh.CommandFlag{Name: "o"}, + &argh.CommandFlag{Name: "l"}, + }, + }, + }, + }, + }, + expAST: []argh.Node{ + &argh.CommandFlag{ + Name: "pizzas", + Nodes: []argh.Node{ + &argh.CommandFlag{Name: "a"}, + &argh.CommandFlag{Name: "ca"}, + &argh.CommandFlag{ + Name: "b", + Values: map[string]string{"0": "1312"}, + Nodes: []argh.Node{ + &argh.Ident{Literal: "1312"}, + }, + }, + &argh.CommandFlag{Name: "l"}, + &argh.CommandFlag{Name: "o"}, + &argh.CommandFlag{Name: "l"}, + }, + }, + }, + }, + { + name: "nested commands with positional args", + args: []string{"pizzas", "fly", "freely", "sometimes", "and", "other", "times", "fry", "deeply", "--forever"}, + cfg: &argh.ParserConfig{ + Prog: &argh.CommandConfig{ + Commands: &argh.Commands{ + Map: map[string]argh.CommandConfig{ + "fly": argh.CommandConfig{ + Commands: &argh.Commands{ + Map: map[string]argh.CommandConfig{ + "fry": argh.CommandConfig{ + Flags: &argh.Flags{ + Map: map[string]argh.FlagConfig{ + "forever": {On: traceOnCommandFlag}, + }, + }, + }, + }, + }, + }, + }, + }, + Flags: &argh.Flags{Map: map[string]argh.FlagConfig{}}, + On: traceOnCommandFlag, + }, + }, + expPT: []argh.Node{ + &argh.CommandFlag{ + Name: "pizzas", + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.CommandFlag{ + Name: "fly", + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.Ident{Literal: "freely"}, + &argh.ArgDelimiter{}, + &argh.Ident{Literal: "sometimes"}, + &argh.ArgDelimiter{}, + &argh.Ident{Literal: "and"}, + &argh.ArgDelimiter{}, + &argh.Ident{Literal: "other"}, + &argh.ArgDelimiter{}, + &argh.Ident{Literal: "times"}, + &argh.ArgDelimiter{}, + &argh.CommandFlag{ + Name: "fry", + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.Ident{Literal: "deeply"}, + &argh.ArgDelimiter{}, + &argh.CommandFlag{Name: "forever"}, + }, + }, + }, + }, + }, + }, + }, + expAST: []argh.Node{ + &argh.CommandFlag{ + Name: "pizzas", + Nodes: []argh.Node{ + &argh.CommandFlag{ + Name: "fly", + Nodes: []argh.Node{ + &argh.Ident{Literal: "freely"}, + &argh.Ident{Literal: "sometimes"}, + &argh.Ident{Literal: "and"}, + &argh.Ident{Literal: "other"}, + &argh.Ident{Literal: "times"}, + &argh.CommandFlag{ + Name: "fry", + Nodes: []argh.Node{ + &argh.Ident{Literal: "deeply"}, + &argh.CommandFlag{Name: "forever"}, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "compound flags with values", + args: []string{"pizzas", "-need", "sauce", "heat", "love", "-also", "over9000"}, + cfg: &argh.ParserConfig{ + Prog: &argh.CommandConfig{ + Flags: &argh.Flags{ + Map: map[string]argh.FlagConfig{ + "a": {NValue: argh.ZeroOrMoreValue, On: traceOnCommandFlag}, + "d": {NValue: argh.OneOrMoreValue, On: traceOnCommandFlag}, + "e": {On: traceOnCommandFlag}, + "l": {On: traceOnCommandFlag}, + "n": {On: traceOnCommandFlag}, + "o": {NValue: 1, ValueNames: []string{"level"}, On: traceOnCommandFlag}, + "s": {NValue: argh.ZeroOrMoreValue, On: traceOnCommandFlag}, + }, + }, + On: traceOnCommandFlag, + }, + }, + expPT: []argh.Node{ + &argh.CommandFlag{ + Name: "pizzas", + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.CompoundShortFlag{ + Nodes: []argh.Node{ + &argh.CommandFlag{Name: "n"}, + &argh.CommandFlag{Name: "e"}, + &argh.CommandFlag{Name: "e"}, + &argh.CommandFlag{ + Name: "d", + Values: map[string]string{ + "0": "sauce", + "1": "heat", + "2": "love", + }, + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.Ident{Literal: "sauce"}, + &argh.ArgDelimiter{}, + &argh.Ident{Literal: "heat"}, + &argh.ArgDelimiter{}, + &argh.Ident{Literal: "love"}, + &argh.ArgDelimiter{}, + }, + }, + }, + }, + &argh.CompoundShortFlag{ + Nodes: []argh.Node{ + &argh.CommandFlag{Name: "a"}, + &argh.CommandFlag{Name: "l"}, + &argh.CommandFlag{Name: "s"}, + &argh.CommandFlag{ + Name: "o", + Values: map[string]string{ + "level": "over9000", + }, + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.Ident{Literal: "over9000"}, + }, + }, + }, + }, + }, + }, + }, + expAST: []argh.Node{ + &argh.CommandFlag{ + Name: "pizzas", + Nodes: []argh.Node{ + &argh.CommandFlag{Name: "n"}, + &argh.CommandFlag{Name: "e"}, + &argh.CommandFlag{Name: "e"}, + &argh.CommandFlag{ + Name: "d", + Values: map[string]string{ + "0": "sauce", + "1": "heat", + "2": "love", + }, + Nodes: []argh.Node{ + &argh.Ident{Literal: "sauce"}, + &argh.Ident{Literal: "heat"}, + &argh.Ident{Literal: "love"}, + }, + }, + &argh.CommandFlag{Name: "a"}, + &argh.CommandFlag{Name: "l"}, + &argh.CommandFlag{Name: "s"}, + &argh.CommandFlag{ + Name: "o", + Values: map[string]string{ + "level": "over9000", + }, + Nodes: []argh.Node{ + &argh.Ident{Literal: "over9000"}, + }, + }, + }, + }, + }, + }, + { + name: "command specific flags", + args: []string{"pizzas", "fly", "--freely", "fry", "--deeply", "-wAt", "hugs"}, + cfg: &argh.ParserConfig{ + Prog: &argh.CommandConfig{ + Commands: &argh.Commands{ + Map: map[string]argh.CommandConfig{ + "fly": argh.CommandConfig{ + Flags: &argh.Flags{ + Map: map[string]argh.FlagConfig{ + "freely": {On: traceOnCommandFlag}, + }, + }, + Commands: &argh.Commands{ + Map: map[string]argh.CommandConfig{ + "fry": argh.CommandConfig{ + Flags: &argh.Flags{ + Map: map[string]argh.FlagConfig{ + "deeply": {On: traceOnCommandFlag}, + "w": {On: traceOnCommandFlag}, + "A": {On: traceOnCommandFlag}, + "t": argh.FlagConfig{NValue: 1, On: traceOnCommandFlag}, + }, + }, + }, + }, + }, + }, + }, + }, + Flags: &argh.Flags{Map: map[string]argh.FlagConfig{}}, + On: traceOnCommandFlag, + }, + }, + expPT: []argh.Node{ + &argh.CommandFlag{ + Name: "pizzas", + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.CommandFlag{ + Name: "fly", + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.CommandFlag{Name: "freely"}, + &argh.ArgDelimiter{}, + &argh.CommandFlag{ + Name: "fry", + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.CommandFlag{Name: "deeply"}, + &argh.ArgDelimiter{}, + &argh.CompoundShortFlag{ + Nodes: []argh.Node{ + &argh.CommandFlag{Name: "w"}, + &argh.CommandFlag{Name: "A"}, + &argh.CommandFlag{ + Name: "t", + Values: map[string]string{"0": "hugs"}, + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.Ident{Literal: "hugs"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expAST: []argh.Node{ + &argh.CommandFlag{ + Name: "pizzas", + Nodes: []argh.Node{ + &argh.CommandFlag{ + Name: "fly", + Nodes: []argh.Node{ + &argh.CommandFlag{Name: "freely"}, + &argh.CommandFlag{ + Name: "fry", + Nodes: []argh.Node{ + &argh.CommandFlag{Name: "deeply"}, + &argh.CommandFlag{Name: "w"}, + &argh.CommandFlag{Name: "A"}, + &argh.CommandFlag{ + Name: "t", + Values: map[string]string{"0": "hugs"}, + Nodes: []argh.Node{ + &argh.Ident{Literal: "hugs"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "total weirdo", + args: []string{"PIZZAs", "^wAT@golf", "^^hecKing", "goose", "bonk", "^^FIERCENESS@-2"}, + cfg: &argh.ParserConfig{ + Prog: &argh.CommandConfig{ + Commands: &argh.Commands{ + Map: map[string]argh.CommandConfig{ + "goose": argh.CommandConfig{ + NValue: 1, + Flags: &argh.Flags{ + Map: map[string]argh.FlagConfig{ + "FIERCENESS": argh.FlagConfig{NValue: 1, On: traceOnCommandFlag}, + }, + }, + }, + }, + }, + Flags: &argh.Flags{ + Map: map[string]argh.FlagConfig{ + "w": {On: traceOnCommandFlag}, + "A": {On: traceOnCommandFlag}, + "T": {NValue: 1, On: traceOnCommandFlag}, + "hecKing": {On: traceOnCommandFlag}, + }, + }, + On: traceOnCommandFlag, + }, + ScannerConfig: &argh.ScannerConfig{ + AssignmentOperator: '@', + FlagPrefix: '^', + MultiValueDelim: ',', + }, + }, + expPT: []argh.Node{ + &argh.CommandFlag{ + Name: "PIZZAs", + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.CompoundShortFlag{ + Nodes: []argh.Node{ + &argh.CommandFlag{Name: "w"}, + &argh.CommandFlag{Name: "A"}, + &argh.CommandFlag{ + Name: "T", + Values: map[string]string{"0": "golf"}, + Nodes: []argh.Node{ + &argh.Assign{}, + &argh.Ident{Literal: "golf"}, + }, + }, + }, + }, + &argh.ArgDelimiter{}, + &argh.CommandFlag{Name: "hecKing"}, + &argh.ArgDelimiter{}, + &argh.CommandFlag{ + Name: "goose", + Values: map[string]string{"0": "bonk"}, + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.Ident{Literal: "bonk"}, + &argh.ArgDelimiter{}, + &argh.CommandFlag{ + Name: "FIERCENESS", + Values: map[string]string{"0": "-2"}, + Nodes: []argh.Node{ + &argh.Assign{}, + &argh.Ident{Literal: "-2"}, + }, + }, + }, + }, + }, + }, + }, + }, + { + name: "windows like", + args: []string{"hotdog", "/f", "/L", "/o:ppy", "hats"}, + cfg: &argh.ParserConfig{ + Prog: &argh.CommandConfig{ + Flags: &argh.Flags{ + Map: map[string]argh.FlagConfig{ + "f": {On: traceOnCommandFlag}, + "L": {On: traceOnCommandFlag}, + "o": argh.FlagConfig{NValue: 1, On: traceOnCommandFlag}, + }, + }, + Commands: &argh.Commands{ + Map: map[string]argh.CommandConfig{ + "hats": {}, + }, + }, + On: traceOnCommandFlag, + }, + ScannerConfig: &argh.ScannerConfig{ + AssignmentOperator: ':', + FlagPrefix: '/', + MultiValueDelim: ',', + }, + }, + expPT: []argh.Node{ + &argh.CommandFlag{ + Name: "hotdog", + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.CommandFlag{Name: "f"}, + &argh.ArgDelimiter{}, + &argh.CommandFlag{Name: "L"}, + &argh.ArgDelimiter{}, + &argh.CommandFlag{ + Name: "o", + Values: map[string]string{"0": "ppy"}, + Nodes: []argh.Node{ + &argh.Assign{}, + &argh.Ident{Literal: "ppy"}, + }, + }, + &argh.ArgDelimiter{}, + &argh.CommandFlag{Name: "hats"}, + }, + }, + }, + }, + { + name: "invalid bare assignment", + args: []string{"pizzas", "=", "--wat"}, + cfg: &argh.ParserConfig{ + Prog: &argh.CommandConfig{ + Flags: &argh.Flags{ + Map: map[string]argh.FlagConfig{ + "wat": {On: traceOnCommandFlag}, + }, + }, + On: traceOnCommandFlag, + }, + }, + expErr: argh.ParserErrorList{ + &argh.ParserError{Pos: argh.Position{Column: 8}, Msg: "invalid bare assignment"}, + }, + expPT: []argh.Node{ + &argh.CommandFlag{ + Name: "pizzas", + Nodes: []argh.Node{ + &argh.ArgDelimiter{}, + &argh.ArgDelimiter{}, + &argh.CommandFlag{Name: "wat"}, + }, + }, + }, + }, + } { + if tc.expPT != nil { + t.Run(tc.name+" parse tree", func(ct *testing.T) { + if tc.skip { + ct.SkipNow() + return + } + + pCfg := tc.cfg + if pCfg == nil { + pCfg = argh.NewParserConfig() + } + + pt, err := argh.ParseArgs(tc.args, pCfg) + if err != nil || tc.expErr != nil { + if !assert.ErrorIs(ct, err, tc.expErr) { + spew.Dump(err, tc.expErr) + spew.Dump(pt) + } + return + } + + if !assert.Equal(ct, tc.expPT, pt.Nodes) { + spew.Dump(pt) + } + }) + } + + if tc.expAST != nil { + t.Run(tc.name+" ast", func(ct *testing.T) { + if tc.skip { + ct.SkipNow() + return + } + + pCfg := tc.cfg + if pCfg == nil { + pCfg = argh.NewParserConfig() + } + + pt, err := argh.ParseArgs(tc.args, pCfg) + if err != nil || tc.expErr != nil { + if !assert.ErrorIs(ct, err, tc.expErr) { + spew.Dump(pt) + } + return + } + + ast := argh.ToAST(pt.Nodes) + + if !assert.Equal(ct, tc.expAST, ast) { + spew.Dump(ast) + } + }) + } + } +} diff --git a/internal/argh/scanner.go b/internal/argh/scanner.go new file mode 100644 index 0000000..3cc8d45 --- /dev/null +++ b/internal/argh/scanner.go @@ -0,0 +1,159 @@ +package argh + +import ( + "bufio" + "bytes" + "errors" + "io" + "log" + "unicode" +) + +type Scanner struct { + r *bufio.Reader + i int + cfg *ScannerConfig +} + +func NewScanner(r io.Reader, cfg *ScannerConfig) *Scanner { + if cfg == nil { + cfg = POSIXyScannerConfig + } + + return &Scanner{ + r: bufio.NewReader(r), + cfg: cfg, + } +} + +func (s *Scanner) Scan() (Token, string, Pos) { + ch, pos := s.read() + + if s.cfg.IsBlankspace(ch) { + _ = s.unread() + return s.scanBlankspace() + } + + if s.cfg.IsAssignmentOperator(ch) { + return ASSIGN, string(ch), pos + } + + if s.cfg.IsMultiValueDelim(ch) { + return MULTI_VALUE_DELIMITER, string(ch), pos + } + + if ch == eol { + return EOL, "", pos + } + + if ch == nul { + return ARG_DELIMITER, string(ch), pos + } + + if unicode.IsGraphic(ch) { + _ = s.unread() + return s.scanArg() + } + + return ILLEGAL, string(ch), pos +} + +func (s *Scanner) read() (rune, Pos) { + ch, _, err := s.r.ReadRune() + s.i++ + + if errors.Is(err, io.EOF) { + return eol, Pos(s.i) + } else if err != nil { + log.Printf("unknown scanner error=%+v", err) + return eol, Pos(s.i) + } + + return ch, Pos(s.i) +} + +func (s *Scanner) unread() Pos { + _ = s.r.UnreadRune() + s.i-- + return Pos(s.i) +} + +func (s *Scanner) scanBlankspace() (Token, string, Pos) { + buf := &bytes.Buffer{} + ch, pos := s.read() + buf.WriteRune(ch) + + for { + ch, pos = s.read() + + if ch == eol { + break + } else if !s.cfg.IsBlankspace(ch) { + pos = s.unread() + break + } else { + _, _ = buf.WriteRune(ch) + } + } + + return BS, buf.String(), pos +} + +func (s *Scanner) scanArg() (Token, string, Pos) { + buf := &bytes.Buffer{} + ch, pos := s.read() + buf.WriteRune(ch) + + for { + ch, pos = s.read() + + if ch == eol || ch == nul || s.cfg.IsAssignmentOperator(ch) || s.cfg.IsMultiValueDelim(ch) { + pos = s.unread() + break + } + + _, _ = buf.WriteRune(ch) + } + + str := buf.String() + + if len(str) == 0 { + return EMPTY, str, pos + } + + ch0 := rune(str[0]) + + if len(str) == 1 { + if s.cfg.IsFlagPrefix(ch0) { + return STDIN_FLAG, str, pos + } + + if s.cfg.IsAssignmentOperator(ch0) { + return ASSIGN, str, pos + } + + return IDENT, str, pos + } + + ch1 := rune(str[1]) + + if len(str) == 2 { + if s.cfg.IsFlagPrefix(ch0) && s.cfg.IsFlagPrefix(ch1) { + return STOP_FLAG, str, pos + } + + if s.cfg.IsFlagPrefix(ch0) { + return SHORT_FLAG, str, pos + } + } + + if s.cfg.IsFlagPrefix(ch0) { + if s.cfg.IsFlagPrefix(ch1) { + return LONG_FLAG, str, pos + } + + return COMPOUND_SHORT_FLAG, str, pos + } + + return IDENT, str, pos +} diff --git a/internal/argh/scanner_config.go b/internal/argh/scanner_config.go new file mode 100644 index 0000000..9bc9be2 --- /dev/null +++ b/internal/argh/scanner_config.go @@ -0,0 +1,39 @@ +package argh + +var ( + // POSIXyScannerConfig defines a scanner config that uses '-' + // as the flag prefix, which also means that "--" is the "long + // flag" prefix, a bare "--" is considered STOP_FLAG, and a + // bare "-" is considered STDIN_FLAG. + POSIXyScannerConfig = &ScannerConfig{ + AssignmentOperator: '=', + FlagPrefix: '-', + MultiValueDelim: ',', + } +) + +type ScannerConfig struct { + AssignmentOperator rune + FlagPrefix rune + MultiValueDelim rune +} + +func (cfg *ScannerConfig) IsFlagPrefix(ch rune) bool { + return ch == cfg.FlagPrefix +} + +func (cfg *ScannerConfig) IsMultiValueDelim(ch rune) bool { + return ch == cfg.MultiValueDelim +} + +func (cfg *ScannerConfig) IsAssignmentOperator(ch rune) bool { + return ch == cfg.AssignmentOperator +} + +func (cfg *ScannerConfig) IsBlankspace(ch rune) bool { + return ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' +} + +func (cfg *ScannerConfig) IsUnderscore(ch rune) bool { + return ch == '_' +} diff --git a/internal/argh/scanner_test.go b/internal/argh/scanner_test.go new file mode 100644 index 0000000..72dccb9 --- /dev/null +++ b/internal/argh/scanner_test.go @@ -0,0 +1,83 @@ +package argh + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func BenchmarkScannerPOSIXyScannerScan(b *testing.B) { + for i := 0; i < b.N; i++ { + scanner := NewScanner(strings.NewReader(strings.Join([]string{ + "walrus", + "-what", + "--ball=awesome", + "--elapsed", + "carrot cake", + }, string(nul))), nil) + for { + tok, _, _ := scanner.Scan() + if tok == EOL { + break + } + } + } +} + +func TestScannerPOSIXyScanner(t *testing.T) { + for _, tc := range []struct { + name string + argv []string + expectedTokens []Token + expectedLiterals []string + expectedPositions []Pos + }{ + { + name: "simple", + argv: []string{"walrus", "-cake", "--corn-dog", "awkward"}, + expectedTokens: []Token{ + IDENT, + ARG_DELIMITER, + COMPOUND_SHORT_FLAG, + ARG_DELIMITER, + LONG_FLAG, + ARG_DELIMITER, + IDENT, + EOL, + }, + expectedLiterals: []string{ + "walrus", string(nul), "-cake", string(nul), "--corn-dog", string(nul), "awkward", "", + }, + expectedPositions: []Pos{ + 6, 7, 12, 13, 23, 24, 31, 32, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + r := require.New(t) + + scanner := NewScanner(strings.NewReader(strings.Join(tc.argv, string(nul))), nil) + + actualTokens := []Token{} + actualLiterals := []string{} + actualPositions := []Pos{} + + for { + tok, lit, pos := scanner.Scan() + + actualTokens = append(actualTokens, tok) + actualLiterals = append(actualLiterals, lit) + actualPositions = append(actualPositions, pos) + + if tok == EOL { + break + } + } + + r.Equal(tc.expectedTokens, actualTokens) + r.Equal(tc.expectedLiterals, actualLiterals) + r.Equal(tc.expectedPositions, actualPositions) + }) + } +} diff --git a/internal/argh/token.go b/internal/argh/token.go new file mode 100644 index 0000000..b26b8b7 --- /dev/null +++ b/internal/argh/token.go @@ -0,0 +1,53 @@ +//go:generate stringer -type Token + +package argh + +import "fmt" + +const ( + ILLEGAL Token = iota + EOL + EMPTY // '' + BS // ' ' '\t' '\n' + IDENT // char group without flag prefix: 'some' 'words' + ARG_DELIMITER // rune(0) + ASSIGN // '=' + MULTI_VALUE_DELIMITER // ',' + LONG_FLAG // char group with double flag prefix: '--flag' + SHORT_FLAG // single char with single flag prefix: '-f' + COMPOUND_SHORT_FLAG // char group with single flag prefix: '-flag' + STDIN_FLAG // '-' + STOP_FLAG // '--' + + nul = rune(0) + eol = rune(-1) +) + +type Token int + +// Position is adapted from go/token.Position +type Position struct { + Column int +} + +func (p *Position) IsValid() bool { return p.Column > 0 } + +func (p Position) String() string { + s := "" + if p.IsValid() { + s = fmt.Sprintf("%d", p.Column) + } + if s == "" { + s = "-" + } + return s +} + +// Pos is borrowed from go/token.Pos +type Pos int + +const NoPos Pos = 0 + +func (p Pos) IsValid() bool { + return p != NoPos +} diff --git a/internal/argh/token_string.go b/internal/argh/token_string.go new file mode 100644 index 0000000..ff6a07e --- /dev/null +++ b/internal/argh/token_string.go @@ -0,0 +1,35 @@ +// Code generated by "stringer -type Token"; DO NOT EDIT. + +package argh + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[ILLEGAL-0] + _ = x[EOL-1] + _ = x[EMPTY-2] + _ = x[BS-3] + _ = x[IDENT-4] + _ = x[ARG_DELIMITER-5] + _ = x[ASSIGN-6] + _ = x[MULTI_VALUE_DELIMITER-7] + _ = x[LONG_FLAG-8] + _ = x[SHORT_FLAG-9] + _ = x[COMPOUND_SHORT_FLAG-10] + _ = x[STDIN_FLAG-11] + _ = x[STOP_FLAG-12] +} + +const _Token_name = "ILLEGALEOLEMPTYBSIDENTARG_DELIMITERASSIGNMULTI_VALUE_DELIMITERLONG_FLAGSHORT_FLAGCOMPOUND_SHORT_FLAGSTDIN_FLAGSTOP_FLAG" + +var _Token_index = [...]uint8{0, 7, 10, 15, 17, 22, 35, 41, 62, 71, 81, 100, 110, 119} + +func (i Token) String() string { + if i < 0 || i >= Token(len(_Token_index)-1) { + return "Token(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _Token_name[_Token_index[i]:_Token_index[i+1]] +} diff --git a/internal/build/build.go b/internal/build/build.go index 344a5d7..3ede95d 100644 --- a/internal/build/build.go +++ b/internal/build/build.go @@ -155,7 +155,7 @@ func main() { }, &cli.StringSliceFlag{ Name: "packages", - Value: cli.NewStringSlice("cli", "internal/build"), + Value: cli.NewStringSlice("cli", "internal/build", "internal/argh"), }, }, }