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.

Dispatch to specific `strconv` formatters rather than `%v` for printing flag/arg types (#215)

* No longer use '%v' formatting, dispatch to faster, more specific strconv formatters

* Use the strings.Builder unsafe trick

* Include test coverage gaps

authored by

Tom Fleet and committed by
GitHub
(Apr 30, 2026, 12:44 PM +0100) a6b64494 061d31b7

+322 -58
+1 -1
README.md
··· 191 191 192 192 > [!NOTE] 193 193 > `cli.Env` requires an explicit type parameter because Go cannot infer it from the string argument alone. 194 - > The compiler enforces that the type matches the flag — `cli.Env[string](...)` on a `bool` flag is a compile error. 194 + > The compiler enforces that the type matches the flag, `cli.Env[string](...)` on a `bool` flag is a compile error. 195 195 196 196 When `MYTOOL_FORCE=true` is set in the environment, `--force` is implied. Passing `--force=false` on the command line always wins. 197 197
+3 -13
mise.toml
··· 41 41 [tasks.lint] 42 42 description = "Run the linters and auto-fix if possible" 43 43 depends = ["fmt"] 44 - run = [ 45 - "golangci-lint run --fix", 46 - "typos", 47 - "nilaway ./...", 48 - ] 44 + run = ["golangci-lint run --fix", "typos", "nilaway ./..."] 49 45 sources = ["**/*.go", ".golangci.yml"] 50 46 51 47 [tasks.docs] ··· 86 82 87 83 [tasks.clean] 88 84 description = "Remove build artifacts and other clutter" 89 - run = [ 90 - "go clean ./...", 91 - "rm -rf *.out", 92 - ] 85 + run = ["go clean ./...", "rm -rf *.out"] 93 86 94 87 [tasks.update] 95 88 description = "Updates dependencies in go.mod and go.sum" 96 - run = [ 97 - "go get -u ./...", 98 - "go mod tidy", 99 - ] 89 + run = ["go get -u ./...", "go mod tidy"]
+1 -1
internal/flag/flag.go
··· 682 682 // 683 683 // "ish" means that empty slices will return true despite their official zero 684 684 // value being nil. The primary use is to determine whether a default value is 685 - // worth displaying to the user in the help text — an empty slice is probably 685 + // worth displaying to the user in the help text, an empty slice is probably 686 686 // not. 687 687 // 688 688 //nolint:cyclop // Not much else we can do here
+2 -5
internal/flag/set_test.go
··· 984 984 test: func(t *testing.T, set *flag.Set) { 985 985 f, exists := set.Get("verbosity") 986 986 test.True(t, exists) 987 - // Env var contributes 2, CLI contributes 1 more — total 3 987 + // Env var contributes 2, CLI contributes 1 more, total 3 988 988 test.Equal(t, f.String(), "3") 989 989 }, 990 990 args: []string{"--verbosity"}, ··· 1633 1633 { 1634 1634 name: "slice env var splits on every comma (no escape mechanism)", 1635 1635 newSet: func(t *testing.T) *flag.Set { 1636 - // Any comma in an env var value is interpreted as a separator — 1637 - // there is no way to embed a literal comma in a slice item. 1638 - // Users needing commas should pass values via --flag one,two. 1639 1636 t.Setenv("MYTOOL_ITEMS", "a,b,c") 1640 1637 1641 1638 var val []string ··· 1845 1842 { 1846 1843 name: "combined short flags where early flag needs value captures rest", 1847 1844 newSet: func(t *testing.T) *flag.Set { 1848 - // -fgh — f is non-bool so consumes the rest of the cluster as its value 1845 + // -fgh. f is non-bool so consumes the rest of the cluster as its value 1849 1846 // i.e. f gets "gh", and g/h are not parsed as flags. 1850 1847 set := flag.NewSet() 1851 1848
+1 -1
internal/flag/value.go
··· 33 33 34 34 // IsSlice reports whether the flag holds a slice value that accumulates 35 35 // repeated calls to Set (e.g. []string, []int). Note that []byte and net.IP 36 - // are NOT slice flags in this sense — they are parsed atomically. 36 + // are NOT slice flags in this sense, they are parsed atomically. 37 37 IsSlice() bool 38 38 39 39 // Set sets the stored value of a flag by parsing the string "str".
+140 -25
internal/format/format.go
··· 4 4 package format 5 5 6 6 import ( 7 - "fmt" 8 - "reflect" 9 7 "strconv" 10 - "strings" 8 + "unsafe" 11 9 12 10 "go.followtheprocess.codes/cli/internal/constraints" 13 11 ) ··· 17 15 floatFmt = 'g' 18 16 floatPrecision = -1 19 17 slice = "[]" 18 + 19 + // Capacity hints used to pre-size []byte buffers in the slice formatters. 20 + // The "brackets" pair is the leading '[' and trailing ']'. 21 + // 22 + // The per-element hints are good enough default cases to minimise the 23 + // buffer growing and re-allocating. 24 + bracketsCap = 2 25 + intElemHint = 4 // "-12, " 26 + floatElemHint = 8 // "-1.234, " 27 + boolElemHint = 7 // "false, " 28 + stringElemHint = 4 // surrounding quotes plus ", " 20 29 ) 21 30 22 31 const ( ··· 99 108 // Slice([]int{1, 2, 3, 4}) // "[1, 2, 3, 4]" 100 109 // Slice([]string{"one", "two", "three"}) // `["one", "two", "three"]` 101 110 func Slice[T any](s []T) string { 102 - length := len(s) 103 - 104 - if length == 0 { 105 - // If it's empty or nil, avoid doing the work below 106 - // and just return "[]" 111 + if len(s) == 0 { 107 112 return slice 108 113 } 109 114 110 - builder := &strings.Builder{} 111 - builder.WriteByte('[') 115 + switch v := any(s).(type) { 116 + case []string: 117 + return formatStringSlice(v) 118 + case []bool: 119 + return formatBoolSlice(v) 120 + case []int: 121 + return formatSignedSlice(v) 122 + case []int8: 123 + return formatSignedSlice(v) 124 + case []int16: 125 + return formatSignedSlice(v) 126 + case []int32: 127 + return formatSignedSlice(v) 128 + case []int64: 129 + return formatSignedSlice(v) 130 + case []uint: 131 + return formatUnsignedSlice(v) 132 + case []uint16: 133 + return formatUnsignedSlice(v) 134 + case []uint32: 135 + return formatUnsignedSlice(v) 136 + case []uint64: 137 + return formatUnsignedSlice(v) 138 + case []float32: 139 + return formatFloat32Slice(v) 140 + case []float64: 141 + return formatFloat64Slice(v) 142 + default: 143 + return slice 144 + } 145 + } 112 146 113 - typ := reflect.TypeFor[T]().Kind() 147 + // toString casts b to a string by reinterpreting the bytes. 148 + // 149 + // This is the same trick [strings.Builder.String] uses to avoid the 150 + // allocation of doing `string(b)`. The caveat is b MUST not be mutated 151 + // after passing to this function. 152 + // 153 + // This is fine in our case here as the []byte buffer is created in the function body 154 + // and never escapes. 155 + func toString(b []byte) string { 156 + return unsafe.String(unsafe.SliceData(b), len(b)) 157 + } 114 158 115 - first := fmt.Sprintf("%v", s[0]) 116 - if typ == reflect.String { 117 - first = strconv.Quote(first) 159 + func formatSignedSlice[T constraints.Signed](s []T) string { 160 + buf := make([]byte, 0, bracketsCap+len(s)*intElemHint) 161 + buf = append(buf, '[') 162 + buf = strconv.AppendInt(buf, int64(s[0]), base10) 163 + 164 + for _, e := range s[1:] { 165 + buf = append(buf, ", "...) 166 + buf = strconv.AppendInt(buf, int64(e), base10) 118 167 } 119 168 120 - builder.WriteString(first) 169 + buf = append(buf, ']') 121 170 122 - for _, element := range s[1:] { 123 - builder.WriteString(", ") 171 + return toString(buf) 172 + } 124 173 125 - str := fmt.Sprintf("%v", element) 126 - if typ == reflect.String { 127 - // If it's a string, quote it 128 - str = strconv.Quote(str) 129 - } 174 + func formatUnsignedSlice[T constraints.Unsigned](s []T) string { 175 + buf := make([]byte, 0, bracketsCap+len(s)*intElemHint) 176 + buf = append(buf, '[') 177 + buf = strconv.AppendUint(buf, uint64(s[0]), base10) 130 178 131 - builder.WriteString(str) 179 + for _, e := range s[1:] { 180 + buf = append(buf, ", "...) 181 + buf = strconv.AppendUint(buf, uint64(e), base10) 132 182 } 133 183 134 - builder.WriteByte(']') 184 + buf = append(buf, ']') 135 185 136 - return builder.String() 186 + return toString(buf) 187 + } 188 + 189 + func formatFloat32Slice(s []float32) string { 190 + buf := make([]byte, 0, bracketsCap+len(s)*floatElemHint) 191 + buf = append(buf, '[') 192 + buf = strconv.AppendFloat(buf, float64(s[0]), floatFmt, floatPrecision, bits32) 193 + 194 + for _, e := range s[1:] { 195 + buf = append(buf, ", "...) 196 + buf = strconv.AppendFloat(buf, float64(e), floatFmt, floatPrecision, bits32) 197 + } 198 + 199 + buf = append(buf, ']') 200 + 201 + return toString(buf) 202 + } 203 + 204 + func formatFloat64Slice(s []float64) string { 205 + buf := make([]byte, 0, bracketsCap+len(s)*floatElemHint) 206 + buf = append(buf, '[') 207 + buf = strconv.AppendFloat(buf, s[0], floatFmt, floatPrecision, bits64) 208 + 209 + for _, e := range s[1:] { 210 + buf = append(buf, ", "...) 211 + buf = strconv.AppendFloat(buf, e, floatFmt, floatPrecision, bits64) 212 + } 213 + 214 + buf = append(buf, ']') 215 + 216 + return toString(buf) 217 + } 218 + 219 + func formatStringSlice(s []string) string { 220 + capacity := bracketsCap 221 + for _, e := range s { 222 + capacity += len(e) + stringElemHint 223 + } 224 + 225 + buf := make([]byte, 0, capacity) 226 + buf = append(buf, '[') 227 + buf = strconv.AppendQuote(buf, s[0]) 228 + 229 + for _, e := range s[1:] { 230 + buf = append(buf, ", "...) 231 + buf = strconv.AppendQuote(buf, e) 232 + } 233 + 234 + buf = append(buf, ']') 235 + 236 + return toString(buf) 237 + } 238 + 239 + func formatBoolSlice(s []bool) string { 240 + buf := make([]byte, 0, bracketsCap+len(s)*boolElemHint) 241 + buf = append(buf, '[') 242 + buf = strconv.AppendBool(buf, s[0]) 243 + 244 + for _, e := range s[1:] { 245 + buf = append(buf, ", "...) 246 + buf = strconv.AppendBool(buf, e) 247 + } 248 + 249 + buf = append(buf, ']') 250 + 251 + return toString(buf) 137 252 }
+174 -12
internal/format/format_test.go
··· 63 63 } 64 64 65 65 func TestSlice(t *testing.T) { 66 - oneString := []string{"one"} 67 - twoStrings := []string{"one", "two"} 68 - strings := []string{"one", "two", "three"} 69 - ints := []int{1, 2, 3} 70 - floats := []float64{1.0, 2.0, 3.0} 71 - bools := []bool{true, true, false} 66 + tests := []struct { 67 + got func() string 68 + name string 69 + want string 70 + }{ 71 + { 72 + name: "nil string slice", 73 + got: func() string { return Slice([]string(nil)) }, 74 + want: "[]", 75 + }, 76 + { 77 + name: "empty int slice", 78 + got: func() string { return Slice([]int{}) }, 79 + want: "[]", 80 + }, 81 + { 82 + name: "one string", 83 + got: func() string { return Slice([]string{"one"}) }, 84 + want: `["one"]`, 85 + }, 86 + { 87 + name: "two strings", 88 + got: func() string { return Slice([]string{"one", "two"}) }, 89 + want: `["one", "two"]`, 90 + }, 91 + { 92 + name: "three strings", 93 + got: func() string { return Slice([]string{"one", "two", "three"}) }, 94 + want: `["one", "two", "three"]`, 95 + }, 96 + { 97 + name: "strings with escapes", 98 + got: func() string { return Slice([]string{"hi\nthere", "tab\there", `quote"here`}) }, 99 + want: `["hi\nthere", "tab\there", "quote\"here"]`, 100 + }, 101 + { 102 + name: "empty string element", 103 + got: func() string { return Slice([]string{""}) }, 104 + want: `[""]`, 105 + }, 106 + { 107 + name: "ints", 108 + got: func() string { return Slice([]int{1, 2, 3}) }, 109 + want: "[1, 2, 3]", 110 + }, 111 + { 112 + name: "negative ints", 113 + got: func() string { return Slice([]int{-1, 0, 1}) }, 114 + want: "[-1, 0, 1]", 115 + }, 116 + { 117 + name: "int8s", 118 + got: func() string { return Slice([]int8{-128, 0, 127}) }, 119 + want: "[-128, 0, 127]", 120 + }, 121 + { 122 + name: "int16s", 123 + got: func() string { return Slice([]int16{-32768, 0, 32767}) }, 124 + want: "[-32768, 0, 32767]", 125 + }, 126 + { 127 + name: "int32s", 128 + got: func() string { return Slice([]int32{-2147483648, 0, 2147483647}) }, 129 + want: "[-2147483648, 0, 2147483647]", 130 + }, 131 + { 132 + name: "int64s", 133 + got: func() string { return Slice([]int64{-1 << 62, 0, 1 << 62}) }, 134 + want: "[-4611686018427387904, 0, 4611686018427387904]", 135 + }, 136 + { 137 + name: "uints", 138 + got: func() string { return Slice([]uint{1, 2, 3}) }, 139 + want: "[1, 2, 3]", 140 + }, 141 + { 142 + name: "uint16s", 143 + got: func() string { return Slice([]uint16{0, 1, 65535}) }, 144 + want: "[0, 1, 65535]", 145 + }, 146 + { 147 + name: "uint32s", 148 + got: func() string { return Slice([]uint32{0, 1, 4294967295}) }, 149 + want: "[0, 1, 4294967295]", 150 + }, 151 + { 152 + name: "uint64s", 153 + got: func() string { return Slice([]uint64{0, 1, 1 << 63}) }, 154 + want: "[0, 1, 9223372036854775808]", 155 + }, 156 + { 157 + name: "floats", 158 + got: func() string { return Slice([]float64{1.0, 2.0, 3.0}) }, 159 + want: "[1, 2, 3]", 160 + }, 161 + { 162 + name: "floats with decimals", 163 + got: func() string { return Slice([]float64{1.5, -2.25, 3.125}) }, 164 + want: "[1.5, -2.25, 3.125]", 165 + }, 166 + { 167 + name: "float32s", 168 + got: func() string { return Slice([]float32{1.5, -2.25, 3.125}) }, 169 + want: "[1.5, -2.25, 3.125]", 170 + }, 171 + { 172 + name: "bools", 173 + got: func() string { return Slice([]bool{true, true, false}) }, 174 + want: "[true, true, false]", 175 + }, 176 + { 177 + name: "unsupported type falls through to empty", 178 + got: func() string { return Slice([]uintptr{1, 2, 3}) }, 179 + want: "[]", 180 + }, 181 + } 72 182 73 - test.Equal(t, Slice(oneString), `["one"]`) 74 - test.Equal(t, Slice(twoStrings), `["one", "two"]`) 75 - test.Equal(t, Slice(strings), `["one", "two", "three"]`, test.Context("strings")) 76 - test.Equal(t, Slice(ints), "[1, 2, 3]", test.Context("ints")) 77 - test.Equal(t, Slice(floats), "[1, 2, 3]", test.Context("floats")) 78 - test.Equal(t, Slice(bools), "[true, true, false]", test.Context("bools")) 183 + for _, tt := range tests { 184 + t.Run(tt.name, func(t *testing.T) { 185 + test.Equal(t, tt.got(), tt.want) 186 + }) 187 + } 188 + } 189 + 190 + func BenchmarkSlice(b *testing.B) { 191 + ints := []int{1, 2, 3, 4, 5, 6, 7, 8} 192 + int64s := []int64{1, 2, 3, 4, 5, 6, 7, 8} 193 + uints := []uint{1, 2, 3, 4, 5, 6, 7, 8} 194 + floats := []float64{1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5} 195 + strs := []string{"alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta"} 196 + bools := []bool{true, false, true, false, true, false, true, false} 197 + 198 + b.Run("ints", func(b *testing.B) { 199 + for b.Loop() { 200 + _ = Slice(ints) 201 + } 202 + }) 203 + 204 + b.Run("int64s", func(b *testing.B) { 205 + for b.Loop() { 206 + _ = Slice(int64s) 207 + } 208 + }) 209 + 210 + b.Run("uints", func(b *testing.B) { 211 + for b.Loop() { 212 + _ = Slice(uints) 213 + } 214 + }) 215 + 216 + b.Run("floats", func(b *testing.B) { 217 + for b.Loop() { 218 + _ = Slice(floats) 219 + } 220 + }) 221 + 222 + b.Run("strings", func(b *testing.B) { 223 + for b.Loop() { 224 + _ = Slice(strs) 225 + } 226 + }) 227 + 228 + b.Run("bools", func(b *testing.B) { 229 + for b.Loop() { 230 + _ = Slice(bools) 231 + } 232 + }) 233 + 234 + b.Run("empty", func(b *testing.B) { 235 + var s []int 236 + 237 + for b.Loop() { 238 + _ = Slice(s) 239 + } 240 + }) 79 241 }