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, Span};
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. A lone `\r` not followed
27/// by `\n` is an ordinary character, not a line break, matching how language
28/// front-ends split source.
29///
30/// # Examples
31///
32/// ```
33/// use span_lang::{BytePos, LineCol, LineIndex};
34///
35/// let index = LineIndex::new("let x = 1;\nlet y = 2;\n");
36///
37/// // Forward: byte offset -> (line, column).
38/// let lc = index.line_col(BytePos::new(11)); // first byte of line 2
39/// assert_eq!(lc, LineCol::new(2, 1));
40///
41/// // Inverse: (line, column) -> byte offset.
42/// assert_eq!(index.offset(lc), Some(BytePos::new(11)));
43/// ```
44#[derive(Debug, Clone)]
45pub struct LineIndex<'src> {
46    src: &'src str,
47    /// Byte offset of the first character of each line. Always starts with `0`,
48    /// so it is never empty and a forward lookup can never underflow.
49    line_starts: Box<[u32]>,
50}
51
52impl<'src> LineIndex<'src> {
53    /// Builds an index over `src`.
54    ///
55    /// This is the only `O(n)` operation in the type; every subsequent lookup is
56    /// sub-linear. `src` must be at most `u32::MAX` bytes — the addressing limit
57    /// of [`BytePos`] — which holds for any single source a language front-end
58    /// loads.
59    ///
60    /// # Examples
61    ///
62    /// ```
63    /// use span_lang::LineIndex;
64    ///
65    /// let index = LineIndex::new("one\ntwo\nthree");
66    /// assert_eq!(index.line_count(), 3);
67    /// ```
68    #[must_use]
69    pub fn new(src: &'src str) -> Self {
70        // One line start at offset 0, plus one immediately after every `\n`.
71        // Pre-size from a coarse average line length to avoid reallocating as the
72        // scan progresses; the exact count is data-dependent.
73        let mut line_starts = Vec::with_capacity(src.len() / 24 + 1);
74        line_starts.push(0);
75        for (i, &byte) in src.as_bytes().iter().enumerate() {
76            if byte == b'\n' {
77                // `i < src.len() <= u32::MAX`, so `i + 1` fits in `u32`.
78                line_starts.push(i as u32 + 1);
79            }
80        }
81        Self {
82            src,
83            line_starts: line_starts.into_boxed_slice(),
84        }
85    }
86
87    /// Returns the number of lines in the source.
88    ///
89    /// This counts line *starts*: one, plus the number of `\n` bytes. The empty
90    /// string is one line, and a trailing newline introduces a final empty line.
91    ///
92    /// # Examples
93    ///
94    /// ```
95    /// use span_lang::LineIndex;
96    ///
97    /// assert_eq!(LineIndex::new("").line_count(), 1);
98    /// assert_eq!(LineIndex::new("a\nb").line_count(), 2);
99    /// assert_eq!(LineIndex::new("a\nb\n").line_count(), 3);
100    /// ```
101    #[inline]
102    #[must_use]
103    pub fn line_count(&self) -> usize {
104        self.line_starts.len()
105    }
106
107    /// Resolves a byte offset to a 1-based [`LineCol`].
108    ///
109    /// The line is found by binary search over the recorded line starts in
110    /// `O(log lines)`; the column is the number of characters between the start of
111    /// that line and `pos`, plus one.
112    ///
113    /// Resolution is total and never panics. An offset past the end of the source
114    /// is treated as the end, and an offset that falls inside a multi-byte
115    /// character is rounded down to the start of that character — so the returned
116    /// coordinate is always a real position in the source.
117    ///
118    /// # Examples
119    ///
120    /// ```
121    /// use span_lang::{BytePos, LineCol, LineIndex};
122    ///
123    /// let index = LineIndex::new("αβγ\nδε");
124    /// // The column counts characters, so γ is column 3 despite being byte 4.
125    /// assert_eq!(index.line_col(BytePos::new(4)), LineCol::new(1, 3));
126    ///
127    /// // Past-the-end clamps to the final position rather than panicking.
128    /// assert_eq!(index.line_col(BytePos::new(9_999)), index.line_col(BytePos::new(11)));
129    /// ```
130    #[must_use]
131    pub fn line_col(&self, pos: BytePos) -> LineCol {
132        // Clamp into range, then floor onto a character boundary so the slice
133        // below can never split a multi-byte sequence.
134        let mut at = pos.to_usize().min(self.src.len());
135        while at > 0 && !self.src.is_char_boundary(at) {
136            at -= 1;
137        }
138
139        // Greatest line start <= `at`. `line_starts[0] == 0 <= at`, so the
140        // partition point is at least 1 and the subtraction cannot underflow.
141        let at_u32 = at as u32;
142        let line_idx = self.line_starts.partition_point(|&start| start <= at_u32) - 1;
143        let line_start = self.line_starts[line_idx] as usize;
144
145        // Count characters on the located line up to `at`. The ASCII fast path
146        // turns the common case into a length read instead of a decode loop.
147        let segment = &self.src[line_start..at];
148        let col = if segment.is_ascii() {
149            segment.len()
150        } else {
151            segment.chars().count()
152        };
153
154        LineCol::new(
155            (line_idx as u32).saturating_add(1),
156            (col as u32).saturating_add(1),
157        )
158    }
159
160    /// Resolves a 1-based [`LineCol`] back to a byte offset.
161    ///
162    /// Returns `None` if the coordinate does not exist in the source: a line or
163    /// column of `0`, a line past the last, or a column past the end of its line.
164    /// This is the inverse of [`line_col`](LineIndex::line_col) — for every valid
165    /// byte position, resolving forward and then back returns the original offset.
166    ///
167    /// # Examples
168    ///
169    /// ```
170    /// use span_lang::{BytePos, LineCol, LineIndex};
171    ///
172    /// let index = LineIndex::new("αβ\nγδ");
173    /// // Line 2, column 2 is the second character of the second line.
174    /// assert_eq!(index.offset(LineCol::new(2, 2)), Some(BytePos::new(7)));
175    ///
176    /// // Coordinates outside the source resolve to `None`.
177    /// assert_eq!(index.offset(LineCol::new(0, 1)), None);
178    /// assert_eq!(index.offset(LineCol::new(9, 1)), None);
179    /// assert_eq!(index.offset(LineCol::new(1, 99)), None);
180    /// ```
181    #[must_use]
182    pub fn offset(&self, line_col: LineCol) -> Option<BytePos> {
183        let line_idx = line_col.line.checked_sub(1)? as usize;
184        let col = line_col.col.checked_sub(1)? as usize;
185
186        let line_start = *self.line_starts.get(line_idx)? as usize;
187        let line_end = self
188            .line_starts
189            .get(line_idx + 1)
190            .map_or(self.src.len(), |&start| start as usize);
191
192        let segment = &self.src[line_start..line_end];
193
194        // Fast path: an all-ASCII line maps column directly to a byte step.
195        if segment.is_ascii() {
196            return if col <= segment.len() {
197                Some(BytePos::new((line_start + col) as u32))
198            } else {
199                None
200            };
201        }
202
203        // General path: advance `col` characters, failing if the line is shorter.
204        let mut offset = line_start;
205        let mut remaining = col;
206        for ch in segment.chars() {
207            if remaining == 0 {
208                break;
209            }
210            offset += ch.len_utf8();
211            remaining -= 1;
212        }
213        if remaining != 0 {
214            return None;
215        }
216        Some(BytePos::new(offset as u32))
217    }
218
219    /// Returns the byte span of a 1-based line's text, excluding its terminator.
220    ///
221    /// The span slices the source to exactly the line's content: the trailing
222    /// `\n` — and a `\r` immediately before it, for a `\r\n` ending — is not
223    /// included, so `&src[start..end]` is the text a diagnostic would underline.
224    /// This is the lookup a renderer uses to print the offending line. Returns
225    /// `None` if `line` is `0` or past the last line.
226    ///
227    /// The line's start is found in `O(log lines)`; trimming the terminator
228    /// inspects at most two bytes, so the whole operation is allocation-free and
229    /// never re-scans the source.
230    ///
231    /// # Examples
232    ///
233    /// ```
234    /// use span_lang::LineIndex;
235    ///
236    /// let src = "first\r\nsecond\nthird";
237    /// let index = LineIndex::new(src);
238    ///
239    /// let line2 = index.line_span(2).expect("line 2 exists");
240    /// assert_eq!(&src[line2.start().to_usize()..line2.end().to_usize()], "second");
241    ///
242    /// // The final, unterminated line is covered too.
243    /// let line3 = index.line_span(3).expect("line 3 exists");
244    /// assert_eq!(&src[line3.start().to_usize()..line3.end().to_usize()], "third");
245    ///
246    /// assert_eq!(index.line_span(0), None);
247    /// assert_eq!(index.line_span(99), None);
248    /// ```
249    #[must_use]
250    pub fn line_span(&self, line: u32) -> Option<Span> {
251        let line_idx = line.checked_sub(1)? as usize;
252        let start = *self.line_starts.get(line_idx)? as usize;
253        let mut end = self
254            .line_starts
255            .get(line_idx + 1)
256            .map_or(self.src.len(), |&next| next as usize);
257
258        // Exclude the terminator: a trailing `\n`, and a `\r` directly before it.
259        let bytes = self.src.as_bytes();
260        if end > start && bytes[end - 1] == b'\n' {
261            end -= 1;
262            if end > start && bytes[end - 1] == b'\r' {
263                end -= 1;
264            }
265        }
266
267        Some(Span::new(start as u32, end as u32))
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    extern crate alloc;
274    use alloc::string::String;
275
276    use super::*;
277
278    /// A deliberately naive reference resolver: a full character scan, no index.
279    /// The fast path is correct only if it agrees with this on every offset.
280    fn naive_line_col(src: &str, offset: usize) -> LineCol {
281        let mut at = offset.min(src.len());
282        while at > 0 && !src.is_char_boundary(at) {
283            at -= 1;
284        }
285        let mut line = 1u32;
286        let mut col = 1u32;
287        for (i, ch) in src.char_indices() {
288            if i >= at {
289                break;
290            }
291            if ch == '\n' {
292                line += 1;
293                col = 1;
294            } else {
295                col += 1;
296            }
297        }
298        LineCol::new(line, col)
299    }
300
301    #[test]
302    fn test_line_col_matches_naive_on_mixed_source() {
303        let src = "fn main() {\r\n    let π = 3;\n}\n";
304        let index = LineIndex::new(src);
305        for offset in 0..=src.len() {
306            assert_eq!(
307                index.line_col(BytePos::new(offset as u32)),
308                naive_line_col(src, offset),
309                "offset {offset}"
310            );
311        }
312    }
313
314    #[test]
315    fn test_round_trip_every_offset_on_mixed_source() {
316        let src = "αβγ\r\nδ\nε😀ζ\n";
317        let index = LineIndex::new(src);
318        for offset in 0..=src.len() {
319            if !src.is_char_boundary(offset) {
320                continue;
321            }
322            let lc = index.line_col(BytePos::new(offset as u32));
323            assert_eq!(
324                index.offset(lc),
325                Some(BytePos::new(offset as u32)),
326                "offset {offset} via {lc}"
327            );
328        }
329    }
330
331    #[test]
332    fn test_empty_source_is_one_line() {
333        let index = LineIndex::new("");
334        assert_eq!(index.line_count(), 1);
335        assert_eq!(index.line_col(BytePos::new(0)), LineCol::new(1, 1));
336        assert_eq!(index.offset(LineCol::new(1, 1)), Some(BytePos::new(0)));
337    }
338
339    #[test]
340    fn test_crlf_counts_as_one_break() {
341        let index = LineIndex::new("a\r\nb");
342        assert_eq!(index.line_count(), 2);
343        // The byte after \r\n starts line 2.
344        assert_eq!(index.line_col(BytePos::new(3)), LineCol::new(2, 1));
345    }
346
347    #[test]
348    fn test_no_trailing_newline_has_final_line() {
349        let index = LineIndex::new("a\nb");
350        assert_eq!(index.line_col(BytePos::new(2)), LineCol::new(2, 1));
351        assert_eq!(index.offset(LineCol::new(2, 1)), Some(BytePos::new(2)));
352    }
353
354    #[test]
355    fn test_offset_rejects_positions_outside_source() {
356        let index = LineIndex::new("abc\ndef");
357        assert_eq!(index.offset(LineCol::new(0, 1)), None);
358        assert_eq!(index.offset(LineCol::new(1, 0)), None);
359        assert_eq!(index.offset(LineCol::new(3, 1)), None);
360        assert_eq!(index.offset(LineCol::new(1, 99)), None);
361    }
362
363    #[test]
364    fn test_line_col_clamps_out_of_range_offset() {
365        let src = "abc";
366        let index = LineIndex::new(src);
367        let end = index.line_col(BytePos::new(3));
368        assert_eq!(index.line_col(BytePos::new(1000)), end);
369    }
370
371    #[test]
372    fn test_line_col_floors_interior_byte_to_char_start() {
373        // 'α' occupies bytes 0..2; an offset of 1 lands inside it.
374        let src = "αβ";
375        let index = LineIndex::new(src);
376        assert_eq!(
377            index.line_col(BytePos::new(1)),
378            index.line_col(BytePos::new(0))
379        );
380    }
381
382    #[test]
383    fn test_line_count_matches_newline_count_plus_one() {
384        let src = String::from("a\nb\nc\n");
385        let index = LineIndex::new(&src);
386        assert_eq!(index.line_count(), 4);
387    }
388
389    #[test]
390    fn test_lone_cr_is_not_a_line_break() {
391        let index = LineIndex::new("a\rb");
392        assert_eq!(index.line_count(), 1);
393        // The '\r' is an ordinary character, so 'b' is column 3.
394        assert_eq!(index.line_col(BytePos::new(2)), LineCol::new(1, 3));
395    }
396
397    #[test]
398    fn test_consecutive_newlines_are_separate_empty_lines() {
399        let index = LineIndex::new("\n\n\n");
400        assert_eq!(index.line_count(), 4);
401        assert_eq!(index.line_col(BytePos::new(1)), LineCol::new(2, 1));
402        assert_eq!(index.line_col(BytePos::new(2)), LineCol::new(3, 1));
403    }
404
405    #[test]
406    fn test_only_newline_is_two_lines() {
407        let index = LineIndex::new("\n");
408        assert_eq!(index.line_count(), 2);
409        assert_eq!(index.offset(LineCol::new(2, 1)), Some(BytePos::new(1)));
410    }
411
412    #[test]
413    fn test_line_span_excludes_lf_and_crlf_terminators() {
414        let src = "first\r\nsecond\nthird";
415        let index = LineIndex::new(src);
416        let text = |span: Span| &src[span.start().to_usize()..span.end().to_usize()];
417        assert_eq!(text(index.line_span(1).unwrap()), "first");
418        assert_eq!(text(index.line_span(2).unwrap()), "second");
419        assert_eq!(text(index.line_span(3).unwrap()), "third");
420    }
421
422    #[test]
423    fn test_line_span_of_trailing_empty_line_is_empty() {
424        let src = "a\n";
425        let index = LineIndex::new(src);
426        let line2 = index.line_span(2).unwrap();
427        assert!(line2.is_empty());
428        assert_eq!(line2.start(), BytePos::new(2));
429    }
430
431    #[test]
432    fn test_line_span_rejects_out_of_range_lines() {
433        let index = LineIndex::new("a\nb");
434        assert_eq!(index.line_span(0), None);
435        assert_eq!(index.line_span(3), None);
436    }
437
438    #[test]
439    fn test_line_span_start_matches_first_column_offset() {
440        let src = "αβ\r\nγ\nδε\n";
441        let index = LineIndex::new(src);
442        for line in 1..=index.line_count() as u32 {
443            let span = index.line_span(line).expect("line in range");
444            assert_eq!(Some(span.start()), index.offset(LineCol::new(line, 1)));
445            // A line's text never contains its terminator.
446            let text = &src[span.start().to_usize()..span.end().to_usize()];
447            assert!(!text.contains('\n'));
448        }
449    }
450}