praxis_source/line_map.rs
1//! Line/column mapping for a source file.
2//!
3//! [`LineMap`] precomputes the byte offset at the start of each line so that
4//! [`BytePos`] → [`LineCol`] conversion is a binary search, not a rescan. All
5//! offsets are **byte** offsets (§4.1: source is UTF-8); a column is the byte
6//! offset from the start of the line, so multi-byte characters occupy more than
7//! one column. This keeps the mapping lossless and O(1) to invert.
8
9use crate::span::BytePos;
10
11/// A 1-based `(line, column)` position. `line` starts at 1, `col` is the byte
12/// offset from the line start (so the first byte of a line is column 0).
13#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
14pub struct LineCol {
15 /// 1-based line number.
16 pub line: u32,
17 /// 0-based byte offset from the start of the line.
18 pub col: u32,
19}
20
21/// A precomputed table of line-start byte offsets for a single source file.
22///
23/// Constructed once per file via [`LineMap::new`]; the compiler and diagnostics
24/// layer read it cheaply thereafter.
25#[derive(Clone, Debug)]
26pub struct LineMap {
27 /// Byte offset of the first byte of each line. Always begins with `0`
28 /// (the start of line 1) and is strictly increasing.
29 line_starts: Vec<u32>,
30 /// Total byte length of the source, used to clamp out-of-range queries.
31 len: u32,
32 /// The source bytes. Kept so trimming/inspection helpers (e.g. stripping a
33 /// trailing terminator when clamping a column) can read the actual bytes.
34 text: Vec<u8>,
35}
36
37impl LineMap {
38 /// Build a line map from source text. `\n`, `\r\n`, and `\r` all count as
39 /// line terminators, matching how the input parser treats logical lines.
40 pub fn new(text: &str) -> LineMap {
41 let mut line_starts = vec![0u32];
42 // Iterate over bytes so we can record byte offsets directly. We look for
43 // any of `\n`, `\r`, or `\r\n`; a `\r` immediately followed by `\n` is
44 // one line break, not two.
45 let bytes = text.as_bytes();
46 let mut i = 0;
47 while i < bytes.len() {
48 let b = bytes[i];
49 if b == b'\n' {
50 push_start(&mut line_starts, i + 1);
51 } else if b == b'\r' {
52 let next_is_lf = bytes.get(i + 1) == Some(&b'\n');
53 let after = if next_is_lf { i + 2 } else { i + 1 };
54 push_start(&mut line_starts, after);
55 i = after;
56 continue;
57 }
58 i += 1;
59 }
60 let len = u32::try_from(bytes.len()).expect("source files must be < 4 GiB");
61 LineMap {
62 line_starts,
63 len,
64 text: bytes.to_vec(),
65 }
66 }
67
68 /// Convert a byte offset to a 1-based `(line, column)`.
69 ///
70 /// Offsets past the end of the file clamp to the last byte of the last line
71 /// rather than overflowing, so a slightly-out-of-range span still renders
72 /// something sensible.
73 pub fn offset_to_linecol(&self, offset: BytePos) -> LineCol {
74 let pos = offset.to_u32().min(self.len);
75 let line = self.line_index(pos);
76 let line_start = self.line_starts[line];
77 LineCol {
78 line: line as u32 + 1,
79 col: pos - line_start,
80 }
81 }
82
83 /// Convert a 1-based `(line, column)` back to a byte offset.
84 ///
85 /// Returns `None` if `line` is zero or beyond the number of lines. A column
86 /// past the end of the line clamps to the line's last byte.
87 pub fn linecol_to_offset(&self, lc: LineCol) -> Option<BytePos> {
88 if lc.line == 0 {
89 return None;
90 }
91 let line_idx = (lc.line - 1) as usize;
92 let line_start = *self.line_starts.get(line_idx)?;
93 // The next line starts where this one's terminator ends. For clamping
94 // a column we want the line's *content* end (before the terminator),
95 // so a too-large column points just past the last content byte rather
96 // than at the `\n`.
97 let next_start = self
98 .line_starts
99 .get(line_idx + 1)
100 .copied()
101 .unwrap_or(self.len);
102 let content_end =
103 Self::trim_line_terminator(&self.text, BytePos(line_start), BytePos(next_start));
104 let col = lc.col.min(content_end.saturating_sub(BytePos(line_start)));
105 Some(BytePos(line_start + col))
106 }
107
108 /// The total number of lines.
109 pub fn line_count(&self) -> usize {
110 self.line_starts.len()
111 }
112
113 /// Index into `line_starts` for the line containing byte offset `pos`.
114 fn line_index(&self, pos: u32) -> usize {
115 // Find the last line_start <= pos. `binary_search_by` returns Err(i)
116 // where i is the insertion point, i.e. the index after the wanted line.
117 match self.line_starts.binary_search_by(|&start| start.cmp(&pos)) {
118 Ok(i) => i,
119 Err(i) => i.saturating_sub(1),
120 }
121 }
122
123 /// The byte range `[start, end)` of a given 1-based line number, or `None`
124 /// if the line number is out of range.
125 ///
126 /// `end` is the byte offset where the next line begins (or the file end for
127 /// the final line), so it includes any line terminator. Callers that render
128 /// the line text should trim it with [`LineMap::trim_line_terminator`].
129 pub fn line_range(&self, line: u32) -> Option<(BytePos, BytePos)> {
130 if line == 0 {
131 return None;
132 }
133 let idx = (line - 1) as usize;
134 let start = *self.line_starts.get(idx)?;
135 let next_start = self.line_starts.get(idx + 1).copied().unwrap_or(self.len);
136 Some((BytePos(start), BytePos(next_start)))
137 }
138
139 /// The content end of a line extent `[start, end)`: `end` minus the bytes of
140 /// the single line terminator (`\n`, `\r`, or `\r\n`) that separates this
141 /// line from the next. Reads the actual bytes, so a CRLF is trimmed as one
142 /// terminator and not as two.
143 ///
144 /// [`LineMap::line_range`] deliberately hands back the extent *including*
145 /// the terminator and tells the caller to trim it — this is that trim, and
146 /// the only copy of it. The terminator set has to stay in step with the
147 /// scanner in [`LineMap::new`], so the rule lives beside the scanner: a
148 /// change there is one edit rather than several that can silently desync.
149 ///
150 /// Takes the bytes rather than reading `self.text` so that a caller which
151 /// already holds the source trims against the very bytes it is about to
152 /// slice. An extent with nothing in it, and a final line with no terminator
153 /// at all, are both returned unchanged.
154 pub fn trim_line_terminator(text: &[u8], start: BytePos, end: BytePos) -> BytePos {
155 // `end` may run past `text` for a synthetic extent, so clamp before
156 // slicing; the guard then covers the empty and the out-of-range case at
157 // once. Only trim when there is something to trim — the final line may
158 // have no terminator.
159 let s = start.to_usize();
160 let e = end.to_usize().min(text.len());
161 if e <= s {
162 return start;
163 }
164 let gap = &text[s..e];
165 if gap.ends_with(b"\r\n") {
166 BytePos(end.to_u32() - 2)
167 } else if gap.ends_with(b"\n") || gap.ends_with(b"\r") {
168 BytePos(end.to_u32() - 1)
169 } else {
170 // No recognizable terminator (e.g. the final line without a trailing
171 // newline): content end is the whole extent.
172 end
173 }
174 }
175}
176
177/// Push a new line start, collapsing accidental duplicates (e.g. an empty line
178/// right after a CRLF could otherwise produce a stale entry).
179fn push_start(line_starts: &mut Vec<u32>, offset: usize) {
180 let offset = u32::try_from(offset).expect("source files must be < 4 GiB");
181 if line_starts.last() != Some(&offset) {
182 line_starts.push(offset);
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 #[test]
191 fn single_line_no_terminator() {
192 let map = LineMap::new("hello");
193 assert_eq!(map.line_count(), 1);
194 assert_eq!(
195 map.offset_to_linecol(BytePos(0)),
196 LineCol { line: 1, col: 0 }
197 );
198 assert_eq!(
199 map.offset_to_linecol(BytePos(4)),
200 LineCol { line: 1, col: 4 }
201 );
202 }
203
204 #[test]
205 fn unix_newlines() {
206 let map = LineMap::new("aa\nbb\ncc");
207 assert_eq!(map.line_count(), 3);
208 assert_eq!(
209 map.offset_to_linecol(BytePos(0)),
210 LineCol { line: 1, col: 0 }
211 );
212 assert_eq!(
213 map.offset_to_linecol(BytePos(3)),
214 LineCol { line: 2, col: 0 }
215 );
216 assert_eq!(
217 map.offset_to_linecol(BytePos(6)),
218 LineCol { line: 3, col: 0 }
219 );
220 }
221
222 #[test]
223 fn crlf_counts_as_one_break() {
224 let map = LineMap::new("a\r\nb");
225 assert_eq!(map.line_count(), 2, "CRLF must be a single line break");
226 assert_eq!(
227 map.offset_to_linecol(BytePos(3)),
228 LineCol { line: 2, col: 0 }
229 );
230 }
231
232 #[test]
233 fn bare_cr_counts_as_break() {
234 let map = LineMap::new("a\rb");
235 assert_eq!(map.line_count(), 2);
236 assert_eq!(
237 map.offset_to_linecol(BytePos(2)),
238 LineCol { line: 2, col: 0 }
239 );
240 }
241
242 #[test]
243 fn trailing_newline_yields_no_phantom_line() {
244 // For "a\n" the newline byte itself (offset 1) terminates line 1, so it
245 // is reported as line 1, col 1 — it does not start a phantom line 2.
246 // Line 2 *starts* at offset 2 (after the newline), which is past the
247 // content, and maps back cleanly.
248 let map = LineMap::new("a\n");
249 assert_eq!(map.line_count(), 2);
250 assert_eq!(
251 map.offset_to_linecol(BytePos(0)),
252 LineCol { line: 1, col: 0 }
253 );
254 assert_eq!(
255 map.offset_to_linecol(BytePos(1)),
256 LineCol { line: 1, col: 1 }
257 );
258 }
259
260 #[test]
261 fn round_trip_linecol_offset() {
262 let map = LineMap::new("out(1)\nout(2)\n");
263 for offset in 0..12u32 {
264 let lc = map.offset_to_linecol(BytePos(offset));
265 let back = map
266 .linecol_to_offset(lc)
267 .expect("round trip should succeed");
268 assert_eq!(
269 back,
270 BytePos(offset),
271 "round trip failed at offset {offset} -> {lc:?}"
272 );
273 }
274 }
275
276 #[test]
277 fn round_trip_with_multibyte_utf8() {
278 // Bytes, not chars: 'λ' is two bytes, so columns advance by 2.
279 // "λx\nλy" -> bytes: [0,1] 'λ', [2] 'x', [3] '\n', [4,5] 'λ', [6] 'y'
280 let map = LineMap::new("λx\nλy");
281 assert_eq!(
282 map.offset_to_linecol(BytePos(0)),
283 LineCol { line: 1, col: 0 }
284 );
285 assert_eq!(
286 map.offset_to_linecol(BytePos(2)),
287 LineCol { line: 1, col: 2 }
288 );
289 // The newline byte (offset 3) terminates line 1; it does not start line 2.
290 assert_eq!(
291 map.offset_to_linecol(BytePos(3)),
292 LineCol { line: 1, col: 3 }
293 );
294 // Line 2 starts at offset 4 (after the newline).
295 assert_eq!(
296 map.offset_to_linecol(BytePos(4)),
297 LineCol { line: 2, col: 0 }
298 );
299 // And the round trip holds for each offset.
300 for offset in 0..6u32 {
301 let lc = map.offset_to_linecol(BytePos(offset));
302 let back = map.linecol_to_offset(lc).unwrap();
303 assert_eq!(back, BytePos(offset), "utf8 round trip at {offset}");
304 }
305 }
306
307 #[test]
308 fn offset_past_end_clamps() {
309 let map = LineMap::new("ab");
310 let lc = map.offset_to_linecol(BytePos(99));
311 assert_eq!(lc, LineCol { line: 1, col: 2 }, "clamps to last byte");
312 }
313
314 #[test]
315 fn linecol_zero_line_is_none() {
316 let map = LineMap::new("ab\ncd");
317 assert!(map.linecol_to_offset(LineCol { line: 0, col: 0 }).is_none());
318 }
319
320 #[test]
321 fn linecol_past_last_line_is_none() {
322 let map = LineMap::new("ab\ncd");
323 assert!(
324 map.linecol_to_offset(LineCol { line: 99, col: 0 })
325 .is_none()
326 );
327 }
328
329 #[test]
330 fn linecol_column_clamps_to_line_end() {
331 let map = LineMap::new("ab\ncd");
332 let off = map.linecol_to_offset(LineCol { line: 1, col: 50 }).unwrap();
333 assert_eq!(off, BytePos(2), "column past line end clamps");
334 }
335
336 /// Exactly one terminator is trimmed, whatever its spelling. Pinned at the
337 /// single copy of the rule, because a change to `LineMap::new`'s scanner has
338 /// to move in step with it.
339 #[test]
340 fn trimming_takes_exactly_one_terminator() {
341 for (text, start, end, want) in [
342 // CRLF is two bytes but a single terminator.
343 ("a\r\nb", 0u32, 3u32, 1u32),
344 ("a\nb", 0, 2, 1),
345 // A bare `\r` terminates a line too.
346 ("a\rb", 0, 2, 1),
347 // A final line without a terminator keeps its whole extent.
348 ("ab", 0, 2, 2),
349 // An empty extent — the line after a trailing newline.
350 ("a\n", 2, 2, 2),
351 ] {
352 assert_eq!(
353 LineMap::trim_line_terminator(text.as_bytes(), BytePos(start), BytePos(end)),
354 BytePos(want),
355 "trimming {text:?}[{start}..{end}]"
356 );
357 }
358 }
359
360 #[test]
361 fn empty_source_has_one_line() {
362 let map = LineMap::new("");
363 assert_eq!(map.line_count(), 1);
364 assert_eq!(
365 map.offset_to_linecol(BytePos(0)),
366 LineCol { line: 1, col: 0 }
367 );
368 }
369}