Skip to main content

math_core/
error.rs

1use std::fmt::{self, Write};
2use std::ops::Range;
3
4use strum_macros::IntoStaticStr;
5
6use crate::MathDisplay;
7use crate::environments::Env;
8use crate::html_utils::{escape_double_quoted_html_attribute, escape_html_content};
9use crate::token::EndToken;
10
11/// Represents an error that occurred during LaTeX parsing.
12#[derive(Debug, Clone)]
13pub struct LatexError(pub Range<usize>, pub(crate) LatexErrKind);
14
15#[derive(Debug, Clone)]
16pub(crate) enum LatexErrKind {
17    UnclosedGroup(EndToken),
18    UnmatchedClose(EndToken),
19    ExpectedArgumentGotClose,
20    ExpectedArgumentGotEOF,
21    ExpectedDelimiter(DelimiterModifier),
22    DisallowedChar(char),
23    UnknownEnvironment(Box<str>),
24    UnknownCommand(Box<str>),
25    UnknownColor(Box<str>),
26    MismatchedEnvironment {
27        expected: Env,
28        got: Env,
29    },
30    CannotBeUsedHere {
31        got: LimitedUsabilityToken,
32        correct_place: Place,
33    },
34    ExpectedRelation,
35    BoundFollowedByBound,
36    DuplicateSubOrSup,
37    ExpectedText(&'static str),
38    ExpectedLength(Box<str>),
39    ExpectedColSpec(Box<str>),
40    ExpectedNumber(Box<str>),
41    NotValidInTextMode,
42    InvalidMacroName(String),
43    InvalidParameterNumber,
44    MacroParameterOutsideCustomCommand,
45    ExpectedParamNumberGotEOF,
46    HardLimitExceeded,
47    Internal,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, IntoStaticStr)]
51pub enum DelimiterModifier {
52    #[strum(serialize = r"\left")]
53    Left,
54    #[strum(serialize = r"\right")]
55    Right,
56    #[strum(serialize = r"\middle")]
57    Middle,
58    #[strum(serialize = r"\big, \Big, ...")]
59    Big,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, IntoStaticStr)]
63#[repr(u32)] // A different value here somehow increases code size on WASM enormously.
64pub enum Place {
65    #[strum(serialize = r"after \int, \sum, ...")]
66    AfterBigOp,
67    #[strum(serialize = r"in a table-like environment")]
68    TableEnv,
69    #[strum(serialize = r"in a numbered equation environment")]
70    NumberedEnv,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, IntoStaticStr)]
74pub enum LimitedUsabilityToken {
75    #[strum(serialize = "&")]
76    Ampersand,
77    #[strum(serialize = r"\tag")]
78    Tag,
79    #[strum(serialize = r"\limits")]
80    Limits,
81}
82
83impl LatexErrKind {
84    /// Returns the error message as a string.
85    ///
86    /// This serves the same purpose as the `Display` implementation,
87    /// but produces more compact WASM code.
88    pub fn string(&self) -> String {
89        match self {
90            LatexErrKind::UnclosedGroup(expected) => {
91                "Expected token \"".to_string() + <&str>::from(expected) + "\", but not found."
92            }
93            LatexErrKind::UnmatchedClose(got) => {
94                "Unmatched closing token: \"".to_string() + <&str>::from(got) + "\"."
95            }
96            LatexErrKind::ExpectedArgumentGotClose => {
97                r"Expected argument but got closing token (`}`, `\end`, `\right`).".to_string()
98            }
99            LatexErrKind::ExpectedArgumentGotEOF => "Expected argument but reached end of input.".to_string(),
100            LatexErrKind::ExpectedDelimiter(location) => {
101                "There must be a parenthesis after \"".to_string()
102                    + <&str>::from(*location)
103                    + "\", but not found."
104            }
105            LatexErrKind::DisallowedChar(got) => {
106                let mut text = "Disallowed character in text group: '".to_string();
107                text.push(*got);
108                text += "'.";
109                text
110            }
111            LatexErrKind::UnknownEnvironment(environment) => {
112                "Unknown environment \"".to_string() + environment + "\"."
113            }
114            LatexErrKind::UnknownCommand(cmd) => "Unknown command \"\\".to_string() + cmd + "\".",
115            LatexErrKind::UnknownColor(color) => "Unknown color \"".to_string() + color + "\".",
116            LatexErrKind::MismatchedEnvironment { expected, got } => {
117                "Expected \"\\end{".to_string()
118                    + expected.as_str()
119                    + "}\", but got \"\\end{"
120                    + got.as_str()
121                    + "}\"."
122            }
123            LatexErrKind::CannotBeUsedHere { got, correct_place } => {
124                "Got \"".to_string()
125                    + <&str>::from(got)
126                    + "\", which may only appear "
127                    + <&str>::from(correct_place)
128                    + "."
129            }
130            LatexErrKind::ExpectedRelation => {
131                "Expected a relation after \\not.".to_string()
132            }
133            LatexErrKind::BoundFollowedByBound => {
134                "'^' or '_' directly followed by '^', '_' or prime.".to_string()
135            }
136            LatexErrKind::DuplicateSubOrSup => {
137                "Duplicate subscript or superscript.".to_string()
138            }
139            LatexErrKind::ExpectedText(place) => "Expected text in ".to_string() + place + ".",
140            LatexErrKind::ExpectedLength(got) => {
141                "Expected length with units, got \"".to_string() + got + "\"."
142            }
143            LatexErrKind::ExpectedNumber(got) => "Expected a number, got \"".to_string() + got + "\".",
144            LatexErrKind::ExpectedColSpec(got) => {
145                "Expected column specification, got \"".to_string() + got + "\"."
146            }
147            LatexErrKind::NotValidInTextMode => {
148                "Not valid in text mode.".to_string()
149            }
150            LatexErrKind::InvalidMacroName(name) => {
151                "Invalid macro name: \"\\".to_string() + name + "\"."
152            }
153            LatexErrKind::InvalidParameterNumber => {
154                "Invalid parameter number. Must be 1-9.".to_string()
155            }
156            LatexErrKind::MacroParameterOutsideCustomCommand => {
157                "Macro parameter found outside of custom command definition.".to_string()
158            }
159            LatexErrKind::ExpectedParamNumberGotEOF => {
160                "Expected parameter number after '#', but got end of input.".to_string()
161            }
162            LatexErrKind::HardLimitExceeded => {
163                "Hard limit exceeded. Please simplify your equation.".to_string()
164            }
165            LatexErrKind::Internal => {
166                "Internal parser error. Please report this bug at https://github.com/tmke8/math-core/issues".to_string()
167            },
168        }
169    }
170}
171
172impl LatexError {
173    /// Format a LaTeX error as an HTML snippet.
174    ///
175    /// # Arguments
176    /// - `latex`: The original LaTeX input that caused the error.
177    /// - `display`: The display mode of the equation (inline or block).
178    /// - `css_class`: An optional CSS class to apply to the error element. If `None`,
179    ///   defaults to `"math-core-error"`.
180    pub fn to_html(&self, latex: &str, display: MathDisplay, css_class: Option<&str>) -> String {
181        let mut output = String::new();
182        let tag = if matches!(display, MathDisplay::Block) {
183            "p"
184        } else {
185            "span"
186        };
187        let css_class = css_class.unwrap_or("math-core-error");
188        let _ = write!(
189            output,
190            r#"<{} class="{}" title="{}: "#,
191            tag, css_class, self.0.start
192        );
193        escape_double_quoted_html_attribute(&mut output, &self.1.string());
194        output.push_str(r#""><code>"#);
195        escape_html_content(&mut output, latex);
196        let _ = write!(output, "</code></{tag}>");
197        output
198    }
199
200    pub fn error_message(&self) -> String {
201        self.1.string()
202    }
203}
204
205#[cfg(feature = "ariadne")]
206impl LatexError {
207    /// Convert this error into an [`ariadne::Report`] for pretty-printing.
208    pub fn to_report<'name>(
209        &self,
210        source_name: &'name str,
211        with_color: bool,
212    ) -> ariadne::Report<'static, (&'name str, Range<usize>)> {
213        use ariadne::{Label, Report, ReportKind};
214
215        let label_msg = match &self.1 {
216            LatexErrKind::UnclosedGroup(expected) => {
217                format!(
218                    "expected \"{}\" to close this group",
219                    <&str>::from(expected)
220                )
221            }
222            LatexErrKind::UnmatchedClose(got) => {
223                format!("unmatched \"{}\"", <&str>::from(got))
224            }
225            LatexErrKind::ExpectedArgumentGotClose => "expected an argument here".into(),
226            LatexErrKind::ExpectedArgumentGotEOF => "expected an argument here".into(),
227            LatexErrKind::ExpectedDelimiter(modifier) => {
228                format!("expected a delimiter after \"{}\"", <&str>::from(*modifier))
229            }
230            LatexErrKind::DisallowedChar(_) => "disallowed character".into(),
231            LatexErrKind::UnknownEnvironment(_) => "unknown environment".into(),
232            LatexErrKind::UnknownCommand(_) => "unknown command".into(),
233            LatexErrKind::UnknownColor(_) => "unknown color".into(),
234            LatexErrKind::MismatchedEnvironment { expected, .. } => {
235                format!("expected \"\\end{{{}}}\" here", expected.as_str(),)
236            }
237            LatexErrKind::CannotBeUsedHere { correct_place, .. } => {
238                format!("may only appear {}", <&str>::from(correct_place))
239            }
240            LatexErrKind::ExpectedRelation => "expected a relation".into(),
241            LatexErrKind::BoundFollowedByBound => "unexpected bound".into(),
242            LatexErrKind::DuplicateSubOrSup => "duplicate".into(),
243            LatexErrKind::ExpectedText(place) => format!("expected text in {place}"),
244            LatexErrKind::ExpectedLength(_) => "expected length here".into(),
245            LatexErrKind::ExpectedNumber(_) => "expected a number here".into(),
246            LatexErrKind::ExpectedColSpec(_) => "expected a column spec here".into(),
247            LatexErrKind::NotValidInTextMode => "not valid in text mode".into(),
248            LatexErrKind::InvalidMacroName(_) => "invalid name here".into(),
249            LatexErrKind::InvalidParameterNumber => "must be 1-9".into(),
250            LatexErrKind::MacroParameterOutsideCustomCommand => "unexpected macro parameter".into(),
251            LatexErrKind::ExpectedParamNumberGotEOF => "expected parameter number".into(),
252            LatexErrKind::HardLimitExceeded => "limit exceeded".into(),
253            LatexErrKind::Internal => "internal error".into(),
254        };
255
256        let mut config = ariadne::Config::default().with_index_type(ariadne::IndexType::Byte);
257        if !with_color {
258            config = config.with_color(false);
259        }
260        Report::build(ReportKind::Error, (source_name, self.0.start..self.0.start))
261            .with_config(config)
262            .with_message(self.1.string())
263            .with_label(Label::new((source_name, self.0.clone())).with_message(label_msg))
264            .finish()
265    }
266}
267
268impl fmt::Display for LatexError {
269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270        write!(f, "{}: {}", self.0.start, self.1.string())
271    }
272}
273
274impl std::error::Error for LatexError {}
275
276pub trait GetUnwrap {
277    /// `str::get` with `Option::unwrap`.
278    fn get_unwrap(&self, range: std::ops::Range<usize>) -> &str;
279}
280
281impl GetUnwrap for str {
282    #[cfg(target_arch = "wasm32")]
283    #[inline]
284    fn get_unwrap(&self, range: std::ops::Range<usize>) -> &str {
285        // On WASM, panics are really expensive in terms of code size,
286        // so we use an unchecked get here.
287        unsafe { self.get_unchecked(range) }
288    }
289    #[cfg(not(target_arch = "wasm32"))]
290    #[inline]
291    fn get_unwrap(&self, range: std::ops::Range<usize>) -> &str {
292        self.get(range).expect("valid range")
293    }
294}