Skip to main content

rustlavel_view/
source.rs

1//! Turning a byte offset back into the line and column an author can act on.
2//!
3//! Positions are only reconstructed when an error is raised, so the lexer and
4//! the parsers stay free of per-character bookkeeping — the same trade the JSON
5//! parser in core makes.
6
7use rustlavel_core::Error;
8
9/// The 1-based line and column of `offset` inside `source`.
10///
11/// Columns are counted in bytes, which matches the rest of the framework and
12/// only differs from characters on lines that already contain non-ASCII text.
13pub fn line_column(source: &str, offset: usize) -> (usize, usize) {
14    let offset = offset.min(source.len());
15    let consumed = &source.as_bytes()[..offset];
16    let line = consumed.iter().filter(|byte| **byte == b'\n').count() + 1;
17    let column =
18        consumed.iter().rposition(|byte| *byte == b'\n').map_or(offset, |i| offset - i - 1) + 1;
19    (line, column)
20}
21
22/// Build a template error pointing at `offset` inside `source`.
23pub fn syntax_error(
24    file: &str,
25    source: &str,
26    offset: usize,
27    message: impl Into<String>,
28) -> Error {
29    let (line, column) = line_column(source, offset);
30    Error::Template { file: file.to_string(), line, column, message: message.into() }
31}
32
33/// A fragment of a template that remembers where it was cut from.
34///
35/// Expressions are parsed out of `@if(...)` and `{{ ... }}` long after the
36/// lexer has moved on; carrying the origin along is what lets an error inside
37/// one still name the file, line, and column the author typed it at.
38#[derive(Debug, Clone, Copy)]
39pub struct Span<'a> {
40    /// The view name, used as the file in error messages.
41    pub file: &'a str,
42    /// The full template source, needed to count lines.
43    pub source: &'a str,
44    /// Where `text` starts inside `source`.
45    pub offset: usize,
46    /// The fragment itself.
47    pub text: &'a str,
48}
49
50impl<'a> Span<'a> {
51    pub fn new(file: &'a str, source: &'a str, offset: usize, len: usize) -> Self {
52        Span { file, source, offset, text: &source[offset..offset + len] }
53    }
54
55    /// Report an error at `at`, an offset *within* this fragment.
56    pub fn error(&self, at: usize, message: impl Into<String>) -> Error {
57        syntax_error(self.file, self.source, self.offset + at, message)
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    #[test]
66    fn counts_lines_and_columns_from_one() {
67        let source = "hello\n  world\n";
68
69        assert_eq!(line_column(source, 0), (1, 1));
70        assert_eq!(line_column(source, 4), (1, 5));
71        assert_eq!(line_column(source, 6), (2, 1));
72        assert_eq!(line_column(source, 8), (2, 3));
73    }
74
75    #[test]
76    fn an_offset_past_the_end_lands_on_the_last_position() {
77        assert_eq!(line_column("ab", 99), (1, 3));
78    }
79
80    #[test]
81    fn a_span_reports_positions_relative_to_its_fragment() {
82        let source = "line one\n@if(x == )\n";
83        let span = Span::new("home", source, 13, 5);
84
85        assert_eq!(span.text, "x == ");
86        match span.error(5, "unexpected end of expression") {
87            Error::Template { file, line, column, .. } => {
88                assert_eq!((file.as_str(), line, column), ("home", 2, 10));
89            }
90            other => panic!("expected a template error, got {other:?}"),
91        }
92    }
93}