Skip to main content

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}