Skip to main content

termesh_editor/
position.rs

1//! Char offsets in, protocol positions out.
2//!
3//! The editor counts chars (ADR-0006 §1); the language protocol counts UTF-16 code
4//! units within a line. Conversion lives here because this crate owns the rope, which
5//! keeps `termesh-lsp` rope-free and trading only in `TextPosition`.
6
7use ropey::Rope;
8
9/// `(line, character)` for a char offset, with `character` in UTF-16 code units.
10pub fn utf16_position(text: &Rope, offset: usize) -> (u32, u32) {
11    let offset = offset.min(text.len_chars());
12    let line = text.char_to_line(offset);
13    let line_start = text.line_to_char(line);
14    let character = text.line(line).char_to_utf16_cu(offset - line_start);
15    (line as u32, character as u32)
16}
17
18/// The char offset for a protocol position, clamped to the line and to the document.
19/// Servers do send out-of-range positions; clamping is the contract, not a bug.
20pub fn offset_from_utf16(text: &Rope, line: u32, character: u32) -> usize {
21    let line = line as usize;
22    if line >= text.len_lines() {
23        return text.len_chars();
24    }
25
26    let line_slice = text.line(line);
27    let content_len =
28        line_slice.len_chars().saturating_sub(usize::from(line_slice.chars().last() == Some('\n')));
29    let content = line_slice.slice(..content_len);
30    let character = (character as usize).min(content.len_utf16_cu());
31    text.line_to_char(line) + content.utf16_cu_to_char(character)
32}
33
34#[cfg(test)]
35mod tests {
36    use super::*;
37    use ropey::Rope;
38
39    #[test]
40    fn ascii_positions_round_trip() {
41        let text = Rope::from_str("fn main() {\n    let x = 1;\n}\n");
42        let offset = 16; // inside the second line
43        let (line, character) = utf16_position(&text, offset);
44        assert_eq!((line, character), (1, 4));
45        assert_eq!(offset_from_utf16(&text, line, character), offset);
46    }
47
48    #[test]
49    fn a_non_bmp_char_counts_as_two_utf16_units() {
50        // The surrogate-pair case is the one naive conversion gets wrong: one char,
51        // four UTF-8 bytes, two UTF-16 code units.
52        let text = Rope::from_str("let s = \"🦀\";\n");
53        let after_crab = text.line(0).chars().position(|c| c == '"').unwrap() + 2;
54        let (line, character) = utf16_position(&text, after_crab);
55        assert_eq!(line, 0);
56        assert_eq!(character, 11, "🦀 must count as two UTF-16 units, not one");
57        assert_eq!(offset_from_utf16(&text, line, character), after_crab);
58    }
59
60    #[test]
61    fn multibyte_bmp_chars_count_as_one_unit() {
62        let text = Rope::from_str("// café\n");
63        let end = text.line(0).len_chars() - 1;
64        let (_, character) = utf16_position(&text, end);
65        assert_eq!(character, end as u32);
66    }
67
68    #[test]
69    fn a_position_past_the_line_clamps_to_the_line_end() {
70        let text = Rope::from_str("ab\ncd\n");
71        assert_eq!(offset_from_utf16(&text, 0, 99), 2);
72    }
73
74    #[test]
75    fn a_position_past_the_document_clamps_to_the_end() {
76        let text = Rope::from_str("ab\n");
77        assert_eq!(offset_from_utf16(&text, 99, 0), text.len_chars());
78    }
79
80    #[test]
81    fn crlf_normalised_text_uses_buffer_offsets_not_disk_offsets() {
82        // Buffer::from_text normalises CRLF to LF, so positions are computed against
83        // the normalised text. Sending disk bytes instead would shift every position.
84        let text = Rope::from_str("a\nb\n");
85        let (line, character) = utf16_position(&text, 2);
86        assert_eq!((line, character), (1, 0));
87    }
88}