Skip to main content

ratex_parser/mhchem/
mod.rs

1//! mhchem (`\ce`, `\pu`): pure Rust port of KaTeX mhchem 3.3.0.
2//!
3//! Data (`machines.json`, `patterns_regex.json`) is generated from `tools/mhchem_reference.js`;
4//! update workflow: `docs/MHCHEM_DATA.md`.
5
6mod actions;
7mod buffer;
8mod data;
9mod engine;
10mod error;
11mod json;
12mod patterns;
13mod texify;
14
15pub use data::data;
16pub use error::{MhchemError, MhchemResult};
17
18use crate::mhchem::data::MhchemData;
19use crate::stack_safety::{DepthBudget, MAX_INPUT_DEPTH};
20use serde_json::Value;
21
22/// Context for recursive `go`.
23///
24/// ```
25/// let ctx = ratex_parser::mhchem::ParserCtx {
26///     data: ratex_parser::mhchem::data(),
27/// };
28/// let ast = ctx.go("H2O", "ce").expect("mhchem");
29/// assert!(!ast.is_empty());
30/// ```
31pub struct ParserCtx<'a> {
32    pub data: &'a MhchemData,
33}
34
35impl ParserCtx<'_> {
36    pub fn go(&self, input: &str, machine: &str) -> MhchemResult<Vec<Value>> {
37        RuntimeCtx {
38            data: self.data,
39            depth_budget: DepthBudget::new(MAX_INPUT_DEPTH),
40        }
41        .go(input, machine)
42    }
43}
44
45/// Internal mhchem context that shares the parser's depth budget across
46/// recursive state-machine calls.
47pub(crate) struct RuntimeCtx<'a> {
48    pub(crate) data: &'a MhchemData,
49    pub depth_budget: DepthBudget,
50}
51
52impl RuntimeCtx<'_> {
53    pub(crate) fn go(&self, input: &str, machine: &str) -> MhchemResult<Vec<Value>> {
54        engine::go_machine(self, input, machine)
55    }
56}
57
58/// Parse `\ce` / `\pu` argument to TeX fragment (wrap `\mathrm` etc. is done here).
59pub fn chem_parse_str(input: &str, mode: &str) -> MhchemResult<String> {
60    chem_parse_str_with_budget(input, mode, DepthBudget::new(MAX_INPUT_DEPTH))
61}
62
63pub(crate) fn chem_parse_str_with_budget(
64    input: &str,
65    mode: &str,
66    depth_budget: DepthBudget,
67) -> MhchemResult<String> {
68    let d = data();
69    let ctx = RuntimeCtx {
70        data: d,
71        depth_budget: depth_budget.clone(),
72    };
73    let sm = match mode {
74        "ce" => "ce",
75        "pu" => "pu",
76        _ => {
77            return Err(MhchemError::msg(format!(
78                "unknown mhchem mode (expected ce|pu): {mode}"
79            )));
80        }
81    };
82    let ast = ctx.go(input.trim(), sm)?;
83    texify::go_with_budget(&ast, false, &depth_budget)
84}
85
86/// Rebuild a macro argument string from tokens ([KaTeX `chemParse`]).
87pub fn mhchem_arg_tokens_to_string(tokens: &[ratex_lexer::token::Token]) -> String {
88    if tokens.is_empty() {
89        return String::new();
90    }
91    let mut expected_loc = tokens.last().unwrap().loc.start;
92    let mut out = String::new();
93    for i in (0..tokens.len()).rev() {
94        let t = &tokens[i];
95        if t.loc.start > expected_loc {
96            out.push(' ');
97            expected_loc = t.loc.start;
98        }
99        out.push_str(&t.text);
100        expected_loc += t.text.len();
101    }
102    out
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn h2o_ce() {
111        let t = chem_parse_str("H2O", "ce").expect("mhchem");
112        assert!(!t.is_empty());
113        assert!(t.contains('H'));
114    }
115
116    #[test]
117    fn reaction_arrow() {
118        let t = chem_parse_str("2H + O -> H2O", "ce").expect("mhchem");
119        assert!(t.contains("rightarrow") || t.contains("->"), "{}", t);
120    }
121
122    #[test]
123    fn pu_simple() {
124        let t = chem_parse_str("123 kJ/mol", "pu").expect("mhchem");
125        assert!(!t.is_empty());
126    }
127
128    #[test]
129    fn nested_submachines_have_a_depth_budget() {
130        let nested_ce = |depth: usize| format!("{}H{}", r"\ce{".repeat(depth), "}".repeat(depth));
131
132        // mhchem also enters helper machines while processing terminal atoms,
133        // so visible \ce nesting consumes slightly less than the full engine budget.
134        assert!(chem_parse_str(&nested_ce(30), "ce").is_ok());
135        let error = chem_parse_str(&nested_ce(31), "ce").unwrap_err();
136        assert!(
137            error.to_string().contains("Recursion limit exceeded"),
138            "unexpected error: {error}"
139        );
140    }
141
142    #[test]
143    fn nested_submachine_engine_depth_is_bounded_before_texify() {
144        let nested_ce = |depth: usize| format!("{}H{}", r"\ce{".repeat(depth), "}".repeat(depth));
145        let nested_empty_ce =
146            |depth: usize| format!("{}{}", r"\ce{".repeat(depth), "}".repeat(depth));
147        let d = data();
148        let ctx = RuntimeCtx {
149            data: d,
150            depth_budget: DepthBudget::new(MAX_INPUT_DEPTH),
151        };
152
153        assert!(ctx.go(&nested_ce(30), "ce").is_ok());
154        let error = ctx.go(&nested_ce(31), "ce").unwrap_err();
155        assert!(
156            error.to_string().contains("Recursion limit exceeded"),
157            "unexpected error: {error}"
158        );
159
160        assert!(ctx.go(&nested_empty_ce(31), "ce").is_ok());
161        let error = ctx.go(&nested_empty_ce(32), "ce").unwrap_err();
162        assert!(
163            error.to_string().contains("Recursion limit exceeded"),
164            "unexpected error: {error}"
165        );
166    }
167
168    #[test]
169    fn pu_scientific_lowercase_e_cdot_uppercase_e_times() {
170        for src in ["1.2e3 kJ", "1,2e3 kJ"] {
171            let t = chem_parse_str(src, "pu").expect("mhchem");
172            assert!(
173                t.contains("\\cdot") && t.contains("10^{3}") && !t.contains("\\times"),
174                "expected \\cdot for lowercase e: {src:?} → {t:?}"
175            );
176        }
177        for src in ["1.2E3 kJ", "1,2E3 kJ"] {
178            let t = chem_parse_str(src, "pu").expect("mhchem");
179            assert!(
180                t.contains("\\times") && t.contains("10^{3}") && !t.contains("\\cdot"),
181                "expected \\times for uppercase E: {src:?} → {t:?}"
182            );
183        }
184    }
185
186    #[test]
187    fn dollar_underset_inner_ce_tex_is_valid_latex() {
188        let inner = r"$\underset{\mathrm{red}}{\ce{HgI2}}$";
189        let tex = chem_parse_str(inner, "ce").expect("mhchem");
190        crate::parser::parse(&tex).expect("mhchem TeX should parse");
191    }
192}