Skip to main content

perl_position_tracking/
convert.rs

1//! UTF-8/UTF-16 position conversion functions.
2//!
3//! The helpers in this module follow Language Server Protocol (LSP) semantics,
4//! where lines and columns are zero-based and columns are measured in UTF-16
5//! code units.
6
7fn line_content_end(line: &str) -> usize {
8    let without_lf = line.strip_suffix('\n').unwrap_or(line);
9    without_lf.strip_suffix('\r').unwrap_or(without_lf).len()
10}
11
12fn text_end_utf16_line_col(text: &str) -> (u32, u32) {
13    if text.is_empty() {
14        return (0, 0);
15    }
16    if text.ends_with('\n') {
17        return (text.split_inclusive('\n').count() as u32, 0);
18    }
19
20    let mut last_line = 0u32;
21    let mut last_col = 0u32;
22    for (idx, line) in text.split_inclusive('\n').enumerate() {
23        last_line = idx as u32;
24        last_col = line[..line_content_end(line)].encode_utf16().count() as u32;
25    }
26    (last_line, last_col)
27}
28
29/// Converts a byte offset into `(line, column_utf16)` coordinates.
30///
31/// Offsets beyond the end of the document are clamped to the last valid
32/// position.
33pub fn offset_to_utf16_line_col(text: &str, offset: usize) -> (u32, u32) {
34    if offset >= text.len() {
35        return text_end_utf16_line_col(text);
36    }
37    let mut acc = 0usize;
38    for (line_idx, line) in text.split_inclusive('\n').enumerate() {
39        let next = acc + line.len();
40        if offset < next {
41            let rel = offset - acc;
42            if rel == 0 {
43                return (line_idx as u32, 0);
44            }
45
46            let content_end = line_content_end(line);
47            if rel >= content_end {
48                return (line_idx as u32, line[..content_end].encode_utf16().count() as u32);
49            }
50            if line.is_char_boundary(rel) {
51                return (line_idx as u32, line[..rel].encode_utf16().count() as u32);
52            }
53            let mut cs = rel;
54            while cs > 0 && !line.is_char_boundary(cs) {
55                cs -= 1;
56            }
57            // Clamp to the previous Unicode scalar boundary.
58            // Returning a half-surrogate UTF-16 column would violate LSP invariants.
59            return (line_idx as u32, line[..cs].encode_utf16().count() as u32);
60        }
61        acc = next;
62    }
63    text_end_utf16_line_col(text)
64}
65
66/// Converts `(line, column_utf16)` coordinates into a byte offset.
67///
68/// If the provided line or column is out of bounds, the result is clamped to
69/// the nearest valid byte position in `text`.
70pub fn utf16_line_col_to_offset(text: &str, line: u32, col: u32) -> usize {
71    let mut offset = 0;
72    for (curr, lt) in text.split_inclusive('\n').enumerate() {
73        if curr as u32 == line {
74            if col == 0 {
75                return offset;
76            }
77            let line_end = line_content_end(lt);
78            let line_content = &lt[..line_end];
79            let mut up = 0u32;
80            for (bi, ch) in line_content.char_indices() {
81                if up == col {
82                    return offset + bi;
83                }
84                if up < col && col < up + ch.len_utf16() as u32 {
85                    return offset + bi;
86                }
87                up += ch.len_utf16() as u32;
88                if up > col {
89                    return offset + bi;
90                }
91            }
92            return offset + line_end.min(text.len() - offset);
93        }
94        offset += lt.len();
95    }
96    text.len()
97}
98
99#[cfg(test)]
100mod tests {
101    use super::{offset_to_utf16_line_col, utf16_line_col_to_offset};
102
103    #[test]
104    fn offset_to_utf16_clamps_mid_codepoint_offsets_to_previous_boundary() {
105        let text = "💖z";
106
107        // Byte offset 1 sits inside the first UTF-8 codepoint (the emoji).
108        // The reported UTF-16 column must stay on a valid boundary (0 or 2).
109        assert_eq!(offset_to_utf16_line_col(text, 1), (0, 0));
110        assert_eq!(offset_to_utf16_line_col(text, 2), (0, 0));
111        assert_eq!(offset_to_utf16_line_col(text, 3), (0, 0));
112    }
113
114    #[test]
115    fn offset_to_utf16_handles_multibyte_and_surrogate_pairs() {
116        let text = "aé💖z";
117
118        assert_eq!(offset_to_utf16_line_col(text, 0), (0, 0));
119        assert_eq!(offset_to_utf16_line_col(text, 1), (0, 1));
120        assert_eq!(offset_to_utf16_line_col(text, 3), (0, 2));
121        assert_eq!(offset_to_utf16_line_col(text, 7), (0, 4));
122        assert_eq!(offset_to_utf16_line_col(text, text.len()), (0, 5));
123    }
124
125    #[test]
126    fn offset_to_utf16_clamps_out_of_bounds_to_last_position() {
127        let text = "alpha\nbeta";
128        assert_eq!(offset_to_utf16_line_col(text, text.len() + 25), (1, 4));
129    }
130
131    #[test]
132    fn offset_to_utf16_reports_new_empty_line_for_terminal_newline() {
133        let text = "one\ntwo\n";
134        assert_eq!(offset_to_utf16_line_col(text, text.len()), (2, 0));
135        assert_eq!(offset_to_utf16_line_col(text, text.len() + 25), (2, 0));
136
137        let crlf_text = "one\r\ntwo\r\n";
138        assert_eq!(offset_to_utf16_line_col(crlf_text, crlf_text.len()), (2, 0));
139        assert_eq!(offset_to_utf16_line_col(crlf_text, crlf_text.len() + 25), (2, 0));
140    }
141
142    #[test]
143    fn utf16_line_col_to_offset_handles_split_surrogate_column() {
144        let text = "x💖y";
145        assert_eq!(utf16_line_col_to_offset(text, 0, 0), 0);
146        assert_eq!(utf16_line_col_to_offset(text, 0, 1), 1);
147        assert_eq!(utf16_line_col_to_offset(text, 0, 2), 1);
148        assert_eq!(utf16_line_col_to_offset(text, 0, 3), 5);
149        assert_eq!(utf16_line_col_to_offset(text, 0, 4), 6);
150    }
151
152    #[test]
153    fn utf16_line_col_to_offset_clamps_when_column_or_line_is_too_large() {
154        let text = "abc\nw💡";
155        assert_eq!(utf16_line_col_to_offset(text, 1, 99), text.len());
156        assert_eq!(utf16_line_col_to_offset(text, 99, 0), text.len());
157    }
158
159    #[test]
160    fn utf16_helpers_handle_crlf_and_surrogates_together() {
161        let text = "a\r\nb💖c\r\n";
162
163        assert_eq!(offset_to_utf16_line_col(text, 3), (1, 0));
164        assert_eq!(offset_to_utf16_line_col(text, 8), (1, 3));
165        assert_eq!(offset_to_utf16_line_col(text, 9), (1, 4));
166        assert_eq!(offset_to_utf16_line_col(text, 10), (1, 4));
167        assert_eq!(utf16_line_col_to_offset(text, 1, 2), 4);
168        assert_eq!(utf16_line_col_to_offset(text, 1, 3), 8);
169        assert_eq!(utf16_line_col_to_offset(text, 1, 4), 9);
170        assert_eq!(utf16_line_col_to_offset(text, 1, 99), 9);
171    }
172
173    #[test]
174    fn utf16_helpers_exclude_crlf_from_line_columns() {
175        let text = "ab\r\ncd";
176
177        assert_eq!(offset_to_utf16_line_col(text, 2), (0, 2));
178        assert_eq!(offset_to_utf16_line_col(text, 3), (0, 2));
179        assert_eq!(utf16_line_col_to_offset(text, 0, 2), 2);
180        assert_eq!(utf16_line_col_to_offset(text, 0, 99), 2);
181    }
182}