Skip to main content

latexml_core/binding/def/
dialect.rs

1use std::{borrow::Cow, rc::Rc};
2
3use once_cell::sync::Lazy;
4use regex::Regex;
5use rustc_hash::FxHashMap as HashMap;
6
7// use crate::common::error::*;
8use crate::binding::content::{merge_font, merge_font_ref};
9use crate::{
10  BoxOps, Digested,
11  binding::{counter::dialect::step_counter, def::traits::IntoDigestedResult},
12  common::{
13    arena, arena::SymHashMap, error::*, font::Font, number::Number, numeric_ops::NumericOps,
14  },
15  definition::{
16    BeforeDigestClosure, ConditionalClosure, ConstructionClosure, Definition, DigestionClosure,
17    ExpansionBody, FontDirective, PrimitiveBody, ReplacementClosure, Reversion, SizingClosure,
18    argument::ArgWrap,
19    conditional::{Conditional, ConditionalOptions, ConditionalType},
20    constructor::{Constructor, ConstructorOptions},
21    expandable::{Expandable, ExpandableOptions},
22    math_primitive::{MathPrimitive, MathPrimitiveOptions},
23    primitive::{Primitive, PrimitiveOptions},
24    register::{
25      Register, RegisterGetterClosure, RegisterSetterClosure, RegisterType, RegisterValue,
26    },
27  },
28  document::Document,
29  gullet, mouth,
30  parameter::{Parameter, Parameters},
31  pin,
32  state::*,
33  stomach::*,
34  tbox::Tbox,
35  token::*,
36  tokens::{TeXString, Tokens},
37  whatsit::Whatsit,
38};
39
40const MATH_CONSTRUCTOR_ATTRIBUTES: &[&str] = &[
41  "name",
42  "meaning",
43  "omcd",
44  "decl_id",
45  "mathstyle",
46  "lpadding",
47  "rpadding",
48];
49
50/// regex for the prefix of a conditional command sequence
51pub static CONDITIONAL_CS_RE: Lazy<Regex> =
52  Lazy::new(|| Regex::new(r"^\\(?:if(.*)|unless)$").unwrap());
53/// regex for the prefix of a protocol (such as literal:)
54pub static LEADING_PROTOCOL_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\w+:").unwrap());
55/// regex for a trailing slash (trivial, but aids replacement of said slash)
56pub static TRAILING_SLASH_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"/$").unwrap());
57/// regex for one-or-more spaces
58pub static SPACES_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\s+").unwrap());
59/// regex for ${}^{label}$
60pub static DIRTY_ID_IDIOM_RE: Lazy<Regex> =
61  Lazy::new(|| Regex::new(r"\$\{\}\^\{(?P<label>[^\}]*)\}\$").unwrap());
62/// regex for characters not expected in a usual id attribute
63pub static NON_ID_CHARSET_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[^\w_\-.]+").unwrap());
64/// regex for a strange noisy TeX `\\~{}`
65pub static TILDE_NOISE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\\~\{\}").unwrap());
66/// regex for a TeX argument specifier or any command sequence
67pub static HAS_ARG_OR_CS: Lazy<Regex> = Lazy::new(|| Regex::new(r"#\d|\\.").unwrap());
68/// regex for the usual argument placeholders `#1`-`#9`
69pub static ARG_HOLE: Lazy<Regex> = Lazy::new(|| Regex::new(r"#(\d)").unwrap());
70
71/// Is defined in the `LaTeX`-y sense of also not being let to \relax.
72pub fn is_defined(name: &str) -> bool {
73  let cs = T_CS!(name);
74  is_defined_token(&cs)
75}
76
77/// Token variant of `is_defined`. Defined in the LaTeX-y sense of also not being let to \relax.
78pub fn is_defined_token(cs: &Token) -> bool {
79  match lookup_meaning(cs) {
80    Some(store) => match store {
81      Stored::Token(_) => true,
82      Stored::Expandable(ref m) => m.get_cs_name() != "\\relax",
83      Stored::Primitive(ref m) => m.get_cs_name() != "\\relax",
84      Stored::Constructor(ref m) => m.get_cs_name() != "\\relax",
85      Stored::Register(ref m) => m.get_cs_name() != "\\relax",
86      Stored::Conditional(ref m) => m.get_cs_name() != "\\relax",
87      Stored::MathPrimitive(ref m) => m.get_cs_name() != "\\relax",
88      _ => true, // other stored values are considered defined
89    },
90    _ => false,
91  }
92}
93
94/// Check if the `token` is not yet defined, or let to `\relax`
95pub fn is_definable(token: &Token) -> bool {
96  // Non-CS / non-ACTIVE tokens (letters, digits, punctuation, etc.) have
97  // a trivial "self" meaning from `lookup_meaning` (= `Stored::Token(self)`).
98  // That's not a real `\def`/`\let` definition — kernel `\@ifdefinable`
99  // would treat them as not-yet-defined. Without this check, calls like
100  // `\@ifdefinable{Z@L@\foo}{…}` (zref-base.sty:118) get our DefToken
101  // reader's first-token-from-brace-group (the letter `Z`) and fail with
102  // "Command \Z already defined" because letter Z's lookup_meaning is
103  // non-None. Witness: arXiv:2504.18121 / 2504.17729 / 2504.17871 et al
104  // (Task #23 zref-base \Z collision cluster).
105  if !token.get_catcode().is_active_or_cs() {
106    return true;
107  }
108  let meaning = lookup_meaning(token);
109  token.with_str(|name| name != "\\relax" && !name.starts_with("\\end"))
110    && (meaning.is_none()
111      || (meaning == lookup_meaning(&TOKEN_RELAX))
112      || lookup_bool("2.09_COMPATIBILITY"))
113}
114
115//======================================================================
116// Defining Conditional Control Sequences.
117//======================================================================
118/// Define a conditional control sequence.
119///
120/// Its processing takes place in the Gullet.
121/// The test is applied to the arguments (if any),
122/// which determines which branch is executed.
123/// If the test is undefined, the conditional is a "user defined" one;
124/// Two additional primitives are defined \footrue and \foofalse;
125/// the test is then determined by the most recently called of those.
126///
127/// If you supply a skipper instead of a test, it is also applied to the arguments
128/// and should skip to the right place in the following \or, \else, \fi.
129pub fn def_conditional(
130  cs: Token,
131  paramlist: Option<Parameters>,
132  test: Option<ConditionalClosure>,
133  options: ConditionalOptions,
134) -> Result<()> {
135  let locked_key_opt = if let Some(true) = options.locked {
136    Some(arena::with(cs.get_sym(), |cs_name| s!("{cs_name}:locked")))
137  } else {
138    None
139  };
140  if cs.with_str(|cs_name| matches!(cs_name, "\\fi" | "\\else" | "\\or" | "\\unless")) {
141    install_definition(
142      Conditional {
143        cs,
144        paramlist,
145        test,
146        conditional_type: cs.with_str(|cs_name| ConditionalType::from(cs_name)),
147        skipper: options.skipper,
148      },
149      options.scope,
150    )
151  } else {
152    // Perl Package.pm L1210-1219 — match `\<2chars><rest>` and warn (not
153    // error) when prefix is not `if`, but still proceed with user-defined
154    // conditional creation. Bug-for-bug compatible: e.g. `\newif\pgf@lib@svg@relative`
155    // (PGF library naming) creates `\f@lib@svg@relativetrue` etc. with the
156    // `pg` prefix consumed — package code that uses `\if\pgf@lib@svg@relative`
157    // still works because the Let to `\iffalse` runs unconditionally.
158    let (name_opt, warn_misnamed) = cs.with_str(|custom| {
159      // First try strict `\if<name>` / `\unless` — preferred path.
160      if let Some(captures) = CONDITIONAL_CS_RE.captures(custom) {
161        let name = captures.get(1).map_or("", |m| m.as_str()).to_string();
162        return (Some(name), false);
163      }
164      // Perl-loose fallback: `\<2chars><rest>` — capture rest as `name`,
165      // emit a `misdefined` Warn since prefix isn't `if`.
166      let bytes = custom.as_bytes();
167      if bytes.len() > 3 && bytes[0] == b'\\' {
168        let rest = std::str::from_utf8(&bytes[3..]).ok().map(str::to_string);
169        (rest, true)
170      } else {
171        (None, false)
172      }
173    });
174    if warn_misnamed {
175      let message = s!(
176        "The conditional {} is being defined but doesn't start with \\if",
177        cs
178      );
179      Warn!("misdefined", cs, message);
180    }
181    if let Some(name) = name_opt {
182      if !name.is_empty() && name != "case" && test.is_none() {
183        // user-defined conditional, like with \newif
184        // Note: setting up these macros is compile-time expensive, maybe there is some way to
185        // avoid...
186        // Note: the double clones are technically correct Rust if annoying to write and read.
187        //       first, we want to capture a cloned value of cs, to be able to keep using cs here.
188        // second, each invocation of the conditional macro needs to create new tokens to
189        // return,       hence a clone is required on each call.
190        def_macro(
191          T_CS!(s!("\\{}true", name)),
192          None,
193          Tokens!(T_CS!("\\let"), cs, T_CS!("\\iftrue")),
194          None,
195        )?;
196        def_macro(
197          T_CS!(s!("\\{}false", name)),
198          None,
199          Tokens!(T_CS!("\\let"), cs, T_CS!("\\iffalse")),
200          None,
201        )?;
202        let_i(&cs, &T_CS!("\\iffalse"), None);
203      } else {
204        //  For \ifcase, the parameter list better be a single Number !!
205        install_definition(
206          Conditional {
207            cs,
208            paramlist,
209            test,
210            conditional_type: ConditionalType::If,
211            skipper: options.skipper,
212          },
213          options.scope,
214        );
215      }
216    } else {
217      let message = s!(
218        "The conditional {} is being defined but doesn't start with \\if",
219        cs
220      );
221      Error!("misdefined", cs, message);
222    }
223  }
224
225  if let Some(locked_key) = locked_key_opt {
226    assign_value(&locked_key, true, Some(Scope::Global));
227  }
228  Ok(())
229}
230
231/// Defines the macro expansion for a command sequence.
232///
233/// A macro control sequence that reads parameters
234/// as specified by `paramlist` and is expanded during macro expansion time in the `Gullet`.
235/// See `ExpansionBody` for the possible kinds of `expansion` material.
236pub fn def_macro<T: Into<Option<ExpansionBody>>>(
237  cs: Token,
238  paramlist: Option<Parameters>,
239  expansion: T,
240  options_opt: Option<ExpandableOptions>,
241) -> Result<()> {
242  let expansion_opt: Option<ExpansionBody> = expansion.into();
243  // TODO: The None case could be refactored to feel much cleaner.
244  // For now it's equivalent to Tokens!()
245  let mut options = options_opt.unwrap_or_default();
246  let scope = options.scope.take();
247  if options.mathactive && cs.with_str(|s| s.len()) == 1 {
248    assign_mathcode(
249      cs.with_str(|cstr| cstr.chars().next().unwrap()),
250      0x8000u16,
251      scope,
252    );
253  }
254  let locked_key_opt = if options.locked {
255    Some(format!("{cs}:locked"))
256  } else {
257    None
258  };
259  let defcs = if options.robust {
260    def_robust_cs(cs, options.locked, options.scope)?
261  } else {
262    cs
263  };
264  install_definition(
265    Expandable::new(defcs, paramlist, expansion_opt, Some(options))?,
266    scope,
267  );
268  if let Some(locked_key) = locked_key_opt {
269    assign_value(&locked_key, true, Some(Scope::Global));
270  }
271  Ok(())
272}
273
274/// configuration for creating a new Register
275#[derive(Default)]
276pub struct RegisterOptions {
277  /// closure to obtain the current register value
278  pub getter:   Option<RegisterGetterClosure>,
279  /// closure to set the current register value
280  pub setter:   Option<RegisterSetterClosure>,
281  /// is this register meant as read-only? (default: false)
282  pub readonly: bool,
283  /// an optional name for the register (default: the cs)
284  pub address:  Option<String>,
285  /// an optional allocation for the register (default: None)
286  pub allocate: Option<String>,
287}
288
289/// Defines a register with an initial value.
290///
291/// (a Number, Dimension, Glue, MuGlue or Tokens --- I haven't handled Box's yet).
292/// Usually, the `prototype` is just the control sequence,
293/// but registers are also handled by prototypes like `\count{Number}`. `DefRegister` arranges
294/// that the register value can be accessed when a numeric, dimension, ... value is being read,
295/// and also defines the control sequence for assignment.
296pub fn def_register<T: Into<RegisterValue>>(
297  cs: Token,
298  parameters: Option<Parameters>,
299  value: T,
300  options: Option<RegisterOptions>,
301) -> Result<()> {
302  let mut options: RegisterOptions = options.unwrap_or_default();
303  let value: RegisterValue = value.into();
304  let has_address_option = options.address.is_some();
305  let mut address = match options.address.take() {
306    Some(v) => v,
307    None => match options.allocate {
308      Some(v) => allocate_register(&v, &cs.to_string())?.unwrap_or_default(),
309      None => String::new(),
310    },
311  };
312  // by adding this check here, we no longer need to use Register::new in the Rust version
313  if address.is_empty() {
314    address = cs.to_string();
315  }
316  // Assign, but do not RE-assign
317  if !has_address_option || !has_value(&address) {
318    assign_value(&address, value.clone(), Some(Scope::Global));
319  }
320
321  let register_type: RegisterType = (&value).into();
322  install_definition(
323    Register {
324      cs,
325      address,
326      parameters,
327      register_type,
328      readonly: options.readonly,
329      getter: options.getter,
330      setter: options.setter,
331      default: Some(value),
332      value: None,
333      locator: gullet::get_locator(),
334      ..Register::default()
335    },
336    Some(Scope::Global),
337  );
338  Ok(())
339}
340
341/// Defines a primitive control sequence
342///
343/// A primitive is processed during
344/// digestion (in the  `Stomach`), after macro expansion but before Construction time.
345/// Primitive control sequences generate Boxes or Lists, generally
346/// containing basic Unicode content, rather than structured XML.
347/// Primitive control sequences are also executed for side effect during digestion,
348/// effecting changes to the `State`.
349pub fn def_primitive(
350  cs: Token,
351  paramlist: Option<Parameters>,
352  compiled_replacement: Option<PrimitiveBody>,
353  options: PrimitiveOptions,
354) -> Result<()> {
355  let options_locked = options.locked;
356  let scope = options.scope;
357  let mut before_digest_env: Vec<BeforeDigestClosure> = Vec::new();
358  let cs_name = cs.with_cs_name(ToString::to_string);
359
360  // Perl: mode => 'text' becomes restricted_horizontal + enterHorizontal
361  let mut needs_enter_horizontal = options.enter_horizontal;
362  let mode = if options.mode.as_deref() == Some("text") {
363    needs_enter_horizontal = true;
364    Some("restricted_horizontal".to_string())
365  } else {
366    options.mode
367  };
368
369  if options.require_math {
370    let cs_name_cloned = cs_name.clone();
371    let require_math_closure = before_digest_simple!({ requireMath!(cs_name_cloned) });
372    before_digest_env.push(require_math_closure);
373  }
374
375  if options.forbid_math {
376    let cs_name_cloned = cs_name.clone();
377    let forbid_math_closure = before_digest_simple!({ forbidMath!(cs_name_cloned) });
378    before_digest_env.push(forbid_math_closure);
379  }
380  if needs_enter_horizontal {
381    before_digest_env.push(before_digest_simple!({
382      enter_horizontal();
383    }));
384  }
385  if options.leave_horizontal {
386    before_digest_env.push(before_digest_simple!({
387      leave_horizontal()?;
388    }));
389  }
390  if let Some(ref mode) = mode {
391    let mode_clone = mode.clone();
392    let begin_mode_closure = before_digest_simple!({
393      begin_mode(&mode_clone)?;
394    });
395    before_digest_env.push(begin_mode_closure);
396  } else if options.bounded {
397    let bgroup_closure = before_digest_simple!({
398      bgroup();
399    });
400    before_digest_env.push(bgroup_closure);
401  }
402  match options.font {
403    Some(FontDirective::Asset(chosen_font)) => {
404      // Perf: capture Rc<Font> directly; closure borrows through it.
405      // Previously: `(*chosen_font).clone()` cloned the Font per invocation.
406      let merge_font_closure = before_digest_simple!({
407        merge_font_ref(&chosen_font);
408      });
409      before_digest_env.push(merge_font_closure);
410    },
411    Some(FontDirective::Closure(font_closure)) => {
412      let execute_font_closure = before_digest_simple!({
413        merge_font(font_closure(None)?);
414      });
415      before_digest_env.push(execute_font_closure);
416    },
417    None => {},
418  }
419  before_digest_env.extend(options.before_digest);
420
421  let mut after_digest_env: Vec<DigestionClosure> = options.after_digest;
422  if let Some(ref mode_str) = mode {
423    let mode_clone = mode_str.clone();
424    let end_mode_closure: DigestionClosure = after_digest_simple!(_whatsit, {
425      end_mode(&mode_clone)?;
426    });
427    after_digest_env.push(end_mode_closure);
428  } else if options.bounded {
429    let egroup_closure: DigestionClosure = after_digest_simple!(_whatsit, {
430      egroup()?;
431    });
432    after_digest_env.push(egroup_closure);
433  }
434  //  Not sure robust entirely makes sense for Primitives, other than LaTeXML vs LaTeX mismatch
435  let defcs = if options.robust {
436    def_robust_cs(cs, options.locked, scope)?
437  } else {
438    cs
439  };
440
441  install_definition(
442    Primitive {
443      cs: defcs,
444      paramlist,
445      replacement: compiled_replacement,
446      before_digest: before_digest_env,
447      after_digest: after_digest_env,
448      alias: options.alias,
449      nargs: options.nargs,
450      is_prefix: options.is_prefix,
451      reversion: options.reversion,
452      font_id: options.font_id,
453    },
454    scope,
455  );
456  if options_locked {
457    assign_value(&s!("{}:locked", cs_name), true, Some(Scope::Global));
458  }
459  Ok(())
460}
461
462/// Advanced math replacements require a XMDual representation
463pub fn def_math_dual(
464  cs: Token,
465  paramlist: Option<Parameters>,
466  presentation: String,
467  options: MathPrimitiveOptions,
468) -> Result<()> {
469  let (cont_cs_str, pres_cs_str) =
470    cs.with_str(|csname| (s!("{csname}@content"), s!("{csname}@presentation")));
471  let cont_cs = T_CS!(cont_cs_str);
472  let pres_cs = T_CS!(pres_cs_str);
473  let defcs = if options.robust {
474    def_robust_cs(cs, options.locked, options.scope)?
475  } else {
476    cs
477  };
478  let presentation_toks = mouth::tokenize_internal(TeXString::assembled(presentation.clone()));
479
480  // Make the original CS expand into a DUAL invoking a presentation macro and content constructor
481  let captured_role = options.role.clone();
482  let captured_revert_as = options.revert_as.clone();
483  let captured_cont_cs = cont_cs;
484  let captured_pres_cs = pres_cs;
485  let captured_pres = presentation.clone();
486  install_definition(
487    Expandable::new(
488      defcs,
489      paramlist.clone(),
490      Some(ExpansionBody::Closure(Rc::new(move |args| {
491        let args_opt_tks = args
492          .into_iter()
493          .map(|arg| arg.into())
494          .collect::<Vec<Option<Tokens>>>();
495        let (cargs, pargs) = dualize_arglist(&captured_pres, args_opt_tks)?;
496
497        let mut dtks = vec![T_CS!("\\lx@dual")];
498        // optional keyval arg
499        if captured_role.is_some() || captured_revert_as.is_some() {
500          dtks.push(T_OTHER!("["));
501          if let Some(ref role) = captured_role {
502            dtks.extend(vec![T_OTHER!("role"), T_OTHER!("="), T_OTHER!(role)]);
503            if let Some(ref _revert_as) = captured_revert_as {
504              dtks.push(T_OTHER!(","));
505            }
506          }
507          if let Some(ref revert_as) = captured_revert_as {
508            dtks.extend(vec![
509              T_OTHER!("revert_as"),
510              T_OTHER!("="),
511              T_OTHER!(revert_as),
512            ]);
513          }
514          dtks.push(T_OTHER!("]"));
515        }
516        // end optional keyval arg
517        // Perl: Invocation($content_cs, @content_args) wraps each arg in braces.
518        // If no args (no params), just emit the CS without braces.
519        dtks.push(T_BEGIN!());
520        dtks.push(captured_cont_cs);
521        for carg in cargs.into_iter().flatten() {
522          dtks.push(T_BEGIN!());
523          dtks.extend(carg.unlist());
524          dtks.push(T_END!());
525        }
526        dtks.push(T_END!());
527        dtks.push(T_BEGIN!());
528        dtks.push(captured_pres_cs);
529        for parg in pargs.into_iter().flatten() {
530          dtks.push(T_BEGIN!());
531          dtks.extend(parg.unlist());
532          dtks.push(T_END!());
533        }
534        dtks.push(T_END!());
535
536        Ok(Tokens::new(dtks))
537      }))),
538      Some(ExpandableOptions {
539        protected: options.protected,
540        ..ExpandableOptions::default()
541      }),
542    )?,
543    options.scope,
544  );
545
546  // Make the presentation macro.
547  install_definition(
548    Expandable::new(
549      pres_cs,
550      paramlist.clone(),
551      Some(ExpansionBody::Tokens(presentation_toks)),
552      Some(ExpandableOptions {
553        protected: options.protected,
554        ..ExpandableOptions::default()
555      }),
556    )?,
557    options.scope,
558  );
559
560  // content: Make the content constructor
561  // content: build the replacement closure
562  let nargs = paramlist
563    .as_ref()
564    .map(|pl| pl.get_parameters().len())
565    .unwrap_or(0);
566  let content_closure: ReplacementClosure = if nargs == 0 {
567    Rc::new(|document, _args, props| {
568      let mut attrs = HashMap::default();
569      for key in ["role", "scriptpos", "stretchy"] {
570        if let Some(v) = props.get(key) {
571          attrs.insert(key.to_owned(), v.to_string());
572        }
573      }
574      for key in MATH_CONSTRUCTOR_ATTRIBUTES {
575        if let Some(v) = props.get(key) {
576          attrs.insert(key.to_string(), v.to_string());
577        }
578      }
579      document.insert_element("ltx:XMTok", Vec::new(), Some(attrs))?;
580      Ok(())
581    })
582  } else {
583    Rc::new(|document, args, props| {
584      let mut app_attrs = HashMap::default();
585      for key in ["role", "scriptpos"] {
586        if let Some(v) = props.get(key) {
587          app_attrs.insert(key.to_owned(), v.to_string());
588        }
589      }
590      document.open_element("ltx:XMApp", Some(app_attrs), None)?;
591      let mut op_attrs = HashMap::default();
592      if let Some(v) = props.get("operator_stretchy") {
593        op_attrs.insert("stretchy".to_owned(), v.to_string());
594      }
595      if let Some(v) = props.get("operator_role") {
596        op_attrs.insert("role".to_owned(), v.to_string());
597      }
598      if let Some(v) = props.get("operator_scriptpos") {
599        op_attrs.insert("scriptpos".to_owned(), v.to_string());
600      }
601      for key in MATH_CONSTRUCTOR_ATTRIBUTES {
602        if let Some(v) = props.get(key) {
603          op_attrs.insert(key.to_string(), v.to_string());
604        }
605      }
606      // operator
607      document.insert_element("ltx:XMTok", Vec::new(), Some(op_attrs))?;
608      // arguments
609      // TODO: options.reorder?
610      for arg in args.iter().flatten() {
611        document.absorb(arg, None)?;
612      }
613      document.close_element("ltx:XMApp")?;
614      Ok(())
615    })
616  };
617  // content: install the constructor
618  let mut content_constructor = Constructor {
619    cs: cont_cs,
620    paramlist,
621    replacement: Some(content_closure),
622    ..Constructor::default()
623  };
624  let scope = options.scope;
625  transfer_common_constructor_options(&cs, &presentation, options, &mut content_constructor);
626  install_definition(content_constructor, scope);
627  Ok(())
628}
629
630/// EXPERIMENT: Introduce an intermediate case for simple symbols
631/// Define a primitive that will create a Tbox with the appropriate set of XMTok attributes.
632pub fn def_math_primitive(
633  cs: Token,
634  _paramlist: Option<Parameters>,
635  presentation: String,
636  options: MathPrimitiveOptions,
637) {
638  let scope = options.scope;
639  let reqfont_opt = options.font.clone();
640  // Perf: wrap options in Rc to avoid per-invocation clone of a 30+ field struct.
641  // Previously cloned `MathPrimitiveOptions` (20+ Option<String>, 4 Vecs) on every
642  // DefMath invocation (e.g. 1000 math tokens = 1000 full clones). Now the closure
643  // reads fields through the Rc and applies overrides via a dedicated method.
644  let shared_options = Rc::new(options.clone());
645  let dynamic_mathstyle = shared_options.dynamic_mathstyle;
646  let dynamic_scriptpos = shared_options.dynamic_scriptpos;
647
648  install_definition(
649    MathPrimitive {
650      cs,
651      paramlist: None, // never any parameters, this is intentional
652      replacement: Some(Rc::new(move |_args| {
653        let locator = gullet::get_locator();
654        // Perl: defmath_prim L1810 — `my $mode = LookupValue('MODE');`
655        // The Tbox records the CURRENT digestion mode so that Box::isMath
656        // (mode =~ /math$/) returns false inside \text{} (restricted_horizontal),
657        // making `?#isMath` template fall through to the text branch.
658        let cur_mode = lookup_string_from_sym(pin!("MODE"));
659        let mode_static: &'static str = match cur_mode.as_str() {
660          "math" => "math",
661          "display_math" => "display_math",
662          "inline_math" => "inline_math",
663          "vertical" => "vertical",
664          "internal_vertical" => "internal_vertical",
665          "horizontal" => "horizontal",
666          "restricted_horizontal" => "restricted_horizontal",
667          _ => "math",
668        };
669        let state_font = lookup_font().unwrap();
670        // Dynamic mathstyle: doVariablesizeOp — "display" in display, "text" otherwise
671        let mathstyle_override: Option<&'static str> = if dynamic_mathstyle {
672          let is_display = state_font
673            .get_mathstyle()
674            .is_some_and(|s| s.as_ref() == "display");
675          Some(if is_display { "display" } else { "text" })
676        } else {
677          None
678        };
679        // Dynamic scriptpos: doScriptpos — "mid" in display, "post" otherwise
680        let scriptpos_override: Option<&'static str> = if dynamic_scriptpos {
681          let is_display = state_font
682            .get_mathstyle()
683            .is_some_and(|s| s.as_ref() == "display");
684          Some(if is_display { "mid" } else { "post" })
685        } else {
686          None
687        };
688        let font = Rc::new(if let Some(ref reqfont) = reqfont_opt {
689          let this_reqfont = reqfont.get_font(None)?;
690          state_font
691            .merge_ref(&this_reqfont)
692            .specialize(&presentation)
693        } else {
694          state_font.specialize(&presentation)
695        });
696
697        Ok(vec![Digested::from(Tbox {
698          text: arena::pin(&presentation),
699          tokens: Tokens!(cs),
700          font,
701          properties: shared_options.to_hash_stored_with_overrides(
702            Some(mode_static),
703            mathstyle_override,
704            scriptpos_override,
705          ),
706          locator: Some(locator),
707        })])
708      })),
709      options,
710      ..MathPrimitive::default()
711    },
712    scope,
713  );
714}
715
716/// Uses of DefMath without arguments, but with constructor-like options, are realized via a
717/// `Constructor` definition
718pub fn def_math_constructor(
719  cs: Token,
720  paramlist: Option<Parameters>,
721  presentation: String,
722  mut options: MathPrimitiveOptions,
723) -> Result<()> {
724  // TODO: do we need to do anything about digesting the presentation?
725  let nargs = paramlist
726    .as_ref()
727    .map(|pl| pl.get_parameters().len())
728    .unwrap_or(0);
729  // let csname_alias = if options.alias.is_none() && options.robust {
730  //   Some(String::from(cs.get_cs_name()))
731  // } else {
732  //   None
733  // };
734  let defcs = if options.robust {
735    def_robust_cs(cs, options.locked, options.scope)?
736  } else {
737    cs
738  };
739  if options.reversion.is_none() && nargs == 0 && options.alias.is_none() {
740    if options.revert_as.is_none()
741      || options.revert_as == Some(Cow::Borrowed("content"))
742      || options.revert_as == Some(Cow::Borrowed("context"))
743    {
744      // TODO :&& (($LaTeXML::DUAL_BRANCH || 'content') eq 'content'))
745      options.reversion = Some(Reversion::Tokens(Tokens!(cs)));
746    } else {
747      // TODO: This differs from the Perl, where `presentation` comes in as Tokens
748      //       we have it come in as a `String`,
749      //       so need to tokenize when reusing it as a reversion.
750      options.reversion = Some(Reversion::Tokens(Tokens::new(Explode!(presentation))));
751    }
752  }
753  let presentation_for_sizer = presentation.clone();
754  let presentation_for_replacement = presentation.clone();
755  let is_mathstyle = options.mathstyle.is_some();
756  let mathstyle_for_font = options.mathstyle.clone();
757  let presentation_for_font = presentation.clone();
758  options.font = Some(FontDirective::Closure(if is_mathstyle {
759    Rc::new(move |_whatsit| {
760      Ok(
761        lookup_font()
762          .unwrap()
763          .merge(Font {
764            mathstyle: mathstyle_for_font
765              .as_ref()
766              .map(|ms| Cow::Owned(ms.to_owned())),
767            ..Font::default()
768          })
769          .specialize(&presentation_for_font),
770      )
771    })
772  } else {
773    Rc::new(move |_whatsit| Ok(lookup_font().unwrap().specialize(&presentation_for_font)))
774  }));
775  let compiled_replacement: Option<ReplacementClosure> = Some(if nargs == 0 {
776    // Perl defmath_cons (Package.pm L1841-1844):
777    //   $nargs == 0
778    //     && $presentation !~ /(?:\(|\)|\\)/
779    //   ? "?#isMath(<ltx:XMTok …$end_tok)($qpresentation)"
780    //   : "<ltx:XMTok …$end_tok"
781    //
782    // The `?#isMath(…)(…)` form is Perl's construction-time conditional —
783    // in math context emit the XMTok, otherwise emit the bare presentation
784    // character. The check is gated on presentation NOT containing `(`,
785    // `)`, or `\`, which would collide with template-specials.
786    //
787    // Ported here as a runtime DOM-ancestry walk (same predicate as
788    // `tbox.rs::be_absorbed`), and the presentation-content guard mirrors
789    // Perl's regex. `\rightarrowfill` (`\x{2192}`, no specials) gets the
790    // text fallback; symbols like `\(` / `\)` / anything with a backslash
791    // keep the unconditional XMTok — matching Perl.
792    let presentation_is_trivial = !presentation_for_replacement
793      .chars()
794      .any(|c| c == '(' || c == ')' || c == '\\');
795    Rc::new(
796      move |document: &mut Document, _, props: &SymHashMap<Stored>| {
797        let font_opt = match props.get("font") {
798          Some(Stored::Font(f)) => Some(Cow::Borrowed(&**f)),
799          Some(Stored::FontDirective(FontDirective::Closure(code))) => {
800            Some(Cow::Owned(code(None)?))
801          },
802          Some(Stored::FontDirective(FontDirective::Asset(font))) => Some(Cow::Borrowed(&**font)),
803          _ => None,
804        };
805        // Perl's `?#isMath(…)` conditional compiles to `ToString($prop{'isMath'})`
806        // (Constructor/Compiler.pm parse_conditional L164-173 + L197: `#prop` →
807        // `$prop{'prop'}`). That property is set on the Whatsit at digestion
808        // time from the stomach's math-mode flag (Constructor.pm L108). So the
809        // check here is on the *Whatsit's* isMath, not document-ancestry — an
810        // `\hbox{\rightarrowfill}` inside `\mathop{…}` digests the XMTok with
811        // isMath=false (hbox switched stomach to text mode), even though the
812        // document insertion point is nested under <ltx:Math>.
813        let is_math = matches!(props.get("isMath"), Some(Stored::Bool(true)));
814        if !is_math && presentation_is_trivial {
815          // Perl `?#isMath(…)(plain)` text branch — just emit the char.
816          document.absorb_string(&presentation_for_replacement, props)?;
817          return Ok(());
818        }
819        let mut attrs = HashMap::default();
820        for key in ["role", "scriptpos", "stretchy"] {
821          if let Some(v) = props.get(key) {
822            attrs.insert(key.to_owned(), v.to_string());
823          }
824        }
825        for key in MATH_CONSTRUCTOR_ATTRIBUTES {
826          if let Some(v) = props.get(key) {
827            attrs.insert(key.to_string(), v.to_string());
828          }
829        }
830        if let Some(font) = font_opt {
831          document.open_element("ltx:XMTok", Some(attrs), Some(&font))?;
832        } else {
833          document.open_element("ltx:XMTok", Some(attrs), None)?;
834        }
835        document.absorb_string(&presentation_for_replacement, props)?;
836        document.close_element("ltx:XMTok")?;
837        Ok(())
838      },
839    )
840  } else {
841    // Perl defmath_cons (Package.pm L1847-1851): when `$nargs` > 0 the
842    // template is always `<ltx:XMApp>…<ltx:XMTok …/></ltx:XMApp>` — there
843    // is NO text-mode fallback for this arm. The `requireMath` beforeDigest
844    // (Perl L1689, same as Rust L1606) has already warned the user; math
845    // context is assumed. Keep parity — emit the XMApp/XMTok structure as
846    // before.
847    Rc::new(
848      move |document: &mut Document, args: &Vec<Option<Digested>>, props: &SymHashMap<Stored>| {
849        let mut attrs = HashMap::default();
850        for key in ["role", "scriptpos", "stretchy"] {
851          if let Some(v) = props.get(key) {
852            attrs.insert(key.to_owned(), v.to_string());
853          }
854        }
855        let font_opt = match props.get("font") {
856          Some(Stored::Font(f)) => Some(Cow::Borrowed(&**f)),
857          Some(Stored::FontDirective(FontDirective::Closure(code))) => {
858            Some(Cow::Owned(code(None)?))
859          },
860          Some(Stored::FontDirective(FontDirective::Asset(font))) => Some(Cow::Borrowed(&**font)),
861          _ => None,
862        };
863        if let Some(ref font) = font_opt {
864          document.open_element("ltx:XMApp", Some(attrs), Some(font))?;
865        } else {
866          document.open_element("ltx:XMApp", Some(attrs), None)?;
867        }
868        // operator
869        let mut op_attrs = HashMap::default();
870        if let Some(role) = props.get("operator_role") {
871          op_attrs.insert(String::from("role"), role.to_string());
872        }
873        if let Some(stretchy) = props.get("operator_stretchy") {
874          op_attrs.insert(String::from("stretchy"), stretchy.to_string());
875        }
876        if let Some(scriptpos) = props.get("operator_scriptpos") {
877          op_attrs.insert(String::from("scriptpos"), scriptpos.to_string());
878        }
879        for key in MATH_CONSTRUCTOR_ATTRIBUTES {
880          if let Some(v) = props.get(key) {
881            op_attrs.insert(key.to_string(), v.to_string());
882          }
883        }
884        if let Some(font) = font_opt {
885          document.open_element("ltx:XMTok", Some(op_attrs), Some(&font))?;
886        } else {
887          document.open_element("ltx:XMTok", Some(op_attrs), None)?;
888        }
889        document.absorb_string(&presentation_for_replacement, props)?;
890        document.close_element("ltx:XMTok")?;
891        // arguments
892        for arg in args {
893          document.open_element("ltx:XMArg", None, None)?;
894          if let Some(arg_v) = arg {
895            document.absorb(arg_v, None)?;
896          }
897          document.close_element("ltx:XMArg")?;
898        }
899
900        document.close_element("ltx:XMApp")?;
901        Ok(())
902      },
903    )
904  });
905  let sizer: Option<SizingClosure> = Some(Rc::new(move |_| {
906    Ok(Font::math_default().compute_string_size(&presentation_for_sizer, SymHashMap::default()))
907  }));
908
909  // let mut prop_options = options.clone();
910  let mut constructor = Constructor {
911    cs: defcs,
912    paramlist,
913    replacement: compiled_replacement,
914    nargs: Some(nargs),
915    sizer,
916    // capture_body: options.capture_body,
917    // outer
918    // long
919    ..Constructor::default()
920  };
921  let scope = options.scope;
922  transfer_common_constructor_options(&cs, &presentation, options, &mut constructor);
923  install_definition(constructor, scope);
924  Ok(())
925}
926
927fn infer_sizer(
928  sizer: Option<&SizingClosure>,
929  _reversion: Option<&Reversion>,
930) -> Option<SizingClosure> {
931  // Perl: sizer is only set if explicitly provided. Never infer from reversion.
932  // Previously this inferred from reversion text, but that's wrong for body-capturing
933  // constructors (e.g. \lx@begin@inline@math with reversion "$" would measure the "$"
934  // character instead of the math body content).
935  sizer.map(Rc::clone)
936}
937
938fn def_robust_cs(cs: Token, locked: bool, scope: Option<Scope>) -> Result<Token> {
939  let cs_str = cs.with_str(|cstr| format!("{cstr} "));
940  let defcs = T_CS!(cs_str);
941  let return_cs = defcs;
942  let expansion = Tokens!(T_CS!("\\protect"), defcs);
943  let options = ExpandableOptions {
944    locked,
945    robust: true,
946    ..ExpandableOptions::default()
947  };
948  // scope should be \x@protect?
949  install_definition(
950    Expandable::new(cs, None, expansion.into(), Some(options))?,
951    scope,
952  );
953  Ok(return_cs)
954}
955
956/// Binding definition connecting a TeX command sequence with a structured XML output.
957///
958/// The Constructor is where LaTeXML really starts getting interesting;
959/// invoking the control sequence will generate an arbitrary XML
960/// fragment in the document tree.  More specifically: during digestion, the arguments
961/// will be read and digested, creating a `Whatsit` to represent the object. During
962/// absorption by the `Document`, the `Whatsit` will generate the XML fragment according
963/// to the `compiled_replacement`.
964pub fn def_constructor(
965  cs: Token,
966  paramlist: Option<Parameters>,
967  compiled_replacement: Option<ReplacementClosure>,
968  mut options: ConstructorOptions,
969) {
970  // TODO: This won't work, as we can only invoke method calls on paramlist in runtime
971  //*latexml_codegen::constructable::NARGS = $paramlist.get_num_args();
972  let scope = options.scope;
973  let cs = if options.robust {
974    // Perl Package.pm L1480-1481:
975    //   alias => (defined $options{alias} ? coerceCS($options{alias})
976    //             : ($options{robust} ? $cs : undef)),
977    // A `robust` definition is installed under the MUNGED cs — LaTeX2e's
978    // `\DeclareRobustCommand` idiom, where `\ref` becomes `\protect\ref␣` and
979    // the real definition lives at `\ref␣`, with a literal trailing space in the
980    // name. The reversion must still print the original `\ref`, so absent an
981    // explicit alias the pre-munge cs becomes one (`Whatsit::revert` prefers the
982    // alias over `get_cs`).
983    //
984    // Without this, every robust constructor reverted with its munged name and
985    // the trailing space rode into user-visible output: `\ref{sec:one}` became
986    // `tex="\ref {sec:one}"` on `ltx:Math`, and through that the MathML
987    // `alttext` — the screen-reader / no-MathML fallback.
988    //
989    // This changes the REVERSION only. The definition is still installed under
990    // the munged cs, so `get_cs_name()` still reports `\ref ` and code that
991    // identifies a whatsit by its cs must keep accepting both spellings (see
992    // `lxrdfa_sty.rs`'s `cs == "\\ref" || cs == "\\ref "`, or use
993    // `get_cs_or_alias`). Only the alias is clean.
994    //
995    // Note this fallback is DefConstructor-only in Perl: `DefPrimitiveI`
996    // (L1318) deliberately passes `undef` when no alias is given, and the
997    // DefMath family has its own rule (`defmath_common_constructor_options`
998    // L1703 sets `alias => $cs` unconditionally).
999    if options.alias.is_none() {
1000      options.alias = Some(cs.with_cs_name(ToString::to_string));
1001    }
1002    def_robust_cs(cs, options.locked, scope).expect("def_robust_cs for constructor failed")
1003  } else {
1004    cs
1005  };
1006  let cs_name = cs.with_cs_name(ToString::to_string);
1007  let locked_key_opt = if options.locked {
1008    Some(s!("{cs_name}:locked"))
1009  } else {
1010    None
1011  };
1012
1013  let mut before_digest_closures: Vec<BeforeDigestClosure> = Vec::new();
1014
1015  // Perl: mode => 'text' becomes restricted_horizontal + enterHorizontal
1016  let mut needs_enter_horizontal = options.enter_horizontal;
1017  let mode = if options.mode.as_deref() == Some("text") {
1018    needs_enter_horizontal = true;
1019    Some("restricted_horizontal".to_string())
1020  } else {
1021    options.mode
1022  };
1023
1024  if options.require_math {
1025    let cs_name_cloned = cs_name.clone();
1026    let require_math_closure = before_digest_simple!({ requireMath!(cs_name_cloned) });
1027    before_digest_closures.push(require_math_closure);
1028  }
1029  if options.forbid_math {
1030    let cs_name_cloned = cs_name;
1031    let forbid_math_closure = before_digest_simple!({ forbidMath!(cs_name_cloned) });
1032    before_digest_closures.push(forbid_math_closure);
1033  }
1034  if needs_enter_horizontal {
1035    before_digest_closures.push(before_digest_simple!({
1036      enter_horizontal();
1037    }));
1038  }
1039  if options.leave_horizontal {
1040    before_digest_closures.push(before_digest_simple!({
1041      leave_horizontal()?;
1042    }));
1043  }
1044  if let Some(ref mode) = mode {
1045    let mode_clone = mode.clone();
1046    let begin_mode_closure = before_digest_simple!({
1047      begin_mode(&mode_clone)?;
1048    });
1049    before_digest_closures.push(begin_mode_closure);
1050  } else if options.bounded {
1051    let bgroup_closure = before_digest_simple!({
1052      bgroup();
1053    });
1054    before_digest_closures.push(bgroup_closure);
1055  }
1056  // DG: The situations with Fonts in Constructors appears rather complex?
1057  //  LaTeXML seems to currently rely on both the top-level "font" option but *also*
1058  //  has code checking for a second-tier "properties => { font => VALUE}" option
1059  //  Can we consolidate into a single, top-level, font handler?
1060  match options.font {
1061    Some(FontDirective::Asset(chosen_font)) => {
1062      let merge_font_closure = before_digest_simple!({
1063        merge_font((*chosen_font).clone());
1064      });
1065      before_digest_closures.push(merge_font_closure);
1066    },
1067    Some(FontDirective::Closure(font_closure)) => {
1068      let execute_font_closure = before_digest_simple!({
1069        merge_font(font_closure(None)?);
1070      });
1071      before_digest_closures.push(execute_font_closure);
1072    },
1073    None => {},
1074  };
1075  before_digest_closures.extend(options.before_digest);
1076
1077  let mut after_digest_closures: Vec<DigestionClosure> = options.after_digest;
1078  if let Some(ref mode_str) = mode {
1079    let mode_clone = mode_str.clone();
1080    let end_mode_closure: DigestionClosure = after_digest_simple!(_whatsit, {
1081      end_mode(&mode_clone)?;
1082    });
1083    after_digest_closures.push(end_mode_closure);
1084  } else if options.bounded {
1085    let egroup_closure: DigestionClosure = after_digest_simple!(_whatsit, {
1086      egroup()?;
1087    });
1088    after_digest_closures.push(egroup_closure);
1089  }
1090
1091  let constructor = Constructor {
1092    cs,
1093    paramlist,
1094    replacement: compiled_replacement,
1095    before_digest: before_digest_closures,
1096    after_digest: after_digest_closures,
1097    before_construct: options.before_construct,
1098    after_construct: options.after_construct,
1099    nargs: options.nargs,
1100    alias: options.alias,
1101    sizer: infer_sizer(options.sizer.as_ref(), options.reversion.as_ref()),
1102    reversion: options.reversion,
1103    capture_body: options.capture_body,
1104    properties: options.properties,
1105    // outer
1106    // long
1107    ..Constructor::default()
1108  };
1109  install_definition(constructor, scope);
1110
1111  if let Some(locked_key) = locked_key_opt {
1112    assign_value(&locked_key, true, Some(Scope::Global));
1113  }
1114}
1115
1116/// Defines an Environment that generates a specific XML fragment.
1117///
1118/// `compiled_replacement` is of the same form as for DefConstructor, but will generally include
1119/// reference to the `#body` property.
1120/// Upon encountering a `\begin{env}`:  the mode is switched, if needed, else a new group is opened;
1121/// then the environment name is noted; the beforeDigest hook is run.
1122/// Then the Whatsit representing the begin command (but ultimately the whole environment) is
1123/// created and the `after_digest_begin` hook is run.
1124/// Next, the body will be digested and collected until the balancing `\end{env}`.
1125/// Then, any `after_digest` hook is run, the environment is ended, finally the mode is ended or the
1126/// group is closed.  The body and `\end{env}` whatsit are added to the `\begin{env}`'s whatsit as
1127/// body and trailer, respectively.
1128pub fn def_environment(
1129  name: String,
1130  paramlist: Option<Parameters>,
1131  compiled_replacement: Option<ReplacementClosure>,
1132  options: ConstructorOptions,
1133) {
1134  // This is for the common case where the environment is opened by \begin{env}
1135  let begin_name = s!("\\begin{{{name}}}");
1136  let end_name = s!("\\end{{{name}}}");
1137  let mut before_digest_env: Vec<BeforeDigestClosure> = Vec::new();
1138
1139  // Perl Package.pm line 1885: $mode = 'restricted_horizontal' if !$mode || ($mode eq 'text');
1140  // Environments ALWAYS have a mode — defaults to restricted_horizontal.
1141  // This means \end{env} always calls endMode(), never egroup().
1142  let mode = match options.mode.as_deref() {
1143    None | Some("text") => Some("restricted_horizontal".to_string()),
1144    _ => options.mode,
1145  };
1146
1147  if options.require_math {
1148    let require_name = begin_name.clone();
1149    let require_math_closure = before_digest_simple!({ requireMath!(require_name) });
1150    before_digest_env.push(require_math_closure);
1151  }
1152  if options.forbid_math {
1153    let forbid_name = begin_name.clone();
1154    let forbid_math_closure = before_digest_simple!({ forbidMath!(forbid_name) });
1155    before_digest_env.push(forbid_math_closure);
1156  }
1157  let bgroup_closure = before_digest_simple!({
1158    bgroup();
1159  });
1160  before_digest_env.push(bgroup_closure);
1161  let atbegin_key = s!("@environment@{name}@atbegin");
1162  let atbegin_hook_closure = before_digest_simple!({
1163    if let Some(b) = lookup_tokens(&atbegin_key) {
1164      vec![digest(b.unlist())?]
1165    } else {
1166      Vec::new()
1167    }
1168  });
1169
1170  before_digest_env.push(atbegin_hook_closure);
1171  if options.enter_horizontal {
1172    before_digest_env.push(before_digest_simple!({
1173      enter_horizontal();
1174    }));
1175  }
1176  if options.leave_horizontal {
1177    before_digest_env.push(before_digest_simple!({
1178      leave_horizontal()?;
1179    }));
1180  }
1181  // Perl Package.pm line 1908: beginMode($mode, 1) — noframe=1 since bgroup already pushed
1182  if let Some(ref mode) = mode {
1183    let bmode = mode.clone();
1184    let mode_closure = before_digest_simple!({
1185      begin_mode_opt(&bmode, true)?;
1186    });
1187    before_digest_env.push(mode_closure);
1188  }
1189
1190  let env_name = name.clone();
1191  let current_environment_closure = before_digest_simple!({
1192    assign_value_sym(crate::pin!("current_environment"), env_name.clone(), None);
1193    let body = T_LETTER!(env_name.clone());
1194    def_macro(
1195      T_CS!("\\@currenvir"),
1196      None,
1197      Some(ExpansionBody::Tokens(Tokens!(body))),
1198      None,
1199    )?;
1200  });
1201  before_digest_env.push(current_environment_closure);
1202
1203  match options.font {
1204    Some(FontDirective::Asset(chosen_font)) => {
1205      // Perf: capture Rc<Font> directly; closure borrows through it.
1206      // Previously: `(*chosen_font).clone()` cloned the Font per invocation.
1207      let merge_font_closure = before_digest_simple!({
1208        merge_font_ref(&chosen_font);
1209      });
1210      before_digest_env.push(merge_font_closure);
1211    },
1212    Some(FontDirective::Closure(font_closure)) => {
1213      let execute_font_closure = before_digest_simple!({
1214        merge_font(font_closure(None)?);
1215      });
1216      before_digest_env.push(execute_font_closure);
1217    },
1218    None => {},
1219  }
1220  // Clone before_digest so the bare `\name` form can run the same
1221  // user-supplied hooks. Perl Package.pm L1949-1969 states that the bare
1222  // form (entered e.g. via `\csname env\endcsname` or by another macro
1223  // expanding to `\env[…]`) "gets the same hook pipeline as \begin{FOO}" —
1224  // including the user's `beforeDigest`. sidecap's `\SCfigure[…]` → `\figure[…]`
1225  // is the canonical trigger: without this, `beforeFloat('figure')` never
1226  // fires, `\@captype` stays undefined, and nested `\caption` cascades as
1227  // "outside any known float".
1228  let bare_before_digest = options.before_digest.clone();
1229  before_digest_env.extend(options.before_digest);
1230
1231  // Clone fields needed for the bare \name constructor (Perl Package.pm lines 1949-1969)
1232  // before they are moved into the \begin{name} constructor below.
1233  let bare_after_digest_begin = options.after_digest_begin.clone();
1234  let bare_after_digest_body = options.after_digest_body.clone();
1235  let bare_before_construct = options.before_construct.clone();
1236  let bare_after_construct = options.after_construct.clone();
1237  let bare_sizer = options.sizer.clone();
1238  let bare_reversion = options.reversion.clone();
1239  let bare_alias = options.alias.clone();
1240
1241  let push_frame_closure = Rc::new(|_document: &mut Document, _whatsit: &Whatsit| {
1242    push_frame();
1243    Ok(())
1244  });
1245  let mut before_construct_with_frame: Vec<ConstructionClosure> = vec![push_frame_closure];
1246  before_construct_with_frame.extend(options.before_construct);
1247
1248  let mut after_construct_with_frame: Vec<ConstructionClosure> = options.after_construct;
1249
1250  let pop_frame_closure = Rc::new(|_document: &mut Document, _whatsit: &Whatsit| {
1251    pop_frame()?;
1252    Ok(())
1253  });
1254  after_construct_with_frame.push(pop_frame_closure);
1255
1256  // Perl Package.pm L1891-1895: "in pure LaTeX would usually have expanded to \env
1257  // and would have skipped spaces before parsing args, if any."
1258  // Prepend SkipSpaces parameter when the environment has arguments.
1259  let paramlist_skips = match paramlist {
1260    Some(ref pl) if pl.get_num_args() > 0 => {
1261      let skip_spaces_param = Parameter {
1262        novalue: true,
1263        name: pin!("SkipSpaces"),
1264        spec: pin!("SkipSpaces"),
1265        reader: Rc::new(|_inner, _extra| {
1266          gullet::skip_spaces()?;
1267          Ok(ArgWrap::None)
1268        }),
1269        ..Parameter::default()
1270      };
1271      let mut params = vec![skip_spaces_param];
1272      params.extend(pl.get_parameters().into_iter().cloned());
1273      Some(Parameters::new(params))
1274    },
1275    _ => paramlist.clone(),
1276  };
1277
1278  let begin_name_constructor = Rc::new(Constructor {
1279    cs:                T_CS!(begin_name),
1280    paramlist:         paramlist_skips,
1281    replacement:       compiled_replacement.clone(),
1282    nargs:             options.nargs,
1283    before_digest:     before_digest_env,
1284    after_digest:      options.after_digest_begin,
1285    after_digest_body: options.after_digest_body,
1286    before_construct:  before_construct_with_frame,
1287    // Curiously, it's the \begin whose afterConstruct gets called.
1288    after_construct:   after_construct_with_frame,
1289    capture_body:      true,
1290    properties:        options.properties.clone(),
1291    // (defined $options{reversion} ? (reversion => $options{reversion}) : ()),
1292    // (defined $sizer ? (sizer => $sizer) : ()),
1293    // ), $options{scope});
1294    sizer:             infer_sizer(options.sizer.as_ref(), options.reversion.as_ref()),
1295    reversion:         options.reversion,
1296    alias:             options.alias,
1297  });
1298  install_definition(begin_name_constructor, options.scope);
1299
1300  let mut after_digest_env = options.after_digest.clone();
1301  let name_clone = name.clone();
1302  let end_name_clone = end_name.clone();
1303  let unexpected_end_closure = after_digest_simple!(_whatsit, {
1304    let env = lookup_string_from_sym(crate::pin!("current_environment"));
1305    if env.is_empty() || name_clone != env {
1306      // Perl Package.pm:1946-1947: message has a trailing `;`, and the
1307      // open-environment list is introduced by "Current are:" (with colon).
1308      let message1 = s!("Can't close environment {};", name_clone);
1309      let message2 = s!(
1310        "Current are: {}",
1311        with_stacked_values_sym(crate::pin!("current_environment"), |vals| vals
1312          .iter()
1313          .map(|x| s!("{:?}", x))
1314          .collect::<Vec<String>>()
1315          .join(", "))
1316      );
1317      Error!("unexpected", end_name_clone, message1, message2);
1318    }
1319    Ok(Vec::new())
1320  });
1321  after_digest_env.push(unexpected_end_closure);
1322
1323  match mode {
1324    Some(ref emode) => {
1325      let emode = emode.clone();
1326      let emode_closure = Rc::new(move |_whatsit: &mut Whatsit| {
1327        // Perl Package.pm L1944-1945:
1328        //   # Switch mode (w/stack frame pop), OR egroup
1329        //   ($mode ? (sub { $_[0]->endMode($mode) }) : sub { $_[0]->egroup; }),
1330        // endMode(mode) with no second arg defaults to noframe=0 — it DOES
1331        // pop a frame. This pairs with L1902's `bgroup` + L1908's
1332        // `beginMode($mode, 1)` (noframe=1, no push) on the begin side:
1333        // bgroup pushes exactly one frame, beginMode writes MODE/BOUND_MODE
1334        // Local into that frame, endMode pops the frame and reverts.
1335        end_mode(&emode)?;
1336        Ok(Vec::new())
1337      });
1338      after_digest_env.push(emode_closure);
1339    },
1340    None => {
1341      let egroup_closure = Rc::new(|_whatsit: &mut Whatsit| {
1342        egroup()?;
1343        Ok(Vec::new())
1344      });
1345      after_digest_env.push(egroup_closure);
1346    },
1347  };
1348
1349  let (mut before_digest_for_endenv, before_digest_end_clone) = {
1350    let cloned = options.before_digest_end.clone();
1351    (options.before_digest_end, cloned)
1352  };
1353  let atend_key = s!("@environment@{name}@atend");
1354  let atend_hook_closure = before_digest_simple!({
1355    if let Some(e) = lookup_tokens(&atend_key) {
1356      vec![digest(e.unlist())?]
1357    } else {
1358      Vec::new()
1359    }
1360  });
1361  before_digest_for_endenv.push(atend_hook_closure);
1362
1363  let end_envname_constructor = Rc::new(Constructor {
1364    cs: T_CS!(end_name),
1365    replacement: None,
1366    paramlist: None,
1367    before_digest: before_digest_for_endenv,
1368    after_digest: after_digest_env,
1369    ..Constructor::default() // TODO ? fill in missing ones
1370  });
1371  install_definition(end_envname_constructor, options.scope);
1372
1373  // For the uncommon case opened by \csname env\endcsname
1374  // Perl Package.pm lines 1949-1969: \FOO gets the same hook pipeline as \begin{FOO}
1375  let mut before_digest_bare: Vec<BeforeDigestClosure> = Vec::new();
1376  before_digest_bare.push(before_digest_simple!({
1377    bgroup();
1378  }));
1379  if options.enter_horizontal {
1380    before_digest_bare.push(before_digest_simple!({
1381      enter_horizontal();
1382    }));
1383  }
1384  if options.leave_horizontal {
1385    before_digest_bare.push(before_digest_simple!({
1386      leave_horizontal()?;
1387    }));
1388  }
1389  if let Some(ref bmode) = mode {
1390    let bmode = bmode.clone();
1391    before_digest_bare.push(before_digest_simple!({
1392      begin_mode_opt(&bmode, true)?;
1393    }));
1394  }
1395  // Perl Package.pm L1949-1969: bare `\name` runs the same user beforeDigest
1396  // hooks as `\begin{name}` (e.g. beforeFloat for `{figure}`). Order matters:
1397  // bgroup + mode have already been pushed; the user hooks come last, mirroring
1398  // the `\begin{name}` pipeline.
1399  before_digest_bare.extend(bare_before_digest);
1400  let push_frame_bare = Rc::new(|_document: &mut Document, _whatsit: &Whatsit| {
1401    push_frame();
1402    Ok(())
1403  });
1404  let pop_frame_bare = Rc::new(|_document: &mut Document, _whatsit: &Whatsit| {
1405    pop_frame()?;
1406    Ok(())
1407  });
1408  // Perl: \name gets the same afterDigest, afterDigestBody, beforeConstruct, afterConstruct,
1409  // sizer, reversion, alias as \begin{name}
1410  let mut before_construct_bare: Vec<ConstructionClosure> = vec![push_frame_bare];
1411  before_construct_bare.extend(bare_before_construct);
1412  let mut after_construct_bare: Vec<ConstructionClosure> = bare_after_construct;
1413  after_construct_bare.push(pop_frame_bare);
1414  let name_constructor = Rc::new(Constructor {
1415    cs: T_CS!(s!("\\{}", &name)),
1416    paramlist,
1417    replacement: compiled_replacement,
1418    nargs: options.nargs,
1419    capture_body: true,
1420    properties: options.properties.clone(),
1421    before_digest: before_digest_bare,
1422    after_digest: bare_after_digest_begin,
1423    after_digest_body: bare_after_digest_body,
1424    before_construct: before_construct_bare,
1425    after_construct: after_construct_bare,
1426    sizer: infer_sizer(bare_sizer.as_ref(), bare_reversion.as_ref()),
1427    reversion: bare_reversion,
1428    alias: bare_alias,
1429  });
1430  install_definition(name_constructor, options.scope);
1431  let end_name = s!("\\end{}", &name);
1432  let mut after_digest_end = options.after_digest;
1433  // Perl Package.pm lines 1970-1975: \endFOO calls endMode if mode was specified
1434  match mode {
1435    Some(ref emode) => {
1436      let emode = emode.clone();
1437      after_digest_end.push(Rc::new(move |_whatsit: &mut Whatsit| {
1438        end_mode(&emode)?;
1439        Ok(Vec::new())
1440      }));
1441    },
1442    None => {
1443      // No mode specified — no egroup needed for this simplified closer.
1444      // (The \end{FOO} constructor already handles egroup.)
1445    },
1446  };
1447
1448  // Perl Package.pm lines 1970-1975: \endFOO has beforeDigestEnd + afterDigest + endMode
1449  let end_name_constructor = Constructor {
1450    cs: T_CS!(end_name),
1451    paramlist: None,
1452    replacement: None,
1453    before_digest: before_digest_end_clone,
1454    after_digest: after_digest_end,
1455    ..Constructor::default()
1456  };
1457  install_definition(Rc::new(end_name_constructor), options.scope);
1458
1459  if options.locked {
1460    assign_value(
1461      &s!("\\begin{{{}}}:locked", &name),
1462      true,
1463      Some(Scope::Global),
1464    );
1465    assign_value(&s!("\\end{{{}}}:locked", &name), true, Some(Scope::Global));
1466    assign_value(&s!("\\{}:locked", &name), true, Some(Scope::Global));
1467    assign_value(&s!("\\end{}:locked", &name), true, Some(Scope::Global));
1468  }
1469}
1470
1471//======================================================================
1472// Support for XMDual
1473
1474// Perhaps it would be better to use a label(-like) indirection here,
1475// so all ID's can stay in the desired format?
1476pub fn get_xmarg_id() -> Result<Tokens> {
1477  // `@lx@xmarg` is an internal-only counter (no user-visible
1478  // counters nest inside it), so `noreset: true` skips the
1479  // `\cl@@lx@xmarg` nested-reset probe — the same observation as
1480  // in xmath_helpers::get_xm_arg_id.
1481  step_counter("@lx@xmarg", true)?;
1482  def_macro(
1483    T_CS!("\\@@lx@xmarg@ID"),
1484    None,
1485    Tokens!(Explode!(
1486      lookup_register("\\c@@lx@xmarg", Vec::new())?
1487        .unwrap()
1488        .value_of()
1489    )),
1490    Some(ExpandableOptions {
1491      scope: Some(Scope::Global),
1492      ..ExpandableOptions::default()
1493    }),
1494  )?;
1495  gullet::do_expand(T_CS!("\\the@lx@xmarg@ID"))
1496}
1497
1498type ArgsUnpacked = Vec<Option<Tokens>>;
1499/// Flesh out two dual (mathematical) forms of a given list of arguments.
1500///
1501/// Given a list of Tokens (to be expanded into mathematical objects)
1502/// return two lists
1503///   (1) The Tokens' wrapped in an XMAarg, with an ID added
1504///   (2) a corresponding list of Tokens creating XMRef's to those IDs
1505///
1506/// Ah, but there are complications!!!
1507/// On the one hand, arguments may be hidden, never appearing on the presentation side
1508/// (all will be passed to the content side); This argues for putting the XMArg's on the content
1509/// side. OTOH, they ought to be on the presentation side, so that they can be expanded & digested
1510/// in the proper context they will be presented, and pick up all the styling (font size,
1511/// displaystyle..) I don't know how to work around the latter, so we'll put args on the
1512/// presentation side, UNLESS they are hidden, in which case they'll be on the content side.
1513/// So, how do we know if they're hidden? We'll scan the presentation for #\d, that's how!
1514pub fn dualize_arglist(
1515  presentation: &str,
1516  args: Vec<Option<Tokens>>,
1517) -> Result<(ArgsUnpacked, ArgsUnpacked)> {
1518  let mut used = HashMap::default();
1519  for cap in ARG_HOLE.captures_iter(presentation) {
1520    // Get the args that were actually used!
1521    let argi = cap.get(1).unwrap().as_str();
1522    let entry = used.entry(argi.parse::<usize>().expect(argi)).or_insert(0);
1523    *entry += 1;
1524  }
1525  let mut cargs = Vec::new();
1526  let mut pargs = Vec::new();
1527  for (index, arg_opt) in args.into_iter().enumerate() {
1528    match arg_opt {
1529      None => {
1530        pargs.push(None);
1531        cargs.push(None);
1532      },
1533      Some(arg) if arg.unlist_ref().is_empty() => {
1534        pargs.push(Some(arg.clone()));
1535        cargs.push(Some(arg));
1536      },
1537      Some(arg_toks) => {
1538        if used.get(&(1 + index)).unwrap_or(&0) > &0 {
1539          // used in presentation?
1540          let id = get_xmarg_id()?;
1541          pargs.push(Some(Tokens!(
1542            T_CS!("\\lx@xmarg"),
1543            T_BEGIN!(),
1544            id.clone().unlist(),
1545            T_END!(),
1546            T_BEGIN!(),
1547            arg_toks.unlist(),
1548            T_END!()
1549          ))); // put XMArg in presentation
1550          cargs.push(Some(Tokens!(
1551            T_CS!("\\lx@xmref"),
1552            T_BEGIN!(),
1553            id.unlist(),
1554            T_END!()
1555          )));
1556        } else {
1557          // Hidden arg, put XMArg in content.
1558          let id = get_xmarg_id()?;
1559          cargs.push(Some(Tokens!(
1560            T_CS!("\\lx@xmarg"),
1561            T_BEGIN!(),
1562            id.clone().unlist(),
1563            T_END!(),
1564            T_BEGIN!(),
1565            arg_toks.unlist(),
1566            T_END!()
1567          )));
1568          pargs.push(Some(Tokens!(
1569            T_CS!("\\lx@xmref"),
1570            T_BEGIN!(),
1571            id.unlist(),
1572            T_END!()
1573          )));
1574        }
1575      },
1576    }
1577  }
1578  Ok((cargs, pargs))
1579}
1580
1581/// Define a Mathematical symbol or function.
1582///
1583/// There are two sets of cases:
1584///  (1) If the presentation appears to be TeX code, we create an XMDual,
1585/// since the presentation may end up with structure, etc.
1586///  (2) But if the presentation is a simple string, or unicode,
1587/// it is just the content of the symbol; even if the function takes arguments.
1588// ALSO
1589//  arrange that the operator token gets cs="$cs"
1590// ALSO
1591//  Possibly some trick with SUMOP/INTOP affecting limits ?
1592//  Well, not exactly, but....
1593// HMM.... Still fishy.
1594// When to make a dual ?
1595// If the $presentation seems to be TeX (ie. it involves #1... but not ONLY!)
1596pub fn def_math(
1597  cs: Token,
1598  paramlist: Option<Parameters>,
1599  presentation: String,
1600  mut options: MathPrimitiveOptions,
1601) -> Result<()> {
1602  // Can't defer parsing parameters since we need to know number of args!
1603  // $paramlist = parseParameters($paramlist, $cs) if defined $paramlist && !ref $paramlist;
1604
1605  let nargs = match paramlist {
1606    Some(ref plist) => plist.get_num_args(),
1607    None => 0,
1608  };
1609  let csname = cs.with_str(ToString::to_string);
1610  let name_opt = {
1611    let name = match options.name {
1612      Some(ref name) => Cow::Owned(name.to_owned()),
1613      None => {
1614        let mut inferred_name = match options.alias {
1615          Some(ref alias) => Cow::Owned(alias.to_owned()),
1616          None => Cow::Borrowed(&csname),
1617        };
1618        if inferred_name.starts_with('\\') {
1619          inferred_name = Cow::Owned(inferred_name.replacen('\\', "", 1))
1620        }
1621        inferred_name
1622      },
1623    };
1624    let meaning_check = options
1625      .meaning
1626      .as_ref()
1627      .map_or_else(|| Cow::Owned(String::new()), Cow::Borrowed);
1628    if (*name == presentation) || (name.is_empty()) || *name == *meaning_check {
1629      None
1630    } else {
1631      Some(name.into_owned())
1632    }
1633  };
1634  options.name = name_opt;
1635  if nargs == 0 && options.role.is_none() {
1636    options.role = Some(String::from("UNKNOWN"))
1637  }
1638  if nargs > 0 && options.operator_role.is_none() {
1639    options.operator_role = Some(String::from("UNKNOWN"))
1640  }
1641  if options.hide_content_reversion {
1642    options.revert_as = Some(Cow::Borrowed("context"));
1643  }
1644
1645  let locked = options.locked;
1646  // Store some data for introspection
1647  // defmath_introspective(cs, paramlist, presentation, options);
1648
1649  // If single character, handle with a rewrite rule
1650  if csname.len() == 1 {
1651    let mut math_attr_hash: HashMap<String, String> = HashMap::default();
1652    transfer_opt_default!(name, options, math_attr_hash);
1653    transfer_opt_default!(meaning, options, math_attr_hash);
1654    transfer_opt_default!(omcd, options, math_attr_hash);
1655    transfer_opt_default!(decl_id, options, math_attr_hash);
1656    transfer_opt_default!(role, options, math_attr_hash);
1657    transfer_opt_default!(replace, options, math_attr_hash);
1658    transfer_opt_default!(mathstyle, options, math_attr_hash);
1659    transfer_opt_default!(stretchy, options, math_attr_hash);
1660    assign_value(
1661      &s!("math_token_attributes_{}", csname),
1662      math_attr_hash,
1663      Some(Scope::Global),
1664    );
1665  }
1666  // If the macro involves arguments,
1667  // we will create an XMDual to separate simple content application
1668  // from the (likely) convoluted presentation.
1669  else if HAS_ARG_OR_CS.is_match(&presentation) {
1670    // TODO: Are the code variants still applicable in Rust?
1671    //((ref presentation eq "CODE")
1672    // || ((ref presentation) && grep { $_->equals(T_PARAM) } presentation->unlist)
1673    // || ((ref presentation) && (grep { $_->isExecutable } presentation->unlist)))
1674    def_math_dual(cs, paramlist, presentation, options)?;
1675  }
1676  // EXPERIMENT: Introduce an intermediate case for simple symbols
1677  // Define a primitive that will create a Box with the appropriate set of XMTok attributes.
1678  else if nargs == 0 && !options.has_complex_option() {
1679    def_math_primitive(cs, paramlist, presentation, options);
1680  } else {
1681    def_math_constructor(cs, paramlist, presentation, options)?;
1682  }
1683  if locked {
1684    assign_value(&format!("{csname}:locked"), true, Some(Scope::Global));
1685  }
1686  Ok(())
1687}
1688
1689/// Transfers the common MathPrimitive options to a (ideally freshly instantiated) Constructor.
1690fn transfer_common_constructor_options(
1691  cs: &Token,
1692  presentation: &str,
1693  options: MathPrimitiveOptions,
1694  cons: &mut Constructor,
1695) {
1696  let cs_str = cs.with_str(ToString::to_string);
1697  let mut properties = options.to_hash_stored();
1698  cons.alias = Some(options.alias.unwrap_or_else(|| cs_str.clone()));
1699  if let Some(sizer) = infer_sizer(options.sizer.as_ref(), options.reversion.as_ref()) {
1700    cons.sizer = Some(sizer);
1701  }
1702  if let Some(reversion) = options.reversion {
1703    cons.reversion = Some(reversion);
1704  }
1705  //
1706  // before_digest
1707  //
1708  // Perl (Package.pm:1304): the `requireMath` beforeDigest is added ONLY when the
1709  // binding passes `requireMath => 1` (`$options{requireMath} ? (sub {...}) : ()`),
1710  // NOT for every DefMath. A plain math symbol (e.g. `\rightarrowfill`, a DefMath
1711  // ARROW) used in TEXT mode must not warn "should only appear in math mode" — Perl
1712  // auto-enters math for it; only explicit requireMath constructs (`\bm`, …) warn.
1713  // (Was unconditional → a broad Rust-only `unexpected:mode` over-emission.)
1714  let mut before_digest_closures: Vec<BeforeDigestClosure> = Vec::new();
1715  if options.require_math {
1716    before_digest_closures.push(before_digest_simple!({
1717      requireMath!(cs_str);
1718    }));
1719  }
1720  if !options.nogroup {
1721    before_digest_closures.push(before_digest_simple!({
1722      bgroup();
1723    }));
1724  }
1725  if let Some(font) = options.font {
1726    before_digest_closures.push(before_digest_simple!({
1727      if let FontDirective::Asset(ref chosen_font) = font {
1728        merge_font((**chosen_font).clone());
1729      }
1730    }));
1731  }
1732  before_digest_closures.extend(options.before_digest);
1733  cons.before_digest = before_digest_closures;
1734  //
1735  // after_digest
1736  //
1737  let mut after_digest_closures = options.after_digest;
1738  // Perl: mathstyle => \&doVariablesizeOp — compute mathstyle at digest time
1739  if options.dynamic_mathstyle {
1740    after_digest_closures.push(after_digest_simple!(_args, {
1741      let state_font = lookup_font().unwrap();
1742      let is_display = state_font
1743        .get_mathstyle()
1744        .is_some_and(|s| s.as_ref() == "display");
1745      let mathstyle = if is_display { "display" } else { "text" };
1746      _args.set_property("mathstyle", Stored::from(mathstyle.to_string()));
1747    }));
1748  }
1749  if !options.nogroup {
1750    after_digest_closures.push(after_digest_simple!(_args, {
1751      egroup()?;
1752    }));
1753  }
1754  cons.after_digest = after_digest_closures;
1755  cons.before_construct = options.before_construct;
1756  cons.after_construct = options.after_construct;
1757  let presentation_for_font = presentation.to_owned();
1758  properties.insert(
1759    "font",
1760    Stored::FontDirective(FontDirective::Closure(
1761      if let Some(mathstyle) = options.mathstyle {
1762        Rc::new(move |_whatsit| {
1763          Ok(
1764            lookup_font()
1765              .unwrap()
1766              .merge(Font {
1767                mathstyle: Some(Cow::Owned(mathstyle.clone())),
1768                ..Font::default()
1769              })
1770              .specialize(&presentation_for_font),
1771          )
1772        })
1773      } else {
1774        Rc::new(move |_whatsit| Ok(lookup_font().unwrap().specialize(&presentation_for_font)))
1775      },
1776    )),
1777  );
1778
1779  cons.properties = Rc::new(move |_args| Ok(properties.clone()));
1780}
1781
1782//======================================================================
1783// Allocated registers.
1784// We ASSUME the same set of \count positions used by TeX & LaTeX
1785// for recording the next available position in \count,\dimen,\skip,\muskip.
1786
1787pub fn allocate_register(rtype: &str, cs: &str) -> Result<Option<String>> {
1788  let addr = match rtype {
1789    "\\count" => "\\count10",
1790    "\\dimen" => "\\count11",
1791    "\\skip" => "\\count12",
1792    "\\muskip" => "\\count13",
1793    "\\box" => "\\count14",
1794    "\\toks" => "\\count15",
1795    _ => "",
1796  };
1797  if !addr.is_empty() {
1798    // addr is a Register but MUST be stored as \count<#>
1799    if let Some(n) = lookup_number(addr) {
1800      // Perl Package.pm L617-622: the allocation counter picks the NEXT
1801      // unbound slot. If `\<type>N+1` is already an explicit DefRegister
1802      // (e.g. a system-allocated `\count10`, `\toks0`), advance past it
1803      // so the new register doesn't collide. Matches Perl's
1804      //   while ($STATE->isValueBound($loc)) { $next++; $loc = $type . $next; }
1805      let mut next = n.value_of() + 1;
1806      let mut loc = format!("{rtype}{next}");
1807      while is_value_bound(&loc, None) {
1808        next += 1;
1809        loc = format!("{rtype}{next}");
1810      }
1811      assign_value(addr, Number::new(next), Some(Scope::Global));
1812      Ok(Some(loc))
1813    } else {
1814      Ok(None)
1815    }
1816  } else {
1817    // Perl Package.pm L626-627: the error names the CS being allocated
1818    // (`Type $type is not an allocated register type, for ToString($cs)`).
1819    Error!(
1820      "misdefined",
1821      rtype,
1822      format!("Type {rtype} is not an allocated register type, for {cs}")
1823    );
1824    Ok(None)
1825  }
1826}