fix(util): rune-safe truncation across formatters

firstLineOf (query) and truncateString (edit) sliced strings at a raw byte
offset, splitting a multibyte UTF-8 character into invalid bytes in
LLM-facing output (ast_query compact format and edit not-found errors are
hot paths). Extract the rune-boundary backoff already used by search's
truncateLine into util.RuneBoundary and route all three sites through it.

Adds table-driven tests for the primitive and a multibyte case for firstLineOf.
This commit is contained in:
2026-06-20 00:44:38 +01:00
parent 549ec2a177
commit 3195e353e2
6 changed files with 64 additions and 8 deletions
+20
View File
@@ -0,0 +1,20 @@
package util
import "unicode/utf8"
// RuneBoundary returns the largest index j <= i such that s[:j] is valid UTF-8
// (j falls on a rune start, never inside a multibyte sequence). i is clamped to
// [0, len(s)]. Callers slicing s[:i] for truncation should slice s[:RuneBoundary(s, i)]
// instead so a multibyte character is never split into an invalid encoding.
func RuneBoundary(s string, i int) int {
if i <= 0 {
return 0
}
if i >= len(s) {
return len(s)
}
for i > 0 && !utf8.RuneStart(s[i]) {
i--
}
return i
}
+37
View File
@@ -0,0 +1,37 @@
package util
import (
"testing"
"unicode/utf8"
)
func TestRuneBoundary(t *testing.T) {
// "café" — 'é' is 2 bytes (0xC3 0xA9), occupying indices 3,4. len == 5.
const cafe = "café"
cases := []struct {
name string
s string
i int
want int
}{
{"negative clamps to zero", "abc", -1, 0},
{"zero", "abc", 0, 0},
{"beyond length clamps to len", "abc", 99, 3},
{"ascii exact boundary", "abcdef", 4, 4},
{"inside multibyte backs off", cafe, 4, 3}, // index 4 is the 2nd byte of 'é'
{"on multibyte start stays", cafe, 3, 3}, // index 3 is the 1st byte of 'é'
{"end of multibyte string", cafe, 5, 5}, // len == 5, clamped path
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := RuneBoundary(c.s, c.i)
if got != c.want {
t.Fatalf("RuneBoundary(%q, %d) = %d, want %d", c.s, c.i, got, c.want)
}
// Invariant: the prefix is always valid UTF-8.
if !utf8.ValidString(c.s[:got]) {
t.Fatalf("RuneBoundary(%q, %d) sliced invalid UTF-8: %q", c.s, c.i, c.s[:got])
}
})
}
}