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    ExpectedArgumentGotEOI,
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    ExpectedAtMostOneToken,
36    ExpectedAtLeastOneToken,
37    BoundFollowedByBound,
38    DuplicateSubOrSup,
39    CannotBeUsedAsArgument,
40    ExpectedText,
41    ExpectedLength(Box<str>),
42    ExpectedColSpec(Box<str>),
43    ExpectedNumber(Box<str>),
44    NotValidInTextMode,
45    NotValidInMathMode,
46    CouldNotExtractText,
47    MoreThanOneLabel,
48    MoreThanOneInfixCmd,
49    UndefinedLabel(Box<str>),
50    InvalidMacroName(String),
51    InvalidParameterNumber,
52    MacroParameterOutsideCustomCommand,
53    ExpectedParamNumberGotEOI,
54    HardLimitExceeded,
55    Internal,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, IntoStaticStr)]
59pub enum DelimiterModifier {
60    #[strum(serialize = r"\left")]
61    Left,
62    #[strum(serialize = r"\right")]
63    Right,
64    #[strum(serialize = r"\middle")]
65    Middle,
66    #[strum(serialize = r"\big, \Big, ...")]
67    Big,
68    #[strum(serialize = r"\genfrac")]
69    Genfrac,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, IntoStaticStr)]
73#[repr(u32)] // A different value here somehow increases code size on WASM enormously.
74pub enum Place {
75    #[strum(serialize = r"after \int, \sum, ...")]
76    AfterBigOp,
77    #[strum(serialize = r"in a table-like environment")]
78    TableEnv,
79    #[strum(serialize = r"in a numbered equation environment")]
80    NumberedEnv,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, IntoStaticStr)]
84pub enum LimitedUsabilityToken {
85    #[strum(serialize = "&")]
86    Ampersand,
87    #[strum(serialize = r"\tag")]
88    Tag,
89    #[strum(serialize = r"\label")]
90    Label,
91    #[strum(serialize = r"\limits")]
92    Limits,
93}
94
95impl LatexErrKind {
96    /// Returns the error message as a string.
97    fn write_msg(&self, s: &mut String) -> std::fmt::Result {
98        match self {
99            LatexErrKind::UnclosedGroup(expected) => {
100                write!(
101                    s,
102                    "Expected token \"{}\", but not found.",
103                    <&str>::from(expected)
104                )?;
105            }
106            LatexErrKind::UnmatchedClose(got) => {
107                write!(s, "Unmatched closing token: \"{}\".", <&str>::from(got))?;
108            }
109            LatexErrKind::ExpectedArgumentGotClose => {
110                write!(
111                    s,
112                    r"Expected argument but got closing token (`}}`, `\end`, `\right`)."
113                )?;
114            }
115            LatexErrKind::ExpectedArgumentGotEOI => {
116                write!(s, "Expected argument but reached end of input.")?;
117            }
118            LatexErrKind::ExpectedDelimiter(location) => {
119                write!(
120                    s,
121                    "There must be a parenthesis after \"{}\", but not found.",
122                    <&str>::from(*location)
123                )?;
124            }
125            LatexErrKind::DisallowedChar(got) => {
126                write!(s, "Disallowed character in text group: '{got}'.")?;
127            }
128            LatexErrKind::UnknownEnvironment(environment) => {
129                write!(s, "Unknown environment \"{environment}\".")?;
130            }
131            LatexErrKind::UnknownCommand(cmd) => {
132                write!(s, "Unknown command \"\\{cmd}\".")?;
133            }
134            LatexErrKind::UnknownColor(color) => {
135                write!(s, "Unknown color \"{color}\".")?;
136            }
137            LatexErrKind::MismatchedEnvironment { expected, got } => {
138                write!(
139                    s,
140                    "Expected \"\\end{{{}}}\", but got \"\\end{{{}}}\".",
141                    expected.as_str(),
142                    got.as_str()
143                )?;
144            }
145            LatexErrKind::CannotBeUsedHere { got, correct_place } => {
146                write!(
147                    s,
148                    "Got \"{}\", which may only appear {}.",
149                    <&str>::from(got),
150                    <&str>::from(correct_place)
151                )?;
152            }
153            LatexErrKind::ExpectedRelation => {
154                write!(s, "Expected a relation after \\not.")?;
155            }
156            LatexErrKind::ExpectedAtMostOneToken => {
157                write!(s, "Expected at most one token as argument.")?;
158            }
159            LatexErrKind::ExpectedAtLeastOneToken => {
160                write!(s, "Expected at least one token as argument.")?;
161            }
162            LatexErrKind::BoundFollowedByBound => {
163                write!(s, "'^' or '_' directly followed by '^', '_' or prime.")?;
164            }
165            LatexErrKind::DuplicateSubOrSup => {
166                write!(s, "Duplicate subscript or superscript.")?;
167            }
168            LatexErrKind::CannotBeUsedAsArgument => {
169                write!(s, "Switch-like commands cannot be used as arguments.")?;
170            }
171            LatexErrKind::ExpectedText => {
172                write!(s, "Expected text in string literal.")?;
173            }
174            LatexErrKind::ExpectedLength(got) => {
175                write!(s, "Expected length with units, got \"{got}\".")?;
176            }
177            LatexErrKind::ExpectedNumber(got) => {
178                write!(s, "Expected a number, got \"{got}\".")?;
179            }
180            LatexErrKind::ExpectedColSpec(got) => {
181                write!(s, "Expected column specification, got \"{got}\".")?;
182            }
183            LatexErrKind::NotValidInTextMode => {
184                write!(s, "Not valid in text mode.")?;
185            }
186            LatexErrKind::NotValidInMathMode => {
187                write!(s, "Not valid in math mode.")?;
188            }
189            LatexErrKind::CouldNotExtractText => {
190                write!(s, "Could not extract text from the given macro.")?;
191            }
192            LatexErrKind::MoreThanOneLabel => {
193                write!(s, "Found more than one label in a row.")?;
194            }
195            LatexErrKind::MoreThanOneInfixCmd => {
196                write!(s, "Found more than one infix fraction in a group.")?;
197            }
198            LatexErrKind::UndefinedLabel(label) => {
199                write!(s, "Found undefined label: \"{label}\".")?;
200            }
201            LatexErrKind::InvalidMacroName(name) => {
202                write!(s, "Invalid macro name: \"\\{name}\".")?;
203            }
204            LatexErrKind::InvalidParameterNumber => {
205                write!(s, "Invalid parameter number. Must be 1-9.")?;
206            }
207            LatexErrKind::MacroParameterOutsideCustomCommand => {
208                write!(
209                    s,
210                    "Macro parameter found outside of custom command definition."
211                )?;
212            }
213            LatexErrKind::ExpectedParamNumberGotEOI => {
214                write!(
215                    s,
216                    "Expected parameter number after '#', but got end of input."
217                )?;
218            }
219            LatexErrKind::HardLimitExceeded => {
220                write!(s, "Hard limit exceeded. Please simplify your equation.")?;
221            }
222            LatexErrKind::Internal => {
223                write!(
224                    s,
225                    "Internal parser error. Please report this bug at https://github.com/tmke8/math-core/issues"
226                )?;
227            }
228        }
229        Ok(())
230    }
231}
232
233impl LatexError {
234    /// Format a LaTeX error as an HTML snippet.
235    ///
236    /// # Arguments
237    /// - `latex`: The original LaTeX input that caused the error.
238    /// - `display`: The display mode of the equation (inline or block).
239    /// - `css_class`: An optional CSS class to apply to the error element. If `None`,
240    ///   defaults to `"math-core-error"`.
241    pub fn to_html(&self, latex: &str, display: MathDisplay, css_class: Option<&str>) -> String {
242        let mut output = String::new();
243        let tag = if matches!(display, MathDisplay::Block) {
244            "p"
245        } else {
246            "span"
247        };
248        let css_class = css_class.unwrap_or("math-core-error");
249        let _ = write!(output, r#"<{tag} class="{css_class}" title=""#);
250        let mut err_msg = String::new();
251        self.to_message(&mut err_msg, latex);
252        escape_double_quoted_html_attribute(&mut output, &err_msg);
253        output.push_str(r#""><code>"#);
254        escape_html_content(&mut output, latex);
255        let _ = write!(output, "</code></{tag}>");
256        output
257    }
258
259    pub fn error_message(&self) -> String {
260        let mut s = String::new();
261        let _ = self.1.write_msg(&mut s);
262        s
263    }
264
265    /// Format a LaTeX error as a plain text message, including the source name and position.
266    ///
267    /// # Arguments
268    /// - `s`: The string to write the message into.
269    /// - `input`: The original LaTeX input that caused the error; used to
270    ///   calculate the character offset for the error position.
271    pub fn to_message(&self, s: &mut String, input: &str) {
272        let loc = input.floor_char_boundary(self.0.start);
273        let codepoint_offset = input[..loc].chars().count();
274        let _ = write!(s, "{codepoint_offset}: ");
275        let _ = self.1.write_msg(s);
276    }
277}
278
279#[cfg(feature = "ariadne")]
280impl LatexError {
281    /// Convert this error into an [`ariadne::Report`] for pretty-printing.
282    pub fn to_report<'name>(
283        &self,
284        source_name: &'name str,
285        with_color: bool,
286    ) -> ariadne::Report<'static, (&'name str, Range<usize>)> {
287        use std::borrow::Cow;
288
289        use ariadne::{Label, Report, ReportKind};
290
291        let label_msg: Cow<'_, str> = match &self.1 {
292            LatexErrKind::UnclosedGroup(expected) => format!(
293                "expected \"{}\" to close this group",
294                <&str>::from(expected)
295            )
296            .into(),
297            LatexErrKind::UnmatchedClose(_) => "no matching opening for this".into(),
298            LatexErrKind::ExpectedArgumentGotClose | LatexErrKind::ExpectedArgumentGotEOI => {
299                "expected an argument here".into()
300            }
301            LatexErrKind::ExpectedDelimiter(_) => "expected a delimiter here".into(),
302            LatexErrKind::DisallowedChar(_) => "disallowed character".into(),
303            LatexErrKind::UnknownEnvironment(_) => "unknown environment".into(),
304            LatexErrKind::UnknownCommand(_) => "unknown command".into(),
305            LatexErrKind::UnknownColor(_) => "unknown color".into(),
306            LatexErrKind::MismatchedEnvironment { expected, .. } => {
307                format!("expected \"\\end{{{}}}\" here", expected.as_str()).into()
308            }
309            LatexErrKind::CannotBeUsedHere { correct_place, .. } => {
310                format!("may only appear {}", <&str>::from(correct_place)).into()
311            }
312            LatexErrKind::ExpectedRelation => "expected a relation".into(),
313            LatexErrKind::ExpectedAtMostOneToken => "expected at most one token here".into(),
314            LatexErrKind::ExpectedAtLeastOneToken => "expected at least one token here".into(),
315            LatexErrKind::BoundFollowedByBound => "unexpected bound".into(),
316            LatexErrKind::DuplicateSubOrSup => "duplicate".into(),
317            LatexErrKind::CannotBeUsedAsArgument => "used as argument".into(),
318            LatexErrKind::ExpectedText => "expected text here".into(),
319            LatexErrKind::ExpectedLength(_) => "expected length here".into(),
320            LatexErrKind::ExpectedNumber(_) => "expected a number here".into(),
321            LatexErrKind::ExpectedColSpec(_) => "expected a column spec here".into(),
322            LatexErrKind::NotValidInTextMode => "this is not valid in text mode".into(),
323            LatexErrKind::NotValidInMathMode => "this is not valid in math mode".into(),
324            LatexErrKind::CouldNotExtractText => "could not extract text from this".into(),
325            LatexErrKind::MoreThanOneLabel => "duplicate label".into(),
326            LatexErrKind::MoreThanOneInfixCmd => "duplicate infix frac".into(),
327            LatexErrKind::UndefinedLabel(_) => "label has not been previously defined".into(),
328            LatexErrKind::InvalidMacroName(_) => "invalid name here".into(),
329            LatexErrKind::InvalidParameterNumber => "must be 1-9".into(),
330            LatexErrKind::MacroParameterOutsideCustomCommand => "unexpected macro parameter".into(),
331            LatexErrKind::ExpectedParamNumberGotEOI => "expected parameter number".into(),
332            LatexErrKind::HardLimitExceeded => "limit exceeded".into(),
333            LatexErrKind::Internal => "internal error".into(),
334        };
335
336        let mut config = ariadne::Config::default().with_index_type(ariadne::IndexType::Byte);
337        if !with_color {
338            config = config.with_color(false);
339        }
340        Report::build(ReportKind::Error, (source_name, self.0.start..self.0.start))
341            .with_config(config)
342            .with_message(self.error_message())
343            .with_label(Label::new((source_name, self.0.clone())).with_message(label_msg))
344            .finish()
345    }
346}
347
348impl fmt::Display for LatexError {
349    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
350        write!(f, "{}", self.error_message())
351    }
352}
353
354impl std::error::Error for LatexError {}
355
356pub trait GetUnwrap {
357    /// `str::get` with `Option::unwrap`.
358    fn get_unwrap(&self, range: std::ops::Range<usize>) -> &str;
359}
360
361impl GetUnwrap for str {
362    #[cfg(target_arch = "wasm32")]
363    #[inline]
364    fn get_unwrap(&self, range: std::ops::Range<usize>) -> &str {
365        // On WASM, panics are really expensive in terms of code size,
366        // so we use an unchecked get here.
367        unsafe { self.get_unchecked(range) }
368    }
369    #[cfg(not(target_arch = "wasm32"))]
370    #[inline]
371    fn get_unwrap(&self, range: std::ops::Range<usize>) -> &str {
372        self.get(range).expect("valid range")
373    }
374}