1use 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#[derive(Clone, Debug, PartialEq, Eq)]
11pub enum RenderIssue {
12 UnsupportedCommand {
14 name: String,
16 span: SourceSpan,
18 },
19 MissingPackage {
22 name: String,
24 package: &'static str,
26 span: SourceSpan,
28 },
29 UnsupportedEnvironment {
31 name: String,
33 span: SourceSpan,
35 },
36 MissingPackageEnv {
38 name: String,
40 package: &'static str,
42 span: SourceSpan,
44 },
45 MathCommandInTextMode {
48 name: String,
50 span: SourceSpan,
52 },
53}
54
55#[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 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
167const fn normalise_mask_for_renderer(mask: PackageMask, renderer: Renderer) -> PackageMask {
176 match renderer {
177 Renderer::Katex | Renderer::MathJaxV3 => mask,
178 }
179}
180
181fn 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
212fn 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 #[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 #[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}