diff --git a/internal/edit/edit.go b/internal/edit/edit.go index 60402e4..84b5364 100644 --- a/internal/edit/edit.go +++ b/internal/edit/edit.go @@ -864,7 +864,7 @@ func truncateString(s string, maxLen int) string { if len(s) <= maxLen { return s } - return s[:maxLen-3] + "..." + return s[:util.RuneBoundary(s, maxLen-3)] + "..." } // ValidateLanguage checks if AST editing is supported for a file. diff --git a/internal/query/format_test.go b/internal/query/format_test.go index a334aa9..c9cd26a 100644 --- a/internal/query/format_test.go +++ b/internal/query/format_test.go @@ -186,6 +186,9 @@ func TestFirstLineOf(t *testing.T) { {"line1\nline2", 20, "line1"}, {"\n\nfoo", 20, "foo"}, {"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 { got := firstLineOf(c.input, c.maxLen) diff --git a/internal/query/query.go b/internal/query/query.go index f72883a..20b4188 100644 --- a/internal/query/query.go +++ b/internal/query/query.go @@ -583,7 +583,7 @@ func firstLineOf(s string, maxLen int) string { continue } if len(line) > maxLen { - return line[:maxLen-1] + "…" + return line[:util.RuneBoundary(line, maxLen-1)] + "…" } return line } diff --git a/internal/search/search.go b/internal/search/search.go index 970c186..74d20c4 100644 --- a/internal/search/search.go +++ b/internal/search/search.go @@ -10,10 +10,10 @@ import ( "os/exec" "path/filepath" "strings" - "unicode/utf8" json "github.com/goccy/go-json" "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/protocol" ) @@ -495,9 +495,5 @@ func truncateLine(s string, maxLen int) string { if len(s) <= maxLen { return s } - end := max(maxLen-3, 0) - for end > 0 && !utf8.RuneStart(s[end]) { - end-- - } - return s[:end] + "..." + return s[:util.RuneBoundary(s, maxLen-3)] + "..." } diff --git a/internal/util/truncate.go b/internal/util/truncate.go new file mode 100644 index 0000000..4efebeb --- /dev/null +++ b/internal/util/truncate.go @@ -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 +} diff --git a/internal/util/truncate_test.go b/internal/util/truncate_test.go new file mode 100644 index 0000000..1086fa0 --- /dev/null +++ b/internal/util/truncate_test.go @@ -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]) + } + }) + } +}