mdwright_lint/stdlib/
math_render.rs1#![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
37pub 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}