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