From 88af0bb3950adacf9bcfb3b36f8ad0a24ff19051 Mon Sep 17 00:00:00 2001 From: Tom Fleet Date: Thu, 23 Apr 2026 19:52:38 +0100 Subject: [PATCH 1/7] Add some more benchmarks and reset args state between Parse --- command.go | 9 +- command_test.go | 111 +++++++++++++++++++++- internal/flag/set.go | 5 + internal/flag/set_test.go | 190 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 313 insertions(+), 2 deletions(-) diff --git a/command.go b/command.go index 2e1e4a5..3d93c3d 100644 --- a/command.go +++ b/command.go @@ -388,12 +388,19 @@ func findRequestedCommand(cmd *Command, args []string) (*Command, []string) { // argsMinusFirstX removes only the first x from args. Otherwise, commands that look like // openshift admin policy add-role-to-user admin my-user, lose the admin argument (arg[4]). +// +// The input slice is not mutated so that repeated Execute calls on the same +// Command see the original rawArgs. func argsMinusFirstX(args []string, x string) []string { // Note: this is borrowed from Cobra but ours is a lot simpler because we don't support // persistent flags for i, arg := range args { if arg == x { - return slices.Delete(args, i, i+1) + result := make([]string, 0, len(args)-1) + result = append(result, args[:i]...) + result = append(result, args[i+1:]...) + + return result } } diff --git a/command_test.go b/command_test.go index 6d8bf75..09ecd1c 100644 --- a/command_test.go +++ b/command_test.go @@ -1056,6 +1056,41 @@ func TestCommandOptionOrder(t *testing.T) { } } +func TestExecuteSubCommandRepeatRun(t *testing.T) { + // Repeated invocations of the same Command must be able to be repeatedly executed + leaf := func() (*cli.Command, error) { + var force bool + + return cli.New( + "leaf", + cli.Short("A leaf subcommand"), + cli.Flag(&force, "force", 'f', "Force something"), + cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), + ) + } + + mid := func() (*cli.Command, error) { + return cli.New( + "mid", + cli.Short("An intermediate subcommand"), + cli.SubCommands(leaf), + ) + } + + cmd, err := cli.New( + "root", + cli.SubCommands(mid), + cli.OverrideArgs([]string{"mid", "leaf", "--force"}), + cli.Stderr(io.Discard), + cli.Stdout(io.Discard), + ) + test.Ok(t, err) + + for range 3 { + test.Ok(t, cmd.Execute(t.Context())) + } +} + func BenchmarkExecuteHelp(b *testing.B) { sub1 := func() (*cli.Command, error) { return cli.New( @@ -1113,7 +1148,7 @@ func BenchmarkExecuteHelp(b *testing.B) { } } -// Benchmarks calling New to build a typical CLI. +// BenchmarkNew measures calling New to build a small CLI. func BenchmarkNew(b *testing.B) { for b.Loop() { _, err := cli.New( @@ -1133,6 +1168,80 @@ func BenchmarkNew(b *testing.B) { } } +// BenchmarkExecute measures the performance of actually invoking a CLI. +func BenchmarkExecute(b *testing.B) { + var ( + force bool + name string + count int + first string + ) + + cmd, err := cli.New( + "bench-execute", + cli.Short("A runnable command to benchmark Execute"), + cli.Flag(&force, "force", 'f', "Force something"), + cli.Flag(&name, "name", 'n', "Name of the thing"), + cli.Flag(&count, "count", 'c', "Count of things", cli.FlagDefault(1)), + cli.Arg(&first, "first", "A positional argument"), + cli.OverrideArgs([]string{"positional", "--force", "--name", "bench", "--count", "10"}), + cli.Stderr(io.Discard), + cli.Stdout(io.Discard), + cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), + ) + test.Ok(b, err) + + for b.Loop() { + err := cmd.Execute(b.Context()) + if err != nil { + b.Fatalf("Execute returned an error: %v", err) + } + } +} + +// BenchmarkExecuteSubcommand measures executing a subcommand. +func BenchmarkExecuteSubcommand(b *testing.B) { + leaf := func() (*cli.Command, error) { + var ( + force bool + name string + ) + + return cli.New( + "leaf", + cli.Short("A leaf subcommand"), + cli.Flag(&force, "force", 'f', "Force something"), + cli.Flag(&name, "name", 'n', "Name of the thing"), + cli.Run(func(ctx context.Context, cmd *cli.Command) error { return nil }), + ) + } + + mid := func() (*cli.Command, error) { + return cli.New( + "mid", + cli.Short("An intermediate subcommand"), + cli.SubCommands(leaf), + ) + } + + cmd, err := cli.New( + "bench-sub", + cli.Short("A command with nested subcommands"), + cli.SubCommands(mid), + cli.OverrideArgs([]string{"mid", "leaf", "--force", "--name", "bench"}), + cli.Stderr(io.Discard), + cli.Stdout(io.Discard), + ) + test.Ok(b, err) + + for b.Loop() { + err := cmd.Execute(b.Context()) + if err != nil { + b.Fatalf("Execute returned an error: %v", err) + } + } +} + // Benchmarks calling the --version flag. func BenchmarkVersion(b *testing.B) { cmd, err := cli.New( diff --git a/internal/flag/set.go b/internal/flag/set.go index 688d5e5..251be5e 100644 --- a/internal/flag/set.go +++ b/internal/flag/set.go @@ -162,6 +162,11 @@ func (s *Set) Parse(args []string) error { return errors.New("Parse called on a nil set") } + // Reset any positional state from a previous Parse so that successive calls + // (e.g. re-executing a Command) don't accumulate args + s.args = s.args[:0] + s.extra = nil + if err = s.applyEnvVars(); err != nil { return fmt.Errorf("could not set flag from env: %w", err) } diff --git a/internal/flag/set_test.go b/internal/flag/set_test.go index 3dd3a7a..69bf2ad 100644 --- a/internal/flag/set_test.go +++ b/internal/flag/set_test.go @@ -1986,6 +1986,46 @@ func TestParse(t *testing.T) { } } +func TestParseResetsState(t *testing.T) { + t.Run("positional args do not accumulate", func(t *testing.T) { + set := flag.NewSet() + + f, err := flag.New(new(bool), "delete", 'd', "Delete something", flag.Config[bool]{}) + test.Ok(t, err) + test.Ok(t, flag.AddToSet(set, f)) + + args := []string{"one", "two", "--delete"} + + test.Ok(t, set.Parse(args)) + test.EqualFunc(t, set.Args(), []string{"one", "two"}, slices.Equal) + + test.Ok(t, set.Parse(args)) + test.EqualFunc(t, set.Args(), []string{"one", "two"}, slices.Equal) + }) + + t.Run("extra args do not accumulate", func(t *testing.T) { + set := flag.NewSet() + + args := []string{"pos", "--", "extra", "more"} + + test.Ok(t, set.Parse(args)) + test.EqualFunc(t, set.ExtraArgs(), []string{"extra", "more"}, slices.Equal) + + test.Ok(t, set.Parse(args)) + test.EqualFunc(t, set.ExtraArgs(), []string{"extra", "more"}, slices.Equal) + }) + + t.Run("previous extras cleared when next call has none", func(t *testing.T) { + set := flag.NewSet() + + test.Ok(t, set.Parse([]string{"a", "--", "x"})) + test.EqualFunc(t, set.ExtraArgs(), []string{"x"}, slices.Equal) + + test.Ok(t, set.Parse([]string{"a", "b"})) + test.Equal(t, len(set.ExtraArgs()), 0) + }) +} + func TestFlagSet(t *testing.T) { tests := []struct { newSet func(t *testing.T) *flag.Set // Function to build the flag set under test @@ -2496,3 +2536,153 @@ func BenchmarkParse(b *testing.B) { } } } + +// Benchmarks Parse on a flag set invoked exclusively via shorthand flags. +func BenchmarkParseShort(b *testing.B) { + set := flag.NewSet() + f, err := flag.New(new(int), "count", 'c', "Count something", flag.Config[int]{}) + test.Ok(b, err) + + f2, err := flag.New(new(bool), "delete", 'd', "Delete something", flag.Config[bool]{}) + test.Ok(b, err) + + f3, err := flag.New(new(string), "name", 'n', "Name something", flag.Config[string]{}) + test.Ok(b, err) + + test.Ok(b, flag.AddToSet(set, f)) + test.Ok(b, flag.AddToSet(set, f2)) + test.Ok(b, flag.AddToSet(set, f3)) + + args := []string{"some", "args", "here", "-d", "-c", "10", "-n", "John"} + + for b.Loop() { + err := set.Parse(args) + if err != nil { + b.Fatalf("Parse returned an error: %v", err) + } + } +} + +// Benchmarks Parse with a mix of long and short flags, representative of how +// users typically invoke a real CLI. +func BenchmarkParseMixed(b *testing.B) { + set := flag.NewSet() + f, err := flag.New(new(int), "count", 'c', "Count something", flag.Config[int]{}) + test.Ok(b, err) + + f2, err := flag.New(new(bool), "delete", 'd', "Delete something", flag.Config[bool]{}) + test.Ok(b, err) + + f3, err := flag.New(new(string), "name", 'n', "Name something", flag.Config[string]{}) + test.Ok(b, err) + + f4, err := flag.New(new(bool), "verbose", 'v', "Verbose output", flag.Config[bool]{}) + test.Ok(b, err) + + test.Ok(b, flag.AddToSet(set, f)) + test.Ok(b, flag.AddToSet(set, f2)) + test.Ok(b, flag.AddToSet(set, f3)) + test.Ok(b, flag.AddToSet(set, f4)) + + args := []string{"some", "args", "here", "-d", "--count", "10", "-n", "John", "--verbose"} + + for b.Loop() { + err := set.Parse(args) + if err != nil { + b.Fatalf("Parse returned an error: %v", err) + } + } +} + +// Benchmarks Parse when flags have environment variables attached, so include +// the cost of the Getenv syscall. +func BenchmarkParseWithEnv(b *testing.B) { + build := func(b *testing.B) (*flag.Set, []string) { + b.Helper() + + set := flag.NewSet() + + f, err := flag.New( + new(int), + "count", + 'c', + "Count something", + flag.Config[int]{EnvVar: "BENCH_PARSE_COUNT"}, + ) + test.Ok(b, err) + + f2, err := flag.New( + new(string), + "name", + 'n', + "Name something", + flag.Config[string]{EnvVar: "BENCH_PARSE_NAME"}, + ) + test.Ok(b, err) + + f3, err := flag.New( + new(bool), + "force", + 'f', + "Force something", + flag.Config[bool]{EnvVar: "BENCH_PARSE_FORCE"}, + ) + test.Ok(b, err) + + test.Ok(b, flag.AddToSet(set, f)) + test.Ok(b, flag.AddToSet(set, f2)) + test.Ok(b, flag.AddToSet(set, f3)) + + return set, []string{"positional"} + } + + b.Run("unset", func(b *testing.B) { + set, args := build(b) + + for b.Loop() { + err := set.Parse(args) + if err != nil { + b.Fatalf("Parse returned an error: %v", err) + } + } + }) + + b.Run("set", func(b *testing.B) { + b.Setenv("BENCH_PARSE_COUNT", "10") + b.Setenv("BENCH_PARSE_NAME", "John") + b.Setenv("BENCH_PARSE_FORCE", "true") + + set, args := build(b) + + for b.Loop() { + err := set.Parse(args) + if err != nil { + b.Fatalf("Parse returned an error: %v", err) + } + } + }) +} + +// Benchmarks Parse on a slice flag multiple times. A slice flag is a +// read/write operation. +func BenchmarkParseSliceFlag(b *testing.B) { + var items []string + + set := flag.NewSet() + f, err := flag.New(&items, "item", 'i', "An item", flag.Config[[]string]{}) + test.Ok(b, err) + + test.Ok(b, flag.AddToSet(set, f)) + + args := []string{"--item", "one", "--item", "two", "--item", "three", "--item", "four"} + + for b.Loop() { + // Reset between iterations so the slice doesn't grow forever + items = items[:0] + + err := set.Parse(args) + if err != nil { + b.Fatalf("Parse returned an error: %v", err) + } + } +} From 3ec49c92238b7d196543e3787096b3f9044fc590 Mon Sep 17 00:00:00 2001 From: Tom Fleet Date: Thu, 23 Apr 2026 20:11:44 +0100 Subject: [PATCH 2/7] Some quick perf wins --- command.go | 33 ++++++---- internal/flag/flag.go | 141 +++++++++++++++++++++--------------------- 2 files changed, 91 insertions(+), 83 deletions(-) diff --git a/command.go b/command.go index 3d93c3d..ce45972 100644 --- a/command.go +++ b/command.go @@ -25,6 +25,16 @@ const ( defaultShort = "A placeholder for something cool" // defaultShort is the default value for cli.Short. ) +// Pre-styled section headers used in the help text. hue's Text allocates +// a new string each call, so we do the styling once at init time. +var ( + usageTitle = style.Title.Text("Usage") + optionsTitle = style.Title.Text("Options") + commandsTitle = style.Title.Text("Commands") + argumentsTitle = style.Title.Text("Arguments") + examplesTitle = style.Title.Text("Examples") +) + // Builder is a function that constructs and returns a [Command], it makes constructing // complex command trees easier as they can be passed directly to the [SubCommands] option. type Builder func() (*Command, error) @@ -486,7 +496,7 @@ func showHelp(cmd *Command) error { s.WriteString("\n\n") } - s.WriteString(style.Title.Text("Usage")) + s.WriteString(usageTitle) s.WriteString(": ") s.WriteString(style.Bold.Text(cmd.name)) @@ -536,7 +546,7 @@ func showHelp(cmd *Command) error { s.WriteString("\n\n") } - s.WriteString(style.Title.Text("Options")) + s.WriteString(optionsTitle) s.WriteString(":\n\n") if err := writeFlags(cmd, s); err != nil { @@ -579,17 +589,16 @@ func writePositionalArgs(cmd *Command, s *strings.Builder) { // text string builder. func writeArgumentsSection(cmd *Command, s *strings.Builder) error { s.WriteString("\n\n") - s.WriteString(style.Title.Text("Arguments")) + s.WriteString(argumentsTitle) s.WriteString(":\n\n") tw := style.Tabwriter(s) for _, arg := range cmd.args { - line := fmt.Sprintf(" %s\t%s\t%s\t[required]", style.Bold.Text(arg.Name()), arg.Type(), arg.Usage()) - if arg.Default() != "" { - line = fmt.Sprintf(" %s\t%s\t%s\t[default: %s]", style.Bold.Text(arg.Name()), arg.Type(), arg.Usage(), arg.Default()) + if def := arg.Default(); def != "" { + fmt.Fprintf(tw, " %s\t%s\t%s\t[default: %s]\n", style.Bold.Text(arg.Name()), arg.Type(), arg.Usage(), def) + } else { + fmt.Fprintf(tw, " %s\t%s\t%s\t[required]\n", style.Bold.Text(arg.Name()), arg.Type(), arg.Usage()) } - - fmt.Fprintln(tw, line) } if err := tw.Flush(); err != nil { @@ -609,7 +618,7 @@ func writeExamples(cmd *Command, s *strings.Builder) { s.WriteString("\n\n") } - s.WriteString(style.Title.Text("Examples")) + s.WriteString(examplesTitle) s.WriteByte(':') s.WriteString("\n\n") @@ -640,7 +649,7 @@ func writeSubcommands(cmd *Command, s *strings.Builder) error { s.WriteString("\n\n") } - s.WriteString(style.Title.Text("Commands")) + s.WriteString(commandsTitle) s.WriteByte(':') s.WriteString("\n\n") @@ -678,7 +687,7 @@ func writeFlags(cmd *Command, s *strings.Builder) error { envStr = "(env: $" + fl.EnvVar() + ")" } - line := fmt.Sprintf(" %s\t--%s\t%s\t%s\t%s\t%s", + fmt.Fprintf(tw, " %s\t--%s\t%s\t%s\t%s\t%s\n", style.Bold.Text(shorthand), style.Bold.Text(name), fl.Type(), @@ -686,8 +695,6 @@ func writeFlags(cmd *Command, s *strings.Builder) error { defaultStr, envStr, ) - - fmt.Fprintln(tw, line) } if err := tw.Flush(); err != nil { diff --git a/internal/flag/flag.go b/internal/flag/flag.go index 93f9aa8..c5da402 100644 --- a/internal/flag/flag.go +++ b/internal/flag/flag.go @@ -24,11 +24,14 @@ var _ Value = &Flag[string]{} // This will fail if we violate our Value interfac // Flag represents a single command line flag. type Flag[T flag.Flaggable] struct { - value *T // The actual stored value - name string // The name of the flag as appears on the command line, e.g. "force" for a --force flag - usage string // one line description of the flag, e.g. "Force deletion without confirmation" - 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 - short rune // Optional shorthand version of the flag, e.g. "f" for a -f flag + value *T // The actual stored value + name string // The name of the flag as appears on the command line, e.g. "force" for a --force flag + usage string // one line description of the flag, e.g. "Force deletion without confirmation" + 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 + typeStr string // Cached result of Type() + noArgValue string // Cached result of NoArgValue() + short rune // Optional shorthand version of the flag, e.g. "f" for a -f flag + isSlice bool // Cached result of IsSlice() } // New constructs and returns a new [Flag]. @@ -50,12 +53,17 @@ func New[T flag.Flaggable](p *T, name string, short rune, usage string, config C *p = config.DefaultValue + typeStr, noArgValue, isSlice := typeInfo[T]() + return &Flag[T]{ - value: p, - name: name, - usage: usage, - short: short, - envVar: config.EnvVar, + value: p, + name: name, + usage: usage, + short: short, + envVar: config.EnvVar, + typeStr: typeStr, + noArgValue: noArgValue, + isSlice: isSlice, }, nil } @@ -99,34 +107,24 @@ func (f *Flag[T]) EnvVar() string { // IsSlice reports whether the flag holds a slice value that accumulates repeated // calls to Set. Returns false for []byte and net.IP, which are parsed atomically. func (f *Flag[T]) IsSlice() bool { - if f.value == nil { - return false - } - - switch any(*f.value).(type) { - case []int, []int8, []int16, []int32, []int64, - []uint, []uint16, []uint32, []uint64, - []float32, []float64, []string: - return true - default: - return false - } + return f.isSlice } // NoArgValue returns a string representation of value the flag should hold // when it is given no arguments on the command line. For example a boolean flag // --delete, when passed without arguments implies --delete true. func (f *Flag[T]) NoArgValue() string { - switch f.Type() { - case format.TypeBool: - // Boolean flags imply passing true, "--force" vs "--force true" - return format.True - case format.TypeCount: - // Count flags imply passing 1, "--count --count" or "-cc" should inc by 2 - return "1" - default: - return "" + return f.noArgValue +} + +// Type returns a string representation of the type of the Flag. +func (f *Flag[T]) Type() string { + if f.value == nil { + // Nil-safe behaviour for zero-value composite-literal flags. + return format.Nil } + + return f.typeStr } // String implements [fmt.Stringer] for a [Flag], and also implements the String @@ -214,79 +212,82 @@ func (f *Flag[T]) String() string { } } -// Type returns a string representation of the type of the Flag. -func (f *Flag[T]) Type() string { //nolint:cyclop // No other way of doing this realistically - if f.value == nil { - return format.Nil - } +// typeInfo computes the type-dependent metadata (Type string, NoArgValue, +// IsSlice) for a flag of type T. It is called once per flag at construction +// so that the hot path of Parse never has to type-switch on any(*f.value), +// which would otherwise box the value on every call. +func typeInfo[T flag.Flaggable]() (typeStr, noArgValue string, isSlice bool) { //nolint:cyclop // No other way of doing this realistically + var zero T - switch typ := any(*f.value).(type) { + switch typ := any(zero).(type) { case int: - return format.TypeInt + return format.TypeInt, "", false case int8: - return format.TypeInt8 + return format.TypeInt8, "", false case int16: - return format.TypeInt16 + return format.TypeInt16, "", false case int32: - return format.TypeInt32 + return format.TypeInt32, "", false case int64: - return format.TypeInt64 + return format.TypeInt64, "", false case flag.Count: - return format.TypeCount + return format.TypeCount, "1", false case uint: - return format.TypeUint + return format.TypeUint, "", false case uint8: - return format.TypeUint8 + return format.TypeUint8, "", false case uint16: - return format.TypeUint16 + return format.TypeUint16, "", false case uint32: - return format.TypeUint32 + return format.TypeUint32, "", false case uint64: - return format.TypeUint64 + return format.TypeUint64, "", false case uintptr: - return format.TypeUintptr + return format.TypeUintptr, "", false case float32: - return format.TypeFloat32 + return format.TypeFloat32, "", false case float64: - return format.TypeFloat64 + return format.TypeFloat64, "", false case string: - return format.TypeString + return format.TypeString, "", false case bool: - return format.TypeBool + return format.TypeBool, format.True, false case []byte: - return format.TypeBytesHex + return format.TypeBytesHex, "", false case time.Time: - return format.TypeTime + return format.TypeTime, "", false case time.Duration: - return format.TypeDuration + return format.TypeDuration, "", false case net.IP: - return format.TypeIP + return format.TypeIP, "", false case *url.URL: - return format.TypeURL + return format.TypeURL, "", false case []int: - return format.TypeIntSlice + return format.TypeIntSlice, "", true case []int8: - return format.TypeInt8Slice + return format.TypeInt8Slice, "", true case []int16: - return format.TypeInt16Slice + return format.TypeInt16Slice, "", true case []int32: - return format.TypeInt32Slice + return format.TypeInt32Slice, "", true case []int64: - return format.TypeInt64Slice + return format.TypeInt64Slice, "", true case []uint: - return format.TypeUintSlice + return format.TypeUintSlice, "", true case []uint16: - return format.TypeUint16Slice + return format.TypeUint16Slice, "", true case []uint32: - return format.TypeUint32Slice + return format.TypeUint32Slice, "", true case []uint64: - return format.TypeUint64Slice + return format.TypeUint64Slice, "", true case []float32: - return format.TypeFloat32Slice + return format.TypeFloat32Slice, "", true case []float64: - return format.TypeFloat64Slice + return format.TypeFloat64Slice, "", true + case []string: + return format.TypeStringSlice, "", true default: - return fmt.Sprintf("%T", typ) + return fmt.Sprintf("%T", typ), "", false } } From 7fe2f76a2b85630b36e5608515b820a634983097 Mon Sep 17 00:00:00 2001 From: Tom Fleet Date: Thu, 23 Apr 2026 20:14:32 +0100 Subject: [PATCH 3/7] Lazily initialise the env var map --- internal/flag/set.go | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/internal/flag/set.go b/internal/flag/set.go index 251be5e..9fb8928 100644 --- a/internal/flag/set.go +++ b/internal/flag/set.go @@ -17,17 +17,20 @@ import ( type Set struct { flags map[string]Value // The actual stored flags, can lookup by name shorthands map[rune]Value // The flags by shorthand - envVars map[string]string // flag name → env var name + envVars map[string]string // flag name → env var name. Lazily created on first flag with an env var args []string // Arguments minus flags or flag values extra []string // Arguments after "--" was hit } +// typicalFlagCount is a rough guess at the number of flags a single +// command is likely to have so we can right size the maps. +const typicalFlagCount = 4 + // NewSet builds and returns a new set of flags. func NewSet() *Set { return &Set{ - flags: make(map[string]Value), - shorthands: make(map[rune]Value), - envVars: make(map[string]string), + flags: make(map[string]Value, typicalFlagCount), + shorthands: make(map[rune]Value, typicalFlagCount), } } @@ -59,6 +62,10 @@ func AddToSet[T flag.Flaggable](set *Set, f *Flag[T]) error { set.flags[name] = f if f.envVar != "" { + if set.envVars == nil { + set.envVars = make(map[string]string, typicalFlagCount) + } + set.envVars[name] = f.envVar } @@ -167,8 +174,10 @@ func (s *Set) Parse(args []string) error { s.args = s.args[:0] s.extra = nil - if err = s.applyEnvVars(); err != nil { - return fmt.Errorf("could not set flag from env: %w", err) + if len(s.envVars) > 0 { + if err = s.applyEnvVars(); err != nil { + return fmt.Errorf("could not set flag from env: %w", err) + } } for len(args) > 0 { From f560c99b84b68854a94a4154e4e04134048e7be1 Mon Sep 17 00:00:00 2001 From: Tom Fleet Date: Thu, 23 Apr 2026 20:17:17 +0100 Subject: [PATCH 4/7] Eliminate a range loop in favour of utf8.DecodeRuneInString --- internal/flag/set.go | 106 +++++++++++++++++++++---------------------- 1 file changed, 52 insertions(+), 54 deletions(-) diff --git a/internal/flag/set.go b/internal/flag/set.go index 9fb8928..70a061c 100644 --- a/internal/flag/set.go +++ b/internal/flag/set.go @@ -8,6 +8,7 @@ import ( "os" "slices" "strings" + "unicode/utf8" "go.followtheprocess.codes/cli/flag" "go.followtheprocess.codes/cli/internal/format" @@ -365,71 +366,68 @@ func (s *Set) parseShortFlag(short string, rest []string) (remaining []string, e // parseSingleShortFlag parses a single short flag entry. func (s *Set) parseSingleShortFlag(shorthands string, rest []string) (string, []string, error) { - for _, char := range shorthands { - if err := validateFlagShort(char); err != nil { - return "", nil, fmt.Errorf("invalid flag shorthand %q: %w", string(char), err) - } - - flag, exists := s.shorthands[char] - if !exists { - return "", nil, fmt.Errorf("unrecognised shorthand flag: %q in -%s", string(char), shorthands) - } + char, _ := utf8.DecodeRuneInString(shorthands) - switch { - case len(shorthands) >= 2 && shorthands[1] == '=': - // '-f=value' (value may be empty, symmetric with '--flag=') - value := shorthands[2:] + if err := validateFlagShort(char); err != nil { + return "", nil, fmt.Errorf("invalid flag shorthand %q: %w", string(char), err) + } - err := flag.Set(value) - if err != nil { - return "", nil, err - } - // No more shorthands to parse as we got given a value - // Nothing to trim off the arguments as "-f=value" is all 1 arg - return "", rest, nil + flag, exists := s.shorthands[char] + if !exists { + return "", nil, fmt.Errorf("unrecognised shorthand flag: %q in -%s", string(char), shorthands) + } - case flag.NoArgValue() != "": - // -f with implied value e.g. boolean or count - err := flag.Set(flag.NoArgValue()) - if err != nil { - return "", nil, err - } + switch { + case len(shorthands) >= 2 && shorthands[1] == '=': + // '-f=value' (value may be empty, symmetric with '--flag=') + value := shorthands[2:] - // We've consumed a single short from the string so trim that off - return shorthands[1:], rest, nil + err := flag.Set(value) + if err != nil { + return "", nil, err + } + // No more shorthands to parse as we got given a value + // Nothing to trim off the arguments as "-f=value" is all 1 arg + return "", rest, nil - case len(shorthands) > 1: - // '-fvalue' - value := shorthands[1:] + case flag.NoArgValue() != "": + // -f with implied value e.g. boolean or count + err := flag.Set(flag.NoArgValue()) + if err != nil { + return "", nil, err + } - err := flag.Set(value) - if err != nil { - return "", nil, err - } + // We've consumed a single short from the string so trim that off + return shorthands[1:], rest, nil - // No more shorthands to parse as we got given a value - // Nothing to trim off as "-fvalue" is all 1 arg - return "", rest, nil + case len(shorthands) > 1: + // '-fvalue' + value := shorthands[1:] - case len(rest) > 0: - // '-f value' - value := rest[0] + err := flag.Set(value) + if err != nil { + return "", nil, err + } - err := flag.Set(value) - if err != nil { - return "", nil, err - } + // No more shorthands to parse as we got given a value + // Nothing to trim off as "-fvalue" is all 1 arg + return "", rest, nil - // We've consumed an argument from rest as it was the value to this flag - // as well as a shorthand - return shorthands[1:], rest[1:], nil + case len(rest) > 0: + // '-f value' + value := rest[0] - default: - // '-f' with required value - return "", nil, fmt.Errorf("flag %s needs an argument: %q in -%s", flag.Name(), string(char), shorthands) + err := flag.Set(value) + if err != nil { + return "", nil, err } - } - // Didn't match any of our rules, must be invalid short flag syntax - return "", nil, fmt.Errorf("invalid short flag syntax: %s", shorthands) + // We've consumed an argument from rest as it was the value to this flag + // as well as a shorthand + return shorthands[1:], rest[1:], nil + + default: + // '-f' with required value + return "", nil, fmt.Errorf("flag %s needs an argument: %q in -%s", flag.Name(), string(char), shorthands) + } } From 2df6de68254ed8581b389088c3b996bf58416bd2 Mon Sep 17 00:00:00 2001 From: Tom Fleet Date: Thu, 23 Apr 2026 20:21:40 +0100 Subject: [PATCH 5/7] Remove an unneeded slice allocation --- command.go | 63 +++++++++++++++++++++++------------------------------- 1 file changed, 27 insertions(+), 36 deletions(-) diff --git a/command.go b/command.go index ce45972..9104cbb 100644 --- a/command.go +++ b/command.go @@ -372,18 +372,15 @@ func (cmd *Command) hasShortFlag(name string) bool { // (if any) subcommand is being requested and return that command along with the arguments // that were meant for it. func findRequestedCommand(cmd *Command, args []string) (*Command, []string) { - // Any arguments without flags could be names of subcommands - argsWithoutFlags := stripFlags(cmd, args) - if len(argsWithoutFlags) == 0 { - // If there are no non-flag arguments, we must already be either at the root command + // The next non-flag argument (if any) is the first immediate subcommand + // e.g. in 'go mod tidy' we're looking for 'mod'. + nextSubCommand, ok := firstNonFlagArg(cmd, args) + if !ok { + // No non-flag arguments, so we must already be either at the root command // or the correct subcommand return cmd, args } - // The next non-flag argument will be the first immediate subcommand - // e.g. in 'go mod tidy', argsWithoutFlags[0] will be 'mod' - nextSubCommand := argsWithoutFlags[0] - // Lookup this immediate subcommand by name and if we find it, recursively call // this function so we eventually end up at the end of the command tree with // the right arguments @@ -430,43 +427,37 @@ func findSubCommand(cmd *Command, next string) *Command { return nil } -// stripFlags takes a slice of raw command line arguments (including possible flags) and removes -// any arguments that are flags or values passed in to flags e.g. --flag value. -func stripFlags(cmd *Command, args []string) []string { - if len(args) == 0 { - return args - } - - argsWithoutFlags := []string{} - - for len(args) > 0 { - arg := args[0] - args = args[1:] - +// firstNonFlagArg walks args and returns the first positional (non-flag) +// argument along with a boolean indicating whether one was found. +// +// It consumes flag-value pairs (e.g. '--flag value' or '-f value') so they +// aren't mistaken for positional arguments, and stops at '--'. +func firstNonFlagArg(cmd *Command, args []string) (arg string, ok bool) { + for i := 0; i < len(args); i++ { + a := args[i] switch { - case arg == "--": + case a == "--": // "--" terminates the flags - return argsWithoutFlags - case strings.HasPrefix(arg, "--") && !strings.Contains(arg, "=") && !cmd.hasFlag(arg[2:]): - // If '--flag arg' then delete arg from args - fallthrough // (do the same as below) - case strings.HasPrefix(arg, "-") && !strings.Contains(arg, "=") && len(arg) == 2 && !cmd.hasShortFlag(arg[1:]): - // If '-f arg' then delete 'arg' from args or break the loop if len(args) <= 1. - if len(args) <= 1 { - return argsWithoutFlags + return "", false + case strings.HasPrefix(a, "--") && !strings.Contains(a, "=") && !cmd.hasFlag(a[2:]): + // If '--flag value' then skip value + fallthrough + case strings.HasPrefix(a, "-") && !strings.Contains(a, "=") && len(a) == 2 && !cmd.hasShortFlag(a[1:]): + // '-f value' skip the value too. If there isn't one, we're done. + if i+1 >= len(args) { + return "", false } - args = args[1:] + i++ continue - - case arg != "" && !strings.HasPrefix(arg, "-"): - // We have a valid positional arg - argsWithoutFlags = append(argsWithoutFlags, arg) + case a != "" && !strings.HasPrefix(a, "-"): + // First valid positional arg + return a, true } } - return argsWithoutFlags + return "", false } // showHelp is the default for a command's helpFunc. From a75dac7dbc24004073223f878da0d03ab0cf7737 Mon Sep 17 00:00:00 2001 From: Tom Fleet Date: Thu, 23 Apr 2026 20:27:31 +0100 Subject: [PATCH 6/7] Simplify some type assertion stuff --- command.go | 10 +++- internal/flag/flag.go | 129 ++++++++---------------------------------- 2 files changed, 33 insertions(+), 106 deletions(-) diff --git a/command.go b/command.go index 9104cbb..c534d09 100644 --- a/command.go +++ b/command.go @@ -25,8 +25,14 @@ const ( defaultShort = "A placeholder for something cool" // defaultShort is the default value for cli.Short. ) -// Pre-styled section headers used in the help text. hue's Text allocates -// a new string each call, so we do the styling once at init time. +// Pre-styled section headers used in the help text. +// +// hue.Style.Text allocates a new string every call so we can't make these +// compile-time constants, but there's no reason to re-style fixed section +// headers on every help render either. Doing it once at package init drops a +// handful of allocs per showHelp. +// +//nolint:gochecknoglobals // Caching the styled titles. var ( usageTitle = style.Title.Text("Usage") optionsTitle = style.Title.Text("Options") diff --git a/internal/flag/flag.go b/internal/flag/flag.go index c5da402..8924f10 100644 --- a/internal/flag/flag.go +++ b/internal/flag/flag.go @@ -346,15 +346,6 @@ func (f *Flag[T]) Set(str string) error { return nil case flag.Count: - // We have to do a bit of custom stuff here as an increment is a read and write op - // First read the current value of the flag and cast it to a Count so we - // can increment it - current, ok := any(*f.value).(flag.Count) - if !ok { - // This basically shouldn't ever happen but it's easy enough to handle nicely - return errBadType(*f.value) - } - // Add the count and store it back, we still parse the given str rather // than just +1 every time as this allows people to do e.g. --verbosity=3 // as well as -vvv @@ -363,7 +354,7 @@ func (f *Flag[T]) Set(str string) error { return parse.Error(parse.KindFlag, f.name, str, typ, err) } - newValue := current + flag.Count(val) + newValue := typ + flag.Count(val) *f.value = *parse.Cast[T](&newValue) return nil @@ -500,182 +491,118 @@ func (f *Flag[T]) Set(str string) error { return nil case []int: // Like Count, a slice flag is a read/write op - slice, ok := any(*f.value).([]int) - if !ok { - return errBadType(*f.value) - } - - // Append the given value to the slice newValue, err := parse.Int(str) if err != nil { return parse.ErrorSlice(parse.KindFlag, f.name, str, typ, err) } - slice = append(slice, newValue) - *f.value = *parse.Cast[T](&slice) + typ = append(typ, newValue) + *f.value = *parse.Cast[T](&typ) return nil case []int8: - slice, ok := any(*f.value).([]int8) - if !ok { - return errBadType(*f.value) - } - newValue, err := parse.Int8(str) if err != nil { return parse.ErrorSlice(parse.KindFlag, f.name, str, typ, err) } - slice = append(slice, newValue) - *f.value = *parse.Cast[T](&slice) + typ = append(typ, newValue) + *f.value = *parse.Cast[T](&typ) return nil case []int16: - slice, ok := any(*f.value).([]int16) - if !ok { - return errBadType(*f.value) - } - newValue, err := parse.Int16(str) if err != nil { return parse.ErrorSlice(parse.KindFlag, f.name, str, typ, err) } - slice = append(slice, newValue) - *f.value = *parse.Cast[T](&slice) + typ = append(typ, newValue) + *f.value = *parse.Cast[T](&typ) return nil case []int32: - slice, ok := any(*f.value).([]int32) - if !ok { - return errBadType(*f.value) - } - newValue, err := parse.Int32(str) if err != nil { return parse.ErrorSlice(parse.KindFlag, f.name, str, typ, err) } - slice = append(slice, newValue) - *f.value = *parse.Cast[T](&slice) + typ = append(typ, newValue) + *f.value = *parse.Cast[T](&typ) return nil case []int64: - slice, ok := any(*f.value).([]int64) - if !ok { - return errBadType(*f.value) - } - newValue, err := parse.Int64(str) if err != nil { return parse.ErrorSlice(parse.KindFlag, f.name, str, typ, err) } - slice = append(slice, newValue) - *f.value = *parse.Cast[T](&slice) + typ = append(typ, newValue) + *f.value = *parse.Cast[T](&typ) return nil - case []uint: - slice, ok := any(*f.value).([]uint) - if !ok { - return errBadType(*f.value) - } - - // Append the given value to the slice newValue, err := parse.Uint(str) if err != nil { return parse.ErrorSlice(parse.KindFlag, f.name, str, typ, err) } - slice = append(slice, newValue) - *f.value = *parse.Cast[T](&slice) + typ = append(typ, newValue) + *f.value = *parse.Cast[T](&typ) return nil case []uint16: - slice, ok := any(*f.value).([]uint16) - if !ok { - return errBadType(*f.value) - } - newValue, err := parse.Uint16(str) if err != nil { return parse.ErrorSlice(parse.KindFlag, f.name, str, typ, err) } - slice = append(slice, newValue) - *f.value = *parse.Cast[T](&slice) + typ = append(typ, newValue) + *f.value = *parse.Cast[T](&typ) return nil case []uint32: - slice, ok := any(*f.value).([]uint32) - if !ok { - return errBadType(*f.value) - } - newValue, err := parse.Uint32(str) if err != nil { return parse.ErrorSlice(parse.KindFlag, f.name, str, typ, err) } - slice = append(slice, newValue) - *f.value = *parse.Cast[T](&slice) + typ = append(typ, newValue) + *f.value = *parse.Cast[T](&typ) return nil case []uint64: - slice, ok := any(*f.value).([]uint64) - if !ok { - return errBadType(*f.value) - } - newValue, err := parse.Uint64(str) if err != nil { return parse.ErrorSlice(parse.KindFlag, f.name, str, typ, err) } - slice = append(slice, newValue) - *f.value = *parse.Cast[T](&slice) + typ = append(typ, newValue) + *f.value = *parse.Cast[T](&typ) return nil case []float32: - slice, ok := any(*f.value).([]float32) - if !ok { - return errBadType(*f.value) - } - newValue, err := parse.Float32(str) if err != nil { return parse.ErrorSlice(parse.KindFlag, f.name, str, typ, err) } - slice = append(slice, newValue) - *f.value = *parse.Cast[T](&slice) + typ = append(typ, newValue) + *f.value = *parse.Cast[T](&typ) return nil case []float64: - slice, ok := any(*f.value).([]float64) - if !ok { - return errBadType(*f.value) - } - newValue, err := parse.Float64(str) if err != nil { return parse.ErrorSlice(parse.KindFlag, f.name, str, typ, err) } - slice = append(slice, newValue) - *f.value = *parse.Cast[T](&slice) + typ = append(typ, newValue) + *f.value = *parse.Cast[T](&typ) return nil case []string: - slice, ok := any(*f.value).([]string) - if !ok { - return errBadType(*f.value) - } - - // No parsing to do because a string is... well, a string - slice = append(slice, str) - *f.value = *parse.Cast[T](&slice) + typ = append(typ, str) + *f.value = *parse.Cast[T](&typ) return nil default: @@ -747,12 +674,6 @@ func validateFlagShort(short rune) error { return nil } -// errBadType makes a consistent error in the face of a bad type -// assertion. -func errBadType[T flag.Flaggable](value T) error { - return fmt.Errorf("bad value %v, could not cast to %T", value, value) -} - // isZeroIsh reports whether value is the zero value (ish) for it's type. // // "ish" means that empty slices will return true from isZeroIsh despite their official From 2ffceb875fd9561a7d6fe604efb6142f83a0b822 Mon Sep 17 00:00:00 2001 From: Tom Fleet Date: Thu, 23 Apr 2026 20:53:03 +0100 Subject: [PATCH 7/7] Reuse the tabwriter to avoid excess allocations --- command.go | 26 +++++++++++++++++--------- internal/style/style.go | 10 ++++++++++ 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/command.go b/command.go index c534d09..b921744 100644 --- a/command.go +++ b/command.go @@ -11,6 +11,8 @@ import ( "strings" "unicode/utf8" + "go.followtheprocess.codes/hue/tabwriter" + publicflag "go.followtheprocess.codes/cli/flag" "go.followtheprocess.codes/cli/internal/arg" @@ -481,6 +483,11 @@ func showHelp(cmd *Command) error { s := &strings.Builder{} s.Grow(helpBufferSize) + // One tabwriter threaded through every aligned section, reset + // between them with ResetTabwriter so only the first section pays the + // internal-buffer allocation cost. + tw := style.Tabwriter(s) + // If we have a short description, write that if cmd.short != "" { s.WriteString(cmd.short) @@ -517,7 +524,7 @@ func showHelp(cmd *Command) error { // If we have defined, list them explicitly and use their descriptions if len(cmd.args) != 0 { - if err := writeArgumentsSection(cmd, s); err != nil { + if err := writeArgumentsSection(cmd, s, tw); err != nil { return err } } @@ -529,7 +536,7 @@ func showHelp(cmd *Command) error { // Now show subcommands if len(cmd.subcommands) != 0 { - if err := writeSubcommands(cmd, s); err != nil { + if err := writeSubcommands(cmd, s, tw); err != nil { return err } } @@ -546,7 +553,7 @@ func showHelp(cmd *Command) error { s.WriteString(optionsTitle) s.WriteString(":\n\n") - if err := writeFlags(cmd, s); err != nil { + if err := writeFlags(cmd, s, tw); err != nil { return err } @@ -584,11 +591,11 @@ func writePositionalArgs(cmd *Command, s *strings.Builder) { // writeArgumentsSection writes the entire positional arguments block to the help // text string builder. -func writeArgumentsSection(cmd *Command, s *strings.Builder) error { +func writeArgumentsSection(cmd *Command, s *strings.Builder, tw *tabwriter.Writer) error { s.WriteString("\n\n") s.WriteString(argumentsTitle) s.WriteString(":\n\n") - tw := style.Tabwriter(s) + style.ResetTabwriter(tw, s) for _, arg := range cmd.args { if def := arg.Default(); def != "" { @@ -638,7 +645,7 @@ func writeExamples(cmd *Command, s *strings.Builder) { } // writeSubcommands writes the subcommand block to the help text string builder. -func writeSubcommands(cmd *Command, s *strings.Builder) error { +func writeSubcommands(cmd *Command, s *strings.Builder, tw *tabwriter.Writer) error { // If there were examples, the last one would have printed a newline if len(cmd.examples) != 0 { s.WriteByte('\n') @@ -650,7 +657,8 @@ func writeSubcommands(cmd *Command, s *strings.Builder) error { s.WriteByte(':') s.WriteString("\n\n") - tw := style.Tabwriter(s) + style.ResetTabwriter(tw, s) + for _, subcommand := range cmd.subcommands { fmt.Fprintf(tw, " %s\t%s\n", style.Bold.Text(subcommand.name), subcommand.short) } @@ -663,8 +671,8 @@ func writeSubcommands(cmd *Command, s *strings.Builder) error { } // writeFlags writes the flag usage block to the help text string builder. -func writeFlags(cmd *Command, s *strings.Builder) error { - tw := style.Tabwriter(s) +func writeFlags(cmd *Command, s *strings.Builder, tw *tabwriter.Writer) error { + style.ResetTabwriter(tw, s) for name, fl := range cmd.flags.Sorted() { var shorthand string diff --git a/internal/style/style.go b/internal/style/style.go index 0397324..78a516e 100644 --- a/internal/style/style.go +++ b/internal/style/style.go @@ -38,3 +38,13 @@ const ( func Tabwriter(w io.Writer) *tabwriter.Writer { return tabwriter.NewWriter(w, minWidth, tabWidth, padding, padChar, flags) } + +// ResetTabwriter re-initialises an existing [tabwriter.Writer] with the cli +// house style, pointing it at w. It returns the same writer for chaining. +// +// Init reuses the writer's internal buffers rather than allocating new ones, +// so threading a single writer through the help renderer avoids a fresh +// tabwriter allocation per section. +func ResetTabwriter(tw *tabwriter.Writer, w io.Writer) *tabwriter.Writer { + return tw.Init(w, minWidth, tabWidth, padding, padChar, flags) +}