Skip to main content

span_lang/
index.rs

1//! The line index: byte offset ↔ line/column resolution over one source.
2
3use alloc::boxed::Box;
4use alloc::vec::Vec;
5
6use crate::{BytePos, LineCol};
7
8/// An index over a single source string that maps byte offsets to line/column
9/// coordinates and back.
10///
11/// A `LineIndex` is built once per source. Construction is a single linear scan
12/// that records the byte offset at which each line begins; after that, a forward
13/// lookup ([`line_col`](LineIndex::line_col)) is a binary search over those line
14/// starts — `O(log lines)` — followed by a character count within the one located
15/// line. Neither lookup direction allocates.
16///
17/// The index borrows the source rather than owning it: this crate maps positions
18/// and does not load text, so the caller keeps ownership of the buffer the index
19/// points into.
20///
21/// # Line endings
22///
23/// A line begins immediately after each `\n`. A `\r\n` sequence is therefore one
24/// line break, not two — the `\r` is the final character of the preceding line.
25/// A source with no trailing newline ends with a final line that has no
26/// terminator, and the empty string is one empty line.
27///
28/// # Examples
29///
30/// ```
31/// use span_lang::{BytePos, LineCol, LineIndex};
32///
33/// let index = LineIndex::new("let x = 1;\nlet y = 2;\n");
34///
35/// // Forward: byte offset -> (line, column).
36/// let lc = index.line_col(BytePos::new(11)); // first byte of line 2
37/// assert_eq!(lc, LineCol::new(2, 1));
38///
39/// // Inverse: (line, column) -> byte offset.
40/// assert_eq!(index.offset(lc), Some(BytePos::new(11)));
41/// ```
42#[derive(Debug, Clone)]
43pub struct LineIndex<'src> {
44    src: &'src str,
45    /// Byte offset of the first character of each line. Always starts with `0`,
46    /// so it is never empty and a forward lookup can never underflow.
47    line_starts: Box<[u32]>,
48}
49
50impl<'src> LineIndex<'src> {
51    /// Builds an index over `src`.
52    ///
53    /// This is the only `O(n)` operation in the type; every subsequent lookup is
54    /// sub-linear. `src` must be at most `u32::MAX` bytes — the addressing limit
55    /// of [`BytePos`] — which holds for any single source a language front-end
56    /// loads.
57    ///
58    /// # Examples
59    ///
60    /// ```
61    /// use span_lang::LineIndex;
62    ///
63    /// let index = LineIndex::new("one\ntwo\nthree");
64    /// assert_eq!(index.line_count(), 3);
65    /// ```
66    #[must_use]
67    pub fn new(src: &'src str) -> Self {
68        // One line start at offset 0, plus one immediately after every `\n`.
69        // Pre-size from a coarse average line length to avoid reallocating as the
70        // scan progresses; the exact count is data-dependent.
71        let mut line_starts = Vec::with_capacity(src.len() / 24 + 1);
72        line_starts.push(0);
73        for (i, &byte) in src.as_bytes().iter().enumerate() {
74            if byte == b'\n' {
75                // `i < src.len() <= u32::MAX`, so `i + 1` fits in `u32`.
76                line_starts.push(i as u32 + 1);
77            }
78        }
79        Self {
80            src,
81            line_starts: line_starts.into_boxed_slice(),
82        }
83    }
84
85    /// Returns the number of lines in the source.
86    ///
87    /// This counts line *starts*: one, plus the number of `\n` bytes. The empty
88    /// string is one line, and a trailing newline introduces a final empty line.
89    ///
90    /// # Examples
91    ///
92    /// ```
93    /// use span_lang::LineIndex;
94    ///
95    /// assert_eq!(LineIndex::new("").line_count(), 1);
96    /// assert_eq!(LineIndex::new("a\nb").line_count(), 2);
97    /// assert_eq!(LineIndex::new("a\nb\n").line_count(), 3);
98    /// ```
99    #[inline]
100    #[must_use]
101    pub fn line_count(&self) -> usize {
102        self.line_starts.len()
103    }
104
105    /// Resolves a byte offset to a 1-based [`LineCol`].
106    ///
107    /// The line is found by binary search over the recorded line starts in
108    /// `O(log lines)`; the column is the number of characters between the start of
109    /// that line and `pos`, plus one.
110    ///
111    /// Resolution is total and never panics. An offset past the end of the source
112    /// is treated as the end, and an offset that falls inside a multi-byte
113    /// character is rounded down to the start of that character — so the returned
114    /// coordinate is always a real position in the source.
115    ///
116    /// # Examples
117    ///
118    /// ```
119    /// use span_lang::{BytePos, LineCol, LineIndex};
120    ///
121    /// let index = LineIndex::new("αβγ\nδε");
122    /// // The column counts characters, so γ is column 3 despite being byte 4.
123    /// assert_eq!(index.line_col(BytePos::new(4)), LineCol::new(1, 3));
124    ///
125    /// // Past-the-end clamps to the final position rather than panicking.
126    /// assert_eq!(index.line_col(BytePos::new(9_999)), index.line_col(BytePos::new(11)));
127    /// ```
128    #[must_use]
129    pub fn line_col(&self, pos: BytePos) -> LineCol {
130        // Clamp into range, then floor onto a character boundary so the slice
131        // below can never split a multi-byte sequence.
132        let mut at = pos.to_usize().min(self.src.len());
133        while at > 0 && !self.src.is_char_boundary(at) {
134            at -= 1;
135        }
136
137        // Greatest line start <= `at`. `line_starts[0] == 0 <= at`, so the
138        // partition point is at least 1 and the subtraction cannot underflow.
139        let at_u32 = at as u32;
140        let line_idx = self.line_starts.partition_point(|&start| start <= at_u32) - 1;
141        let line_start = self.line_starts[line_idx] as usize;
142
143        // Count characters on the located line up to `at`. The ASCII fast path
144        // turns the common case into a length read instead of a decode loop.
145        let segment = &self.src[line_start..at];
146        let col = if segment.is_ascii() {
147            segment.len()
148        } else {
149            segment.chars().count()
150        };
151
152        LineCol::new(
153            (line_idx as u32).saturating_add(1),
154            (col as u32).saturating_add(1),
155        )
156    }
157
158    /// Resolves a 1-based [`LineCol`] back to a byte offset.
159    ///
160    /// Returns `None` if the coordinate does not exist in the source: a line or
161    /// column of `0`, a line past the last, or a column past the end of its line.
162    /// This is the inverse of [`line_col`](LineIndex::line_col) — for every valid
163    /// byte position, resolving forward and then back returns the original offset.
164    ///
165    /// # Examples
166    ///
167    /// ```
168    /// use span_lang::{BytePos, LineCol, LineIndex};
169    ///
170    /// let index = LineIndex::new("αβ\nγδ");
171    /// // Line 2, column 2 is the second character of the second line.
172    /// assert_eq!(index.offset(LineCol::new(2, 2)), Some(BytePos::new(7)));
173    ///
174    /// // Coordinates outside the source resolve to `None`.
175    /// assert_eq!(index.offset(LineCol::new(0, 1)), None);
176    /// assert_eq!(index.offset(LineCol::new(9, 1)), None);
177    /// assert_eq!(index.offset(LineCol::new(1, 99)), None);
178    /// ```
179    #[must_use]
180    pub fn offset(&self, line_col: LineCol) -> Option<BytePos> {
181        let line_idx = line_col.line.checked_sub(1)? as usize;
182        let col = line_col.col.checked_sub(1)? as usize;
183
184        let line_start = *self.line_starts.get(line_idx)? as usize;
185        let line_end = self
186            .line_starts
187            .get(line_idx + 1)
188            .map_or(self.src.len(), |&start| start as usize);
189
190        let segment = &self.src[line_start..line_end];
191
192        // Fast path: an all-ASCII line maps column directly to a byte step.
193        if segment.is_ascii() {
194            return if col <= segment.len() {
195                Some(BytePos::new((line_start + col) as u32))
196            } else {
197                None
198            };
199        }
200
201        // General path: advance `col` characters, failing if the line is shorter.
202        let mut offset = line_start;
203        let mut remaining = col;
204        for ch in segment.chars() {
205            if remaining == 0 {
206                break;
207            }
208            offset += ch.len_utf8();
209            remaining -= 1;
210        }
211        if remaining != 0 {
212            return None;
213        }
214        Some(BytePos::new(offset as u32))
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    extern crate alloc;
221    use alloc::string::String;
222
223    use super::*;
224
225    /// A deliberately naive reference resolver: a full character scan, no index.
226    /// The fast path is correct only if it agrees with this on every offset.
227    fn naive_line_col(src: &str, offset: usize) -> LineCol {
228        let mut at = offset.min(src.len());
229        while at > 0 && !src.is_char_boundary(at) {
230            at -= 1;
231        }
232        let mut line = 1u32;
233        let mut col = 1u32;
234        for (i, ch) in src.char_indices() {
235            if i >= at {
236                break;
237            }
238            if ch == '\n' {
239                line += 1;
240                col = 1;
241            } else {
242                col += 1;
243            }
244        }
245        LineCol::new(line, col)
246    }
247
248    #[test]
249    fn test_line_col_matches_naive_on_mixed_source() {
250        let src = "fn main() {\r\n    let π = 3;\n}\n";
251        let index = LineIndex::new(src);
252        for offset in 0..=src.len() {
253            assert_eq!(
254                index.line_col(BytePos::new(offset as u32)),
255                naive_line_col(src, offset),
256                "offset {offset}"
257            );
258        }
259    }
260
261    #[test]
262    fn test_round_trip_every_offset_on_mixed_source() {
263        let src = "αβγ\r\nδ\nε😀ζ\n";
264        let index = LineIndex::new(src);
265        for offset in 0..=src.len() {
266            if !src.is_char_boundary(offset) {
267                continue;
268            }
269            let lc = index.line_col(BytePos::new(offset as u32));
270            assert_eq!(
271                index.offset(lc),
272                Some(BytePos::new(offset as u32)),
273                "offset {offset} via {lc}"
274            );
275        }
276    }
277
278    #[test]
279    fn test_empty_source_is_one_line() {
280        let index = LineIndex::new("");
281        assert_eq!(index.line_count(), 1);
282        assert_eq!(index.line_col(BytePos::new(0)), LineCol::new(1, 1));
283        assert_eq!(index.offset(LineCol::new(1, 1)), Some(BytePos::new(0)));
284    }
285
286    #[test]
287    fn test_crlf_counts_as_one_break() {
288        let index = LineIndex::new("a\r\nb");
289        assert_eq!(index.line_count(), 2);
290        // The byte after \r\n starts line 2.
291        assert_eq!(index.line_col(BytePos::new(3)), LineCol::new(2, 1));
292    }
293
294    #[test]
295    fn test_no_trailing_newline_has_final_line() {
296        let index = LineIndex::new("a\nb");
297        assert_eq!(index.line_col(BytePos::new(2)), LineCol::new(2, 1));
298        assert_eq!(index.offset(LineCol::new(2, 1)), Some(BytePos::new(2)));
299    }
300
301    #[test]
302    fn test_offset_rejects_positions_outside_source() {
303        let index = LineIndex::new("abc\ndef");
304        assert_eq!(index.offset(LineCol::new(0, 1)), None);
305        assert_eq!(index.offset(LineCol::new(1, 0)), None);
306        assert_eq!(index.offset(LineCol::new(3, 1)), None);
307        assert_eq!(index.offset(LineCol::new(1, 99)), None);
308    }
309
310    #[test]
311    fn test_line_col_clamps_out_of_range_offset() {
312        let src = "abc";
313        let index = LineIndex::new(src);
314        let end = index.line_col(BytePos::new(3));
315        assert_eq!(index.line_col(BytePos::new(1000)), end);
316    }
317
318    #[test]
319    fn test_line_col_floors_interior_byte_to_char_start() {
320        // 'α' occupies bytes 0..2; an offset of 1 lands inside it.
321        let src = "αβ";
322        let index = LineIndex::new(src);
323        assert_eq!(
324            index.line_col(BytePos::new(1)),
325            index.line_col(BytePos::new(0))
326        );
327    }
328
329    #[test]
330    fn test_line_count_matches_newline_count_plus_one() {
331        let src = String::from("a\nb\nc\n");
332        let index = LineIndex::new(&src);
333        assert_eq!(index.line_count(), 4);
334    }
335}