Skip to main content

_core/
error.rs

1//! Line-numbered build errors (Phase F / M4.1). Every fail-fast
2//! site returns `Result<_, Error>` instead of `Result<_, String>`;
3//! the message format is upgraded but the Python exception type is
4//! unchanged (`ValueError` carrying the Display rendering).
5//!
6//! `line` is 1-based in the relevant source (manuscript for
7//! scanner errors, head file for template errors, snippet for
8//! direct API calls, fence-body-relative resolved by the caller
9//! for diagram statements). `line == 0` means "no manuscript
10//! line": data errors (ragged JSON series, TOML value errors)
11//! where the echo — the offending values — is the context. The
12//! ` --> line N` part is omitted then, never rendered as `0`.
13//! `col` is `None` until F.2 assigns carets; absence of a caret is
14//! never a failure, only missing precision.
15
16use std::fmt;
17
18/// A build error with source context.
19#[derive(Debug, Clone, PartialEq)]
20pub struct Error {
21    /// Human message (unchanged vocabulary from the String era).
22    pub msg: String,
23    /// 1-based source line, or 0 when no manuscript line applies.
24    pub line: usize,
25    /// 1-based column of the offending token (F.2 fills this in).
26    pub col: Option<usize>,
27    /// Offending source text (statement / line / values).
28    pub echo: String,
29}
30
31impl Error {
32    pub fn new(msg: impl Into<String>, line: usize, echo: impl Into<String>) -> Self {
33        Self { msg: msg.into(), line, col: None, echo: echo.into() }
34    }
35
36    /// Attach a 1-based caret column (F.2 fills these where the
37    /// grammar knows the token span).
38    pub fn with_col(mut self, col: Option<usize>) -> Self {
39        self.col = col;
40        self
41    }
42
43    /// Data error: no manuscript line (JSON/TOML values).
44    pub fn data(msg: impl Into<String>, echo: impl Into<String>) -> Self {
45        Self::new(msg, 0, echo)
46    }
47}
48
49impl fmt::Display for Error {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        // Long echoes (data dumps) truncate — the head of the
52        // offending text is what locates it.
53        let echo: String = self.echo.chars().take(240).collect();
54        let cut = self.echo.chars().count() > 240;
55        if self.line == 0 {
56            if echo.is_empty() {
57                write!(f, "{}", self.msg)
58            } else if cut {
59                write!(f, "{}\n{}…", self.msg, echo)
60            } else {
61                write!(f, "{}\n{}", self.msg, echo)
62            }
63        } else {
64            writeln!(f, "{}", self.msg)?;
65            writeln!(f, " --> line {}", self.line)?;
66            if let Some(c) = self.col {
67                writeln!(f, "{}^", " ".repeat(c.saturating_sub(1)))?;
68            }
69            if cut {
70                write!(f, "{}…", echo)
71            } else {
72                write!(f, "{}", echo)
73            }
74        }
75    }
76}
77
78impl std::error::Error for Error {}
79
80/// 1-based character column of the byte index inside `line`
81/// (caret arithmetic is in chars — echoes may hold multibyte
82/// text, and byte columns would misplace the caret).
83pub fn col_of(line: &str, byte_idx: usize) -> usize {
84    line[..byte_idx.min(line.len())].chars().count() + 1
85}
86
87/// `format!` a message into an `Error` at one call site.
88#[macro_export]
89macro_rules! err {
90    ($line:expr, $echo:expr, $($t:tt)*) => {
91        $crate::error::Error::new(format!($($t)*), $line, $echo)
92    };
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn display_shows_msg_line_echo() {
101        let e = Error::new("bad statement", 12, "loop x");
102        let s = e.to_string();
103        assert!(s.contains("bad statement"), "{s}");
104        assert!(s.contains(" --> line 12"), "{s}");
105        assert!(s.contains("loop x"), "{s}");
106    }
107
108    #[test]
109    fn data_error_omits_line() {
110        let e = Error::data("ragged values", "[1, 2]");
111        let s = e.to_string();
112        assert!(!s.contains("line 0"), "{s}");
113        assert!(s.contains("ragged values") && s.contains("[1, 2]"), "{s}");
114    }
115
116    #[test]
117    fn long_echo_truncates() {
118        let e = Error::new("m", 1, "x".repeat(300));
119        assert!(e.to_string().ends_with('…'));
120    }
121}