Skip to content

Commit bc5e616

Browse files
authored
Merge pull request #17 from arran4/perf-optimize-cmalltitle-10160609927452003911
⚡ Optimize CMAllTitle string allocations
2 parents 38332e8 + a5deb5e commit bc5e616

2 files changed

Lines changed: 145 additions & 4 deletions

File tree

types.go

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ type SeparatorWord string
3636

3737
// String implementations
3838
func (w SingleCaseWord) String() string { return strings.ToLower(string(w)) }
39-
func (w FirstUpperCaseWord) String() string { return UpperCaseFirst(strings.ToLower(string(w))) }
39+
func (w FirstUpperCaseWord) String() string { return upperCaseFirstLower(string(w)) }
4040
func (w AcronymWord) String() string { return string(w) }
4141
func (w UpperCaseWord) String() string { return strings.ToUpper(string(w)) }
4242
func (w SeparatorWord) String() string { return string(w) }
@@ -118,6 +118,46 @@ func MustLowerCaseFirst(s string) string {
118118
return res
119119
}
120120

121+
// upperCaseFirstLower capitalizes the first character and lowercases the rest.
122+
func upperCaseFirstLower(s string) string {
123+
if s == "" {
124+
return ""
125+
}
126+
r, size := utf8.DecodeRuneInString(s)
127+
if r == utf8.RuneError && size == 1 {
128+
// Invalid UTF-8 start byte.
129+
// We want to replace it with RuneError (like strings.ToLower/ToUpper do).
130+
// So we force needChange.
131+
} else if r == utf8.RuneError {
132+
// Valid RuneError (U+FFFD)
133+
}
134+
135+
u := unicode.ToUpper(r)
136+
137+
// Check if changes are needed
138+
needChange := (r != u) || (r == utf8.RuneError && size == 1)
139+
if !needChange {
140+
for _, rc := range s[size:] {
141+
if unicode.ToLower(rc) != rc {
142+
needChange = true
143+
break
144+
}
145+
}
146+
}
147+
148+
if !needChange {
149+
return s
150+
}
151+
152+
var b strings.Builder
153+
b.Grow(len(s))
154+
b.WriteRune(u)
155+
for _, rc := range s[size:] {
156+
b.WriteRune(unicode.ToLower(rc))
157+
}
158+
return b.String()
159+
}
160+
121161
func (w ExactCaseWord) String() string { return string(w) }
122162

123163
// Options
@@ -231,7 +271,7 @@ func WordsToFormattedCase(words []Word, opts ...any) (string, error) {
231271
} else if cfg.allLower || cfg.whispering {
232272
w = strings.ToLower(w)
233273
} else if cfg.caseMode == CMAllTitle {
234-
w = UpperCaseFirst(strings.ToLower(w))
274+
w = upperCaseFirstLower(w)
235275
} else {
236276
w = strings.ToLower(w)
237277
}
@@ -262,7 +302,7 @@ func WordsToFormattedCase(words []Word, opts ...any) (string, error) {
262302
} else if cfg.whispering {
263303
w = strings.ToLower(w)
264304
} else if cfg.caseMode == CMAllTitle {
265-
w = UpperCaseFirst(strings.ToLower(w))
305+
w = upperCaseFirstLower(w)
266306
}
267307
case UpperCaseWord:
268308
w = word.String()
@@ -271,7 +311,7 @@ func WordsToFormattedCase(words []Word, opts ...any) (string, error) {
271311
} else if cfg.allLower || cfg.whispering {
272312
w = strings.ToLower(w)
273313
} else if cfg.caseMode == CMAllTitle {
274-
w = UpperCaseFirst(strings.ToLower(w))
314+
w = upperCaseFirstLower(w)
275315
} else {
276316
w = strings.ToLower(w)
277317
}

types_internal_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package strings2
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestUpperCaseFirstLower_Correctness(t *testing.T) {
8+
tests := []struct {
9+
name string
10+
input string
11+
expected string
12+
}{
13+
{
14+
name: "Empty String",
15+
input: "",
16+
expected: "",
17+
},
18+
{
19+
name: "ASCII Lower",
20+
input: "test",
21+
expected: "Test",
22+
},
23+
{
24+
name: "ASCII Mixed",
25+
input: "tEsT",
26+
expected: "Test",
27+
},
28+
{
29+
name: "ASCII Upper",
30+
input: "TEST",
31+
expected: "Test",
32+
},
33+
{
34+
name: "Already Correct",
35+
input: "Test",
36+
expected: "Test",
37+
},
38+
{
39+
name: "Unicode Lower",
40+
input: "äpfel",
41+
expected: "Äpfel",
42+
},
43+
{
44+
name: "Unicode Upper",
45+
input: "ÄPFEL",
46+
expected: "Äpfel",
47+
},
48+
{
49+
name: "Unicode Mixed",
50+
input: "äPfEl",
51+
expected: "Äpfel",
52+
},
53+
{
54+
name: "Special Char Start",
55+
input: "!test",
56+
expected: "!test",
57+
},
58+
{
59+
name: "Number Start",
60+
input: "1test",
61+
expected: "1test",
62+
},
63+
{
64+
name: "Invalid UTF-8",
65+
input: "\xff\xfe\xfd",
66+
expected: "\uFFFD\uFFFD\uFFFD",
67+
},
68+
{
69+
name: "Partial Invalid UTF-8",
70+
input: "test\xff",
71+
expected: "Test\uFFFD",
72+
},
73+
}
74+
75+
for _, tt := range tests {
76+
t.Run(tt.name, func(t *testing.T) {
77+
got := upperCaseFirstLower(tt.input)
78+
if got != tt.expected {
79+
t.Errorf("upperCaseFirstLower(%q) = %q, want %q", tt.input, got, tt.expected)
80+
}
81+
})
82+
}
83+
}
84+
85+
func TestUpperCaseFirstLower_Allocations(t *testing.T) {
86+
// Tests that no allocation occurs if the string is already correct
87+
input := "Test"
88+
if testing.AllocsPerRun(10, func() {
89+
upperCaseFirstLower(input)
90+
}) > 0 {
91+
t.Errorf("upperCaseFirstLower(%q) allocated memory when no change was needed", input)
92+
}
93+
94+
// Test that allocation occurs when change IS needed
95+
input2 := "test"
96+
if testing.AllocsPerRun(10, func() {
97+
upperCaseFirstLower(input2)
98+
}) == 0 {
99+
t.Errorf("upperCaseFirstLower(%q) did not allocate memory when change was needed", input2)
100+
}
101+
}

0 commit comments

Comments
 (0)