github.com/bobziuchkovski/writ@v0.8.9/command.go (about) 1 // Copyright (c) 2016 Bob Ziuchkovski 2 // 3 // Permission is hereby granted, free of charge, to any person obtaining a copy 4 // of this software and associated documentation files (the "Software"), to deal 5 // in the Software without restriction, including without limitation the rights 6 // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 // copies of the Software, and to permit persons to whom the Software is 8 // furnished to do so, subject to the following conditions: 9 // 10 // The above copyright notice and this permission notice shall be included in 11 // all copies or substantial portions of the Software. 12 // 13 // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE 16 // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 // THE SOFTWARE. 20 21 package writ 22 23 import ( 24 "bytes" 25 "fmt" 26 "io" 27 "os" 28 "reflect" 29 "strings" 30 "text/template" 31 "unicode" 32 ) 33 34 type commandError struct { 35 err error 36 } 37 38 func (e commandError) Error() string { 39 return e.err.Error() 40 } 41 42 // panicCommand reports invalid use of the Command type 43 func panicCommand(format string, values ...interface{}) { 44 e := commandError{fmt.Errorf(format, values...)} 45 panic(e) 46 } 47 48 // Path represents a parsed Command list as returned by Command.Decode(). 49 // It is used to differentiate between user selection of commands and 50 // subcommands. 51 type Path []*Command 52 53 // String returns the names of each command joined by spaces. 54 func (p Path) String() string { 55 var parts []string 56 for _, cmd := range p { 57 parts = append(parts, cmd.Name) 58 } 59 return strings.Join(parts, " ") 60 } 61 62 // First returns the first command of the path. This is the top-level/root command 63 // where Decode() was invoked. 64 func (p Path) First() *Command { 65 return p[0] 66 } 67 68 // Last returns the last command of the path. This is the user-selected command. 69 func (p Path) Last() *Command { 70 return p[len(p)-1] 71 } 72 73 // findOption searches for the named option on the nearest ancestor command 74 func (p Path) findOption(name string) *Option { 75 for i := len(p) - 1; i >= 0; i-- { 76 o := p[i].Option(name) 77 if o != nil { 78 return o 79 } 80 } 81 return nil 82 } 83 84 // New reads the input spec, searching for fields tagged with "option", 85 // "flag", or "command". The field type and tags are used to construct 86 // a corresponding Command instance, which can be used to decode program 87 // arguments. See the package overview documentation for details. 88 // 89 // NOTE: The spec value must be a pointer to a struct. 90 func New(name string, spec interface{}) *Command { 91 cmd := parseCommandSpec(name, spec, nil) 92 cmd.validate() 93 return cmd 94 } 95 96 // Command specifies program options and subcommands. 97 // 98 // NOTE: If building a *Command directly without New(), the Help output 99 // will be empty by default. Most applications will want to set the 100 // Help.Usage and Help.CommandGroups / Help.OptionGroups fields as 101 // appropriate. 102 type Command struct { 103 // Required 104 Name string 105 106 // Optional 107 Aliases []string 108 Options []*Option 109 Subcommands []*Command 110 Help Help 111 Description string // Commands without descriptions are hidden 112 } 113 114 // String returns the command's name. 115 func (c *Command) String() string { 116 return c.Name 117 } 118 119 // Decode parses the given arguments according to GNU getopt_long conventions. 120 // It matches Option arguments, both short and long-form, and decodes those 121 // arguments with the matched Option's Decoder field. If the Command has 122 // associated subcommands, the subcommand names are matched and extracted 123 // from the start of the positional arguments. 124 // 125 // To avoid ambiguity, subcommand matching terminates at the first unmatched 126 // positional argument. Similarly, option names are matched against the 127 // command hierarchy as it exists at the point the option is encountered. If 128 // command "first" has a subcommand "second", and "second" has an option 129 // "foo", then "first second --foo" is valid but "first --foo second" returns 130 // an error. If the two commands, "first" and "second", both specify a "bar" 131 // option, then "first --bar second" decodes "bar" on "first", whereas 132 // "first second --bar" decodes "bar" on "second". 133 // 134 // As with GNU getopt_long, a bare "--" argument terminates argument parsing. 135 // All arguments after the first "--" argument are considered positional 136 // parameters. 137 func (c *Command) Decode(args []string) (path Path, positional []string, err error) { 138 c.validate() 139 c.setDefaults() 140 return parseArgs(c, args) 141 } 142 143 // Subcommand locates subcommands on the method receiver. It returns a match 144 // if any of the receiver's subcommands have a matching name or alias. Otherwise 145 // it returns nil. 146 func (c *Command) Subcommand(name string) *Command { 147 for _, sub := range c.Subcommands { 148 if sub.Name == name { 149 return sub 150 } 151 for _, a := range sub.Aliases { 152 if a == name { 153 return sub 154 } 155 } 156 } 157 return nil 158 } 159 160 // Option locates options on the method receiver. It returns a match if any of 161 // the receiver's options have a matching name. Otherwise it returns nil. Options 162 // are searched only on the method receiver, not any of it's subcommands. 163 func (c *Command) Option(name string) *Option { 164 for _, o := range c.Options { 165 for _, n := range o.Names { 166 if name == n { 167 return o 168 } 169 } 170 } 171 return nil 172 } 173 174 // GroupOptions is used to build OptionGroups for help output. It searches the 175 // method receiver for the named options and returns a corresponding OptionGroup. 176 // If any of the named options are not found, GroupOptions panics. 177 func (c *Command) GroupOptions(names ...string) OptionGroup { 178 var group OptionGroup 179 for _, n := range names { 180 o := c.Option(n) 181 if o == nil { 182 panicCommand("Option not found: %s", n) 183 } 184 group.Options = append(group.Options, o) 185 } 186 return group 187 } 188 189 // GroupCommands is used to build CommandGroups for help output. It searches the 190 // method receiver for the named subcommands and returns a corresponding CommandGroup. 191 // If any of the named subcommands are not found, GroupCommands panics. 192 func (c *Command) GroupCommands(names ...string) CommandGroup { 193 var group CommandGroup 194 for _, n := range names { 195 c := c.Subcommand(n) 196 if c == nil { 197 panicCommand("Option not found: %s", n) 198 } 199 group.Commands = append(group.Commands, c) 200 } 201 return group 202 } 203 204 // WriteHelp renders help output to the given io.Writer. Output is influenced 205 // by the Command's Help field. See the Help type for details. 206 func (c *Command) WriteHelp(w io.Writer) error { 207 var tmpl *template.Template 208 if c.Help.Template != nil { 209 tmpl = c.Help.Template 210 } else { 211 tmpl = defaultTemplate 212 } 213 214 buf := bytes.NewBuffer(nil) 215 err := tmpl.Execute(buf, c) 216 if err != nil { 217 panicCommand("failed to render help: %s", err) 218 } 219 _, err = buf.WriteTo(w) 220 return err 221 } 222 223 // ExitHelp writes help output and terminates the program. If err is nil, 224 // the output is written to os.Stdout and the program terminates with a 0 exit 225 // code. Otherwise, both the help output and error message are written to 226 // os.Stderr and the program terminates with a 1 exit code. 227 func (c *Command) ExitHelp(err error) { 228 if err == nil { 229 c.WriteHelp(os.Stdout) 230 os.Exit(0) 231 } 232 c.WriteHelp(os.Stderr) 233 fmt.Fprintf(os.Stderr, "\nError: %s\n", err) 234 os.Exit(1) 235 } 236 237 // validate command spec 238 func (c *Command) validate() { 239 if c.Name == "" { 240 panicCommand("Command name cannot be empty") 241 } 242 if strings.HasPrefix(c.Name, "-") { 243 panicCommand("Command names cannot begin with '-' (command %s)", c.Name) 244 } 245 runes := []rune(c.Name) 246 for _, r := range runes { 247 if unicode.IsSpace(r) { 248 panicCommand("Command names cannot have spaces (command %q)", c.Name) 249 } 250 } 251 252 for _, a := range c.Aliases { 253 if strings.HasPrefix(a, "-") { 254 panicCommand("Command aliases cannot begin with '-' (command %s, alias %s)", c.Name, a) 255 } 256 runes := []rune(a) 257 for _, r := range runes { 258 if unicode.IsSpace(r) { 259 panicCommand("Command aliases cannot have spaces (command %s, alias %q)", c.Name, a) 260 } 261 } 262 } 263 264 seen := make(map[string]bool) 265 for _, sub := range c.Subcommands { 266 sub.validate() 267 subnames := append(sub.Aliases, sub.Name) 268 for _, name := range subnames { 269 _, present := seen[name] 270 if present { 271 panicCommand("command names must be unique (%s is specified multiple times)", name) 272 } 273 seen[name] = true 274 } 275 } 276 277 seen = make(map[string]bool) 278 for _, o := range c.Options { 279 o.validate() 280 for _, name := range o.Names { 281 _, present := seen[name] 282 if present { 283 panicCommand("option names must be unique (%s is specified multiple times)", name) 284 } 285 seen[name] = true 286 } 287 } 288 } 289 290 func (c *Command) setDefaults() { 291 for _, opt := range c.Options { 292 defaulter, ok := opt.Decoder.(OptionDefaulter) 293 if ok { 294 defaulter.SetDefault() 295 } 296 } 297 for _, sub := range c.Subcommands { 298 sub.setDefaults() 299 } 300 } 301 302 /* 303 * Argument parsing 304 */ 305 306 func parseArgs(c *Command, args []string) (path Path, positional []string, err error) { 307 path = Path{c} 308 positional = make([]string, 0) // positional args should never be nil 309 310 seen := make(map[*Option]bool) 311 parseCmd, parseOpt := true, true 312 for i := 0; i < len(args); i++ { 313 a := args[i] 314 if parseCmd { 315 subcmd := path.Last().Subcommand(a) 316 if subcmd != nil { 317 path = append(path, subcmd) 318 continue 319 } 320 } 321 322 if parseOpt && strings.HasPrefix(a, "-") { 323 if a == "-" { 324 positional = append(positional, a) 325 parseCmd = false 326 continue 327 } 328 if a == "--" { 329 parseOpt = false 330 parseCmd = false 331 continue 332 } 333 334 var opt *Option 335 opt, args, err = processOption(path, args, i) 336 if err != nil { 337 return 338 } 339 _, present := seen[opt] 340 if present && !opt.Plural { 341 err = fmt.Errorf("option %q specified too many times", args[i]) 342 return 343 } 344 seen[opt] = true 345 continue 346 } 347 348 // Unmatched positional arg 349 parseCmd = false 350 positional = append(positional, a) 351 } 352 return 353 } 354 355 func processOption(path Path, args []string, optidx int) (opt *Option, newargs []string, err error) { 356 if strings.HasPrefix(args[optidx], "--") { 357 return processLongOption(path, args, optidx) 358 } 359 return processShortOption(path, args, optidx) 360 } 361 362 func processLongOption(path Path, args []string, optidx int) (opt *Option, newargs []string, err error) { 363 keyval := strings.SplitN(strings.TrimPrefix(args[optidx], "--"), "=", 2) 364 name := keyval[0] 365 newargs = args 366 367 opt = path.findOption(name) 368 if opt == nil { 369 err = fmt.Errorf("option '--%s' is not recognized", name) 370 return 371 } 372 if opt.Flag { 373 if len(keyval) == 2 { 374 err = fmt.Errorf("flag '--%s' does not accept an argument", name) 375 } else { 376 err = opt.Decoder.Decode("") 377 } 378 } else { 379 if len(keyval) == 2 { 380 err = opt.Decoder.Decode(keyval[1]) 381 } else { 382 if len(args[optidx:]) < 2 { 383 err = fmt.Errorf("option '--%s' requires an argument", name) 384 } else { 385 // Consume the next arg 386 err = opt.Decoder.Decode(args[optidx+1]) 387 newargs = duplicateArgs(args) 388 newargs = append(newargs[:optidx+1], newargs[optidx+2:]...) 389 } 390 } 391 } 392 return 393 } 394 395 func processShortOption(path Path, args []string, optidx int) (opt *Option, newargs []string, err error) { 396 keyval := strings.SplitN(strings.TrimPrefix(args[optidx], "-"), "", 2) 397 name := keyval[0] 398 newargs = args 399 400 opt = path.findOption(name) 401 if opt == nil { 402 err = fmt.Errorf("option '-%s' is not recognized", name) 403 return 404 } 405 if opt.Flag { 406 err = opt.Decoder.Decode("") 407 if len(keyval) == 2 { 408 // Short-form options are aggregated. TODO: Cleanup 409 // Rewrite current arg as -<name> and append remaining aggregate opts as a new arg after the current one 410 newargs = duplicateArgs(args) 411 newargs = append(newargs[:optidx+1], append([]string{"-" + keyval[1]}, newargs[optidx+1:]...)...) 412 newargs[optidx] = "-" + name 413 } 414 } else { 415 if len(keyval) == 2 { 416 err = opt.Decoder.Decode(keyval[1]) 417 } else { 418 if len(args[optidx:]) < 2 { 419 err = fmt.Errorf("option '-%s' requires an argument", name) 420 } else { 421 // Consume the next arg 422 err = opt.Decoder.Decode(args[optidx+1]) 423 newargs = duplicateArgs(args) 424 newargs = append(newargs[:optidx+1], newargs[optidx+2:]...) 425 } 426 } 427 } 428 return 429 } 430 431 func duplicateArgs(args []string) []string { 432 dupe := make([]string, len(args)) 433 for i := range args { 434 dupe[i] = args[i] 435 } 436 return dupe 437 } 438 439 /* 440 * Command spec parsing 441 */ 442 443 var ( 444 decoderPtr *OptionDecoder 445 decoderT = reflect.TypeOf(decoderPtr).Elem() 446 447 aliasTag = "alias" 448 commandTag = "command" 449 defaultTag = "default" 450 descriptionTag = "description" 451 envTag = "env" 452 flagTag = "flag" 453 optionTag = "option" 454 placeholderTag = "placeholder" 455 invalidTags = map[string][]string{ 456 commandTag: {defaultTag, envTag, flagTag, optionTag, placeholderTag}, 457 flagTag: {aliasTag, commandTag, defaultTag, envTag, optionTag, placeholderTag}, 458 optionTag: {aliasTag, commandTag, flagTag}, 459 } 460 ) 461 462 func parseCommandSpec(name string, spec interface{}, path Path) *Command { 463 rval := reflect.ValueOf(spec) 464 if rval.Kind() != reflect.Ptr { 465 panicCommand("command spec must be a pointer to struct type, not %s", rval.Kind()) 466 } 467 if rval.Elem().Kind() != reflect.Struct { 468 panicCommand("command spec must be a pointer to struct type, not %s", rval.Kind()) 469 } 470 rval = rval.Elem() 471 472 cmd := &Command{Name: name} 473 path = append(path, cmd) 474 475 for i := 0; i < rval.Type().NumField(); i++ { 476 field := rval.Type().Field(i) 477 fieldVal := rval.FieldByIndex(field.Index) 478 if field.Tag.Get(commandTag) != "" { 479 cmd.Subcommands = append(cmd.Subcommands, parseCommandField(field, fieldVal, path)) 480 continue 481 } 482 if field.Tag.Get(flagTag) != "" { 483 cmd.Options = append(cmd.Options, parseFlagField(field, fieldVal)) 484 continue 485 } 486 if field.Tag.Get(optionTag) != "" { 487 cmd.Options = append(cmd.Options, parseOptionField(field, fieldVal)) 488 continue 489 } 490 } 491 492 var visibleOpts []*Option 493 for _, opt := range cmd.Options { 494 if opt.Description != "" { 495 visibleOpts = append(visibleOpts, opt) 496 } 497 } 498 if len(visibleOpts) > 0 { 499 cmd.Help.OptionGroups = []OptionGroup{ 500 {Options: visibleOpts, Header: "Available Options:"}, 501 } 502 } 503 var visibleSubs []*Command 504 for _, sub := range cmd.Subcommands { 505 if sub.Description != "" { 506 visibleSubs = append(visibleSubs, sub) 507 } 508 } 509 if len(visibleSubs) > 0 { 510 cmd.Help.CommandGroups = []CommandGroup{ 511 {Commands: visibleSubs, Header: "Available Commands:"}, 512 } 513 } 514 cmd.Help.Usage = fmt.Sprintf("Usage: %s [OPTION]... [ARG]...", path.String()) 515 return cmd 516 } 517 518 func parseCommandField(field reflect.StructField, fieldVal reflect.Value, path Path) *Command { 519 checkTags(field, commandTag) 520 checkExported(field, commandTag) 521 522 names := parseCommaNames(field.Tag.Get(commandTag)) 523 if len(names) == 0 { 524 panicCommand("commands must have a name (field %s)", field.Name) 525 } 526 if len(names) != 1 { 527 panicCommand("commands must have a single name (field %s)", field.Name) 528 } 529 530 cmd := parseCommandSpec(names[0], fieldVal.Addr().Interface(), path) 531 cmd.Aliases = parseCommaNames(field.Tag.Get(aliasTag)) 532 cmd.Description = field.Tag.Get(descriptionTag) 533 cmd.validate() 534 return cmd 535 } 536 537 func parseFlagField(field reflect.StructField, fieldVal reflect.Value) *Option { 538 checkTags(field, flagTag) 539 checkExported(field, flagTag) 540 541 names := parseCommaNames(field.Tag.Get(flagTag)) 542 if len(names) == 0 { 543 panicCommand("at least one flag name must be specified (field %s)", field.Name) 544 } 545 546 opt := &Option{ 547 Names: names, 548 Flag: true, 549 Description: field.Tag.Get(descriptionTag), 550 } 551 552 if field.Type.Implements(decoderT) { 553 opt.Decoder = fieldVal.Interface().(OptionDecoder) 554 } else if fieldVal.CanAddr() && reflect.PtrTo(field.Type).Implements(decoderT) { 555 opt.Decoder = fieldVal.Addr().Interface().(OptionDecoder) 556 } else { 557 switch field.Type.Kind() { 558 case reflect.Bool: 559 opt.Decoder = NewFlagDecoder(fieldVal.Addr().Interface().(*bool)) 560 case reflect.Int: 561 opt.Decoder = NewFlagAccumulator(fieldVal.Addr().Interface().(*int)) 562 opt.Plural = true 563 default: 564 panicCommand("field type not valid as a flag -- did you mean to use %q instead? (field %s)", "option", field.Name) 565 } 566 } 567 568 opt.validate() 569 return opt 570 } 571 572 func parseOptionField(field reflect.StructField, fieldVal reflect.Value) *Option { 573 checkTags(field, optionTag) 574 checkExported(field, optionTag) 575 576 names := parseCommaNames(field.Tag.Get(optionTag)) 577 if len(names) == 0 { 578 panicCommand("at least one option name must be specified (field %s)", field.Name) 579 } 580 581 opt := &Option{ 582 Names: names, 583 Description: field.Tag.Get(descriptionTag), 584 Placeholder: field.Tag.Get(placeholderTag), 585 } 586 587 if field.Type.Implements(decoderT) { 588 opt.Decoder = fieldVal.Interface().(OptionDecoder) 589 } else if fieldVal.CanAddr() && reflect.PtrTo(field.Type).Implements(decoderT) { 590 opt.Decoder = fieldVal.Addr().Interface().(OptionDecoder) 591 } else { 592 if fieldVal.Kind() == reflect.Bool { 593 panicCommand("bool fields are not valid as options. Use a %q tag instead (field %s)", "flag", field.Name) 594 } 595 if fieldVal.Kind() == reflect.Slice || fieldVal.Kind() == reflect.Map { 596 opt.Plural = true 597 } 598 opt.Decoder = NewOptionDecoder(fieldVal.Addr().Interface()) 599 } 600 601 defaultArg := field.Tag.Get(defaultTag) 602 if defaultArg != "" { 603 opt.Decoder = NewDefaulter(opt.Decoder, defaultArg) 604 } 605 envName := field.Tag.Get(envTag) 606 if envName != "" { 607 opt.Decoder = NewEnvDefaulter(opt.Decoder, envName) 608 } 609 610 opt.validate() 611 return opt 612 } 613 614 func checkTags(field reflect.StructField, fieldType string) { 615 badTags, present := invalidTags[fieldType] 616 if !present { 617 panic("BUG: fieldType not present in invalidTags map") 618 } 619 for _, t := range badTags { 620 if field.Tag.Get(t) != "" { 621 panicCommand("tag %s is not valid for %ss (field %s)", t, fieldType, field.Name) 622 } 623 } 624 } 625 626 func checkExported(field reflect.StructField, fieldType string) { 627 if field.PkgPath != "" && !field.Anonymous { 628 panicCommand("%ss must be exported (field %s)", fieldType, field.Name) 629 } 630 } 631 632 func parseCommaNames(spec string) []string { 633 isSep := func(r rune) bool { 634 return r == ',' || unicode.IsSpace(r) 635 } 636 return strings.FieldsFunc(spec, isSep) 637 }