Skip to main content

increparse_lsp/
line_index.rs

1//! Mapping between LSP `(line, character)` positions and byte offsets.
2
3use crate::encoding::PositionEncoding;
4use lsp_types::Position;
5
6/// A line-start table over one text snapshot.
7///
8/// Rebuild one per text revision ([`Document`](crate::Document) does this
9/// automatically); conversions are then O(log lines) plus O(line length) for
10/// the column walk.
11///
12/// # Examples
13///
14/// ```
15/// use increparse_lsp::{LineIndex, PositionEncoding};
16/// use lsp_types::Position;
17///
18/// let text = "héllo\nwörld";
19/// let index = LineIndex::new(text);
20///
21/// // "héllo" is 6 bytes / 5 UTF-16 units; end of line 0:
22/// assert_eq!(
23///     index.offset(text, Position::new(0, 5), PositionEncoding::Utf16),
24///     Some(6)
25/// );
26/// assert_eq!(
27///     index.position(text, 6, PositionEncoding::Utf16),
28///     Position::new(0, 5)
29/// );
30/// ```
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct LineIndex {
33    line_starts: Vec<usize>,
34    len: usize,
35}
36
37impl LineIndex {
38    /// Builds the index for `text`.
39    pub fn new(text: &str) -> Self {
40        let mut line_starts = vec![0usize];
41        for (i, byte) in text.bytes().enumerate() {
42            if byte == b'\n' {
43                line_starts.push(i + 1);
44            }
45        }
46        Self {
47            line_starts,
48            len: text.len(),
49        }
50    }
51
52    /// Number of lines (a trailing newline starts a final empty line).
53    pub fn line_count(&self) -> usize {
54        self.line_starts.len()
55    }
56
57    /// Byte offset where `line` begins.
58    pub fn line_start(&self, line: u32) -> Option<usize> {
59        self.line_starts.get(line as usize).copied()
60    }
61
62    /// Byte offset just past the last byte of `line`, excluding the line
63    /// terminator.
64    pub fn line_end(&self, text: &str, line: u32) -> Option<usize> {
65        let start = self.line_start(line)?;
66        let next = self
67            .line_starts
68            .get(line as usize + 1)
69            .copied()
70            .unwrap_or(self.len);
71        let mut end = next;
72        if end > start && text.as_bytes()[end - 1] == b'\n' {
73            end -= 1;
74        }
75        Some(end)
76    }
77
78    /// Converts a `Position` to a byte offset.
79    ///
80    /// Out-of-range lines clamp to the last line; overshooting columns clamp
81    /// to the end of the line (LSP clients legitimately send both while
82    /// typing). Returns `None` only if `position.line` overflows `u32`.
83    pub fn offset(
84        &self,
85        text: &str,
86        position: Position,
87        encoding: PositionEncoding,
88    ) -> Option<usize> {
89        let line = position.line.min(self.line_count() as u32 - 1);
90        let start = self.line_start(line)?;
91        let end = self.line_end(text, line)?;
92        let line_text = text
93            .get(start..end)
94            .unwrap_or_else(|| text.get(start..).unwrap_or(""));
95        Some(start + encoding.offset_of_units(line_text, position.character))
96    }
97
98    /// Converts a byte offset to a `Position`.
99    ///
100    /// Out-of-bounds offsets clamp to the text length; offsets inside a
101    /// multibyte character or a line terminator round down to the char
102    /// boundary before them.
103    pub fn position(&self, text: &str, offset: usize, encoding: PositionEncoding) -> Position {
104        let offset = offset.min(self.len);
105        let line = self
106            .line_starts
107            .partition_point(|&start| start <= offset)
108            .saturating_sub(1);
109        let start = self.line_starts[line];
110        let mut end = offset;
111        while end > start && !text.is_char_boundary(end) {
112            end -= 1;
113        }
114        let line_text = text.get(start..end).unwrap_or("");
115        Position {
116            line: line as u32,
117            character: encoding.units_of(line_text),
118        }
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    const TEXT: &str = "abc\ndefé\n\nx😀\n";
127
128    #[test]
129    fn line_structure() {
130        let index = LineIndex::new(TEXT);
131        assert_eq!(index.line_count(), 5);
132        assert_eq!(index.line_start(0), Some(0));
133        assert_eq!(index.line_start(1), Some(4));
134        assert_eq!(index.line_start(2), Some(10));
135        assert_eq!(index.line_start(3), Some(11));
136        assert_eq!(index.line_start(4), Some(17));
137        assert_eq!(index.line_start(5), None);
138        assert_eq!(index.line_end(TEXT, 0), Some(3));
139        assert_eq!(index.line_end(TEXT, 1), Some(9));
140        assert_eq!(index.line_end(TEXT, 2), Some(10));
141        assert_eq!(index.line_end(TEXT, 3), Some(16));
142        assert_eq!(index.line_end(TEXT, 4), Some(17));
143    }
144
145    #[test]
146    fn round_trip_utf16() {
147        let index = LineIndex::new(TEXT);
148        let enc = PositionEncoding::Utf16;
149
150        // Line 3 is "x😀": 'x' at byte 11, the 4-byte emoji at 12..16.
151        let pos = Position {
152            line: 3,
153            character: 1,
154        };
155        assert_eq!(index.offset(TEXT, pos, enc), Some(12));
156        assert_eq!(index.position(TEXT, 12, enc), pos);
157
158        // Character 2 lands inside the surrogate pair: clamps past it, and
159        // the position normalizes to the end of the line (3 units).
160        let overshoot = Position {
161            line: 3,
162            character: 2,
163        };
164        assert_eq!(index.offset(TEXT, overshoot, enc), Some(16));
165        assert_eq!(
166            index.position(TEXT, 16, enc),
167            Position {
168                line: 3,
169                character: 3
170            }
171        );
172    }
173
174    #[test]
175    fn round_trip_utf8_and_utf32() {
176        let index = LineIndex::new(TEXT);
177
178        // UTF-8 columns are byte offsets within the line.
179        assert_eq!(
180            index.offset(
181                TEXT,
182                Position {
183                    line: 3,
184                    character: 2
185                },
186                PositionEncoding::Utf8
187            ),
188            Some(13)
189        );
190        // Offset 13 is inside the emoji: rounds down to its first byte.
191        assert_eq!(
192            index.position(TEXT, 13, PositionEncoding::Utf8),
193            Position {
194                line: 3,
195                character: 1
196            }
197        );
198
199        // UTF-32 columns are code points.
200        assert_eq!(
201            index.offset(
202                TEXT,
203                Position {
204                    line: 3,
205                    character: 2
206                },
207                PositionEncoding::Utf32
208            ),
209            Some(16)
210        );
211        assert_eq!(
212            index.position(TEXT, 16, PositionEncoding::Utf32),
213            Position {
214                line: 3,
215                character: 2
216            }
217        );
218    }
219
220    #[test]
221    fn out_of_range_clamps() {
222        let index = LineIndex::new(TEXT);
223        let enc = PositionEncoding::Utf16;
224
225        assert_eq!(
226            index.offset(
227                TEXT,
228                Position {
229                    line: 99,
230                    character: 0
231                },
232                enc
233            ),
234            Some(17)
235        );
236        assert_eq!(
237            index.offset(
238                TEXT,
239                Position {
240                    line: 0,
241                    character: 99
242                },
243                enc
244            ),
245            Some(3)
246        );
247        assert_eq!(
248            index.position(TEXT, 999, enc),
249            Position {
250                line: 4,
251                character: 0
252            }
253        );
254    }
255
256    #[test]
257    fn empty_text_degenerates() {
258        let index = LineIndex::new("");
259        assert_eq!(index.line_count(), 1);
260        assert_eq!(
261            index.offset(
262                "",
263                Position {
264                    line: 0,
265                    character: 0
266                },
267                PositionEncoding::Utf16
268            ),
269            Some(0)
270        );
271        assert_eq!(
272            index.position("", 0, PositionEncoding::Utf16),
273            Position {
274                line: 0,
275                character: 0
276            }
277        );
278    }
279}