Skip to main content

tui_lipan/text/
line_index.rs

1//! Snapshot line/column indexing for UTF-8 text.
2
3use std::ops::Range;
4
5use crate::utils::text::clamp_cursor;
6
7/// Zero-based text position in a logical line.
8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
9pub struct TextPosition {
10    /// Zero-based logical line.
11    pub line: usize,
12    /// Zero-based Unicode-scalar column by default.
13    pub column: usize,
14}
15
16/// Half-open text range expressed as line/column positions.
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
18pub struct TextRange {
19    /// Start position of the range.
20    pub start: TextPosition,
21    /// End position of the range.
22    pub end: TextPosition,
23}
24
25/// Column encoding used when projecting byte offsets to text positions.
26#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
27pub enum TextEncoding {
28    /// Column is a UTF-8 byte offset from the start of the line.
29    Utf8,
30    /// Column is a UTF-16 code-unit offset from the start of the line.
31    Utf16,
32    /// Column is a Unicode scalar count from the start of the line.
33    #[default]
34    UnicodeScalar,
35}
36
37/// Snapshot index of logical line starts for a UTF-8 text buffer.
38///
39/// `LineIndex` stores only line-start byte offsets and the source text length.
40/// Methods that need to inspect content take `&str`; callers should rebuild the
41/// index when that text changes. Only `\n` creates a new logical line, so `\r`
42/// remains ordinary content. Empty text has one logical line, and a trailing
43/// `\n` creates a final empty line.
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct LineIndex {
46    line_starts: Vec<usize>,
47    text_len: usize,
48}
49
50impl LineIndex {
51    /// Build a line index for a text snapshot.
52    pub fn new(text: &str) -> Self {
53        let mut line_starts = vec![0];
54        for (byte, ch) in text.char_indices() {
55            if ch == '\n' {
56                line_starts.push(byte + ch.len_utf8());
57            }
58        }
59
60        Self {
61            line_starts,
62            text_len: text.len(),
63        }
64    }
65
66    /// Return the byte length of the text snapshot used to build this index.
67    pub fn text_len(&self) -> usize {
68        self.text_len
69    }
70
71    /// Return the number of logical lines in this snapshot.
72    pub fn line_count(&self) -> usize {
73        self.line_starts.len()
74    }
75
76    /// Return the byte offset at the start of `line`, clamped to an existing line.
77    pub fn line_start(&self, line: usize) -> usize {
78        self.line_starts[self.clamp_line(line)]
79    }
80
81    /// Return the byte offset at the end of `line`, excluding a trailing newline.
82    pub fn line_end(&self, text: &str, line: usize) -> usize {
83        let line = self.clamp_line(line);
84        let end = if line + 1 < self.line_starts.len() {
85            self.line_starts[line + 1].saturating_sub(1)
86        } else {
87            text.len()
88        };
89
90        clamp_cursor(text, end.min(text.len()))
91    }
92
93    /// Return the byte offset at the end of `line`, including its newline if present.
94    pub fn line_end_including_newline(&self, text: &str, line: usize) -> usize {
95        let line = self.clamp_line(line);
96        let end = if line + 1 < self.line_starts.len() {
97            self.line_starts[line + 1]
98        } else {
99            text.len()
100        };
101
102        clamp_cursor(text, end.min(text.len()))
103    }
104
105    /// Return the byte range for `line`, excluding a trailing newline.
106    pub fn line_range(&self, text: &str, line: usize) -> Range<usize> {
107        self.line_start(line).min(text.len())..self.line_end(text, line)
108    }
109
110    /// Convert a byte offset to a Unicode-scalar line/column position.
111    pub fn byte_to_position(&self, text: &str, byte: usize) -> TextPosition {
112        self.byte_to_position_with_encoding(text, byte, TextEncoding::UnicodeScalar)
113    }
114
115    /// Convert a Unicode-scalar line/column position to a byte offset.
116    pub fn position_to_byte(&self, text: &str, position: TextPosition) -> usize {
117        self.position_to_byte_with_encoding(text, position, TextEncoding::UnicodeScalar)
118    }
119
120    /// Convert a byte offset to a line/column position using `encoding` for columns.
121    pub fn byte_to_position_with_encoding(
122        &self,
123        text: &str,
124        byte: usize,
125        encoding: TextEncoding,
126    ) -> TextPosition {
127        let byte = clamp_cursor(text, byte);
128        let line = self.line_for_byte(byte);
129        let start = self.line_start(line).min(byte);
130        let column_text = &text[start..byte];
131        let column = match encoding {
132            TextEncoding::Utf8 => byte - start,
133            TextEncoding::Utf16 => column_text.chars().map(char::len_utf16).sum(),
134            TextEncoding::UnicodeScalar => column_text.chars().count(),
135        };
136
137        TextPosition { line, column }
138    }
139
140    /// Convert a line/column position using `encoding` for columns to a byte offset.
141    pub fn position_to_byte_with_encoding(
142        &self,
143        text: &str,
144        position: TextPosition,
145        encoding: TextEncoding,
146    ) -> usize {
147        let line = self.clamp_line(position.line);
148        let start = self.line_start(line).min(text.len());
149        let end = self.line_end(text, line).max(start);
150        let line_text = &text[start..end];
151        let offset = match encoding {
152            TextEncoding::Utf8 => clamp_cursor(line_text, position.column),
153            TextEncoding::Utf16 => byte_offset_for_utf16_column(line_text, position.column),
154            TextEncoding::UnicodeScalar => {
155                byte_offset_for_scalar_column(line_text, position.column)
156            }
157        };
158
159        start + offset
160    }
161
162    /// Convert a byte range to a text range using Unicode-scalar columns.
163    pub fn byte_range_to_range(&self, text: &str, range: Range<usize>) -> TextRange {
164        TextRange {
165            start: self.byte_to_position(text, range.start),
166            end: self.byte_to_position(text, range.end),
167        }
168    }
169
170    /// Convert a text range using Unicode-scalar columns to a byte range.
171    pub fn range_to_byte_range(&self, text: &str, range: TextRange) -> Range<usize> {
172        self.position_to_byte(text, range.start)..self.position_to_byte(text, range.end)
173    }
174
175    fn clamp_line(&self, line: usize) -> usize {
176        line.min(self.line_starts.len().saturating_sub(1))
177    }
178
179    fn line_for_byte(&self, byte: usize) -> usize {
180        self.line_starts
181            .partition_point(|&start| start <= byte)
182            .saturating_sub(1)
183            .min(self.line_starts.len().saturating_sub(1))
184    }
185}
186
187fn byte_offset_for_scalar_column(text: &str, column: usize) -> usize {
188    text.char_indices()
189        .map(|(byte, _)| byte)
190        .nth(column)
191        .unwrap_or(text.len())
192}
193
194fn byte_offset_for_utf16_column(text: &str, column: usize) -> usize {
195    let mut units = 0;
196    for (byte, ch) in text.char_indices() {
197        let next_units = units + ch.len_utf16();
198        if next_units > column {
199            return byte;
200        }
201        units = next_units;
202        if units == column {
203            return byte + ch.len_utf8();
204        }
205    }
206    text.len()
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn indexes_empty_text_as_one_line() {
215        let text = "";
216        let index = LineIndex::new(text);
217
218        assert_eq!(index.text_len(), 0);
219        assert_eq!(index.line_count(), 1);
220        assert_eq!(index.line_start(0), 0);
221        assert_eq!(index.line_end(text, 0), 0);
222        assert_eq!(index.line_end_including_newline(text, 0), 0);
223        assert_eq!(index.line_range(text, 0), 0..0);
224        assert_eq!(
225            index.byte_to_position(text, 0),
226            TextPosition { line: 0, column: 0 }
227        );
228    }
229
230    #[test]
231    fn indexes_single_line() {
232        let text = "hello";
233        let index = LineIndex::new(text);
234
235        assert_eq!(index.line_count(), 1);
236        assert_eq!(index.line_start(0), 0);
237        assert_eq!(index.line_end(text, 0), 5);
238        assert_eq!(
239            index.byte_to_position(text, 3),
240            TextPosition { line: 0, column: 3 }
241        );
242        assert_eq!(
243            index.position_to_byte(text, TextPosition { line: 0, column: 4 }),
244            4
245        );
246    }
247
248    #[test]
249    fn indexes_multiple_lines() {
250        let text = "alpha\nbeta\ngamma";
251        let index = LineIndex::new(text);
252
253        assert_eq!(index.line_count(), 3);
254        assert_eq!(index.line_start(1), 6);
255        assert_eq!(index.line_end(text, 1), 10);
256        assert_eq!(index.line_end_including_newline(text, 1), 11);
257        assert_eq!(
258            index.byte_to_position(text, 8),
259            TextPosition { line: 1, column: 2 }
260        );
261    }
262
263    #[test]
264    fn trailing_newline_creates_final_empty_line() {
265        let text = "alpha\n";
266        let index = LineIndex::new(text);
267
268        assert_eq!(index.line_count(), 2);
269        assert_eq!(index.line_start(1), text.len());
270        assert_eq!(index.line_end(text, 1), text.len());
271        assert_eq!(
272            index.byte_to_position(text, text.len()),
273            TextPosition { line: 1, column: 0 }
274        );
275    }
276
277    #[test]
278    fn indexes_consecutive_empty_lines() {
279        let text = "a\n\nb";
280        let index = LineIndex::new(text);
281
282        assert_eq!(index.line_count(), 3);
283        assert_eq!(index.line_range(text, 1), 2..2);
284        assert_eq!(
285            index.byte_to_position(text, 2),
286            TextPosition { line: 1, column: 0 }
287        );
288        assert_eq!(
289            index.byte_to_position(text, 3),
290            TextPosition { line: 2, column: 0 }
291        );
292    }
293
294    #[test]
295    fn counts_non_ascii_columns_as_scalars_by_default() {
296        let text = "aé你";
297        let index = LineIndex::new(text);
298
299        assert_eq!(
300            index.byte_to_position(text, 3),
301            TextPosition { line: 0, column: 2 }
302        );
303        assert_eq!(
304            index.position_to_byte(text, TextPosition { line: 0, column: 2 }),
305            3
306        );
307        assert_eq!(
308            index.position_to_byte(text, TextPosition { line: 0, column: 3 }),
309            text.len()
310        );
311    }
312
313    #[test]
314    fn clamps_bytes_inside_codepoint_to_previous_boundary() {
315        let text = "aéb";
316        let index = LineIndex::new(text);
317
318        assert_eq!(
319            index.byte_to_position(text, 2),
320            TextPosition { line: 0, column: 1 }
321        );
322        assert_eq!(
323            index.byte_to_position_with_encoding(text, 2, TextEncoding::Utf8),
324            TextPosition { line: 0, column: 1 }
325        );
326    }
327
328    #[test]
329    fn supports_utf16_columns_for_non_bmp_characters() {
330        let text = "a💩b";
331        let index = LineIndex::new(text);
332
333        assert_eq!(
334            index.byte_to_position_with_encoding(text, 5, TextEncoding::Utf16),
335            TextPosition { line: 0, column: 3 }
336        );
337        assert_eq!(
338            index.position_to_byte_with_encoding(
339                text,
340                TextPosition { line: 0, column: 1 },
341                TextEncoding::Utf16
342            ),
343            1
344        );
345        assert_eq!(
346            index.position_to_byte_with_encoding(
347                text,
348                TextPosition { line: 0, column: 2 },
349                TextEncoding::Utf16
350            ),
351            1
352        );
353        assert_eq!(
354            index.position_to_byte_with_encoding(
355                text,
356                TextPosition { line: 0, column: 3 },
357                TextEncoding::Utf16
358            ),
359            5
360        );
361        assert_eq!(
362            index.position_to_byte_with_encoding(
363                text,
364                TextPosition { line: 0, column: 4 },
365                TextEncoding::Utf16
366            ),
367            6
368        );
369    }
370
371    #[test]
372    fn converts_ranges_round_trip() {
373        let text = "ab\nc💩d";
374        let index = LineIndex::new(text);
375        let bytes = 1..8;
376        let range = index.byte_range_to_range(text, bytes.clone());
377
378        assert_eq!(range.start, TextPosition { line: 0, column: 1 });
379        assert_eq!(range.end, TextPosition { line: 1, column: 2 });
380        assert_eq!(index.range_to_byte_range(text, range), bytes);
381    }
382
383    #[test]
384    fn clamps_out_of_range_line_and_column() {
385        let text = "abc\nde";
386        let index = LineIndex::new(text);
387
388        assert_eq!(index.line_start(99), 4);
389        assert_eq!(index.line_end(text, 99), text.len());
390        assert_eq!(
391            index.position_to_byte(
392                text,
393                TextPosition {
394                    line: 99,
395                    column: 99
396                }
397            ),
398            text.len()
399        );
400        assert_eq!(
401            index.byte_to_position(text, 999),
402            TextPosition { line: 1, column: 2 }
403        );
404    }
405
406    #[test]
407    fn treats_carriage_return_as_content() {
408        let text = "a\rb\nc";
409        let index = LineIndex::new(text);
410
411        assert_eq!(index.line_count(), 2);
412        assert_eq!(
413            index.byte_to_position(text, 3),
414            TextPosition { line: 0, column: 3 }
415        );
416    }
417}