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