use mdwright_latex::{CommandEvent, SourceSpan, inspect_math_body};
use crate::profile::{PackageMask, RenderProfile, Renderer, package_from_name, package_name};
use crate::tables::{command_overlay, environment_overlay, lookup_overlay};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RenderIssue {
UnsupportedCommand {
name: String,
span: SourceSpan,
},
MissingPackage {
name: String,
package: &'static str,
span: SourceSpan,
},
UnsupportedEnvironment {
name: String,
span: SourceSpan,
},
MissingPackageEnv {
name: String,
package: &'static str,
span: SourceSpan,
},
MathCommandInTextMode {
name: String,
span: SourceSpan,
},
}
#[must_use]
pub fn check_math_body(source: &str, profile: &RenderProfile) -> Vec<RenderIssue> {
let events = inspect_math_body(source);
let mut issues = Vec::new();
let mut text_depth: usize = 0;
for event in events {
match event {
CommandEvent::TextModeEnter { .. } => {
text_depth = text_depth.saturating_add(1);
}
CommandEvent::TextModeExit { .. } => {
text_depth = text_depth.saturating_sub(1);
}
CommandEvent::Command { name, span } => {
if text_depth > 0 {
if is_math_only_command(name) {
issues.push(RenderIssue::MathCommandInTextMode {
name: name.to_owned(),
span,
});
}
continue;
}
if let Some(issue) = classify_command(name, span, profile) {
issues.push(issue);
}
}
CommandEvent::EnvironmentEnter { name, span } => {
if let Some(issue) = classify_environment(name, span, profile) {
issues.push(issue);
}
}
CommandEvent::EnvironmentExit { .. } => {}
}
}
issues
}
fn classify_command(name: &str, span: SourceSpan, profile: &RenderProfile) -> Option<RenderIssue> {
if profile.has_macro(name) {
return None;
}
if is_structural_macro(name) {
return None;
}
if let Some(entry) = lookup_overlay(command_overlay(profile.renderer()), name) {
return resolve_package(name, span, entry.package, profile, false);
}
if let Some(info) = mdwright_latex::lookup_command(name) {
if let Some(mask) = package_from_name(info.package()) {
let mask = normalise_mask_for_renderer(mask, profile.renderer());
return resolve_package(name, span, mask, profile, false);
}
return Some(RenderIssue::UnsupportedCommand {
name: name.to_owned(),
span,
});
}
Some(RenderIssue::UnsupportedCommand {
name: name.to_owned(),
span,
})
}
fn classify_environment(name: &str, span: SourceSpan, profile: &RenderProfile) -> Option<RenderIssue> {
if let Some(entry) = lookup_overlay(environment_overlay(profile.renderer()), name) {
return resolve_package(name, span, entry.package, profile, true);
}
Some(RenderIssue::UnsupportedEnvironment {
name: name.to_owned(),
span,
})
}
fn resolve_package(
name: &str,
span: SourceSpan,
mask: PackageMask,
profile: &RenderProfile,
is_environment: bool,
) -> Option<RenderIssue> {
if profile.has_package(mask) {
return None;
}
let package = package_name(mask);
Some(if is_environment {
RenderIssue::MissingPackageEnv {
name: name.to_owned(),
package,
span,
}
} else {
RenderIssue::MissingPackage {
name: name.to_owned(),
package,
span,
}
})
}
const fn normalise_mask_for_renderer(mask: PackageMask, renderer: Renderer) -> PackageMask {
match renderer {
Renderer::Katex | Renderer::MathJaxV3 => mask,
}
}
fn is_structural_macro(name: &str) -> bool {
matches!(
name,
"left"
| "right"
| "bigl"
| "bigr"
| "Bigl"
| "Bigr"
| "biggl"
| "biggr"
| "Biggl"
| "Biggr"
| "big"
| "Big"
| "bigg"
| "Bigg"
| "text"
| "textbf"
| "textit"
| "textrm"
| "textsf"
| "texttt"
| "textnormal"
| "mbox"
| "hbox"
)
}
fn is_math_only_command(name: &str) -> bool {
if let Some(info) = mdwright_latex::lookup_command(name) {
use mdwright_latex::CommandCategory;
return matches!(
info.category(),
CommandCategory::Greek
| CommandCategory::BinaryOperator
| CommandCategory::Relation
| CommandCategory::Arrow
| CommandCategory::LargeOperator
| CommandCategory::Accent
| CommandCategory::Delimiter
);
}
false
}
#[cfg(test)]
mod tests {
#![allow(
clippy::expect_used,
clippy::wildcard_enum_match_arm,
reason = "tests assert diagnostic shape against fixed inputs"
)]
use super::*;
fn issues(source: &str, profile: &RenderProfile) -> Vec<RenderIssue> {
check_math_body(source, profile)
}
#[test]
fn well_formed_math_produces_no_issues_under_mathjax() {
let profile = RenderProfile::mathjax_v3();
assert!(issues(r"\alpha + \beta = \gamma", &profile).is_empty());
assert!(issues(r"\frac{a}{b} + \sqrt{x}", &profile).is_empty());
}
#[test]
fn ams_commands_pass_under_mathjax_default() {
let profile = RenderProfile::mathjax_v3();
assert!(issues(r"\dfrac{a}{b}", &profile).is_empty());
assert!(issues(r"\mathbb{R}", &profile).is_empty());
}
#[test]
fn chemistry_command_requires_mhchem_under_mathjax() {
let profile = RenderProfile::mathjax_v3();
let found = issues(r"\ce{H2O}", &profile);
assert!(matches!(
found.as_slice(),
[RenderIssue::MissingPackage { name, package: "mhchem", .. }] if name == "ce"
));
}
#[test]
fn loading_mhchem_clears_chemistry_diagnostic_under_mathjax() {
let profile = RenderProfile::mathjax_v3().with_package("mhchem");
assert!(issues(r"\ce{H2O}", &profile).is_empty());
}
#[test]
fn physics_commands_require_physics_package_under_mathjax() {
let profile = RenderProfile::mathjax_v3();
let found = issues(r"\bra{\psi}\ket{\phi}", &profile);
let names: Vec<&str> = found
.iter()
.filter_map(|issue| match issue {
RenderIssue::MissingPackage {
name,
package: "physics",
..
} => Some(name.as_str()),
_ => None,
})
.collect();
assert_eq!(names, vec!["bra", "ket"]);
}
#[test]
fn definitely_unknown_command_is_unsupported() {
let profile = RenderProfile::mathjax_v3();
let found = issues(r"\nosuchcommandever", &profile);
assert!(matches!(
found.as_slice(),
[RenderIssue::UnsupportedCommand { name, .. }] if name == "nosuchcommandever"
));
}
#[test]
fn user_macro_silences_unsupported_command() {
let profile = RenderProfile::mathjax_v3().with_macro("RR", 0);
assert!(issues(r"\RR", &profile).is_empty());
}
#[test]
fn unknown_environment_is_unsupported() {
let profile = RenderProfile::mathjax_v3();
let found = issues(r"\begin{tikzpicture}x\end{tikzpicture}", &profile);
assert!(matches!(
found.as_slice(),
[RenderIssue::UnsupportedEnvironment { name, .. }] if name == "tikzpicture"
));
}
#[test]
fn amscd_environment_needs_package_under_mathjax() {
let profile = RenderProfile::mathjax_v3();
let found = issues(r"\begin{CD}A @>>> B\end{CD}", &profile);
assert!(matches!(
found.first(),
Some(RenderIssue::MissingPackageEnv {
name,
package: "amscd",
..
}) if name == "CD"
));
}
#[test]
fn math_command_inside_text_is_flagged() {
let profile = RenderProfile::mathjax_v3();
let found = issues(r"\text{the symbol \alpha here}", &profile);
assert!(matches!(
found.as_slice(),
[RenderIssue::MathCommandInTextMode { name, .. }] if name == "alpha"
));
}
#[test]
fn math_command_outside_text_is_not_flagged() {
let profile = RenderProfile::mathjax_v3();
assert!(issues(r"\alpha + \beta", &profile).is_empty());
}
#[test]
fn color_needs_color_package_under_mathjax() {
let profile = RenderProfile::mathjax_v3();
let found = issues(r"\color{red} x", &profile);
assert!(matches!(
found.first(),
Some(RenderIssue::MissingPackage {
name,
package: "color",
..
}) if name == "color"
));
}
#[test]
fn structural_left_right_are_silent() {
let profile = RenderProfile::mathjax_v3();
assert!(issues(r"\left( x \right)", &profile).is_empty());
}
#[test]
fn well_formed_math_produces_no_issues_under_katex() {
let profile = RenderProfile::katex();
assert!(issues(r"\alpha + \beta = \gamma", &profile).is_empty());
assert!(issues(r"\frac{a}{b} + \sqrt{x}", &profile).is_empty());
assert!(issues(r"\mathbb{R} \xrightarrow{f} \mathfrak{m}", &profile).is_empty());
}
#[test]
fn chemistry_command_requires_mhchem_under_katex() {
let profile = RenderProfile::katex();
let found = issues(r"\ce{H2O}", &profile);
assert!(matches!(
found.as_slice(),
[RenderIssue::MissingPackage { name, package: "mhchem", .. }] if name == "ce"
));
}
#[test]
fn loading_mhchem_clears_chemistry_diagnostic_under_katex() {
let profile = RenderProfile::katex().with_package("mhchem");
assert!(issues(r"\ce{H2O}", &profile).is_empty());
}
#[test]
fn tikz_environment_is_unsupported_under_katex() {
let profile = RenderProfile::katex();
let found = issues(r"\begin{tikzpicture}x\end{tikzpicture}", &profile);
assert!(matches!(
found.as_slice(),
[RenderIssue::UnsupportedEnvironment { name, .. }] if name == "tikzpicture"
));
}
#[test]
fn profile_records_renderer_choice() {
assert_eq!(RenderProfile::mathjax_v3().renderer(), Renderer::MathJaxV3);
assert_eq!(RenderProfile::katex().renderer(), Renderer::Katex);
}
}