Skip to content

Commit 66093c8

Browse files
authored
perf: remove allocations from getFirstRuneAsString (#578)
1 parent b146a47 commit 66093c8

File tree

2 files changed

+83
-3
lines changed

2 files changed

+83
-3
lines changed

borders.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package lipgloss
22

33
import (
44
"strings"
5+
"unicode/utf8"
56

67
"github.com/charmbracelet/x/ansi"
78
"github.com/muesli/termenv"
@@ -485,6 +486,6 @@ func getFirstRuneAsString(str string) string {
485486
if str == "" {
486487
return str
487488
}
488-
r := []rune(str)
489-
return string(r[0])
489+
_, size := utf8.DecodeRuneInString(str)
490+
return str[:size]
490491
}

borders_test.go

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package lipgloss
22

3-
import "testing"
3+
import (
4+
"testing"
5+
)
46

57
func TestStyle_GetBorderSizes(t *testing.T) {
68
tests := []struct {
@@ -94,3 +96,80 @@ func TestStyle_GetBorderSizes(t *testing.T) {
9496
})
9597
}
9698
}
99+
100+
// Old implementation using rune slice conversion
101+
func getFirstRuneAsStringOld(str string) string {
102+
if str == "" {
103+
return str
104+
}
105+
r := []rune(str)
106+
return string(r[0])
107+
}
108+
109+
func TestGetFirstRuneAsString(t *testing.T) {
110+
tests := []struct {
111+
name string
112+
input string
113+
want string
114+
}{
115+
{"Empty", "", ""},
116+
{"SingleASCII", "A", "A"},
117+
{"SingleUnicode", "世", "世"},
118+
{"ASCIIString", "Hello", "H"},
119+
{"UnicodeString", "你好世界", "你"},
120+
{"MixedASCIIFirst", "Hello世界", "H"},
121+
{"MixedUnicodeFirst", "世界Hello", "世"},
122+
{"Emoji", "😀Happy", "😀"},
123+
{"MultiByteFirst", "ñoño", "ñ"},
124+
{"LongString", "The quick brown fox jumps over the lazy dog", "T"},
125+
}
126+
127+
for _, tt := range tests {
128+
t.Run(tt.name, func(t *testing.T) {
129+
got := getFirstRuneAsString(tt.input)
130+
if got != tt.want {
131+
t.Errorf("getFirstRuneAsString(%q) = %q, want %q", tt.input, got, tt.want)
132+
}
133+
134+
// Verify new implementation matches old implementation
135+
old := getFirstRuneAsStringOld(tt.input)
136+
if got != old {
137+
t.Errorf("getFirstRuneAsString(%q) = %q, but old implementation returns %q", tt.input, got, old)
138+
}
139+
})
140+
}
141+
}
142+
143+
func BenchmarkGetFirstRuneAsString(b *testing.B) {
144+
testCases := []struct {
145+
name string
146+
str string
147+
}{
148+
{"ASCII", "Hello, World!"},
149+
{"Unicode", "你好世界"},
150+
{"Single", "A"},
151+
{"Empty", ""},
152+
}
153+
154+
b.Run("Old", func(b *testing.B) {
155+
for _, tc := range testCases {
156+
b.Run(tc.name, func(b *testing.B) {
157+
b.ReportAllocs()
158+
for i := 0; i < b.N; i++ {
159+
_ = getFirstRuneAsStringOld(tc.str)
160+
}
161+
})
162+
}
163+
})
164+
165+
b.Run("New", func(b *testing.B) {
166+
for _, tc := range testCases {
167+
b.Run(tc.name, func(b *testing.B) {
168+
b.ReportAllocs()
169+
for i := 0; i < b.N; i++ {
170+
_ = getFirstRuneAsString(tc.str)
171+
}
172+
})
173+
}
174+
})
175+
}

0 commit comments

Comments
 (0)