Skip to main content

mdwright_lint/stdlib/
math_render.rs

1//! `math/render-compat` — math-renderer compatibility checks for every math
2//! span in the document.
3//!
4//! Names like MathJax and KaTeX appear unbacktickd in prose; the
5//! `clippy::doc_markdown` allow on the module covers that.
6//!
7//! Umbrella rule that emits diagnostics under several per-kind rule codes:
8//!
9//! - `math/render-unsupported-command`
10//! - `math/render-missing-package`
11//! - `math/render-unsupported-environment`
12//! - `math/render-missing-package-env`
13//! - `math/render-math-command-in-text`
14//!
15//! The umbrella name `math/render-compat` is what users disable to turn the
16//! whole family off; the per-kind names are what users disable to silence one
17//! kind only. The dispatcher in `rule_set.rs` preserves the per-kind codes set
18//! by this rule.
19//!
20//! The active renderer (MathJax v3, KaTeX) and its loaded packages come from
21//! `[lint.render]` in the project config; the lint rule itself only carries
22//! the resolved profile.
23
24#![allow(
25    clippy::doc_markdown,
26    reason = "names like MathJax and KaTeX appear in prose; backticking each one would add noise"
27)]
28
29use std::borrow::Cow;
30
31use mdwright_document::{Document, MathBody};
32use mdwright_mathrender::{RenderIssue, RenderProfile, check_math_body};
33
34use crate::diagnostic::Diagnostic;
35use crate::rule::LintRule;
36
37/// Math-renderer compatibility lint. Construct with `new()` for the
38/// MathJax v3 default profile; the CLI swaps in a config-derived profile via
39/// `with_profile`.
40pub struct RenderCompat {
41    profile: RenderProfile,
42}
43
44impl RenderCompat {
45    #[must_use]
46    pub fn new() -> Self {
47        Self {
48            profile: RenderProfile::mathjax_v3(),
49        }
50    }
51
52    #[must_use]
53    pub fn with_profile(profile: RenderProfile) -> Self {
54        Self { profile }
55    }
56}
57
58impl Default for RenderCompat {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63
64impl LintRule for RenderCompat {
65    fn name(&self) -> &str {
66        "math/render-compat"
67    }
68
69    fn description(&self) -> &str {
70        "Math-renderer compatibility for inline and display math (MathJax v3 / KaTeX)."
71    }
72
73    fn is_default(&self) -> bool {
74        false
75    }
76
77    fn check(&self, doc: &Document, out: &mut Vec<Diagnostic>) {
78        for region in doc.math_regions() {
79            let body = region.span().body();
80            let cleaned = body.as_str(doc.source());
81            for issue in check_math_body(cleaned.as_ref(), &self.profile) {
82                if let Some(diagnostic) = into_diagnostic(doc, body, &self.profile, &issue) {
83                    out.push(diagnostic);
84                }
85            }
86        }
87    }
88}
89
90fn into_diagnostic(
91    doc: &Document,
92    body: &MathBody,
93    profile: &RenderProfile,
94    issue: &RenderIssue,
95) -> Option<Diagnostic> {
96    let renderer = profile.renderer().name();
97    let pkg_noun = profile.renderer().package_noun();
98    let (code, message, span) = match issue {
99        RenderIssue::UnsupportedCommand { name, span } => (
100            "math/render-unsupported-command",
101            format!("{renderer} does not ship a command `\\{name}` in any {pkg_noun}."),
102            span,
103        ),
104        RenderIssue::MissingPackage { name, package, span } => (
105            "math/render-missing-package",
106            format!("command `\\{name}` requires the {renderer} `{package}` {pkg_noun}, which is not loaded."),
107            span,
108        ),
109        RenderIssue::UnsupportedEnvironment { name, span } => (
110            "math/render-unsupported-environment",
111            format!("{renderer} does not ship an environment `{name}` in any {pkg_noun}."),
112            span,
113        ),
114        RenderIssue::MissingPackageEnv { name, package, span } => (
115            "math/render-missing-package-env",
116            format!("environment `{name}` requires the {renderer} `{package}` {pkg_noun}, which is not loaded."),
117            span,
118        ),
119        RenderIssue::MathCommandInTextMode { name, span } => (
120            "math/render-math-command-in-text",
121            format!("math-mode command `\\{name}` inside `\\text{{...}}` will render as plain text, not as math."),
122            span,
123        ),
124    };
125    let range = span.as_range();
126    let start = body.clean_offset_to_source(range.start);
127    let end = body.clean_offset_to_source(range.end);
128    let mut diagnostic = Diagnostic::at(doc, 0, start..end, message, None)?;
129    diagnostic.rule = Cow::Borrowed(code);
130    Some(diagnostic)
131}
132
133#[cfg(test)]
134mod tests {
135    #![allow(clippy::expect_used, reason = "tests assert diagnostic shape against fixed inputs")]
136
137    use super::*;
138    use mdwright_document::{Document, MathDelimiterSet, MathParseOptions, ParseOptions};
139
140    fn run(src: &str) -> Vec<Diagnostic> {
141        run_with(src, &RenderCompat::new())
142    }
143
144    fn run_with(src: &str, rule: &RenderCompat) -> Vec<Diagnostic> {
145        let opts = ParseOptions::default().with_math(MathParseOptions {
146            delimiters: MathDelimiterSet::Github,
147        });
148        let doc = Document::parse_with_options(src, opts).expect("parse");
149        let mut out = Vec::new();
150        rule.check(&doc, &mut out);
151        out
152    }
153
154    #[test]
155    fn flags_chemistry_command_without_mhchem_default_mathjax() {
156        let diagnostics = run(r"Reaction: $\ce{H2O}$ here.");
157        let codes: Vec<&str> = diagnostics.iter().map(|d| d.rule.as_ref()).collect();
158        assert_eq!(codes, vec!["math/render-missing-package"]);
159        let first = diagnostics.first().expect("at least one diagnostic");
160        assert!(first.message.contains("MathJax v3"));
161    }
162
163    #[test]
164    fn diagnostic_message_names_the_active_renderer_for_katex() {
165        let rule = &RenderCompat::with_profile(RenderProfile::katex());
166        let diagnostics = run_with(r"Reaction: $\ce{H2O}$ here.", rule);
167        let first = diagnostics.first().expect("at least one diagnostic");
168        assert_eq!(diagnostics.len(), 1);
169        assert!(first.message.contains("KaTeX"));
170        assert!(first.message.contains("extension"));
171    }
172
173    #[test]
174    fn flags_unsupported_environment() {
175        let diagnostics = run("$$\n\\begin{tikzpicture}x\\end{tikzpicture}\n$$\n");
176        let codes: Vec<&str> = diagnostics.iter().map(|d| d.rule.as_ref()).collect();
177        assert_eq!(codes, vec!["math/render-unsupported-environment"]);
178    }
179
180    #[test]
181    fn flags_math_in_text_mode() {
182        let diagnostics = run(r"Inline: $\text{value is \alpha}$ here.");
183        let codes: Vec<&str> = diagnostics.iter().map(|d| d.rule.as_ref()).collect();
184        assert_eq!(codes, vec!["math/render-math-command-in-text"]);
185    }
186
187    #[test]
188    fn ignores_well_formed_math_under_mathjax_default() {
189        let diagnostics = run(r"Hello: $\frac{a}{b} + \sqrt{x}$.");
190        assert!(diagnostics.is_empty(), "{diagnostics:?}");
191    }
192
193    #[test]
194    fn ignores_well_formed_math_under_katex() {
195        let rule = &RenderCompat::with_profile(RenderProfile::katex());
196        let diagnostics = run_with(r"Hello: $\mathbb{R} \xrightarrow{f} \mathfrak{m}$.", rule);
197        assert!(diagnostics.is_empty(), "{diagnostics:?}");
198    }
199
200    #[test]
201    fn spans_resolve_to_source_positions() {
202        let src = r"Hello: $\ce{H2O}$.";
203        let diagnostics = run(src);
204        let diagnostic = diagnostics.first().expect("missing diagnostic");
205        let captured = src.get(diagnostic.span.clone()).expect("span");
206        assert_eq!(captured, r"\ce");
207    }
208
209    #[test]
210    fn loading_package_via_profile_silences_diagnostic() {
211        let rule = &RenderCompat::with_profile(RenderProfile::mathjax_v3().with_package("mhchem"));
212        let diagnostics = run_with(r"Reaction: $\ce{H2O}$.", rule);
213        assert!(diagnostics.is_empty(), "{diagnostics:?}");
214    }
215}