mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-10-20 05:57:51 +00:00
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:
@@ -864,7 +864,7 @@ func truncateString(s string, maxLen int) string {
|
|||||||
if len(s) <= maxLen {
|
if len(s) <= maxLen {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
return s[:maxLen-3] + "..."
|
return s[:util.RuneBoundary(s, maxLen-3)] + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateLanguage checks if AST editing is supported for a file.
|
// ValidateLanguage checks if AST editing is supported for a file.
|
||||||
|
|||||||
@@ -186,6 +186,9 @@ func TestFirstLineOf(t *testing.T) {
|
|||||||
{"line1\nline2", 20, "line1"},
|
{"line1\nline2", 20, "line1"},
|
||||||
{"\n\nfoo", 20, "foo"},
|
{"\n\nfoo", 20, "foo"},
|
||||||
{"abcdefghij", 5, "abcd…"},
|
{"abcdefghij", 5, "abcd…"},
|
||||||
|
// Multibyte: "ααα" is 6 bytes (each α = 2). maxLen-1 = 3 lands inside the
|
||||||
|
// 2nd α; must back off to a rune boundary, never split into invalid UTF-8.
|
||||||
|
{"ααα", 4, "α…"},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
got := firstLineOf(c.input, c.maxLen)
|
got := firstLineOf(c.input, c.maxLen)
|
||||||
|
|||||||
@@ -583,7 +583,7 @@ func firstLineOf(s string, maxLen int) string {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(line) > maxLen {
|
if len(line) > maxLen {
|
||||||
return line[:maxLen-1] + "…"
|
return line[:util.RuneBoundary(line, maxLen-1)] + "…"
|
||||||
}
|
}
|
||||||
return line
|
return line
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
json "github.com/goccy/go-json"
|
json "github.com/goccy/go-json"
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
|
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
|
||||||
|
"github.com/lukaszraczylo/mcp-filepuff/internal/util"
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
|
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
|
||||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||||
)
|
)
|
||||||
@@ -495,9 +495,5 @@ func truncateLine(s string, maxLen int) string {
|
|||||||
if len(s) <= maxLen {
|
if len(s) <= maxLen {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
end := max(maxLen-3, 0)
|
return s[:util.RuneBoundary(s, maxLen-3)] + "..."
|
||||||
for end > 0 && !utf8.RuneStart(s[end]) {
|
|
||||||
end--
|
|
||||||
}
|
|
||||||
return s[:end] + "..."
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user