perl_lsp/textdoc.rs
1//! Rope-based text document handling for LSP with UTF-16 aware position mapping
2//!
3//! This module provides efficient document management using the `ropey` crate for
4//! O(log n) insertions, deletions, and position conversions. It handles the conversion
5//! between LSP's UTF-16 based positions and Rust's UTF-8 strings, ensuring proper
6//! handling of Unicode characters including emojis and multi-byte sequences.
7//!
8//! ## Key Features
9//! - **Efficient Edits**: O(log n) performance for document modifications
10//! - **UTF-16 Compliance**: Proper LSP position mapping for Unicode text
11//! - **Incremental Updates**: Support for LSP TextDocumentContentChangeEvent
12//! - **Position Safety**: Boundary-checked conversions with graceful clamping
13
14use lsp_types::{Position, Range, TextDocumentContentChangeEvent};
15use ropey::Rope;
16
17/// Document state using Rope for efficient text operations
18///
19/// The `Doc` struct stores document content in a Rope data structure,
20/// providing O(log n) performance for edits while maintaining UTF-8/UTF-16
21/// position mapping capabilities for LSP compliance.
22#[derive(Clone)]
23pub struct Doc {
24 /// Rope-backed document content for efficient edits and slicing
25 pub rope: Rope,
26 /// Document version number for LSP synchronization
27 pub version: i32,
28}
29
30/// Position encoding format for LSP compatibility
31///
32/// LSP uses UTF-16 code units for positions, while Rust strings are UTF-8.
33/// This enum determines how position conversions are performed.
34#[derive(Clone, Copy)]
35pub enum PosEnc {
36 /// UTF-16 encoding (LSP standard) - counts UTF-16 code units
37 Utf16,
38 /// UTF-8 encoding (Rust native) - counts UTF-8 bytes
39 Utf8,
40}
41
42#[derive(Clone, Copy, Debug, Eq, PartialEq)]
43/// Exact range mapping data for safe incremental parser edits.
44pub struct SafeRangeMapping {
45 /// Start of the mapped range as a Rope char index.
46 pub start_char: usize,
47 /// End of the mapped range as a Rope char index.
48 pub end_char: usize,
49 /// Start of the mapped range as a byte offset.
50 pub start_byte: usize,
51 /// End of the mapped range as a byte offset.
52 pub end_byte: usize,
53}
54
55fn is_forward_range(range: &Range) -> bool {
56 (range.start.line, range.start.character) <= (range.end.line, range.end.character)
57}
58
59fn line_content_char_len(rope: &Rope, line: usize) -> usize {
60 let line_slice = rope.line(line);
61 let mut chars = line_slice.len_chars();
62
63 if chars > 0 && line_slice.char(chars - 1) == '\n' {
64 chars -= 1;
65 }
66 if chars > 0 && line_slice.char(chars - 1) == '\r' {
67 chars -= 1;
68 }
69
70 chars
71}
72
73fn lsp_pos_to_char_with_alignment(rope: &Rope, pos: Position, enc: PosEnc) -> (usize, bool) {
74 if pos.line as usize >= rope.len_lines() {
75 return (rope.len_chars(), true);
76 }
77
78 let line = pos.line as usize;
79 let line_char0 = rope.line_to_char(line);
80 let line_slice = rope.line(line);
81 let content_chars = line_content_char_len(rope, line);
82
83 match enc {
84 PosEnc::Utf8 => {
85 let mut bytes = 0u32;
86 for (char_idx, ch) in line_slice.chars().take(content_chars).enumerate() {
87 let next = bytes + ch.len_utf8() as u32;
88 if next == pos.character {
89 return (line_char0 + char_idx + 1, true);
90 }
91 if next > pos.character {
92 return (line_char0 + char_idx, false);
93 }
94 bytes = next;
95 }
96 (line_char0 + content_chars, bytes == pos.character)
97 }
98 PosEnc::Utf16 => {
99 let mut utf16_units = 0u32;
100 for (char_idx, ch) in line_slice.chars().take(content_chars).enumerate() {
101 let next = utf16_units + ch.len_utf16() as u32;
102 if next == pos.character {
103 return (line_char0 + char_idx + 1, true);
104 }
105 if next > pos.character {
106 return (line_char0 + char_idx, false);
107 }
108 utf16_units = next;
109 }
110 (line_char0 + content_chars, utf16_units == pos.character)
111 }
112 }
113}
114
115/// Convert LSP position to char index with UTF-16/UTF-8 encoding support
116///
117/// This function handles the conversion from LSP Position (line, character)
118/// to a char index in the Rope, accounting for UTF-16 vs UTF-8 encoding
119/// differences. Unicode characters like emojis are handled correctly.
120///
121/// # Arguments
122/// * `rope` - The rope containing the document text
123/// * `pos` - LSP position with 0-based line and character indices
124/// * `enc` - Whether to interpret character positions as UTF-16 or UTF-8
125///
126/// # Returns
127/// Char index clamped to valid rope boundaries
128pub fn lsp_pos_to_char(rope: &Rope, pos: Position, enc: PosEnc) -> usize {
129 let (char_idx, _) = lsp_pos_to_char_with_alignment(rope, pos, enc);
130 char_idx.min(rope.len_chars())
131}
132
133/// Convert LSP position to byte offset with UTF-16/UTF-8 encoding support
134///
135/// This function handles the conversion from LSP Position (line, character)
136/// to a byte offset in the Rope, accounting for UTF-16 vs UTF-8 encoding
137/// differences. Unicode characters like emojis are handled correctly.
138///
139/// # Arguments
140/// * `rope` - The rope containing the document text
141/// * `pos` - LSP position with 0-based line and character indices
142/// * `enc` - Whether to interpret character positions as UTF-16 or UTF-8
143///
144/// # Returns
145/// Byte offset clamped to valid rope boundaries
146pub fn lsp_pos_to_byte(rope: &Rope, pos: Position, enc: PosEnc) -> usize {
147 rope.char_to_byte(lsp_pos_to_char(rope, pos, enc))
148}
149
150/// Convert byte offset to LSP position with UTF-16/UTF-8 encoding support
151///
152/// This function performs the reverse conversion from a byte offset in the Rope
153/// back to an LSP Position, ensuring proper character counting for the specified
154/// encoding format.
155///
156/// # Arguments
157/// * `rope` - The rope containing the document text
158/// * `byte` - Byte offset to convert (will be clamped to rope bounds)
159/// * `enc` - Whether to count characters as UTF-16 or UTF-8
160///
161/// # Returns
162/// LSP Position with 0-based line and character indices
163pub fn byte_to_lsp_pos(rope: &Rope, byte: usize, enc: PosEnc) -> Position {
164 let byte = byte.min(rope.len_bytes());
165 let char_idx = rope.byte_to_char(byte);
166 let line = rope.char_to_line(char_idx);
167 let line_char0 = rope.line_to_char(line);
168 let col_chars = (char_idx - line_char0).min(line_content_char_len(rope, line));
169
170 let character = match enc {
171 PosEnc::Utf8 => {
172 // UTF-8: return byte count from start of line
173 let line_slice = rope.line(line);
174 line_slice.chars().take(col_chars).map(|c| c.len_utf8() as u32).sum()
175 }
176 PosEnc::Utf16 => {
177 // UTF-16: return UTF-16 code unit count from start of line
178 let line_slice = rope.line(line);
179 line_slice.chars().take(col_chars).map(|c| c.len_utf16() as u32).sum()
180 }
181 };
182
183 Position { line: line as u32, character }
184}
185
186/// Convert LSP range to char index pair
187///
188/// Converts both start and end positions of an LSP Range to char indices
189/// for rope operations. Ropey's `remove` and `insert` methods operate on
190/// char indices, not byte offsets.
191///
192/// # Arguments
193/// * `rope` - The rope containing the document text
194/// * `range` - LSP range with start and end positions
195/// * `enc` - Position encoding format
196///
197/// # Returns
198/// Tuple of (start_char, end_char) clamped to rope bounds
199pub fn range_to_chars(rope: &Rope, range: &Range, enc: PosEnc) -> (usize, usize) {
200 let s = lsp_pos_to_char(rope, range.start, enc);
201 let e = lsp_pos_to_char(rope, range.end, enc);
202 (s.min(rope.len_chars()), e.min(rope.len_chars()))
203}
204
205/// Convert LSP range to byte offset pair
206///
207/// Converts both start and end positions of an LSP Range to byte offsets.
208/// Use `range_to_chars` for rope operations like `remove` and `insert`.
209///
210/// # Arguments
211/// * `rope` - The rope containing the document text
212/// * `range` - LSP range with start and end positions
213/// * `enc` - Position encoding format
214///
215/// # Returns
216/// Tuple of (start_byte, end_byte) clamped to rope bounds
217pub fn range_to_bytes(rope: &Rope, range: &Range, enc: PosEnc) -> (usize, usize) {
218 let s = lsp_pos_to_byte(rope, range.start, enc);
219 let e = lsp_pos_to_byte(rope, range.end, enc);
220 (s.min(rope.len_bytes()), e.min(rope.len_bytes()))
221}
222
223/// Safely map an LSP range for parser-incremental use.
224///
225/// Returns `None` when the range is malformed (reversed) or either endpoint lands
226/// inside a multi-byte / surrogate boundary where exact mapping is ambiguous.
227pub fn safe_range_mapping(rope: &Rope, range: &Range, enc: PosEnc) -> Option<SafeRangeMapping> {
228 if !is_forward_range(range) {
229 return None;
230 }
231
232 let (start_char, start_aligned) = lsp_pos_to_char_with_alignment(rope, range.start, enc);
233 let (end_char, end_aligned) = lsp_pos_to_char_with_alignment(rope, range.end, enc);
234
235 if !start_aligned || !end_aligned || start_char > end_char {
236 return None;
237 }
238
239 let start_byte = rope.char_to_byte(start_char.min(rope.len_chars()));
240 let end_byte = rope.char_to_byte(end_char.min(rope.len_chars()));
241 Some(SafeRangeMapping { start_char, end_char, start_byte, end_byte })
242}
243
244/// Apply incremental LSP text changes to a Rope-backed document
245///
246/// Processes an array of LSP TextDocumentContentChangeEvent objects,
247/// applying them to the document's rope in sequence. Supports both
248/// full document replacement and ranged incremental edits.
249///
250/// # Arguments
251/// * `doc` - Mutable document to modify
252/// * `changes` - Array of LSP change events to apply
253/// * `enc` - Position encoding for interpreting ranges
254///
255/// # Behavior
256/// - Changes without ranges replace the entire document
257/// - Changes with ranges perform incremental edits at specified positions
258/// - All position calculations respect UTF-16/UTF-8 encoding differences
259/// - Invalid ranges are safely clamped to document boundaries
260///
261/// # Note
262/// Ropey's `remove` and `insert` operate on **char indices**, not byte offsets.
263/// This function correctly converts LSP positions to char indices for rope operations.
264pub fn apply_changes(doc: &mut Doc, changes: &[TextDocumentContentChangeEvent], enc: PosEnc) {
265 for ch in changes {
266 if let Some(r) = &ch.range {
267 if !is_forward_range(r) {
268 continue;
269 }
270 // IMPORTANT: Rope::remove and Rope::insert use char indices, not byte offsets
271 let (s, e) = range_to_chars(&doc.rope, r, enc);
272 if s <= e {
273 doc.rope.remove(s..e);
274 doc.rope.insert(s, &ch.text);
275 }
276 } else {
277 // Full document replace
278 doc.rope = Rope::from_str(&ch.text);
279 }
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 /// Regression test: delete emoji via LSP range should work correctly.
288 /// This catches both the "rope uses chars" bug and UTF-16 boundary handling.
289 #[test]
290 fn test_delete_emoji_via_lsp_range() {
291 // "hi 😀x\n" - emoji is 4 bytes, 2 UTF-16 units
292 let mut doc = Doc { rope: Rope::from_str("hi \u{1F600}x\n"), version: 1 };
293
294 // Delete the emoji: positions are in UTF-16 code units
295 // "hi " = 3 chars/units, emoji = 2 units, so emoji is at [3, 5)
296 let change = TextDocumentContentChangeEvent {
297 range: Some(Range {
298 start: Position { line: 0, character: 3 },
299 end: Position { line: 0, character: 5 },
300 }),
301 range_length: None,
302 text: String::new(),
303 };
304
305 apply_changes(&mut doc, &[change], PosEnc::Utf16);
306
307 assert_eq!(doc.rope.to_string(), "hi x\n", "Emoji should be deleted correctly");
308 }
309
310 /// Test that position inside surrogate pair clamps correctly (before, not after).
311 #[test]
312 fn test_position_inside_surrogate_clamps_before() {
313 let rope = Rope::from_str("hi \u{1F600}x");
314
315 // Position 4 is "inside" the emoji (which spans [3, 5) in UTF-16)
316 let pos = Position { line: 0, character: 4 };
317 let char_idx = lsp_pos_to_char(&rope, pos, PosEnc::Utf16);
318
319 // Should clamp to char index 3 (start of emoji), not 4 (after emoji)
320 assert_eq!(char_idx, 3, "Position inside surrogate should clamp to start of char");
321 }
322
323 /// Test UTF-8 encoding handles multi-byte chars correctly.
324 #[test]
325 fn test_utf8_position_multi_byte() {
326 let rope = Rope::from_str("hi \u{1F600}x");
327
328 // In UTF-8, emoji is 4 bytes, so 'x' is at byte offset 7
329 // "hi " = 3 bytes, emoji = 4 bytes, 'x' = byte 7
330 let pos = Position { line: 0, character: 7 };
331 let char_idx = lsp_pos_to_char(&rope, pos, PosEnc::Utf8);
332
333 // Should be char index 4 (0='h', 1='i', 2=' ', 3=emoji, 4='x')
334 assert_eq!(char_idx, 4, "UTF-8 byte offset should map to correct char");
335 }
336
337 /// Test that positions past line end clamp before the line break.
338 #[test]
339 fn test_position_past_line_end_clamps_before_newline() {
340 let rope = Rope::from_str("abc\ndef");
341
342 let char_idx = lsp_pos_to_char(&rope, Position { line: 0, character: 99 }, PosEnc::Utf16);
343
344 assert_eq!(char_idx, 3, "Past-EOL positions must not consume the newline");
345 }
346
347 /// Test that CRLF line endings are treated as line terminators, not LSP columns.
348 #[test]
349 fn test_position_past_crlf_line_end_clamps_before_line_break() {
350 let rope = Rope::from_str("abc\r\ndef");
351
352 let char_idx = lsp_pos_to_char(&rope, Position { line: 0, character: 4 }, PosEnc::Utf16);
353
354 assert_eq!(char_idx, 3, "Past-EOL positions must not land inside CRLF");
355 }
356
357 /// Test that invalid EOL-overflow edits insert at line end rather than on the next line.
358 #[test]
359 fn test_apply_change_past_line_end_inserts_before_newline() {
360 let mut doc = Doc { rope: Rope::from_str("abc\ndef"), version: 1 };
361
362 let change = TextDocumentContentChangeEvent {
363 range: Some(Range {
364 start: Position { line: 0, character: 99 },
365 end: Position { line: 0, character: 99 },
366 }),
367 range_length: None,
368 text: "!".to_string(),
369 };
370
371 apply_changes(&mut doc, &[change], PosEnc::Utf16);
372
373 assert_eq!(doc.rope.to_string(), "abc!\ndef");
374 }
375
376 /// Test byte_to_lsp_pos returns correct UTF-16 character count.
377 #[test]
378 fn test_byte_to_lsp_pos_utf16_emoji() {
379 let rope = Rope::from_str("hi \u{1F600}x");
380
381 // 'x' is at byte 7, char index 4
382 let pos = byte_to_lsp_pos(&rope, 7, PosEnc::Utf16);
383
384 // "hi " = 3 UTF-16 units, emoji = 2 UTF-16 units, so 'x' is at character 5
385 assert_eq!(pos.line, 0);
386 assert_eq!(pos.character, 5, "UTF-16 character should account for emoji surrogate pair");
387 }
388
389 /// Test byte_to_lsp_pos clamps byte offsets inside line terminators to line end.
390 #[test]
391 fn test_byte_to_lsp_pos_clamps_line_terminator_to_line_end() {
392 let rope = Rope::from_str("abc\r\ndef");
393
394 let pos = byte_to_lsp_pos(&rope, 4, PosEnc::Utf16);
395
396 assert_eq!(pos, Position { line: 0, character: 3 });
397 }
398
399 /// Test roundtrip: byte -> lsp position -> byte
400 #[test]
401 fn test_roundtrip_with_emoji() {
402 let rope = Rope::from_str("hi \u{1F600}x\nworld");
403
404 // Test 'x' position (byte 7)
405 let pos = byte_to_lsp_pos(&rope, 7, PosEnc::Utf16);
406 let back = lsp_pos_to_byte(&rope, pos, PosEnc::Utf16);
407 assert_eq!(back, 7, "Roundtrip should preserve byte offset");
408
409 // Test 'w' position (byte 9, line 1)
410 let pos2 = byte_to_lsp_pos(&rope, 9, PosEnc::Utf16);
411 assert_eq!(pos2.line, 1);
412 let back2 = lsp_pos_to_byte(&rope, pos2, PosEnc::Utf16);
413 assert_eq!(back2, 9, "Roundtrip on second line should work");
414 }
415}