Skip to main content

latexml_core/
state.rs

1use std::{
2  borrow::Cow,
3  cell::RefCell,
4  collections::VecDeque,
5  fmt::{self, Display},
6  rc::Rc,
7};
8
9use once_cell::sync::Lazy;
10use regex::Regex;
11use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
12
13// expose Perl-style local assignments from state
14pub use crate::common::local_assignments::*;
15pub use crate::common::store::Stored; // reexport for convenience
16use crate::{
17  Digested, DigestedData,
18  alignment::Alignment,
19  common::{
20    BindingDispatcher, LabelMappingHook, ResolvingBindingDispatcher,
21    arena::{self, SymHashMap, SymStr},
22    dimension::Dimension,
23    error::{emit_warn, *},
24    float::Float,
25    font::Font,
26    glue::Glue,
27    model::{self, IndirectModel, Model, compute_indirect_model_aux},
28    muglue::MuGlue,
29    number::Number,
30    numeric_ops::{NumericOps, UNITY},
31  },
32  definition::{
33    Definition, ExpansionBody,
34    argument::ArgWrap,
35    conditional::ConditionalType,
36    constructor::Constructor,
37    expandable::{self, Expandable},
38    register::{Register, RegisterValue},
39  },
40  document::{resource::Resource, tag::TagOptions},
41  gullet, mouth, pin,
42  token::{Catcode, Token},
43  tokens::{TeXString, Tokens},
44  util::pathname,
45};
46
47static CODE_TEX_EXT: &str = ".code.tex";
48
49/// regex for *.tex and *.bib
50static TEX_OR_BIB_EXT_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\.(tex|bib)$").unwrap());
51/// Used in conversion to scaled points.
52///
53/// These are the float `sp`-per-unit ratios, kept for *display / scaling*
54/// consumers (pgf/graphics/hyperref divide by them). For dimension
55/// **construction** use [`convert_unit_ratio`] + `numeric_ops::fixpoint_unit`
56/// instead — exact integer arithmetic, bit-faithful to TeX (issue #127). Each
57/// value here equals `65536·num/den` of the matching `convert_unit_ratio` entry.
58pub static UNITS: Lazy<HashMap<String, f64>> = Lazy::new(|| {
59  map!(
60    "pt" => 65536.0,
61    "pc" => 12.0 * 65536.0,
62    "in" => 72.27 * 65536.0,
63    "bp" => 72.27 * 65536.0 / 72.0,
64    "px" => 72.27 * 65536.0 / 72.0,   // Assume px=bp ?
65    "cm" => 72.27 * 65536.0 / 2.54,
66    "mm" => 72.27 * 65536.0 / 2.54 / 10.0,
67    "dd" => 1238.0 * 65536.0 / 1157.0,
68    "cc" => 12.0 * 1238.0 * 65536.0 / 1157.0,
69    "sp" => 1.0
70  )
71});
72
73/// installation scope in the state_tables
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum Scope {
76  /// globally visible, does not expire
77  Global,
78  /// globally visible, but expires at the end of the current group
79  Local,
80  /// a named scope - visible only when explicitly activated
81  Named(SymStr),
82  /// in-place: replace the value in the frame it was last bound in, WITHOUT
83  /// recording a new undo entry (or globally, at the locked base frame, if it
84  /// was never bound). Perl `State.pm:175` `$scope eq 'inplace'` ("Special case
85  /// for `\box` & friends"). This is Knuth's "same level" reassignment / what
86  /// @xworld21 tentatively called `scope => 'definition'`: the binding keeps its
87  /// save-stack level, so a mutation rides exactly as long as the current
88  /// binding — persisting past the current group if the binding was made above
89  /// it, reverting with the group if it was made locally. Distinct from Global
90  /// (which promotes + wipes lower-frame undo) and Local (which pushes an outer
91  /// binding down a level). The Value-table fast path is `assign_value_inplace`.
92  InPlace,
93}
94
95/// the kinds of tables bookkept in the State
96#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
97pub enum TableName {
98  /// token meaning
99  Meaning,
100  /// all stateful values
101  Value,
102  /// catcode bindings
103  Catcode,
104  /// mathcode bindings
105  Mathcode,
106  /// sf code bindings
107  Sfcode,
108  /// lc code bindings
109  Lccode,
110  /// uc code bindings
111  Uccode,
112  /// del code bindings
113  Delcode,
114  /// stash of inactive named values
115  Stash,
116  /// active stash of named values
117  StashActive,
118}
119impl Display for TableName {
120  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121    write!(f, "{}", match self {
122      TableName::Meaning => "Meaning",
123      TableName::Value => "Value",
124      TableName::Catcode => "Catcode",
125      TableName::Mathcode => "Mathcode",
126      TableName::Sfcode => "Sfcode",
127      TableName::Lccode => "Lccode",
128      TableName::Uccode => "Uccode",
129      TableName::Delcode => "Delcode",
130      TableName::Stash => "Stash",
131      TableName::StashActive => "StashActive",
132    })
133  }
134}
135impl TableName {
136  /// provides all TableName variants. useful for iterating over all tables
137  pub fn variants() -> &'static [TableName] {
138    use self::TableName::*;
139    &[
140      Meaning,
141      Value,
142      Catcode,
143      Mathcode,
144      Sfcode,
145      Lccode,
146      Uccode,
147      Delcode,
148      Stash,
149      StashActive,
150    ]
151  }
152}
153
154/// High-level catcode profiles
155#[derive(Clone, Debug, PartialEq, Eq)]
156pub enum Catcodes {
157  /// the usual mainmatter catcodes (e.g. @ is other)
158  Standard,
159  /// the usual style catcodes (e.g. @ is letter)
160  Style,
161  /// left unspecified
162  None,
163}
164
165/// Ledger for stacked assignments
166pub type AssignmentCount = HashMap<SymStr, usize>;
167/// The `(table_name, key, value)` contents of a stored table of assignments
168pub type StashTable = Vec<(TableName, SymStr, Stored)>;
169#[derive(Debug, Clone, Default)]
170/// For each of several tables (being "value", "meaning", "catcode" or other space of names),
171/// each table maintains the bound values, and "undo" defines the stack frames
172pub struct UndoFrame {
173  locked:       bool,
174  meaning:      AssignmentCount,
175  value:        AssignmentCount,
176  catcode:      AssignmentCount,
177  mathcode:     AssignmentCount,
178  sfcode:       AssignmentCount,
179  lccode:       AssignmentCount,
180  uccode:       AssignmentCount,
181  delcode:      AssignmentCount,
182  stash:        AssignmentCount,
183  stash_active: AssignmentCount,
184}
185
186impl UndoFrame {
187  /// borrow the undo assignment counts for a given table name
188  pub fn table(&self, name: TableName) -> &AssignmentCount {
189    use self::TableName::*;
190    match name {
191      Meaning => &self.meaning,
192      Value => &self.value,
193      Catcode => &self.catcode,
194      Mathcode => &self.mathcode,
195      Sfcode => &self.sfcode,
196      Lccode => &self.lccode,
197      Uccode => &self.uccode,
198      Delcode => &self.delcode,
199      Stash => &self.stash,
200      StashActive => &self.stash_active,
201    }
202  }
203  /// mutably borrow the undo assignment counts for a given table name
204  pub fn table_mut(&mut self, name: TableName) -> &mut AssignmentCount {
205    use self::TableName::*;
206    match name {
207      Meaning => &mut self.meaning,
208      Value => &mut self.value,
209      Catcode => &mut self.catcode,
210      Mathcode => &mut self.mathcode,
211      Sfcode => &mut self.sfcode,
212      Lccode => &mut self.lccode,
213      Uccode => &mut self.uccode,
214      Delcode => &mut self.delcode,
215      Stash => &mut self.stash,
216      StashActive => &mut self.stash_active,
217    }
218  }
219}
220
221/// The type of values that are storable by the different namespaced "tables" in State.
222///
223/// There are tables for:
224///
225///  catcode: keys are char;
226///     Also, `math:char` =1 when `char` is active in math.
227///  mathcode, sfcode, lccode, uccode, delcode : are similar to catcode but store
228///    additional kinds codes per char (see TeX)
229///  value: keys are anything (typically a string, though) and value is the value associated with it
230///  meaning: The definition assocated with `key`, usually a control-sequence.
231///  stash & stash_active: support named scopes
232///      (see also activateScope & deactivateScope)
233pub type Table = HashMap<SymStr, VecDeque<Stored>>;
234
235/// The state efficiently bookkeeps the bindings in a TeX-like fashion.
236///
237/// Bindings associate data with keys (eg definitions with macro names)
238/// and respect TeX grouping; that is, an assignment is only in effect
239/// until the current group (opened by \bgroup) is closed (by \egroup).
240pub struct State {
241  // Tables
242  /// bookkeeps arbitrary Stored values
243  value:                       Table,
244  /// The definition assocated with a key, usually a control-sequence.
245  meaning:                     Table,
246  stash:                       Table,
247  stash_active:                Table,
248  catcode:                     Table,
249  mathcode:                    Table,
250  sfcode:                      Table,
251  lccode:                      Table,
252  uccode:                      Table,
253  delcode:                     Table,
254  // Table bookkeeping
255  undo:                        VecDeque<UndoFrame>,
256  // stateful runtime - data structures
257  /// the schema-derived model used for the current document
258  prefixes:                    HashMap<SymStr, bool>, // ?
259  pub tag_properties:          HashMap<SymStr, TagOptions>,
260  /// an optional indirect model for long-distance relationships
261  pub indirect_model:          Option<IndirectModel>,
262  /// Document-related resources declared during core conversion, pending until XML is finalized
263  pub pending_resources:       Vec<Resource>,
264  // stateful runtime - simple fields
265  // TODO: Maybe group these in a "SessionFlags" struct?
266  //       we can then reset that if we reimplement a daemon app
267  pub verbosity:               i32,
268  pub input_encoding:          Option<String>,
269  // strict: bool,
270  // include_comments: bool,
271  /// Seed only for the group-scoped `SEARCHPATHS` value-table list (read once at
272  /// construction to seed it); live lookups go through [`get_search_paths`].
273  pub search_paths:            VecDeque<String>,
274  /// Seed only for the group-scoped `GRAPHICSPATHS` value-table list (read once at
275  /// construction to seed it); live lookups go through [`get_graphics_paths`].
276  pub graphics_paths:          VecDeque<String>,
277  // include_styles: bool,
278  /// flag to disable math parsing
279  pub nomathparse:             bool,
280  /// flag enabling source-locator (`--source-map`) tracking + emission.
281  /// Off by default; gates BOTH the per-token start capture and the
282  /// per-element `data-sourcepos` stamping so a normal conversion pays
283  /// nothing. See `docs/performance/SOURCE_PROVENANCE.md`.
284  pub source_map:              bool,
285  /// Document-level `sources` table for the source-map feature: ordered
286  /// list of source files seen, index = the integer `tag` emitted in
287  /// `data-sourcepos` (Source-Map-v3 `sources` style — never an inlined
288  /// path). Populated lazily via `source_tag()` only when `source_map` is
289  /// on. See `docs/performance/SOURCE_PROVENANCE.md` §0.1.
290  pub source_table:            Vec<SymStr>,
291  /// Read-log of every *named* source opened through `Mouth::create`
292  /// (file paths and cached-content names; literal/anonymous mouths are
293  /// not named, so not recorded). Distinct from `source_table`, which
294  /// is populated lazily at *document-construction* time and filters to
295  /// user sources — this log is complete and available right after a
296  /// digest, which the LSP server's warm-cache dependency snapshot
297  /// relies on (`lsp_server/overlay.rs::warmup_dep_snapshot`).
298  pub opened_sources:          HashSet<SymStr>,
299  // TODO: We can make this a Vec<BindingDispatcher> if we want to accumulate more definitions
300  /// The installed binding-resolution chain — reports the source path it loaded
301  /// from (for a `.rhai` file binding), or `None` for a compiled-in binding.
302  pub bindings_dispatch:       Option<ResolvingBindingDispatcher>,
303  /// Auxiliary convenience -- extra dispatch
304  pub extra_bindings_dispatch: Option<BindingDispatcher>,
305  /// All `(name, ext)` pairs for compile-time bindings the dispatchers can
306  /// load, stacked one slice per registered dispatcher. Populated at
307  /// startup by each binding crate via `add_binding_names`, so both
308  /// `latexml_package` and `latexml_contrib` contribute their classes/
309  /// styles/defs/pools to the fallback pool. Consumed by:
310  /// - `find_file(notex=true)` to resolve compile-time bindings without touching the filesystem.
311  /// - `load_class`'s Perl-parity prefix-match fallback (Package.pm L2702-2706) via the
312  ///   `get_class_binding_names()` filtered view.
313  pub binding_names:           Vec<&'static [(&'static str, &'static str)]>,
314  /// Perl: LABEL_MAPPING_HOOK — closure mapping (label, counter, norefnum) -> (refnum, id)
315  pub label_mapping_hook:      Option<LabelMappingHook>,
316}
317// SAFETY: `State` holds `Rc`/`RefCell`/`libxml::tree::Node` (!Send). Marked
318// Send so callers can build it on one thread and then transition to another
319// thread before any use. After first use, State MUST NOT cross thread
320// boundaries (all `use_*_state()` helpers use a `#[thread_local]` switcher).
321// Violating this contract would race libxml2's reference counts → UAF/UB.
322// State is deliberately NOT Sync: no two threads may alias the same State.
323unsafe impl Send for State {}
324
325impl Default for State {
326  fn default() -> Self {
327    let top_frame = UndoFrame {
328      locked: true,
329      ..UndoFrame::default()
330    };
331    let mut undo_vdq = VecDeque::new();
332    undo_vdq.push_front(top_frame);
333
334    State {
335      // Tables — pre-size the two largest to absorb dump load. The
336      // `meaning` table receives 109,863 entries from latex.dump
337      // alone, so without pre-sizing it doubles 5+ times during
338      // dump load (each rehash is O(N)). `value` receives several
339      // thousand register/state-key entries through the lifecycle.
340      // Effective capacity (FxHashMap, 0.875 load factor): 131072 → ~115k.
341      value:                   HashMap::with_capacity_and_hasher(8_192, Default::default()),
342      meaning:                 HashMap::with_capacity_and_hasher(131_072, Default::default()),
343      stash:                   HashMap::default(),
344      stash_active:            HashMap::default(),
345      // Char-keyed tables: ASCII alphabet + a smattering of high-codepoint
346      // entries get installed (textcomp + ts1enc.dfu populate ~200-300
347      // entries each). Pre-size to 512 to skip the 8→16→…→256→512
348      // doubling chain on startup.
349      catcode:                 HashMap::with_capacity_and_hasher(512, Default::default()),
350      mathcode:                HashMap::with_capacity_and_hasher(512, Default::default()),
351      sfcode:                  HashMap::with_capacity_and_hasher(512, Default::default()),
352      lccode:                  HashMap::with_capacity_and_hasher(512, Default::default()),
353      uccode:                  HashMap::with_capacity_and_hasher(512, Default::default()),
354      delcode:                 HashMap::with_capacity_and_hasher(512, Default::default()),
355      // Table bookkeeping
356      undo:                    undo_vdq,
357      // stateful runtime - data structures
358      prefixes:                HashMap::default(),
359      tag_properties:          HashMap::default(),
360      indirect_model:          None,
361      pending_resources:       Vec::new(),
362      // stateful runtime - simple fields
363      verbosity:               0,
364      input_encoding:          None,
365      // strict: false,
366      // include_comments: true,
367      search_paths:            VecDeque::new(),
368      graphics_paths:          VecDeque::new(),
369      // include_styles: false,
370      nomathparse:             false,
371      source_map:              false,
372      source_table:            Vec::new(),
373      opened_sources:          HashSet::default(),
374      bindings_dispatch:       None,
375      extra_bindings_dispatch: None,
376      binding_names:           Vec::new(),
377      label_mapping_hook:      None,
378    }
379  }
380}
381
382#[thread_local]
383static STD_STATE: Lazy<RefCell<State>> = Lazy::new(|| {
384  RefCell::new(State::new(StateOptions {
385    catcodes: Some(Catcodes::Standard),
386    ..StateOptions::default()
387  }))
388});
389#[thread_local]
390static STY_STATE: Lazy<RefCell<State>> = Lazy::new(|| {
391  RefCell::new(State::new(StateOptions {
392    catcodes: Some(Catcodes::Style),
393    ..StateOptions::default()
394  }))
395});
396#[thread_local]
397static STATE: Lazy<RefCell<State>> = Lazy::new(|| {
398  RefCell::new(State::new(StateOptions {
399    catcodes: Some(Catcodes::Standard),
400    ..StateOptions::default()
401  }))
402});
403
404/// Eagerly initialize this thread's `STD_STATE`/`STY_STATE` catcode-regime
405/// templates. They are accessed mid-conversion on catcode switches
406/// (`\makeatletter`, verbatim, …); each one's `Lazy` initializer runs
407/// `State::new`, which interns via the arena. Forcing them at conversion
408/// entry — AFTER [`arena::force_init`](crate::common::arena::force_init) —
409/// keeps them from initializing re-entrantly mid-expansion, the macOS
410/// `#[thread_local]` hazard behind issue #217. (The active `STATE` is
411/// already forced by `set_state` in `Core::new`.) No behavioral change on
412/// Linux.
413pub(crate) fn force_init() {
414  Lazy::force(&STD_STATE);
415  Lazy::force(&STY_STATE);
416}
417
418macro_rules! state {
419  () => {
420    (*STATE).borrow()
421  };
422}
423macro_rules! state_mut {
424  () => {
425    (*STATE).borrow_mut()
426  };
427}
428macro_rules! sty_state_mut {
429  () => {
430    (*STY_STATE).borrow_mut()
431  };
432}
433macro_rules! std_state_mut {
434  () => {
435    (*STD_STATE).borrow_mut()
436  };
437}
438
439/// state fields allowed for customization during construction
440#[derive(Default)]
441pub struct StateOptions {
442  pub model:            Option<Model>,
443  pub verbosity:        Option<i32>,
444  pub strict:           Option<bool>,
445  pub include_comments: Option<bool>,
446  pub include_styles:   Option<bool>,
447  pub nomathparse:      Option<bool>,
448  pub source_map:       Option<bool>,
449  pub documentid:       Option<String>,
450  pub search_paths:     Option<Vec<String>>,
451  pub graphics_paths:   Option<Vec<String>>,
452  pub catcodes:         Option<Catcodes>,
453  pub input_encoding:   Option<String>,
454}
455
456// Public interface: package-access methods, for an implied thread-local singleton STATE
457
458// Private interface: struct-access methods, for a concrete piece of State data
459
460impl State {
461  pub fn new(options: StateOptions) -> Self {
462    use crate::token::Catcode::*;
463
464    // Setup default catcodes.
465    let catcode_profile = match options.catcodes {
466      None => Catcodes::Standard,
467      Some(cp) => cp,
468    };
469
470    let mut catcodes: HashMap<char, Catcode> = HashMap::default();
471    match catcode_profile {
472      Catcodes::Standard | Catcodes::Style => {
473        catcodes.insert('\\', ESCAPE);
474        catcodes.insert('{', BEGIN);
475        catcodes.insert('}', END);
476        catcodes.insert('$', MATH);
477        catcodes.insert('&', ALIGN);
478        catcodes.insert('\r', EOL);
479        catcodes.insert('#', PARAM);
480        catcodes.insert('^', SUPER);
481        catcodes.insert('_', SUB);
482        catcodes.insert(' ', SPACE);
483        catcodes.insert('\t', SPACE);
484        catcodes.insert('%', COMMENT);
485        catcodes.insert('~', ACTIVE);
486        // NUL (`\^^@`, U+0000): Perl LaTeXML's default is catcode 12 (OTHER),
487        // NOT the TeXbook's 9 (IGNORE). We follow Perl (ground truth) so that
488        // `\^^@`/`` `^^@ `` reads code 0 (TeXbook 9 would *drop* the NUL token,
489        // making `` `^^@ `` skip to the next token — `\relax` etc. — and return
490        // a bogus code; xint's `\romannumeral`&&@` expansion idiom needs 0).
491        // Real-world bbl files (e.g. astro-ph0004127's spie4012-01a.bbl) carry
492        // stray NULs from BibTeX `\"u`-mangling; as OTHER they become harmless
493        // literal chars (stripped at XML serialization), matching Perl —
494        // crucially NOT ESCAPE, so no bogus `\uninger`-style CS forms. An
495        // explicit `\catcode`^^Q=9` (user/package) is still honored; only the
496        // *default* changes.
497        catcodes.insert('\0', OTHER);
498        catcodes.insert('\u{000c}', ACTIVE);
499        for c in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".chars() {
500          catcodes.insert(c, LETTER);
501        }
502      },
503      Catcodes::None => {},
504    }
505    if catcode_profile == Catcodes::Style {
506      catcodes.insert('@', LETTER);
507    }
508
509    let mut value_table = HashMap::default();
510    let mut specials_vdq = VecDeque::new();
511    specials_vdq.push_front(Stored::Chars(Box::new([
512      '^', '_', '~', '&', '$', '#', '\'',
513    ])));
514    value_table.insert(arena::pin_static("SPECIALS"), specials_vdq);
515
516    let mut catcodes_typed: Table = HashMap::default();
517    for (k, v) in catcodes {
518      let mut vdq = VecDeque::new();
519      vdq.push_front(Stored::Catcode(v));
520      catcodes_typed.insert(arena::pin_char(k), vdq);
521    }
522
523    // Basic defaults
524    if let Some(model) = options.model {
525      model::set_model(model);
526    };
527    let verbosity = options.verbosity.unwrap_or(0);
528    // let strict = options.strict.unwrap_or(false);
529    // INCLUDE_COMMENTS: Perl defaults to true (Core.pm L143).
530    // T_COMMENT tokens are now properly converted to XML comment nodes
531    // via Document::insert_comment using raw libxml2 FFI.
532    // Note: Only set when explicitly specified, because STY_STATE/STD_STATE
533    // use default options and state rotation (swap) would overwrite the
534    // main state's INCLUDE_COMMENTS value.
535    let include_comments = options.include_comments;
536    // let include_styles = options.include_styles.unwrap_or(false);
537    let nomathparse = options.nomathparse.unwrap_or(false);
538    let source_map = options.source_map.unwrap_or(false);
539
540    // Perl Core.pm L50-52: `SEARCHPATHS => [map { pathname_absolute(...) } '.',
541    // @searchpaths]` — the current working directory is ALWAYS searched, and
542    // FIRST (it takes precedence over the `--path` dirs). A `--path` entry
543    // ending in `//` is the kpsewhich recursive-search marker; preserve it
544    // across canonicalization (strip it, canonicalize the base, re-append) so
545    // `candidate_pathnames` can expand the marker to the whole subtree.
546    let search_paths: VecDeque<String> = std::iter::once(String::from("."))
547      .chain(options.search_paths.into_iter().flatten())
548      .map(|p| match p.strip_suffix("//") {
549        Some(base) => format!("{}//", pathname::absolute(&pathname::canonical(base))),
550        None => pathname::absolute(&pathname::canonical(&p)),
551      })
552      .collect();
553    let graphics_paths = match options.graphics_paths {
554      None => VecDeque::new(),
555      Some(paths) => paths
556        .iter()
557        .map(|p| pathname::absolute(&pathname::canonical(p)))
558        .collect(),
559    };
560
561    let mut state = State {
562      value: value_table,
563      catcode: catcodes_typed,
564      verbosity,
565      // strict,
566      // include_comments,
567      search_paths,
568      graphics_paths,
569      // include_styles,
570      input_encoding: options.input_encoding,
571      nomathparse,
572      source_map,
573      ..State::default()
574    };
575    // INITEX-equivalent defaults — mirror Perl `State.pm:128-137`.
576    // Sets letter/digit mathcodes (class 7, family 1 for letters / 0 for digits),
577    // upper/lowercase mappings, and sfcode=999 for uppercase letters. Without
578    // these, dump-load path leaves letter mathcodes unset (plain.dump.txt only
579    // captures the 57 plain.tex OVERRIDES), so `\cal abc` math falls through
580    // the text path and loses meaning/role attributes. NODUMP path used to set
581    // these via plain_base.rs L17-41 only — not Perl-faithful since INITEX
582    // owns these (TeXbook ch.17 p309). Setting them here makes both paths
583    // consistent and matches Perl's State::new behaviour.
584    for c in b'0'..=b'9' {
585      state.assign_internal(
586        TableName::Mathcode,
587        arena::pin_char(c as char),
588        Stored::Charcode(0x7000 + c as u16),
589        None,
590      );
591    }
592    for c in b'a'..=b'z' {
593      let big = c - 32;
594      state.assign_internal(
595        TableName::Mathcode,
596        arena::pin_char(c as char),
597        Stored::Charcode(0x7100 + c as u16),
598        None,
599      );
600      state.assign_internal(
601        TableName::Mathcode,
602        arena::pin_char(big as char),
603        Stored::Charcode(0x7100 + big as u16),
604        None,
605      );
606      state.assign_internal(
607        TableName::Uccode,
608        arena::pin_char(c as char),
609        Stored::Charcode(big as u16),
610        None,
611      );
612      state.assign_internal(
613        TableName::Lccode,
614        arena::pin_char(big as char),
615        Stored::Charcode(c as u16),
616        None,
617      );
618      state.assign_internal(
619        TableName::Sfcode,
620        arena::pin_char(big as char),
621        Stored::Charcode(999),
622        None,
623      );
624    }
625    // TODO: should these be *fields* in state or really as in Perl - globally assigned values?
626    state.assign_value(
627      "DOCUMENTID",
628      options.documentid.unwrap_or_default(),
629      Some(Scope::Global),
630    );
631    // Perl Core.pm L143: assignValue(INCLUDE_COMMENTS => ..., 'global')
632    // Only set when explicitly specified (not for STY_STATE/STD_STATE defaults)
633    if let Some(ic) = include_comments {
634      state.assign_value("INCLUDE_COMMENTS", ic, Some(Scope::Global));
635    }
636    // Perl Core.pm L47: INCLUDE_PATH_PIS (default true) — emit searchpath PIs
637    state.assign_value("INCLUDE_PATH_PIS", true, Some(Scope::Global));
638    // Perl Core.pm L43: STRICT (default false)
639    if let Some(strict) = options.strict {
640      state.assign_value("STRICT", strict, Some(Scope::Global));
641    }
642    // Perl Core.pm L55-57: `--includestyles` fans out to BOTH INCLUDE_STYLES
643    // and INCLUDE_CLASSES (both default 0), so it raw-loads .sty packages AND
644    // .cls classes from the search path. Faithful to Perl — its own
645    // `# … accept both classes and styles?` hedge notwithstanding, the code
646    // sets both, and INCLUDE_CLASSES is what unlocks raw .cls in LoadClass.
647    if let Some(include_styles) = options.include_styles {
648      state.assign_value("INCLUDE_STYLES", include_styles, Some(Scope::Global));
649      state.assign_value("INCLUDE_CLASSES", include_styles, Some(Scope::Global));
650    }
651    // Perl Core.pm L62: NOMATHPARSE
652    state.assign_value("NOMATHPARSE", nomathparse, Some(Scope::Global));
653    // Perl Core.pm L61: PERL_INPUT_ENCODING (default utf-8)
654    let enc = state.input_encoding.as_deref().unwrap_or("utf-8");
655    state.assign_value(
656      "PERL_INPUT_ENCODING",
657      Stored::String(arena::pin(enc)),
658      Some(Scope::Global),
659    );
660
661    // Perl Core.pm L53: $state->assignValue(GRAPHICSPATHS => [map {…} @{$opts{graphicspaths}}])
662    // Mirror with a VecDequeStored of String entries; subsequent push/unshift
663    // operations in `\graphicspath`, `\svgpath`, and Core.pm-equivalent source
664    // directory prepends will append/prepend to this same list.
665    if !state.graphics_paths.is_empty() {
666      let vdq: VecDeque<Stored> = state
667        .graphics_paths
668        .iter()
669        .map(|p| Stored::String(arena::pin(p)))
670        .collect();
671      state.assign_internal(
672        TableName::Value,
673        arena::pin("GRAPHICSPATHS"),
674        Stored::VecDequeStored(vdq),
675        Some(Scope::Global),
676      );
677    }
678
679    // SEARCHPATHS mirrors GRAPHICSPATHS: a group-scoped value list (Perl
680    // `AssignValue(SEARCHPATHS…)`, local-by-default) rather than a plain field,
681    // so an `\import`/`\subimport` group reverts its change at `}` while a
682    // package's global add persists (#561). Seeded Global from the `search_paths`
683    // field (`.` + the `--path` dirs); `get_search_paths` reads the value table.
684    {
685      let vdq: VecDeque<Stored> = state
686        .search_paths
687        .iter()
688        .map(|p| Stored::String(arena::pin(p)))
689        .collect();
690      state.assign_internal(
691        TableName::Value,
692        arena::pin("SEARCHPATHS"),
693        Stored::VecDequeStored(vdq),
694        Some(Scope::Global),
695      );
696    }
697
698    state
699  }
700
701  /// borrow/get the named table
702  pub fn table(&self, name: TableName) -> &Table {
703    use self::TableName::*;
704    match name {
705      Meaning => &self.meaning,
706      Value => &self.value,
707      Catcode => &self.catcode,
708      Mathcode => &self.mathcode,
709      Sfcode => &self.sfcode,
710      Lccode => &self.lccode,
711      Uccode => &self.uccode,
712      Delcode => &self.delcode,
713      Stash => &self.stash,
714      StashActive => &self.stash_active,
715    }
716  }
717  /// mutably borrow/get the named table
718  pub fn table_mut(&mut self, name: TableName) -> &mut Table {
719    use self::TableName::*;
720    match name {
721      Meaning => &mut self.meaning,
722      Value => &mut self.value,
723      Catcode => &mut self.catcode,
724      Mathcode => &mut self.mathcode,
725      Sfcode => &mut self.sfcode,
726      Lccode => &mut self.lccode,
727      Uccode => &mut self.uccode,
728      Delcode => &mut self.delcode,
729      Stash => &mut self.stash,
730      StashActive => &mut self.stash_active,
731    }
732  }
733
734  /// Perl DumpFile equivalent: Take a snapshot of the current state.
735  /// Returns a HashMap mapping (table_name, key) → Stored value.
736  /// Only captures the front (current) value of each key.
737  /// Used before processing latex.ltx to diff what changed.
738  pub fn snapshot(&self) -> rustc_hash::FxHashMap<(TableName, SymStr), Stored> {
739    let tables = [
740      TableName::Value,
741      TableName::Meaning,
742      TableName::Catcode,
743      TableName::Mathcode,
744      TableName::Sfcode,
745      TableName::Lccode,
746      TableName::Uccode,
747      TableName::Delcode,
748    ];
749    let mut snap = rustc_hash::FxHashMap::default();
750    for &tname in &tables {
751      let table = self.table(tname);
752      for (key, values) in table {
753        if let Some(front) = values.front() {
754          snap.insert((tname, *key), front.clone());
755        }
756      }
757    }
758    snap
759  }
760
761  /// Perl DumpFile equivalent: Compute the diff between current state and a snapshot.
762  /// Returns only entries that CHANGED since the snapshot was taken.
763  /// Skips entries that contain closures (Primitive, Constructor, Conditional)
764  /// since those can't be serialized — they come from Rust engine code.
765  pub fn diff_from_snapshot(
766    &self,
767    snap: &rustc_hash::FxHashMap<(TableName, SymStr), Stored>,
768  ) -> Vec<(TableName, SymStr, Stored)> {
769    let tables = [
770      TableName::Value,
771      TableName::Meaning,
772      TableName::Catcode,
773      TableName::Mathcode,
774      TableName::Sfcode,
775      TableName::Lccode,
776      TableName::Uccode,
777      TableName::Delcode,
778    ];
779    let mut diff = Vec::new();
780    for &tname in &tables {
781      let table = self.table(tname);
782      for (key, values) in table {
783        if let Some(current) = values.front() {
784          let key_pair = (tname, *key);
785          let changed = match snap.get(&key_pair) {
786            None => true, // new entry
787            Some(prev) => {
788              // Compare string representations (cheap approximation of Perl's dump-based diff)
789              format!("{:?}", current) != format!("{:?}", prev)
790            },
791          };
792          if changed && is_serializable(current) {
793            diff.push((tname, *key, current.clone()));
794          }
795        }
796      }
797    }
798    diff
799  }
800
801  // needed for assign_internal, so keeping it as a object method
802  /// gets the current value of a named prefix
803  pub fn get_prefix(&self, prefix: &str) -> bool { self.get_prefix_sym(arena::pin(prefix)) }
804
805  /// `get_prefix` variant with a pre-pinned SymStr (see `crate::pin!`) —
806  /// `assign_internal` probes the `global` prefix on every unscoped
807  /// assignment, so the per-call `arena::pin` there was pure overhead.
808  pub fn get_prefix_sym(&self, prefix: SymStr) -> bool {
809    match self.prefixes.get(&prefix) {
810      Some(b) => *b,
811      _ => false,
812    }
813  }
814
815  pub(crate) fn assign_internal(
816    &mut self,
817    table_name: TableName,
818    key: SymStr,
819    value: Stored,
820    mut scope_opt: Option<Scope>,
821  ) {
822    // hotcode lookupDefinition for \globaldefs,
823    // since this is called extremely often and should be highly standardized.
824    // TeX semantics: positive → all assignments global, negative → \global
825    // ignored, zero → no override. `\globaldefs` is a Number register, so the
826    // stored variant is `Stored::Number`, NOT `Stored::Int` — Perl's `==`
827    // coerces both, Rust must unwrap explicitly. Perl `State.pm:144-151` uses
828    // strict `==1`/`==-1`; we slightly broaden to TeX's sign-based rule
829    // (matches behavior for the canonical `\globaldefs=1`/`\globaldefs=-1`
830    // uses while also handling rare `\globaldefs=2` etc).
831    // `Scope::Named(_)` and `Scope::InPlace` are preserved per Perl's "ONLY
832    // override global/local/undef" rule (State.pm:146 — `$scope ne 'global' &&
833    // $scope ne 'local'` short-circuits the override): an in-place mutation of
834    // the existing binding is NOT re-scoped by `\globaldefs`.
835    // Without this: pgfplots' `\pgfplots@pop@next@legend`
836    // (`\def\foo{{\globaldefs=1 \let\x=\relax}}`) silently drops the `\let`
837    // on group exit, leaving `\pgfplots@curlegend`/`@curplotlist` undefined
838    // and looping `\pgfplots@createlegend` at the digest wall-clock cap.
839    let preserve = matches!(scope_opt, Some(Scope::Named(_) | Scope::InPlace));
840    if !preserve
841      && let Some(globaldefs) = self.value.get(&pin!("\\globaldefs"))
842      && let Some(global_value) = globaldefs.front()
843    {
844      let int_value: i64 = match *global_value {
845        Stored::Int(v) => v,
846        Stored::Number(n) => n.0,
847        _ => 0,
848      };
849      if int_value > 0 {
850        scope_opt = Some(Scope::Global);
851      } else if int_value < 0 {
852        scope_opt = Some(Scope::Local);
853      }
854    }
855    // TRACE: watch for cleanup:w
856    // regular check, local scope is default, unless a global prefix is set
857    let scope = match scope_opt {
858      Some(s) => s,
859      None => {
860        if self.get_prefix_sym(pin!("global")) {
861          Scope::Global
862        } else {
863          Scope::Local
864        }
865      },
866    };
867    match scope {
868      Scope::Global => {
869        let mut undo_count = 0;
870
871        // Remove bindings made in all frames down-to & including the next lower locked frame
872        let mut last_frame = None;
873        for frame in &mut self.undo {
874          let is_locked = frame.locked;
875          let frame_table = frame.table_mut(table_name);
876          if let Some(n) = frame_table.remove(&key) {
877            undo_count += n;
878          }
879          last_frame = Some(frame);
880          if is_locked {
881            break;
882          }
883        }
884        // whatever is left -- if anything -- should be bindings below the locked frame.
885        if let Some(frame) = last_frame {
886          frame.table_mut(table_name).insert(key, 1); // Note that there's only one
887          // value in the stack, now
888        }
889
890        // Undo the bindings, if `key` was bound in this frame
891        let state_table = self.table_mut(table_name);
892        if let Some(defs) = state_table.get_mut(&key) {
893          for _ in 1..=undo_count {
894            defs.pop_front();
895          }
896        }
897
898        let table_entry = state_table.entry(key).or_default();
899        table_entry.push_front(value);
900      },
901      Scope::Local => {
902        // Again, split the logic as 1) bookkeeping in undo, then 2) operations in state_tables
903        let mut is_replace = false;
904        // 1. Undo mutable logic
905        if let Some(current_frame) = self.undo.front_mut() {
906          let current_frame_table = current_frame.table_mut(table_name);
907
908          is_replace = current_frame_table.get(&key).unwrap_or(&0) > &0;
909          if is_replace { // If the value was previously assigned in this frame
910            // we do this in 2.1, then proceed to 2.2
911          } else {
912            // Otherwise, push new value & set 1 to be undone
913            current_frame_table.insert(key, 1);
914            //  And push new binding in 2.2
915          }
916        }
917        // 2. state_table mutable logic
918        let state_table = self.table_mut(table_name);
919        let defs = state_table.entry(key).or_default();
920        if is_replace {
921          // 2.1. Replace the value, i.e. remove existing one
922          defs.pop_front();
923        }
924        // 2.2 Add new value
925        defs.push_front(value);
926      },
927      Scope::InPlace => {
928        // Perl `State.pm:175`: replace the front value in the frame it was last
929        // bound in, adding NO undo entry, so the mutation keeps the binding's
930        // save-stack level (the `\box` / same-level semantics). If the key was
931        // never bound, seed it at the locked base frame (Perl's "push globally"
932        // fallback). Mirrors the Value-table `assign_value_inplace` fast path.
933        let state_table = self.table_mut(table_name);
934        if let Some(defs) = state_table.get_mut(&key)
935          && let Some(front) = defs.front_mut()
936        {
937          *front = value;
938        } else {
939          state_table.entry(key).or_default().push_front(value);
940          for frame in &mut self.undo {
941            if frame.locked {
942              frame.table_mut(table_name).insert(key, 1);
943              break;
944            }
945          }
946        }
947      },
948      Scope::Named(scope_name) => {
949        // initialize stash if empty
950        let needs_init = match self.stash.get(&scope_name) {
951          None => true,
952          Some(v) => v.is_empty(),
953        };
954        if needs_init {
955          self.assign_internal(
956            TableName::Stash,
957            scope_name,
958            Stored::Stash(Vec::new()),
959            Some(Scope::Global),
960          );
961        }
962        if let Some(Stored::Stash(stash)) =
963          self.stash.get_mut(&scope_name).as_mut().unwrap().get_mut(0)
964        {
965          stash.push((table_name, key, value.clone()));
966        }
967        let has_active = match self.stash_active.get(&scope_name) {
968          None => false,
969          Some(v) => !v.is_empty(),
970        };
971        if has_active {
972          self.assign_internal(table_name, key, value, Some(Scope::Local));
973        }
974      },
975    }
976  }
977
978  /// assigns a `Stored` value at the given key and scope
979  pub fn assign_value<T: Into<Stored>, S: Into<Option<Scope>>>(
980    &mut self,
981    key: &str,
982    value: T,
983    scope: S,
984  ) {
985    let value = value.into();
986    let scope = scope.into();
987    let key_sym = arena::pin(key);
988    self.assign_internal(TableName::Value, key_sym, value, scope);
989  }
990  //======================================================================
991  /// fetches a Stored value at the given key, from the Value table
992  pub fn lookup_value(&self, key: &str) -> Option<&Stored> {
993    self.lookup_value_sym(arena::pin(key))
994  }
995  pub fn lookup_value_sym(&self, key: SymStr) -> Option<&Stored> {
996    match self.value.get(&key) {
997      None => None,
998      Some(vvec) => match vvec.front() {
999        None | Some(Stored::None) => None,
1000        Some(other) => Some(other),
1001      },
1002    }
1003  }
1004
1005  /// mutably borrows a Stored value at the given key, from the Value table
1006  pub fn lookup_value_mut(&mut self, key: &str) -> Option<&mut Stored> {
1007    match self.value.get_mut(&arena::pin(key)) {
1008      None => None,
1009      Some(vvec) => match vvec.front_mut() {
1010        None | Some(Stored::None) => None,
1011        Some(other) => Some(other),
1012      },
1013    }
1014  }
1015  /// like `lookup_value` but only recognizes `Stored::VecDequeStored`
1016  pub fn lookup_vecdeque(&self, key: &str) -> Option<&VecDeque<Stored>> {
1017    match self.lookup_value(key) {
1018      None | Some(Stored::None) => None,
1019      Some(v) => v.into(),
1020    }
1021  }
1022  pub fn lookup_font_info(&self, key: &Token) -> Result<Option<&Stored>> {
1023    let key_str = match lookup_definition(key)? {
1024      Some(defn) => {
1025        s!("fontinfo_{}", defn.get_cs_name())
1026      },
1027      _ => {
1028        s!("fontinfo_{key}")
1029      },
1030    };
1031    Ok(self.lookup_value(&key_str))
1032  }
1033  /// manage a (global) hash of values
1034  pub fn lookup_mapping(&self, map: &str, key: &str) -> Option<&Stored> {
1035    self.lookup_mapping_sym(arena::pin(map), key)
1036  }
1037  pub fn lookup_mapping_sym(&self, map_sym: SymStr, key: &str) -> Option<&Stored> {
1038    match self.value.get(&map_sym) {
1039      None => None,
1040      Some(map_vec) => match map_vec.front() {
1041        Some(Stored::HashStored(h)) => h.get(key),
1042        _ => None,
1043      },
1044    }
1045  }
1046
1047  pub fn lookup_mapping_keys(&self, map: &str) -> Vec<SymStr> {
1048    let map_sym = arena::pin(map);
1049    match self.value.get(&map_sym) {
1050      None => Vec::new(),
1051      Some(map_vec) => match map_vec.front() {
1052        Some(Stored::HashStored(h)) => h.keys().copied().collect(),
1053        _ => Vec::new(),
1054      },
1055    }
1056  }
1057
1058  pub fn lookup_stacked_values(&self, key: &str) -> Vec<&Stored> {
1059    let key_sym = arena::pin(key);
1060    self.lookup_stacked_values_sym(key_sym)
1061  }
1062
1063  pub fn lookup_stacked_values_sym(&self, key: SymStr) -> Vec<&Stored> {
1064    if let Some(vdq) = self.value.get(&key) {
1065      vdq.iter().collect::<Vec<&Stored>>()
1066    } else {
1067      Vec::new()
1068    }
1069  }
1070
1071  fn lookup_definition_internal(&self, key: &Token) -> Option<&VecDeque<Stored>> {
1072    let cc = key.get_catcode();
1073    let name = key.get_sym();
1074    let lookupname: Option<SymStr> = if (cc == Catcode::ACTIVE) || (cc == Catcode::CS) {
1075      // `\special_relax`-family tokens all resolve under the bare `\special_relax`
1076      // name (shared `\relax` meaning; identity lives in `noexpand_shadowed`).
1077      if name == pin!("") {
1078        None
1079      } else {
1080        Some(meaning_key(key))
1081      }
1082    } else {
1083      key.get_executable_primitive_name().map(arena::pin)
1084    };
1085
1086    if let Some(lname) = lookupname {
1087      self.meaning.get(&lname)
1088    } else {
1089      None
1090    }
1091  }
1092  pub fn ensure_tag_property(&mut self, tag: SymStr) -> &mut TagOptions {
1093    self.tag_properties.entry(tag).or_default()
1094  }
1095}
1096
1097#[derive(Debug, Copy, Clone, PartialEq)]
1098enum RotateState {
1099  Main,
1100  Std,
1101  Sty,
1102}
1103// Perf/safety: `Cell<RotateState>` instead of `static mut` — RotateState is
1104// Copy, so Cell gives us Get/Set with no unsafe, preserving the thread_local
1105// single-threaded access guarantee without requiring unsafe at each call site.
1106#[thread_local]
1107static STATE_IN_USE: std::cell::Cell<RotateState> = std::cell::Cell::new(RotateState::Main);
1108
1109pub fn use_sty_state() {
1110  if STATE_IN_USE.get() != RotateState::Sty {
1111    let mut sty_state = sty_state_mut!();
1112    let mut main_state = state_mut!();
1113    std::mem::swap(&mut *sty_state, &mut *main_state);
1114    STATE_IN_USE.set(RotateState::Sty);
1115  }
1116}
1117pub fn use_std_state() {
1118  if STATE_IN_USE.get() != RotateState::Std {
1119    let mut std_state = std_state_mut!();
1120    let mut main_state = state_mut!();
1121    std::mem::swap(&mut *std_state, &mut *main_state);
1122    STATE_IN_USE.set(RotateState::Std);
1123  }
1124}
1125pub fn use_main_state() {
1126  match STATE_IN_USE.get() {
1127    RotateState::Sty => {
1128      let mut sty_state = sty_state_mut!();
1129      let mut main_state = state_mut!();
1130      std::mem::swap(&mut *sty_state, &mut *main_state);
1131      STATE_IN_USE.set(RotateState::Main);
1132    },
1133    RotateState::Std => {
1134      let mut std_state = std_state_mut!();
1135      let mut main_state = state_mut!();
1136      std::mem::swap(&mut *std_state, &mut *main_state);
1137      STATE_IN_USE.set(RotateState::Main);
1138    },
1139    RotateState::Main => {},
1140  };
1141}
1142
1143/// Free every definition/register/box this thread accumulated, returning
1144/// all three `State` singletons (`STATE`, `STD_STATE`, `STY_STATE`) to a
1145/// fresh, empty baseline and the rotation to `Main`.
1146///
1147/// **Danger:** this invalidates all live definitions/`SymStr`-keyed data
1148/// on the thread. Sound only between fully independent conversions in a
1149/// reused process — the test harness (each test serializes to owned
1150/// `String`s, then resets before its thread exits) or a future daemon
1151/// that re-initializes afterward. The single-conversion binary never
1152/// calls this; it exits instead. Pairs with [`crate::common::arena::reset`]
1153/// — see [`crate::reset_thread_engine`] for the combined entry point and
1154/// the `#[thread_local]`-no-drop rationale.
1155pub fn reset_thread_state() {
1156  // Make sure STATE holds the main state (not swapped out with std/sty)
1157  // before we replace it, so all three slots are freed for real.
1158  use_main_state();
1159  *STATE.borrow_mut() = State::new(StateOptions {
1160    catcodes: Some(Catcodes::Standard),
1161    ..StateOptions::default()
1162  });
1163  *STD_STATE.borrow_mut() = State::new(StateOptions {
1164    catcodes: Some(Catcodes::Standard),
1165    ..StateOptions::default()
1166  });
1167  *STY_STATE.borrow_mut() = State::new(StateOptions {
1168    catcodes: Some(Catcodes::Style),
1169    ..StateOptions::default()
1170  });
1171  STATE_IN_USE.set(RotateState::Main);
1172}
1173
1174/// A shorthand for installing definitions
1175pub fn install_definition<T: Into<Stored>>(definition: T, scope: Option<Scope>) {
1176  let definition = definition.into();
1177
1178  // Locked definitions!!! (or should this test be in assignMeaning?)
1179  // Ignore attempts to (re)define $cs from tex sources
1180  let token = match definition {
1181    Stored::Expandable(ref defn) => defn.get_cs(),
1182    Stored::Conditional(ref defn) => defn.get_cs(),
1183    Stored::Constructor(ref defn) => defn.get_cs(),
1184    Stored::Primitive(ref defn) => defn.get_cs(),
1185    Stored::MathPrimitive(ref defn) => defn.get_cs(),
1186    Stored::Register(ref defn) => defn.get_cs(),
1187    Stored::Token(ref token) => Cow::Borrowed(token),
1188    _ => panic!("_wrong_argument_for_install_definition"),
1189  };
1190  let cs_sym = token.get_cs_name();
1191  // Probe-only: if "{cs}:locked" was never interned it cannot be bound, so
1192  // skip both the intern (which permanently grew the arena by one ":locked"
1193  // twin per defined cs) and the table lookup (2026-08-23 audit R6).
1194  let lock_key = token.with_cs_name(|cs| s!("{cs}:locked"));
1195  let is_locked = arena::get(&lock_key).is_some_and(lookup_bool_sym);
1196  if is_locked && !state_is_unlocked() {
1197    if let Some(Stored::String(s)) = state!().lookup_value("SOURCEFILE") {
1198      // report if the redefinition seems to come from document source
1199      if arena::with(*s, |txt| {
1200        txt == "Anonymous String" || TEX_OR_BIB_EXT_RE.is_match(txt) && !txt.ends_with(CODE_TEX_EXT)
1201      }) {
1202        // Perl `State.pm` L514 reports the CS itself — `Ignoring redefinition
1203        // of \cite` — not the lookup key. Reporting `lock_key` here named a
1204        // control sequence that does not exist (`\cite:locked`), so the one
1205        // diagnostic for a refused redefinition did not grep for the command
1206        // it was about.
1207        let cs_name = token.with_cs_name(ToString::to_string);
1208        Info!("ignore", cs_name, s!("Ignoring redefinition of {cs_name}"));
1209      }
1210    }
1211  } else {
1212    state_mut!().assign_internal(TableName::Meaning, cs_sym, definition, scope);
1213  }
1214}
1215
1216/// Generate a stub definition for an undefined control-sequence,
1217/// along with appropriate error messge.
1218pub fn generate_error_stub(token: &Token) -> Result<Token> {
1219  let cs = token.with_cs_name(ToString::to_string);
1220  // Perl-faithful counter leniency. A `\c@<ctr>` control sequence is, by
1221  // LaTeX convention, the count register backing counter `<ctr>`. When code
1222  // reads an *undefined* one in a number/register context (e.g.
1223  // `\setcounter{x}{\value{y}}` or `\algrestore`/`\ContinuedFloat` reading
1224  // `\c@subalgorithm@save`), Perl does NOT raise a hard "undefined control
1225  // sequence" error — its counter machinery warns "Counter '<ctr>' was not
1226  // defined; assuming 0" (Package.pm L712) and treats it as 0. Without this,
1227  // `read_x_token` expands the bare undefined `\c@<ctr>` through the generic
1228  // <ltx:ERROR/> path below and the run gains a spurious error. Mirror Perl:
1229  // warn and define the register as 0 so the reader sees a register value,
1230  // not an undefined CS. Same category/message as `counter::dialect::
1231  // counter_value`. Witness 1910.02851 (`\algrestore{RLZFactorization}` +
1232  // `\ContinuedFloat` → `\c@subalgorithm@save`); Perl rc=0.
1233  if let Some(ctr) = cs.strip_prefix("\\c@") {
1234    if !lookup_bool("SUPPRESS_UNDEFINED_ERRORS") {
1235      Warn!(
1236        "undefined",
1237        ctr,
1238        s!("Counter '{}' was not defined; assuming 0", ctr)
1239      );
1240    }
1241    crate::binding::def::dialect::def_register(*token, None, Number::new(0), None)?;
1242    return Ok(*token);
1243  }
1244  // Gate the undefined-CS summary tally by SUPPRESS_UNDEFINED_ERRORS so it
1245  // matches the `Error!` gate at L1021 below — during expl3-code.tex raw
1246  // load with thousands of forward-references we install the ERROR stub
1247  // without polluting the user-facing summary count. See
1248  // project_kernel_dump_parity.md "iow_wrap residual" for full diagnosis.
1249  if !lookup_bool("SUPPRESS_UNDEFINED_ERRORS") {
1250    note_status(LogStatus::Undefined, Some(&cs));
1251  }
1252  // To minimize chatter, go ahead and define it...
1253  if cs.starts_with("\\if") {
1254    // Apparently an \ifsomething ???
1255    let name = cs.replace("\\if", "");
1256    // Perl `generateErrorStub` (State.pm L539-540) passes the recovery note
1257    // as a SEPARATE Error detail, so `generateMessage` renders it on its own
1258    // indented line — not merged into the primary message. Match that (and
1259    // the already-correct stomach.rs path) so the cortex `details`/log first
1260    // line is just "...is not defined." like Perl.
1261    Error!(
1262      "undefined",
1263      token,
1264      s!("The token {} is not defined.", token.stringify()),
1265      "Defining it now as with \\newif"
1266    );
1267    install_definition(
1268      Expandable::new(
1269        T_CS!(s!("\\{}true", name)),
1270        None,
1271        Some(s!("\\let{}\\iftrue", cs).into()),
1272        None,
1273      )?,
1274      Some(Scope::Global),
1275    );
1276    install_definition(
1277      Expandable::new(
1278        T_CS!(s!("\\{}false", name)),
1279        None,
1280        Some(s!("\\let{}\\iffalse", cs).into()),
1281        None,
1282      )?,
1283      Some(Scope::Global),
1284    );
1285    let_i(token, &T_CS!("\\iffalse"), Some(Scope::Global));
1286  } else {
1287    // Allow suppression of undefined errors during bulk loading (e.g., expl3-code.tex)
1288    // where forward references are later resolved by post-load fixups.
1289    if !lookup_bool("SUPPRESS_UNDEFINED_ERRORS") {
1290      Error!(
1291        "undefined",
1292        token,
1293        s!("The token {} is not defined.", token.stringify()),
1294        "Defining it now as <ltx:ERROR/>"
1295      );
1296    }
1297    install_definition(
1298      Constructor {
1299        cs: *token,
1300        replacement: Some(Rc::new(move |document, _args, _props| {
1301          document.make_error("undefined", &cs)
1302        })),
1303        ..Constructor::default()
1304      },
1305      //TODO: sizer => "X"),
1306      Some(Scope::Global),
1307    );
1308  }
1309  Ok(*token)
1310}
1311
1312/// Install a `Constructor` for `token` whose sole effect at digestion time is to
1313/// emit `<ltx:ERROR class='undefined'>content</ltx:ERROR>` (the Rust equivalent
1314/// of Perl `Document::makeError`). It logs NOTHING — the caller is responsible
1315/// for the `Error!`/`note_status`. Mirrors the make_error constructor that
1316/// `generate_error_stub` installs for undefined *commands*, so undefined
1317/// *environments* (`\begin{undefinedenv}`) leave the same visible
1318/// `<ltx:ERROR>` marker as Perl instead of silently vanishing from the output.
1319pub fn install_undefined_error_constructor(token: Token, content: &str) {
1320  let content = content.to_string();
1321  install_definition(
1322    Constructor {
1323      cs: token,
1324      replacement: Some(Rc::new(move |document, _args, _props| {
1325        document.make_error("undefined", &content)
1326      })),
1327      ..Constructor::default()
1328    },
1329    Some(Scope::Global),
1330  );
1331}
1332
1333// SAFETY
1334// any method which does not return a borrowed piece of data should be package-level
1335// so that the global singleton State can get locked+unlocked during the same call
1336// thus entirely AVOIDING possible runtime panics due to RefCell lock races.
1337// TODO: Should this be a prelude?
1338
1339/// assigns a `Stored` value at the given key and scope
1340/// Direct mirror of Perl's free-function form
1341/// `LaTeXML::Core::State::assign_internal($STATE, $table, $key, $value, $scope)`
1342/// (Core/State.pm L140). Bypasses every dialect / lock / let-chase / admission
1343/// layer Rust has accreted on top of the table mutation; used by the dump
1344/// loader (Core/Dumper.pm `V/Cc/Mc/Sc/Lc/Uc/Dc/Im/I/Lt`) so the dump replay
1345/// matches Perl exactly: one record == one `assign_internal` call.
1346pub fn assign_internal<T: Into<Stored>>(
1347  table_name: TableName,
1348  key: SymStr,
1349  value: T,
1350  scope: Option<Scope>,
1351) {
1352  state_mut!().assign_internal(table_name, key, value.into(), scope);
1353}
1354
1355/// Bind `key` to `value` in the value table — Perl's `AssignValue`.
1356///
1357/// [`Scope`] decides how long the binding lasts: [`Scope::Local`] expires with
1358/// the enclosing TeX group, [`Scope::Global`] does not, [`Scope::Named`] applies
1359/// only while that scope is activated, and [`Scope::InPlace`] rebinds at the
1360/// frame the value was last bound in. Passing `None` takes the state's current
1361/// default. Read back with [`lookup_value`], or one of the typed
1362/// [`lookup_string`] / [`lookup_number`] / [`lookup_bool`] accessors.
1363pub fn assign_value<T: Into<Stored>, S: Into<Option<Scope>>>(key: &str, value: T, scope: S) {
1364  state_mut!().assign_value(key, value, scope)
1365}
1366
1367/// assigns a `Stored` value 'inplace': replaces the front value in whatever frame
1368/// it was originally assigned in, without recording an undo entry.
1369/// This matches Perl's `assignValue(key, value, 'inplace')`.
1370/// Used for MODE changes in enter_horizontal (switches mode without creating a new binding).
1371pub fn assign_value_inplace(key: &str, value: impl Into<Stored>) {
1372  assign_value_inplace_sym(arena::pin(key), value)
1373}
1374/// Sym-keyed variant of `assign_value_inplace` — skip the per-call
1375/// `arena::pin(key)` for hot callers with a pre-pinned SymStr.
1376pub fn assign_value_inplace_sym(key_sym: SymStr, value: impl Into<Stored>) {
1377  let value = value.into();
1378  let state = &mut *state_mut!();
1379  let table = &mut state.value;
1380  if let Some(vvec) = table.get_mut(&key_sym)
1381    && let Some(front) = vvec.front_mut()
1382  {
1383    *front = value;
1384    return;
1385  }
1386  // If the value was never assigned, push globally (matching Perl behavior)
1387  let vvec = table.entry(key_sym).or_default();
1388  vvec.push_front(value);
1389  // Find the locked frame and record the undo there
1390  for frame in &mut state.undo {
1391    if frame.locked {
1392      frame.table_mut(TableName::Value).insert(key_sym, 1);
1393      break;
1394    }
1395  }
1396}
1397
1398/// assigns a `Stored` value at the given (arena ticket!) key and scope
1399pub fn assign_value_sym<T: Into<Stored>, S: Into<Option<Scope>>>(key: SymStr, value: T, scope: S) {
1400  let value = value.into();
1401  let scope = scope.into();
1402  state_mut!().assign_internal(TableName::Value, key, value, scope);
1403}
1404
1405/// inline lookup_value after which globally assign an empty Tokens() to undo
1406pub fn remove_value(key: &str) -> Option<Stored> { remove_value_sym(arena::pin(key)) }
1407
1408/// `remove_value` variant for hot call sites with a pre-pinned SymStr (see
1409/// `crate::pin!`) — added for `after_assignment`, which fires on every
1410/// `\def`/`\let`/register assignment.
1411pub fn remove_value_sym(key_sym: SymStr) -> Option<Stored> {
1412  match state_mut!().value.get_mut(&key_sym) {
1413    None => None,
1414    Some(vvec) => match vvec.front_mut() {
1415      None | Some(&mut Stored::None) => None,
1416      Some(found) => Some(std::mem::take(found)),
1417    },
1418  }
1419}
1420/// Replaces the value in question with `Stored::None` (see `checkin_value` for returning it)
1421pub fn checkout_value(key: &str) -> Option<Stored> {
1422  match state_mut!().value.get_mut(&arena::pin(key)) {
1423    None => None,
1424    Some(vvec) => vvec.front_mut().map(std::mem::take),
1425  }
1426}
1427/// Returns a value into its `Stored::None` placeholder (see `checkout_value` for taking it)
1428pub fn checkin_value(key: &str, value: Stored) {
1429  match state_mut!().value.get_mut(&arena::pin(key)) {
1430    None => {
1431      // Key was never assigned — silently ignore the checkin
1432      emit_warn(
1433        "internal",
1434        "state",
1435        &format!("checkin_value called for unknown key '{key}'"),
1436      );
1437    },
1438    Some(vvec) => match vvec.front_mut() {
1439      None => {
1440        emit_warn(
1441          "internal",
1442          "state",
1443          &format!("checkin_value called with empty value stack for key '{key}'"),
1444        );
1445      },
1446      Some(found) => {
1447        match found {
1448          Stored::None => std::mem::replace(found, value),
1449          _ => panic!("checkin_value should only be called after checkout_value"),
1450        };
1451      },
1452    },
1453  }
1454}
1455/// manage a (global) list of values
1456pub fn push_value<T: Into<Stored>>(key: &str, value: T) -> Result<()> {
1457  let key_sym = arena::pin(key);
1458  let value = value.into();
1459  // Capture any BUG-path message, but raise the Error! *after* the state_mut!()
1460  // borrow is dropped — Error! reads MAX_ERRORS, and a live mutable borrow there
1461  // panics "RefCell already mutably borrowed" (tikz-cd 2001.08973).
1462  let bug: Option<String> = {
1463    let mut state = state_mut!();
1464    if !state.value.contains_key(&key_sym) {
1465      state.assign_internal(
1466        TableName::Value,
1467        key_sym,
1468        Stored::VecDequeStored(VecDeque::new()),
1469        Some(Scope::Global),
1470      );
1471    }
1472    match state.value.get_mut(&key_sym).unwrap().front_mut() {
1473      Some(&mut Stored::VecDequeStored(ref mut front)) => {
1474        front.push_back(value);
1475        None
1476      },
1477      // auto-vivify, if None
1478      Some(ref mut field) if matches!(field, Stored::None) => {
1479        let mut new_vdq = VecDeque::new();
1480        new_vdq.push_back(value);
1481        **field = Stored::VecDequeStored(new_vdq);
1482        None
1483      },
1484      // Convert Strings (immutable array) to VecDequeStored for push — matches Perl auto-vivification
1485      Some(ref mut field) if matches!(field, Stored::Strings(_)) => {
1486        let existing: VecDeque<Stored> = if let Stored::Strings(strings) = &**field {
1487          strings.iter().map(|s| Stored::String(*s)).collect()
1488        } else {
1489          VecDeque::new()
1490        };
1491        let mut new_vdq = existing;
1492        new_vdq.push_back(value);
1493        **field = Stored::VecDequeStored(new_vdq);
1494        None
1495      },
1496      other => Some(s!(
1497        "BUG: Tried to push_value into an unsupported Stored field! Field was: {other:?}"
1498      )),
1499    }
1500  };
1501  if let Some(message) = bug {
1502    // Lowercase category for consistency with engine convention.
1503    Error!("state", "Stored", message);
1504  }
1505  Ok(())
1506}
1507/// pops the last value in a named `Stored::VecDequeStored` queue, if any
1508pub fn pop_value(key: &str) -> Result<Option<Stored>> {
1509  let key_sym = arena::pin(key);
1510  // Compute the pop result under the borrow, then raise the BUG Error! *after*
1511  // dropping it — Error! reads MAX_ERRORS, which panics under a live mutable
1512  // borrow (mirrors push_value; tikz-cd 2001.08973).
1513  let popped: std::result::Result<Option<Stored>, ()> = {
1514    let mut state = state_mut!();
1515    if !state.value.contains_key(&key_sym) {
1516      state.assign_internal(
1517        TableName::Value,
1518        key_sym,
1519        Stored::VecDequeStored(VecDeque::new()),
1520        Some(Scope::Global),
1521      );
1522    }
1523    if let Some(&mut Stored::VecDequeStored(ref mut front)) =
1524      state.value.get_mut(&key_sym).unwrap().front_mut()
1525    {
1526      Ok(front.pop_back())
1527    } else {
1528      Err(())
1529    }
1530  };
1531  match popped {
1532    Ok(v) => Ok(v),
1533    Err(()) => {
1534      Error!(
1535        "State",
1536        "Stored",
1537        "BUG: Tried to pop_value from a non-vecdeque value key!"
1538      );
1539      Ok(None)
1540    },
1541  }
1542}
1543/// Check if the Value table contains a given key
1544pub fn has_value(key: &str) -> bool { has_value_sym(arena::pin(key)) }
1545/// Sym-keyed variant of `has_value` — avoids the per-call `arena::pin(key)`.
1546pub fn has_value_sym(key_sym: SymStr) -> bool {
1547  match state!().value.get(&key_sym) {
1548    None => false,
1549    Some(list) => match list.front() {
1550      None => false,
1551      Some(v) => !matches!(v, &Stored::None),
1552    },
1553  }
1554}
1555/// Pushes Tokens into a `Stored::Tokens` value when defined,
1556/// or assigns when new.
1557pub fn push_tokens(key: &str, value: Tokens) {
1558  let mut state = state_mut!();
1559  match state.lookup_value_mut(key) {
1560    Some(Stored::Tokens(tks)) => tks.unlist_mut().extend(value.unlist()),
1561    None | Some(Stored::None) => state.assign_value(key, Stored::Tokens(value), None),
1562    Some(other) => panic!("Can only push_tokens into a Stored::Tokens, but got {other:?}"),
1563  }
1564}
1565
1566/// The value bound to `key`, or `None` when nothing is bound — Perl's
1567/// `LookupValue`.
1568///
1569/// Returns whatever [`Stored`] variant was assigned, so a caller that knows the
1570/// type usually wants [`lookup_string`] / [`lookup_number`] / [`lookup_bool`]
1571/// instead. Clones the value; [`with_value`] lends it when inspecting is enough.
1572pub fn lookup_value(key: &str) -> Option<Stored> { state!().lookup_value(key).cloned() }
1573pub fn with_value<R, FnR>(key: &str, caller: FnR) -> R
1574where FnR: FnOnce(Option<&Stored>) -> R {
1575  caller(state!().lookup_value(key))
1576}
1577/// Sym-keyed variant of `with_value` — avoids the per-call `arena::pin(key)`.
1578pub fn with_value_sym<R, FnR>(key: SymStr, caller: FnR) -> R
1579where FnR: FnOnce(Option<&Stored>) -> R {
1580  caller(state!().lookup_value_sym(key))
1581}
1582pub fn with_value_mut<R, FnR>(key: &str, caller: FnR) -> R
1583where FnR: FnOnce(Option<&mut Stored>) -> R {
1584  caller(state_mut!().lookup_value_mut(key))
1585}
1586/// Undo-stack depth (open TeX groups) — pass-1 streaming telemetry.
1587pub fn undo_depth() -> usize { state!().undo.len() }
1588
1589/// A bit of Perl "existence as truth" semantics mixed in with proper boolean lookup
1590pub fn lookup_bool(key: &str) -> bool { lookup_bool_sym(arena::pin(key)) }
1591
1592/// `lookup_bool` variant for hot call sites with a pre-pinned SymStr
1593/// (see `crate::pin!`). Skips the per-call `arena::pin(key)` hash
1594/// lookup — significant on every-expansion hot paths. `SymStr` is a
1595/// `u32` wrapper (Copy), so it passes by value — no borrow overhead.
1596pub fn lookup_bool_sym(key: SymStr) -> bool {
1597  let state = state!();
1598  match state.lookup_value_sym(key) {
1599    None => false,
1600    Some(v) => v.into(),
1601  }
1602}
1603
1604/// `lookup_string` variant using a pre-pinned SymStr key.
1605pub fn lookup_string_from_sym(key: SymStr) -> String {
1606  let state = state!();
1607  match state.lookup_value_sym(key) {
1608    None => String::new(),
1609    Some(v) => v.into(),
1610  }
1611}
1612/// like `lookup_value`, but casts the entry into a SymStr from the string interner
1613///  (`pin!("")` if None)
1614pub fn lookup_string_sym(key: &str) -> SymStr {
1615  let state = state!();
1616  match state.lookup_value(key) {
1617    None => pin!(""),
1618    Some(Stored::String(v)) => *v,
1619    Some(other) => arena::pin(other.to_string()),
1620  }
1621}
1622/// like `lookup_value`, but casts the entry into a String (empty if None)
1623pub fn lookup_string(key: &str) -> String {
1624  let state = state!();
1625  match state.lookup_value(key) {
1626    None => String::new(),
1627    // A list value has no scalar string form; return "" rather than leaking the
1628    // internal `VecDequeStored[…]`/`Strings` Debug repr (#315). Structural
1629    // access to list values is via the Rhai `LookupValue` binding, which
1630    // returns an array (mirroring Perl's `LookupValue` → arrayref).
1631    Some(v) if v.is_list() => String::new(),
1632    Some(v) => v.into(),
1633  }
1634}
1635/// like `lookup_value` but only recognizes Int, Bool and Number variants of Stored (default: 0)
1636pub fn lookup_int(key: &str) -> i64 { lookup_int_sym(arena::pin(key)) }
1637
1638/// `lookup_int` variant for hot call sites with a pre-pinned SymStr (see
1639/// `crate::pin!`). Skips the per-call `arena::pin(key)` hash lookup — the
1640/// sibling of [`lookup_bool_sym`], added for the per-conditional
1641/// `if_count`/`if_limit` probes (`Conditional::invoke` fires on every
1642/// `\if`/`\ifx`/`\ifnum`/…).
1643pub fn lookup_int_sym(key: SymStr) -> i64 {
1644  let state = state!();
1645  match state.lookup_value_sym(key) {
1646    Some(Stored::Int(i)) => *i,
1647    Some(Stored::Bool(true)) => 1, // this is Perl's boolean -> integer semantics
1648    Some(Stored::Number(n)) => n.value_of(),
1649    _ => 0,
1650  }
1651}
1652/// `lookup_int` variant that never panics on a live mutable borrow.
1653///
1654/// Returns `None` when STATE is currently mutably borrowed (contention),
1655/// `Some(0)` when the key is absent/non-integer (matching `lookup_int`'s
1656/// default), else `Some(value)`.
1657///
1658/// This exists for the `Error!`/`Warn!` reporting path: an error can legitimately
1659/// be raised from inside a `state_mut()` scope (e.g. `push_value`'s BUG branch,
1660/// or any constructor `after_digest` holding the borrow). A plain `borrow()` there
1661/// panics "RefCell already mutably borrowed", aborting the whole conversion
1662/// (FATAL_panic; crashed tikz-cd 2001.08973 via `push_value("QED@stack", …)`).
1663/// The error reporter must be re-entrancy-safe regardless of what borrows are
1664/// held — degrade to "unknown" on contention rather than crash.
1665pub fn try_lookup_int(key: &str) -> Option<i64> {
1666  let state = (*STATE).try_borrow().ok()?;
1667  Some(match state.lookup_value(key) {
1668    Some(Stored::Int(i)) => *i,
1669    Some(Stored::Bool(true)) => 1,
1670    Some(Stored::Number(n)) => n.value_of(),
1671    _ => 0,
1672  })
1673}
1674
1675pub fn remove_vecdeque(key: &str) -> Option<VecDeque<Stored>> {
1676  match remove_value(key) {
1677    Some(Stored::VecDequeStored(v)) => Some(v),
1678    _ => None,
1679  }
1680}
1681/// convenience method to lookup the current value at the "font" key
1682pub fn lookup_font() -> Option<Rc<Font>> {
1683  // try_borrow, not state!()'s borrow(): this accessor is reachable from a
1684  // Whatsit's Display/revert path (e.g. tex_glue::revert_skip → lookup_font)
1685  // which can run *while STATE is already mutably borrowed* — e.g. formatting a
1686  // whatsit into a log/error message inside a state_mut() scope. A plain
1687  // borrow() then panics "RefCell already mutably borrowed", aborting the worker
1688  // (FATAL_101; crashed hep-th9908053, a \documentstyle[12pt]{article} 2.09
1689  // paper). Degrade to None on contention instead of crashing.
1690  //
1691  // CAUTION for future callers (PR #249 review P3-18): None-on-contention is
1692  // only correct for Display/revert/log-formatting consumers (where a
1693  // defaulted font is cosmetic). Several digestion-path callers `.unwrap()`
1694  // the result (tbox.rs, whatsit.rs, stomach.rs) — they would panic loudly on
1695  // contention, which is the desired behavior there: a DIGESTION-path
1696  // re-entrant lookup is a real bug, and silently defaulting the font would
1697  // turn it into invisible wrong-font drift in the XML. If you add a caller,
1698  // pick deliberately: `.unwrap()` on digestion paths, graceful None only
1699  // where the font is presentational.
1700  let Ok(st) = (*STATE).try_borrow() else {
1701    return None;
1702  };
1703  match st.lookup_value_sym(pin!("font")) {
1704    None | Some(Stored::None) => None,
1705    Some(f) => f.into(),
1706  }
1707}
1708/// convenience method to lookup the current value at the "mathfont" key
1709pub fn lookup_mathfont() -> Option<Rc<Font>> {
1710  // Route through `lookup_value_sym` with a cached SymStr (via
1711  // `pin!`) to skip the per-call `arena::pin("mathfont")` probe on
1712  // this hot path (math-env entry/exit, per-formula checks).
1713  match state!().lookup_value_sym(pin!("mathfont")) {
1714    None | Some(Stored::None) => None,
1715    Some(v) => v.into(),
1716  }
1717}
1718
1719/// a convenience method to globally asign a `Font` to the "font" key
1720pub fn assign_font(font: Rc<Font>, scope: Option<Scope>) {
1721  assign_value_sym(pin!("font"), Stored::Font(font), scope);
1722}
1723
1724/// a variant of `lookup_value` that casts the value into `Number`
1725pub fn lookup_number(key: &str) -> Option<Number> {
1726  match state!().lookup_value(key) {
1727    None | Some(Stored::None) => None,
1728    Some(v) => v.into(),
1729  }
1730}
1731/// a variant of `lookup_value` that casts the value into `Float`
1732///
1733/// The float counterpart of [`lookup_number`]. `Float` isn't a TeX register
1734/// type (see `common::float`), but binding authors need a fractional read/write
1735/// pair — e.g. `NOMINAL_FONT_SIZE` at the `11pt` class option is `10.95`, which
1736/// [`lookup_number`]/[`lookup_int`] would truncate to `10` (issue #542).
1737pub fn lookup_float(key: &str) -> Option<Float> {
1738  match state!().lookup_value(key) {
1739    None | Some(Stored::None) => None,
1740    Some(v) => v.into(),
1741  }
1742}
1743/// a variant of `lookup_value` that casts the value into `Dimension`
1744pub fn lookup_dimension(key: &str) -> Option<Dimension> {
1745  match state!().lookup_value(key) {
1746    None | Some(Stored::None) => None,
1747    Some(v) => v.into(),
1748  }
1749}
1750/// a variant of `lookup_value` that only recognizes a `Stored::Glue`
1751pub fn lookup_glue(key: &str) -> Option<Glue> {
1752  match state!().lookup_value(key) {
1753    Some(Stored::Glue(v)) => Some(*v),
1754    None | Some(Stored::None) => None,
1755    Some(other) => panic!("State lookup expected Glue, found: {other:?}"),
1756  }
1757}
1758/// a variant of `lookup_value` that only recognizes a `Stored::Glue`
1759pub fn lookup_muglue(key: &str) -> Option<MuGlue> {
1760  match state!().lookup_value(key) {
1761    Some(Stored::MuGlue(v)) => Some(*v),
1762    None | Some(Stored::None) => None,
1763    Some(other) => panic!("State lookup expected MuGlue, found: {other:?}"),
1764  }
1765}
1766/// a variant of `lookup_value` that casts the response into `Tokens`
1767pub fn lookup_tokens(key: &str) -> Option<Tokens> {
1768  let state = state!();
1769  match state.lookup_value(key) {
1770    None | Some(Stored::None) => None,
1771    Some(Stored::Tokens(v)) => Some(v.clone()),
1772    Some(Stored::Token(v)) => Some(Tokens::new(vec![*v])),
1773    Some(Stored::String(sym)) => {
1774      // Release the state borrow first, then read the interned string through
1775      // the re-entrant arena. The copy is unavoidable: an arena `&str` is not
1776      // `'static` (it dangles across a realloc — see WISDOM), and `TeXString`
1777      // borrows only `'static`, so the value has to be owned to cross into the
1778      // tokenizer. `Mouth::new` copies its input anyway.
1779      let sym = *sym;
1780      drop(state);
1781      arena::with(sym, |astr| {
1782        Some(mouth::tokenize_internal(TeXString::assembled(
1783          astr.to_string(),
1784        )))
1785      })
1786    },
1787    Some(Stored::VecDequeStored(v)) => {
1788      // Reverting the queue to Tokens routes each String item through
1789      // `mouth::tokenize_internal`, which takes a *mutable* STATE borrow — so
1790      // clone the queue and drop the immutable `state` borrow first (mirrors
1791      // the `Stored::String` branch above). Without this, LookupTokens on a
1792      // VecDequeStored key (e.g. "class_options") panics "RefCell already
1793      // borrowed" (#314).
1794      let vdq = v.clone();
1795      drop(state);
1796      Stored::VecDequeStored(vdq).into()
1797    },
1798    _ => None,
1799  }
1800}
1801/// a variant of `lookup_value` that only recognizes a `Stored::Token`
1802pub fn lookup_token(key: &str) -> Option<Token> {
1803  match state!().lookup_value(key) {
1804    Some(Stored::Token(t)) => Some(*t),
1805    _ => None,
1806  }
1807}
1808
1809/// a variant of `lookup_token` taking an already-pinned SymStr key —
1810/// avoids the per-call `arena::pin(key)` hash lookup.
1811pub fn lookup_token_sym(key: SymStr) -> Option<Token> {
1812  match state!().lookup_value_sym(key) {
1813    Some(Stored::Token(t)) => Some(*t),
1814    _ => None,
1815  }
1816}
1817
1818pub fn lookup_alignment() -> Option<Digested> {
1819  // Can only be a token or definition; we want defns!
1820  // is this the right logic here? don't expand unless digesting?
1821  state!().lookup_value_sym(pin!("Alignment")).and_then(|v| {
1822    if let Stored::Digested(d) = v {
1823      if matches!(d.data(), DigestedData::Alignment(_)) {
1824        // for now clone the Digested object (approx. an Rc<_> clone)
1825        // instead of returning &Digested, to simplify lifetime checks
1826        Some(d.clone())
1827      } else {
1828        None
1829      }
1830    } else {
1831      None
1832    }
1833  })
1834}
1835pub fn assign_alignment(alignment: Alignment, scope: Option<Scope>) {
1836  assign_value("Alignment", alignment, scope);
1837}
1838
1839pub fn assign_register(
1840  cs: &str,
1841  value: RegisterValue,
1842  scope: Option<Scope>,
1843  parameters: Vec<ArgWrap>,
1844) -> Result<()> {
1845  assign_register_token(&T_CS!(cs), value, scope, parameters)
1846}
1847/// `assign_register` variant taking a pre-built Token — lets hot
1848/// callers skip the `T_CS!(&str)` pin when they already have the CS
1849/// cached (e.g. via `T_CS!("\\c@…")` literal which routes through
1850/// `pin!`).
1851pub fn assign_register_token(
1852  cs: &Token,
1853  value: RegisterValue,
1854  scope: Option<Scope>,
1855  parameters: Vec<ArgWrap>,
1856) -> Result<()> {
1857  let defn_opt = lookup_definition(cs)?;
1858  if let Some(defn) = defn_opt
1859    && defn.is_register()
1860  {
1861    defn.set_value(value, scope, parameters);
1862    return Ok(());
1863  }
1864  Warn!(
1865    "expected",
1866    "register",
1867    format!("The control sequence '{cs}' is not a register")
1868  );
1869  Ok(())
1870}
1871pub fn lookup_register(cs: &str, parameters: Vec<ArgWrap>) -> Result<Option<RegisterValue>> {
1872  lookup_register_token(&T_CS!(cs), parameters)
1873}
1874/// Token-keyed variant of `lookup_register` — saves the per-call
1875/// `T_CS!(&str)` pin for hot callers with a cached CS token.
1876pub fn lookup_register_token(
1877  cs: &Token,
1878  parameters: Vec<ArgWrap>,
1879) -> Result<Option<RegisterValue>> {
1880  Ok(match lookup_definition(cs)? {
1881    Some(defn) => {
1882      if defn.is_register() {
1883        defn.value_of(parameters)
1884      } else {
1885        let message = s!("The control sequence '{}' is not a register", cs);
1886        Warn!("expected", "register", message);
1887        None
1888      }
1889    },
1890    _ => None,
1891  })
1892}
1893
1894/// Quiet sibling of [`lookup_register`] for call sites that mirror Perl's
1895/// explicit `lookupDefinition(cs) && $defn->isRegister ? $defn->valueOf : <default>`
1896/// guard — e.g. TeX_Tables `\lx@text@intercol`/`\lx@math@intercol`
1897/// (`TeX_Tables.pool.ltxml` L639/L646), where a document may legitimately
1898/// `\renewcommand` a length register (`\tabcolsep`/`\arraycolsep`) into a plain
1899/// macro. In that case the register-ness is genuinely gone in Perl too, and Perl
1900/// silently falls back to its default (`Dimension(0)`) with **no warning**.
1901/// Returns `None` (no warning) when the CS is undefined or is not a register,
1902/// so the caller can apply its own faithful default.
1903pub fn lookup_register_quiet(cs: &str) -> Option<RegisterValue> {
1904  let defn = lookup_definition(&T_CS!(cs)).ok().flatten()?;
1905  if defn.is_register() {
1906    defn.value_of(Vec::new())
1907  } else {
1908    None
1909  }
1910}
1911
1912/// Faithful port of Perl `LookupDimension` (`Package.pm` L1371-1393, as
1913/// widened by upstream PR #2829): try to turn the argument into a Dimension,
1914/// recognizing strings, registers, ….
1915///
1916/// * a string that looks like an obvious dimension (`/^[0-9+-.]\w\w+$/`,
1917///   e.g. `"3pt"` — but NOT `"0.4pt"`, whose `.` fails `\w`) parses directly;
1918/// * otherwise the string is tokenized: a single token that resolves to a
1919///   register returns its value ("easy and proper case");
1920/// * a multi-token sequence is read as a dimension from a fresh mouth;
1921/// * anything else warns (`expected:register`) unless `noerror`, and yields
1922///   `None` (Perl returns undef).
1923///
1924/// NOTE the #2829 semantics change carried over faithfully: a single token
1925/// whose definition is a MACRO (e.g. a document that `\def`s `\jot`) no
1926/// longer reads its body as a dimension — it now falls through to the warn
1927/// branch. (Perl's digested-Box coercion branch has no Rust equivalent here:
1928/// all our callers pass strings.)
1929pub fn lookup_dimension_cs(cs: &str, noerror: bool) -> Option<Dimension> {
1930  use std::str::FromStr;
1931  // Obvious dimension string? (Perl: /^[0-9\+\-\.]\w\w+$/)
1932  let mut chars = cs.chars();
1933  let leading_sign_or_digit =
1934    matches!(chars.next(), Some(c) if c.is_ascii_digit() || matches!(c, '+' | '-' | '.'));
1935  let obvious = leading_sign_or_digit
1936    && cs.chars().count() >= 3
1937    && chars.all(|c| c.is_alphanumeric() || c == '_');
1938  if obvious && let Ok(d) = Dimension::from_str(cs) {
1939    return Some(d);
1940  }
1941  let tokens = mouth::tokenize_internal(TeXString::assembled(cs.to_string()));
1942  let toks = tokens.unlist();
1943  if toks.len() == 1 {
1944    match lookup_definition(&toks[0]) {
1945      Ok(Some(defn)) if defn.is_register() => {
1946        // Easy (and proper) case.
1947        return defn.value_of(Vec::new()).map(|rv| Dimension::from(&rv));
1948      },
1949      // Defined but not a register (a `\def`-ized length): fall through and
1950      // read its body as a dimension. NB this is a deliberate DIVERGENCE
1951      // from post-#2829 Perl, which unintentionally LOST this path in the
1952      // rewrite (a single macro token falls to the warn branch upstream) —
1953      // see KNOWN_PERL_ERRORS #41. Real arXiv papers `\def\arraycolsep{...}`
1954      // (cluster regressions cover this); pre-#2829 Perl read the body.
1955      Ok(Some(_)) => {},
1956      // Undefined single token: warn like Perl and yield nothing.
1957      _ => {
1958        if !noerror {
1959          let message = s!("The control sequence '{}' is not a register", cs);
1960          Warn!("expected", "register", message);
1961        }
1962        return None;
1963      },
1964    }
1965  }
1966  // Read the token sequence (a defined single CS expands here, exactly like
1967  // Perl's readingFromMouth) as a dimension from a fresh mouth; an
1968  // unreadable sequence warns Missing-number inside read_dimension and
1969  // yields Dimension(0), matching Perl.
1970  gullet::reading_from_mouth(mouth::Mouth::default(), move || {
1971    gullet::unread(Tokens::new(toks));
1972    gullet::read_dimension()
1973  })
1974  .ok()
1975}
1976
1977pub fn lookup_expandable(
1978  token: &Token,
1979  toplevel_opt: Option<bool>,
1980) -> Result<Option<Rc<dyn Definition>>> {
1981  let toplevel = toplevel_opt.unwrap_or(true); // Default, for full expansion, same as read_x_token
1982  // Can only be a token or definition; we want defns!
1983  // is this the right logic here? don't expand unless digesting?
1984  Ok(
1985    lookup_definition(token)?
1986      .filter(|defn| (*defn).is_expandable() && (toplevel || !(*defn).is_protected())),
1987  )
1988}
1989
1990/// Whether token is affected by \noexpand
1991pub fn is_dont_expandable(token: &Token) -> bool {
1992  // Basically: a CS or Active token that is either not defined, or is expandable
1993  // (but not \let to a token)
1994  if token.get_catcode().is_active_or_cs() {
1995    let lookupname = meaning_key(token);
1996    if lookupname != pin!("") {
1997      match state!().meaning.get(&lookupname) {
1998        Some(entry) => {
1999          if let Some(def) = entry.front() {
2000            // the expandable variants are allowed
2001            matches!(
2002              def,
2003              Stored::Expandable(_) | Stored::Conditional(_) | Stored::None
2004            )
2005          } else {
2006            // undefined is allowed too (this is *really* subtle -- took some debugging of
2007            // etoolbox) both an empty VDQ, a VDQ with an entry present but matching
2008            // Stored::Noney, OR a completely missing VDQ are allowed "undefined" cases, each of
2009            // which flagging as "true"
2010            true
2011          }
2012        },
2013        None => true,
2014      }
2015    } else {
2016      true
2017    }
2018  } else {
2019    false
2020  }
2021}
2022
2023pub fn lookup_conditional(token: &Token) -> Option<ConditionalType> {
2024  // `get_executable_name` previously built a fresh `String` + `arena::pin`
2025  // probe per call; `pin_cs_name` already returns a cached `SymStr`
2026  // (primitive → `Catcode::name_sym`, otherwise `self.text`). Saves a
2027  // RefCell mut-borrow on the interner + a hashmap probe per token in
2028  // the gullet's conditional dispatch.
2029  if !token.code.is_executable() {
2030    return None;
2031  }
2032  let lookup_sym = token.pin_cs_name();
2033  state!().meaning.get(&lookup_sym).and_then(|entry| {
2034    if let Some(Stored::Conditional(defn)) = entry.front() {
2035      Some(defn.conditional_type)
2036    } else {
2037      None
2038    }
2039  })
2040}
2041
2042pub fn unshift_value<T: Into<Stored>>(key: &str, values: Vec<T>) {
2043  let values_iter = values.into_iter().map(Into::into);
2044  let key_sym = arena::pin(key);
2045  let mut state = state_mut!();
2046  if !state.value.contains_key(&key_sym) {
2047    state.assign_internal(
2048      TableName::Value,
2049      key_sym,
2050      Stored::VecDequeStored(VecDeque::new()),
2051      Some(Scope::Global),
2052    )
2053  }
2054  let receiver = state.value.get_mut(&key_sym).unwrap().front_mut();
2055  if let Some(&mut Stored::VecDequeStored(ref mut front)) = receiver {
2056    for value in values_iter.rev() {
2057      // preserving order unshift, as Perl's
2058      front.push_front(value)
2059    }
2060  } else if receiver.is_none() || matches!(receiver, Some(Stored::None)) {
2061    // Key doesn't exist yet — create a new VecDequeStored via the existing borrow
2062    let mut vd = VecDeque::new();
2063    for value in values_iter {
2064      vd.push_back(value);
2065    }
2066    state.assign_internal(
2067      TableName::Value,
2068      key_sym,
2069      Stored::VecDequeStored(vd),
2070      Some(Scope::Global),
2071    );
2072  } else {
2073    // Wrong type — warn but don't panic
2074    Warn!(
2075      "unexpected",
2076      "unshift_value",
2077      s!(
2078        "unshift_value expects VecDequeStored receiver for key {:?}, got: {:?}",
2079        key,
2080        receiver.map(|r| std::mem::discriminant(r))
2081      )
2082    );
2083  }
2084}
2085
2086pub fn shift_value(key: &str) -> Result<Option<Stored>> {
2087  let key_sym = arena::pin(key);
2088  let mut state = state_mut!();
2089  if !state.value.contains_key(&key_sym) {
2090    state.assign_internal(
2091      TableName::Value,
2092      key_sym,
2093      Stored::VecDequeStored(VecDeque::new()),
2094      Some(Scope::Global),
2095    )
2096  }
2097  Ok(
2098    if let Some(&mut Stored::VecDequeStored(ref mut front)) =
2099      state.value.get_mut(&key_sym).unwrap().front_mut()
2100    {
2101      front.pop_front()
2102    } else {
2103      Error!(
2104        "State",
2105        "Stored",
2106        "BUG: Tried to shift_value from a non-vecdeque value key!"
2107      );
2108      None
2109    },
2110  )
2111}
2112
2113/// Bind `key` to `value` inside the named mapping — Perl's `AssignMapping`.
2114///
2115/// A mapping is a named hash living in the value table (`TAG_PROPERTIES`,
2116/// `counter_for_type`, …). The mapping itself is created **globally** on first
2117/// use, so that entries assigned inside a group are still found from outside
2118/// it; passing `None` for `value` removes the key. Read entries back with
2119/// [`with_mapping`].
2120pub fn assign_mapping<T: Into<Stored>>(map: &str, key: &str, value: Option<T>) {
2121  let map_sym = arena::pin(map);
2122  let mut state = state_mut!();
2123  if !state.value.contains_key(&map_sym) || state.value[&map_sym].is_empty() {
2124    state.assign_internal(
2125      TableName::Value,
2126      map_sym,
2127      Stored::HashStored(SymHashMap::default()),
2128      Some(Scope::Global),
2129    );
2130  }
2131  let map_store = state.value.get_mut(&map_sym).unwrap();
2132  // TODO: What is the right abstraction here? this is hacky
2133  let mut stub_hash = SymHashMap::default();
2134  let mapping = match *map_store.front_mut().unwrap() {
2135    Stored::HashStored(ref mut mapping) => mapping,
2136    _ => &mut stub_hash,
2137  };
2138  match value {
2139    None => mapping.remove(key),
2140    Some(v) => mapping.insert(key, v.into()),
2141  };
2142}
2143
2144pub fn lookup_mapping(map: &str, key: &str) -> Option<Stored> {
2145  state!().lookup_mapping(map, key).cloned()
2146}
2147/// Sym-keyed variant — skip the per-call `arena::pin(map)` for hot
2148/// callers with a pre-pinned map key (e.g. via `pin!("siunitx_macros")`).
2149pub fn lookup_mapping_sym(map_sym: SymStr, key: &str) -> Option<Stored> {
2150  state!().lookup_mapping_sym(map_sym, key).cloned()
2151}
2152
2153//======================================================================
2154/// Was `name` bound?  If  `frame` is given, check only whether it is bound in
2155/// that frame (0 is the topmost).
2156pub fn is_value_bound(key: &str, frame_opt: Option<usize>) -> bool {
2157  let key_sym = arena::pin(key);
2158  match frame_opt {
2159    Some(frame) => state!()
2160      .undo
2161      .get(frame)
2162      .as_ref()
2163      .unwrap()
2164      .table(TableName::Value)
2165      .contains_key(&key_sym),
2166    None => !state!()
2167      .value
2168      .get(&key_sym)
2169      .unwrap_or(&VecDeque::new())
2170      .is_empty(),
2171  }
2172}
2173
2174//======================================================================
2175/// Lookup & assign a character's Catcode
2176pub fn lookup_catcode(c: char) -> Option<Catcode> {
2177  // speedup over variant with allocation
2178  // i.e. "let s = c.to_string();"
2179  let s = arena::pin_char(c);
2180  match state!().catcode.get(&s) {
2181    None => None,
2182    Some(cvec) => match cvec.front() {
2183      Some(Stored::Catcode(cc)) => Some(*cc),
2184      Some(_) => None, // non-catcode value in catcode table — treat as undefined
2185      _ => None,
2186    },
2187  }
2188}
2189
2190/// assigns a Catcode for a given character
2191pub fn assign_catcode(key: char, value: Catcode, scope: Option<Scope>) {
2192  let s = arena::pin_char(key);
2193  state_mut!().assign_internal(TableName::Catcode, s, Stored::Catcode(value), scope);
2194}
2195/// like `lookup_catcode` but targets Mathcode and its table
2196pub fn lookup_mathcode(key: &str) -> Option<u16> {
2197  let key_sym = arena::pin(key);
2198  match state!().mathcode.get(&key_sym) {
2199    Some(c) => match c.front() {
2200      Some(Stored::Charcode(codeval)) => Some(*codeval),
2201      _ => None,
2202    },
2203    None => None,
2204  }
2205}
2206pub fn lookup_mathcode_sym(key_sym: SymStr) -> Option<u16> {
2207  match state!().mathcode.get(&key_sym) {
2208    Some(c) => match c.front() {
2209      Some(Stored::Charcode(codeval)) => Some(*codeval),
2210      _ => None,
2211    },
2212    None => None,
2213  }
2214}
2215/// like `assign_catcode` but targets Mathcode and its table
2216pub fn assign_mathcode<T: Into<u16>>(key: char, value: T, scope: Option<Scope>) {
2217  state_mut!().assign_internal(
2218    TableName::Mathcode,
2219    arena::pin_char(key),
2220    Stored::Charcode(value.into()),
2221    scope,
2222  );
2223}
2224/// like `lookup_catcode` but targets Sfcode and its table
2225pub fn lookup_sfcode(key: char) -> Option<u16> {
2226  match state!().sfcode.get(&arena::pin_char(key)) {
2227    Some(c) => match c.front() {
2228      Some(Stored::Charcode(codeval)) => Some(*codeval),
2229      _ => None,
2230    },
2231    None => None,
2232  }
2233}
2234/// like `assign_catcode` but targets Sfcode and its table
2235pub fn assign_sfcode<T: Into<u16>>(key: char, value: T, scope: Option<Scope>) {
2236  state_mut!().assign_internal(
2237    TableName::Sfcode,
2238    arena::pin_char(key),
2239    Stored::Charcode(value.into()),
2240    scope,
2241  );
2242}
2243/// like `lookup_catcode` but targets Lccode and its table
2244pub fn lookup_lccode(key: char) -> Option<u16> {
2245  match state!().lccode.get(&arena::pin_char(key)) {
2246    Some(c) => match c.front() {
2247      Some(Stored::Charcode(codeval)) => Some(*codeval),
2248      _ => None,
2249    },
2250    None => None,
2251  }
2252}
2253/// like `assign_catcode` but targets Lccode and its table
2254pub fn assign_lccode<T: Into<u16>, C: Into<char>>(key: C, value: T, scope: Option<Scope>) {
2255  let c: char = key.into();
2256  state_mut!().assign_internal(
2257    TableName::Lccode,
2258    arena::pin_char(c),
2259    Stored::Charcode(value.into()),
2260    scope,
2261  );
2262}
2263/// like `lookup_catcode` but targets Uccode and its table
2264pub fn lookup_uccode(key: char) -> Option<u16> {
2265  let mut tmp = [0u8; 4];
2266  let s = arena::pin(key.encode_utf8(&mut tmp));
2267  match state!().uccode.get(&s) {
2268    Some(c) => match c.front() {
2269      Some(Stored::Charcode(codeval)) => Some(*codeval),
2270      _ => None,
2271    },
2272    None => None,
2273  }
2274}
2275/// like `assign_catcode` but targets Uccode and its table
2276pub fn assign_uccode<T: Into<u16>, C: Into<char>>(key: C, value: T, scope: Option<Scope>) {
2277  let c: char = key.into();
2278  let mut tmp = [0u8; 4];
2279  let s = arena::pin(c.encode_utf8(&mut tmp));
2280  state_mut!().assign_internal(TableName::Uccode, s, Stored::Charcode(value.into()), scope);
2281}
2282/// like `lookup_catcode` but targets Delcode and its table
2283pub fn lookup_delcode(key: char) -> Option<u16> {
2284  let mut tmp = [0u8; 4];
2285  let s = arena::pin(key.encode_utf8(&mut tmp));
2286  match state!().delcode.get(&s) {
2287    Some(c) => match c.front() {
2288      Some(Stored::Charcode(codeval)) => Some(*codeval),
2289      _ => None,
2290    },
2291    None => None,
2292  }
2293}
2294/// like `assign_catcode` but targets Delcode and its table
2295pub fn assign_delcode<T: Into<u16>>(key: char, value: T, scope: Option<Scope>) {
2296  let mut tmp = [0u8; 4];
2297  let s = arena::pin(key.encode_utf8(&mut tmp));
2298  state_mut!().assign_internal(TableName::Delcode, s, Stored::Charcode(value.into()), scope);
2299}
2300/// The key under which a token's meaning is stored. **All** `\special_relax`-family
2301/// tokens (`\noexpand`'d forms — the bare `\special_relax` and every
2302/// `\special_relax\x01<shadowed>`) resolve under the bare `\special_relax` name:
2303/// they share its `\relax` meaning, faithful to TeX where a `\noexpand`'d token
2304/// has relax meaning regardless of which token it shadows. The shadowed identity
2305/// is recovered separately via [`Token::noexpand_shadowed`] (delimited matching
2306/// only). Use this anywhere a token's *name* keys a meaning lookup or a
2307/// "same control sequence?" comparison. Cheap on the common path: non-CS tokens
2308/// short-circuit before any string access.
2309#[inline]
2310pub fn meaning_key(token: &Token) -> SymStr {
2311  if token.is_noexpand_family() {
2312    pin!("\\special_relax")
2313  } else {
2314    token.text
2315  }
2316}
2317
2318/// Get the "Meaning" of a token.
2319///
2320/// For active control sequences this may give the definition object (if
2321/// defined) or another token (if `\let`) or `None`. Any other token is returned
2322/// as is — which is what makes a `\let`-style comparison between a control
2323/// sequence and a character token work.
2324///
2325/// Clones the stored meaning; use [`with_meaning`] when inspecting it is enough.
2326pub fn lookup_meaning(token: &Token) -> Option<Stored> {
2327  if token.get_catcode().is_active_or_cs() && token.text != pin!("") {
2328    match state!().meaning.get(&meaning_key(token)) {
2329      Some(entry) => match entry.front() {
2330        None | Some(Stored::None) => None,
2331        Some(other) => Some(other.clone()),
2332      },
2333      None => None,
2334    }
2335  } else {
2336    Some(Stored::Token(*token))
2337  }
2338}
2339
2340/// Closure-based variant of `lookup_meaning` — avoids the per-call
2341/// `Stored::clone()` when the caller only needs to *inspect* the
2342/// meaning (e.g. extract a CS Token from an Expandable/Primitive
2343/// definition). Stored::clone is ~1% of total instructions on
2344/// siunitx-heavy fixtures (5M+ calls per run, each cloning a full
2345/// Stored enum). This helper borrows the stored value instead.
2346///
2347/// For non-CS/ACTIVE tokens, passes `Some(Stored::Token(*token))` —
2348/// matching lookup_meaning's fallback semantics. Note this requires
2349/// a single stack allocation of Stored::Token (Copy), not a heap
2350/// clone.
2351pub fn with_meaning<R>(token: &Token, f: impl FnOnce(Option<&Stored>) -> R) -> R {
2352  let state = state!();
2353  if token.get_catcode().is_active_or_cs() && token.text != pin!("") {
2354    match state.meaning.get(&meaning_key(token)) {
2355      Some(entry) => match entry.front() {
2356        None | Some(Stored::None) => f(None),
2357        Some(other) => f(Some(other)),
2358      },
2359      None => f(None),
2360    }
2361  } else {
2362    // Non-CS/ACTIVE: the "meaning" is just the token itself. The
2363    // caller gets a borrow of a temporary here, which is safe for
2364    // the duration of the closure.
2365    let s = Stored::Token(*token);
2366    f(Some(&s))
2367  }
2368}
2369
2370/// like `lookup_value` but only recognizes `Stored::VecDequeStored`
2371pub fn lookup_vecdeque(key: &str) -> Option<VecDeque<Stored>> {
2372  match state!().lookup_value(key) {
2373    None | Some(Stored::None) => None,
2374    Some(v) => <Option<&VecDeque<Stored>>>::from(v).cloned(),
2375  }
2376}
2377
2378pub fn with_vecdeque<R, FnR>(key: &str, caller: FnR) -> R
2379where FnR: FnOnce(Option<&VecDeque<Stored>>) -> R {
2380  caller(state!().lookup_vecdeque(key))
2381}
2382
2383/// $meaning should be a definition (for defining active control sequences)
2384/// or another token, for \let
2385pub fn assign_meaning<T: Into<Stored>>(token: &Token, meaning: T, scope: Option<Scope>) {
2386  let mut meaning = meaning.into();
2387  // short-circuit guard to avoid e.g. T_MATH let to itself
2388  if let Stored::Token(ref mt) = meaning
2389    && token == mt
2390  {
2391    return;
2392  }
2393  // For \let chains: if the target token has an expandable/primitive definition,
2394  // store that definition directly instead of the Token indirection.
2395  // This ensures `\let \foo \bar` where \bar is expandable makes \foo expandable too.
2396  // Follow at most 50 \let links to avoid cycles.
2397  if let Stored::Token(ref target) = meaning {
2398    let mut current = *target;
2399    for _ in 0..50 {
2400      match lookup_meaning(&current) {
2401        Some(Stored::Token(next)) => {
2402          current = next; // follow chain
2403        },
2404        Some(Stored::None) | None => break, // dead end — keep as Token
2405        Some(defn) => {
2406          // Found a real definition — use it directly
2407          meaning = defn;
2408          break;
2409        },
2410      }
2411    }
2412  }
2413  let csname_sym = token.pin_cs_name();
2414  state_mut!().assign_internal(TableName::Meaning, csname_sym, meaning, scope);
2415}
2416
2417/// Remove a token's meaning entirely — the token becomes undefined, as if it
2418/// had never been defined, so a later use takes the normal undefined-CS error
2419/// path naming the token itself. Bypasses the group-undo journal: intended
2420/// ONLY for format-bootstrap time (no user groups open), where a format layer
2421/// retracts a definition inherited from a lower layer that the emulated
2422/// format must not expose (e.g. plain.tex's `\+` in a LaTeX session — real
2423/// LaTeX is INITEX-based and never defines it).
2424pub fn remove_meaning_global(token: &Token) {
2425  let key = meaning_key(token);
2426  state_mut!().meaning.remove(&key);
2427}
2428
2429// keep this in sync with `lookup_meaning`, it is copied over for optimization purposes
2430pub fn has_meaning(token: &Token) -> bool {
2431  if token.get_catcode().is_active_or_cs() && token.text != pin!("") {
2432    match state!().meaning.get(&meaning_key(token)) {
2433      Some(entry) => match entry.front() {
2434        None | Some(Stored::None) => false,
2435        Some(_) => true,
2436      },
2437      None => false,
2438    }
2439  } else {
2440    true
2441  }
2442}
2443
2444/// used for expansion & various queries
2445/// Since we're not doing digestion here, we don't need to handle mathactive,
2446/// nor cs let to executable tokens
2447/// This returns a definition object, or undef
2448pub fn lookup_definition(key: &Token) -> Result<Option<Rc<dyn Definition>>> {
2449  Ok(
2450    if let Some(defs) = state!().lookup_definition_internal(key) {
2451      match defs.front() {
2452        Some(Stored::Conditional(entry)) => Some(entry.clone()),
2453        Some(Stored::Constructor(entry)) => Some(entry.clone()),
2454        Some(Stored::Expandable(entry)) => Some(entry.clone()),
2455        Some(Stored::MathPrimitive(entry)) => Some(entry.clone()),
2456        Some(Stored::Primitive(entry)) => Some(entry.clone()),
2457        Some(Stored::Register(entry)) => Some(entry.clone()),
2458        Some(Stored::None) | Some(Stored::Token(_)) | None => None,
2459        Some(v) => {
2460          let message = s!("in lookup_definition for {:?}. Value was: {:?}", key, v);
2461          Error!("unexpected", "value", message);
2462          None
2463        },
2464      }
2465    } else {
2466      None
2467    },
2468  )
2469}
2470
2471/// Returns a definition as `Stored` so that one can call `.read_arguments`
2472///
2473/// This can't be specialized during compile-time over a trait object?
2474/// Instead we'll dispatch via `Stored` at runtime, to allow generic calls.
2475pub fn lookup_definition_stored(key: &Token) -> Result<Option<Stored>> {
2476  Ok(match state!().lookup_definition_internal(key) {
2477    Some(defs) => match defs.front() {
2478      // Still, good time to handle the Token case and catch weird storage errors
2479      Some(Stored::Conditional(entry)) => Some(Stored::Conditional(Rc::clone(entry))),
2480      Some(Stored::Constructor(entry)) => Some(Stored::Constructor(Rc::clone(entry))),
2481      Some(Stored::Expandable(entry)) => Some(Stored::Expandable(Rc::clone(entry))),
2482      Some(Stored::MathPrimitive(entry)) => Some(Stored::MathPrimitive(Rc::clone(entry))),
2483      Some(Stored::Primitive(entry)) => Some(Stored::Primitive(Rc::clone(entry))),
2484      Some(Stored::Register(entry)) => Some(Stored::Register(Rc::clone(entry))),
2485      Some(Stored::Token(entry)) => Some(Stored::Expandable(Rc::new(Expandable {
2486        cs: key.with_str(|k| T_CS!(k)),
2487        paramlist: None,
2488        expansion: (*entry).into(),
2489        ..Expandable::default()
2490      }))),
2491      Some(v) => {
2492        let message = s!("in lookup_definition for {:?}. Value was: {:?}", key, v);
2493        Error!("unexpected", "value", message);
2494        None
2495      },
2496      None => None,
2497    },
2498    _ => None,
2499  })
2500}
2501
2502/// A specialized version of `lookup_definition` for registers, since we can't adequately perform
2503/// multi-dispatch when we have a "Self: Sized" for the Definition trait object.
2504pub fn lookup_register_definition(key: &Token) -> Option<Rc<Register>> {
2505  match state!().lookup_definition_internal(key) {
2506    Some(defs) => match defs.front() {
2507      Some(Stored::Register(entry)) => Some(Rc::clone(entry)),
2508      _ => None,
2509    },
2510    _ => None,
2511  }
2512}
2513/// Recognizes mathactive tokens in math mode and also looks for
2514/// cs that have been let to other `executable' tokens.
2515/// Returns a definition object, or a "self inserting" token.
2516/// Used for digestion.
2517pub fn lookup_digestable_definition(token: &Token) -> Option<Stored> {
2518  let cc = token.get_catcode();
2519  let t_sym = token.get_sym();
2520  let is_active_or_cs = cc.is_active_or_cs();
2521  let lookup_sym = if is_active_or_cs
2522    || ((cc == Catcode::LETTER || (cc == Catcode::OTHER))
2523      && lookup_bool_sym(crate::pin!("IN_MATH"))
2524      && (lookup_mathcode_sym(t_sym).unwrap_or(0) == 0x8000))
2525  {
2526    // `\special_relax`-family tokens digest under the bare `\special_relax` no-op.
2527    meaning_key(token)
2528  } else {
2529    // Use cached SymStr from `Catcode::name_sym` instead of re-interning
2530    // `cc.name()` (a &'static str) on every non-active-or-cs token —
2531    // saves a hashmap probe per token on the digest hot path.
2532    cc.name_sym()
2533  };
2534  // Debug!("Looking up digestable {:?}", lookupname);
2535  let state = state!();
2536  let entry_opt = state.meaning.get(&lookup_sym);
2537  if lookup_sym != pin!("") && entry_opt.is_some() && !entry_opt.as_ref().unwrap().is_empty() {
2538    // Debug!("Found definition for: {:?}", lookupname);
2539    if let Some(entry) = entry_opt
2540      && let Some(front) = entry.front()
2541    {
2542      if let Stored::Token(t) = front {
2543        if let Some(lookup_name) = t.get_executable_primitive_name() {
2544          let lookup_sym = arena::pin(lookup_name);
2545          if let Some(retry_entry) = state!().meaning.get(&lookup_sym) {
2546            // special case,
2547            // If a cs has been let to an executable token, lookup ITS defn.
2548            return retry_entry.front().cloned();
2549          }
2550        }
2551        // Also follow \let chains for CS tokens: if \foo is \let to \bar,
2552        // resolve \bar's definition. This handles expl3 aliases like
2553        // \tex_long:D → \long, \tex_gdef:D → \gdef.
2554        if t.get_catcode() == Catcode::CS
2555          && let Some(target_entry) = state.meaning.get(&t.text)
2556          && let Some(target_front) = target_entry.front()
2557          && !matches!(target_front, Stored::Token(_) | Stored::None)
2558        {
2559          return Some(target_front.clone());
2560        }
2561      }
2562      // Perl State.pm:474 lookupDigestableDefinition: the guard
2563      // `($defn = $$entry[0])` is FALSE when the entry's value is undef, so
2564      // execution falls through to `return $token` (self-inserting) for a
2565      // LETTER/OTHER token and to `return undef` for an active/CS one. A
2566      // math-active LETTER/OTHER character whose active meaning was `\let`
2567      // to an undefined CS hits exactly this case — e.g. braket-style
2568      // `\Pr{A|B}`: the macro body does `\mathcode`\|=32768 \let|\SetVert`
2569      // with `\SetVert` itself undefined (neither our nor Perl's braket
2570      // binding defines it), leaving `|`'s meaning an explicit
2571      // `Stored::None`. Returning `Some(Stored::None)` here routed the `|`
2572      // to generateErrorStub ("The token T_OTHER[|] is not defined"); Perl
2573      // instead self-inserts the literal char. Mirror Perl: a None-valued
2574      // entry for a non-active/CS (math-active) char self-inserts; active/CS
2575      // tokens still fall to the `None` return below. Witness 1602.01342.
2576      if matches!(front, Stored::None) && !is_active_or_cs {
2577        return Some(token.into());
2578      }
2579      // if a regular definition, just return.
2580      return Some(front.clone());
2581    }
2582  } else if is_active_or_cs {
2583    return None;
2584  }
2585  Some(token.into())
2586}
2587
2588// NOTE: Common usage patterns seem to be to lookup
2589//   expandable definitions
2590//   register values
2591//   conditionals
2592//   digestibles
2593// or just variants on testing defined-ness
2594// May be will introduce more clarity (possibly efficiency)
2595// to collect those more uniformly and implement here, or in Package
2596
2597//======================================================================
2598/// Starts a new level of grouping.
2599/// Note that this is lower level than C<\bgroup>;
2600/// Diagnostic helper: dump the keys in undo`0`'s value table.
2601/// For temporary instrumentation only — no production callers should rely on this.
2602pub fn dump_top_frame_keys() -> String {
2603  let state = state!();
2604  let f0 = state.undo.front().expect("undo is non-empty");
2605  let mut entries: Vec<String> = Vec::new();
2606  for (k, v) in f0.table(TableName::Value).iter() {
2607    let val = state
2608      .value
2609      .get(k)
2610      .and_then(|vec| vec.front())
2611      .map(|s| format!("{s:?}"))
2612      .unwrap_or_else(|| "<none>".into());
2613    let ks: String = arena::with(*k, |s| s.to_string());
2614    entries.push(format!("{ks}=[{v}, {val}]"));
2615  }
2616  entries.sort();
2617  entries.join(", ")
2618}
2619
2620pub fn push_frame() {
2621  // Easy: just push a new undo frame.
2622  state_mut!().undo.push_front(UndoFrame::default());
2623}
2624
2625/// Snapshot of the keys currently bound at the topmost (calling) undo frame
2626/// for the Meaning table. Used by Perl-style autoload triggers that need to
2627/// promote everything a package's load just installed at this scope to
2628/// GLOBAL — without that promotion, sibling autoload triggers fired AFTER
2629/// a group pop would re-fire on a now-undefined sibling CS (the canonical
2630/// case is `\begin{subequations}` triggering amsmath autoload at depth=N,
2631/// then a later `\begin{align}` at depth=0 finding `\align` undefined
2632/// because amsmath's depth=N install was popped on `\end{subequations}`).
2633pub fn snapshot_top_frame_meaning_keys() -> Vec<SymStr> {
2634  state!()
2635    .undo
2636    .front()
2637    .map(|f| f.meaning.keys().copied().collect())
2638    .unwrap_or_default()
2639}
2640
2641/// Hoist every Meaning binding installed at the topmost frame since
2642/// `pre_snapshot` was taken to GLOBAL scope. Idempotent: keys already
2643/// in `pre_snapshot` are skipped. Operates on the Meaning table only —
2644/// callers that need to promote Value/Catcode/etc. should add parallel
2645/// helpers (none required so far).
2646pub fn hoist_top_frame_meaning_delta(pre_snapshot: &[SymStr]) {
2647  let pre: rustc_hash::FxHashSet<SymStr> = pre_snapshot.iter().copied().collect();
2648  let new_keys: Vec<SymStr> = {
2649    let state = state!();
2650    state
2651      .undo
2652      .front()
2653      .map(|f| {
2654        f.meaning
2655          .keys()
2656          .copied()
2657          .filter(|k| !pre.contains(k))
2658          .collect()
2659      })
2660      .unwrap_or_default()
2661  };
2662  for key in new_keys {
2663    let current = {
2664      let state = state!();
2665      state
2666        .meaning
2667        .get(&key)
2668        .and_then(|stack| stack.front().cloned())
2669    };
2670    if let Some(value) = current {
2671      // CONDITIONALS ONLY. The failure this exists for is a definition destroyed
2672      // while a GLOBAL document hook still reads it, and every witness is a
2673      // `\newif` conditional (`\ifpgf@external@grabshipout`, OXIDIZED_DESIGN
2674      // #65). Hoisting a package's ordinary macros too is what makes a second
2675      // sibling subfile render the FIRST one's content: promoting pkgA's
2676      // `\newcommand` to global makes pkgB's same-named `\newcommand` a silent
2677      // no-op, so sibling B shows A's body — silent wrong content, and worse
2678      // than Perl, which scopes both. `\newif` installs `\ifX` as a Conditional
2679      // (`\Xtrue`/`\Xfalse` are plain macros the hooks do not read), so this
2680      // filter keeps every witness working while leaving macros scoped.
2681      if !matches!(value, Stored::Conditional(_)) {
2682        continue;
2683      }
2684      // Direct re-bind via assign_internal so we don't need to round-trip a
2685      // full Token. The Meaning table is keyed by SymStr (the CS name);
2686      // any future read via `assign_meaning(token, ...)` would reach the
2687      // same cell. Scope::Global removes higher-frame undo entries and
2688      // installs at the lowest non-locked frame.
2689      state_mut!().assign_internal(TableName::Meaning, key, value, Some(Scope::Global));
2690    }
2691  }
2692}
2693/// Ends the current level of grouping.
2694/// Note that this is lower level than `\egroup`;
2695pub fn pop_frame() -> Result<()> {
2696  let mut state = state_mut!();
2697  if state.undo.front().as_ref().unwrap().locked {
2698    fatal!(
2699      TargetUnexpected,
2700      Endgroup,
2701      "attempt to pop last locked stack frame"
2702    );
2703  // Fatal('unexpected', '<endgroup>', $self->getStomach,
2704  // "Attempt to pop last locked stack frame"); }
2705  } else {
2706    let popped_frame = state.undo.pop_front().unwrap();
2707    for table_name in TableName::variants() {
2708      let undo_table = popped_frame.table(*table_name);
2709      let state_table = state.table_mut(*table_name);
2710      for (key, undo_count) in undo_table.iter() {
2711        // Typically only 1 value to shift off the table, unless scopes have been activated.
2712        let named_table = state_table.get_mut(key).unwrap();
2713        for _ in 0..*undo_count {
2714          named_table.pop_front();
2715        }
2716      }
2717    }
2718  }
2719  Ok(())
2720}
2721
2722/// Determine depth of group nesting.
2723///
2724/// nesting created by {,},\bgroup,\egroup,\begingroup,\endgroup
2725/// by counting all frames which are not Daemon frames (and thus don't possess _FRAME_LOCK_).
2726/// This may give incorrect results for some special environments (e.g. minipage)
2727pub fn get_frame_depth() -> usize { state!().undo.iter().filter(|frame| !frame.locked).count() }
2728
2729/// `true` when the CURRENT (front) stack frame is the locked bottom frame —
2730/// i.e. there is no openable group/mode frame to pop. Popping it would FATAL.
2731pub fn current_frame_locked() -> bool { state!().undo.front().map(|f| f.locked).unwrap_or(true) }
2732/// begins a semiverbatim frame, neutralizing the usual + requested characters
2733pub fn begin_semiverbatim(extraspecials: Option<&[char]>) {
2734  // Is this a good/safe enough shorthand, or should we really be doing beginMode?
2735  push_frame();
2736  assign_value("MODE", "restricted_horizontal", None);
2737  assign_value("IN_MATH", false, None);
2738  let mut all_specials: Vec<char> = Vec::new();
2739  if let Some(extra) = extraspecials {
2740    for special in extra {
2741      all_specials.push(*special);
2742    }
2743  }
2744  {
2745    if let Some(Stored::Chars(specials_store)) = state!().lookup_value("SPECIALS") {
2746      for special_char in &**specials_store {
2747        all_specials.push(*special_char);
2748      }
2749    }
2750  }
2751
2752  for special_char in all_specials {
2753    assign_catcode(special_char, Catcode::OTHER, Some(Scope::Local));
2754  }
2755  assign_mathcode('\'', 0x8000u16, Some(Scope::Local));
2756  // try to stay as ASCII as possible
2757  if let Some(ref current_font) = lookup_font() {
2758    let local_font = current_font.merge(fontmap!(encoding => "ASCII"));
2759    assign_font(Rc::new(local_font), Some(Scope::Local));
2760  }
2761}
2762/// end by just calling `pop_frame`
2763pub fn end_semiverbatim() -> Result<()> { pop_frame() }
2764
2765//   #======================================================================
2766
2767// PARTIAL port of Perl `LaTeXML::Core::State::push/popDaemonFrame`
2768// (used by the Perl `latexmls` daemon to reset bindings between runs while
2769// keeping the loaded Pool). `pop_daemon_frame` is faithful (pop unlocked
2770// frames, unlock + pop the daemon frame, Fatal on the last frame).
2771// `push_daemon_frame` is NOT yet: Perl (State.pm L607-627) additionally
2772// `daemon_copy`s every mutable HASH/ARRAY value binding into the new frame —
2773// so IN-PLACE mutations under the daemon frame (Rust: `with_value_mut` on
2774// `VecDequeStored`/`HashTagData`/... values) can't corrupt the pre-frame
2775// state — and records `_PRELOADED_POOL_`. Without that copy, a daemon reset
2776// only undoes frame-tracked ASSIGNMENTS, not in-place mutations. The Rust
2777// persistent server (`latexml_oxide --server`) instead isolates each
2778// conversion in a `fork()`ed child, so these are not currently wired into a
2779// caller — kept (with the round-trip test in `tests/00_unit_state.rs`) as the
2780// seed of an in-process reset primitive for a future thread-reusing daemon
2781// mode, which MUST add the deep-copy semantics before relying on it. See
2782// `lsp_server` for the chosen fork-isolation design.
2783pub fn push_daemon_frame() {
2784  let daemon_frame = UndoFrame {
2785    locked: true,
2786    ..UndoFrame::default()
2787  };
2788  state_mut!().undo.push_front(daemon_frame);
2789}
2790
2791pub fn pop_daemon_frame() -> Result<()> {
2792  let mut state = state_mut!();
2793  // `is_some_and(!locked)` rather than `unwrap()`: an (impossible-in-practice)
2794  // empty undo stack must fall through to the Fatal below, not panic.
2795  while state.undo.front().is_some_and(|f| !f.locked) {
2796    drop(state);
2797    pop_frame()?;
2798    state = state_mut!();
2799  }
2800  if state.undo.len() > 1 {
2801    state.undo.front_mut().unwrap().locked = false;
2802    drop(state);
2803    pop_frame()?;
2804  } else {
2805    fatal!(
2806      TargetUnexpected,
2807      Endgroup,
2808      "Daemon Attempt to pop last stack frame"
2809    );
2810  }
2811  Ok(())
2812}
2813
2814// ======================================================================
2815/// Set one of the definition prefixes global, etc (only global matters!)
2816pub fn set_prefix(prefix: &str) { state_mut!().prefixes.insert(arena::pin(prefix), true); }
2817/// gets the current value of a named prefix
2818pub fn get_prefix(prefix: &str) -> bool { state!().get_prefix(prefix) }
2819/// `get_prefix` with a pre-pinned SymStr key (see `crate::pin!`) — for the
2820/// per-`\def` prefix probes in `Expandable::new` and friends.
2821pub fn get_prefix_sym(prefix: SymStr) -> bool { state!().get_prefix_sym(prefix) }
2822
2823/// clears the global prefixes
2824pub fn clear_prefixes() { state_mut!().prefixes = HashMap::default(); }
2825
2826// #======================================================================
2827/// Named scope bracketing a subfile LaTeXML included itself — a `standalone`
2828/// child's preamble, an `\import`ed file. Real LaTeX has no group at either spot
2829/// (standalone *gobbles* the child preamble; import restores its paths by plain
2830/// `\def` after the `\input`), so a package loaded inside one is an artifact of
2831/// LaTeXML executing what the real packages skip. Bindings that open such a
2832/// bracket activate this scope; `require_package` reads it to decide whether a
2833/// load must outlive the bracket. See OXIDIZED_DESIGN #65.
2834///
2835/// The name carries the frame depth of the bracket that opened it — Perl's own
2836/// `section:4` / `label:foo` convention (State.pm L965-975) — because activity
2837/// alone is not enough: `StashActive` is `Scope::Local` at the bracket's frame,
2838/// so a plain "is the region active?" test is ALSO true at every deeper frame,
2839/// and an author's `{\usepackage{…}}` written *inside* a subfile preamble would
2840/// be hoisted as well. That is a downgrade: pdflatex and Perl both leave such a
2841/// package lost. Matching the depth confines the region to the bracket's own
2842/// level.
2843pub fn subfile_scope_at_depth(depth: usize) -> SymStr { arena::pin(format!("subfile:{depth}")) }
2844
2845/// The subfile scope for the CURRENT frame depth — what a bracket activates on
2846/// opening, and what `require_package` tests before hoisting.
2847pub fn subfile_scope_here() -> SymStr { subfile_scope_at_depth(get_frame_depth()) }
2848
2849/// Is the named scope currently active? See `scope_active_in` for why this is a
2850/// front-value test and not a presence test, and `subfile_scope_at_depth` for the region
2851/// marker it supports.
2852pub fn is_scope_active(scope: SymStr) -> bool { scope_active_in(&state!(), scope) }
2853
2854/// Perl's scope-activity test, shared by the three call sites that need it
2855/// (`is_scope_active`, `activate_scope`, `deactivate_scope`; `get_active_scopes`
2856/// deliberately still enumerates KEYS, faithful to Perl State.pm L722-725, which
2857/// has the same latent quirk — it has no callers):
2858/// the truthiness of the FRONT `stash_active` value — `$$self{stash_active}
2859/// {$scope}[0]` in `activateScope` (State.pm L682) and `deactivateScope`
2860/// (L700).
2861///
2862/// Presence is NOT the test, and cannot be: deactivation OVERWRITES the front
2863/// value with a falsy one rather than removing the key — an ordinary global
2864/// assignment (`assign_internal(… 'stash_active', $scope, 0, 'global')`,
2865/// State.pm L701; ours passes `Stored::Bool(false)`). A global assign replaces
2866/// rather than layers: it drops the per-frame counts down to the locked frame,
2867/// pops exactly that many values, and leaves ONE. So the front value is the
2868/// whole state. (A delete is not available anyway — `stash_active` rides the
2869/// generic table + undo machinery, whose per-frame pop counts a removed key
2870/// would desynchronise.) Pinned by
2871/// `reentrancy_tests::scope_activity_tracks_value_not_presence`.
2872///
2873/// Reach: the production consumers — `counter/dialect.rs` (a reference number's
2874/// `<ctr>:<refnum>` scope, deactivated then re-activated as the counter moves,
2875/// mirroring Perl `Package.pm` L774-779) and `latex_constructs.rs`'s `label:`
2876/// scopes — activate names that nothing currently STASHES into, so an activation
2877/// installs no bindings and the re-activation fix is correct but latent. It
2878/// becomes observable the moment a binding defines with `scope => "<ctr>:<n>"`.
2879/// That is why the guards are unit-level: there is no output difference to
2880/// assert end-to-end yet. (The one `Scope::Named` stash writer, `declare.rs`'s
2881/// `id:<section_id>`, is consumed by `rewrite.rs` by prefix, not by activation.)
2882///
2883/// The local/global asymmetry is deliberate (Perl's own note above
2884/// `deactivateScope`): activation is `local`, so it expires with its group
2885/// without a teardown call — which is what makes a named scope usable as a
2886/// region marker (see `subfile_scope_at_depth`) — while deactivation is `global` so it
2887/// survives group exit. A local deactivation would be undone by the very group
2888/// that contained it.
2889fn scope_active_in(state: &State, scope: SymStr) -> bool {
2890  state
2891    .stash_active
2892    .get(&scope)
2893    .and_then(|entry| entry.front())
2894    .is_some_and(|v| !matches!(v, Stored::Bool(false)))
2895}
2896
2897/// Activates all stashed definitions for the named scope. No-op if the scope is already active.
2898pub fn activate_scope(scope: SymStr) {
2899  let mut state = state_mut!();
2900  // Perl L682 `if (!$$self{stash_active}{$scope}[0])` — do not re-activate if
2901  // already active, but a scope that was DEACTIVATED must be activatable again.
2902  if scope_active_in(&state, scope) {
2903    return;
2904  }
2905
2906  state.assign_internal(
2907    TableName::StashActive,
2908    scope,
2909    Stored::Bool(true),
2910    Some(Scope::Local),
2911  );
2912  // Also, we need to take ownership of the stashed data, so that we can assign it.
2913  // TODO: Potential to optimize?
2914  // Also x2, we are using a shared "Stored" interface for all data that passes through
2915  // assign_internal, but that causes both uncertainty and overhead in the Stash table
2916  // specifically. TODO x2: Maybe a more ambitious refactor will separate out the Stash logic
2917  // and use "StashTable" directly instead of Stored::Stash(StashTable) ?
2918
2919  let mut actions = Vec::new();
2920
2921  if let Some(Some(Stored::Stash(defns))) = state.stash.get(&scope).map(|x| x.iter().next()) {
2922    for (table_name, key, value) in defns {
2923      // copy the values out from the stashed defns, so that Rust
2924      // is calm we are borrowing safely.
2925
2926      actions.push((*table_name, key.to_owned(), value.clone()));
2927    }
2928  }
2929  // Here we ALWAYS push the stashed values into the table
2930  // since they may be popped off by deactivateScope
2931  for (table_name, key, value) in actions {
2932    let frame = &mut state.undo[0];
2933    let frame_table = frame.table_mut(table_name);
2934    let entry = frame_table.entry(key).or_insert(0);
2935    *entry += 1; // Note that this many values must be undone
2936    let key_table = state.table_mut(table_name).entry(key).or_default();
2937    key_table.push_front(value); // And push new binding.
2938  }
2939}
2940
2941// Probably, in most cases, the assignments made by activateScope
2942// will be undone by egroup or popping frames.
2943// But they can also be undone explicitly
2944
2945/// Removes any definitions that were associated with the named `scope`.
2946/// Normally not needed, since a scopes definitions are locally bound anyway.
2947pub fn deactivate_scope(scope: SymStr) {
2948  let mut state = state_mut!();
2949  // Perl L700 `if ($$self{stash_active}{$scope}[0])` — only an ACTIVE scope is
2950  // deactivated; a second deactivation must not re-run the pop below.
2951  if !scope_active_in(&state, scope) {
2952    return;
2953  }
2954
2955  state.assign_internal(
2956    TableName::StashActive,
2957    scope,
2958    Stored::Bool(false),
2959    Some(Scope::Global),
2960  );
2961
2962  let mut collected = Vec::new();
2963  if let Some(Some(Stored::Stash(defns))) = state.stash.get(&scope).map(|x| x.iter().next()) {
2964    for (table_name, key, value) in defns {
2965      collected.push((table_name.to_owned(), key.to_owned(), value.to_owned()));
2966    }
2967  }
2968
2969  for (table_name, key, value) in collected {
2970    let front_is_value = if let Some(table_entry_peek) = state.table(table_name).get(&key) {
2971      if let Some(table_front) = table_entry_peek.front() {
2972        *table_front == value
2973      } else {
2974        false
2975      }
2976    } else {
2977      false
2978    };
2979    let table_entry = state.table_mut(table_name).entry(key).or_default();
2980    if front_is_value {
2981      // Here we're popping off the values pushed by activateScope
2982      // to (possibly) reveal a local assignment in the same frame, preceding activateScope.
2983      (*table_entry).pop_front();
2984
2985      if let Some(frame) = state.undo.front_mut() {
2986        let frame_table = frame.table_mut(table_name);
2987        let frame_count = frame_table.entry(key).or_default();
2988        *frame_count -= 1;
2989      }
2990    } else {
2991      let message = arena::with(key, |key_str| {
2992        s!(
2993          "Unassigning wrong value for {} from table {} in deactivateScopevalue is {:?} but stack \
2994          is {:?}",
2995          key_str,
2996          table_name,
2997          value,
2998          table_entry
2999            .iter()
3000            .map(ToString::to_string)
3001            .collect::<Vec<String>>()
3002            .join(", ")
3003        )
3004      });
3005      arena::with(key, |key_str| Warn!("internal", key_str, message));
3006    }
3007  }
3008}
3009/// return all known named scopes
3010pub fn get_known_scopes() -> Vec<SymStr> { state!().stash.keys().copied().collect::<Vec<_>>() }
3011/// return the currently activated named scopes
3012pub fn get_active_scopes() -> Vec<SymStr> {
3013  state!().stash_active.keys().copied().collect::<Vec<_>>()
3014}
3015
3016//======================================================================
3017// Units.
3018// Put here since it could concievably evolve to depend on the current font.
3019/// convert a unit name into a `f64` scaling factor over `sp`
3020pub fn convert_unit(unit_arg: &str) -> f64 {
3021  let unit = unit_arg.to_lowercase();
3022  // Font-relative units fall back to 10pt metrics when no current font is
3023  // set (e.g. pre-bootstrap unit conversion). Perl gets this via the
3024  // built-in default font; matching with a static fallback is cheaper
3025  // than forcing every caller to ensure a font frame exists.
3026  let font_metric =
3027    |getter: fn(&Font) -> i64| -> f64 { lookup_font().map(|f| getter(&f) as f64).unwrap_or(0.0) };
3028  match unit.as_str() {
3029    "em" => font_metric(|f| f.get_em_width()),
3030    "ex" => font_metric(|f| f.get_ex_height()),
3031    "mu" => font_metric(|f| f.get_mu_width()),
3032    u => match UNITS.get(u) {
3033      Some(sp) => *sp,
3034      None => {
3035        let message = s!("Illegal unit of measure {:?}, assuming pt.", u);
3036        Warn!("expected", "<unit>", message);
3037        *UNITS.get("pt").unwrap()
3038      },
3039    },
3040  }
3041}
3042
3043/// Convert a unit name into the exact TeX `(num, den)` fraction such that a
3044/// dimension of `value` units is `floor(round(value·65536)·num/den)` scaled
3045/// points (see `numeric_ops::fixpoint_unit`).
3046///
3047/// Physical units use TeX's `set_conversion(num)(denom)` fractions verbatim
3048/// (tex.web §458, lines 9020-9032): `in=7227/100, pc=12/1, cm=7227/254,
3049/// mm=7227/2540, bp=7227/7200, dd=1238/1157, cc=14856/1157`, plus `pt=1/1` and
3050/// `sp=1/65536`. `px` follows LaTeXML in aliasing `bp`. Font-relative units
3051/// (`em`/`ex`/`mu`) return `(metric_sp, 65536)`, matching tex.web §8983's
3052/// `nx_plus_y(_, v, xn_over_d(v, f, 65536))` for internal units. This is the
3053/// exact-integer counterpart of [`convert_unit`]; each physical entry satisfies
3054/// `convert_unit(u) == 65536·num/den`.
3055pub fn convert_unit_ratio(unit_arg: &str) -> (i64, i64) {
3056  let unit = unit_arg.to_lowercase();
3057  let font_metric =
3058    |getter: fn(&Font) -> i64| -> i64 { lookup_font().map(|f| getter(&f)).unwrap_or(0) };
3059  // UNITY == 65536 sp per pt; font-relative and `sp` units convert via the
3060  // `floor(fix·v/UNITY)` path (tex.web §8983 nx_plus_y/xn_over_d).
3061  match unit.as_str() {
3062    "em" => (font_metric(|f| f.get_em_width()), UNITY),
3063    "ex" => (font_metric(|f| f.get_ex_height()), UNITY),
3064    "mu" => (font_metric(|f| f.get_mu_width()), UNITY),
3065    "pt" => (1, 1),
3066    "pc" => (12, 1),
3067    "in" => (7227, 100),
3068    "bp" | "px" => (7227, 7200),
3069    "cm" => (7227, 254),
3070    "mm" => (7227, 2540),
3071    "dd" => (1238, 1157),
3072    "cc" => (14856, 1157),
3073    "sp" => (1, UNITY),
3074    u => {
3075      let message = s!("Illegal unit of measure {:?}, assuming pt.", u);
3076      Warn!("expected", "<unit>", message);
3077      (1, 1)
3078    },
3079  }
3080}
3081
3082// ======================================================================
3083
3084// sub getStatus {
3085//   my ($self, $type) = @_;
3086//   return $$self{status}{$type}; }
3087
3088// sub getStatusMessage {
3089//   my ($self) = @_;
3090//   my $status = $$self{status};
3091//   my @report = ();
3092// push(@report, colorizeString("$$status{warning} warning" . ($$status{warning} > 1 ? 's' :
3093// ''), 'warning'))     if $$status{warning};
3094// push(@report, colorizeString("$$status{error} error" . ($$status{error} > 1 ? 's' : ''),
3095// 'error'))     if $$status{error};
3096//   push(@report, "$$status{fatal} fatal error" . ($$status{fatal} > 1 ? 's' : ''))
3097
3098//     if $$status{fatal};
3099//   my @undef = ($$status{undefined} ? keys %{ $$status{undefined} } : ());
3100//   push(@report, colorizeString(scalar(@undef) . " undefined macro" . (@undef > 1 ? 's' : '')
3101//         . "[" . join(', ', @undef) . "]", 'details'))
3102//     if @undef;
3103//   my @miss = ($$status{missing} ? keys %{ $$status{missing} } : ());
3104//   push(@report, colorizeString(scalar(@miss) . " missing file" . (@miss > 1 ? 's' : '')
3105//         . "[" . join(', ', @miss) . "]", 'details'))
3106//     if @miss;
3107//   return join('; ', @report) || colorizeString('No obvious problems', 'success'); }
3108
3109// sub getStatusCode {
3110//   my ($self) = @_;
3111//   my $status = $$self{status};
3112//   my $code;
3113//   if ($$status{fatal} && $$status{fatal} > 0) {
3114//     $code = 3; }
3115//   elsif ($$status{error} && $$status{error} > 0) {
3116//     $code = 2; }
3117//   elsif ($$status{warning} && $$status{warning} > 0) {
3118//     $code = 1; }
3119//   else {
3120//     $code = 0; }
3121//   return $code; }
3122// #======================================================================
3123
3124// TODO: Continue here -- need to diagnose why the indirect model is not returning
3125// an intermediate "ltx:p" when asking for "#PCDATA" inside "ltx:_CaptureBlock_",
3126// instead getting an intermediate "ltx:para".
3127
3128/// The indirect model includes all elements allowed as direct children,
3129/// and all descendents of a node that can be inserted after autoOpen'ing intermediate elements.
3130///
3131/// This model therefor includes information from the Schema, as well as
3132/// `auto_open` information that may be introduced in binding files.
3133// [Thus it should NOT be modifying the Model object, which may cover several documents in Daemon]
3134// `imodel[tag][child] => inter` means if in `tag`, to open `child`, we must first open `inter`
3135pub fn compute_indirect_model() -> IndirectModel {
3136  let mut imodel: IndirectModel = SymHashMap::default();
3137  // Determine any indirect paths to each descendent via an `autoOpen-able' tag.
3138  // Perl Document.pm L196-199 maps the `autoOpen` property to a fractional
3139  // OPENABILITY. Most tags get 1.0; `ltx:picture` gets 0.5 (L4995) so it
3140  // loses path-priority against full auto-openers (para, p, text, item, …).
3141  // We scale to u32 (100 = full, 50 = half) to keep integer arithmetic; the
3142  // `desirability * openability / 100` recursion mirrors Perl's float math.
3143  let mut openability: SymHashMap<u32> = SymHashMap::default();
3144  // Collect all known tags: from the schema model AND from state tag_properties
3145  let mut all_tags: HashSet<SymStr> = model::get_tags().into_iter().collect();
3146  for tag in state!().tag_properties.keys() {
3147    all_tags.insert(*tag);
3148  }
3149  let picture_sym = pin!("ltx:picture");
3150  for tag in &all_tags {
3151    if let Some(x) = state!().tag_properties.get(tag)
3152      && let Some(true) = x.auto_open
3153    {
3154      // Perl: Tag('ltx:picture', autoOpen => 0.5). All other autoOpen
3155      // sites in the LaTeXML tree use `autoOpen => 1`, so a simple
3156      // `tag == ltx:picture` check reproduces the fraction faithfully.
3157      let priority = if *tag == picture_sym { 50u32 } else { 100u32 };
3158      openability.insert_sym(*tag, priority);
3159    }
3160  }
3161
3162  for tag in &all_tags {
3163    let tag = *tag;
3164    let mut desc: SymHashMap<SymHashMap<usize>> = SymHashMap::default();
3165    compute_indirect_model_aux(tag, None, 100, &mut openability, &mut desc);
3166    let desc_keys: Vec<SymStr> = desc.keys().copied().collect();
3167    for kid in desc_keys {
3168      // Find best path to `kid`.
3169      let mut best = 0;
3170      let mut desc_kid_keys: Vec<SymStr> =
3171        desc.entry_sym(kid).or_default().keys().copied().collect();
3172      // TODO: why sort?
3173      // Update: it appears that "ltx:p" and "ltx:para" in ltx:_CaptureBlock_ is one reason!!!
3174      desc_kid_keys.sort_by(|a, b| arena::with2(*a, *b, |astr, bstr| astr.cmp(bstr)));
3175      for start in desc_kid_keys {
3176        if tag != kid && tag != start {
3177          let start_entry = {
3178            let kid_entry = desc.entry_sym(kid).or_default();
3179            *kid_entry.entry_sym(start).or_insert(0)
3180          };
3181          if start_entry > best {
3182            imodel.entry_sym(tag).or_default().insert_sym(kid, start);
3183            {
3184              best = start_entry;
3185            }
3186          }
3187        }
3188      }
3189    }
3190  }
3191  // PATCHUP
3192  if model::is_permissive() {
3193    // !!! Alarm!!!
3194    imodel
3195      .entry("#Document")
3196      .or_default()
3197      .insert("#PCDATA", arena::pin_static("ltx:p"));
3198  }
3199
3200  imodel
3201}
3202
3203// Package helpers used in core need to be localized here -- as state methods
3204/// `Let` macro setter
3205pub fn let_i(token1: &Token, token2: &Token, scope: Option<Scope>) {
3206  let meaning =// if token2.get_dont_expand().is_some() {
3207  //   Stored::Token(token2.clone())
3208  // } else {
3209    lookup_meaning(token2)
3210      .unwrap_or(Stored::None);
3211  // };
3212  // Deep-copy the robust-wrapper pair.
3213  //
3214  // Our `DefConstructor`/`DefMacro` with `robust => true` stores the
3215  // public CS (e.g. `\ref`) as an Expandable wrapper that expands to
3216  // `\protect \<cs><space>`. The actual body lives under a SEPARATE
3217  // `\<cs><space>` slot. A plain `\let \origref \ref` would copy
3218  // only the wrapper — leaving the `\ref<space>` body shared between
3219  // `\origref` and `\ref`. A subsequent `\DeclareRobustCommand \ref
3220  // {...}` then overwrites `\ref<space>` and `\origref` silently
3221  // tracks the new body — often causing an infinite loop when the
3222  // new body references `\origref` itself (a common LaTeX idiom for
3223  // adding starred-form support: `\let\origref\ref
3224  // \DeclareRobustCommand\ref{\@ifstar\origref\origref}`).
3225  //
3226  // Match upstream LaTeX semantics by also `\let`ing the body half:
3227  // `\let \origref<space> \ref<space>` so the two CSes own
3228  // independent body slots and remain decoupled.
3229  //
3230  // Witnesses: canvas-3 stage-23 0810.0695 (PlanarMain.tex's
3231  // `\ifpdf...\else \let\origref\ref \DeclareRobustCommand\ref{
3232  // \@ifstar\origref\origref}\fi` triggers via the else-branch
3233  // because ifpdf.sty defaults `\ifpdf` to false in LaTeXML).
3234  // Recognize the robust-wrapper expansion `\protect \<name><space>`
3235  // by shape: a 2-token Expandable body matching exactly those tokens
3236  // where the second token's CS name equals `<token2-name><space>`.
3237  if let Stored::Expandable(ref defn) = meaning
3238    && let Some(ExpansionBody::Tokens(ref tks)) = defn.expansion
3239  {
3240    let body = tks.unlist_ref();
3241    if body.len() == 2 && body[0].with_str(|s| s == "\\protect") {
3242      let expected_body_name = token2.with_str(|s| s!("{s} "));
3243      if body[1].with_str(|s| s == expected_body_name) {
3244        // (1) Copy `\<token2><space>` body to `\<token1><space>`
3245        // so the two CSes have independent body slots.
3246        let token1_space = crate::T_CS!(token1.with_str(|s| s!("{s} ")));
3247        let token2_space = crate::T_CS!(expected_body_name);
3248        let body_meaning = lookup_meaning(&token2_space).unwrap_or(Stored::None);
3249        let body_csname_sym = token1_space.pin_cs_name();
3250        state_mut!().assign_internal(TableName::Meaning, body_csname_sym, body_meaning, scope);
3251        // (2) Install `\<token1>` as a NEW robust wrapper that
3252        // points to `\<token1><space>` (rather than reusing
3253        // `\<token2>`'s wrapper, which still hardcodes
3254        // `\<token2><space>` in its body and would silently
3255        // re-track any later `\DeclareRobustCommand\<token2>{...}`).
3256        let new_wrapper_body = Tokens::new(vec![crate::T_CS!("\\protect"), token1_space]);
3257        let new_wrapper = Expandable::new(
3258          *token1,
3259          None,
3260          Some(ExpansionBody::Tokens(new_wrapper_body)),
3261          Some(expandable::ExpandableOptions {
3262            robust: true,
3263            ..expandable::ExpandableOptions::default()
3264          }),
3265        );
3266        if let Ok(wrapper) = new_wrapper {
3267          install_definition(wrapper, scope);
3268          after_assignment();
3269          return;
3270        }
3271      }
3272    }
3273  }
3274  assign_meaning(token1, meaning, scope);
3275  after_assignment();
3276}
3277/// `XEquals` check for two token arguments
3278pub fn x_equals(token1: &Token, token2: &Token) -> bool {
3279  let def1_opt = lookup_meaning(token1); // # token, definition object or None
3280  let def2_opt = lookup_meaning(token2); // ditto
3281  match (def1_opt, def2_opt) {
3282    (Some(def1), Some(def2)) => def1 == def2, // If both have defns, must be same defn!
3283    (None, None) => true,                     // true if both undefined
3284    (..) => false,                            // False, if only one has 'meaning'
3285  }
3286}
3287
3288/// simple id generator for a ligature
3289pub fn generate_ligature_id() -> usize {
3290  let id = 1 + lookup_int("autogen_ligature_id");
3291  assign_value("autogen_ligature_id", Stored::Int(id), Scope::Global);
3292  id as usize
3293}
3294
3295/// run the accumulated directives from `\afterassignment`
3296pub fn after_assignment() {
3297  match remove_value_sym(pin!("afterAssignment")) {
3298    Some(Stored::Tokens(after)) => gullet::unread(after),
3299    Some(Stored::Token(after)) => gullet::unread_one(after),
3300    None | Some(Stored::None) => {},
3301    Some(other) => panic!("unexpected in after_assignment: {other:?}"),
3302  }
3303}
3304
3305// Ported from Perl's "local" declarations
3306
3307pub fn get_tag_property(tag: SymStr) -> TagOptions { state_mut!().ensure_tag_property(tag).clone() }
3308pub fn ensure_tag_property(tag: SymStr) { state_mut!().ensure_tag_property(tag); }
3309
3310pub fn with_tag_property<R, FnR>(tag: SymStr, caller: FnR) -> R
3311where FnR: FnOnce(Option<&TagOptions>) -> R {
3312  caller(state!().tag_properties.get(&tag))
3313}
3314pub fn with_tag_property_mut<R, FnR>(tag: SymStr, caller: FnR) -> R
3315where FnR: FnOnce(&mut TagOptions) -> R {
3316  ensure_tag_property(tag);
3317  caller(state_mut!().tag_properties.get_mut(&tag).unwrap())
3318}
3319
3320pub fn has_indirect_model() -> bool { state!().indirect_model.is_some() }
3321pub fn set_indirect_model(im: IndirectModel) {
3322  let mut state = state_mut!();
3323  state.indirect_model = Some(im);
3324}
3325pub fn get_nomathparse_flag() -> bool { state!().nomathparse }
3326pub fn set_nomathparse_flag(val: bool) {
3327  let mut state = state_mut!();
3328  state.nomathparse = val;
3329}
3330
3331/// Whether source-locator (`--source-map`) tracking + emission is on.
3332/// Read by the source-provenance machinery (mouth token-start capture,
3333/// `Document::absorb` `data-sourcepos` stamping) to stay zero-cost when off.
3334/// See `docs/performance/SOURCE_PROVENANCE.md`.
3335pub fn source_map_enabled() -> bool { state!().source_map }
3336pub fn set_source_map_flag(val: bool) {
3337  let mut state = state_mut!();
3338  state.source_map = val;
3339}
3340
3341/// Find-or-append a source file in the document-level `sources` table,
3342/// returning its integer `tag` (index). The per-element `data-sourcepos`
3343/// attribute carries this compact integer rather than a path — the
3344/// Source-Map-v3 `sources` convention (compact + anonymisable). Only
3345/// called on the source-map path. See `docs/performance/SOURCE_PROVENANCE.md` §0.1.
3346pub fn source_tag(source: SymStr) -> u32 {
3347  let mut state = state_mut!();
3348  if let Some(idx) = state.source_table.iter().position(|s| *s == source) {
3349    idx as u32
3350  } else {
3351    state.source_table.push(source);
3352    (state.source_table.len() - 1) as u32
3353  }
3354}
3355
3356/// Snapshot of the `sources` table (index = tag) for emitting the
3357/// document-level tag→file header.
3358pub fn source_table_snapshot() -> Vec<SymStr> { state!().source_table.clone() }
3359
3360/// Record a *named* source in the opened-sources read-log. Called from
3361/// `Mouth::create` for file and cached-content mouths — a cold path (one
3362/// call per file open, not per token).
3363pub fn record_opened_source(source: SymStr) { state_mut!().opened_sources.insert(source); }
3364
3365/// Snapshot of the opened-sources read-log (see `record_opened_source`).
3366pub fn opened_sources_snapshot() -> Vec<SymStr> {
3367  state!().opened_sources.iter().copied().collect()
3368}
3369
3370pub fn current_verbosity() -> i32 { state!().verbosity }
3371
3372pub fn push_pending_resource(value: Resource) { state_mut!().pending_resources.push(value); }
3373pub fn take_pending_resources() -> Vec<Resource> {
3374  std::mem::take(&mut state_mut!().pending_resources)
3375}
3376pub fn reset_pending_resources() { state_mut!().pending_resources = Vec::new(); }
3377pub fn get_indirect_model_relationship(tag: SymStr, childtag: SymStr) -> Option<SymStr> {
3378  match state!().indirect_model.as_ref().unwrap().get_sym(tag) {
3379    Some(sub_m) => sub_m.get_sym(childtag).copied(),
3380    None => None,
3381  }
3382}
3383
3384pub fn get_bindings_dispatch() -> Option<ResolvingBindingDispatcher> {
3385  state!().bindings_dispatch.clone()
3386}
3387pub fn get_extra_bindings_dispatch() -> Option<BindingDispatcher> {
3388  state!().extra_bindings_dispatch.clone()
3389}
3390pub fn set_bindings_dispatch(dispatcher: ResolvingBindingDispatcher) {
3391  let mut state = state_mut!();
3392  state.bindings_dispatch = Some(dispatcher);
3393}
3394pub fn set_extra_bindings_dispatch(dispatcher: BindingDispatcher) {
3395  let mut state = state_mut!();
3396  state.extra_bindings_dispatch = Some(dispatcher);
3397}
3398
3399/// Snapshot of all registered (name, ext) binding pairs across all
3400/// dispatchers. Used by `find_file(notex=true)` to detect compiled-binding
3401/// existence regardless of extension (cls/sty/def/pool/code.tex/...).
3402pub fn get_binding_names() -> Vec<&'static [(&'static str, &'static str)]> {
3403  state!().binding_names.clone()
3404}
3405/// Append one crate's `(name, ext)` slice. Companion to
3406/// `set_bindings_dispatch` / `set_extra_bindings_dispatch` — call alongside
3407/// dispatcher registration so `find_file` can resolve compile-time
3408/// bindings. Duplicates are deduplicated by pointer so repeated calls from
3409/// the same crate don't inflate the fallback pool.
3410pub fn add_binding_names(names: &'static [(&'static str, &'static str)]) {
3411  let mut state = state_mut!();
3412  let ptr = names.as_ptr();
3413  if state.binding_names.iter().any(|s| s.as_ptr() == ptr) {
3414    return;
3415  }
3416  state.binding_names.push(names);
3417}
3418
3419/// Filtered view of `get_binding_names()` returning ONLY class names
3420/// (without `.cls` suffix). Used by `load_class` for Perl's prefix-match
3421/// fallback (Package.pm L2702-2706). Returns a flat `Vec<&str>` rather
3422/// than per-crate slices — callers that need to preserve crate boundaries
3423/// should iterate `get_binding_names()` directly.
3424pub fn get_class_binding_names() -> Vec<&'static str> {
3425  state!()
3426    .binding_names
3427    .iter()
3428    .flat_map(|slice| slice.iter())
3429    .filter(|(_, ext)| *ext == "cls")
3430    .map(|(name, _)| *name)
3431    .collect()
3432}
3433
3434/// `true` when at least one registered binding declares `ext` as its
3435/// extension. Used by `\input`'s heuristic to decide whether
3436/// `\input{name.<ext>}` should consult the binding registry — e.g.
3437/// `.sty`, `.cls`, `.def`, `.pool`, `code.tex` are all valid binding
3438/// extensions, while `.eps`, `.png`, `.bib` are not. Matches by extension
3439/// only (the `name` is checked separately by `dispatch()`'s exact lookup).
3440pub fn is_binding_extension(ext: &str) -> bool {
3441  state!()
3442    .binding_names
3443    .iter()
3444    .any(|slice| slice.iter().any(|(_, e)| *e == ext))
3445}
3446
3447/// `true` when a binding is registered for the exact `(name, ext)` pair.
3448/// Convenience wrapper over the per-crate slices in `binding_names`.
3449/// Mirrors `dispatch()`'s lookup but without the side effect of loading.
3450pub fn binding_exists(name: &str, ext: &str) -> bool {
3451  state!()
3452    .binding_names
3453    .iter()
3454    .any(|slice| slice.iter().any(|(n, e)| *n == name && *e == ext))
3455}
3456
3457pub fn get_label_mapping_hook() -> Option<LabelMappingHook> { state!().label_mapping_hook.clone() }
3458pub fn set_label_mapping_hook(hook: LabelMappingHook) {
3459  let mut state = state_mut!();
3460  state.label_mapping_hook = Some(hook);
3461}
3462
3463/// Read SEARCHPATHS from the group-scoped value table (Perl
3464/// `LookupValue('SEARCHPATHS')`). Mirrors [`get_graphics_paths`]: the list is a
3465/// group-scoped value, not a plain field, so an `\import`/`\subimport` group
3466/// reverts its change at `}` and a package's global add persists.
3467pub fn get_search_paths() -> Vec<String> {
3468  lookup_value("SEARCHPATHS")
3469    .map(|v| match v {
3470      Stored::Strings(syms) => syms.iter().map(|s| arena::to_string(*s)).collect(),
3471      Stored::VecDequeStored(vdq) => vdq
3472        .iter()
3473        .filter_map(|item| match item {
3474          Stored::String(s) => Some(arena::to_string(*s)),
3475          _ => None,
3476        })
3477        .collect(),
3478      _ => Vec::new(),
3479    })
3480    .unwrap_or_default()
3481}
3482pub fn with_search_paths<R, FnR>(caller: FnR) -> R
3483where FnR: FnOnce(&[String]) -> R {
3484  caller(&get_search_paths())
3485}
3486/// Global append (Perl `PushValue(SEARCHPATHS)`) — a persistent search dir.
3487pub fn add_search_path(path: String) {
3488  let mut paths = get_search_paths();
3489  paths.push(path);
3490  set_search_paths(paths);
3491}
3492/// Global prepend (Perl `UnshiftValue(SEARCHPATHS)`) — a persistent search dir.
3493pub fn search_paths_push_front(path: String) {
3494  let mut paths = get_search_paths();
3495  paths.insert(0, path);
3496  set_search_paths(paths);
3497}
3498/// Replace SEARCHPATHS GLOBALLY (Perl `AssignValue(SEARCHPATHS => [...], 'global')`).
3499/// For the local-by-default `\import` scoping, use [`set_search_paths_local`].
3500pub fn set_search_paths(paths: Vec<String>) { assign_search_paths(paths, Scope::Global); }
3501/// Replace SEARCHPATHS in the CURRENT group only (Perl `AssignValue(SEARCHPATHS
3502/// => [...])` default-local): reverted when the enclosing `\import`/`\subimport`
3503/// group closes. This is what makes `import.sty` faithful without an explicit
3504/// save/restore stack.
3505pub fn set_search_paths_local(paths: Vec<String>) { assign_search_paths(paths, Scope::Local); }
3506fn assign_search_paths(paths: Vec<String>, scope: Scope) {
3507  let vdq: VecDeque<Stored> = paths
3508    .into_iter()
3509    .map(|p| Stored::String(arena::pin(&p)))
3510    .collect();
3511  assign_value("SEARCHPATHS", Stored::VecDequeStored(vdq), Some(scope));
3512}
3513pub fn has_search_paths() -> bool { !get_search_paths().is_empty() }
3514/// Mirror Perl's `LookupValue('GRAPHICSPATHS')` — a list value that all
3515/// `\graphicspath`, `\svgpath`, initial source-directory prepends, and
3516/// `image_candidates` consult. Always return as `Vec<String>` even if the
3517/// value was stored as `Strings` (initial assignValue) or `VecDequeStored`
3518/// (after any push/unshift).
3519pub fn get_graphics_paths() -> Vec<String> {
3520  lookup_value("GRAPHICSPATHS")
3521    .map(|v| match v {
3522      Stored::Strings(syms) => syms.iter().map(|s| arena::to_string(*s)).collect(),
3523      Stored::VecDequeStored(vdq) => vdq
3524        .iter()
3525        .filter_map(|item| match item {
3526          Stored::String(s) => Some(arena::to_string(*s)),
3527          _ => None,
3528        })
3529        .collect(),
3530      _ => Vec::new(),
3531    })
3532    .unwrap_or_default()
3533}
3534
3535/// Zero-alloc membership test for GRAPHICSPATHS. Mirrors the Perl idiom
3536/// `grep { $_ eq $dir } @{ $state->lookupValue('GRAPHICSPATHS') }` but
3537/// without allocating an owned `Vec<String>` for a single boolean — the
3538/// interned-symbol `with`/`with2` family resolves each path in place.
3539pub fn graphics_paths_contains(needle: &str) -> bool {
3540  lookup_value("GRAPHICSPATHS")
3541    .map(|v| match v {
3542      Stored::Strings(syms) => syms.iter().any(|s| arena::with(*s, |p| p == needle)),
3543      Stored::VecDequeStored(vdq) => vdq.iter().any(|item| match item {
3544        Stored::String(s) => arena::with(*s, |p| p == needle),
3545        _ => false,
3546      }),
3547      _ => false,
3548    })
3549    .unwrap_or(false)
3550}
3551
3552/// Mirror Perl's `$state->unshiftValue(GRAPHICSPATHS => $dir)`. Used by
3553/// Core.pm-style source-directory prepends.
3554pub fn graphics_paths_push_front(path: String) {
3555  let key = arena::pin("GRAPHICSPATHS");
3556  let entry = Stored::String(arena::pin(&path));
3557  let mut state = state_mut!();
3558  if !state.value.contains_key(&key) {
3559    state.assign_internal(
3560      TableName::Value,
3561      key,
3562      Stored::VecDequeStored(VecDeque::new()),
3563      Some(Scope::Global),
3564    );
3565  }
3566  let receiver = state.value.get_mut(&key).unwrap().front_mut();
3567  match receiver {
3568    Some(Stored::VecDequeStored(vdq)) => vdq.push_front(entry),
3569    Some(Stored::Strings(syms)) => {
3570      let mut vdq: VecDeque<Stored> = syms.iter().map(|s| Stored::String(*s)).collect();
3571      vdq.push_front(entry);
3572      state.assign_internal(
3573        TableName::Value,
3574        key,
3575        Stored::VecDequeStored(vdq),
3576        Some(Scope::Global),
3577      );
3578    },
3579    _ => {
3580      let mut vdq = VecDeque::new();
3581      vdq.push_front(entry);
3582      state.assign_internal(
3583        TableName::Value,
3584        key,
3585        Stored::VecDequeStored(vdq),
3586        Some(Scope::Global),
3587      );
3588    },
3589  }
3590}
3591
3592/// manage a (global) hash of values
3593pub fn with_mapping<R, FnR>(map: &str, key: &str, caller: FnR) -> R
3594where FnR: FnOnce(Option<&Stored>) -> R {
3595  let map_sym = arena::pin(map);
3596  caller(match state!().value.get(&map_sym) {
3597    None => None,
3598    Some(map_vec) => match map_vec.front() {
3599      Some(Stored::HashStored(h)) => h.get(key),
3600      _ => None,
3601    },
3602  })
3603}
3604
3605pub fn with_mapping_sym<R, FnR>(map: SymStr, key: SymStr, caller: FnR) -> R
3606where FnR: FnOnce(Option<&Stored>) -> R {
3607  caller(match state!().value.get(&map) {
3608    None => None,
3609    Some(map_vec) => match map_vec.front() {
3610      Some(Stored::HashStored(h)) => h.get_sym(key),
3611      _ => None,
3612    },
3613  })
3614}
3615
3616pub fn with_mapping_keys<R, FnR>(map: &str, caller: FnR) -> R
3617where FnR: FnOnce(Vec<SymStr>) -> R {
3618  caller(state!().lookup_mapping_keys(map))
3619}
3620
3621pub fn with_font_info<R, FnR>(key: &Token, caller: FnR) -> R
3622where FnR: FnOnce(Result<Option<&Stored>>) -> R {
3623  caller(state!().lookup_font_info(key))
3624}
3625
3626pub fn get_input_encoding() -> Option<SymStr> { state!().input_encoding.as_ref().map(arena::pin) }
3627pub fn set_input_encoding(val: Option<String>) {
3628  let mut state = state_mut!();
3629  state.input_encoding = val;
3630}
3631
3632pub fn with_stacked_values<R, FnR>(key: &str, caller: FnR) -> R
3633where FnR: FnOnce(Vec<&Stored>) -> R {
3634  caller(state!().lookup_stacked_values(key))
3635}
3636/// Sym-keyed variant of `with_stacked_values`.
3637pub fn with_stacked_values_sym<R, FnR>(key: SymStr, caller: FnR) -> R
3638where FnR: FnOnce(Vec<&Stored>) -> R {
3639  caller(state!().lookup_stacked_values_sym(key))
3640}
3641
3642pub fn set_state(incoming_state: State) {
3643  // Reset state rotation to Main to prevent stale Sty/Std state from previous runs
3644  STATE_IN_USE.set(RotateState::Main);
3645  let mut global_state = state_mut!();
3646  *global_state = incoming_state;
3647}
3648
3649/// Check whether a Stored value can be serialized for the kernel dump.
3650/// Values containing closures (Primitive, Constructor, Conditional, etc.)
3651/// cannot be serialized — they come from Rust engine code, not the dump.
3652/// This matches Perl's DumpFile which only serializes Expandable macros.
3653pub fn is_serializable(stored: &Stored) -> bool {
3654  use Stored::*;
3655  match stored {
3656    // Data types: always serializable
3657    None | Bool(_) | String(_) | Charcode(_) | Int(_) | Catcode(_) => true,
3658    Token(_) | Tokens(_) | Number(_) | Float(_) => true,
3659    Glue(_) | MuGlue(_) | Dimension(_) | MuDimension(_) => true,
3660    Reversion(_) | KeyVal(_) => true,
3661    Chars(_) | Strings(_) => true,
3662    // Expandable: serializable when body is Tokens OR None (regular
3663    // macros). Closure-bodied Expandables (e.g. `\expandafter`,
3664    // `\unexpanded`, `\the` — defined via `DefMacro!` with a closure
3665    // body) ALSO pass — dump_writer's `serialize_stored` emits a PA
3666    // alias to the canonical CS so `\let \tex_expandafter:D
3667    // \expandafter`-style aliases survive the dump. (Bug C parity fix
3668    // — see project_kernel_dump_tdd.md.) The writer's add-only policy
3669    // at load time skips entries whose key is already defined in the
3670    // compiled engine, so primary CSes don't double-bind.
3671    Expandable(_) => true,
3672    // Register: serializable (stores value + type, no closures)
3673    Register(_) => true,
3674    // Font: serializable (data only)
3675    Font(_) => true,
3676    // Primitives/MathPrimitives/Conditionals: the CLOSURE can't be
3677    // serialized, but each carries its own canonical CS name. If the
3678    // entry's key differs from that canonical CS, this is a `\let`-alias
3679    // we CAN capture (as a "PA" pointer) so the dump reader replays the
3680    // `\let` at load time. dump_writer returns the PA tag; dump_reader
3681    // re-applies via state::let_i. This is how \tex_let:D, \tex_def:D,
3682    // \tex_ifx:D, \if_meaning:w, and the hundreds of other expl3-renamed
3683    // primitives + conditionals survive the dump without needing to re-run
3684    // 36k lines of expl3-code.tex.
3685    //
3686    // Returning true here only means "pass to dump_writer"; the writer's
3687    // serialize_stored emits the PA target. Self-aliases (primary CSes
3688    // not yet aliased anywhere) typically don't appear in the diff because
3689    // they're in the pre-snapshot — but if they do, the dump reader skips
3690    // them by comparing key to target.
3691    Primitive(_) | MathPrimitive(_) | Conditional(_) => true,
3692    // Constructor: same logic as Primitive/Conditional. Constructors carry a
3693    // closure body the dump can't serialize, BUT they each carry a canonical
3694    // CS field. When the entry key differs from that CS, it's a `\let`-alias
3695    // (e.g. `\let \tex_par:D \par` where `\par` is itself a `Let!` alias to
3696    // `\lx@normal@par` — a Constructor). dump_writer emits `PA\t<cs>`;
3697    // dump_reader replays via `state::let_i`. Mirrors Perl's writer:
3698    // `dump_constructor` is undefined in `Dumper.pm`, but TeX_Job.pool
3699    // `DumpFile`'s let-detection branch (L184-198) catches the (key !=
3700    // value->getCSName) case and emits `Lt(key, letkey)`. Without this,
3701    // `\tex_par:D`, `\tex_cr:D`, `\tex_noindent:D`, etc. drop from the dump
3702    // because diff_from_snapshot filters them before the writer's
3703    // Constructor arm sees them.
3704    Constructor(_) => true,
3705    // Collections: serializable if contents are
3706    VecDequeStored(_) | HashStored(_) | HashString(_) => true,
3707    // Everything else: skip for safety
3708    _ => false,
3709  }
3710}
3711
3712/// Take a snapshot of the current State (for dump diff).
3713pub fn take_snapshot() -> rustc_hash::FxHashMap<(TableName, SymStr), Stored> { state!().snapshot() }
3714
3715/// Compute diff from snapshot and return changed serializable entries.
3716pub fn diff_snapshot(
3717  snap: &rustc_hash::FxHashMap<(TableName, SymStr), Stored>,
3718) -> Vec<(TableName, SymStr, Stored)> {
3719  state!().diff_from_snapshot(snap)
3720}
3721
3722// Thread-local holder for the snapshot taken at a named init phase.
3723// Currently only "bootstrap" is used: when `latex.rs` finishes loading
3724// `latex_bootstrap`, it stashes the state snapshot here. `ini_tex::dump_format`
3725// reads it so its diff matches Perl's `DumpFile` semantics — "what did raw
3726// latex.ltx + the rest of the engine init add on top of pure bootstrap".
3727// Without this hook the snapshot is taken after `_base.rs` + `_constructs.rs`
3728// have also run, making the diff far narrower than Perl's dump. See
3729// SYNC_STATUS D0 (d.1).
3730type StateSnapshot = rustc_hash::FxHashMap<(TableName, SymStr), Stored>;
3731type StagedSnapshotMap = rustc_hash::FxHashMap<&'static str, StateSnapshot>;
3732
3733thread_local! {
3734  static STAGED_SNAPSHOTS: RefCell<StagedSnapshotMap> =
3735    RefCell::new(rustc_hash::FxHashMap::default());
3736}
3737
3738/// Take a snapshot now and store it under a named key for later retrieval.
3739/// Intended for phased engine init (e.g. `stage_snapshot("bootstrap")` called
3740/// right after `latex_bootstrap` has loaded).
3741pub fn stage_snapshot(name: &'static str) {
3742  let snap = take_snapshot();
3743  STAGED_SNAPSHOTS.with(|m| {
3744    m.borrow_mut().insert(name, snap);
3745  });
3746}
3747
3748/// Stage an already-taken snapshot under a named key. Used by callers
3749/// (like `ini_tex`) that want to snapshot at a specific point without
3750/// waiting for a pool hook.
3751pub fn stage_snapshot_value(
3752  name: &'static str,
3753  snap: rustc_hash::FxHashMap<(TableName, SymStr), Stored>,
3754) {
3755  STAGED_SNAPSHOTS.with(|m| {
3756    m.borrow_mut().insert(name, snap);
3757  });
3758}
3759
3760/// Retrieve a previously staged snapshot, if present.
3761pub fn get_staged_snapshot(
3762  name: &str,
3763) -> Option<rustc_hash::FxHashMap<(TableName, SymStr), Stored>> {
3764  STAGED_SNAPSHOTS.with(|m| m.borrow().get(name).cloned())
3765}
3766
3767#[cfg(test)]
3768mod reentrancy_tests {
3769  use super::*;
3770
3771  /// `try_lookup_int` must degrade to `None` under a live mutable borrow
3772  /// (contention) instead of panicking, while behaving like `lookup_int`
3773  /// otherwise. This is the load-bearing primitive of the `Error!`-during-
3774  /// `state_mut()` fix (tikz-cd 2001.08973).
3775  #[test]
3776  fn try_lookup_int_degrades_on_contention() {
3777    // Absent key, no contention → Some(0), matching lookup_int's default.
3778    assert_eq!(try_lookup_int("p1a_absent_key_xyz"), Some(0));
3779    // A live mutable borrow → None (cannot read), no panic.
3780    let _guard = (*STATE).borrow_mut();
3781    assert_eq!(try_lookup_int("MAX_ERRORS"), None);
3782  }
3783
3784  /// Reproduces tikz-cd 2001.08973: `push_value` into a non-VecDeque field
3785  /// hits the BUG-path `Error!`, which reads `MAX_ERRORS`. Before the fix,
3786  /// `push_value` held `state_mut!()` across that `Error!`, panicking
3787  /// "RefCell already mutably borrowed". It must now report the BUG and
3788  /// return Ok without panicking.
3789  #[test]
3790  fn push_value_bug_path_is_borrow_safe() {
3791    assign_value("p1a_bug_key", Stored::Int(7), Some(Scope::Global));
3792    let r = push_value("p1a_bug_key", Stored::Int(1));
3793    assert!(r.is_ok());
3794    // Same guarantee for the pop side.
3795    let r2 = pop_value("p1a_bug_key");
3796    assert!(r2.is_ok());
3797  }
3798
3799  /// `Scope::InPlace` (Perl `State.pm:175` 'inplace') is the same-level
3800  /// reassignment behind the Rhai `LookupDefinition(cs).push*` hook-splice
3801  /// (`install_definition(d, Some(Scope::InPlace))`). It must be neither Global
3802  /// nor Local across a group boundary — this is exactly the divergence
3803  /// @xworld21 flagged in PR #333 (r3623947537). Exercised on the Value table,
3804  /// which funnels through the identical `assign_internal` arm.
3805  #[test]
3806  fn inplace_scope_keeps_the_bindings_level() {
3807    // Scenario 1: a value bound ABOVE the group, mutated in-place from INSIDE
3808    // the group, PERSISTS past group exit (Local would have reverted it). This
3809    // is BookML's real case: patch an already-global def, mutation stays.
3810    assign_value("ip_above", Stored::Int(1), Some(Scope::Global));
3811    push_frame();
3812    assign_value("ip_above", Stored::Int(2), Some(Scope::InPlace));
3813    assert_eq!(
3814      lookup_int("ip_above"),
3815      2,
3816      "in-place mutation is active at once"
3817    );
3818    pop_frame().expect("pop group");
3819    assert_eq!(
3820      lookup_int("ip_above"),
3821      2,
3822      "in-place patch of an outer-bound value rode the outer binding past group \
3823       exit (Local would revert to 1)"
3824    );
3825
3826    // Scenario 2: a value LOCALLY redefined in the group, then mutated in-place,
3827    // REVERTS to the outer value at group exit (Global would have kept the
3828    // patch). The in-place edit rode the LOCAL binding, which is discarded.
3829    assign_value("ip_local", Stored::Int(1), Some(Scope::Global));
3830    push_frame();
3831    assign_value("ip_local", Stored::Int(2), Some(Scope::Local));
3832    assign_value("ip_local", Stored::Int(3), Some(Scope::InPlace));
3833    assert_eq!(
3834      lookup_int("ip_local"),
3835      3,
3836      "in-place mutated the local front"
3837    );
3838    pop_frame().expect("pop group");
3839    assert_eq!(
3840      lookup_int("ip_local"),
3841      1,
3842      "in-place patch of a locally-bound value was discarded with the group \
3843       (Global would keep 3)"
3844    );
3845  }
3846
3847  /// `is_scope_active` must track the FRONT value's truthiness, not key presence:
3848  /// `deactivate_scope` OVERWRITES the front value with `Stored::Bool(false)`
3849  /// instead of removing the entry, so a presence test reports a deactivated
3850  /// scope as still active.
3851  /// Perl writes the same test inline as `$$self{stash_active}{$scope}[0]`
3852  /// (State.pm L682). The region property `subfile_scope_at_depth` relies on
3853  /// (OXIDIZED_DESIGN #65) is the second assertion: an activation made inside a
3854  /// group is undone by that group, with no matching teardown call.
3855  #[test]
3856  fn scope_activity_tracks_value_not_presence() {
3857    let scope = arena::pin("t@scope@activity");
3858    assert!(!is_scope_active(scope), "unknown scope must be inactive");
3859
3860    activate_scope(scope);
3861    assert!(is_scope_active(scope), "activated scope must read active");
3862
3863    deactivate_scope(scope);
3864    assert!(
3865      !is_scope_active(scope),
3866      "a deactivated scope must read INACTIVE — `stash_active` still holds the \
3867       key, carrying Stored::Bool(false)"
3868    );
3869    // Deactivation is a GLOBAL assign, which replaces rather than layers: the
3870    // stack collapses to exactly that one falsy value. This is why the front
3871    // value is the whole state, and why presence cannot be the activity test.
3872    let depth = state!()
3873      .stash_active
3874      .get(&scope)
3875      .map(|entry| entry.len())
3876      .unwrap_or(0);
3877    assert_eq!(
3878      depth, 1,
3879      "global assign must overwrite, leaving one value — not stack a second"
3880    );
3881
3882    // A second deactivation must be a silent no-op. Perl gates on `[0]` being
3883    // TRUE (State.pm L700) precisely so the binding-pop below it does not run
3884    // twice; re-running it pops values `activate_scope` never pushed, which is
3885    // what Perl's "Unassigning wrong value for KEY from table T in
3886    // deactivateScope" warning reports.
3887    deactivate_scope(scope);
3888    assert!(
3889      !is_scope_active(scope),
3890      "still inactive after a second deactivate"
3891    );
3892    let depth2 = state!()
3893      .stash_active
3894      .get(&scope)
3895      .map(|entry| entry.len())
3896      .unwrap_or(0);
3897    assert_eq!(
3898      depth2, 1,
3899      "a second deactivation must not stack another value"
3900    );
3901  }
3902
3903  /// A DEACTIVATED scope must be activatable again — Perl gates `activateScope`
3904  /// on `!$$self{stash_active}{$scope}[0]` (State.pm L682), the front value's
3905  /// truthiness. Gating on key presence instead made the first deactivation
3906  /// permanent, since `deactivate_scope` leaves `Stored::Bool(false)` behind.
3907  /// Reached in Perl by the counter/label scopes, which deactivate the old
3908  /// reference number before activating the next (`Package.pm` L774-779).
3909  #[test]
3910  fn a_deactivated_scope_can_be_reactivated() {
3911    let scope = arena::pin("t@scope@reactivate");
3912    activate_scope(scope);
3913    deactivate_scope(scope);
3914    assert!(!is_scope_active(scope), "precondition: deactivated");
3915
3916    activate_scope(scope);
3917    assert!(
3918      is_scope_active(scope),
3919      "re-activation after deactivation must take effect (Perl State.pm L682)"
3920    );
3921  }
3922
3923  /// The self-terminating half: `activate_scope` marks `StashActive` with
3924  /// `Scope::Local`, so the enclosing group ends the region by construction.
3925  #[test]
3926  fn scope_activation_is_bounded_by_its_group() {
3927    let scope = arena::pin("t@scope@bounded");
3928    push_frame();
3929    activate_scope(scope);
3930    assert!(
3931      is_scope_active(scope),
3932      "active inside the group that opened it"
3933    );
3934    pop_frame().expect("pop group");
3935    assert!(
3936      !is_scope_active(scope),
3937      "the region must end with its group — no explicit deactivate_scope"
3938    );
3939  }
3940}