Skip to content

Commit 797a802

Browse files
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
1 parent cdb5dbf commit 797a802

7 files changed

Lines changed: 322 additions & 58 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ cli.Flag(&force, "force", 'f', "Force deletion", cli.Env[bool]("MYTOOL_FORCE"))
191191

192192
> [!NOTE]
193193
> `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.
195195
196196
When `MYTOOL_FORCE=true` is set in the environment, `--force` is implied. Passing `--force=false` on the command line always wins.
197197

internal/flag/flag.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -682,7 +682,7 @@ func validateFlagShort(short rune) error {
682682
//
683683
// "ish" means that empty slices will return true despite their official zero
684684
// 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
686686
// not.
687687
//
688688
//nolint:cyclop // Not much else we can do here

internal/flag/set_test.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -984,7 +984,7 @@ func TestParse(t *testing.T) {
984984
test: func(t *testing.T, set *flag.Set) {
985985
f, exists := set.Get("verbosity")
986986
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
988988
test.Equal(t, f.String(), "3")
989989
},
990990
args: []string{"--verbosity"},
@@ -1633,9 +1633,6 @@ func TestParse(t *testing.T) {
16331633
{
16341634
name: "slice env var splits on every comma (no escape mechanism)",
16351635
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.
16391636
t.Setenv("MYTOOL_ITEMS", "a,b,c")
16401637

16411638
var val []string
@@ -1845,7 +1842,7 @@ func TestParse(t *testing.T) {
18451842
{
18461843
name: "combined short flags where early flag needs value captures rest",
18471844
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
18491846
// i.e. f gets "gh", and g/h are not parsed as flags.
18501847
set := flag.NewSet()
18511848

internal/flag/value.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ type Value interface {
3333

3434
// IsSlice reports whether the flag holds a slice value that accumulates
3535
// 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.
3737
IsSlice() bool
3838

3939
// Set sets the stored value of a flag by parsing the string "str".

internal/format/format.go

Lines changed: 139 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,8 @@
44
package format
55

66
import (
7-
"fmt"
8-
"reflect"
97
"strconv"
10-
"strings"
8+
"unsafe"
119

1210
"go.followtheprocess.codes/cli/internal/constraints"
1311
)
@@ -17,6 +15,17 @@ const (
1715
floatFmt = 'g'
1816
floatPrecision = -1
1917
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 ", "
2029
)
2130

2231
const (
@@ -99,39 +108,145 @@ func Float64(f float64) string {
99108
// Slice([]int{1, 2, 3, 4}) // "[1, 2, 3, 4]"
100109
// Slice([]string{"one", "two", "three"}) // `["one", "two", "three"]`
101110
func Slice[T any](s []T) string {
102-
length := len(s)
111+
if len(s) == 0 {
112+
return slice
113+
}
103114

104-
if length == 0 {
105-
// If it's empty or nil, avoid doing the work below
106-
// and just return "[]"
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:
107143
return slice
108144
}
145+
}
146+
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+
}
158+
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)
167+
}
168+
169+
buf = append(buf, ']')
170+
171+
return toString(buf)
172+
}
173+
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)
178+
179+
for _, e := range s[1:] {
180+
buf = append(buf, ", "...)
181+
buf = strconv.AppendUint(buf, uint64(e), base10)
182+
}
109183

110-
builder := &strings.Builder{}
111-
builder.WriteByte('[')
184+
buf = append(buf, ']')
112185

113-
typ := reflect.TypeFor[T]().Kind()
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)
114193

115-
first := fmt.Sprintf("%v", s[0])
116-
if typ == reflect.String {
117-
first = strconv.Quote(first)
194+
for _, e := range s[1:] {
195+
buf = append(buf, ", "...)
196+
buf = strconv.AppendFloat(buf, float64(e), floatFmt, floatPrecision, bits32)
118197
}
119198

120-
builder.WriteString(first)
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)
121208

122-
for _, element := range s[1:] {
123-
builder.WriteString(", ")
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+
}
124238

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-
}
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])
130243

131-
builder.WriteString(str)
244+
for _, e := range s[1:] {
245+
buf = append(buf, ", "...)
246+
buf = strconv.AppendBool(buf, e)
132247
}
133248

134-
builder.WriteByte(']')
249+
buf = append(buf, ']')
135250

136-
return builder.String()
251+
return toString(buf)
137252
}

0 commit comments

Comments
 (0)