Skip to main content

math_core/
error.rs

1use alloc::string::String;
2use core::fmt::{self, Write};
3use core::ops::Range;
4
5use kstring::KString;
6use strum_macros::IntoStaticStr;
7
8use crate::environments::Env;
9use crate::html_utils::{escape_double_quoted_html_attribute, escape_html_content};
10use crate::token::EndToken;
11use crate::{MathDisplay, token::LimitsKind};
12
13/// Represents an error that occurred during LaTeX parsing.
14#[derive(Debug, Clone)]
15pub struct LatexError(pub Range<usize>, pub(crate) LatexErrKind);
16
17#[derive(Debug, Clone)]
18pub(crate) enum LatexErrKind {
19    UnclosedGroup(EndToken),
20    UnmatchedClose(EndToken),
21    ExpectedArgumentGotClose,
22    ExpectedArgumentGotEOI,
23    ExpectedDelimiter(DelimiterModifier),
24    DisallowedChar(char),
25    UnknownEnvironment(KString),
26    UnknownCommand(KString),
27    UnknownColor(KString),
28    MismatchedEnvironment {
29        expected: Env,
30        got: Env,
31    },
32    CannotBeUsedHere {
33        got: LimitedUsabilityToken,
34        correct_place: Place,
35    },
36    ExpectedRelation,
37    ExpectedLargeOp,
38    ExpectedAtMostOneToken,
39    ExpectedExactlyOneToken,
40    BoundFollowedByBound,
41    DuplicateSubOrSup,
42    CannotBeUsedAsArgument,
43    ExpectedAscii,
44    ExpectedLength(KString),
45    IllegalUnit {
46        unit: KString,
47        math_unit_expected: bool,
48    },
49    InvalidUnit(KString),
50    ExpectedColSpec(KString),
51    ExpectedStyle,
52    NotValidInTextMode,
53    NotValidInMathMode,
54    /// A `$` in text mode, which would switch back to math mode.
55    NestedMathModeUnimplemented,
56    /// A `$` in math mode, where there is no mode to switch to.
57    UnexpectedDollar,
58    CouldNotExtractText,
59    MoreThanOneLabel,
60    MoreThanOneInfixCmd,
61    InvalidMacroName(String),
62    /// `\newcommand` was given something which is not a command name.
63    ExpectedCommandName,
64    /// `\newcommand` was given a name which is already taken.
65    CommandAlreadyDefined,
66    /// `\renewcommand` was given a name which isn't defined yet.
67    CommandNotDefined,
68    InvalidParameterNumber,
69    ParameterNumberOutOfRange {
70        n: u8,
71        actual: u8,
72    },
73    /// The parameter text of a `\def` contains something other than `#1`, `#2`, ...
74    DelimitedParameters,
75    /// The parameters of a `\def` are not numbered consecutively, starting at 1.
76    UnexpectedParameterNumber {
77        expected: u8,
78        actual: u8,
79    },
80    MacroParameterOutsideCustomCommand,
81    ExpectedParamNumberGotEOI,
82    HardLimitExceeded,
83    TooManyExpansions,
84    Internal,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, IntoStaticStr)]
88pub enum DelimiterModifier {
89    #[strum(serialize = r"\left")]
90    Left,
91    #[strum(serialize = r"\right")]
92    Right,
93    #[strum(serialize = r"\middle")]
94    Middle,
95    #[strum(serialize = r"\big, \Big, ...")]
96    Big,
97    #[strum(serialize = r"\genfrac")]
98    Genfrac,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, IntoStaticStr)]
102#[repr(u32)] // A different value here somehow increases code size on WASM enormously.
103pub enum Place {
104    #[strum(serialize = r"after \int, \sum, ...")]
105    AfterBigOp,
106    #[strum(serialize = r"in a table-like environment")]
107    TableEnv,
108    #[strum(serialize = r"in a numbered equation environment")]
109    NumberedEnv,
110    #[strum(serialize = r"directly after a `\\` or at the beginning of an array or matrix")]
111    ArrayRowStart,
112    #[strum(serialize = r"directly before \let or \def")]
113    BeforeDefinition,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, IntoStaticStr)]
117pub enum LimitedUsabilityToken {
118    #[strum(serialize = "&")]
119    Ampersand,
120    #[strum(serialize = r"\tag")]
121    Tag,
122    #[strum(serialize = r"\tag*")]
123    TagStar,
124    #[strum(serialize = r"\label")]
125    Label,
126    #[strum(serialize = r"\limits")]
127    Limits,
128    #[strum(serialize = r"\nolimits")]
129    NoLimits,
130    #[strum(serialize = r"\displaylimits")]
131    DisplayLimits,
132    #[strum(serialize = r"\hline")]
133    HLine,
134    #[strum(serialize = r"\hdashline")]
135    HDashLine,
136    #[strum(serialize = r"\global")]
137    Global,
138}
139
140impl From<LimitsKind> for LimitedUsabilityToken {
141    fn from(kind: LimitsKind) -> Self {
142        match kind {
143            LimitsKind::Always => LimitedUsabilityToken::Limits,
144            LimitsKind::Never => LimitedUsabilityToken::NoLimits,
145            LimitsKind::Display => LimitedUsabilityToken::DisplayLimits,
146        }
147    }
148}
149
150impl LatexErrKind {
151    /// Returns the error message as a string.
152    fn write_msg(&self, s: &mut String) -> core::fmt::Result {
153        match self {
154            LatexErrKind::UnclosedGroup(expected) => {
155                write!(
156                    s,
157                    "Expected closing token \"{}\", but reached end of input.",
158                    <&str>::from(expected)
159                )?;
160            }
161            LatexErrKind::UnmatchedClose(got) => {
162                write!(s, "Unmatched closing token: \"{}\".", <&str>::from(got))?;
163            }
164            LatexErrKind::ExpectedArgumentGotClose => {
165                write!(
166                    s,
167                    r"Expected argument but got closing token (`}}`, `\end`, `\right`)."
168                )?;
169            }
170            LatexErrKind::ExpectedArgumentGotEOI => {
171                write!(s, "Expected argument but reached end of input.")?;
172            }
173            LatexErrKind::ExpectedDelimiter(location) => {
174                write!(
175                    s,
176                    "There must be a parenthesis after \"{}\", but not found.",
177                    <&str>::from(*location)
178                )?;
179            }
180            LatexErrKind::ExpectedStyle => {
181                write!(
182                    s,
183                    r"Expected one of `\displaystyle`, `\textstyle`, `\scriptstyle`, or `\scriptscriptstyle`"
184                )?;
185            }
186            LatexErrKind::DisallowedChar(got) => {
187                write!(s, "Disallowed character in text group: '{got}'.")?;
188            }
189            LatexErrKind::UnknownEnvironment(environment) => {
190                write!(s, "Unknown environment \"{environment}\".")?;
191            }
192            LatexErrKind::UnknownCommand(cmd) => {
193                write!(s, "Unknown command \"\\{cmd}\".")?;
194            }
195            LatexErrKind::UnknownColor(color) => {
196                write!(s, "Unknown color \"{color}\".")?;
197            }
198            LatexErrKind::MismatchedEnvironment { expected, got } => {
199                write!(
200                    s,
201                    "Expected \"\\end{{{}}}\", but found \"\\end{{{}}}\".",
202                    expected.as_str(),
203                    got.as_str()
204                )?;
205            }
206            LatexErrKind::CannotBeUsedHere { got, correct_place } => {
207                write!(
208                    s,
209                    "Found \"{}\", which may only appear {}.",
210                    <&str>::from(got),
211                    <&str>::from(correct_place)
212                )?;
213            }
214            LatexErrKind::ExpectedRelation => {
215                write!(s, "Expected a relation after \\not.")?;
216            }
217            LatexErrKind::ExpectedLargeOp => {
218                write!(s, "Expected a large operator.")?;
219            }
220            LatexErrKind::ExpectedAtMostOneToken => {
221                write!(s, "Expected at most one token as argument.")?;
222            }
223            LatexErrKind::ExpectedExactlyOneToken => {
224                write!(s, "Expected exactly one token as argument.")?;
225            }
226            LatexErrKind::BoundFollowedByBound => {
227                write!(s, "'^' or '_' directly followed by '^', '_' or prime.")?;
228            }
229            LatexErrKind::DuplicateSubOrSup => {
230                write!(s, "Duplicate subscript or superscript.")?;
231            }
232            LatexErrKind::CannotBeUsedAsArgument => {
233                write!(s, "Switch-like commands cannot be used as arguments.")?;
234            }
235            LatexErrKind::ExpectedAscii => {
236                write!(
237                    s,
238                    "Expected non-special ASCII characters in string literal."
239                )?;
240            }
241            LatexErrKind::ExpectedLength(got) => {
242                write!(s, "Expected length with units, found \"{got}\".")?;
243            }
244            LatexErrKind::IllegalUnit {
245                unit,
246                math_unit_expected,
247            } => {
248                if *math_unit_expected {
249                    write!(
250                        s,
251                        "Text unit \"{unit}\" cannot be used with \\mkern/\\mskip/\\mspace."
252                    )?;
253                } else {
254                    write!(
255                        s,
256                        "Math unit \"{unit}\" cannot be used with \\kern/\\hskip/\\hspace."
257                    )?;
258                }
259            }
260            LatexErrKind::InvalidUnit(unit) => {
261                write!(s, "Found invalid unit \"{unit}\".")?;
262            }
263            LatexErrKind::ExpectedColSpec(got) => {
264                write!(s, "Expected column specification, found \"{got}\".")?;
265            }
266            LatexErrKind::NotValidInTextMode => {
267                write!(s, "Not valid in text mode.")?;
268            }
269            LatexErrKind::NotValidInMathMode => {
270                write!(s, "Not valid in math mode.")?;
271            }
272            LatexErrKind::NestedMathModeUnimplemented => {
273                write!(s, "Math mode within text mode is not implemented yet.")?;
274            }
275            LatexErrKind::UnexpectedDollar => {
276                write!(s, "Unexpected \"$\".")?;
277            }
278            LatexErrKind::CouldNotExtractText => {
279                write!(s, "Could not extract text from the given macro.")?;
280            }
281            LatexErrKind::MoreThanOneLabel => {
282                write!(s, "Found more than one label in a row.")?;
283            }
284            LatexErrKind::MoreThanOneInfixCmd => {
285                write!(s, "Found more than one infix fraction in a group.")?;
286            }
287            LatexErrKind::InvalidMacroName(name) => {
288                write!(s, "Invalid macro name: \"\\{name}\".")?;
289            }
290            LatexErrKind::ExpectedCommandName => {
291                write!(s, "Expected the name of a command.")?;
292            }
293            LatexErrKind::CommandAlreadyDefined => {
294                write!(s, "This command is already defined.")?;
295            }
296            LatexErrKind::CommandNotDefined => {
297                write!(s, "This command is not defined.")?;
298            }
299            LatexErrKind::InvalidParameterNumber => {
300                write!(s, "Invalid parameter number. Must be 1-9.")?;
301            }
302            LatexErrKind::ParameterNumberOutOfRange { n, actual } => {
303                write!(
304                    s,
305                    "Parameter number {actual} is out of range. Expected a number of at most {n}."
306                )?;
307            }
308            LatexErrKind::DelimitedParameters => {
309                write!(
310                    s,
311                    "Delimited parameters are not supported. Expected \"#n\" or \"{{\" here."
312                )?;
313            }
314            LatexErrKind::UnexpectedParameterNumber { expected, actual } => {
315                write!(
316                    s,
317                    "Expected parameter #{expected}, found #{actual}. Parameters must be numbered consecutively, starting at 1."
318                )?;
319            }
320            LatexErrKind::MacroParameterOutsideCustomCommand => {
321                write!(
322                    s,
323                    "Macro parameter found outside of custom command definition."
324                )?;
325            }
326            LatexErrKind::ExpectedParamNumberGotEOI => {
327                write!(
328                    s,
329                    "Expected parameter number after '#', but reached end of input."
330                )?;
331            }
332            LatexErrKind::HardLimitExceeded => {
333                write!(s, "Hard limit exceeded. Please simplify your equation.")?;
334            }
335            LatexErrKind::TooManyExpansions => {
336                write!(
337                    s,
338                    "Too many expansions of custom commands. A command may be expanding to itself."
339                )?;
340            }
341            LatexErrKind::Internal => {
342                write!(
343                    s,
344                    "Internal parser error. Please report this bug at https://github.com/tmke8/math-core/issues"
345                )?;
346            }
347        }
348        Ok(())
349    }
350}
351
352impl LatexError {
353    /// Format a LaTeX error as an HTML snippet.
354    ///
355    /// # Arguments
356    /// - `latex`: The original LaTeX input that caused the error.
357    /// - `display`: The display mode of the equation (inline or block).
358    /// - `css_class`: An optional CSS class to apply to the error element. If `None`,
359    ///   defaults to `"math-core-error"`.
360    pub fn to_html(&self, latex: &str, display: MathDisplay, css_class: Option<&str>) -> String {
361        let mut output = String::new();
362        let tag = if matches!(display, MathDisplay::Block) {
363            "p"
364        } else {
365            "span"
366        };
367        let css_class = css_class.unwrap_or("math-core-error");
368        let _ = write!(output, r#"<{tag} class="{css_class}" title=""#);
369        let mut err_msg = String::new();
370        self.to_message(&mut err_msg, latex);
371        escape_double_quoted_html_attribute(&mut output, &err_msg);
372        output.push_str(r#""><code>"#);
373        escape_html_content(&mut output, latex);
374        let _ = write!(output, "</code></{tag}>");
375        output
376    }
377
378    /// Returns only the error message itself as a string.
379    pub fn error_message(&self) -> String {
380        let mut s = String::new();
381        let _ = self.1.write_msg(&mut s);
382        s
383    }
384
385    /// Format a LaTeX error as a plain text message, including the source name and position.
386    ///
387    /// # Arguments
388    /// - `s`: The string to write the message into.
389    /// - `input`: The original LaTeX input that caused the error; used to
390    ///   calculate the character offset for the error position.
391    pub fn to_message(&self, s: &mut String, input: &str) {
392        let loc = input.floor_char_boundary(self.0.start);
393        let codepoint_offset = input[..loc].chars().count();
394        let _ = write!(s, "{codepoint_offset}: ");
395        let _ = self.1.write_msg(s);
396    }
397
398    /// Returns a short label for the main error location.
399    pub fn label(&self) -> &'static str {
400        match &self.1 {
401            LatexErrKind::UnclosedGroup(_) => "a group was never closed",
402            LatexErrKind::UnmatchedClose(_) => "no matching opening for this",
403            LatexErrKind::ExpectedArgumentGotClose | LatexErrKind::ExpectedArgumentGotEOI => {
404                "expected an argument here"
405            }
406            LatexErrKind::ExpectedDelimiter(_) => "expected a delimiter here",
407            LatexErrKind::DisallowedChar(_) => "disallowed character",
408            LatexErrKind::UnknownEnvironment(_) => "unknown environment",
409            LatexErrKind::UnknownCommand(_) => "unknown command",
410            LatexErrKind::UnknownColor(_) => "unknown color",
411            LatexErrKind::MismatchedEnvironment { .. } => {
412                "expected a different environment name here"
413            }
414            LatexErrKind::CannotBeUsedHere { .. } => "cannot be used here",
415            LatexErrKind::ExpectedRelation => "expected a relation",
416            LatexErrKind::ExpectedLargeOp => "expected a large operator",
417            LatexErrKind::ExpectedStyle => "expected a style",
418            LatexErrKind::ExpectedAtMostOneToken => "expected at most one token here",
419            LatexErrKind::ExpectedExactlyOneToken => "expected exactly one token here",
420            LatexErrKind::BoundFollowedByBound => "unexpected bound",
421            LatexErrKind::DuplicateSubOrSup => "duplicate",
422            LatexErrKind::CannotBeUsedAsArgument => "used as argument",
423            LatexErrKind::ExpectedAscii => "special or not ASCII",
424            LatexErrKind::ExpectedLength(_) => "expected length here",
425            LatexErrKind::IllegalUnit { .. } => "illegal unit here",
426            LatexErrKind::InvalidUnit(_) => "invalid unit here",
427            LatexErrKind::ExpectedColSpec(_) => "expected a column spec here",
428            LatexErrKind::NotValidInTextMode => "this is not valid in text mode",
429            LatexErrKind::NotValidInMathMode => "this is not valid in math mode",
430            LatexErrKind::NestedMathModeUnimplemented => "cannot switch to math mode here",
431            LatexErrKind::UnexpectedDollar => "unexpected dollar sign",
432            LatexErrKind::CouldNotExtractText => "could not extract text from this",
433            LatexErrKind::MoreThanOneLabel => "duplicate label",
434            LatexErrKind::MoreThanOneInfixCmd => "duplicate infix frac",
435            LatexErrKind::InvalidMacroName(_) => "invalid name here",
436            LatexErrKind::ExpectedCommandName => "expected a command name here",
437            LatexErrKind::CommandAlreadyDefined => "already defined",
438            LatexErrKind::CommandNotDefined => "not defined",
439            LatexErrKind::InvalidParameterNumber => "must be 1-9",
440            LatexErrKind::ParameterNumberOutOfRange { .. } => "parameter number out of range",
441            LatexErrKind::DelimitedParameters => "unsupported delimiter",
442            LatexErrKind::UnexpectedParameterNumber { .. } => "unexpected parameter number",
443            LatexErrKind::MacroParameterOutsideCustomCommand => "unexpected macro parameter",
444            LatexErrKind::ExpectedParamNumberGotEOI => "expected parameter number",
445            LatexErrKind::HardLimitExceeded => "limit exceeded",
446            LatexErrKind::TooManyExpansions => "expansion limit exceeded",
447            LatexErrKind::Internal => "internal error",
448        }
449    }
450}
451
452#[cfg(feature = "ariadne")]
453impl LatexError {
454    /// Convert this error into an [`ariadne::Report`] for pretty-printing.
455    pub fn to_report<'name>(
456        &self,
457        source_name: &'name str,
458        with_color: bool,
459    ) -> ariadne::Report<'static, (&'name str, Range<usize>)> {
460        use ariadne::{Label, Report, ReportKind};
461
462        let label_msg = self.label();
463
464        let mut config = ariadne::Config::default().with_index_type(ariadne::IndexType::Byte);
465        if !with_color {
466            config = config.with_color(false);
467        }
468        Report::build(ReportKind::Error, (source_name, self.0.start..self.0.start))
469            .with_config(config)
470            .with_message(self.error_message())
471            .with_label(Label::new((source_name, self.0.clone())).with_message(label_msg))
472            .finish()
473    }
474}
475
476impl fmt::Display for LatexError {
477    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
478        write!(f, "{}", self.error_message())
479    }
480}
481
482impl core::error::Error for LatexError {}
483
484pub trait GetUnwrap {
485    /// `str::get` with `Option::unwrap`.
486    fn get_unwrap(&self, range: core::ops::Range<usize>) -> &str;
487}
488
489impl GetUnwrap for str {
490    #[cfg(target_arch = "wasm32")]
491    #[inline]
492    fn get_unwrap(&self, range: core::ops::Range<usize>) -> &str {
493        // On WASM, panics are really expensive in terms of code size,
494        // so we use an unchecked get here.
495        unsafe { self.get_unchecked(range) }
496    }
497    #[cfg(not(target_arch = "wasm32"))]
498    #[inline]
499    fn get_unwrap(&self, range: core::ops::Range<usize>) -> &str {
500        self.get(range).expect("valid range")
501    }
502}