Skip to content

Commit f62848d

Browse files
committed
fix: truncate() now handles multi-byte characters correctly
The Truncate function was using byte slicing (str[:n]) which corrupts multi-byte UTF-8 characters like emojis. Changed to rune slicing to preserve character integrity when truncating strings. Before: truncate("hello😀world", 7) → "hello\xf0…" (corrupted) After: truncate("hello😀world", 7) → "hello😀…" (correct)
1 parent a0af29c commit f62848d

File tree

3 files changed

+80
-7
lines changed

3 files changed

+80
-7
lines changed

core/common/utils_rad.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
package com
22

33
func Truncate(str string, maxLen int64) string {
4+
runes := []rune(str)
45
if TerminalIsUtf8 {
5-
str = str[:maxLen-1]
6-
str += "…"
6+
return string(runes[:maxLen-1]) + "…"
77
} else {
8-
str = str[:maxLen-3]
9-
str += "..."
8+
return string(runes[:maxLen-3]) + "..."
109
}
11-
return str
1210
}
1311

1412
func Reverse(str string) string {

core/funcs.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -594,8 +594,14 @@ func init() {
594594
Execute: func(f FuncInvocation) RadValue {
595595
str := f.GetStr("_str")
596596
maxLen := f.GetInt("_len")
597-
if maxLen < 0 {
598-
return f.ReturnErrf(rl.ErrNumInvalidRange, "Requires a non-negative int, got %d", maxLen)
597+
598+
// Minimum length depends on ellipsis: "…" (1 char) vs "..." (3 chars)
599+
minLen := int64(1)
600+
if !com.TerminalIsUtf8 {
601+
minLen = 3
602+
}
603+
if maxLen < minLen {
604+
return f.ReturnErrf(rl.ErrNumInvalidRange, "Requires at least %d, got %d", minLen, maxLen)
599605
}
600606

601607
strLen := str.Len()

core/testing/func_truncate_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package testing
2+
3+
import "testing"
4+
5+
func Test_Truncate_MultiByte(t *testing.T) {
6+
script := `
7+
s = "hello😀world"
8+
print(truncate(s, 7))
9+
`
10+
setupAndRunCode(t, script, "--color=never")
11+
assertOnlyOutput(t, stdOutBuffer, "hello😀…\n")
12+
assertNoErrors(t)
13+
}
14+
15+
func Test_Truncate_MultiByte_ExactBoundary(t *testing.T) {
16+
script := `
17+
s = "a😀b"
18+
print(truncate(s, 2))
19+
`
20+
setupAndRunCode(t, script, "--color=never")
21+
assertOnlyOutput(t, stdOutBuffer, "a…\n")
22+
assertNoErrors(t)
23+
}
24+
25+
func Test_Truncate_AllEmoji(t *testing.T) {
26+
script := `
27+
s = "😀😀😀😀😀"
28+
print(truncate(s, 3))
29+
`
30+
setupAndRunCode(t, script, "--color=never")
31+
assertOnlyOutput(t, stdOutBuffer, "😀😀…\n")
32+
assertNoErrors(t)
33+
}
34+
35+
func Test_Truncate_MinLength(t *testing.T) {
36+
// Minimum length is 1 (for UTF-8 ellipsis "…")
37+
script := `
38+
print(truncate("hello", 1))
39+
`
40+
setupAndRunCode(t, script, "--color=never")
41+
assertOnlyOutput(t, stdOutBuffer, "…\n")
42+
assertNoErrors(t)
43+
}
44+
45+
func Test_Truncate_ErrorsForZero(t *testing.T) {
46+
script := `
47+
print(truncate("hello", 0))
48+
`
49+
setupAndRunCode(t, script, "--color=never")
50+
expected := `Error at L2:7
51+
52+
print(truncate("hello", 0))
53+
^^^^^^^^^^^^^^^^^^^^ Requires at least 1, got 0 (RAD20017)
54+
`
55+
assertError(t, 1, expected)
56+
}
57+
58+
func Test_Truncate_ErrorsForNegative(t *testing.T) {
59+
script := `
60+
print(truncate("hello", -5))
61+
`
62+
setupAndRunCode(t, script, "--color=never")
63+
expected := `Error at L2:7
64+
65+
print(truncate("hello", -5))
66+
^^^^^^^^^^^^^^^^^^^^^ Requires at least 1, got -5 (RAD20017)
67+
`
68+
assertError(t, 1, expected)
69+
}

0 commit comments

Comments
 (0)