Skip to content

Commit 0a43bb8

Browse files
committed
Optimize RuneWidth and StringWidth performance
- Merge combining+nonprint into a single zerowidth table at init, reducing two binary searches to one for zero-width rune detection - Merge ambiguous+doublewidth into widewidth table for EastAsian path - Remove redundant narrow table check in non-EastAsian path - Eliminate inTables variadic function overhead by using direct calls - Simplify EastAsian StrictEmojiNeutral dead code path - Add ASCII fast path in StringWidth to bypass grapheme segmenter - Use strings.Builder in Wrap to avoid O(n²) string concatenation Benchstat (8 samples): RuneWidthAll/regular -34.49% RuneWidthAllEastAsian/regular -44.79% String1WidthAll/regular -25.53% geomean -17.69%
1 parent 41dc6c5 commit 0a43bb8

1 file changed

Lines changed: 69 additions & 22 deletions

File tree

runewidth.go

Lines changed: 69 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,48 @@ var (
2424
}
2525
)
2626

27+
var (
28+
zerowidth table // combining + nonprint merged for faster zero-width lookup
29+
widewidth table // ambiguous + doublewidth merged for EA path
30+
)
31+
2732
func init() {
33+
zerowidth = mergeIntervals(combining, nonprint)
34+
widewidth = mergeIntervals(ambiguous, doublewidth)
2835
handleEnv()
2936
}
3037

38+
func mergeIntervals(t1, t2 table) table {
39+
merged := make(table, 0, len(t1)+len(t2))
40+
i, j := 0, 0
41+
for i < len(t1) && j < len(t2) {
42+
if t1[i].first <= t2[j].first {
43+
merged = append(merged, t1[i])
44+
i++
45+
} else {
46+
merged = append(merged, t2[j])
47+
j++
48+
}
49+
}
50+
merged = append(merged, t1[i:]...)
51+
merged = append(merged, t2[j:]...)
52+
if len(merged) == 0 {
53+
return merged
54+
}
55+
result := merged[:1]
56+
for _, iv := range merged[1:] {
57+
last := &result[len(result)-1]
58+
if iv.first <= last.last+1 {
59+
if iv.last > last.last {
60+
last.last = iv.last
61+
}
62+
} else {
63+
result = append(result, iv)
64+
}
65+
}
66+
return result
67+
}
68+
3169
func handleEnv() {
3270
env := os.Getenv("RUNEWIDTH_EASTASIAN")
3371
if env == "" {
@@ -52,15 +90,6 @@ type interval struct {
5290

5391
type table []interval
5492

55-
func inTables(r rune, ts ...table) bool {
56-
for _, t := range ts {
57-
if inTable(r, t) {
58-
return true
59-
}
60-
}
61-
return false
62-
}
63-
6493
func inTable(r rune, t table) bool {
6594
if r < t[0].first {
6695
return false
@@ -131,9 +160,7 @@ func (c *Condition) RuneWidth(r rune) int {
131160
return 0
132161
case r < 0x300:
133162
return 1
134-
case inTable(r, narrow):
135-
return 1
136-
case inTables(r, nonprint, combining):
163+
case inTable(r, zerowidth):
137164
return 0
138165
case inTable(r, doublewidth):
139166
return 2
@@ -142,13 +169,13 @@ func (c *Condition) RuneWidth(r rune) int {
142169
}
143170
} else {
144171
switch {
145-
case inTables(r, nonprint, combining):
172+
case inTable(r, zerowidth):
146173
return 0
147174
case inTable(r, narrow):
148175
return 1
149-
case inTables(r, ambiguous, doublewidth):
176+
case inTable(r, widewidth):
150177
return 2
151-
case !c.StrictEmojiNeutral && inTables(r, ambiguous, emoji, narrow):
178+
case !c.StrictEmojiNeutral && inTable(r, emoji):
152179
return 2
153180
default:
154181
return 1
@@ -185,6 +212,16 @@ func (c *Condition) StringWidth(s string) (width int) {
185212
return c.RuneWidth(r)
186213
}
187214
}
215+
// ASCII fast path: no grapheme clustering needed for pure ASCII
216+
if isAllASCII(s) {
217+
for i := 0; i < len(s); i++ {
218+
b := s[i]
219+
if b >= 0x20 && b != 0x7F {
220+
width++
221+
}
222+
}
223+
return
224+
}
188225
g := graphemes.FromString(s)
189226
for g.Next() {
190227
var chWidth int
@@ -199,6 +236,15 @@ func (c *Condition) StringWidth(s string) (width int) {
199236
return
200237
}
201238

239+
func isAllASCII(s string) bool {
240+
for i := 0; i < len(s); i++ {
241+
if s[i] >= 0x80 {
242+
return false
243+
}
244+
}
245+
return true
246+
}
247+
202248
// Truncate return string truncated with w cells
203249
func (c *Condition) Truncate(s string, w int, tail string) string {
204250
if c.StringWidth(s) <= w {
@@ -264,24 +310,25 @@ func (c *Condition) TruncateLeft(s string, w int, prefix string) string {
264310
// Wrap return string wrapped with w cells
265311
func (c *Condition) Wrap(s string, w int) string {
266312
width := 0
267-
out := ""
313+
var out strings.Builder
314+
out.Grow(len(s) + len(s)/w + 1)
268315
for _, r := range s {
269316
cw := c.RuneWidth(r)
270317
if r == '\n' {
271-
out += string(r)
318+
out.WriteRune(r)
272319
width = 0
273320
continue
274321
} else if width+cw > w {
275-
out += "\n"
322+
out.WriteByte('\n')
276323
width = 0
277-
out += string(r)
324+
out.WriteRune(r)
278325
width += cw
279326
continue
280327
}
281-
out += string(r)
328+
out.WriteRune(r)
282329
width += cw
283330
}
284-
return out
331+
return out.String()
285332
}
286333

287334
// FillLeft return string filled in left by spaces in w cells
@@ -320,7 +367,7 @@ func RuneWidth(r rune) int {
320367

321368
// IsAmbiguousWidth returns whether is ambiguous width or not.
322369
func IsAmbiguousWidth(r rune) bool {
323-
return inTables(r, private, ambiguous)
370+
return inTable(r, private) || inTable(r, ambiguous)
324371
}
325372

326373
// IsCombiningWidth returns whether is combining width or not.

0 commit comments

Comments
 (0)