Skip to main content

mos_core/
line_index.rs

1//! Immutable text snapshots with indexed UTF-16 source positions.
2
3use std::sync::Arc;
4
5const CHECKPOINT_BYTES: usize = 256;
6
7#[derive(Clone, Copy, Debug)]
8struct Checkpoint {
9    byte: usize,
10    column: usize,
11}
12
13#[derive(Clone, Copy, Debug)]
14struct Line {
15    start: usize,
16    checkpoint: usize,
17}
18
19/// An immutable UTF-8 snapshot and its reusable line/UTF-16 index.
20///
21/// Construction takes linear time. Lookups binary-search line starts and sparse
22/// checkpoints, then scan at most 256 bytes (plus one code point). The index
23/// owns its text so it cannot accidentally be used with a different revision.
24/// Cloning shares both text and index. No filesystem or protocol dependency.
25///
26/// LF starts a new line; CR is a normal character, preserving CRLF byte offsets.
27/// Columns and lines are zero-based. Tabs count as one UTF-16 code unit.
28///
29/// ```
30/// use mos_core::LineIndex;
31/// let source = LineIndex::new("😀\nhello");
32/// assert_eq!(source.utf16_position(4), (0, 2));
33/// assert_eq!(source.byte_offset(1, 2), 7);
34/// ```
35#[derive(Clone, Debug)]
36pub struct LineIndex {
37    text: Arc<str>,
38    lines: Arc<[Line]>,
39    checkpoints: Arc<[Checkpoint]>,
40}
41
42impl LineIndex {
43    /// Index a snapshot. Pass an `Arc<str>` to share existing text storage.
44    #[must_use]
45    pub fn new(text: impl Into<Arc<str>>) -> Self {
46        let text = text.into();
47        let mut lines = vec![Line {
48            start: 0,
49            checkpoint: 0,
50        }];
51        let mut checkpoints = Vec::new();
52        let mut checkpoint_byte = 0;
53        let mut column = 0;
54        for (byte, character) in text.char_indices() {
55            if byte - checkpoint_byte >= CHECKPOINT_BYTES {
56                checkpoints.push(Checkpoint { byte, column });
57                checkpoint_byte = byte;
58            }
59            if character == '\n' {
60                let start = byte + 1;
61                lines.push(Line {
62                    start,
63                    checkpoint: checkpoints.len(),
64                });
65                checkpoint_byte = start;
66                column = 0;
67            } else {
68                column += character.len_utf16();
69            }
70        }
71        Self {
72            text,
73            lines: lines.into(),
74            checkpoints: checkpoints.into(),
75        }
76    }
77
78    /// The exact text used to build this index.
79    #[must_use]
80    pub fn text(&self) -> &str {
81        &self.text
82    }
83
84    /// Convert a byte offset to a zero-based `(line, UTF-16 column)`.
85    /// Offsets past EOF clamp to EOF; offsets inside UTF-8 code points round down.
86    #[must_use]
87    pub fn utf16_position(&self, byte: usize) -> (usize, usize) {
88        let byte = self.text.floor_char_boundary(byte.min(self.text.len()));
89        let line = self.lines.partition_point(|line| line.start <= byte) - 1;
90        let checkpoints = self.line_checkpoints(line);
91        let checkpoint = checkpoints
92            .partition_point(|point| point.byte <= byte)
93            .checked_sub(1)
94            .map_or(
95                Checkpoint {
96                    byte: self.lines[line].start,
97                    column: 0,
98                },
99                |index| checkpoints[index],
100            );
101        let column = checkpoint.column + self.text[checkpoint.byte..byte].encode_utf16().count();
102        (line, column)
103    }
104
105    /// Convert a zero-based line and UTF-16 column to a byte offset.
106    /// Missing lines clamp to EOF; columns beyond a line clamp to its LF (or EOF).
107    /// A column inside a surrogate pair rounds up to the next code-point boundary.
108    #[must_use]
109    pub fn byte_offset(&self, line: usize, column: usize) -> usize {
110        let Some(_) = self.lines.get(line) else {
111            return self.text.len();
112        };
113        let checkpoints = self.line_checkpoints(line);
114        let checkpoint = checkpoints
115            .partition_point(|point| point.column <= column)
116            .checked_sub(1)
117            .map_or(
118                Checkpoint {
119                    byte: self.lines[line].start,
120                    column: 0,
121                },
122                |index| checkpoints[index],
123            );
124        let mut current_column = checkpoint.column;
125        for (offset, character) in self.text[checkpoint.byte..].char_indices() {
126            if character == '\n' || current_column >= column {
127                return checkpoint.byte + offset;
128            }
129            current_column += character.len_utf16();
130        }
131        self.text.len()
132    }
133
134    fn line_checkpoints(&self, line: usize) -> &[Checkpoint] {
135        let start = self.lines[line].checkpoint;
136        let end = self
137            .lines
138            .get(line + 1)
139            .map_or(self.checkpoints.len(), |next| next.checkpoint);
140        &self.checkpoints[start..end]
141    }
142}
143
144impl std::ops::Deref for LineIndex {
145    type Target = str;
146    fn deref(&self) -> &str {
147        self.text()
148    }
149}
150
151impl From<String> for LineIndex {
152    fn from(text: String) -> Self {
153        Self::new(text)
154    }
155}
156
157impl From<&str> for LineIndex {
158    fn from(text: &str) -> Self {
159        Self::new(text)
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn positions_preserve_unicode_and_newline_boundaries() {
169        let source = LineIndex::new("a😀\r\n字\n");
170        assert_eq!(source.utf16_position(3), (0, 1));
171        assert_eq!(source.utf16_position(5), (0, 3));
172        assert_eq!(source.utf16_position(6), (0, 4));
173        assert_eq!(source.utf16_position(7), (1, 0));
174        assert_eq!(source.utf16_position(usize::MAX), (2, 0));
175        assert_eq!(source.byte_offset(0, 2), 5);
176        assert_eq!(source.byte_offset(0, usize::MAX), 6);
177        assert_eq!(source.byte_offset(usize::MAX, 0), source.len());
178        assert_eq!(LineIndex::new("").utf16_position(1), (0, 0));
179        assert_eq!(LineIndex::new("").byte_offset(0, 1), 0);
180    }
181
182    #[test]
183    fn checkpoints_round_trip_long_lines_and_clones_share_storage() {
184        let text = format!("{}\n{}\n", "a😀é字\t".repeat(300), "x".repeat(1025));
185        let source = LineIndex::new(text);
186        for (byte, _) in source.char_indices() {
187            let (line, column) = source.utf16_position(byte);
188            assert_eq!(source.byte_offset(line, column), byte);
189        }
190        let clone = source.clone();
191        assert!(Arc::ptr_eq(&source.text, &clone.text));
192        assert!(Arc::ptr_eq(&source.lines, &clone.lines));
193        assert!(Arc::ptr_eq(&source.checkpoints, &clone.checkpoints));
194    }
195}