Simple, intuitive CLI framework for Go pkg.go.dev/go.followtheprocess.codes/cli
go cli
0

Configure Feed

Select the types of activity you want to include in your feed.

Performance wins across the board (#211)

* Add some more benchmarks and reset args state between Parse

* Some quick perf wins

* Lazily initialise the env var map

* Eliminate a range loop in favour of utf8.DecodeRuneInString

* Remove an unneeded slice allocation

* Simplify some type assertion stuff

* Reuse the tabwriter to avoid excess allocations

authored by

Tom Fleet and committed by
GitHub
(Apr 23, 2026, 8:56 PM +0100) be33f52b 76ce7f52

+566 -304
+78 -59
command.go
··· 11 11 "strings" 12 12 "unicode/utf8" 13 13 14 + "go.followtheprocess.codes/hue/tabwriter" 15 + 14 16 publicflag "go.followtheprocess.codes/cli/flag" 15 17 16 18 "go.followtheprocess.codes/cli/internal/arg" ··· 23 25 versionBufferSize = 256 // versionBufferSize is sufficient to hold a full --version text. 24 26 defaultVersion = "dev" // defaultVersion is the version shown in --version when the user has not provided one. 25 27 defaultShort = "A placeholder for something cool" // defaultShort is the default value for cli.Short. 28 + ) 29 + 30 + // Pre-styled section headers used in the help text. 31 + // 32 + // hue.Style.Text allocates a new string every call so we can't make these 33 + // compile-time constants, but there's no reason to re-style fixed section 34 + // headers on every help render either. Doing it once at package init drops a 35 + // handful of allocs per showHelp. 36 + // 37 + //nolint:gochecknoglobals // Caching the styled titles. 38 + var ( 39 + usageTitle = style.Title.Text("Usage") 40 + optionsTitle = style.Title.Text("Options") 41 + commandsTitle = style.Title.Text("Commands") 42 + argumentsTitle = style.Title.Text("Arguments") 43 + examplesTitle = style.Title.Text("Examples") 26 44 ) 27 45 28 46 // Builder is a function that constructs and returns a [Command], it makes constructing ··· 362 380 // (if any) subcommand is being requested and return that command along with the arguments 363 381 // that were meant for it. 364 382 func findRequestedCommand(cmd *Command, args []string) (*Command, []string) { 365 - // Any arguments without flags could be names of subcommands 366 - argsWithoutFlags := stripFlags(cmd, args) 367 - if len(argsWithoutFlags) == 0 { 368 - // If there are no non-flag arguments, we must already be either at the root command 383 + // The next non-flag argument (if any) is the first immediate subcommand 384 + // e.g. in 'go mod tidy' we're looking for 'mod'. 385 + nextSubCommand, ok := firstNonFlagArg(cmd, args) 386 + if !ok { 387 + // No non-flag arguments, so we must already be either at the root command 369 388 // or the correct subcommand 370 389 return cmd, args 371 390 } 372 - 373 - // The next non-flag argument will be the first immediate subcommand 374 - // e.g. in 'go mod tidy', argsWithoutFlags[0] will be 'mod' 375 - nextSubCommand := argsWithoutFlags[0] 376 391 377 392 // Lookup this immediate subcommand by name and if we find it, recursively call 378 393 // this function so we eventually end up at the end of the command tree with ··· 388 403 389 404 // argsMinusFirstX removes only the first x from args. Otherwise, commands that look like 390 405 // openshift admin policy add-role-to-user admin my-user, lose the admin argument (arg[4]). 406 + // 407 + // The input slice is not mutated so that repeated Execute calls on the same 408 + // Command see the original rawArgs. 391 409 func argsMinusFirstX(args []string, x string) []string { 392 410 // Note: this is borrowed from Cobra but ours is a lot simpler because we don't support 393 411 // persistent flags 394 412 for i, arg := range args { 395 413 if arg == x { 396 - return slices.Delete(args, i, i+1) 414 + result := make([]string, 0, len(args)-1) 415 + result = append(result, args[:i]...) 416 + result = append(result, args[i+1:]...) 417 + 418 + return result 397 419 } 398 420 } 399 421 ··· 413 435 return nil 414 436 } 415 437 416 - // stripFlags takes a slice of raw command line arguments (including possible flags) and removes 417 - // any arguments that are flags or values passed in to flags e.g. --flag value. 418 - func stripFlags(cmd *Command, args []string) []string { 419 - if len(args) == 0 { 420 - return args 421 - } 422 - 423 - argsWithoutFlags := []string{} 424 - 425 - for len(args) > 0 { 426 - arg := args[0] 427 - args = args[1:] 428 - 438 + // firstNonFlagArg walks args and returns the first positional (non-flag) 439 + // argument along with a boolean indicating whether one was found. 440 + // 441 + // It consumes flag-value pairs (e.g. '--flag value' or '-f value') so they 442 + // aren't mistaken for positional arguments, and stops at '--'. 443 + func firstNonFlagArg(cmd *Command, args []string) (arg string, ok bool) { 444 + for i := 0; i < len(args); i++ { 445 + a := args[i] 429 446 switch { 430 - case arg == "--": 447 + case a == "--": 431 448 // "--" terminates the flags 432 - return argsWithoutFlags 433 - case strings.HasPrefix(arg, "--") && !strings.Contains(arg, "=") && !cmd.hasFlag(arg[2:]): 434 - // If '--flag arg' then delete arg from args 435 - fallthrough // (do the same as below) 436 - case strings.HasPrefix(arg, "-") && !strings.Contains(arg, "=") && len(arg) == 2 && !cmd.hasShortFlag(arg[1:]): 437 - // If '-f arg' then delete 'arg' from args or break the loop if len(args) <= 1. 438 - if len(args) <= 1 { 439 - return argsWithoutFlags 449 + return "", false 450 + case strings.HasPrefix(a, "--") && !strings.Contains(a, "=") && !cmd.hasFlag(a[2:]): 451 + // If '--flag value' then skip value 452 + fallthrough 453 + case strings.HasPrefix(a, "-") && !strings.Contains(a, "=") && len(a) == 2 && !cmd.hasShortFlag(a[1:]): 454 + // '-f value' skip the value too. If there isn't one, we're done. 455 + if i+1 >= len(args) { 456 + return "", false 440 457 } 441 458 442 - args = args[1:] 459 + i++ 443 460 444 461 continue 445 - 446 - case arg != "" && !strings.HasPrefix(arg, "-"): 447 - // We have a valid positional arg 448 - argsWithoutFlags = append(argsWithoutFlags, arg) 462 + case a != "" && !strings.HasPrefix(a, "-"): 463 + // First valid positional arg 464 + return a, true 449 465 } 450 466 } 451 467 452 - return argsWithoutFlags 468 + return "", false 453 469 } 454 470 455 471 // showHelp is the default for a command's helpFunc. ··· 467 483 s := &strings.Builder{} 468 484 s.Grow(helpBufferSize) 469 485 486 + // One tabwriter threaded through every aligned section, reset 487 + // between them with ResetTabwriter so only the first section pays the 488 + // internal-buffer allocation cost. 489 + tw := style.Tabwriter(s) 490 + 470 491 // If we have a short description, write that 471 492 if cmd.short != "" { 472 493 s.WriteString(cmd.short) ··· 479 500 s.WriteString("\n\n") 480 501 } 481 502 482 - s.WriteString(style.Title.Text("Usage")) 503 + s.WriteString(usageTitle) 483 504 s.WriteString(": ") 484 505 s.WriteString(style.Bold.Text(cmd.name)) 485 506 ··· 503 524 504 525 // If we have defined, list them explicitly and use their descriptions 505 526 if len(cmd.args) != 0 { 506 - if err := writeArgumentsSection(cmd, s); err != nil { 527 + if err := writeArgumentsSection(cmd, s, tw); err != nil { 507 528 return err 508 529 } 509 530 } ··· 515 536 516 537 // Now show subcommands 517 538 if len(cmd.subcommands) != 0 { 518 - if err := writeSubcommands(cmd, s); err != nil { 539 + if err := writeSubcommands(cmd, s, tw); err != nil { 519 540 return err 520 541 } 521 542 } ··· 529 550 s.WriteString("\n\n") 530 551 } 531 552 532 - s.WriteString(style.Title.Text("Options")) 553 + s.WriteString(optionsTitle) 533 554 s.WriteString(":\n\n") 534 555 535 - if err := writeFlags(cmd, s); err != nil { 556 + if err := writeFlags(cmd, s, tw); err != nil { 536 557 return err 537 558 } 538 559 ··· 570 591 571 592 // writeArgumentsSection writes the entire positional arguments block to the help 572 593 // text string builder. 573 - func writeArgumentsSection(cmd *Command, s *strings.Builder) error { 594 + func writeArgumentsSection(cmd *Command, s *strings.Builder, tw *tabwriter.Writer) error { 574 595 s.WriteString("\n\n") 575 - s.WriteString(style.Title.Text("Arguments")) 596 + s.WriteString(argumentsTitle) 576 597 s.WriteString(":\n\n") 577 - tw := style.Tabwriter(s) 598 + style.ResetTabwriter(tw, s) 578 599 579 600 for _, arg := range cmd.args { 580 - line := fmt.Sprintf(" %s\t%s\t%s\t[required]", style.Bold.Text(arg.Name()), arg.Type(), arg.Usage()) 581 - if arg.Default() != "" { 582 - line = fmt.Sprintf(" %s\t%s\t%s\t[default: %s]", style.Bold.Text(arg.Name()), arg.Type(), arg.Usage(), arg.Default()) 601 + if def := arg.Default(); def != "" { 602 + fmt.Fprintf(tw, " %s\t%s\t%s\t[default: %s]\n", style.Bold.Text(arg.Name()), arg.Type(), arg.Usage(), def) 603 + } else { 604 + fmt.Fprintf(tw, " %s\t%s\t%s\t[required]\n", style.Bold.Text(arg.Name()), arg.Type(), arg.Usage()) 583 605 } 584 - 585 - fmt.Fprintln(tw, line) 586 606 } 587 607 588 608 if err := tw.Flush(); err != nil { ··· 602 622 s.WriteString("\n\n") 603 623 } 604 624 605 - s.WriteString(style.Title.Text("Examples")) 625 + s.WriteString(examplesTitle) 606 626 s.WriteByte(':') 607 627 s.WriteString("\n\n") 608 628 ··· 625 645 } 626 646 627 647 // writeSubcommands writes the subcommand block to the help text string builder. 628 - func writeSubcommands(cmd *Command, s *strings.Builder) error { 648 + func writeSubcommands(cmd *Command, s *strings.Builder, tw *tabwriter.Writer) error { 629 649 // If there were examples, the last one would have printed a newline 630 650 if len(cmd.examples) != 0 { 631 651 s.WriteByte('\n') ··· 633 653 s.WriteString("\n\n") 634 654 } 635 655 636 - s.WriteString(style.Title.Text("Commands")) 656 + s.WriteString(commandsTitle) 637 657 s.WriteByte(':') 638 658 s.WriteString("\n\n") 639 659 640 - tw := style.Tabwriter(s) 660 + style.ResetTabwriter(tw, s) 661 + 641 662 for _, subcommand := range cmd.subcommands { 642 663 fmt.Fprintf(tw, " %s\t%s\n", style.Bold.Text(subcommand.name), subcommand.short) 643 664 } ··· 650 671 } 651 672 652 673 // writeFlags writes the flag usage block to the help text string builder. 653 - func writeFlags(cmd *Command, s *strings.Builder) error { 654 - tw := style.Tabwriter(s) 674 + func writeFlags(cmd *Command, s *strings.Builder, tw *tabwriter.Writer) error { 675 + style.ResetTabwriter(tw, s) 655 676 656 677 for name, fl := range cmd.flags.Sorted() { 657 678 var shorthand string ··· 671 692 envStr = "(env: $" + fl.EnvVar() + ")" 672 693 } 673 694 674 - line := fmt.Sprintf(" %s\t--%s\t%s\t%s\t%s\t%s", 695 + fmt.Fprintf(tw, " %s\t--%s\t%s\t%s\t%s\t%s\n", 675 696 style.Bold.Text(shorthand), 676 697 style.Bold.Text(name), 677 698 fl.Type(), ··· 679 700 defaultStr, 680 701 envStr, 681 702 ) 682 - 683 - fmt.Fprintln(tw, line) 684 703 } 685 704 686 705 if err := tw.Flush(); err != nil {
+110 -1
command_test.go
··· 1056 1056 } 1057 1057 } 1058 1058 1059 + func TestExecuteSubCommandRepeatRun(t *testing.T) { 1060 + // Repeated invocations of the same Command must be able to be repeatedly executed 1061 + leaf := func() (*cli.Command, error) { 1062 + var force bool 1063 + 1064 + return cli.New( 1065 + "leaf", 1066 + cli.Short("A leaf subcommand"), 1067 + cli.Flag(&force, "force", 'f', "Force something"), 1068 + cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), 1069 + ) 1070 + } 1071 + 1072 + mid := func() (*cli.Command, error) { 1073 + return cli.New( 1074 + "mid", 1075 + cli.Short("An intermediate subcommand"), 1076 + cli.SubCommands(leaf), 1077 + ) 1078 + } 1079 + 1080 + cmd, err := cli.New( 1081 + "root", 1082 + cli.SubCommands(mid), 1083 + cli.OverrideArgs([]string{"mid", "leaf", "--force"}), 1084 + cli.Stderr(io.Discard), 1085 + cli.Stdout(io.Discard), 1086 + ) 1087 + test.Ok(t, err) 1088 + 1089 + for range 3 { 1090 + test.Ok(t, cmd.Execute(t.Context())) 1091 + } 1092 + } 1093 + 1059 1094 func BenchmarkExecuteHelp(b *testing.B) { 1060 1095 sub1 := func() (*cli.Command, error) { 1061 1096 return cli.New( ··· 1113 1148 } 1114 1149 } 1115 1150 1116 - // Benchmarks calling New to build a typical CLI. 1151 + // BenchmarkNew measures calling New to build a small CLI. 1117 1152 func BenchmarkNew(b *testing.B) { 1118 1153 for b.Loop() { 1119 1154 _, err := cli.New( ··· 1129 1164 ) 1130 1165 if err != nil { 1131 1166 b.Fatal(err) 1167 + } 1168 + } 1169 + } 1170 + 1171 + // BenchmarkExecute measures the performance of actually invoking a CLI. 1172 + func BenchmarkExecute(b *testing.B) { 1173 + var ( 1174 + force bool 1175 + name string 1176 + count int 1177 + first string 1178 + ) 1179 + 1180 + cmd, err := cli.New( 1181 + "bench-execute", 1182 + cli.Short("A runnable command to benchmark Execute"), 1183 + cli.Flag(&force, "force", 'f', "Force something"), 1184 + cli.Flag(&name, "name", 'n', "Name of the thing"), 1185 + cli.Flag(&count, "count", 'c', "Count of things", cli.FlagDefault(1)), 1186 + cli.Arg(&first, "first", "A positional argument"), 1187 + cli.OverrideArgs([]string{"positional", "--force", "--name", "bench", "--count", "10"}), 1188 + cli.Stderr(io.Discard), 1189 + cli.Stdout(io.Discard), 1190 + cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), 1191 + ) 1192 + test.Ok(b, err) 1193 + 1194 + for b.Loop() { 1195 + err := cmd.Execute(b.Context()) 1196 + if err != nil { 1197 + b.Fatalf("Execute returned an error: %v", err) 1198 + } 1199 + } 1200 + } 1201 + 1202 + // BenchmarkExecuteSubcommand measures executing a subcommand. 1203 + func BenchmarkExecuteSubcommand(b *testing.B) { 1204 + leaf := func() (*cli.Command, error) { 1205 + var ( 1206 + force bool 1207 + name string 1208 + ) 1209 + 1210 + return cli.New( 1211 + "leaf", 1212 + cli.Short("A leaf subcommand"), 1213 + cli.Flag(&force, "force", 'f', "Force something"), 1214 + cli.Flag(&name, "name", 'n', "Name of the thing"), 1215 + cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), 1216 + ) 1217 + } 1218 + 1219 + mid := func() (*cli.Command, error) { 1220 + return cli.New( 1221 + "mid", 1222 + cli.Short("An intermediate subcommand"), 1223 + cli.SubCommands(leaf), 1224 + ) 1225 + } 1226 + 1227 + cmd, err := cli.New( 1228 + "bench-sub", 1229 + cli.Short("A command with nested subcommands"), 1230 + cli.SubCommands(mid), 1231 + cli.OverrideArgs([]string{"mid", "leaf", "--force", "--name", "bench"}), 1232 + cli.Stderr(io.Discard), 1233 + cli.Stdout(io.Discard), 1234 + ) 1235 + test.Ok(b, err) 1236 + 1237 + for b.Loop() { 1238 + err := cmd.Execute(b.Context()) 1239 + if err != nil { 1240 + b.Fatalf("Execute returned an error: %v", err) 1132 1241 } 1133 1242 } 1134 1243 }
+96 -174
internal/flag/flag.go
··· 24 24 25 25 // Flag represents a single command line flag. 26 26 type Flag[T flag.Flaggable] struct { 27 - value *T // The actual stored value 28 - name string // The name of the flag as appears on the command line, e.g. "force" for a --force flag 29 - usage string // one line description of the flag, e.g. "Force deletion without confirmation" 30 - envVar string // Name of an environment variable that may set this flag's value if the flag is not explicitly provided on the command line 31 - short rune // Optional shorthand version of the flag, e.g. "f" for a -f flag 27 + value *T // The actual stored value 28 + name string // The name of the flag as appears on the command line, e.g. "force" for a --force flag 29 + usage string // one line description of the flag, e.g. "Force deletion without confirmation" 30 + envVar string // Name of an environment variable that may set this flag's value if the flag is not explicitly provided on the command line 31 + typeStr string // Cached result of Type() 32 + noArgValue string // Cached result of NoArgValue() 33 + short rune // Optional shorthand version of the flag, e.g. "f" for a -f flag 34 + isSlice bool // Cached result of IsSlice() 32 35 } 33 36 34 37 // New constructs and returns a new [Flag]. ··· 50 53 51 54 *p = config.DefaultValue 52 55 56 + typeStr, noArgValue, isSlice := typeInfo[T]() 57 + 53 58 return &Flag[T]{ 54 - value: p, 55 - name: name, 56 - usage: usage, 57 - short: short, 58 - envVar: config.EnvVar, 59 + value: p, 60 + name: name, 61 + usage: usage, 62 + short: short, 63 + envVar: config.EnvVar, 64 + typeStr: typeStr, 65 + noArgValue: noArgValue, 66 + isSlice: isSlice, 59 67 }, nil 60 68 } 61 69 ··· 99 107 // IsSlice reports whether the flag holds a slice value that accumulates repeated 100 108 // calls to Set. Returns false for []byte and net.IP, which are parsed atomically. 101 109 func (f *Flag[T]) IsSlice() bool { 102 - if f.value == nil { 103 - return false 104 - } 105 - 106 - switch any(*f.value).(type) { 107 - case []int, []int8, []int16, []int32, []int64, 108 - []uint, []uint16, []uint32, []uint64, 109 - []float32, []float64, []string: 110 - return true 111 - default: 112 - return false 113 - } 110 + return f.isSlice 114 111 } 115 112 116 113 // NoArgValue returns a string representation of value the flag should hold 117 114 // when it is given no arguments on the command line. For example a boolean flag 118 115 // --delete, when passed without arguments implies --delete true. 119 116 func (f *Flag[T]) NoArgValue() string { 120 - switch f.Type() { 121 - case format.TypeBool: 122 - // Boolean flags imply passing true, "--force" vs "--force true" 123 - return format.True 124 - case format.TypeCount: 125 - // Count flags imply passing 1, "--count --count" or "-cc" should inc by 2 126 - return "1" 127 - default: 128 - return "" 117 + return f.noArgValue 118 + } 119 + 120 + // Type returns a string representation of the type of the Flag. 121 + func (f *Flag[T]) Type() string { 122 + if f.value == nil { 123 + // Nil-safe behaviour for zero-value composite-literal flags. 124 + return format.Nil 129 125 } 126 + 127 + return f.typeStr 130 128 } 131 129 132 130 // String implements [fmt.Stringer] for a [Flag], and also implements the String ··· 214 212 } 215 213 } 216 214 217 - // Type returns a string representation of the type of the Flag. 218 - func (f *Flag[T]) Type() string { //nolint:cyclop // No other way of doing this realistically 219 - if f.value == nil { 220 - return format.Nil 221 - } 215 + // typeInfo computes the type-dependent metadata (Type string, NoArgValue, 216 + // IsSlice) for a flag of type T. It is called once per flag at construction 217 + // so that the hot path of Parse never has to type-switch on any(*f.value), 218 + // which would otherwise box the value on every call. 219 + func typeInfo[T flag.Flaggable]() (typeStr, noArgValue string, isSlice bool) { //nolint:cyclop // No other way of doing this realistically 220 + var zero T 222 221 223 - switch typ := any(*f.value).(type) { 222 + switch typ := any(zero).(type) { 224 223 case int: 225 - return format.TypeInt 224 + return format.TypeInt, "", false 226 225 case int8: 227 - return format.TypeInt8 226 + return format.TypeInt8, "", false 228 227 case int16: 229 - return format.TypeInt16 228 + return format.TypeInt16, "", false 230 229 case int32: 231 - return format.TypeInt32 230 + return format.TypeInt32, "", false 232 231 case int64: 233 - return format.TypeInt64 232 + return format.TypeInt64, "", false 234 233 case flag.Count: 235 - return format.TypeCount 234 + return format.TypeCount, "1", false 236 235 case uint: 237 - return format.TypeUint 236 + return format.TypeUint, "", false 238 237 case uint8: 239 - return format.TypeUint8 238 + return format.TypeUint8, "", false 240 239 case uint16: 241 - return format.TypeUint16 240 + return format.TypeUint16, "", false 242 241 case uint32: 243 - return format.TypeUint32 242 + return format.TypeUint32, "", false 244 243 case uint64: 245 - return format.TypeUint64 244 + return format.TypeUint64, "", false 246 245 case uintptr: 247 - return format.TypeUintptr 246 + return format.TypeUintptr, "", false 248 247 case float32: 249 - return format.TypeFloat32 248 + return format.TypeFloat32, "", false 250 249 case float64: 251 - return format.TypeFloat64 250 + return format.TypeFloat64, "", false 252 251 case string: 253 - return format.TypeString 252 + return format.TypeString, "", false 254 253 case bool: 255 - return format.TypeBool 254 + return format.TypeBool, format.True, false 256 255 case []byte: 257 - return format.TypeBytesHex 256 + return format.TypeBytesHex, "", false 258 257 case time.Time: 259 - return format.TypeTime 258 + return format.TypeTime, "", false 260 259 case time.Duration: 261 - return format.TypeDuration 260 + return format.TypeDuration, "", false 262 261 case net.IP: 263 - return format.TypeIP 262 + return format.TypeIP, "", false 264 263 case *url.URL: 265 - return format.TypeURL 264 + return format.TypeURL, "", false 266 265 case []int: 267 - return format.TypeIntSlice 266 + return format.TypeIntSlice, "", true 268 267 case []int8: 269 - return format.TypeInt8Slice 268 + return format.TypeInt8Slice, "", true 270 269 case []int16: 271 - return format.TypeInt16Slice 270 + return format.TypeInt16Slice, "", true 272 271 case []int32: 273 - return format.TypeInt32Slice 272 + return format.TypeInt32Slice, "", true 274 273 case []int64: 275 - return format.TypeInt64Slice 274 + return format.TypeInt64Slice, "", true 276 275 case []uint: 277 - return format.TypeUintSlice 276 + return format.TypeUintSlice, "", true 278 277 case []uint16: 279 - return format.TypeUint16Slice 278 + return format.TypeUint16Slice, "", true 280 279 case []uint32: 281 - return format.TypeUint32Slice 280 + return format.TypeUint32Slice, "", true 282 281 case []uint64: 283 - return format.TypeUint64Slice 282 + return format.TypeUint64Slice, "", true 284 283 case []float32: 285 - return format.TypeFloat32Slice 284 + return format.TypeFloat32Slice, "", true 286 285 case []float64: 287 - return format.TypeFloat64Slice 286 + return format.TypeFloat64Slice, "", true 287 + case []string: 288 + return format.TypeStringSlice, "", true 288 289 default: 289 - return fmt.Sprintf("%T", typ) 290 + return fmt.Sprintf("%T", typ), "", false 290 291 } 291 292 } 292 293 ··· 345 346 346 347 return nil 347 348 case flag.Count: 348 - // We have to do a bit of custom stuff here as an increment is a read and write op 349 - // First read the current value of the flag and cast it to a Count so we 350 - // can increment it 351 - current, ok := any(*f.value).(flag.Count) 352 - if !ok { 353 - // This basically shouldn't ever happen but it's easy enough to handle nicely 354 - return errBadType(*f.value) 355 - } 356 - 357 349 // Add the count and store it back, we still parse the given str rather 358 350 // than just +1 every time as this allows people to do e.g. --verbosity=3 359 351 // as well as -vvv ··· 362 354 return parse.Error(parse.KindFlag, f.name, str, typ, err) 363 355 } 364 356 365 - newValue := current + flag.Count(val) 357 + newValue := typ + flag.Count(val) 366 358 *f.value = *parse.Cast[T](&newValue) 367 359 368 360 return nil ··· 499 491 return nil 500 492 case []int: 501 493 // Like Count, a slice flag is a read/write op 502 - slice, ok := any(*f.value).([]int) 503 - if !ok { 504 - return errBadType(*f.value) 505 - } 506 - 507 - // Append the given value to the slice 508 494 newValue, err := parse.Int(str) 509 495 if err != nil { 510 496 return parse.ErrorSlice(parse.KindFlag, f.name, str, typ, err) 511 497 } 512 498 513 - slice = append(slice, newValue) 514 - *f.value = *parse.Cast[T](&slice) 499 + typ = append(typ, newValue) 500 + *f.value = *parse.Cast[T](&typ) 515 501 516 502 return nil 517 503 case []int8: 518 - slice, ok := any(*f.value).([]int8) 519 - if !ok { 520 - return errBadType(*f.value) 521 - } 522 - 523 504 newValue, err := parse.Int8(str) 524 505 if err != nil { 525 506 return parse.ErrorSlice(parse.KindFlag, f.name, str, typ, err) 526 507 } 527 508 528 - slice = append(slice, newValue) 529 - *f.value = *parse.Cast[T](&slice) 509 + typ = append(typ, newValue) 510 + *f.value = *parse.Cast[T](&typ) 530 511 531 512 return nil 532 513 case []int16: 533 - slice, ok := any(*f.value).([]int16) 534 - if !ok { 535 - return errBadType(*f.value) 536 - } 537 - 538 514 newValue, err := parse.Int16(str) 539 515 if err != nil { 540 516 return parse.ErrorSlice(parse.KindFlag, f.name, str, typ, err) 541 517 } 542 518 543 - slice = append(slice, newValue) 544 - *f.value = *parse.Cast[T](&slice) 519 + typ = append(typ, newValue) 520 + *f.value = *parse.Cast[T](&typ) 545 521 546 522 return nil 547 523 case []int32: 548 - slice, ok := any(*f.value).([]int32) 549 - if !ok { 550 - return errBadType(*f.value) 551 - } 552 - 553 524 newValue, err := parse.Int32(str) 554 525 if err != nil { 555 526 return parse.ErrorSlice(parse.KindFlag, f.name, str, typ, err) 556 527 } 557 528 558 - slice = append(slice, newValue) 559 - *f.value = *parse.Cast[T](&slice) 529 + typ = append(typ, newValue) 530 + *f.value = *parse.Cast[T](&typ) 560 531 561 532 return nil 562 533 case []int64: 563 - slice, ok := any(*f.value).([]int64) 564 - if !ok { 565 - return errBadType(*f.value) 566 - } 567 - 568 534 newValue, err := parse.Int64(str) 569 535 if err != nil { 570 536 return parse.ErrorSlice(parse.KindFlag, f.name, str, typ, err) 571 537 } 572 538 573 - slice = append(slice, newValue) 574 - *f.value = *parse.Cast[T](&slice) 539 + typ = append(typ, newValue) 540 + *f.value = *parse.Cast[T](&typ) 575 541 576 542 return nil 577 - 578 543 case []uint: 579 - slice, ok := any(*f.value).([]uint) 580 - if !ok { 581 - return errBadType(*f.value) 582 - } 583 - 584 - // Append the given value to the slice 585 544 newValue, err := parse.Uint(str) 586 545 if err != nil { 587 546 return parse.ErrorSlice(parse.KindFlag, f.name, str, typ, err) 588 547 } 589 548 590 - slice = append(slice, newValue) 591 - *f.value = *parse.Cast[T](&slice) 549 + typ = append(typ, newValue) 550 + *f.value = *parse.Cast[T](&typ) 592 551 593 552 return nil 594 553 case []uint16: 595 - slice, ok := any(*f.value).([]uint16) 596 - if !ok { 597 - return errBadType(*f.value) 598 - } 599 - 600 554 newValue, err := parse.Uint16(str) 601 555 if err != nil { 602 556 return parse.ErrorSlice(parse.KindFlag, f.name, str, typ, err) 603 557 } 604 558 605 - slice = append(slice, newValue) 606 - *f.value = *parse.Cast[T](&slice) 559 + typ = append(typ, newValue) 560 + *f.value = *parse.Cast[T](&typ) 607 561 608 562 return nil 609 563 case []uint32: 610 - slice, ok := any(*f.value).([]uint32) 611 - if !ok { 612 - return errBadType(*f.value) 613 - } 614 - 615 564 newValue, err := parse.Uint32(str) 616 565 if err != nil { 617 566 return parse.ErrorSlice(parse.KindFlag, f.name, str, typ, err) 618 567 } 619 568 620 - slice = append(slice, newValue) 621 - *f.value = *parse.Cast[T](&slice) 569 + typ = append(typ, newValue) 570 + *f.value = *parse.Cast[T](&typ) 622 571 623 572 return nil 624 573 case []uint64: 625 - slice, ok := any(*f.value).([]uint64) 626 - if !ok { 627 - return errBadType(*f.value) 628 - } 629 - 630 574 newValue, err := parse.Uint64(str) 631 575 if err != nil { 632 576 return parse.ErrorSlice(parse.KindFlag, f.name, str, typ, err) 633 577 } 634 578 635 - slice = append(slice, newValue) 636 - *f.value = *parse.Cast[T](&slice) 579 + typ = append(typ, newValue) 580 + *f.value = *parse.Cast[T](&typ) 637 581 638 582 return nil 639 583 case []float32: 640 - slice, ok := any(*f.value).([]float32) 641 - if !ok { 642 - return errBadType(*f.value) 643 - } 644 - 645 584 newValue, err := parse.Float32(str) 646 585 if err != nil { 647 586 return parse.ErrorSlice(parse.KindFlag, f.name, str, typ, err) 648 587 } 649 588 650 - slice = append(slice, newValue) 651 - *f.value = *parse.Cast[T](&slice) 589 + typ = append(typ, newValue) 590 + *f.value = *parse.Cast[T](&typ) 652 591 653 592 return nil 654 593 case []float64: 655 - slice, ok := any(*f.value).([]float64) 656 - if !ok { 657 - return errBadType(*f.value) 658 - } 659 - 660 594 newValue, err := parse.Float64(str) 661 595 if err != nil { 662 596 return parse.ErrorSlice(parse.KindFlag, f.name, str, typ, err) 663 597 } 664 598 665 - slice = append(slice, newValue) 666 - *f.value = *parse.Cast[T](&slice) 599 + typ = append(typ, newValue) 600 + *f.value = *parse.Cast[T](&typ) 667 601 668 602 return nil 669 603 case []string: 670 - slice, ok := any(*f.value).([]string) 671 - if !ok { 672 - return errBadType(*f.value) 673 - } 674 - 675 - // No parsing to do because a string is... well, a string 676 - slice = append(slice, str) 677 - *f.value = *parse.Cast[T](&slice) 604 + typ = append(typ, str) 605 + *f.value = *parse.Cast[T](&typ) 678 606 679 607 return nil 680 608 default: ··· 744 672 } 745 673 746 674 return nil 747 - } 748 - 749 - // errBadType makes a consistent error in the face of a bad type 750 - // assertion. 751 - func errBadType[T flag.Flaggable](value T) error { 752 - return fmt.Errorf("bad value %v, could not cast to %T", value, value) 753 675 } 754 676 755 677 // isZeroIsh reports whether value is the zero value (ish) for it's type.
+82 -70
internal/flag/set.go
··· 8 8 "os" 9 9 "slices" 10 10 "strings" 11 + "unicode/utf8" 11 12 12 13 "go.followtheprocess.codes/cli/flag" 13 14 "go.followtheprocess.codes/cli/internal/format" ··· 17 18 type Set struct { 18 19 flags map[string]Value // The actual stored flags, can lookup by name 19 20 shorthands map[rune]Value // The flags by shorthand 20 - envVars map[string]string // flag name → env var name 21 + envVars map[string]string // flag name → env var name. Lazily created on first flag with an env var 21 22 args []string // Arguments minus flags or flag values 22 23 extra []string // Arguments after "--" was hit 23 24 } 24 25 26 + // typicalFlagCount is a rough guess at the number of flags a single 27 + // command is likely to have so we can right size the maps. 28 + const typicalFlagCount = 4 29 + 25 30 // NewSet builds and returns a new set of flags. 26 31 func NewSet() *Set { 27 32 return &Set{ 28 - flags: make(map[string]Value), 29 - shorthands: make(map[rune]Value), 30 - envVars: make(map[string]string), 33 + flags: make(map[string]Value, typicalFlagCount), 34 + shorthands: make(map[rune]Value, typicalFlagCount), 31 35 } 32 36 } 33 37 ··· 59 63 set.flags[name] = f 60 64 61 65 if f.envVar != "" { 66 + if set.envVars == nil { 67 + set.envVars = make(map[string]string, typicalFlagCount) 68 + } 69 + 62 70 set.envVars[name] = f.envVar 63 71 } 64 72 ··· 162 170 return errors.New("Parse called on a nil set") 163 171 } 164 172 165 - if err = s.applyEnvVars(); err != nil { 166 - return fmt.Errorf("could not set flag from env: %w", err) 173 + // Reset any positional state from a previous Parse so that successive calls 174 + // (e.g. re-executing a Command) don't accumulate args 175 + s.args = s.args[:0] 176 + s.extra = nil 177 + 178 + if len(s.envVars) > 0 { 179 + if err = s.applyEnvVars(); err != nil { 180 + return fmt.Errorf("could not set flag from env: %w", err) 181 + } 167 182 } 168 183 169 184 for len(args) > 0 { ··· 351 366 352 367 // parseSingleShortFlag parses a single short flag entry. 353 368 func (s *Set) parseSingleShortFlag(shorthands string, rest []string) (string, []string, error) { 354 - for _, char := range shorthands { 355 - if err := validateFlagShort(char); err != nil { 356 - return "", nil, fmt.Errorf("invalid flag shorthand %q: %w", string(char), err) 357 - } 369 + char, _ := utf8.DecodeRuneInString(shorthands) 358 370 359 - flag, exists := s.shorthands[char] 360 - if !exists { 361 - return "", nil, fmt.Errorf("unrecognised shorthand flag: %q in -%s", string(char), shorthands) 362 - } 363 - 364 - switch { 365 - case len(shorthands) >= 2 && shorthands[1] == '=': 366 - // '-f=value' (value may be empty, symmetric with '--flag=') 367 - value := shorthands[2:] 368 - 369 - err := flag.Set(value) 370 - if err != nil { 371 - return "", nil, err 372 - } 373 - // No more shorthands to parse as we got given a value 374 - // Nothing to trim off the arguments as "-f=value" is all 1 arg 375 - return "", rest, nil 376 - 377 - case flag.NoArgValue() != "": 378 - // -f with implied value e.g. boolean or count 379 - err := flag.Set(flag.NoArgValue()) 380 - if err != nil { 381 - return "", nil, err 382 - } 383 - 384 - // We've consumed a single short from the string so trim that off 385 - return shorthands[1:], rest, nil 386 - 387 - case len(shorthands) > 1: 388 - // '-fvalue' 389 - value := shorthands[1:] 390 - 391 - err := flag.Set(value) 392 - if err != nil { 393 - return "", nil, err 394 - } 395 - 396 - // No more shorthands to parse as we got given a value 397 - // Nothing to trim off as "-fvalue" is all 1 arg 398 - return "", rest, nil 399 - 400 - case len(rest) > 0: 401 - // '-f value' 402 - value := rest[0] 403 - 404 - err := flag.Set(value) 405 - if err != nil { 406 - return "", nil, err 407 - } 408 - 409 - // We've consumed an argument from rest as it was the value to this flag 410 - // as well as a shorthand 411 - return shorthands[1:], rest[1:], nil 412 - 413 - default: 414 - // '-f' with required value 415 - return "", nil, fmt.Errorf("flag %s needs an argument: %q in -%s", flag.Name(), string(char), shorthands) 416 - } 371 + if err := validateFlagShort(char); err != nil { 372 + return "", nil, fmt.Errorf("invalid flag shorthand %q: %w", string(char), err) 417 373 } 418 374 419 - // Didn't match any of our rules, must be invalid short flag syntax 420 - return "", nil, fmt.Errorf("invalid short flag syntax: %s", shorthands) 375 + flag, exists := s.shorthands[char] 376 + if !exists { 377 + return "", nil, fmt.Errorf("unrecognised shorthand flag: %q in -%s", string(char), shorthands) 378 + } 379 + 380 + switch { 381 + case len(shorthands) >= 2 && shorthands[1] == '=': 382 + // '-f=value' (value may be empty, symmetric with '--flag=') 383 + value := shorthands[2:] 384 + 385 + err := flag.Set(value) 386 + if err != nil { 387 + return "", nil, err 388 + } 389 + // No more shorthands to parse as we got given a value 390 + // Nothing to trim off the arguments as "-f=value" is all 1 arg 391 + return "", rest, nil 392 + 393 + case flag.NoArgValue() != "": 394 + // -f with implied value e.g. boolean or count 395 + err := flag.Set(flag.NoArgValue()) 396 + if err != nil { 397 + return "", nil, err 398 + } 399 + 400 + // We've consumed a single short from the string so trim that off 401 + return shorthands[1:], rest, nil 402 + 403 + case len(shorthands) > 1: 404 + // '-fvalue' 405 + value := shorthands[1:] 406 + 407 + err := flag.Set(value) 408 + if err != nil { 409 + return "", nil, err 410 + } 411 + 412 + // No more shorthands to parse as we got given a value 413 + // Nothing to trim off as "-fvalue" is all 1 arg 414 + return "", rest, nil 415 + 416 + case len(rest) > 0: 417 + // '-f value' 418 + value := rest[0] 419 + 420 + err := flag.Set(value) 421 + if err != nil { 422 + return "", nil, err 423 + } 424 + 425 + // We've consumed an argument from rest as it was the value to this flag 426 + // as well as a shorthand 427 + return shorthands[1:], rest[1:], nil 428 + 429 + default: 430 + // '-f' with required value 431 + return "", nil, fmt.Errorf("flag %s needs an argument: %q in -%s", flag.Name(), string(char), shorthands) 432 + } 421 433 }
+190
internal/flag/set_test.go
··· 1986 1986 } 1987 1987 } 1988 1988 1989 + func TestParseResetsState(t *testing.T) { 1990 + t.Run("positional args do not accumulate", func(t *testing.T) { 1991 + set := flag.NewSet() 1992 + 1993 + f, err := flag.New(new(bool), "delete", 'd', "Delete something", flag.Config[bool]{}) 1994 + test.Ok(t, err) 1995 + test.Ok(t, flag.AddToSet(set, f)) 1996 + 1997 + args := []string{"one", "two", "--delete"} 1998 + 1999 + test.Ok(t, set.Parse(args)) 2000 + test.EqualFunc(t, set.Args(), []string{"one", "two"}, slices.Equal) 2001 + 2002 + test.Ok(t, set.Parse(args)) 2003 + test.EqualFunc(t, set.Args(), []string{"one", "two"}, slices.Equal) 2004 + }) 2005 + 2006 + t.Run("extra args do not accumulate", func(t *testing.T) { 2007 + set := flag.NewSet() 2008 + 2009 + args := []string{"pos", "--", "extra", "more"} 2010 + 2011 + test.Ok(t, set.Parse(args)) 2012 + test.EqualFunc(t, set.ExtraArgs(), []string{"extra", "more"}, slices.Equal) 2013 + 2014 + test.Ok(t, set.Parse(args)) 2015 + test.EqualFunc(t, set.ExtraArgs(), []string{"extra", "more"}, slices.Equal) 2016 + }) 2017 + 2018 + t.Run("previous extras cleared when next call has none", func(t *testing.T) { 2019 + set := flag.NewSet() 2020 + 2021 + test.Ok(t, set.Parse([]string{"a", "--", "x"})) 2022 + test.EqualFunc(t, set.ExtraArgs(), []string{"x"}, slices.Equal) 2023 + 2024 + test.Ok(t, set.Parse([]string{"a", "b"})) 2025 + test.Equal(t, len(set.ExtraArgs()), 0) 2026 + }) 2027 + } 2028 + 1989 2029 func TestFlagSet(t *testing.T) { 1990 2030 tests := []struct { 1991 2031 newSet func(t *testing.T) *flag.Set // Function to build the flag set under test ··· 2490 2530 args := []string{"some", "args", "here", "--delete", "--count", "10", "--name", "John"} 2491 2531 2492 2532 for b.Loop() { 2533 + err := set.Parse(args) 2534 + if err != nil { 2535 + b.Fatalf("Parse returned an error: %v", err) 2536 + } 2537 + } 2538 + } 2539 + 2540 + // Benchmarks Parse on a flag set invoked exclusively via shorthand flags. 2541 + func BenchmarkParseShort(b *testing.B) { 2542 + set := flag.NewSet() 2543 + f, err := flag.New(new(int), "count", 'c', "Count something", flag.Config[int]{}) 2544 + test.Ok(b, err) 2545 + 2546 + f2, err := flag.New(new(bool), "delete", 'd', "Delete something", flag.Config[bool]{}) 2547 + test.Ok(b, err) 2548 + 2549 + f3, err := flag.New(new(string), "name", 'n', "Name something", flag.Config[string]{}) 2550 + test.Ok(b, err) 2551 + 2552 + test.Ok(b, flag.AddToSet(set, f)) 2553 + test.Ok(b, flag.AddToSet(set, f2)) 2554 + test.Ok(b, flag.AddToSet(set, f3)) 2555 + 2556 + args := []string{"some", "args", "here", "-d", "-c", "10", "-n", "John"} 2557 + 2558 + for b.Loop() { 2559 + err := set.Parse(args) 2560 + if err != nil { 2561 + b.Fatalf("Parse returned an error: %v", err) 2562 + } 2563 + } 2564 + } 2565 + 2566 + // Benchmarks Parse with a mix of long and short flags, representative of how 2567 + // users typically invoke a real CLI. 2568 + func BenchmarkParseMixed(b *testing.B) { 2569 + set := flag.NewSet() 2570 + f, err := flag.New(new(int), "count", 'c', "Count something", flag.Config[int]{}) 2571 + test.Ok(b, err) 2572 + 2573 + f2, err := flag.New(new(bool), "delete", 'd', "Delete something", flag.Config[bool]{}) 2574 + test.Ok(b, err) 2575 + 2576 + f3, err := flag.New(new(string), "name", 'n', "Name something", flag.Config[string]{}) 2577 + test.Ok(b, err) 2578 + 2579 + f4, err := flag.New(new(bool), "verbose", 'v', "Verbose output", flag.Config[bool]{}) 2580 + test.Ok(b, err) 2581 + 2582 + test.Ok(b, flag.AddToSet(set, f)) 2583 + test.Ok(b, flag.AddToSet(set, f2)) 2584 + test.Ok(b, flag.AddToSet(set, f3)) 2585 + test.Ok(b, flag.AddToSet(set, f4)) 2586 + 2587 + args := []string{"some", "args", "here", "-d", "--count", "10", "-n", "John", "--verbose"} 2588 + 2589 + for b.Loop() { 2590 + err := set.Parse(args) 2591 + if err != nil { 2592 + b.Fatalf("Parse returned an error: %v", err) 2593 + } 2594 + } 2595 + } 2596 + 2597 + // Benchmarks Parse when flags have environment variables attached, so include 2598 + // the cost of the Getenv syscall. 2599 + func BenchmarkParseWithEnv(b *testing.B) { 2600 + build := func(b *testing.B) (*flag.Set, []string) { 2601 + b.Helper() 2602 + 2603 + set := flag.NewSet() 2604 + 2605 + f, err := flag.New( 2606 + new(int), 2607 + "count", 2608 + 'c', 2609 + "Count something", 2610 + flag.Config[int]{EnvVar: "BENCH_PARSE_COUNT"}, 2611 + ) 2612 + test.Ok(b, err) 2613 + 2614 + f2, err := flag.New( 2615 + new(string), 2616 + "name", 2617 + 'n', 2618 + "Name something", 2619 + flag.Config[string]{EnvVar: "BENCH_PARSE_NAME"}, 2620 + ) 2621 + test.Ok(b, err) 2622 + 2623 + f3, err := flag.New( 2624 + new(bool), 2625 + "force", 2626 + 'f', 2627 + "Force something", 2628 + flag.Config[bool]{EnvVar: "BENCH_PARSE_FORCE"}, 2629 + ) 2630 + test.Ok(b, err) 2631 + 2632 + test.Ok(b, flag.AddToSet(set, f)) 2633 + test.Ok(b, flag.AddToSet(set, f2)) 2634 + test.Ok(b, flag.AddToSet(set, f3)) 2635 + 2636 + return set, []string{"positional"} 2637 + } 2638 + 2639 + b.Run("unset", func(b *testing.B) { 2640 + set, args := build(b) 2641 + 2642 + for b.Loop() { 2643 + err := set.Parse(args) 2644 + if err != nil { 2645 + b.Fatalf("Parse returned an error: %v", err) 2646 + } 2647 + } 2648 + }) 2649 + 2650 + b.Run("set", func(b *testing.B) { 2651 + b.Setenv("BENCH_PARSE_COUNT", "10") 2652 + b.Setenv("BENCH_PARSE_NAME", "John") 2653 + b.Setenv("BENCH_PARSE_FORCE", "true") 2654 + 2655 + set, args := build(b) 2656 + 2657 + for b.Loop() { 2658 + err := set.Parse(args) 2659 + if err != nil { 2660 + b.Fatalf("Parse returned an error: %v", err) 2661 + } 2662 + } 2663 + }) 2664 + } 2665 + 2666 + // Benchmarks Parse on a slice flag multiple times. A slice flag is a 2667 + // read/write operation. 2668 + func BenchmarkParseSliceFlag(b *testing.B) { 2669 + var items []string 2670 + 2671 + set := flag.NewSet() 2672 + f, err := flag.New(&items, "item", 'i', "An item", flag.Config[[]string]{}) 2673 + test.Ok(b, err) 2674 + 2675 + test.Ok(b, flag.AddToSet(set, f)) 2676 + 2677 + args := []string{"--item", "one", "--item", "two", "--item", "three", "--item", "four"} 2678 + 2679 + for b.Loop() { 2680 + // Reset between iterations so the slice doesn't grow forever 2681 + items = items[:0] 2682 + 2493 2683 err := set.Parse(args) 2494 2684 if err != nil { 2495 2685 b.Fatalf("Parse returned an error: %v", err)
+10
internal/style/style.go
··· 38 38 func Tabwriter(w io.Writer) *tabwriter.Writer { 39 39 return tabwriter.NewWriter(w, minWidth, tabWidth, padding, padChar, flags) 40 40 } 41 + 42 + // ResetTabwriter re-initialises an existing [tabwriter.Writer] with the cli 43 + // house style, pointing it at w. It returns the same writer for chaining. 44 + // 45 + // Init reuses the writer's internal buffers rather than allocating new ones, 46 + // so threading a single writer through the help renderer avoids a fresh 47 + // tabwriter allocation per section. 48 + func ResetTabwriter(tw *tabwriter.Writer, w io.Writer) *tabwriter.Writer { 49 + return tw.Init(w, minWidth, tabWidth, padding, padChar, flags) 50 + }