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}