Skip to main content

mdwright_mathrender/
check.rs

1//! Single-pass renderer-compatibility check.
2
3use mdwright_latex::{CommandEvent, SourceSpan, inspect_math_body};
4
5use crate::profile::{PackageMask, RenderProfile, Renderer, package_from_name, package_name};
6use crate::tables::{command_overlay, environment_overlay, lookup_overlay};
7
8/// One compatibility issue found in a math body. Spans are byte ranges into
9/// the math-body source given to `check_math_body`.
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub enum RenderIssue {
12    /// A command the renderer does not ship in any package the profile knows.
13    UnsupportedCommand {
14        /// Command name without the leading backslash.
15        name: String,
16        /// Byte range covering the command token.
17        span: SourceSpan,
18    },
19    /// A command the renderer can render, but only with a package that this
20    /// profile does not load. Suggest the package name in `package`.
21    MissingPackage {
22        /// Command name without the leading backslash.
23        name: String,
24        /// Canonical name of the package the user should load.
25        package: &'static str,
26        /// Byte range covering the command token.
27        span: SourceSpan,
28    },
29    /// An environment the renderer does not ship in any package the profile knows.
30    UnsupportedEnvironment {
31        /// Environment name as written between the braces.
32        name: String,
33        /// Byte range covering `\begin{name}` through the closing brace.
34        span: SourceSpan,
35    },
36    /// An environment that requires a package this profile does not load.
37    MissingPackageEnv {
38        /// Environment name as written between the braces.
39        name: String,
40        /// Canonical name of the package the user should load.
41        package: &'static str,
42        /// Byte range covering `\begin{name}` through the closing brace.
43        span: SourceSpan,
44    },
45    /// A math-mode command used inside a `\text{...}` region, where the
46    /// renderer will treat it as plain text rather than rendering it.
47    MathCommandInTextMode {
48        /// Command name without the leading backslash.
49        name: String,
50        /// Byte range covering the command token.
51        span: SourceSpan,
52    },
53}
54
55/// Check `source` (one math body, no enclosing delimiters) against `profile`.
56///
57/// The check is single-pass over the lexer event stream from `mdwright-latex`:
58/// each command and environment is classified into "ok" / "needs package" /
59/// "unsupported" by consulting the profile's renderer table, the
60/// `mdwright-latex` registry fallback, and the profile's package mask. Issues
61/// come back in source order; the result is empty when the body is fully
62/// compatible.
63#[must_use]
64pub fn check_math_body(source: &str, profile: &RenderProfile) -> Vec<RenderIssue> {
65    let events = inspect_math_body(source);
66    let mut issues = Vec::new();
67    let mut text_depth: usize = 0;
68
69    for event in events {
70        match event {
71            CommandEvent::TextModeEnter { .. } => {
72                text_depth = text_depth.saturating_add(1);
73            }
74            CommandEvent::TextModeExit { .. } => {
75                text_depth = text_depth.saturating_sub(1);
76            }
77            CommandEvent::Command { name, span } => {
78                if text_depth > 0 {
79                    if is_math_only_command(name) {
80                        issues.push(RenderIssue::MathCommandInTextMode {
81                            name: name.to_owned(),
82                            span,
83                        });
84                    }
85                    continue;
86                }
87                if let Some(issue) = classify_command(name, span, profile) {
88                    issues.push(issue);
89                }
90            }
91            CommandEvent::EnvironmentEnter { name, span } => {
92                if let Some(issue) = classify_environment(name, span, profile) {
93                    issues.push(issue);
94                }
95            }
96            CommandEvent::EnvironmentExit { .. } => {}
97        }
98    }
99
100    issues
101}
102
103fn classify_command(name: &str, span: SourceSpan, profile: &RenderProfile) -> Option<RenderIssue> {
104    if profile.has_macro(name) {
105        return None;
106    }
107    if is_structural_macro(name) {
108        return None;
109    }
110    if let Some(entry) = lookup_overlay(command_overlay(profile.renderer()), name) {
111        return resolve_package(name, span, entry.package, profile, false);
112    }
113    if let Some(info) = mdwright_latex::lookup_command(name) {
114        if let Some(mask) = package_from_name(info.package()) {
115            // KaTeX has no separate `text-base`; `mdwright-latex` labels some
116            // text-mode commands that way. Treat that label as BASE for KaTeX.
117            let mask = normalise_mask_for_renderer(mask, profile.renderer());
118            return resolve_package(name, span, mask, profile, false);
119        }
120        return Some(RenderIssue::UnsupportedCommand {
121            name: name.to_owned(),
122            span,
123        });
124    }
125    Some(RenderIssue::UnsupportedCommand {
126        name: name.to_owned(),
127        span,
128    })
129}
130
131fn classify_environment(name: &str, span: SourceSpan, profile: &RenderProfile) -> Option<RenderIssue> {
132    if let Some(entry) = lookup_overlay(environment_overlay(profile.renderer()), name) {
133        return resolve_package(name, span, entry.package, profile, true);
134    }
135    Some(RenderIssue::UnsupportedEnvironment {
136        name: name.to_owned(),
137        span,
138    })
139}
140
141fn resolve_package(
142    name: &str,
143    span: SourceSpan,
144    mask: PackageMask,
145    profile: &RenderProfile,
146    is_environment: bool,
147) -> Option<RenderIssue> {
148    if profile.has_package(mask) {
149        return None;
150    }
151    let package = package_name(mask);
152    Some(if is_environment {
153        RenderIssue::MissingPackageEnv {
154            name: name.to_owned(),
155            package,
156            span,
157        }
158    } else {
159        RenderIssue::MissingPackage {
160            name: name.to_owned(),
161            package,
162            span,
163        }
164    })
165}
166
167/// Fold renderer-specific package conventions. `mdwright-latex` records some
168/// commands with package `"text-base"` (text-mode commands like `\textbf`).
169/// KaTeX has no such split — its core covers them — so the mask is folded to
170/// BASE there. For MathJax v3 the bit set is the same (BASE) since the
171/// registry's text-base bucket is folded into BASE by `package_from_name`
172/// returning `None` for "text-base" and the upstream caller treating that as
173/// unsupported; here we keep the upstream behaviour intact for MathJax and
174/// only translate for KaTeX.
175const fn normalise_mask_for_renderer(mask: PackageMask, renderer: Renderer) -> PackageMask {
176    match renderer {
177        Renderer::Katex | Renderer::MathJaxV3 => mask,
178    }
179}
180
181/// Structural commands `inspect_math_body` reports but which every supported
182/// renderer always understands as part of the base grammar.
183fn is_structural_macro(name: &str) -> bool {
184    matches!(
185        name,
186        "left"
187            | "right"
188            | "bigl"
189            | "bigr"
190            | "Bigl"
191            | "Bigr"
192            | "biggl"
193            | "biggr"
194            | "Biggl"
195            | "Biggr"
196            | "big"
197            | "Big"
198            | "bigg"
199            | "Bigg"
200            | "text"
201            | "textbf"
202            | "textit"
203            | "textrm"
204            | "textsf"
205            | "texttt"
206            | "textnormal"
207            | "mbox"
208            | "hbox"
209    )
210}
211
212/// Whether `name` is a math-mode-only command. Used to decide whether a
213/// command inside `\text{...}` is a likely rendering mistake.
214fn is_math_only_command(name: &str) -> bool {
215    if let Some(info) = mdwright_latex::lookup_command(name) {
216        use mdwright_latex::CommandCategory;
217        return matches!(
218            info.category(),
219            CommandCategory::Greek
220                | CommandCategory::BinaryOperator
221                | CommandCategory::Relation
222                | CommandCategory::Arrow
223                | CommandCategory::LargeOperator
224                | CommandCategory::Accent
225                | CommandCategory::Delimiter
226        );
227    }
228    false
229}
230
231#[cfg(test)]
232mod tests {
233    #![allow(
234        clippy::expect_used,
235        clippy::wildcard_enum_match_arm,
236        reason = "tests assert diagnostic shape against fixed inputs"
237    )]
238
239    use super::*;
240
241    fn issues(source: &str, profile: &RenderProfile) -> Vec<RenderIssue> {
242        check_math_body(source, profile)
243    }
244
245    // ---- MathJax v3 cases (carried over from the original suite) ----
246
247    #[test]
248    fn well_formed_math_produces_no_issues_under_mathjax() {
249        let profile = RenderProfile::mathjax_v3();
250        assert!(issues(r"\alpha + \beta = \gamma", &profile).is_empty());
251        assert!(issues(r"\frac{a}{b} + \sqrt{x}", &profile).is_empty());
252    }
253
254    #[test]
255    fn ams_commands_pass_under_mathjax_default() {
256        let profile = RenderProfile::mathjax_v3();
257        assert!(issues(r"\dfrac{a}{b}", &profile).is_empty());
258        assert!(issues(r"\mathbb{R}", &profile).is_empty());
259    }
260
261    #[test]
262    fn chemistry_command_requires_mhchem_under_mathjax() {
263        let profile = RenderProfile::mathjax_v3();
264        let found = issues(r"\ce{H2O}", &profile);
265        assert!(matches!(
266            found.as_slice(),
267            [RenderIssue::MissingPackage { name, package: "mhchem", .. }] if name == "ce"
268        ));
269    }
270
271    #[test]
272    fn loading_mhchem_clears_chemistry_diagnostic_under_mathjax() {
273        let profile = RenderProfile::mathjax_v3().with_package("mhchem");
274        assert!(issues(r"\ce{H2O}", &profile).is_empty());
275    }
276
277    #[test]
278    fn physics_commands_require_physics_package_under_mathjax() {
279        let profile = RenderProfile::mathjax_v3();
280        let found = issues(r"\bra{\psi}\ket{\phi}", &profile);
281        let names: Vec<&str> = found
282            .iter()
283            .filter_map(|issue| match issue {
284                RenderIssue::MissingPackage {
285                    name,
286                    package: "physics",
287                    ..
288                } => Some(name.as_str()),
289                _ => None,
290            })
291            .collect();
292        assert_eq!(names, vec!["bra", "ket"]);
293    }
294
295    #[test]
296    fn definitely_unknown_command_is_unsupported() {
297        let profile = RenderProfile::mathjax_v3();
298        let found = issues(r"\nosuchcommandever", &profile);
299        assert!(matches!(
300            found.as_slice(),
301            [RenderIssue::UnsupportedCommand { name, .. }] if name == "nosuchcommandever"
302        ));
303    }
304
305    #[test]
306    fn user_macro_silences_unsupported_command() {
307        let profile = RenderProfile::mathjax_v3().with_macro("RR", 0);
308        assert!(issues(r"\RR", &profile).is_empty());
309    }
310
311    #[test]
312    fn unknown_environment_is_unsupported() {
313        let profile = RenderProfile::mathjax_v3();
314        let found = issues(r"\begin{tikzpicture}x\end{tikzpicture}", &profile);
315        assert!(matches!(
316            found.as_slice(),
317            [RenderIssue::UnsupportedEnvironment { name, .. }] if name == "tikzpicture"
318        ));
319    }
320
321    #[test]
322    fn amscd_environment_needs_package_under_mathjax() {
323        let profile = RenderProfile::mathjax_v3();
324        let found = issues(r"\begin{CD}A @>>> B\end{CD}", &profile);
325        assert!(matches!(
326            found.first(),
327            Some(RenderIssue::MissingPackageEnv {
328                name,
329                package: "amscd",
330                ..
331            }) if name == "CD"
332        ));
333    }
334
335    #[test]
336    fn math_command_inside_text_is_flagged() {
337        let profile = RenderProfile::mathjax_v3();
338        let found = issues(r"\text{the symbol \alpha here}", &profile);
339        assert!(matches!(
340            found.as_slice(),
341            [RenderIssue::MathCommandInTextMode { name, .. }] if name == "alpha"
342        ));
343    }
344
345    #[test]
346    fn math_command_outside_text_is_not_flagged() {
347        let profile = RenderProfile::mathjax_v3();
348        assert!(issues(r"\alpha + \beta", &profile).is_empty());
349    }
350
351    #[test]
352    fn color_needs_color_package_under_mathjax() {
353        let profile = RenderProfile::mathjax_v3();
354        let found = issues(r"\color{red} x", &profile);
355        assert!(matches!(
356            found.first(),
357            Some(RenderIssue::MissingPackage {
358                name,
359                package: "color",
360                ..
361            }) if name == "color"
362        ));
363    }
364
365    #[test]
366    fn structural_left_right_are_silent() {
367        let profile = RenderProfile::mathjax_v3();
368        assert!(issues(r"\left( x \right)", &profile).is_empty());
369    }
370
371    // ---- KaTeX cases ----
372
373    #[test]
374    fn well_formed_math_produces_no_issues_under_katex() {
375        let profile = RenderProfile::katex();
376        assert!(issues(r"\alpha + \beta = \gamma", &profile).is_empty());
377        assert!(issues(r"\frac{a}{b} + \sqrt{x}", &profile).is_empty());
378        assert!(issues(r"\mathbb{R} \xrightarrow{f} \mathfrak{m}", &profile).is_empty());
379    }
380
381    #[test]
382    fn chemistry_command_requires_mhchem_under_katex() {
383        let profile = RenderProfile::katex();
384        let found = issues(r"\ce{H2O}", &profile);
385        assert!(matches!(
386            found.as_slice(),
387            [RenderIssue::MissingPackage { name, package: "mhchem", .. }] if name == "ce"
388        ));
389    }
390
391    #[test]
392    fn loading_mhchem_clears_chemistry_diagnostic_under_katex() {
393        let profile = RenderProfile::katex().with_package("mhchem");
394        assert!(issues(r"\ce{H2O}", &profile).is_empty());
395    }
396
397    #[test]
398    fn tikz_environment_is_unsupported_under_katex() {
399        let profile = RenderProfile::katex();
400        let found = issues(r"\begin{tikzpicture}x\end{tikzpicture}", &profile);
401        assert!(matches!(
402            found.as_slice(),
403            [RenderIssue::UnsupportedEnvironment { name, .. }] if name == "tikzpicture"
404        ));
405    }
406
407    #[test]
408    fn profile_records_renderer_choice() {
409        assert_eq!(RenderProfile::mathjax_v3().renderer(), Renderer::MathJaxV3);
410        assert_eq!(RenderProfile::katex().renderer(), Renderer::Katex);
411    }
412}