fix(stringutil): operate on runes instead of bytes in Truncate (#22388)

Fixes https://github.com/coder/coder/issues/22375

Updates `stringutil.Truncate` to properly handle multi-byte UTF-8
characters.
Adds tests for multi-byte truncation with word boundary.

Created by Mux using Opus 4.6
This commit is contained in:
Cian Johnston
2026-02-27 17:46:37 +00:00
committed by GitHub
parent 0252205374
commit 0cfa03718e
4 changed files with 69 additions and 12 deletions
+22 -10
View File
@@ -5,6 +5,7 @@ import (
"strconv"
"strings"
"unicode"
"unicode/utf8"
"github.com/acarl005/stripansi"
"github.com/microcosm-cc/bluemonday"
@@ -53,7 +54,7 @@ const (
TruncateWithFullWords TruncateOption = 1 << 1
)
// Truncate truncates s to n characters.
// Truncate truncates s to n runes.
// Additional behaviors can be specified using TruncateOptions.
func Truncate(s string, n int, opts ...TruncateOption) string {
var options TruncateOption
@@ -63,7 +64,8 @@ func Truncate(s string, n int, opts ...TruncateOption) string {
if n < 1 {
return ""
}
if len(s) <= n {
runes := []rune(s)
if len(runes) <= n {
return s
}
@@ -72,18 +74,18 @@ func Truncate(s string, n int, opts ...TruncateOption) string {
maxLen--
}
var sb strings.Builder
// If we need to truncate to full words, find the last word boundary before n.
if options&TruncateWithFullWords != 0 {
lastWordBoundary := strings.LastIndexFunc(s[:maxLen], unicode.IsSpace)
// Convert the rune-safe prefix to a string, then find
// the last word boundary (byte offset within that prefix).
truncated := string(runes[:maxLen])
lastWordBoundary := strings.LastIndexFunc(truncated, unicode.IsSpace)
if lastWordBoundary < 0 {
// We cannot find a word boundary. At this point, we'll truncate the string.
// It's better than nothing.
_, _ = sb.WriteString(s[:maxLen])
} else { // lastWordBoundary <= maxLen
_, _ = sb.WriteString(s[:lastWordBoundary])
_, _ = sb.WriteString(truncated)
} else {
_, _ = sb.WriteString(truncated[:lastWordBoundary])
}
} else {
_, _ = sb.WriteString(s[:maxLen])
_, _ = sb.WriteString(string(runes[:maxLen]))
}
if options&TruncateWithEllipsis != 0 {
@@ -126,3 +128,13 @@ func UISanitize(in string) string {
}
return strings.TrimSpace(b.String())
}
// Capitalize returns s with its first rune upper-cased. It is safe for
// multi-byte UTF-8 characters, unlike naive byte-slicing approaches.
func Capitalize(s string) string {
r, size := utf8.DecodeRuneInString(s)
if size == 0 {
return s
}
return string(unicode.ToUpper(r)) + s[size:]
}