Skip to main content

fmd_math/
macros.rs

1//! User macros and preamble packs: `\newcommand`-tier non-recursive
2//! substitution, expanded at the token level before parsing (§11.4).
3//!
4//! # The model
5//!
6//! A [`MacroSet`] is a named table of substitution macros — either a
7//! **preamble pack** (the `tex_templates.yml` concept reborn: a macro/symbol
8//! bundle selected by config, looked up here by its stable content id) or
9//! definitions a consumer assembles. Source strings may additionally define
10//! macros inline with `\newcommand{\name}[n]{body}` / `\renewcommand`;
11//! inline definitions layer over the pack (`\newcommand` refuses to shadow
12//! an existing name, `\renewcommand` requires one — LaTeX's own rules).
13//! An inline definition may supply `[n][default]`: the first argument is
14//! then optional at each call, and the remaining `n - 1` are mandatory.
15//! Explicit `[]` is an empty argument, not a request to use the default.
16//!
17//! Expansion is **token-level**, before the grammar: a macro call's
18//! arguments are collected as balanced token groups (or single tokens, the
19//! TeX undelimited-argument rule), the body's `#k` parameters splice the
20//! argument tokens in, and the result is rescanned so macros may reference
21//! other macros. Two disciplines make this safe under the parser-budget
22//! doctrine (§16.5):
23//!
24//! - **Recursion is refused, by name**: a macro that re-enters its own
25//!   expansion — directly, through another macro, or through an argument —
26//!   is a precise [`MathError::Malformed`] naming the macro, never a hang.
27//! - **Expansion is budgeted**: token-copy and substitution work, including
28//!   intermediate replacements that disappear during rescanning, and nesting
29//!   depth are capped. Visible and empty-output fan-out bombs error cleanly.
30//!
31//! # Provenance (§11.3)
32//!
33//! Body-produced tokens carry the **call site's span** (the expansion
34//! site — exactly the rule command-produced glyphs already follow), while
35//! argument tokens keep their own source spans (they are real source
36//! text). Omitted optional arguments come from the definition and are
37//! rebased to the call, just like body material. `isolate` and
38//! `tex_to_color_map` therefore keep working through macros.
39//!
40//! # Cache identity
41//!
42//! [`MacroSet::canonical_bytes`] is a deterministic serialization of the
43//! whole table (sorted, delimited, versioned). Consumers fold it into
44//! their typeset cache keys, so **a pack change re-typesets, correctly** —
45//! the §14.4 requirement. Inline defaults are part of the source string;
46//! no optional-argument state persists between parse calls.
47
48use crate::error::MathError;
49use crate::node::Span;
50use crate::token::{Tok, TokKind, lex};
51use std::collections::BTreeMap;
52
53/// Total token-copy and substitution work an expansion may perform.
54const EXPANSION_TOKEN_BUDGET: usize = 65_536;
55/// Nesting depth of macro-within-macro expansion.
56const EXPANSION_DEPTH_BUDGET: usize = 32;
57
58/// A named table of `\newcommand`-tier substitution macros. See the module
59/// docs for the expansion, provenance, and budget rules.
60#[derive(Clone, Debug, Default, PartialEq, Eq)]
61pub struct MacroSet {
62    defs: BTreeMap<String, MacroDef>,
63}
64
65#[derive(Clone, Debug, PartialEq, Eq)]
66struct MacroDef {
67    /// Parameter count, 0..=9.
68    params: u8,
69    /// The body, TeX source.
70    body: String,
71}
72
73impl MacroSet {
74    /// An empty set.
75    #[must_use]
76    pub fn new() -> Self {
77        Self::default()
78    }
79
80    /// The builtin preamble packs, by stable content id (the ids the
81    /// fmn-config registry records): `fmd-math/pack/default` (the everyday
82    /// bundle), `fmd-math/pack/basic` (minimal), `fmd-math/pack/empty`
83    /// (bare primitives). Plain names (`default`, `basic`, `empty`) are
84    /// accepted too.
85    #[must_use]
86    pub fn pack(id: &str) -> Option<Self> {
87        match id {
88            "fmd-math/pack/default" | "default" => {
89                // The Reference's default template declares `\minus`, a
90                // binary-minus shorthand (its one real macro; the rest of
91                // its preamble is package loading with no native meaning).
92                // Built directly — the definition is static and trivially
93                // valid (the tests define the same macro through the
94                // validating path).
95                let mut defs = BTreeMap::new();
96                defs.insert(
97                    "minus".to_owned(),
98                    MacroDef {
99                        params: 0,
100                        body: "-".to_owned(),
101                    },
102                );
103                Some(Self { defs })
104            }
105            "fmd-math/pack/basic" | "basic" | "fmd-math/pack/empty" | "empty" => Some(Self::new()),
106            _ => None,
107        }
108    }
109
110    /// Define a macro: `params` parameters (`#1`…`#9`), a TeX-source body.
111    /// Replaces any existing definition of the name (packs are assembled
112    /// with this; *source-level* shadowing rules are `\newcommand`'s).
113    ///
114    /// # Errors
115    ///
116    /// [`MathError::Malformed`] (at byte 0 of the definition body) for an
117    /// invalid name, too many parameters, an unbalanced body, or a `#k`
118    /// outside `1..=params`.
119    pub fn define(&mut self, name: &str, params: u8, body: &str) -> Result<(), MathError> {
120        let malformed = |what: String| MathError::Malformed { what, at: 0 };
121        if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphabetic()) {
122            return Err(malformed(format!(
123                "macro name {name:?} must be one or more ASCII letters"
124            )));
125        }
126        if params > 9 {
127            return Err(malformed(format!(
128                "macro \\{name} declares {params} parameters; TeX allows at most 9"
129            )));
130        }
131        validate_body(name, params, body)?;
132        self.defs.insert(
133            name.to_owned(),
134            MacroDef {
135                params,
136                body: body.to_owned(),
137            },
138        );
139        Ok(())
140    }
141
142    /// The defined names, sorted.
143    pub fn names(&self) -> impl Iterator<Item = &str> {
144        self.defs.keys().map(String::as_str)
145    }
146
147    /// How many macros are defined.
148    #[must_use]
149    pub fn len(&self) -> usize {
150        self.defs.len()
151    }
152
153    /// Whether the set is empty.
154    #[must_use]
155    pub fn is_empty(&self) -> bool {
156        self.defs.is_empty()
157    }
158
159    /// A deterministic serialization of the whole table — the cache-key
160    /// ingredient (hash these bytes; equal bytes ⇔ equal macro semantics).
161    /// Format: a version tag, then `name US params US body RS` per macro in
162    /// sorted order (US/RS are the ASCII unit/record separators, which
163    /// cannot appear in names and are vanishingly unlikely in bodies; the
164    /// version tag changes if this framing ever does).
165    #[must_use]
166    pub fn canonical_bytes(&self) -> Vec<u8> {
167        let mut out = b"fmd-math-macroset-v1\x1e".to_vec();
168        for (name, def) in &self.defs {
169            out.extend_from_slice(name.as_bytes());
170            out.push(0x1f);
171            out.push(b'0' + def.params);
172            out.push(0x1f);
173            out.extend_from_slice(def.body.as_bytes());
174            out.push(0x1e);
175        }
176        out
177    }
178}
179
180/// Validate a macro body at definition time: balanced groups and in-range
181/// `#k` references, so use-site errors can only be about *use*.
182fn validate_body(name: &str, params: u8, body: &str) -> Result<(), MathError> {
183    let malformed = |what: String| MathError::Malformed { what, at: 0 };
184    let mut depth = 0_i32;
185    let toks = lex(body);
186    let mut i = 0;
187    while i < toks.len() {
188        match toks[i].kind {
189            TokKind::BeginGroup => depth += 1,
190            TokKind::EndGroup => {
191                depth -= 1;
192                if depth < 0 {
193                    return Err(malformed(format!(
194                        "macro \\{name} body has an unmatched '}}'"
195                    )));
196                }
197            }
198            TokKind::Char('#') => {
199                let param = toks.get(i + 1).and_then(|t| match t.kind {
200                    TokKind::Char(c) => c.to_digit(10),
201                    _ => None,
202                });
203                match param {
204                    Some(d) if (1..=u32::from(params)).contains(&d) => i += 1,
205                    Some(d) => {
206                        return Err(malformed(format!(
207                            "macro \\{name} body uses #{d} but declares {params} parameter(s)"
208                        )));
209                    }
210                    None => {
211                        return Err(malformed(format!(
212                            "macro \\{name} body has a '#' not followed by a parameter digit"
213                        )));
214                    }
215                }
216            }
217            _ => {}
218        }
219        i += 1;
220    }
221    if depth != 0 {
222        return Err(malformed(format!(
223            "macro \\{name} body has {depth} unclosed '{{'"
224        )));
225    }
226    Ok(())
227}
228
229/// One live macro during expansion: the definition's body, pre-lexed.
230struct Live<'a> {
231    params: u8,
232    body: Vec<Tok<'a>>,
233    /// Some(empty) still means argument #1 is optional. Declaration spans
234    /// are replaced by the invocation span when the default is actually used.
235    default: Option<Vec<Tok<'a>>>,
236}
237
238/// Read an optional bracket argument without treating a protected `]` as a
239/// terminator. Braces protect their entire contents; escaped bracket tokens
240/// are literals. A closing brace may not be stolen from the surrounding group.
241fn optional_group(
242    toks: &[Tok<'_>],
243    open: usize,
244    src_len: usize,
245    name: &str,
246) -> Result<(usize, usize), MathError> {
247    let start = open + 1;
248    let mut groups = 0usize;
249    for (index, token) in toks.iter().enumerate().skip(start) {
250        match token.kind {
251            TokKind::BeginGroup => groups += 1,
252            TokKind::EndGroup if groups == 0 => {
253                return Err(MathError::Malformed {
254                    what: format!("\\{name}: optional argument closes a surrounding group"),
255                    at: token.span.start,
256                });
257            }
258            TokKind::EndGroup => groups -= 1,
259            TokKind::Char(']') if groups == 0 => return Ok((start, index)),
260            _ => {}
261        }
262    }
263    Err(MathError::Malformed {
264        what: format!("\\{name}: unclosed optional argument (expected ']')"),
265        at: src_len,
266    })
267}
268
269fn skip_spaces(toks: &[Tok<'_>], index: &mut usize) {
270    while toks
271        .get(*index)
272        .is_some_and(|token| matches!(token.kind, TokKind::Space))
273    {
274        *index += 1;
275    }
276}
277
278/// Expand a lexed token stream against a macro set, processing inline
279/// `\newcommand`/`\renewcommand` definitions. Returns the expanded stream;
280/// tokens spliced from macro bodies carry their call site's span.
281pub(crate) fn expand<'a>(
282    toks: Vec<Tok<'a>>,
283    set: &'a MacroSet,
284    src_len: usize,
285) -> Result<Vec<Tok<'a>>, MathError> {
286    // Fast path: nothing to expand and nothing to define.
287    let involved = !set.is_empty()
288        || toks
289            .iter()
290            .any(|t| matches!(t.kind, TokKind::ControlWord("newcommand" | "renewcommand")));
291    if !involved {
292        return Ok(toks);
293    }
294
295    let mut table: BTreeMap<&'a str, Live<'a>> = BTreeMap::new();
296    for (name, def) in &set.defs {
297        table.insert(
298            name.as_str(),
299            Live {
300                params: def.params,
301                body: lex(&def.body),
302                default: None,
303            },
304        );
305    }
306
307    let mut cx = Expansion {
308        table,
309        budget: EXPANSION_TOKEN_BUDGET,
310        src_len,
311    };
312    let mut out = Vec::with_capacity(toks.len());
313    let mut i = 0;
314    while i < toks.len() {
315        let tok = &toks[i];
316        match tok.kind {
317            TokKind::ControlWord(cw @ ("newcommand" | "renewcommand")) => {
318                i = cx.definition(&toks, i, cw == "renewcommand")?;
319            }
320            TokKind::ControlWord(name) if cx.table.contains_key(name) => {
321                let mut active = Vec::new();
322                i = cx.call(&toks, i, name, &mut active, 0, &mut out)?;
323            }
324            _ => {
325                out.push(tok.clone());
326                i += 1;
327            }
328        }
329    }
330    Ok(out)
331}
332
333struct Expansion<'a> {
334    table: BTreeMap<&'a str, Live<'a>>,
335    budget: usize,
336    src_len: usize,
337}
338
339impl<'a> Expansion<'a> {
340    /// Parse an inline definition, register it, and return its end index.
341    fn definition(&mut self, toks: &[Tok<'a>], i: usize, renew: bool) -> Result<usize, MathError> {
342        let cw_span = toks[i].span;
343        let which = if renew {
344            "\\renewcommand"
345        } else {
346            "\\newcommand"
347        };
348        let mut j = i + 1;
349        skip_spaces(toks, &mut j);
350        // The name: `{\name}` or bare `\name`.
351        let braced = matches!(toks.get(j).map(|t| &t.kind), Some(TokKind::BeginGroup));
352        if braced {
353            j += 1;
354            skip_spaces(toks, &mut j);
355        }
356        let Some(name_tok) = toks.get(j) else {
357            return Err(MathError::Malformed {
358                what: format!("{which} ends before its macro name"),
359                at: self.src_len,
360            });
361        };
362        let TokKind::ControlWord(name) = name_tok.kind else {
363            return Err(MathError::Malformed {
364                what: format!("{which} expects a \\name to define"),
365                at: name_tok.span.start,
366            });
367        };
368        j += 1;
369        if braced {
370            skip_spaces(toks, &mut j);
371            let Some(Tok {
372                kind: TokKind::EndGroup,
373                ..
374            }) = toks.get(j)
375            else {
376                return Err(MathError::Malformed {
377                    what: format!("{which}{{\\{name}}} has an unclosed name group"),
378                    at: toks.get(j).map_or(self.src_len, |t| t.span.start),
379                });
380            };
381            j += 1;
382        }
383        skip_spaces(toks, &mut j);
384        // Optional parameter count `[n]`, followed by an optional default.
385        let mut params = 0_u8;
386        let mut default = None;
387        if matches!(toks.get(j).map(|t| &t.kind), Some(TokKind::Char('['))) {
388            let digit = toks.get(j + 1).and_then(|t| match t.kind {
389                TokKind::Char(c) => c.to_digit(10),
390                _ => None,
391            });
392            let close = matches!(toks.get(j + 2).map(|t| &t.kind), Some(TokKind::Char(']')));
393            match (digit, close) {
394                (Some(d @ 1..=9), true) => {
395                    params = u8::try_from(d).unwrap_or(9);
396                    j += 3;
397                }
398                _ => {
399                    return Err(MathError::Malformed {
400                        what: format!("{which}{{\\{name}}}: expected [1]..[9] parameter count"),
401                        at: toks.get(j).map_or(self.src_len, |t| t.span.start),
402                    });
403                }
404            }
405            skip_spaces(toks, &mut j);
406            if matches!(toks.get(j).map(|t| &t.kind), Some(TokKind::Char('['))) {
407                let (start, end) = optional_group(toks, j, self.src_len, name)?;
408                // A default is literal replacement material, not a second
409                // parameterized body. Escaped \# remains an ordinary token.
410                validate_body_tokens(name, 0, &toks[start..end], cw_span.start)?;
411                self.charge(end - start, cw_span)?;
412                default = Some(toks[start..end].to_vec());
413                j = end + 1;
414                skip_spaces(toks, &mut j);
415            }
416        }
417        // The body: one balanced group.
418        let Some(Tok {
419            kind: TokKind::BeginGroup,
420            ..
421        }) = toks.get(j)
422        else {
423            return Err(MathError::Malformed {
424                what: format!("{which}{{\\{name}}}: expected a {{body}} group"),
425                at: toks.get(j).map_or(self.src_len, |t| t.span.start),
426            });
427        };
428        let body_start = j + 1;
429        let mut depth = 1_i32;
430        let mut k = body_start;
431        while k < toks.len() {
432            match toks[k].kind {
433                TokKind::BeginGroup => depth += 1,
434                TokKind::EndGroup => {
435                    depth -= 1;
436                    if depth == 0 {
437                        break;
438                    }
439                }
440                _ => {}
441            }
442            k += 1;
443        }
444        if depth != 0 {
445            return Err(MathError::Malformed {
446                what: format!("{which}{{\\{name}}}: unclosed body group"),
447                at: self.src_len,
448            });
449        }
450        let exists = self.table.contains_key(name);
451        if !renew && exists {
452            return Err(MathError::Malformed {
453                what: format!(
454                    "\\newcommand: \\{name} is already defined (use \\renewcommand to replace it)"
455                ),
456                at: cw_span.start,
457            });
458        }
459        if renew && !exists {
460            return Err(MathError::Malformed {
461                what: format!("\\renewcommand: \\{name} is not defined (use \\newcommand)"),
462                at: cw_span.start,
463            });
464        }
465        // Validate before replacing the live definition. Defaults and bodies
466        // participate in the same bounded token-copy work accounting.
467        validate_body_tokens(name, params, &toks[body_start..k], cw_span.start)?;
468        self.charge(k - body_start, cw_span)?;
469        let body = toks[body_start..k].to_vec();
470        self.table.insert(
471            name,
472            Live {
473                params,
474                body,
475                default,
476            },
477        );
478        Ok(k + 1)
479    }
480
481    /// Expand one macro call at `toks[i]`; pushes onto `out` and returns
482    /// the index after the call's arguments.
483    fn call(
484        &mut self,
485        toks: &[Tok<'a>],
486        i: usize,
487        name: &'a str,
488        active: &mut Vec<String>,
489        depth: usize,
490        out: &mut Vec<Tok<'a>>,
491    ) -> Result<usize, MathError> {
492        let call_start = toks[i].span;
493        if depth >= EXPANSION_DEPTH_BUDGET {
494            return Err(MathError::Malformed {
495                what: format!(
496                    "macro expansion nests deeper than {EXPANSION_DEPTH_BUDGET} (at \\{name})"
497                ),
498                at: call_start.start,
499            });
500        }
501        if active.iter().any(|a| a == name) {
502            return Err(MathError::Malformed {
503                what: format!(
504                    "recursive macro: \\{name} expands itself (macros are non-recursive substitutions)"
505                ),
506                at: call_start.start,
507            });
508        }
509        self.charge(1, call_start)?;
510        let params = self.table.get(name).map(|live| live.params).unwrap_or(0);
511        let optional = self
512            .table
513            .get(name)
514            .is_some_and(|live| live.default.is_some());
515        let mut j = i + 1;
516        // Borrow explicit arguments. None marks an omitted optional argument;
517        // only that case clones the definition's default token sequence.
518        let mut arguments: Vec<Option<&[Tok<'a>]>> = Vec::new();
519        let mut end_span = call_start;
520        let mut use_default = false;
521        if optional {
522            skip_spaces(toks, &mut j);
523            if matches!(
524                toks.get(j).map(|token| &token.kind),
525                Some(TokKind::Char('['))
526            ) {
527                let (start, end) = optional_group(toks, j, self.src_len, name)?;
528                arguments.push(Some(&toks[start..end]));
529                end_span = toks[end].span;
530                j = end + 1;
531            } else {
532                arguments.push(None);
533                use_default = true;
534            }
535        }
536        for argn in arguments.len() + 1..=usize::from(params) {
537            skip_spaces(toks, &mut j);
538            let Some(first) = toks.get(j) else {
539                return Err(MathError::Malformed {
540                    what: format!("\\{name} needs {params} argument(s); input ends before #{argn}"),
541                    at: self.src_len,
542                });
543            };
544            if matches!(first.kind, TokKind::BeginGroup) {
545                let start = j + 1;
546                let mut depth_b = 1_i32;
547                let mut k = start;
548                while k < toks.len() {
549                    match toks[k].kind {
550                        TokKind::BeginGroup => depth_b += 1,
551                        TokKind::EndGroup => {
552                            depth_b -= 1;
553                            if depth_b == 0 {
554                                break;
555                            }
556                        }
557                        _ => {}
558                    }
559                    k += 1;
560                }
561                if depth_b != 0 {
562                    return Err(MathError::Malformed {
563                        what: format!("\\{name}: unclosed argument group for #{argn}"),
564                        at: self.src_len,
565                    });
566                }
567                arguments.push(Some(&toks[start..k]));
568                end_span = toks[k].span;
569                j = k + 1;
570            } else if matches!(first.kind, TokKind::EndGroup) {
571                return Err(MathError::Malformed {
572                    what: format!("\\{name}: missing argument #{argn} before closing group"),
573                    at: first.span.start,
574                });
575            } else {
576                arguments.push(Some(core::slice::from_ref(first)));
577                end_span = first.span;
578                j += 1;
579            }
580        }
581        let call_span = call_start.union(end_span);
582        let mut default = Vec::new();
583        if use_default {
584            let count = self
585                .table
586                .get(name)
587                .and_then(|live| live.default.as_ref())
588                .map_or(0, Vec::len);
589            self.charge(count, call_span)?;
590            default = self
591                .table
592                .get(name)
593                .and_then(|live| live.default.clone())
594                .unwrap_or_default();
595            for token in &mut default {
596                token.span = call_span;
597            }
598        }
599        let args: Vec<&[Tok<'a>]> = arguments
600            .iter()
601            .map(|argument| argument.unwrap_or(default.as_slice()))
602            .collect();
603        self.splice(name, &args, call_span, active, depth, out)?;
604        Ok(j)
605    }
606
607    /// Substitute the entire body before rescanning it. A nested invocation
608    /// such as `\inner{#2}{#1}` must see the outer call's actual arguments,
609    /// not literal parameter tokens from the outer definition. Substitution
610    /// boundaries must not become artificial boundaries for nested arguments.
611    fn splice(
612        &mut self,
613        name: &'a str,
614        args: &[&[Tok<'a>]],
615        call_span: Span,
616        active: &mut Vec<String>,
617        depth: usize,
618        out: &mut Vec<Tok<'a>>,
619    ) -> Result<(), MathError> {
620        let count = self.table.get(name).map_or(0, |live| live.body.len());
621        // Charge traversal/copy work even when every parameter is empty and
622        // the complete body subsequently disappears during substitution.
623        self.charge(count, call_span)?;
624        let body = self
625            .table
626            .get(name)
627            .map(|live| live.body.clone())
628            .unwrap_or_default();
629        let mut replacement = Vec::new();
630        let mut j = 0;
631        while j < body.len() {
632            let token = &body[j];
633            if matches!(token.kind, TokKind::Char('#')) {
634                let index = body.get(j + 1).and_then(|next| match next.kind {
635                    TokKind::Char(c @ '1'..='9') => Some(c as usize - '1' as usize),
636                    _ => None,
637                });
638                let Some(argument) = index.and_then(|index| args.get(index)) else {
639                    return Err(MathError::Malformed {
640                        what: format!("macro \\{name} body has an invalid parameter reference"),
641                        at: call_span.start,
642                    });
643                };
644                self.charge(argument.len(), call_span)?;
645                replacement.extend_from_slice(argument);
646                j += 2;
647            } else {
648                self.charge(1, call_span)?;
649                let mut token = token.clone();
650                token.span = call_span;
651                replacement.push(token);
652                j += 1;
653            }
654        }
655
656        active.push(name.to_owned());
657        let result = (|| {
658            let mut cursor = 0;
659            while cursor < replacement.len() {
660                match replacement[cursor].kind {
661                    TokKind::ControlWord(nested) if self.table.contains_key(nested) => {
662                        cursor = self.call(&replacement, cursor, nested, active, depth + 1, out)?;
663                    }
664                    _ => {
665                        out.push(replacement[cursor].clone());
666                        cursor += 1;
667                    }
668                }
669            }
670            Ok(())
671        })();
672        active.pop();
673        result
674    }
675
676    fn budget_error(span: Span) -> MathError {
677        MathError::Malformed {
678            what: format!(
679                "macro expansion requires more than {EXPANSION_TOKEN_BUDGET} token work units"
680            ),
681            at: span.start,
682        }
683    }
684
685    /// Charge before allocating replacements, including material later
686    /// consumed by a nested macro. Counting final output alone leaves
687    /// exponential empty-output expansions effectively unbounded.
688    fn charge(&mut self, units: usize, span: Span) -> Result<(), MathError> {
689        self.budget = self
690            .budget
691            .checked_sub(units)
692            .ok_or_else(|| Self::budget_error(span))?;
693        Ok(())
694    }
695}
696
697/// Token-level body validation for inline definitions (the string-level
698/// twin lives in [`validate_body`]).
699fn validate_body_tokens(
700    name: &str,
701    params: u8,
702    body: &[Tok<'_>],
703    at: usize,
704) -> Result<(), MathError> {
705    let mut j = 0;
706    while j < body.len() {
707        if let TokKind::Char('#') = body[j].kind {
708            let d = body.get(j + 1).and_then(|n| match n.kind {
709                TokKind::Char(c) => c.to_digit(10),
710                _ => None,
711            });
712            match d {
713                Some(d) if (1..=u32::from(params)).contains(&d) => j += 1,
714                Some(d) => {
715                    return Err(MathError::Malformed {
716                        what: format!(
717                            "macro \\{name} body uses #{d} but declares {params} parameter(s)"
718                        ),
719                        at,
720                    });
721                }
722                None => {
723                    return Err(MathError::Malformed {
724                        what: format!("macro \\{name} body has a '#' not followed by a digit"),
725                        at,
726                    });
727                }
728            }
729        }
730        j += 1;
731    }
732    Ok(())
733}
734
735#[cfg(test)]
736mod tests {
737    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
738
739    use super::*;
740
741    fn expand_str<'a>(src: &'a str, set: &'a MacroSet) -> Result<String, MathError> {
742        let toks = expand(lex(src), set, src.len())?;
743        Ok(toks
744            .iter()
745            .map(|t| match &t.kind {
746                TokKind::ControlWord(w) => format!("\\{w} "),
747                TokKind::ControlSymbol(c) => format!("\\{c}"),
748                TokKind::BeginGroup => "{".into(),
749                TokKind::EndGroup => "}".into(),
750                TokKind::Sup => "^".into(),
751                TokKind::Sub => "_".into(),
752                TokKind::AlignTab => "&".into(),
753                TokKind::Tie => "~".into(),
754                TokKind::MathShift => "$".into(),
755                TokKind::Space => " ".into(),
756                TokKind::Char(c) => (*c).to_string(),
757            })
758            .collect())
759    }
760
761    #[test]
762    fn pack_macros_expand_with_call_site_spans() {
763        let set = MacroSet::pack("fmd-math/pack/default").unwrap();
764        let src = r"a\minus b";
765        let toks = expand(lex(src), &set, src.len()).unwrap();
766        let minus = toks
767            .iter()
768            .find(|t| matches!(t.kind, TokKind::Char('-')))
769            .expect("expanded minus");
770        assert_eq!((minus.span.start, minus.span.end), (1, 7));
771    }
772
773    #[test]
774    fn inline_definition_with_arguments() {
775        let set = MacroSet::new();
776        let out = expand_str(r"\newcommand{\half}[1]{\frac{#1}{2}}\half{x}", &set).unwrap();
777        assert_eq!(out, r"\frac {x}{2}");
778    }
779
780    #[test]
781    fn arguments_keep_their_own_spans_and_bodies_take_the_call() {
782        let set = MacroSet::new();
783        let src = r"\newcommand{\half}[1]{\frac{#1}{2}}\half{x}";
784        let toks = expand(lex(src), &set, src.len()).unwrap();
785        let call_start = src.find(r"\half{x}").unwrap();
786        let x = toks
787            .iter()
788            .find(|t| matches!(t.kind, TokKind::Char('x')))
789            .unwrap();
790        assert_eq!(&src[x.span.start..x.span.end], "x");
791        let frac = toks
792            .iter()
793            .find(|t| matches!(t.kind, TokKind::ControlWord("frac")))
794            .unwrap();
795        assert_eq!(frac.span.start, call_start);
796        assert_eq!(frac.span.end, src.len());
797    }
798
799    #[test]
800    fn macros_reference_other_macros() {
801        let mut set = MacroSet::new();
802        set.define("dd", 0, r"\mathrm{d}").unwrap();
803        set.define("dx", 0, r"\dd x").unwrap();
804        let out = expand_str(r"\dx", &set).unwrap();
805        assert_eq!(out, r"\mathrm {d}x");
806    }
807
808    #[test]
809    fn recursion_is_refused_with_the_macro_named() {
810        let mut set = MacroSet::new();
811        set.define("loop", 0, r"a\loop").unwrap();
812        let err = expand_str(r"\loop", &set).unwrap_err();
813        assert!(err.to_string().contains("recursive macro: \\loop"), "{err}");
814        let mut set = MacroSet::new();
815        set.define("ping", 0, r"\pong").unwrap();
816        set.define("pong", 0, r"\ping").unwrap();
817        let err = expand_str(r"\ping", &set).unwrap_err();
818        assert!(err.to_string().contains("recursive macro"), "{err}");
819    }
820
821    #[test]
822    fn expansion_bombs_hit_the_budget() {
823        let mut set = MacroSet::new();
824        set.define("a", 0, "xx").unwrap();
825        for (prev, name) in [
826            ("a", "b"),
827            ("b", "c"),
828            ("c", "d"),
829            ("d", "e"),
830            ("e", "f"),
831            ("f", "g"),
832            ("g", "h"),
833            ("h", "i"),
834            ("i", "j"),
835            ("j", "k"),
836            ("k", "l"),
837            ("l", "m"),
838            ("m", "n"),
839            ("n", "o"),
840            ("o", "p"),
841            ("p", "q"),
842            ("q", "r"),
843        ] {
844            let body = format!("\\{prev}\\{prev}");
845            set.define(name, 0, &body).unwrap();
846        }
847        let err = expand_str(r"\r", &set).unwrap_err();
848        let msg = err.to_string();
849        assert!(
850            msg.contains("more than") || msg.contains("nests deeper"),
851            "{msg}"
852        );
853    }
854
855    #[test]
856    fn shadowing_rules_are_latexs() {
857        let set = MacroSet::new();
858        let err = expand_str(r"\newcommand{\x}{a}\newcommand{\x}{b}", &set).unwrap_err();
859        assert!(err.to_string().contains("already defined"), "{err}");
860        let err = expand_str(r"\renewcommand{\y}{a}", &set).unwrap_err();
861        assert!(err.to_string().contains("not defined"), "{err}");
862        let out = expand_str(r"\newcommand{\x}{a}\renewcommand{\x}{b}\x", &set).unwrap();
863        assert_eq!(out, "b");
864    }
865
866    #[test]
867    fn definition_faults_are_precise() {
868        let set = MacroSet::new();
869        for (src, needle) in [
870            (r"\newcommand", "ends before its macro name"),
871            (r"\newcommand{x}{a}", "expects a \\name"),
872            (r"\newcommand{\x}[0]{a}", "expected [1]..[9]"),
873            (r"\newcommand{\x}[2]{#3}", "uses #3 but declares 2"),
874            (r"\newcommand{\x}", "expected a {body} group"),
875            (r"\newcommand{\x}{a", "unclosed body group"),
876        ] {
877            let err = expand_str(src, &set).unwrap_err();
878            assert!(err.to_string().contains(needle), "{src}: {err}");
879        }
880    }
881
882    #[test]
883    fn undelimited_single_token_arguments() {
884        let set = MacroSet::new();
885        let out = expand_str(r"\newcommand{\sq}[1]{#1^2}\sq x", &set).unwrap();
886        assert_eq!(out, "x^2");
887    }
888
889    #[test]
890    fn canonical_bytes_are_deterministic_and_content_sensitive() {
891        let mut a = MacroSet::new();
892        a.define("dd", 0, r"\mathrm{d}").unwrap();
893        a.define("half", 1, r"\frac{#1}{2}").unwrap();
894        let mut b = MacroSet::new();
895        b.define("half", 1, r"\frac{#1}{2}").unwrap();
896        b.define("dd", 0, r"\mathrm{d}").unwrap();
897        assert_eq!(a.canonical_bytes(), b.canonical_bytes());
898        let mut c = MacroSet::new();
899        c.define("dd", 0, r"\mathrm{D}").unwrap();
900        c.define("half", 1, r"\frac{#1}{2}").unwrap();
901        assert_ne!(a.canonical_bytes(), c.canonical_bytes());
902    }
903
904    #[test]
905    fn define_validation_is_precise() {
906        let mut set = MacroSet::new();
907        assert!(set.define("", 0, "x").is_err());
908        assert!(set.define("bad name", 0, "x").is_err());
909        assert!(set.define("x", 10, "y").is_err());
910        assert!(set.define("x", 1, "#2").is_err());
911        assert!(set.define("x", 0, "{unclosed").is_err());
912        assert!(set.define("x", 0, "}stray").is_err());
913        assert!(set.define("ok", 2, r"\frac{#1}{#2}").is_ok());
914    }
915
916    #[test]
917    fn packs_exist_by_content_id_and_name() {
918        for id in [
919            "fmd-math/pack/default",
920            "default",
921            "fmd-math/pack/basic",
922            "basic",
923            "fmd-math/pack/empty",
924            "empty",
925        ] {
926            assert!(MacroSet::pack(id).is_some(), "{id}");
927        }
928        assert!(MacroSet::pack("nonexistent").is_none());
929        assert_eq!(MacroSet::pack("default").unwrap().len(), 1);
930        assert!(MacroSet::pack("empty").unwrap().is_empty());
931    }
932
933    #[test]
934    fn nested_parameterized_macros_receive_substituted_arguments() {
935        let mut set = MacroSet::new();
936        set.define("ratio", 2, r"\frac{#1}{#2}").unwrap();
937        set.define("inverse", 2, r"\ratio{#2}{#1}").unwrap();
938        set.define("twice", 1, r"\inverse{2}{#1}+\inverse{2}{#1}")
939            .unwrap();
940        assert_eq!(
941            expand_str(r"\twice{x+y}", &set).unwrap(),
942            r"\frac {x+y}{2}+\frac {x+y}{2}",
943        );
944    }
945
946    #[test]
947    fn parameter_slots_are_not_nested_argument_boundaries() {
948        let mut set = MacroSet::new();
949        set.define("pair", 2, "#1+#2").unwrap();
950        set.define("apply", 2, "#1{#2}").unwrap();
951        assert_eq!(expand_str(r"\apply{\pair{x}}{y}", &set).unwrap(), "x+y");
952        set.define("identity", 1, "#1").unwrap();
953        assert_eq!(expand_str(r"\apply{\identity}{x}", &set).unwrap(), "x");
954    }
955
956    #[test]
957    fn nested_expansion_preserves_literal_spans_and_rebases_generated_tokens() {
958        let mut set = MacroSet::new();
959        set.define("ratio", 2, r"\frac{#1}{#2}").unwrap();
960        set.define("half", 1, r"\ratio{#1}{2}").unwrap();
961        let src = r"a+\half{中}";
962        let call_start = src.find(r"\half").unwrap();
963        let tokens = expand(lex(src), &set, src.len()).unwrap();
964        for token in &tokens {
965            assert!(token.span.start <= token.span.end && token.span.end <= src.len());
966            match token.kind {
967                TokKind::Char('中') => assert_eq!(&src[token.span.start..token.span.end], "中"),
968                TokKind::Char('2') | TokKind::ControlWord("frac") => {
969                    assert_eq!((token.span.start, token.span.end), (call_start, src.len()));
970                }
971                _ => {}
972            }
973        }
974        assert!(
975            tokens
976                .iter()
977                .any(|token| matches!(token.kind, TokKind::Char('中')))
978        );
979    }
980
981    #[test]
982    fn empty_output_fanout_is_bounded_too() {
983        let mut set = MacroSet::new();
984        set.define("a", 0, "").unwrap();
985        for (previous, name) in [
986            ("a", "b"),
987            ("b", "c"),
988            ("c", "d"),
989            ("d", "e"),
990            ("e", "f"),
991            ("f", "g"),
992            ("g", "h"),
993            ("h", "i"),
994            ("i", "j"),
995            ("j", "k"),
996            ("k", "l"),
997            ("l", "m"),
998            ("m", "n"),
999            ("n", "o"),
1000            ("o", "p"),
1001            ("p", "q"),
1002            ("q", "r"),
1003        ] {
1004            set.define(name, 0, &format!("\\{previous}\\{previous}"))
1005                .unwrap();
1006        }
1007        let error = expand_str(r"\r", &set).unwrap_err();
1008        assert!(error.to_string().contains("token work units"), "{error}");
1009    }
1010
1011    #[test]
1012    fn discarded_intermediate_replacements_still_consume_budget() {
1013        let mut set = MacroSet::new();
1014        set.define("discard", 1, "").unwrap();
1015        set.define("large", 1, r"\discard{#1#1#1#1#1#1#1#1#1}")
1016            .unwrap();
1017        let source = format!("\\large{{{}}}", "x".repeat(8192));
1018        let error = expand_str(&source, &set).unwrap_err();
1019        assert!(error.to_string().contains("token work units"), "{error}");
1020    }
1021
1022    #[test]
1023    fn composed_recursive_arguments_remain_rejected() {
1024        let mut set = MacroSet::new();
1025        set.define("identity", 1, "#1").unwrap();
1026        let error = expand_str(r"\identity{\identity{x}}", &set).unwrap_err();
1027        assert!(error.to_string().contains("recursive macro"), "{error}");
1028    }
1029
1030    #[test]
1031    fn missing_argument_cannot_consume_a_closing_group() {
1032        let mut set = MacroSet::new();
1033        set.define("identity", 1, "#1").unwrap();
1034        let error = expand_str(r"{\identity}", &set).unwrap_err();
1035        assert!(error.to_string().contains("missing argument #1"), "{error}");
1036    }
1037
1038    #[test]
1039    fn optional_defaults_and_explicit_overrides_compose_with_required_arguments() {
1040        let set = MacroSet::new();
1041        let source = r"\newcommand{\power}[2][2]{#2^{#1}}\power{x}+\power[3]{y}";
1042        assert_eq!(expand_str(source, &set).unwrap(), "x^{2}+y^{3}");
1043    }
1044
1045    #[test]
1046    fn explicit_empty_optional_argument_does_not_select_the_default() {
1047        let set = MacroSet::new();
1048        let source = r"\newcommand{\join}[2][d]{#1#2}\join[]{x}+\join{y}";
1049        assert_eq!(expand_str(source, &set).unwrap(), "x+dy");
1050        let source = r"\newcommand{\empty}[1][]{#1}\empty+\empty[z]";
1051        assert_eq!(expand_str(source, &set).unwrap(), "+z");
1052    }
1053
1054    #[test]
1055    fn optional_brackets_respect_braced_and_escaped_closers() {
1056        let set = MacroSet::new();
1057        assert_eq!(
1058            expand_str(r"\newcommand{\pick}[1][{]}]{#1}\pick", &set).unwrap(),
1059            "{]}",
1060        );
1061        assert_eq!(
1062            expand_str(r"\newcommand{\pick}[1][x]{#1}\pick[{]}]", &set).unwrap(),
1063            "{]}",
1064        );
1065        assert_eq!(
1066            expand_str(r"\newcommand{\pick}[1][\]]{#1}\pick", &set).unwrap(),
1067            r"\]",
1068        );
1069    }
1070
1071    #[test]
1072    fn defaults_may_invoke_macros_and_nested_calls_may_override_them() {
1073        let set = MacroSet::new();
1074        let source = concat!(
1075            r"\newcommand{\denom}{2}",
1076            r"\newcommand{\ratio}[2][\denom]{\frac{#2}{#1}}",
1077            r"\newcommand{\third}[1]{\ratio[3]{#1}}",
1078            r"\ratio{x}+\third{y}",
1079        );
1080        assert_eq!(
1081            expand_str(source, &set).unwrap(),
1082            r"\frac {x}{2}+\frac {y}{3}"
1083        );
1084    }
1085
1086    #[test]
1087    fn renewcommand_replaces_and_can_remove_optional_defaults() {
1088        let set = MacroSet::new();
1089        let source = concat!(
1090            r"\newcommand{\pick}[1][a]{#1}\pick+",
1091            r"\renewcommand{\pick}[1][b]{#1}\pick+",
1092            r"\renewcommand{\pick}[1]{#1}\pick{c}",
1093        );
1094        assert_eq!(expand_str(source, &set).unwrap(), "a+b+c");
1095    }
1096
1097    #[test]
1098    fn default_tokens_take_call_spans_but_explicit_optional_tokens_keep_their_source() {
1099        let set = MacroSet::new();
1100        let prefix = r"\newcommand{\pick}[1][z]{#1}";
1101        let source = format!("{prefix}\\pick");
1102        let tokens = expand(lex(&source), &set, source.len()).unwrap();
1103        assert_eq!(tokens.len(), 1);
1104        assert_eq!(
1105            (tokens[0].span.start, tokens[0].span.end),
1106            (prefix.len(), source.len())
1107        );
1108        let source = format!("{prefix}\\pick[中]");
1109        let tokens = expand(lex(&source), &set, source.len()).unwrap();
1110        assert_eq!(tokens.len(), 1);
1111        assert_eq!(&source[tokens[0].span.start..tokens[0].span.end], "中");
1112    }
1113
1114    #[test]
1115    fn malformed_optional_arguments_fail_without_stealing_outer_delimiters() {
1116        let set = MacroSet::new();
1117        for (source, message) in [
1118            (r"\newcommand{\pick}[1][abc", "unclosed optional argument"),
1119            (r"\newcommand{\pick}[1][#1]{#1}", "uses #1 but declares 0"),
1120            (
1121                r"\newcommand{\pick}[1][x]{#1}\pick[a",
1122                "unclosed optional argument",
1123            ),
1124            (
1125                r"\newcommand{\pick}[1][x]{#1}{\pick[a}",
1126                "surrounding group",
1127            ),
1128            (
1129                r"\newcommand{\pick}[2][x]{#2}\pick[y]",
1130                "input ends before #2",
1131            ),
1132        ] {
1133            let error = expand_str(source, &set).unwrap_err();
1134            assert!(error.to_string().contains(message), "{source}: {error}");
1135        }
1136    }
1137
1138    #[test]
1139    fn optional_defaults_do_not_bypass_recursion_or_work_limits() {
1140        let set = MacroSet::new();
1141        let error =
1142            expand_str(r"\newcommand{\selfref}[1][\selfref]{#1}\selfref", &set).unwrap_err();
1143        assert!(error.to_string().contains("recursive macro"), "{error}");
1144        let source = format!(
1145            "\\newcommand{{\\large}}[1][{}]{{#1#1#1#1#1#1#1#1#1}}\\large",
1146            "x".repeat(8192),
1147        );
1148        let error = expand_str(&source, &set).unwrap_err();
1149        assert!(error.to_string().contains("token work units"), "{error}");
1150    }
1151
1152    #[test]
1153    fn empty_parameter_substitution_still_charges_body_traversal() {
1154        let mut set = MacroSet::new();
1155        set.define("erase", 1, &"#1".repeat(1024)).unwrap();
1156        let source = r"\erase{}".repeat(100);
1157        let error = expand_str(&source, &set).unwrap_err();
1158        assert!(error.to_string().contains("token work units"), "{error}");
1159    }
1160
1161    #[test]
1162    fn optional_macros_reach_the_real_math_parser_and_mathml_renderer() {
1163        let source = concat!(
1164            r"\newcommand{\ratio}[2][2]{\frac{#2}{#1}}",
1165            r"\newcommand{\third}[1]{\ratio[3]{#1}}",
1166            r"\ratio{x}+\third{y}",
1167        );
1168        let actual = crate::parse(source).unwrap();
1169        let expected = crate::parse(r"\frac{x}{2}+\frac{y}{3}").unwrap();
1170        for display in [false, true] {
1171            let xml = crate::to_mathml(&actual, display);
1172            assert_eq!(xml, crate::to_mathml(&expected, display));
1173            crate::mathml_well_formed(&xml).unwrap();
1174        }
1175    }
1176}