Skip to main content

rssn_advanced/parser/
error.rs

1//! Parse error types with line/column span information.
2//!
3//! Per `parser_review §3`, the previous `ParseError::span: String`
4//! captured the raw "remaining input" suffix, which is opaque for
5//! large inputs. This rewrite tracks the exact byte offset, line, and
6//! column relative to the original source buffer.
7
8use core::fmt;
9
10/// Location of a parser error in the original source buffer.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct Span {
13    /// Byte offset from the start of the input (0-based).
14    pub offset: usize,
15    /// Length of the offending substring, in bytes.
16    pub len: usize,
17    /// 1-based line number where the error starts.
18    pub line: u32,
19    /// 1-based column number where the error starts.
20    pub col: u32,
21}
22
23impl Span {
24    /// Computes a [`Span`] from an absolute byte `offset` into `source`,
25    /// counting newlines for line/col attribution.
26    #[must_use]
27    pub fn from_offset(source: &str, offset: usize, len: usize) -> Self {
28        let clamped = offset.min(source.len());
29        let prefix = &source.as_bytes()[..clamped];
30        let mut line: u32 = 1;
31        let mut last_newline: usize = 0;
32        for (i, &b) in prefix.iter().enumerate() {
33            if b == b'\n' {
34                line = line.saturating_add(1);
35                last_newline = i + 1;
36            }
37        }
38        // Column = bytes since the most recent '\n', plus 1.
39        #[allow(clippy::cast_possible_truncation)]
40        let col = (clamped - last_newline) as u32 + 1;
41        Self {
42            offset,
43            len,
44            line,
45            col,
46        }
47    }
48}
49
50impl fmt::Display for Span {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        write!(f, "{}:{}", self.line, self.col)
53    }
54}
55
56/// An error that occurred during symbolic expression parsing.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct ParseError {
59    /// A human-readable description of the error.
60    pub message: String,
61    /// Location of the error in the original source buffer.
62    pub span: Span,
63}
64
65impl fmt::Display for ParseError {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        write!(f, "Parse error at {}: {}", self.span, self.message)
68    }
69}
70
71impl std::error::Error for ParseError {}
72
73/// Cold constructor for unexpected-EOF parse errors (e.g. missing closing `)`).
74#[doc(hidden)]
75#[cold]
76#[track_caller]
77#[inline(never)]
78#[must_use]
79pub fn cold_parse_error_unexpected_eof(span: Span) -> ParseError {
80    ParseError {
81        message: "Unexpected end of input; expected closing ')'".to_owned(),
82        span,
83    }
84}
85
86/// Cold constructor for unexpected-token parse errors.
87#[doc(hidden)]
88#[cold]
89#[track_caller]
90#[inline(never)]
91#[must_use]
92pub fn cold_parse_error_unexpected_token(span: Span, bad: char) -> ParseError {
93    ParseError {
94        message: format!("Unexpected character {bad:?}; expected a number, variable, or '('"),
95        span,
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn span_at_start_of_input() {
105        let s = Span::from_offset("hello", 0, 1);
106        assert_eq!(s.line, 1);
107        assert_eq!(s.col, 1);
108    }
109
110    #[test]
111    fn span_after_newline() {
112        let src = "a + b\nc + d\nbad";
113        // offset of the 'b' in "bad" — past two newlines.
114        let off = src.find("bad").expect("find");
115        let s = Span::from_offset(src, off, 3);
116        assert_eq!(s.line, 3);
117        assert_eq!(s.col, 1);
118    }
119
120    #[test]
121    fn span_offset_past_end_clamps() {
122        let s = Span::from_offset("xy", 999, 1);
123        assert_eq!(s.line, 1);
124        assert_eq!(s.col, 3); // After the last byte.
125    }
126}