@@ -3,11 +3,319 @@ package util
6− "github.com/stretchr/testify/require"
6+ powernap "github.com/charmbracelet/x/powernap/pkg/lsp"
8 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
7 "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
8+ "github.com/stretchr/testify/require"
11+func TestPositionToByteOffset(t *testing.T) {
20+ lineText: "hello world",
25+ name: "CJK characters (3 bytes each in UTF-8, 1 UTF-16 unit)",
31+ name: "CJK - position after CJK",
32+ lineText: "var x = \"你好world\"",
37+ name: "Emoji (4 bytes in UTF-8, 2 UTF-16 units)",
43+ name: "Multiple emoji",
49+ name: "Mixed content",
50+ lineText: "Hello👋你好",
61+ name: "Position beyond end",
67+ name: "Empty string",
73+ name: "Surrogate pair at start",
79+ name: "ZWJ family emoji (1 grapheme, 7 runes, 11 UTF-16 units)",
80+ lineText: "hello👨\u200d👩\u200d👧\u200d👦world",
85+ name: "ZWJ family emoji - offset into middle of grapheme cluster",
86+ lineText: "hello👨\u200d👩\u200d👧\u200d👦world",
91+ name: "Flag emoji (1 grapheme, 2 runes, 4 UTF-16 units)",
92+ lineText: "hello🇺🇸world",
97+ name: "Combining character (1 grapheme, 2 runes, 2 UTF-16 units)",
98+ lineText: "caf\u0065\u0301!",
103+ name: "Skin tone modifier (1 grapheme, 2 runes, 4 UTF-16 units)",
104+ lineText: "hi👋🏽bye",
110+ for _, tt := range tests {
111+ t.Run(tt.name, func(t *testing.T) {
112+ result := powernap.PositionToByteOffset(tt.lineText, tt.utf16Char)
113+ if result != tt.expected {
114+ t.Errorf("PositionToByteOffset(%q, %d) = %d, want %d",
115+ tt.lineText, tt.utf16Char, result, tt.expected)
121+func TestApplyTextEdit_UTF16(t *testing.T) {
122+ // Test that UTF-16 offsets are correctly converted to byte offsets
126+ edit protocol.TextEdit
130+ name: "ASCII only - no conversion needed",
131+ lines: []string{"hello world"},
132+ edit: protocol.TextEdit{
133+ Range: protocol.Range{
134+ Start: protocol.Position{Line: 0, Character: 6},
135+ End: protocol.Position{Line: 0, Character: 11},
137+ NewText: "universe",
139+ expected: []string{"hello universe"},
142+ name: "CJK characters - edit after Chinese characters",
143+ lines: []string{`var x = "你好world"`},
144+ edit: protocol.TextEdit{
145+ Range: protocol.Range{
146+ // "你好" = 2 UTF-16 units, but 6 bytes in UTF-8
147+ // Position 11 is where "world" starts in UTF-16
148+ Start: protocol.Position{Line: 0, Character: 11},
149+ End: protocol.Position{Line: 0, Character: 16},
151+ NewText: "universe",
153+ expected: []string{`var x = "你好universe"`},
156+ name: "Emoji - edit after emoji (2 UTF-16 units)",
157+ lines: []string{`fmt.Println("👋hello")`},
158+ edit: protocol.TextEdit{
159+ Range: protocol.Range{
160+ // 👋 = 2 UTF-16 units, 4 bytes in UTF-8
161+ // Position 15 is where "hello" starts in UTF-16
162+ Start: protocol.Position{Line: 0, Character: 15},
163+ End: protocol.Position{Line: 0, Character: 20},
167+ expected: []string{`fmt.Println("👋world")`},
170+ name: "ZWJ family emoji - edit after grapheme cluster",
171+ // "hello👨👩👧👦world" — family is 1 grapheme but 11 UTF-16 units
172+ lines: []string{"hello\U0001F468\u200d\U0001F469\u200d\U0001F467\u200d\U0001F466world"},
173+ edit: protocol.TextEdit{
174+ Range: protocol.Range{
175+ // "hello" = 5 UTF-16 units, family = 11 UTF-16 units
176+ // "world" starts at UTF-16 offset 16
177+ Start: protocol.Position{Line: 0, Character: 16},
178+ End: protocol.Position{Line: 0, Character: 21},
182+ expected: []string{"hello\U0001F468\u200d\U0001F469\u200d\U0001F467\u200d\U0001F466earth"},
185+ name: "ZWJ family emoji - edit splits grapheme cluster in half",
186+ // LSP servers can position into the middle of a grapheme cluster.
187+ // After "hello" (5 UTF-16 units), the ZWJ family emoji starts.
188+ // UTF-16 offset 7 lands between 👨 (2 units) and ZWJ, inside
189+ // the grapheme cluster. The byte offset for position 7 is 9
190+ // (5 bytes for "hello" + 4 bytes for 👨).
191+ lines: []string{"hello\U0001F468\u200d\U0001F469\u200d\U0001F467\u200d\U0001F466world"},
192+ edit: protocol.TextEdit{
193+ Range: protocol.Range{
194+ Start: protocol.Position{Line: 0, Character: 7},
195+ End: protocol.Position{Line: 0, Character: 16},
199+ // Keeps "hello" + 👨 (first rune of cluster) then removes
200+ // the rest of the cluster, leaving "hello👨world".
201+ expected: []string{"hello\U0001F468world"},
204+ name: "Flag emoji - edit after flag",
205+ // 🇺🇸 = 2 regional indicator runes, 4 UTF-16 units, 8 bytes
206+ lines: []string{"hello🇺🇸world"},
207+ edit: protocol.TextEdit{
208+ Range: protocol.Range{
209+ Start: protocol.Position{Line: 0, Character: 9},
210+ End: protocol.Position{Line: 0, Character: 14},
214+ expected: []string{"hello🇺🇸earth"},
217+ name: "Combining accent - edit after composed character",
218+ // "café!" where é = e + U+0301 (2 code points, 2 UTF-16 units)
219+ lines: []string{"caf\u0065\u0301!"},
220+ edit: protocol.TextEdit{
221+ Range: protocol.Range{
222+ // "caf" = 3, "e" = 1, U+0301 = 1, total = 5 UTF-16 units
223+ Start: protocol.Position{Line: 0, Character: 5},
224+ End: protocol.Position{Line: 0, Character: 6},
228+ expected: []string{"caf\u0065\u0301?"},
231+ name: "Skin tone modifier - edit after modified emoji",
232+ // 👋🏽 = U+1F44B U+1F3FD = 2 runes, 4 UTF-16 units, 8 bytes
233+ lines: []string{"hi👋🏽bye"},
234+ edit: protocol.TextEdit{
235+ Range: protocol.Range{
236+ // "hi" = 2, 👋🏽 = 4, total = 6 UTF-16 units
237+ Start: protocol.Position{Line: 0, Character: 6},
238+ End: protocol.Position{Line: 0, Character: 9},
242+ expected: []string{"hi👋🏽later"},
246+ for _, tt := range tests {
247+ t.Run(tt.name, func(t *testing.T) {
248+ result, err := applyTextEdit(tt.lines, tt.edit, powernap.UTF16)
250+ t.Fatalf("applyTextEdit failed: %v", err)
252+ if len(result) != len(tt.expected) {
253+ t.Errorf("expected %d lines, got %d: %v", len(tt.expected), len(result), result)
256+ for i := range result {
257+ if result[i] != tt.expected[i] {
258+ t.Errorf("line %d: expected %q, got %q", i, tt.expected[i], result[i])
265+func TestApplyTextEdit_UTF8(t *testing.T) {
266+ // Test that UTF-8 offsets are used directly without conversion
270+ edit protocol.TextEdit
274+ name: "ASCII only - direct byte offset",
275+ lines: []string{"hello world"},
276+ edit: protocol.TextEdit{
277+ Range: protocol.Range{
278+ Start: protocol.Position{Line: 0, Character: 6},
279+ End: protocol.Position{Line: 0, Character: 11},
281+ NewText: "universe",
283+ expected: []string{"hello universe"},
286+ name: "CJK characters - byte offset used directly",
287+ lines: []string{`var x = "你好world"`},
288+ edit: protocol.TextEdit{
289+ Range: protocol.Range{
290+ // With UTF-8 encoding, position 15 is the byte offset
291+ Start: protocol.Position{Line: 0, Character: 15},
292+ End: protocol.Position{Line: 0, Character: 20},
294+ NewText: "universe",
296+ expected: []string{`var x = "你好universe"`},
300+ for _, tt := range tests {
301+ t.Run(tt.name, func(t *testing.T) {
302+ result, err := applyTextEdit(tt.lines, tt.edit, powernap.UTF8)
304+ t.Fatalf("applyTextEdit failed: %v", err)
306+ if len(result) != len(tt.expected) {
307+ t.Errorf("expected %d lines, got %d: %v", len(tt.expected), len(result), result)
310+ for i := range result {
311+ if result[i] != tt.expected[i] {
312+ t.Errorf("line %d: expected %q, got %q", i, tt.expected[i], result[i])
11func TestRangesOverlap(t *testing.T) {
319func TestRangesOverlap(t *testing.T) {
12 t.Parallel()
320 t.Parallel()