Skip to main content

perl_position_tracking/
mapper.rs

1//! Centralized position mapping for correct LSP position handling
2//!
3//! Handles:
4//! - CRLF/LF/CR line endings
5//! - UTF-16 code units (LSP protocol)
6//! - Byte offsets (parser)
7//! - Efficient conversions using rope data structure
8
9use crate::WirePosition as Position;
10use ropey::Rope;
11use serde_json::Value;
12
13/// Centralized position mapper using rope for efficiency.
14///
15/// Converts between byte offsets (used by the parser) and LSP positions
16/// (line/character in UTF-16 code units) while handling mixed line endings.
17///
18/// # Examples
19///
20/// ```
21/// use perl_position_tracking::PositionMapper;
22///
23/// let text = "my $x = 1;\nmy $y = 2;\n";
24/// let mapper = PositionMapper::new(text);
25///
26/// // Convert byte offset 0 → LSP position (line 0, char 0)
27/// let pos = mapper.byte_to_lsp_pos(0);
28/// assert_eq!(pos.line, 0);
29/// assert_eq!(pos.character, 0);
30///
31/// // Second line starts at byte 11
32/// let pos = mapper.byte_to_lsp_pos(11);
33/// assert_eq!(pos.line, 1);
34/// assert_eq!(pos.character, 0);
35/// ```
36pub struct PositionMapper {
37    /// The rope containing the document text
38    rope: Rope,
39    /// Cache of line ending style
40    line_ending: LineEnding,
41}
42
43/// Line ending style detected in a document
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum LineEnding {
46    /// Unix-style line endings (LF only)
47    Lf,
48    /// Windows-style line endings (CRLF)
49    CrLf,
50    /// Classic Mac line endings (CR only)
51    Cr,
52    /// Mixed line endings detected
53    Mixed,
54}
55
56impl PositionMapper {
57    /// Create a new position mapper from text.
58    ///
59    /// Detects line endings and builds an internal rope for efficient
60    /// position conversions.
61    ///
62    /// # Examples
63    ///
64    /// ```
65    /// use perl_position_tracking::PositionMapper;
66    ///
67    /// let mapper = PositionMapper::new("print 'hello';\n");
68    /// let pos = mapper.byte_to_lsp_pos(6);
69    /// assert_eq!(pos.line, 0);
70    /// assert_eq!(pos.character, 6);
71    /// ```
72    pub fn new(text: &str) -> Self {
73        let rope = Rope::from_str(text);
74        let line_ending = detect_line_ending(text);
75        Self { rope, line_ending }
76    }
77
78    /// Update the text content
79    pub fn update(&mut self, text: &str) {
80        self.rope = Rope::from_str(text);
81        self.line_ending = detect_line_ending(text);
82    }
83
84    /// Apply an incremental edit
85    pub fn apply_edit(&mut self, start_byte: usize, end_byte: usize, new_text: &str) {
86        // Clamp to valid range
87        let start_byte = start_byte.min(self.rope.len_bytes());
88        let end_byte = end_byte.min(self.rope.len_bytes());
89
90        // Convert byte offsets to char indices (rope uses chars!)
91        let start_char = self.rope.byte_to_char(start_byte);
92        let end_char = self.rope.byte_to_char(end_byte);
93
94        // Remove old text
95        if end_char > start_char {
96            self.rope.remove(start_char..end_char);
97        }
98
99        // Insert new text
100        if !new_text.is_empty() {
101            self.rope.insert(start_char, new_text);
102        }
103
104        // Update line ending detection
105        self.line_ending = detect_line_ending(&self.rope.to_string());
106    }
107
108    /// Convert LSP position to byte offset.
109    ///
110    /// Takes a line/character position (UTF-16 code units, as specified by the
111    /// LSP protocol) and returns the corresponding byte offset in the source.
112    ///
113    /// # Examples
114    ///
115    /// ```
116    /// use perl_position_tracking::{PositionMapper, WirePosition};
117    ///
118    /// let mapper = PositionMapper::new("my $x = 1;\nmy $y = 2;\n");
119    /// // Line 1, character 3 → "$y"
120    /// let byte = mapper.lsp_pos_to_byte(WirePosition { line: 1, character: 3 });
121    /// assert_eq!(byte, Some(14));
122    /// ```
123    pub fn lsp_pos_to_byte(&self, pos: Position) -> Option<usize> {
124        let line_idx = pos.line as usize;
125        if line_idx >= self.rope.len_lines() {
126            return None;
127        }
128
129        let line_start_byte = self.rope.line_to_byte(line_idx);
130        let line = self.rope.line(line_idx);
131
132        // Convert UTF-16 code units to byte offset
133        let mut utf16_offset = 0u32;
134        let mut byte_offset = 0;
135
136        for ch in line.chars() {
137            if utf16_offset >= pos.character {
138                break;
139            }
140
141            let ch_utf16_len = if ch as u32 > 0xFFFF { 2 } else { 1 };
142            let next_utf16 = utf16_offset + ch_utf16_len;
143
144            // Clamp positions inside a surrogate pair to the start of the
145            // code point, matching `utf16_line_col_to_offset`.
146            if next_utf16 > pos.character {
147                break;
148            }
149
150            utf16_offset = next_utf16;
151            byte_offset += ch.len_utf8();
152        }
153
154        Some(line_start_byte + byte_offset)
155    }
156
157    /// Convert byte offset to LSP position.
158    ///
159    /// Returns line/character (UTF-16 code units) suitable for LSP responses.
160    ///
161    /// # Examples
162    ///
163    /// ```
164    /// use perl_position_tracking::PositionMapper;
165    ///
166    /// let mapper = PositionMapper::new("sub foo {\n    return 1;\n}\n");
167    /// let pos = mapper.byte_to_lsp_pos(14);  // points into "return"
168    /// assert_eq!(pos.line, 1);
169    /// assert_eq!(pos.character, 4);
170    /// ```
171    pub fn byte_to_lsp_pos(&self, byte_offset: usize) -> Position {
172        let byte_offset = byte_offset.min(self.rope.len_bytes());
173
174        let line_idx = self.rope.byte_to_line(byte_offset);
175        let line_start_byte = self.rope.line_to_byte(line_idx);
176        let byte_in_line = byte_offset - line_start_byte;
177
178        // Convert byte offset to UTF-16 code units
179        let line = self.rope.line(line_idx);
180        let mut utf16_offset = 0u32;
181        let mut current_byte = 0;
182
183        for ch in line.chars() {
184            if current_byte >= byte_in_line {
185                break;
186            }
187            let ch_len = ch.len_utf8();
188            if current_byte + ch_len > byte_in_line {
189                // We're in the middle of this character
190                break;
191            }
192            current_byte += ch_len;
193            let ch_utf16_len = if ch as u32 > 0xFFFF { 2 } else { 1 };
194            utf16_offset += ch_utf16_len;
195        }
196
197        Position { line: line_idx as u32, character: utf16_offset }
198    }
199
200    /// Get the text content
201    pub fn text(&self) -> String {
202        self.rope.to_string()
203    }
204
205    /// Get a slice of text
206    pub fn slice(&self, start_byte: usize, end_byte: usize) -> String {
207        let start = start_byte.min(self.rope.len_bytes());
208        let end = end_byte.min(self.rope.len_bytes());
209        self.rope.slice(self.rope.byte_to_char(start)..self.rope.byte_to_char(end)).to_string()
210    }
211
212    /// Get total byte length
213    pub fn len_bytes(&self) -> usize {
214        self.rope.len_bytes()
215    }
216
217    /// Get total number of lines
218    pub fn len_lines(&self) -> usize {
219        self.rope.len_lines()
220    }
221
222    /// Convert LSP position to char index (for rope operations)
223    pub fn lsp_pos_to_char(&self, pos: Position) -> Option<usize> {
224        self.lsp_pos_to_byte(pos).map(|byte| self.rope.byte_to_char(byte))
225    }
226
227    /// Convert char index to LSP position
228    pub fn char_to_lsp_pos(&self, char_idx: usize) -> Position {
229        let byte_offset = self.rope.char_to_byte(char_idx);
230        self.byte_to_lsp_pos(byte_offset)
231    }
232
233    /// Check if empty
234    pub fn is_empty(&self) -> bool {
235        self.rope.len_bytes() == 0
236    }
237
238    /// Get line ending style
239    pub fn line_ending(&self) -> LineEnding {
240        self.line_ending
241    }
242}
243
244/// Convert JSON LSP position to our Position type.
245///
246/// Extracts line and character fields from a JSON object.
247pub fn json_to_position(pos: &Value) -> Option<Position> {
248    // Bounds-checked narrowing: an LSP position value > u32::MAX is invalid, so
249    // yield None rather than silently truncating to the low 32 bits (which would
250    // map e.g. line 0x1_0000_0000 to a bogus line 0).
251    Some(Position {
252        line: u32::try_from(pos["line"].as_u64()?).ok()?,
253        character: u32::try_from(pos["character"].as_u64()?).ok()?,
254    })
255}
256
257/// Convert Position to JSON for LSP.
258///
259/// Creates a JSON object with line and character fields.
260pub fn position_to_json(pos: Position) -> Value {
261    serde_json::json!({
262        "line": pos.line,
263        "character": pos.character
264    })
265}
266
267/// Detect the predominant line ending style
268fn detect_line_ending(text: &str) -> LineEnding {
269    let mut crlf_count = 0;
270    let mut lf_count = 0;
271    let mut cr_count = 0;
272
273    let bytes = text.as_bytes();
274    let mut i = 0;
275    while i < bytes.len() {
276        if i + 1 < bytes.len() && bytes[i] == b'\r' && bytes[i + 1] == b'\n' {
277            crlf_count += 1;
278            i += 2;
279        } else if bytes[i] == b'\n' {
280            lf_count += 1;
281            i += 1;
282        } else if bytes[i] == b'\r' {
283            cr_count += 1;
284            i += 1;
285        } else {
286            i += 1;
287        }
288    }
289
290    // Determine predominant style
291    if crlf_count > 0 && lf_count == 0 && cr_count == 0 {
292        LineEnding::CrLf
293    } else if lf_count > 0 && crlf_count == 0 && cr_count == 0 {
294        LineEnding::Lf
295    } else if cr_count > 0 && crlf_count == 0 && lf_count == 0 {
296        LineEnding::Cr
297    } else if crlf_count > 0 || lf_count > 0 || cr_count > 0 {
298        LineEnding::Mixed
299    } else {
300        LineEnding::Lf // Default
301    }
302}
303
304/// Apply UTF-8 edit to a string.
305///
306/// Replaces the byte range with the given replacement text.
307pub fn apply_edit_utf8(
308    text: &mut String,
309    start_byte: usize,
310    old_end_byte: usize,
311    replacement: &str,
312) {
313    if !text.is_char_boundary(start_byte) || !text.is_char_boundary(old_end_byte) {
314        // Safety: ensure we're at char boundaries
315        return;
316    }
317    text.replace_range(start_byte..old_end_byte, replacement);
318}
319
320/// Count newlines in text.
321///
322/// Returns the number of LF characters in the string.
323pub fn newline_count(text: &str) -> usize {
324    text.chars().filter(|&c| c == '\n').count()
325}
326
327/// Get the column (in UTF-8 bytes) of the last line.
328///
329/// Returns the byte offset from the last newline to the end of the string.
330pub fn last_line_column_utf8(text: &str) -> u32 {
331    // Clamp to u32::MAX on overflow rather than silently truncating to the low
332    // 32 bits. This only matters for a >4GB last line, but a saturating cast
333    // preserves "as large as representable" instead of wrapping to a small value.
334    let column = if let Some(last_newline) = text.rfind('\n') {
335        text.len() - last_newline - 1
336    } else {
337        text.len()
338    };
339    u32::try_from(column).unwrap_or(u32::MAX)
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use perl_tdd_support::must_some;
346
347    #[test]
348    fn test_lf_positions() {
349        let text = "line 1\nline 2\nline 3";
350        let mapper = PositionMapper::new(text);
351
352        // Start of document
353        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 0 }), Some(0));
354        assert_eq!(mapper.byte_to_lsp_pos(0), Position { line: 0, character: 0 });
355
356        // Middle of first line
357        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 3 }), Some(3));
358        assert_eq!(mapper.byte_to_lsp_pos(3), Position { line: 0, character: 3 });
359
360        // Start of second line
361        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 1, character: 0 }), Some(7));
362        assert_eq!(mapper.byte_to_lsp_pos(7), Position { line: 1, character: 0 });
363    }
364
365    #[test]
366    fn test_crlf_positions() {
367        let text = "line 1\r\nline 2\r\nline 3";
368        let mapper = PositionMapper::new(text);
369
370        assert_eq!(mapper.line_ending(), LineEnding::CrLf);
371
372        // Start of second line (after \r\n)
373        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 1, character: 0 }), Some(8));
374        assert_eq!(mapper.byte_to_lsp_pos(8), Position { line: 1, character: 0 });
375
376        // Start of third line
377        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 2, character: 0 }), Some(16));
378        assert_eq!(mapper.byte_to_lsp_pos(16), Position { line: 2, character: 0 });
379    }
380
381    #[test]
382    fn test_utf16_positions() {
383        let text = "hello 😀 world"; // Emoji is 2 UTF-16 code units
384        let mapper = PositionMapper::new(text);
385
386        // Before emoji
387        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 6 }), Some(6));
388
389        // After emoji (6 + 2 UTF-16 units = 8)
390        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 8 }), Some(10)); // 6 + 4 bytes for emoji
391
392        // Convert back
393        assert_eq!(mapper.byte_to_lsp_pos(10), Position { line: 0, character: 8 });
394    }
395
396    #[test]
397    fn test_utf16_positions_clamp_mid_surrogate_to_char_start() {
398        let text = "a😀b";
399        let mapper = PositionMapper::new(text);
400
401        // UTF-16 position 2 lands inside 😀 (which spans code units 1..3).
402        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 2 }), Some(1));
403    }
404
405    #[test]
406    fn test_utf16_surrogate_pair_boundaries() {
407        // 💖 (U+1F496) is a non-BMP char requiring a surrogate pair.
408        // Byte layout: 'x'=1 byte, '💖'=4 bytes (U+1F496), 'y'=1 byte.
409        // UTF-16 layout: 'x'=1 unit, '💖'=2 units (surrogate pair), 'y'=1 unit.
410        let text = "x💖y";
411        let mapper = PositionMapper::new(text);
412
413        // Before surrogate pair (column 0, 1)
414        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 0 }), Some(0));
415        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 1 }), Some(1));
416
417        // Mid-surrogate (column 2) — must clamp to start of emoji (byte 1),
418        // matching `utf16_line_col_to_offset` behavior.
419        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 2 }), Some(1));
420
421        // End of surrogate pair (column 3) — points just past emoji.
422        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 3 }), Some(5));
423
424        // After 'y' (column 4) — end of string.
425        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 4 }), Some(6));
426    }
427
428    #[test]
429    fn test_utf16_max_code_point() {
430        // U+10FFFF is the highest valid Unicode code point.
431        // Encoded as UTF-8 it's 4 bytes; in UTF-16 it's a surrogate pair (2 units).
432        let max_char = '\u{10FFFF}';
433        let text = format!("a{max_char}b");
434        let mapper = PositionMapper::new(&text);
435
436        // 'a' is col 0, U+10FFFF occupies cols 1..3, 'b' is col 3.
437        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 0 }), Some(0));
438        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 1 }), Some(1));
439        // Mid-surrogate clamp
440        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 2 }), Some(1));
441        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 3 }), Some(5));
442        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 4 }), Some(6));
443
444        // Round-trip the byte offsets back to positions (non-mid-surrogate).
445        assert_eq!(mapper.byte_to_lsp_pos(0), Position { line: 0, character: 0 });
446        assert_eq!(mapper.byte_to_lsp_pos(1), Position { line: 0, character: 1 });
447        assert_eq!(mapper.byte_to_lsp_pos(5), Position { line: 0, character: 3 });
448        assert_eq!(mapper.byte_to_lsp_pos(6), Position { line: 0, character: 4 });
449    }
450
451    #[test]
452    fn test_utf16_mixed_bmp_and_supplementary_plane() {
453        // é (U+00E9, BMP, 2 bytes UTF-8, 1 UTF-16 unit)
454        // 💖 (U+1F496, supplementary, 4 bytes UTF-8, 2 UTF-16 units)
455        // ñ (U+00F1, BMP, 2 bytes UTF-8, 1 UTF-16 unit)
456        // 🎉 (U+1F389, supplementary, 4 bytes UTF-8, 2 UTF-16 units)
457        let text = "aé💖ñ🎉b";
458        let mapper = PositionMapper::new(text);
459
460        // Columns:
461        //   a  = 0
462        //   é  = 1
463        //   💖 = 2..4 (surrogate pair)
464        //   ñ  = 4
465        //   🎉 = 5..7 (surrogate pair)
466        //   b  = 7
467        //   end = 8
468        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 0 }), Some(0)); // a
469        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 1 }), Some(1)); // é
470        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 2 }), Some(3)); // 💖 start
471        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 3 }), Some(3)); // mid-surrogate clamp
472        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 4 }), Some(7)); // ñ
473        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 5 }), Some(9)); // 🎉 start
474        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 6 }), Some(9)); // mid-surrogate clamp
475        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 7 }), Some(13)); // b
476        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 8 }), Some(14)); // end
477    }
478
479    #[test]
480    fn test_utf16_zero_length_input() {
481        let text = "";
482        let mapper = PositionMapper::new(text);
483
484        // An empty rope has one logical line (line 0) of length 0.
485        // Position (0, 0) should map to byte 0.
486        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 0 }), Some(0));
487        // Any character beyond 0 should clamp to byte 0 (end of empty line).
488        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 5 }), Some(0));
489
490        // Line past end of document returns None.
491        assert!(mapper.lsp_pos_to_byte(Position { line: 1, character: 0 }).is_none());
492
493        // Reverse direction: byte 0 should map to (0, 0).
494        assert_eq!(mapper.byte_to_lsp_pos(0), Position { line: 0, character: 0 });
495    }
496
497    #[test]
498    fn test_utf16_consecutive_surrogate_pairs() {
499        // Back-to-back supplementary-plane chars to ensure mid-surrogate
500        // clamping doesn't advance past the current char.
501        let text = "💖💖";
502        let mapper = PositionMapper::new(text);
503
504        // First 💖 is cols 0..2, second 💖 is cols 2..4.
505        // Bytes: first = 0..4, second = 4..8.
506        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 0 }), Some(0));
507        // Mid first surrogate pair — clamp to start of first emoji (byte 0).
508        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 1 }), Some(0));
509        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 2 }), Some(4));
510        // Mid second surrogate pair — clamp to start of second emoji (byte 4).
511        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 3 }), Some(4));
512        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 4 }), Some(8));
513    }
514
515    #[test]
516    fn test_utf16_clamp_matches_convert_helper() {
517        // Parity: PositionMapper::lsp_pos_to_byte should agree with the
518        // convert::utf16_line_col_to_offset helper at every column, including
519        // mid-surrogate positions. These are the two canonical UTF-16 -> byte
520        // converters and they must never disagree.
521        use crate::convert::utf16_line_col_to_offset;
522
523        let text = "a😀b💖c\nx💡y";
524        let mapper = PositionMapper::new(text);
525
526        // Line 0: "a😀b💖c"
527        //   a=0, 😀=1..3, b=3, 💖=4..6, c=6, end=7
528        for col in 0..=7 {
529            let mapper_byte =
530                mapper.lsp_pos_to_byte(Position { line: 0, character: col }).unwrap_or(usize::MAX);
531            let helper_byte = utf16_line_col_to_offset(text, 0, col);
532            assert_eq!(
533                mapper_byte, helper_byte,
534                "disagreement at line 0 col {col}: mapper={mapper_byte} helper={helper_byte}"
535            );
536        }
537    }
538
539    #[test]
540    fn test_mixed_line_endings() {
541        let text = "line 1\r\nline 2\nline 3\rline 4";
542        let mapper = PositionMapper::new(text);
543
544        assert_eq!(mapper.line_ending(), LineEnding::Mixed);
545
546        // Each line start
547        assert_eq!(mapper.byte_to_lsp_pos(0), Position { line: 0, character: 0 });
548        assert_eq!(mapper.byte_to_lsp_pos(8), Position { line: 1, character: 0 });
549        assert_eq!(mapper.byte_to_lsp_pos(15), Position { line: 2, character: 0 });
550        assert_eq!(mapper.byte_to_lsp_pos(22), Position { line: 3, character: 0 });
551    }
552
553    #[test]
554    fn test_incremental_edit() {
555        let mut mapper = PositionMapper::new("hello world");
556
557        // Replace "world" with "Rust"
558        mapper.apply_edit(6, 11, "Rust");
559        assert_eq!(mapper.text(), "hello Rust");
560
561        // Insert in middle
562        mapper.apply_edit(5, 5, " beautiful");
563        assert_eq!(mapper.text(), "hello beautiful Rust");
564
565        // Delete "beautiful " (keep one space)
566        mapper.apply_edit(5, 16, " ");
567        assert_eq!(mapper.text(), "hello Rust");
568    }
569
570    // --- Additional targeted tests for lsp_pos_to_byte, lsp_pos_to_char,
571    //     char_to_lsp_pos with multi-byte UTF-8, CRLF, and out-of-bounds ---
572
573    /// Round-trip: byte → lsp_pos → byte for a string containing a 🦀 (crab emoji,
574    /// U+1F980, 4 bytes UTF-8, 2 UTF-16 code units) and accented é (U+00E9,
575    /// 2 bytes UTF-8, 1 UTF-16 code unit).
576    #[test]
577    fn test_multibyte_utf8_round_trip_byte_to_lsp_pos_to_byte() {
578        // Layout (bytes):  'a'=1, 'é'=2, '🦀'=4, 'b'=1  → total 8 bytes
579        // UTF-16 columns:   a=0,   é=1,   🦀=2..4, b=4, end=5
580        let text = "aé🦀b";
581        let mapper = PositionMapper::new(text);
582
583        // Verify byte → LSP pos for the start of each character
584        assert_eq!(mapper.byte_to_lsp_pos(0), Position { line: 0, character: 0 }); // 'a'
585        assert_eq!(mapper.byte_to_lsp_pos(1), Position { line: 0, character: 1 }); // 'é'
586        assert_eq!(mapper.byte_to_lsp_pos(3), Position { line: 0, character: 2 }); // '🦀'
587        assert_eq!(mapper.byte_to_lsp_pos(7), Position { line: 0, character: 4 }); // 'b'
588
589        // Round-trip: lsp_pos → byte → lsp_pos must be identity for
590        // positions at the start of each character.
591        for (byte_offset, col) in [(0u32, 0u32), (1, 1), (3, 2), (7, 4)] {
592            let pos = Position { line: 0, character: col };
593            let got_byte = must_some(mapper.lsp_pos_to_byte(pos));
594            assert_eq!(
595                got_byte, byte_offset as usize,
596                "lsp_pos_to_byte for col {col} should be byte {byte_offset}"
597            );
598            // And back
599            assert_eq!(
600                mapper.byte_to_lsp_pos(got_byte),
601                pos,
602                "byte_to_lsp_pos should round-trip for col {col}"
603            );
604        }
605    }
606
607    /// lsp_pos_to_char and char_to_lsp_pos round-trip on a text containing é.
608    #[test]
609    fn test_lsp_pos_to_char_and_char_to_lsp_pos_round_trip() {
610        // 'é' is 2 UTF-8 bytes but 1 char index in the rope and 1 UTF-16 unit.
611        let text = "aéb";
612        let mapper = PositionMapper::new(text);
613
614        // lsp_pos_to_char: line 0, col 1 (UTF-16) → é starts at char index 1
615        let char_idx = must_some(mapper.lsp_pos_to_char(Position { line: 0, character: 1 }));
616        assert_eq!(char_idx, 1, "char index of 'é' is 1");
617
618        // char_to_lsp_pos: char index 1 → line 0, col 1 (UTF-16)
619        let pos = mapper.char_to_lsp_pos(char_idx);
620        assert_eq!(pos, Position { line: 0, character: 1 });
621
622        // Another round: 'b' is char index 2
623        let char_b = must_some(mapper.lsp_pos_to_char(Position { line: 0, character: 2 }));
624        assert_eq!(char_b, 2);
625        assert_eq!(mapper.char_to_lsp_pos(char_b), Position { line: 0, character: 2 });
626    }
627
628    /// CRLF multi-line text: verify lsp_pos_to_byte returns the correct byte offset
629    /// for positions on each line, including the byte that follows the \r\n pair.
630    #[test]
631    fn test_crlf_lsp_pos_to_byte_per_line() {
632        // "abc\r\ndef\r\nghi"
633        // bytes:  a=0,b=1,c=2,\r=3,\n=4,d=5,e=6,f=7,\r=8,\n=9,g=10,h=11,i=12
634        let text = "abc\r\ndef\r\nghi";
635        let mapper = PositionMapper::new(text);
636        assert_eq!(mapper.line_ending(), LineEnding::CrLf);
637
638        // Line 0 start
639        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 0 }), Some(0));
640        // Line 0 char 2 → byte 2 ('c')
641        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 0, character: 2 }), Some(2));
642        // Line 1 start → byte 5 ('d')
643        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 1, character: 0 }), Some(5));
644        // Line 1 char 2 → byte 7 ('f')
645        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 1, character: 2 }), Some(7));
646        // Line 2 start → byte 10 ('g')
647        assert_eq!(mapper.lsp_pos_to_byte(Position { line: 2, character: 0 }), Some(10));
648    }
649
650    /// Out-of-bounds line → lsp_pos_to_byte and lsp_pos_to_char return None.
651    #[test]
652    fn test_out_of_bounds_line_returns_none() {
653        let text = "one\ntwo\n";
654        let mapper = PositionMapper::new(text);
655
656        // The rope sees "one\ntwo\n" as 3 lines (line 0, 1, and an empty line 2).
657        // Line 3 does not exist.
658        assert!(
659            mapper.lsp_pos_to_byte(Position { line: 3, character: 0 }).is_none(),
660            "line past end of document should return None"
661        );
662        assert!(
663            mapper.lsp_pos_to_char(Position { line: 3, character: 0 }).is_none(),
664            "line past end of document should return None for lsp_pos_to_char"
665        );
666    }
667
668    /// Out-of-bounds column on a line — the implementation clamps to the end of
669    /// the line rather than returning None.
670    #[test]
671    fn test_out_of_bounds_column_clamps_to_line_end() {
672        let text = "hello\nworld\n";
673        let mapper = PositionMapper::new(text);
674
675        // "hello\n" has 5 visible chars (cols 0..5) + newline.
676        // Asking for col 9999 should clamp to the newline / end of line content.
677        let clamped = must_some(mapper.lsp_pos_to_byte(Position { line: 0, character: 9999 }));
678
679        // The byte returned must be within the span of line 0 (bytes 0..6 inclusive,
680        // where byte 5 is '\n').  We accept anything in [0, 6].
681        assert!(clamped <= 6, "clamped byte {clamped} should not exceed end of line 0 (byte 6)");
682    }
683
684    /// Regression for #2479: a JSON `line` value greater than `u32::MAX` must
685    /// yield `None` rather than silently truncating to the low 32 bits (which
686    /// would map `0x1_0000_0000` to a bogus line `0`).
687    #[test]
688    fn test_json_to_position_line_above_u32_max_returns_none() {
689        // 0x1_0000_0000 == u32::MAX + 1; its low 32 bits are all zero.
690        let over = u64::from(u32::MAX) + 1;
691        let json = serde_json::json!({ "line": over, "character": 0 });
692        assert!(
693            json_to_position(&json).is_none(),
694            "line value above u32::MAX must yield None, not a truncated position"
695        );
696
697        // Same guard on the character field.
698        let json = serde_json::json!({ "line": 0, "character": over });
699        assert!(
700            json_to_position(&json).is_none(),
701            "character value above u32::MAX must yield None, not a truncated position"
702        );
703
704        // A value exactly at u32::MAX is still valid and must round-trip.
705        let json = serde_json::json!({ "line": u32::MAX, "character": u32::MAX });
706        let pos = must_some(json_to_position(&json));
707        assert_eq!(pos.line, u32::MAX);
708        assert_eq!(pos.character, u32::MAX);
709    }
710
711    /// Regression for #2487: `last_line_column_utf8` must saturate to `u32::MAX`
712    /// rather than truncate to the low 32 bits when the last line is longer than
713    /// `u32::MAX` bytes.
714    ///
715    /// Allocating a real >4GB string in a unit test is impractical, so this test
716    /// asserts the saturating-cast invariant directly: any `usize` column above
717    /// `u32::MAX` must map to `u32::MAX`, and the value whose low 32 bits are zero
718    /// (`u32::MAX as usize + 1`) must NOT map to `0`.
719    #[test]
720    fn test_last_line_column_saturating_cast_no_low_bit_truncation() {
721        // The cast used by last_line_column_utf8 for the overflow case.
722        let saturating = |column: usize| -> u32 { u32::try_from(column).unwrap_or(u32::MAX) };
723
724        // Low 32 bits of (u32::MAX + 1) are zero — a naive `as u32` would return 0.
725        let over_by_one = u32::MAX as usize + 1;
726        assert_eq!(
727            saturating(over_by_one),
728            u32::MAX,
729            "a >u32::MAX-length last line must saturate to u32::MAX, not truncate to 0"
730        );
731
732        // Values within range are preserved exactly.
733        assert_eq!(saturating(0), 0);
734        assert_eq!(saturating(42), 42);
735        assert_eq!(saturating(u32::MAX as usize), u32::MAX);
736
737        // And the in-range path of the real function still works.
738        assert_eq!(last_line_column_utf8(""), 0);
739        assert_eq!(last_line_column_utf8("abc"), 3);
740        assert_eq!(last_line_column_utf8("a\nbc"), 2);
741    }
742}