Skip to main content

latexml_core/common/
def_parser.rs

1use once_cell::sync::Lazy;
2use regex::Regex;
3
4use crate::{
5  common::{
6    arena::{self},
7    error::*,
8  },
9  mouth,
10  parameter::{Parameter, Parameters},
11  pin,
12  token::*,
13  tokens::{TeXString, Tokens},
14};
15
16static CSNAME_MACRO_RE: Lazy<Regex> =
17  Lazy::new(|| Regex::new(r"^\\csname\s+(.*)\\endcsname").unwrap());
18// Includes `_` (expl3 private/word-internal) and an optional `:<letters>`
19// suffix (expl3 parameter-type sigil) so prototype strings like
20// "\\draw_path_arc:nnn{}{}{}" parse with the entire `\draw_path_arc:nnn`
21// as the control-sequence name. Under normal LaTeX catcodes `_` is SUB
22// and `:` is OTHER, so these names only round-trip through the
23// tokenizer under expl3 catcode regime — but compile-time prototype
24// strings bypass the tokenizer, so this is purely a string-parsing
25// concern. Witness: l3draw_sty stubs would previously fail with
26// "Unrecognized parameter type with name '_begin', spec '_begin:'".
27static CS_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(\\[a-zA-Z@_]+(?::[a-zA-Z]*)?)").unwrap());
28static SINGLE_CHAR_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(\\.)").unwrap());
29static ACTIVE_CHAR_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^(.)").unwrap());
30
31/// If calling at compile-time, pass `None` for state, to avoid initialization.
32pub fn parse_prototype(proto: &str, init_flag: bool) -> Result<(Token, Option<Parameters>)> {
33  let cs;
34  let normalized_proto = if let Some(captures) = CSNAME_MACRO_RE.captures(proto) {
35    let csname_content = captures.get(1).map_or("", |m| m.as_str());
36    // At compile time, reject \csname patterns with braces — these produce CS names
37    // like \begin{env} that can cause infinite loops in the proc macro expansion.
38    // Starred environments (\csname eqnarray*\endcsname) are fine.
39    if !init_flag && (csname_content.contains('{') || csname_content.contains('}')) {
40      panic!(
41        "\\csname...\\endcsname with braces in definition prototype is not supported at compile time: \"{}\". \
42         Use RawTeX!() or runtime DefMacroI() for CS names containing {{}} characters.",
43        proto
44      );
45    }
46    cs = T_CS!(s!("\\{}", csname_content));
47    // also replace in proto
48    CSNAME_MACRO_RE.replace(proto, "")
49  } else if let Some(captures) = CS_RE.captures(proto) {
50    // Match a cs
51    let csname = captures.get(1).map_or("", |m| m.as_str()).to_string();
52    cs = T_CS!(csname);
53    // also replace in proto
54    CS_RE.replace(proto, "")
55  } else if let Some(captures) = SINGLE_CHAR_RE.captures(proto) {
56    // Match a single char cs, env name,...
57    cs = T_CS!(captures.get(1).map_or("", |m| m.as_str()));
58    // also replace in proto
59    SINGLE_CHAR_RE.replace(proto, "")
60  } else if let Some(captures) = ACTIVE_CHAR_RE.captures(proto) {
61    // Match an active char
62    cs = mouth::tokenize_internal(TeXString::assembled(
63      captures.get(1).map_or("", |m| m.as_str()).to_string(),
64    ))
65    .unlist()
66    .remove(0);
67    // also replace in proto
68    ACTIVE_CHAR_RE.replace(proto, "")
69  } else {
70    let message = s!(
71      "Definition prototype doesn't have proper control sequence: \"{}\"",
72      proto
73    );
74    fatal!(Prototype, Misdefined, message);
75  };
76  let final_proto = normalized_proto.trim();
77  let paramlist = parse_parameters(final_proto, &cs, init_flag)?;
78  Ok((cs, paramlist))
79}
80
81/// If calling at compile-time, pass `None` for state, to avoid initialization.
82pub fn parse_parameters(
83  outer_prototype: &str,
84  cs: &Token,
85  init_flag: bool,
86) -> Result<Option<Parameters>> {
87  // The prototype grammar, as a winnow parse (#171 family; the golden corpus
88  // in `golden_tests` pins byte-equivalence with the prior regex munch loop):
89  //   item := "{" inner "}" \s*          (Plain, recursive inner)
90  //         | "[" inner "]" \s*          (Optional; inner may be Default:…)
91  //         | word (":" extra)? \s*      (named type, extra |-split)   [nonempty]
92  //         | any-single-char            (literal Token; spaces too — each
93  //                                       space is its own Token, faithfully)
94  use winnow::{
95    combinator::{delimited, opt, preceded},
96    prelude::*,
97    token::{any, take_while},
98  };
99
100  fn ws0(input: &mut &str) -> ModalResult<()> {
101    take_while(0.., |c: char| c.is_whitespace())
102      .void()
103      .parse_next(input)
104  }
105  /// `{inner}` / `[inner]` group: returns the inner slice; trailing \s* eaten.
106  fn group<'a>(open: char, close: char) -> impl FnMut(&mut &'a str) -> ModalResult<&'a str> {
107    move |input: &mut &'a str| {
108      let inner = delimited(open, take_while(0.., move |c| c != close), close).parse_next(input)?;
109      ws0(input)?;
110      Ok(inner)
111    }
112  }
113  /// `word(:extra)?` with word ∈ \w+ or a bare nonempty `:extra`; \s* eaten.
114  fn paramspec<'a>(input: &mut &'a str) -> ModalResult<(&'a str, Option<&'a str>)> {
115    let word = take_while(0.., |c: char| c.is_alphanumeric() || c == '_').parse_next(input)?;
116    let extra = opt(preceded(
117      ':',
118      take_while(0.., |c: char| !c.is_whitespace() && c != '{' && c != '['),
119    ))
120    .parse_next(input)?;
121    if word.is_empty() && extra.is_none() {
122      return Err(winnow::error::ErrMode::Backtrack(
123        winnow::error::ContextError::new(),
124      ));
125    }
126    ws0(input)?;
127    Ok((word, extra))
128  }
129
130  let mut parameters = Vec::with_capacity(4);
131  let mut rest: &str = outer_prototype;
132  let input = &mut rest;
133  while !input.is_empty() {
134    // Probe-and-commit on a copy per branch: a failing branch must not consume
135    // (`delimited` advances past its opening token before failing otherwise —
136    // e.g. a lone "{" from the non-nesting inner of "{{}}").
137    let mut probe;
138    let res = {
139      probe = *input;
140      group('{', '}')
141        .parse_next(&mut probe)
142        .inspect(|_| *input = probe)
143    };
144    let mut p: Parameter = match res {
145      Ok(inner_spec) => {
146        // Plain (possibly typed-inner) braced group, spec keeps its braces.
147        let inner: Option<Parameters> = if inner_spec.is_empty() {
148          None
149        } else {
150          parse_parameters(inner_spec, cs, init_flag)?
151        };
152        Parameter {
153          name: pin!("Plain"),
154          spec: arena::pin(format!("{{{inner_spec}}}")),
155          inner: inner.map(|ps| ps.into()).unwrap_or_default(),
156          ..Parameter::default()
157        }
158      },
159      _ => {
160        let res = {
161          probe = *input;
162          group('[', ']')
163            .parse_next(&mut probe)
164            .inspect(|_| *input = probe)
165        };
166        match res {
167          Ok(inner_spec) => {
168            let spec = arena::pin(format!("[{inner_spec}]"));
169            if let Some(default_str) = inner_spec.strip_prefix("Default:") {
170              let extra = if default_str.is_empty() {
171                vec![]
172              } else {
173                vec![mouth::tokenize_internal(TeXString::assembled(
174                  default_str.to_string(),
175                ))]
176              };
177              Parameter {
178                name: pin!("Optional"),
179                spec,
180                extra,
181                ..Parameter::default()
182              }
183            } else if !inner_spec.is_empty() {
184              Parameter {
185                name: pin!("Optional"),
186                spec,
187                inner: parse_parameters(inner_spec, cs, init_flag)?
188                  .map(|ps| ps.into())
189                  .unwrap_or_default(),
190                ..Parameter::default()
191              }
192            } else {
193              Parameter {
194                name: pin!("Optional"),
195                spec,
196                ..Parameter::default()
197              }
198            }
199          },
200          _ => {
201            let res = {
202              probe = *input;
203              paramspec.parse_next(&mut probe).inspect(|_| *input = probe)
204            };
205            match res {
206              Ok((word, extra_opt)) => {
207                let spec_str = match extra_opt {
208                  Some(extra) => format!("{word}:{extra}"),
209                  None => word.to_string(),
210                };
211                let extra: Vec<Tokens> = match extra_opt {
212                  None | Some("") => Vec::new(),
213                  Some(extra_str) => extra_str
214                    .split('|')
215                    .map(|t| {
216                      Tokens::new(
217                        mouth::tokenize_internal(TeXString::assembled(t.to_string())).unlist(),
218                      )
219                    })
220                    .collect(),
221                };
222                Parameter {
223                  name: arena::pin(word),
224                  spec: arena::pin(&spec_str),
225                  extra,
226                  ..Parameter::default()
227                }
228              },
229              _ => {
230                // Literal single char (incl. each whitespace char) as a Token parameter.
231                let ch = any.parse_next(input).map_err(
232                  |_: winnow::error::ErrMode<winnow::error::ContextError>| {
233                    Error::from(s!(
234                      "parse_parameters: unreadable prototype tail for {:?}",
235                      cs
236                    ))
237                  },
238                )?;
239                let ch_token = CharToken!(ch, Catcode::OTHER);
240                Parameter {
241                  name: pin!("Token"),
242                  spec: pin!("Token"),
243                  extra: vec![Tokens::new(vec![ch_token])],
244                  ..Parameter::default()
245                }
246              },
247            }
248          },
249        }
250      },
251    };
252    if init_flag {
253      p = p.init()?;
254    }
255    parameters.push(p);
256  }
257  if parameters.is_empty() {
258    Ok(None)
259  } else {
260    Ok(Some(Parameters::new(parameters)))
261  }
262}
263
264#[cfg(test)]
265mod golden_tests {
266  /// Render a parse result in a stable, comparison-friendly form.
267  fn describe(proto: &str) -> String {
268    match super::parse_parameters(proto, &crate::T_CS!("\\x"), false) {
269      Ok(None) => "None".to_string(),
270      Ok(Some(ps)) => ps
271        .get_parameters()
272        .iter()
273        .map(|p| {
274          format!(
275            "{}:{}/x{}/i{}",
276            crate::common::arena::with(p.name, |s| s.to_string()),
277            crate::common::arena::with(p.spec, |s| s.to_string()),
278            p.extra.len(),
279            p.inner
280              .as_ref()
281              .map(|ps| ps.get_parameters().len())
282              .unwrap_or(0)
283          )
284        })
285        .collect::<Vec<_>>()
286        .join(" | "),
287      Err(e) => format!("ERR:{e}"),
288    }
289  }
290
291  /// Golden corpus pinning the prototype grammar's behavior (captured from
292  /// the regex implementation 2026-06-10) — the gate for the winnow rewrite:
293  /// any divergence here is a semantics change, not a refactor.
294  #[test]
295  fn golden_prototype_corpus() {
296    crate::state::set_state(crate::state::State::new(
297      crate::state::StateOptions::default(),
298    ));
299    let golden: &[(&str, &str)] = &[
300      ("{}", "Plain:{}/x0/i0"),
301      ("{}{}", "Plain:{}/x0/i0 | Plain:{}/x0/i0"),
302      ("[]", "Optional:[]/x0/i0"),
303      ("[]{}", "Optional:[]/x0/i0 | Plain:{}/x0/i0"),
304      ("{Number}", "Plain:{Number}/x0/i1"),
305      (
306        "{Float}{Float} {}",
307        "Plain:{Float}/x0/i1 | Plain:{Float}/x0/i1 | Plain:{}/x0/i0",
308      ),
309      (
310        "OptionalMatch:* [][] Semiverbatim",
311        "OptionalMatch:OptionalMatch:*/x1/i0 | Optional:[]/x0/i0 | Optional:[]/x0/i0 | \
312         Semiverbatim:Semiverbatim/x0/i0",
313      ),
314      (
315        "OptionalKeyVals:LST",
316        "OptionalKeyVals:OptionalKeyVals:LST/x1/i0",
317      ),
318      (
319        "RequiredKeyVals:RH {}",
320        "RequiredKeyVals:RequiredKeyVals:RH/x1/i0 | Plain:{}/x0/i0",
321      ),
322      ("Until:\\end", "Until:Until:\\end/x1/i0"),
323      (
324        "XUntil:\\fi {}",
325        "XUntil:XUntil:\\fi/x1/i0 | Plain:{}/x0/i0",
326      ),
327      (
328        "[Default:0]{}",
329        "Optional:[Default:0]/x1/i0 | Plain:{}/x0/i0",
330      ),
331      ("Semiverbatim", "Semiverbatim:Semiverbatim/x0/i0"),
332      (
333        "SkipSpaces {}",
334        "SkipSpaces:SkipSpaces/x0/i0 | Plain:{}/x0/i0",
335      ),
336      ("Digested", "Digested:Digested/x0/i0"),
337      ("DigestedBody", "DigestedBody:DigestedBody/x0/i0"),
338      (
339        "(){}",
340        "Token:Token/x1/i0 | Token:Token/x1/i0 | Plain:{}/x0/i0",
341      ),
342      (
343        "( {Float} , {Float} )",
344        "Token:Token/x1/i0 | Token:Token/x1/i0 | Plain:{Float}/x0/i1 | Token:Token/x1/i0 | \
345         Token:Token/x1/i0 | Plain:{Float}/x0/i1 | Token:Token/x1/i0",
346      ),
347      ("Optional:=Default:9", "Optional:Optional:=Default:9/x1/i0"),
348      ("{Until:;}", "Plain:{Until:;}/x0/i1"),
349      ("+", "Token:Token/x1/i0"),
350      ("Match:- {}", "Match:Match:-/x1/i0 | Plain:{}/x0/i0"),
351      // pst_all_sty shapes: non-nesting braced inner ("{{}}" -> Plain with a
352      // lone "{" inner Token, then a dangling "}" Token) — regex-faithful.
353      (
354        "OptionalMatch:* {{}} [] {}",
355        "OptionalMatch:OptionalMatch:*/x1/i0 | Plain:{{}/x0/i1 | Token:Token/x1/i0 | Token:Token/x1/i0 | \
356         Optional:[]/x0/i0 | Plain:{}/x0/i0",
357      ),
358      ("{", "Token:Token/x1/i0"),
359    ];
360    for (proto, expected) in golden {
361      let expected = expected.split_whitespace().collect::<Vec<_>>().join(" ");
362      let actual = describe(proto)
363        .split_whitespace()
364        .collect::<Vec<_>>()
365        .join(" ");
366      assert_eq!(actual, expected, "prototype grammar diverged on {proto:?}");
367    }
368  }
369}