Skip to main content

mdwright_lint/stdlib/
latex_command.rs

1//! `\foo` LaTeX control sequence in prose.
2//!
3//! Opinionated rule: this project's convention is Unicode
4//! mathematics in prose; LaTeX commands like `\cdot` do not render.
5//! For commands with a known Unicode equivalent the rule offers a
6//! safe autofix; commands taking an argument (`\bar{x}`) are flagged
7//! without a fix. Default-off because other projects render LaTeX.
8
9use std::sync::OnceLock;
10
11use regex::Regex;
12
13use crate::diagnostic::{Diagnostic, Fix};
14use crate::regex_util::compile_static;
15use crate::rule::LintRule;
16use mdwright_document::Document;
17use mdwright_latex::latex_symbol;
18
19pub struct LatexCommand;
20
21fn pattern() -> &'static Regex {
22    static RE: OnceLock<Regex> = OnceLock::new();
23    RE.get_or_init(|| compile_static(r"\\\\?([A-Za-z]+)(?:\{[^}]*\})?"))
24}
25
26impl LintRule for LatexCommand {
27    fn name(&self) -> &str {
28        "latex-command"
29    }
30
31    fn description(&self) -> &str {
32        "LaTeX control sequence in prose (opt-in for Unicode-math projects)."
33    }
34
35    fn explain(&self) -> &str {
36        include_str!("explain/latex_command.md")
37    }
38
39    fn produces_fix(&self) -> bool {
40        true
41    }
42
43    fn is_default(&self) -> bool {
44        false
45    }
46
47    fn check(&self, doc: &Document, out: &mut Vec<Diagnostic>) {
48        let math = doc.math_regions();
49        for chunk in doc.prose_chunks() {
50            for cap in pattern().captures_iter(&chunk.text) {
51                let Some(m) = cap.get(0) else { continue };
52                let Some(name_match) = cap.get(1) else {
53                    continue;
54                };
55                let abs_start = chunk.byte_offset.saturating_add(m.start());
56                let abs_end = chunk.byte_offset.saturating_add(m.end());
57                // Skip LaTeX commands inside math regions: `\alpha`
58                // inside `\[ … \]` is intentional, not a Unicode-math
59                // convention violation. The rule applies only to
60                // prose context.
61                if math
62                    .iter()
63                    .any(|r| r.range.start <= abs_start && abs_end <= r.range.end)
64                {
65                    continue;
66                }
67                let name = name_match.as_str();
68                let fix = latex_symbol(name).map(|u| Fix {
69                    replacement: u.to_owned(),
70                    safe: true,
71                });
72                let message = format!(
73                    "LaTeX command `\\{name}` in prose — replace with Unicode math; \
74                     this project does not render LaTeX"
75                );
76                if let Some(d) = Diagnostic::at(doc, chunk.byte_offset, m.range(), message, fix) {
77                    out.push(d);
78                }
79            }
80        }
81    }
82}