Skip to main content

swls_core/
text.rs

1//! Minimal text indexing for LSP position math.
2//!
3//! Replaces our previous use of [`ropey`]. We never actually used ropey for what
4//! ropey is *for* — incremental edits and cheap snapshots — because the server
5//! uses full-document sync and rebuilds the buffer from a `String` on every
6//! change. The only thing we used was index conversion, and ropey hid the one
7//! decision that actually matters for correctness: **which encoding a column is
8//! measured in**.
9//!
10//! [`LineIndex`] makes that explicit. It owns the source text plus a table of
11//! line-start byte offsets, and every conversion takes a [`PositionEncoding`] so
12//! the byte/UTF-16 choice is visible at the call site instead of buried in a
13//! dependency.
14//!
15//! ## Encodings
16//!
17//! An LSP `Position.character` is a count *from the start of its line*. The unit
18//! depends on the negotiated [`PositionEncoding`]:
19//!
20//! * [`PositionEncoding::Utf16`] — UTF-16 code units. This is the LSP **default**
21//!   and what clients assume unless the server negotiates otherwise.
22//! * [`PositionEncoding::Utf8`] — bytes. Cheapest, and what this codebase used to
23//!   emit unconditionally (correct only for ASCII).
24//!
25//! Internally everything is a UTF-8 **byte** offset; encodings only ever affect
26//! the in-line column number.
27
28use crate::lsp_types::Position;
29use std::ops::Range;
30
31/// The unit in which an LSP `Position.character` column is measured.
32///
33/// Defaults to [`Utf16`](PositionEncoding::Utf16), matching the LSP spec default.
34#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
35pub enum PositionEncoding {
36    /// UTF-16 code units (LSP default).
37    #[default]
38    Utf16,
39    /// Raw UTF-8 bytes.
40    Utf8,
41}
42
43/// Source text plus a line-start index, supporting byte ↔ [`Position`] conversion.
44///
45/// Owns the text so it can serve slices and per-line lookups without the caller
46/// also threading a `&str` through. This is the same memory profile as the old
47/// `RopeC(Rope)` (which already duplicated the `Source` string).
48#[derive(Clone, Debug, Default)]
49pub struct LineIndex {
50    text: String,
51    /// Byte offset of the start of each line. Always begins with `0`; its length
52    /// is the number of lines (a trailing `\n` yields a final empty line).
53    line_starts: Vec<usize>,
54}
55
56impl LineIndex {
57    /// Build an index over `text`, scanning once for line breaks (`O(n)`).
58    pub fn new(text: impl Into<String>) -> Self {
59        let text = text.into();
60        let mut line_starts = vec![0];
61        line_starts.extend(
62            text.bytes()
63                .enumerate()
64                .filter(|&(_, b)| b == b'\n')
65                .map(|(i, _)| i + 1),
66        );
67        Self { text, line_starts }
68    }
69
70    /// The full source text.
71    #[inline]
72    pub fn as_str(&self) -> &str {
73        &self.text
74    }
75
76    /// Total length in bytes.
77    #[inline]
78    pub fn len_bytes(&self) -> usize {
79        self.text.len()
80    }
81
82    /// Number of lines (a trailing newline counts as starting one more line).
83    #[inline]
84    pub fn len_lines(&self) -> usize {
85        self.line_starts.len()
86    }
87
88    /// Byte offset at which `line` starts, or `None` if out of range. A
89    /// one-past-the-end `line` is *not* accepted (use [`len_bytes`] for that).
90    #[inline]
91    pub fn line_start(&self, line: usize) -> Option<usize> {
92        self.line_starts.get(line).copied()
93    }
94
95    /// Byte range `[start, end)` of `line`, including its trailing newline (if any).
96    pub fn line_byte_range(&self, line: usize) -> Option<Range<usize>> {
97        let start = self.line_start(line)?;
98        let end = self.line_starts.get(line + 1).copied().unwrap_or(self.text.len());
99        Some(start..end)
100    }
101
102    /// Text of `line`, including its trailing newline (if any).
103    pub fn line_str(&self, line: usize) -> Option<&str> {
104        let r = self.line_byte_range(line)?;
105        self.text.get(r)
106    }
107
108    /// Sub-slice by **byte** range. Returns `None` for out-of-bounds ranges or
109    /// ranges that do not fall on `char` boundaries.
110    #[inline]
111    pub fn byte_slice(&self, range: Range<usize>) -> Option<&str> {
112        self.text.get(range)
113    }
114
115    /// The `char` starting at byte offset `byte`, if `byte` is a char boundary
116    /// within the text.
117    pub fn char_at_byte(&self, byte: usize) -> Option<char> {
118        self.text.get(byte..)?.chars().next()
119    }
120
121    /// Convert a byte offset to an LSP [`Position`] in the given encoding.
122    ///
123    /// Accepts `byte == len_bytes()` (end of document). Returns `None` if `byte`
124    /// is out of range or not on a char boundary.
125    pub fn byte_to_position(&self, byte: usize, encoding: PositionEncoding) -> Option<Position> {
126        if byte > self.text.len() {
127            return None;
128        }
129        let line = match self.line_starts.binary_search(&byte) {
130            Ok(line) => line,
131            Err(next) => next - 1, // next >= 1 because line_starts[0] == 0 <= byte
132        };
133        let line_start = self.line_starts[line];
134        let segment = self.text.get(line_start..byte)?; // None if not a char boundary
135        let character = match encoding {
136            PositionEncoding::Utf16 => segment.encode_utf16().count(),
137            PositionEncoding::Utf8 => segment.len(),
138        };
139        Some(Position::new(line as u32, character as u32))
140    }
141
142    /// Convert an LSP [`Position`] (interpreted in `encoding`) to a byte offset.
143    ///
144    /// A `character` past the end of its line is clamped to the line's end (which
145    /// keeps the lenient behaviour editors rely on when the cursor sits after the
146    /// last character). Returns `None` only if the line itself is out of range.
147    pub fn position_to_byte(&self, position: Position, encoding: PositionEncoding) -> Option<usize> {
148        let line = position.line as usize;
149        let line_range = self.line_byte_range(line)?;
150        let line_text = self.text.get(line_range.clone())?;
151        let target = position.character as usize;
152
153        let col_bytes = match encoding {
154            PositionEncoding::Utf8 => target.min(line_text.len()),
155            PositionEncoding::Utf16 => {
156                let mut utf16 = 0usize;
157                let mut found = None;
158                for (byte_off, ch) in line_text.char_indices() {
159                    if utf16 >= target {
160                        found = Some(byte_off);
161                        break;
162                    }
163                    utf16 += ch.len_utf16();
164                }
165                found.unwrap_or(line_text.len())
166            }
167        };
168        Some(line_range.start + col_bytes)
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    const U16: PositionEncoding = PositionEncoding::Utf16;
177    const U8: PositionEncoding = PositionEncoding::Utf8;
178
179    fn pos(l: u32, c: u32) -> Position {
180        Position::new(l, c)
181    }
182
183    #[test]
184    fn line_counting_matches_ropey_conventions() {
185        assert_eq!(LineIndex::new("").len_lines(), 1);
186        assert_eq!(LineIndex::new("abc").len_lines(), 1);
187        assert_eq!(LineIndex::new("abc\n").len_lines(), 2); // trailing newline → empty last line
188        assert_eq!(LineIndex::new("a\nb\nc").len_lines(), 3);
189    }
190
191    #[test]
192    fn ascii_byte_to_position() {
193        let idx = LineIndex::new("ab\ncde");
194        assert_eq!(idx.byte_to_position(0, U16), Some(pos(0, 0)));
195        assert_eq!(idx.byte_to_position(2, U16), Some(pos(0, 2))); // before '\n'
196        assert_eq!(idx.byte_to_position(3, U16), Some(pos(1, 0))); // start of line 1
197        assert_eq!(idx.byte_to_position(6, U16), Some(pos(1, 3))); // end of document
198        assert_eq!(idx.byte_to_position(7, U16), None); // past end
199    }
200
201    #[test]
202    fn two_byte_char_column_differs_by_encoding() {
203        // 'é' is 2 bytes, 1 UTF-16 unit. "xé" then 'y' at byte 3.
204        let idx = LineIndex::new("xéy");
205        let y_byte = "xé".len(); // 3
206        assert_eq!(idx.byte_to_position(y_byte, U16), Some(pos(0, 2))); // x, é
207        assert_eq!(idx.byte_to_position(y_byte, U8), Some(pos(0, 3))); // x, é(2 bytes)
208    }
209
210    #[test]
211    fn astral_char_is_two_utf16_units_one_char() {
212        // '😀' is 4 bytes, 2 UTF-16 units, 1 scalar. 'z' follows.
213        let idx = LineIndex::new("😀z");
214        let z_byte = "😀".len(); // 4
215        assert_eq!(idx.byte_to_position(z_byte, U16), Some(pos(0, 2))); // surrogate pair
216        assert_eq!(idx.byte_to_position(z_byte, U8), Some(pos(0, 4)));
217    }
218
219    #[test]
220    fn position_to_byte_roundtrips_utf16() {
221        for text in ["", "abc", "ab\ncde", "xéy\nfoo", "😀z\n€uro", "a\r\nb"] {
222            let idx = LineIndex::new(text);
223            // Every char boundary should round-trip byte → position → byte.
224            for (byte, _) in text.char_indices().chain(std::iter::once((text.len(), ' '))) {
225                let p = idx.byte_to_position(byte, U16).unwrap();
226                assert_eq!(
227                    idx.position_to_byte(p, U16),
228                    Some(byte),
229                    "roundtrip failed for {text:?} at byte {byte} (pos {p:?})"
230                );
231            }
232        }
233    }
234
235    #[test]
236    fn position_to_byte_clamps_past_end_of_line() {
237        let idx = LineIndex::new("ab\ncd");
238        // character way past the line length clamps to end of that line (incl. '\n').
239        assert_eq!(idx.position_to_byte(pos(0, 99), U16), Some(3)); // "ab\n" -> byte 3
240        assert_eq!(idx.position_to_byte(pos(1, 99), U16), Some(5)); // end of doc
241        assert_eq!(idx.position_to_byte(pos(9, 0), U16), None); // line out of range
242    }
243
244    #[test]
245    fn crlf_line_starts() {
246        let idx = LineIndex::new("a\r\nb");
247        assert_eq!(idx.len_lines(), 2);
248        // '\r' is part of line 0; line 1 starts after '\n'.
249        assert_eq!(idx.byte_to_position(3, U16), Some(pos(1, 0)));
250        assert_eq!(idx.line_str(0), Some("a\r\n"));
251        assert_eq!(idx.line_str(1), Some("b"));
252    }
253
254    #[test]
255    fn byte_slice_and_char_at_byte() {
256        let idx = LineIndex::new("xéy");
257        assert_eq!(idx.byte_slice(0..1), Some("x"));
258        assert_eq!(idx.byte_slice(0..2), None); // splits 'é'
259        assert_eq!(idx.char_at_byte(1), Some('é'));
260        assert_eq!(idx.char_at_byte(2), None); // mid-'é'
261        assert_eq!(idx.char_at_byte(3), Some('y'));
262    }
263}