Skip to main content

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}