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}