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  let lock_key = token.with_cs_name(|cs| s!("{cs}:locked"));
1192  if lookup_bool(&lock_key) && !state_is_unlocked() {
1193    if let Some(Stored::String(s)) = state!().lookup_value("SOURCEFILE") {
1194      // report if the redefinition seems to come from document source
1195      if arena::with(*s, |txt| {
1196        txt == "Anonymous String" || TEX_OR_BIB_EXT_RE.is_match(txt) && !txt.ends_with(CODE_TEX_EXT)
1197      }) {
1198        // Perl `State.pm` L514 reports the CS itself — `Ignoring redefinition
1199        // of \cite` — not the lookup key. Reporting `lock_key` here named a
1200        // control sequence that does not exist (`\cite:locked`), so the one
1201        // diagnostic for a refused redefinition did not grep for the command
1202        // it was about.
1203        let cs_name = token.with_cs_name(ToString::to_string);
1204        Info!("ignore", cs_name, s!("Ignoring redefinition of {cs_name}"));
1205      }
1206    }
1207  } else {
1208    state_mut!().assign_internal(TableName::Meaning, cs_sym, definition, scope);
1209  }
1210}
1211
1212/// Generate a stub definition for an undefined control-sequence,
1213/// along with appropriate error messge.
1214pub fn generate_error_stub(token: &Token) -> Result<Token> {
1215  let cs = token.with_cs_name(ToString::to_string);
1216  // Perl-faithful counter leniency. A `\c@<ctr>` control sequence is, by
1217  // LaTeX convention, the count register backing counter `<ctr>`. When code
1218  // reads an *undefined* one in a number/register context (e.g.
1219  // `\setcounter{x}{\value{y}}` or `\algrestore`/`\ContinuedFloat` reading
1220  // `\c@subalgorithm@save`), Perl does NOT raise a hard "undefined control
1221  // sequence" error — its counter machinery warns "Counter '<ctr>' was not
1222  // defined; assuming 0" (Package.pm L712) and treats it as 0. Without this,
1223  // `read_x_token` expands the bare undefined `\c@<ctr>` through the generic
1224  // <ltx:ERROR/> path below and the run gains a spurious error. Mirror Perl:
1225  // warn and define the register as 0 so the reader sees a register value,
1226  // not an undefined CS. Same category/message as `counter::dialect::
1227  // counter_value`. Witness 1910.02851 (`\algrestore{RLZFactorization}` +
1228  // `\ContinuedFloat` → `\c@subalgorithm@save`); Perl rc=0.
1229  if let Some(ctr) = cs.strip_prefix("\\c@") {
1230    if !lookup_bool("SUPPRESS_UNDEFINED_ERRORS") {
1231      Warn!(
1232        "undefined",
1233        ctr,
1234        s!("Counter '{}' was not defined; assuming 0", ctr)
1235      );
1236    }
1237    crate::binding::def::dialect::def_register(*token, None, Number::new(0), None)?;
1238    return Ok(*token);
1239  }
1240  // Gate the undefined-CS summary tally by SUPPRESS_UNDEFINED_ERRORS so it
1241  // matches the `Error!` gate at L1021 below — during expl3-code.tex raw
1242  // load with thousands of forward-references we install the ERROR stub
1243  // without polluting the user-facing summary count. See
1244  // project_kernel_dump_parity.md "iow_wrap residual" for full diagnosis.
1245  if !lookup_bool("SUPPRESS_UNDEFINED_ERRORS") {
1246    note_status(LogStatus::Undefined, Some(&cs));
1247  }
1248  // To minimize chatter, go ahead and define it...
1249  if cs.starts_with("\\if") {
1250    // Apparently an \ifsomething ???
1251    let name = cs.replace("\\if", "");
1252    // Perl `generateErrorStub` (State.pm L539-540) passes the recovery note
1253    // as a SEPARATE Error detail, so `generateMessage` renders it on its own
1254    // indented line — not merged into the primary message. Match that (and
1255    // the already-correct stomach.rs path) so the cortex `details`/log first
1256    // line is just "...is not defined." like Perl.
1257    Error!(
1258      "undefined",
1259      token,
1260      s!("The token {} is not defined.", token.stringify()),
1261      "Defining it now as with \\newif"
1262    );
1263    install_definition(
1264      Expandable::new(
1265        T_CS!(s!("\\{}true", name)),
1266        None,
1267        Some(s!("\\let{}\\iftrue", cs).into()),
1268        None,
1269      )?,
1270      Some(Scope::Global),
1271    );
1272    install_definition(
1273      Expandable::new(
1274        T_CS!(s!("\\{}false", name)),
1275        None,
1276        Some(s!("\\let{}\\iffalse", cs).into()),
1277        None,
1278      )?,
1279      Some(Scope::Global),
1280    );
1281    let_i(token, &T_CS!("\\iffalse"), Some(Scope::Global));
1282  } else {
1283    // Allow suppression of undefined errors during bulk loading (e.g., expl3-code.tex)
1284    // where forward references are later resolved by post-load fixups.
1285    if !lookup_bool("SUPPRESS_UNDEFINED_ERRORS") {
1286      Error!(
1287        "undefined",
1288        token,
1289        s!("The token {} is not defined.", token.stringify()),
1290        "Defining it now as <ltx:ERROR/>"
1291      );
1292    }
1293    install_definition(
1294      Constructor {
1295        cs: *token,
1296        replacement: Some(Rc::new(move |document, _args, _props| {
1297          document.make_error("undefined", &cs)
1298        })),
1299        ..Constructor::default()
1300      },
1301      //TODO: sizer => "X"),
1302      Some(Scope::Global),
1303    );
1304  }
1305  Ok(*token)
1306}
1307
1308/// Install a `Constructor` for `token` whose sole effect at digestion time is to
1309/// emit `<ltx:ERROR class='undefined'>content</ltx:ERROR>` (the Rust equivalent
1310/// of Perl `Document::makeError`). It logs NOTHING — the caller is responsible
1311/// for the `Error!`/`note_status`. Mirrors the make_error constructor that
1312/// `generate_error_stub` installs for undefined *commands*, so undefined
1313/// *environments* (`\begin{undefinedenv}`) leave the same visible
1314/// `<ltx:ERROR>` marker as Perl instead of silently vanishing from the output.
1315pub fn install_undefined_error_constructor(token: Token, content: &str) {
1316  let content = content.to_string();
1317  install_definition(
1318    Constructor {
1319      cs: token,
1320      replacement: Some(Rc::new(move |document, _args, _props| {
1321        document.make_error("undefined", &content)
1322      })),
1323      ..Constructor::default()
1324    },
1325    Some(Scope::Global),
1326  );
1327}
1328
1329// SAFETY
1330// any method which does not return a borrowed piece of data should be package-level
1331// so that the global singleton State can get locked+unlocked during the same call
1332// thus entirely AVOIDING possible runtime panics due to RefCell lock races.
1333// TODO: Should this be a prelude?
1334
1335/// assigns a `Stored` value at the given key and scope
1336/// Direct mirror of Perl's free-function form
1337/// `LaTeXML::Core::State::assign_internal($STATE, $table, $key, $value, $scope)`
1338/// (Core/State.pm L140). Bypasses every dialect / lock / let-chase / admission
1339/// layer Rust has accreted on top of the table mutation; used by the dump
1340/// loader (Core/Dumper.pm `V/Cc/Mc/Sc/Lc/Uc/Dc/Im/I/Lt`) so the dump replay
1341/// matches Perl exactly: one record == one `assign_internal` call.
1342pub fn assign_internal<T: Into<Stored>>(
1343  table_name: TableName,
1344  key: SymStr,
1345  value: T,
1346  scope: Option<Scope>,
1347) {
1348  state_mut!().assign_internal(table_name, key, value.into(), scope);
1349}
1350
1351/// Bind `key` to `value` in the value table — Perl's `AssignValue`.
1352///
1353/// [`Scope`] decides how long the binding lasts: [`Scope::Local`] expires with
1354/// the enclosing TeX group, [`Scope::Global`] does not, [`Scope::Named`] applies
1355/// only while that scope is activated, and [`Scope::InPlace`] rebinds at the
1356/// frame the value was last bound in. Passing `None` takes the state's current
1357/// default. Read back with [`lookup_value`], or one of the typed
1358/// [`lookup_string`] / [`lookup_number`] / [`lookup_bool`] accessors.
1359pub fn assign_value<T: Into<Stored>, S: Into<Option<Scope>>>(key: &str, value: T, scope: S) {
1360  state_mut!().assign_value(key, value, scope)
1361}
1362
1363/// assigns a `Stored` value 'inplace': replaces the front value in whatever frame
1364/// it was originally assigned in, without recording an undo entry.
1365/// This matches Perl's `assignValue(key, value, 'inplace')`.
1366/// Used for MODE changes in enter_horizontal (switches mode without creating a new binding).
1367pub fn assign_value_inplace(key: &str, value: impl Into<Stored>) {
1368  assign_value_inplace_sym(arena::pin(key), value)
1369}
1370/// Sym-keyed variant of `assign_value_inplace` — skip the per-call
1371/// `arena::pin(key)` for hot callers with a pre-pinned SymStr.
1372pub fn assign_value_inplace_sym(key_sym: SymStr, value: impl Into<Stored>) {
1373  let value = value.into();
1374  let state = &mut *state_mut!();
1375  let table = &mut state.value;
1376  if let Some(vvec) = table.get_mut(&key_sym)
1377    && let Some(front) = vvec.front_mut()
1378  {
1379    *front = value;
1380    return;
1381  }
1382  // If the value was never assigned, push globally (matching Perl behavior)
1383  let vvec = table.entry(key_sym).or_default();
1384  vvec.push_front(value);
1385  // Find the locked frame and record the undo there
1386  for frame in &mut state.undo {
1387    if frame.locked {
1388      frame.table_mut(TableName::Value).insert(key_sym, 1);
1389      break;
1390    }
1391  }
1392}
1393
1394/// assigns a `Stored` value at the given (arena ticket!) key and scope
1395pub fn assign_value_sym<T: Into<Stored>, S: Into<Option<Scope>>>(key: SymStr, value: T, scope: S) {
1396  let value = value.into();
1397  let scope = scope.into();
1398  state_mut!().assign_internal(TableName::Value, key, value, scope);
1399}
1400
1401/// inline lookup_value after which globally assign an empty Tokens() to undo
1402pub fn remove_value(key: &str) -> Option<Stored> { remove_value_sym(arena::pin(key)) }
1403
1404/// `remove_value` variant for hot call sites with a pre-pinned SymStr (see
1405/// `crate::pin!`) — added for `after_assignment`, which fires on every
1406/// `\def`/`\let`/register assignment.
1407pub fn remove_value_sym(key_sym: SymStr) -> Option<Stored> {
1408  match state_mut!().value.get_mut(&key_sym) {
1409    None => None,
1410    Some(vvec) => match vvec.front_mut() {
1411      None | Some(&mut Stored::None) => None,
1412      Some(found) => Some(std::mem::take(found)),
1413    },
1414  }
1415}
1416/// Replaces the value in question with `Stored::None` (see `checkin_value` for returning it)
1417pub fn checkout_value(key: &str) -> Option<Stored> {
1418  match state_mut!().value.get_mut(&arena::pin(key)) {
1419    None => None,
1420    Some(vvec) => vvec.front_mut().map(std::mem::take),
1421  }
1422}
1423/// Returns a value into its `Stored::None` placeholder (see `checkout_value` for taking it)
1424pub fn checkin_value(key: &str, value: Stored) {
1425  match state_mut!().value.get_mut(&arena::pin(key)) {
1426    None => {
1427      // Key was never assigned — silently ignore the checkin
1428      emit_warn(
1429        "internal",
1430        "state",
1431        &format!("checkin_value called for unknown key '{key}'"),
1432      );
1433    },
1434    Some(vvec) => match vvec.front_mut() {
1435      None => {
1436        emit_warn(
1437          "internal",
1438          "state",
1439          &format!("checkin_value called with empty value stack for key '{key}'"),
1440        );
1441      },
1442      Some(found) => {
1443        match found {
1444          Stored::None => std::mem::replace(found, value),
1445          _ => panic!("checkin_value should only be called after checkout_value"),
1446        };
1447      },
1448    },
1449  }
1450}
1451/// manage a (global) list of values
1452pub fn push_value<T: Into<Stored>>(key: &str, value: T) -> Result<()> {
1453  let key_sym = arena::pin(key);
1454  let value = value.into();
1455  // Capture any BUG-path message, but raise the Error! *after* the state_mut!()
1456  // borrow is dropped — Error! reads MAX_ERRORS, and a live mutable borrow there
1457  // panics "RefCell already mutably borrowed" (tikz-cd 2001.08973).
1458  let bug: Option<String> = {
1459    let mut state = state_mut!();
1460    if !state.value.contains_key(&key_sym) {
1461      state.assign_internal(
1462        TableName::Value,
1463        key_sym,
1464        Stored::VecDequeStored(VecDeque::new()),
1465        Some(Scope::Global),
1466      );
1467    }
1468    match state.value.get_mut(&key_sym).unwrap().front_mut() {
1469      Some(&mut Stored::VecDequeStored(ref mut front)) => {
1470        front.push_back(value);
1471        None
1472      },
1473      // auto-vivify, if None
1474      Some(ref mut field) if matches!(field, Stored::None) => {
1475        let mut new_vdq = VecDeque::new();
1476        new_vdq.push_back(value);
1477        **field = Stored::VecDequeStored(new_vdq);
1478        None
1479      },
1480      // Convert Strings (immutable array) to VecDequeStored for push — matches Perl auto-vivification
1481      Some(ref mut field) if matches!(field, Stored::Strings(_)) => {
1482        let existing: VecDeque<Stored> = if let Stored::Strings(strings) = &**field {
1483          strings.iter().map(|s| Stored::String(*s)).collect()
1484        } else {
1485          VecDeque::new()
1486        };
1487        let mut new_vdq = existing;
1488        new_vdq.push_back(value);
1489        **field = Stored::VecDequeStored(new_vdq);
1490        None
1491      },
1492      other => Some(s!(
1493        "BUG: Tried to push_value into an unsupported Stored field! Field was: {other:?}"
1494      )),
1495    }
1496  };
1497  if let Some(message) = bug {
1498    // Lowercase category for consistency with engine convention.
1499    Error!("state", "Stored", message);
1500  }
1501  Ok(())
1502}
1503/// pops the last value in a named `Stored::VecDequeStored` queue, if any
1504pub fn pop_value(key: &str) -> Result<Option<Stored>> {
1505  let key_sym = arena::pin(key);
1506  // Compute the pop result under the borrow, then raise the BUG Error! *after*
1507  // dropping it — Error! reads MAX_ERRORS, which panics under a live mutable
1508  // borrow (mirrors push_value; tikz-cd 2001.08973).
1509  let popped: std::result::Result<Option<Stored>, ()> = {
1510    let mut state = state_mut!();
1511    if !state.value.contains_key(&key_sym) {
1512      state.assign_internal(
1513        TableName::Value,
1514        key_sym,
1515        Stored::VecDequeStored(VecDeque::new()),
1516        Some(Scope::Global),
1517      );
1518    }
1519    if let Some(&mut Stored::VecDequeStored(ref mut front)) =
1520      state.value.get_mut(&key_sym).unwrap().front_mut()
1521    {
1522      Ok(front.pop_back())
1523    } else {
1524      Err(())
1525    }
1526  };
1527  match popped {
1528    Ok(v) => Ok(v),
1529    Err(()) => {
1530      Error!(
1531        "State",
1532        "Stored",
1533        "BUG: Tried to pop_value from a non-vecdeque value key!"
1534      );
1535      Ok(None)
1536    },
1537  }
1538}
1539/// Check if the Value table contains a given key
1540pub fn has_value(key: &str) -> bool {
1541  let key_sym = arena::pin(key);
1542  match state!().value.get(&key_sym) {
1543    None => false,
1544    Some(list) => match list.front() {
1545      None => false,
1546      Some(v) => !matches!(v, &Stored::None),
1547    },
1548  }
1549}
1550/// Pushes Tokens into a `Stored::Tokens` value when defined,
1551/// or assigns when new.
1552pub fn push_tokens(key: &str, value: Tokens) {
1553  let mut state = state_mut!();
1554  match state.lookup_value_mut(key) {
1555    Some(Stored::Tokens(tks)) => tks.unlist_mut().extend(value.unlist()),
1556    None | Some(Stored::None) => state.assign_value(key, Stored::Tokens(value), None),
1557    Some(other) => panic!("Can only push_tokens into a Stored::Tokens, but got {other:?}"),
1558  }
1559}
1560
1561/// The value bound to `key`, or `None` when nothing is bound — Perl's
1562/// `LookupValue`.
1563///
1564/// Returns whatever [`Stored`] variant was assigned, so a caller that knows the
1565/// type usually wants [`lookup_string`] / [`lookup_number`] / [`lookup_bool`]
1566/// instead. Clones the value; [`with_value`] lends it when inspecting is enough.
1567pub fn lookup_value(key: &str) -> Option<Stored> { state!().lookup_value(key).cloned() }
1568pub fn with_value<R, FnR>(key: &str, caller: FnR) -> R
1569where FnR: FnOnce(Option<&Stored>) -> R {
1570  caller(state!().lookup_value(key))
1571}
1572/// Sym-keyed variant of `with_value` — avoids the per-call `arena::pin(key)`.
1573pub fn with_value_sym<R, FnR>(key: SymStr, caller: FnR) -> R
1574where FnR: FnOnce(Option<&Stored>) -> R {
1575  caller(state!().lookup_value_sym(key))
1576}
1577pub fn with_value_mut<R, FnR>(key: &str, caller: FnR) -> R
1578where FnR: FnOnce(Option<&mut Stored>) -> R {
1579  caller(state_mut!().lookup_value_mut(key))
1580}
1581/// Undo-stack depth (open TeX groups) — pass-1 streaming telemetry.
1582pub fn undo_depth() -> usize { state!().undo.len() }
1583
1584/// A bit of Perl "existence as truth" semantics mixed in with proper boolean lookup
1585pub fn lookup_bool(key: &str) -> bool { lookup_bool_sym(arena::pin(key)) }
1586
1587/// `lookup_bool` variant for hot call sites with a pre-pinned SymStr
1588/// (see `crate::pin!`). Skips the per-call `arena::pin(key)` hash
1589/// lookup — significant on every-expansion hot paths. `SymStr` is a
1590/// `u32` wrapper (Copy), so it passes by value — no borrow overhead.
1591pub fn lookup_bool_sym(key: SymStr) -> bool {
1592  let state = state!();
1593  match state.lookup_value_sym(key) {
1594    None => false,
1595    Some(v) => v.into(),
1596  }
1597}
1598
1599/// `lookup_string` variant using a pre-pinned SymStr key.
1600pub fn lookup_string_from_sym(key: SymStr) -> String {
1601  let state = state!();
1602  match state.lookup_value_sym(key) {
1603    None => String::new(),
1604    Some(v) => v.into(),
1605  }
1606}
1607/// like `lookup_value`, but casts the entry into a SymStr from the string interner
1608///  (`pin!("")` if None)
1609pub fn lookup_string_sym(key: &str) -> SymStr {
1610  let state = state!();
1611  match state.lookup_value(key) {
1612    None => pin!(""),
1613    Some(Stored::String(v)) => *v,
1614    Some(other) => arena::pin(other.to_string()),
1615  }
1616}
1617/// like `lookup_value`, but casts the entry into a String (empty if None)
1618pub fn lookup_string(key: &str) -> String {
1619  let state = state!();
1620  match state.lookup_value(key) {
1621    None => String::new(),
1622    // A list value has no scalar string form; return "" rather than leaking the
1623    // internal `VecDequeStored[…]`/`Strings` Debug repr (#315). Structural
1624    // access to list values is via the Rhai `LookupValue` binding, which
1625    // returns an array (mirroring Perl's `LookupValue` → arrayref).
1626    Some(v) if v.is_list() => String::new(),
1627    Some(v) => v.into(),
1628  }
1629}
1630/// like `lookup_value` but only recognizes Int, Bool and Number variants of Stored (default: 0)
1631pub fn lookup_int(key: &str) -> i64 { lookup_int_sym(arena::pin(key)) }
1632
1633/// `lookup_int` variant for hot call sites with a pre-pinned SymStr (see
1634/// `crate::pin!`). Skips the per-call `arena::pin(key)` hash lookup — the
1635/// sibling of [`lookup_bool_sym`], added for the per-conditional
1636/// `if_count`/`if_limit` probes (`Conditional::invoke` fires on every
1637/// `\if`/`\ifx`/`\ifnum`/…).
1638pub fn lookup_int_sym(key: SymStr) -> i64 {
1639  let state = state!();
1640  match state.lookup_value_sym(key) {
1641    Some(Stored::Int(i)) => *i,
1642    Some(Stored::Bool(true)) => 1, // this is Perl's boolean -> integer semantics
1643    Some(Stored::Number(n)) => n.value_of(),
1644    _ => 0,
1645  }
1646}
1647/// `lookup_int` variant that never panics on a live mutable borrow.
1648///
1649/// Returns `None` when STATE is currently mutably borrowed (contention),
1650/// `Some(0)` when the key is absent/non-integer (matching `lookup_int`'s
1651/// default), else `Some(value)`.
1652///
1653/// This exists for the `Error!`/`Warn!` reporting path: an error can legitimately
1654/// be raised from inside a `state_mut()` scope (e.g. `push_value`'s BUG branch,
1655/// or any constructor `after_digest` holding the borrow). A plain `borrow()` there
1656/// panics "RefCell already mutably borrowed", aborting the whole conversion
1657/// (FATAL_panic; crashed tikz-cd 2001.08973 via `push_value("QED@stack", …)`).
1658/// The error reporter must be re-entrancy-safe regardless of what borrows are
1659/// held — degrade to "unknown" on contention rather than crash.
1660pub fn try_lookup_int(key: &str) -> Option<i64> {
1661  let state = (*STATE).try_borrow().ok()?;
1662  Some(match state.lookup_value(key) {
1663    Some(Stored::Int(i)) => *i,
1664    Some(Stored::Bool(true)) => 1,
1665    Some(Stored::Number(n)) => n.value_of(),
1666    _ => 0,
1667  })
1668}
1669
1670pub fn remove_vecdeque(key: &str) -> Option<VecDeque<Stored>> {
1671  match remove_value(key) {
1672    Some(Stored::VecDequeStored(v)) => Some(v),
1673    _ => None,
1674  }
1675}
1676/// convenience method to lookup the current value at the "font" key
1677pub fn lookup_font() -> Option<Rc<Font>> {
1678  // try_borrow, not state!()'s borrow(): this accessor is reachable from a
1679  // Whatsit's Display/revert path (e.g. tex_glue::revert_skip → lookup_font)
1680  // which can run *while STATE is already mutably borrowed* — e.g. formatting a
1681  // whatsit into a log/error message inside a state_mut() scope. A plain
1682  // borrow() then panics "RefCell already mutably borrowed", aborting the worker
1683  // (FATAL_101; crashed hep-th9908053, a \documentstyle[12pt]{article} 2.09
1684  // paper). Degrade to None on contention instead of crashing.
1685  //
1686  // CAUTION for future callers (PR #249 review P3-18): None-on-contention is
1687  // only correct for Display/revert/log-formatting consumers (where a
1688  // defaulted font is cosmetic). Several digestion-path callers `.unwrap()`
1689  // the result (tbox.rs, whatsit.rs, stomach.rs) — they would panic loudly on
1690  // contention, which is the desired behavior there: a DIGESTION-path
1691  // re-entrant lookup is a real bug, and silently defaulting the font would
1692  // turn it into invisible wrong-font drift in the XML. If you add a caller,
1693  // pick deliberately: `.unwrap()` on digestion paths, graceful None only
1694  // where the font is presentational.
1695  let Ok(st) = (*STATE).try_borrow() else {
1696    return None;
1697  };
1698  match st.lookup_value_sym(pin!("font")) {
1699    None | Some(Stored::None) => None,
1700    Some(f) => f.into(),
1701  }
1702}
1703/// convenience method to lookup the current value at the "mathfont" key
1704pub fn lookup_mathfont() -> Option<Rc<Font>> {
1705  // Route through `lookup_value_sym` with a cached SymStr (via
1706  // `pin!`) to skip the per-call `arena::pin("mathfont")` probe on
1707  // this hot path (math-env entry/exit, per-formula checks).
1708  match state!().lookup_value_sym(pin!("mathfont")) {
1709    None | Some(Stored::None) => None,
1710    Some(v) => v.into(),
1711  }
1712}
1713
1714/// a convenience method to globally asign a `Font` to the "font" key
1715pub fn assign_font(font: Rc<Font>, scope: Option<Scope>) {
1716  assign_value_sym(pin!("font"), Stored::Font(font), scope);
1717}
1718
1719/// a variant of `lookup_value` that casts the value into `Number`
1720pub fn lookup_number(key: &str) -> Option<Number> {
1721  match state!().lookup_value(key) {
1722    None | Some(Stored::None) => None,
1723    Some(v) => v.into(),
1724  }
1725}
1726/// a variant of `lookup_value` that casts the value into `Float`
1727///
1728/// The float counterpart of [`lookup_number`]. `Float` isn't a TeX register
1729/// type (see `common::float`), but binding authors need a fractional read/write
1730/// pair — e.g. `NOMINAL_FONT_SIZE` at the `11pt` class option is `10.95`, which
1731/// [`lookup_number`]/[`lookup_int`] would truncate to `10` (issue #542).
1732pub fn lookup_float(key: &str) -> Option<Float> {
1733  match state!().lookup_value(key) {
1734    None | Some(Stored::None) => None,
1735    Some(v) => v.into(),
1736  }
1737}
1738/// a variant of `lookup_value` that casts the value into `Dimension`
1739pub fn lookup_dimension(key: &str) -> Option<Dimension> {
1740  match state!().lookup_value(key) {
1741    None | Some(Stored::None) => None,
1742    Some(v) => v.into(),
1743  }
1744}
1745/// a variant of `lookup_value` that only recognizes a `Stored::Glue`
1746pub fn lookup_glue(key: &str) -> Option<Glue> {
1747  match state!().lookup_value(key) {
1748    Some(Stored::Glue(v)) => Some(*v),
1749    None | Some(Stored::None) => None,
1750    Some(other) => panic!("State lookup expected Glue, found: {other:?}"),
1751  }
1752}
1753/// a variant of `lookup_value` that only recognizes a `Stored::Glue`
1754pub fn lookup_muglue(key: &str) -> Option<MuGlue> {
1755  match state!().lookup_value(key) {
1756    Some(Stored::MuGlue(v)) => Some(*v),
1757    None | Some(Stored::None) => None,
1758    Some(other) => panic!("State lookup expected MuGlue, found: {other:?}"),
1759  }
1760}
1761/// a variant of `lookup_value` that casts the response into `Tokens`
1762pub fn lookup_tokens(key: &str) -> Option<Tokens> {
1763  let state = state!();
1764  match state.lookup_value(key) {
1765    None | Some(Stored::None) => None,
1766    Some(Stored::Tokens(v)) => Some(v.clone()),
1767    Some(Stored::Token(v)) => Some(Tokens::new(vec![*v])),
1768    Some(Stored::String(sym)) => {
1769      // Release the state borrow first, then read the interned string through
1770      // the re-entrant arena. The copy is unavoidable: an arena `&str` is not
1771      // `'static` (it dangles across a realloc — see WISDOM), and `TeXString`
1772      // borrows only `'static`, so the value has to be owned to cross into the
1773      // tokenizer. `Mouth::new` copies its input anyway.
1774      let sym = *sym;
1775      drop(state);
1776      arena::with(sym, |astr| {
1777        Some(mouth::tokenize_internal(TeXString::assembled(
1778          astr.to_string(),
1779        )))
1780      })
1781    },
1782    Some(Stored::VecDequeStored(v)) => {
1783      // Reverting the queue to Tokens routes each String item through
1784      // `mouth::tokenize_internal`, which takes a *mutable* STATE borrow — so
1785      // clone the queue and drop the immutable `state` borrow first (mirrors
1786      // the `Stored::String` branch above). Without this, LookupTokens on a
1787      // VecDequeStored key (e.g. "class_options") panics "RefCell already
1788      // borrowed" (#314).
1789      let vdq = v.clone();
1790      drop(state);
1791      Stored::VecDequeStored(vdq).into()
1792    },
1793    _ => None,
1794  }
1795}
1796/// a variant of `lookup_value` that only recognizes a `Stored::Token`
1797pub fn lookup_token(key: &str) -> Option<Token> {
1798  match state!().lookup_value(key) {
1799    Some(Stored::Token(t)) => Some(*t),
1800    _ => None,
1801  }
1802}
1803
1804/// a variant of `lookup_token` taking an already-pinned SymStr key —
1805/// avoids the per-call `arena::pin(key)` hash lookup.
1806pub fn lookup_token_sym(key: SymStr) -> Option<Token> {
1807  match state!().lookup_value_sym(key) {
1808    Some(Stored::Token(t)) => Some(*t),
1809    _ => None,
1810  }
1811}
1812
1813pub fn lookup_alignment() -> Option<Digested> {
1814  // Can only be a token or definition; we want defns!
1815  // is this the right logic here? don't expand unless digesting?
1816  state!().lookup_value_sym(pin!("Alignment")).and_then(|v| {
1817    if let Stored::Digested(d) = v {
1818      if matches!(d.data(), DigestedData::Alignment(_)) {
1819        // for now clone the Digested object (approx. an Rc<_> clone)
1820        // instead of returning &Digested, to simplify lifetime checks
1821        Some(d.clone())
1822      } else {
1823        None
1824      }
1825    } else {
1826      None
1827    }
1828  })
1829}
1830pub fn assign_alignment(alignment: Alignment, scope: Option<Scope>) {
1831  assign_value("Alignment", alignment, scope);
1832}
1833
1834pub fn assign_register(
1835  cs: &str,
1836  value: RegisterValue,
1837  scope: Option<Scope>,
1838  parameters: Vec<ArgWrap>,
1839) -> Result<()> {
1840  assign_register_token(&T_CS!(cs), value, scope, parameters)
1841}
1842/// `assign_register` variant taking a pre-built Token — lets hot
1843/// callers skip the `T_CS!(&str)` pin when they already have the CS
1844/// cached (e.g. via `T_CS!("\\c@…")` literal which routes through
1845/// `pin!`).
1846pub fn assign_register_token(
1847  cs: &Token,
1848  value: RegisterValue,
1849  scope: Option<Scope>,
1850  parameters: Vec<ArgWrap>,
1851) -> Result<()> {
1852  let defn_opt = lookup_definition(cs)?;
1853  if let Some(defn) = defn_opt
1854    && defn.is_register()
1855  {
1856    defn.set_value(value, scope, parameters);
1857    return Ok(());
1858  }
1859  Warn!(
1860    "expected",
1861    "register",
1862    format!("The control sequence '{cs}' is not a register")
1863  );
1864  Ok(())
1865}
1866pub fn lookup_register(cs: &str, parameters: Vec<ArgWrap>) -> Result<Option<RegisterValue>> {
1867  lookup_register_token(&T_CS!(cs), parameters)
1868}
1869/// Token-keyed variant of `lookup_register` — saves the per-call
1870/// `T_CS!(&str)` pin for hot callers with a cached CS token.
1871pub fn lookup_register_token(
1872  cs: &Token,
1873  parameters: Vec<ArgWrap>,
1874) -> Result<Option<RegisterValue>> {
1875  Ok(match lookup_definition(cs)? {
1876    Some(defn) => {
1877      if defn.is_register() {
1878        defn.value_of(parameters)
1879      } else {
1880        let message = s!("The control sequence '{}' is not a register", cs);
1881        Warn!("expected", "register", message);
1882        None
1883      }
1884    },
1885    _ => None,
1886  })
1887}
1888
1889/// Quiet sibling of [`lookup_register`] for call sites that mirror Perl's
1890/// explicit `lookupDefinition(cs) && $defn->isRegister ? $defn->valueOf : <default>`
1891/// guard — e.g. TeX_Tables `\lx@text@intercol`/`\lx@math@intercol`
1892/// (`TeX_Tables.pool.ltxml` L639/L646), where a document may legitimately
1893/// `\renewcommand` a length register (`\tabcolsep`/`\arraycolsep`) into a plain
1894/// macro. In that case the register-ness is genuinely gone in Perl too, and Perl
1895/// silently falls back to its default (`Dimension(0)`) with **no warning**.
1896/// Returns `None` (no warning) when the CS is undefined or is not a register,
1897/// so the caller can apply its own faithful default.
1898pub fn lookup_register_quiet(cs: &str) -> Option<RegisterValue> {
1899  let defn = lookup_definition(&T_CS!(cs)).ok().flatten()?;
1900  if defn.is_register() {
1901    defn.value_of(Vec::new())
1902  } else {
1903    None
1904  }
1905}
1906
1907/// Faithful port of Perl `LookupDimension` (`Package.pm` L1371-1393, as
1908/// widened by upstream PR #2829): try to turn the argument into a Dimension,
1909/// recognizing strings, registers, ….
1910///
1911/// * a string that looks like an obvious dimension (`/^[0-9+-.]\w\w+$/`,
1912///   e.g. `"3pt"` — but NOT `"0.4pt"`, whose `.` fails `\w`) parses directly;
1913/// * otherwise the string is tokenized: a single token that resolves to a
1914///   register returns its value ("easy and proper case");
1915/// * a multi-token sequence is read as a dimension from a fresh mouth;
1916/// * anything else warns (`expected:register`) unless `noerror`, and yields
1917///   `None` (Perl returns undef).
1918///
1919/// NOTE the #2829 semantics change carried over faithfully: a single token
1920/// whose definition is a MACRO (e.g. a document that `\def`s `\jot`) no
1921/// longer reads its body as a dimension — it now falls through to the warn
1922/// branch. (Perl's digested-Box coercion branch has no Rust equivalent here:
1923/// all our callers pass strings.)
1924pub fn lookup_dimension_cs(cs: &str, noerror: bool) -> Option<Dimension> {
1925  use std::str::FromStr;
1926  // Obvious dimension string? (Perl: /^[0-9\+\-\.]\w\w+$/)
1927  let mut chars = cs.chars();
1928  let leading_sign_or_digit =
1929    matches!(chars.next(), Some(c) if c.is_ascii_digit() || matches!(c, '+' | '-' | '.'));
1930  let obvious = leading_sign_or_digit
1931    && cs.chars().count() >= 3
1932    && chars.all(|c| c.is_alphanumeric() || c == '_');
1933  if obvious && let Ok(d) = Dimension::from_str(cs) {
1934    return Some(d);
1935  }
1936  let tokens = mouth::tokenize_internal(TeXString::assembled(cs.to_string()));
1937  let toks = tokens.unlist();
1938  if toks.len() == 1 {
1939    match lookup_definition(&toks[0]) {
1940      Ok(Some(defn)) if defn.is_register() => {
1941        // Easy (and proper) case.
1942        return defn.value_of(Vec::new()).map(|rv| Dimension::from(&rv));
1943      },
1944      // Defined but not a register (a `\def`-ized length): fall through and
1945      // read its body as a dimension. NB this is a deliberate DIVERGENCE
1946      // from post-#2829 Perl, which unintentionally LOST this path in the
1947      // rewrite (a single macro token falls to the warn branch upstream) —
1948      // see KNOWN_PERL_ERRORS #41. Real arXiv papers `\def\arraycolsep{...}`
1949      // (cluster regressions cover this); pre-#2829 Perl read the body.
1950      Ok(Some(_)) => {},
1951      // Undefined single token: warn like Perl and yield nothing.
1952      _ => {
1953        if !noerror {
1954          let message = s!("The control sequence '{}' is not a register", cs);
1955          Warn!("expected", "register", message);
1956        }
1957        return None;
1958      },
1959    }
1960  }
1961  // Read the token sequence (a defined single CS expands here, exactly like
1962  // Perl's readingFromMouth) as a dimension from a fresh mouth; an
1963  // unreadable sequence warns Missing-number inside read_dimension and
1964  // yields Dimension(0), matching Perl.
1965  gullet::reading_from_mouth(mouth::Mouth::default(), move || {
1966    gullet::unread(Tokens::new(toks));
1967    gullet::read_dimension()
1968  })
1969  .ok()
1970}
1971
1972pub fn lookup_expandable(
1973  token: &Token,
1974  toplevel_opt: Option<bool>,
1975) -> Result<Option<Rc<dyn Definition>>> {
1976  let toplevel = toplevel_opt.unwrap_or(true); // Default, for full expansion, same as read_x_token
1977  // Can only be a token or definition; we want defns!
1978  // is this the right logic here? don't expand unless digesting?
1979  Ok(
1980    lookup_definition(token)?
1981      .filter(|defn| (*defn).is_expandable() && (toplevel || !(*defn).is_protected())),
1982  )
1983}
1984
1985/// Whether token is affected by \noexpand
1986pub fn is_dont_expandable(token: &Token) -> bool {
1987  // Basically: a CS or Active token that is either not defined, or is expandable
1988  // (but not \let to a token)
1989  if token.get_catcode().is_active_or_cs() {
1990    let lookupname = meaning_key(token);
1991    if lookupname != pin!("") {
1992      match state!().meaning.get(&lookupname) {
1993        Some(entry) => {
1994          if let Some(def) = entry.front() {
1995            // the expandable variants are allowed
1996            matches!(
1997              def,
1998              Stored::Expandable(_) | Stored::Conditional(_) | Stored::None
1999            )
2000          } else {
2001            // undefined is allowed too (this is *really* subtle -- took some debugging of
2002            // etoolbox) both an empty VDQ, a VDQ with an entry present but matching
2003            // Stored::Noney, OR a completely missing VDQ are allowed "undefined" cases, each of
2004            // which flagging as "true"
2005            true
2006          }
2007        },
2008        None => true,
2009      }
2010    } else {
2011      true
2012    }
2013  } else {
2014    false
2015  }
2016}
2017
2018pub fn lookup_conditional(token: &Token) -> Option<ConditionalType> {
2019  // `get_executable_name` previously built a fresh `String` + `arena::pin`
2020  // probe per call; `pin_cs_name` already returns a cached `SymStr`
2021  // (primitive → `Catcode::name_sym`, otherwise `self.text`). Saves a
2022  // RefCell mut-borrow on the interner + a hashmap probe per token in
2023  // the gullet's conditional dispatch.
2024  if !token.code.is_executable() {
2025    return None;
2026  }
2027  let lookup_sym = token.pin_cs_name();
2028  state!().meaning.get(&lookup_sym).and_then(|entry| {
2029    if let Some(Stored::Conditional(defn)) = entry.front() {
2030      Some(defn.conditional_type)
2031    } else {
2032      None
2033    }
2034  })
2035}
2036
2037pub fn unshift_value<T: Into<Stored>>(key: &str, values: Vec<T>) {
2038  let values_iter = values.into_iter().map(Into::into);
2039  let key_sym = arena::pin(key);
2040  let mut state = state_mut!();
2041  if !state.value.contains_key(&key_sym) {
2042    state.assign_internal(
2043      TableName::Value,
2044      key_sym,
2045      Stored::VecDequeStored(VecDeque::new()),
2046      Some(Scope::Global),
2047    )
2048  }
2049  let receiver = state.value.get_mut(&key_sym).unwrap().front_mut();
2050  if let Some(&mut Stored::VecDequeStored(ref mut front)) = receiver {
2051    for value in values_iter.rev() {
2052      // preserving order unshift, as Perl's
2053      front.push_front(value)
2054    }
2055  } else if receiver.is_none() || matches!(receiver, Some(Stored::None)) {
2056    // Key doesn't exist yet — create a new VecDequeStored via the existing borrow
2057    let mut vd = VecDeque::new();
2058    for value in values_iter {
2059      vd.push_back(value);
2060    }
2061    state.assign_internal(
2062      TableName::Value,
2063      key_sym,
2064      Stored::VecDequeStored(vd),
2065      Some(Scope::Global),
2066    );
2067  } else {
2068    // Wrong type — warn but don't panic
2069    Warn!(
2070      "unexpected",
2071      "unshift_value",
2072      s!(
2073        "unshift_value expects VecDequeStored receiver for key {:?}, got: {:?}",
2074        key,
2075        receiver.map(|r| std::mem::discriminant(r))
2076      )
2077    );
2078  }
2079}
2080
2081pub fn shift_value(key: &str) -> Result<Option<Stored>> {
2082  let key_sym = arena::pin(key);
2083  let mut state = state_mut!();
2084  if !state.value.contains_key(&key_sym) {
2085    state.assign_internal(
2086      TableName::Value,
2087      key_sym,
2088      Stored::VecDequeStored(VecDeque::new()),
2089      Some(Scope::Global),
2090    )
2091  }
2092  Ok(
2093    if let Some(&mut Stored::VecDequeStored(ref mut front)) =
2094      state.value.get_mut(&key_sym).unwrap().front_mut()
2095    {
2096      front.pop_front()
2097    } else {
2098      Error!(
2099        "State",
2100        "Stored",
2101        "BUG: Tried to shift_value from a non-vecdeque value key!"
2102      );
2103      None
2104    },
2105  )
2106}
2107
2108/// Bind `key` to `value` inside the named mapping — Perl's `AssignMapping`.
2109///
2110/// A mapping is a named hash living in the value table (`TAG_PROPERTIES`,
2111/// `counter_for_type`, …). The mapping itself is created **globally** on first
2112/// use, so that entries assigned inside a group are still found from outside
2113/// it; passing `None` for `value` removes the key. Read entries back with
2114/// [`with_mapping`].
2115pub fn assign_mapping<T: Into<Stored>>(map: &str, key: &str, value: Option<T>) {
2116  let map_sym = arena::pin(map);
2117  let mut state = state_mut!();
2118  if !state.value.contains_key(&map_sym) || state.value[&map_sym].is_empty() {
2119    state.assign_internal(
2120      TableName::Value,
2121      map_sym,
2122      Stored::HashStored(SymHashMap::default()),
2123      Some(Scope::Global),
2124    );
2125  }
2126  let map_store = state.value.get_mut(&map_sym).unwrap();
2127  // TODO: What is the right abstraction here? this is hacky
2128  let mut stub_hash = SymHashMap::default();
2129  let mapping = match *map_store.front_mut().unwrap() {
2130    Stored::HashStored(ref mut mapping) => mapping,
2131    _ => &mut stub_hash,
2132  };
2133  match value {
2134    None => mapping.remove(key),
2135    Some(v) => mapping.insert(key, v.into()),
2136  };
2137}
2138
2139pub fn lookup_mapping(map: &str, key: &str) -> Option<Stored> {
2140  state!().lookup_mapping(map, key).cloned()
2141}
2142/// Sym-keyed variant — skip the per-call `arena::pin(map)` for hot
2143/// callers with a pre-pinned map key (e.g. via `pin!("siunitx_macros")`).
2144pub fn lookup_mapping_sym(map_sym: SymStr, key: &str) -> Option<Stored> {
2145  state!().lookup_mapping_sym(map_sym, key).cloned()
2146}
2147
2148//======================================================================
2149/// Was `name` bound?  If  `frame` is given, check only whether it is bound in
2150/// that frame (0 is the topmost).
2151pub fn is_value_bound(key: &str, frame_opt: Option<usize>) -> bool {
2152  let key_sym = arena::pin(key);
2153  match frame_opt {
2154    Some(frame) => state!()
2155      .undo
2156      .get(frame)
2157      .as_ref()
2158      .unwrap()
2159      .table(TableName::Value)
2160      .contains_key(&key_sym),
2161    None => !state!()
2162      .value
2163      .get(&key_sym)
2164      .unwrap_or(&VecDeque::new())
2165      .is_empty(),
2166  }
2167}
2168
2169//======================================================================
2170/// Lookup & assign a character's Catcode
2171pub fn lookup_catcode(c: char) -> Option<Catcode> {
2172  // speedup over variant with allocation
2173  // i.e. "let s = c.to_string();"
2174  let s = arena::pin_char(c);
2175  match state!().catcode.get(&s) {
2176    None => None,
2177    Some(cvec) => match cvec.front() {
2178      Some(Stored::Catcode(cc)) => Some(*cc),
2179      Some(_) => None, // non-catcode value in catcode table — treat as undefined
2180      _ => None,
2181    },
2182  }
2183}
2184
2185/// assigns a Catcode for a given character
2186pub fn assign_catcode(key: char, value: Catcode, scope: Option<Scope>) {
2187  let s = arena::pin_char(key);
2188  state_mut!().assign_internal(TableName::Catcode, s, Stored::Catcode(value), scope);
2189}
2190/// like `lookup_catcode` but targets Mathcode and its table
2191pub fn lookup_mathcode(key: &str) -> Option<u16> {
2192  let key_sym = arena::pin(key);
2193  match state!().mathcode.get(&key_sym) {
2194    Some(c) => match c.front() {
2195      Some(Stored::Charcode(codeval)) => Some(*codeval),
2196      _ => None,
2197    },
2198    None => None,
2199  }
2200}
2201pub fn lookup_mathcode_sym(key_sym: SymStr) -> Option<u16> {
2202  match state!().mathcode.get(&key_sym) {
2203    Some(c) => match c.front() {
2204      Some(Stored::Charcode(codeval)) => Some(*codeval),
2205      _ => None,
2206    },
2207    None => None,
2208  }
2209}
2210/// like `assign_catcode` but targets Mathcode and its table
2211pub fn assign_mathcode<T: Into<u16>>(key: char, value: T, scope: Option<Scope>) {
2212  state_mut!().assign_internal(
2213    TableName::Mathcode,
2214    arena::pin_char(key),
2215    Stored::Charcode(value.into()),
2216    scope,
2217  );
2218}
2219/// like `lookup_catcode` but targets Sfcode and its table
2220pub fn lookup_sfcode(key: char) -> Option<u16> {
2221  match state!().sfcode.get(&arena::pin_char(key)) {
2222    Some(c) => match c.front() {
2223      Some(Stored::Charcode(codeval)) => Some(*codeval),
2224      _ => None,
2225    },
2226    None => None,
2227  }
2228}
2229/// like `assign_catcode` but targets Sfcode and its table
2230pub fn assign_sfcode<T: Into<u16>>(key: char, value: T, scope: Option<Scope>) {
2231  state_mut!().assign_internal(
2232    TableName::Sfcode,
2233    arena::pin_char(key),
2234    Stored::Charcode(value.into()),
2235    scope,
2236  );
2237}
2238/// like `lookup_catcode` but targets Lccode and its table
2239pub fn lookup_lccode(key: char) -> Option<u16> {
2240  match state!().lccode.get(&arena::pin_char(key)) {
2241    Some(c) => match c.front() {
2242      Some(Stored::Charcode(codeval)) => Some(*codeval),
2243      _ => None,
2244    },
2245    None => None,
2246  }
2247}
2248/// like `assign_catcode` but targets Lccode and its table
2249pub fn assign_lccode<T: Into<u16>, C: Into<char>>(key: C, value: T, scope: Option<Scope>) {
2250  let c: char = key.into();
2251  state_mut!().assign_internal(
2252    TableName::Lccode,
2253    arena::pin_char(c),
2254    Stored::Charcode(value.into()),
2255    scope,
2256  );
2257}
2258/// like `lookup_catcode` but targets Uccode and its table
2259pub fn lookup_uccode(key: char) -> Option<u16> {
2260  let mut tmp = [0u8; 4];
2261  let s = arena::pin(key.encode_utf8(&mut tmp));
2262  match state!().uccode.get(&s) {
2263    Some(c) => match c.front() {
2264      Some(Stored::Charcode(codeval)) => Some(*codeval),
2265      _ => None,
2266    },
2267    None => None,
2268  }
2269}
2270/// like `assign_catcode` but targets Uccode and its table
2271pub fn assign_uccode<T: Into<u16>, C: Into<char>>(key: C, value: T, scope: Option<Scope>) {
2272  let c: char = key.into();
2273  let mut tmp = [0u8; 4];
2274  let s = arena::pin(c.encode_utf8(&mut tmp));
2275  state_mut!().assign_internal(TableName::Uccode, s, Stored::Charcode(value.into()), scope);
2276}
2277/// like `lookup_catcode` but targets Delcode and its table
2278pub fn lookup_delcode(key: char) -> Option<u16> {
2279  let mut tmp = [0u8; 4];
2280  let s = arena::pin(key.encode_utf8(&mut tmp));
2281  match state!().delcode.get(&s) {
2282    Some(c) => match c.front() {
2283      Some(Stored::Charcode(codeval)) => Some(*codeval),
2284      _ => None,
2285    },
2286    None => None,
2287  }
2288}
2289/// like `assign_catcode` but targets Delcode and its table
2290pub fn assign_delcode<T: Into<u16>>(key: char, value: T, scope: Option<Scope>) {
2291  let mut tmp = [0u8; 4];
2292  let s = arena::pin(key.encode_utf8(&mut tmp));
2293  state_mut!().assign_internal(TableName::Delcode, s, Stored::Charcode(value.into()), scope);
2294}
2295/// The key under which a token's meaning is stored. **All** `\special_relax`-family
2296/// tokens (`\noexpand`'d forms — the bare `\special_relax` and every
2297/// `\special_relax\x01<shadowed>`) resolve under the bare `\special_relax` name:
2298/// they share its `\relax` meaning, faithful to TeX where a `\noexpand`'d token
2299/// has relax meaning regardless of which token it shadows. The shadowed identity
2300/// is recovered separately via [`Token::noexpand_shadowed`] (delimited matching
2301/// only). Use this anywhere a token's *name* keys a meaning lookup or a
2302/// "same control sequence?" comparison. Cheap on the common path: non-CS tokens
2303/// short-circuit before any string access.
2304#[inline]
2305pub fn meaning_key(token: &Token) -> SymStr {
2306  if token.is_noexpand_family() {
2307    pin!("\\special_relax")
2308  } else {
2309    token.text
2310  }
2311}
2312
2313/// Get the "Meaning" of a token.
2314///
2315/// For active control sequences this may give the definition object (if
2316/// defined) or another token (if `\let`) or `None`. Any other token is returned
2317/// as is — which is what makes a `\let`-style comparison between a control
2318/// sequence and a character token work.
2319///
2320/// Clones the stored meaning; use [`with_meaning`] when inspecting it is enough.
2321pub fn lookup_meaning(token: &Token) -> Option<Stored> {
2322  if token.get_catcode().is_active_or_cs() && token.text != pin!("") {
2323    match state!().meaning.get(&meaning_key(token)) {
2324      Some(entry) => match entry.front() {
2325        None | Some(Stored::None) => None,
2326        Some(other) => Some(other.clone()),
2327      },
2328      None => None,
2329    }
2330  } else {
2331    Some(Stored::Token(*token))
2332  }
2333}
2334
2335/// Closure-based variant of `lookup_meaning` — avoids the per-call
2336/// `Stored::clone()` when the caller only needs to *inspect* the
2337/// meaning (e.g. extract a CS Token from an Expandable/Primitive
2338/// definition). Stored::clone is ~1% of total instructions on
2339/// siunitx-heavy fixtures (5M+ calls per run, each cloning a full
2340/// Stored enum). This helper borrows the stored value instead.
2341///
2342/// For non-CS/ACTIVE tokens, passes `Some(Stored::Token(*token))` —
2343/// matching lookup_meaning's fallback semantics. Note this requires
2344/// a single stack allocation of Stored::Token (Copy), not a heap
2345/// clone.
2346pub fn with_meaning<R>(token: &Token, f: impl FnOnce(Option<&Stored>) -> R) -> R {
2347  let state = state!();
2348  if token.get_catcode().is_active_or_cs() && token.text != pin!("") {
2349    match state.meaning.get(&meaning_key(token)) {
2350      Some(entry) => match entry.front() {
2351        None | Some(Stored::None) => f(None),
2352        Some(other) => f(Some(other)),
2353      },
2354      None => f(None),
2355    }
2356  } else {
2357    // Non-CS/ACTIVE: the "meaning" is just the token itself. The
2358    // caller gets a borrow of a temporary here, which is safe for
2359    // the duration of the closure.
2360    let s = Stored::Token(*token);
2361    f(Some(&s))
2362  }
2363}
2364
2365/// like `lookup_value` but only recognizes `Stored::VecDequeStored`
2366pub fn lookup_vecdeque(key: &str) -> Option<VecDeque<Stored>> {
2367  match state!().lookup_value(key) {
2368    None | Some(Stored::None) => None,
2369    Some(v) => <Option<&VecDeque<Stored>>>::from(v).cloned(),
2370  }
2371}
2372
2373pub fn with_vecdeque<R, FnR>(key: &str, caller: FnR) -> R
2374where FnR: FnOnce(Option<&VecDeque<Stored>>) -> R {
2375  caller(state!().lookup_vecdeque(key))
2376}
2377
2378/// $meaning should be a definition (for defining active control sequences)
2379/// or another token, for \let
2380pub fn assign_meaning<T: Into<Stored>>(token: &Token, meaning: T, scope: Option<Scope>) {
2381  let mut meaning = meaning.into();
2382  // short-circuit guard to avoid e.g. T_MATH let to itself
2383  if let Stored::Token(ref mt) = meaning
2384    && token == mt
2385  {
2386    return;
2387  }
2388  // For \let chains: if the target token has an expandable/primitive definition,
2389  // store that definition directly instead of the Token indirection.
2390  // This ensures `\let \foo \bar` where \bar is expandable makes \foo expandable too.
2391  // Follow at most 50 \let links to avoid cycles.
2392  if let Stored::Token(ref target) = meaning {
2393    let mut current = *target;
2394    for _ in 0..50 {
2395      match lookup_meaning(&current) {
2396        Some(Stored::Token(next)) => {
2397          current = next; // follow chain
2398        },
2399        Some(Stored::None) | None => break, // dead end — keep as Token
2400        Some(defn) => {
2401          // Found a real definition — use it directly
2402          meaning = defn;
2403          break;
2404        },
2405      }
2406    }
2407  }
2408  let csname_sym = token.pin_cs_name();
2409  state_mut!().assign_internal(TableName::Meaning, csname_sym, meaning, scope);
2410}
2411
2412/// Remove a token's meaning entirely — the token becomes undefined, as if it
2413/// had never been defined, so a later use takes the normal undefined-CS error
2414/// path naming the token itself. Bypasses the group-undo journal: intended
2415/// ONLY for format-bootstrap time (no user groups open), where a format layer
2416/// retracts a definition inherited from a lower layer that the emulated
2417/// format must not expose (e.g. plain.tex's `\+` in a LaTeX session — real
2418/// LaTeX is INITEX-based and never defines it).
2419pub fn remove_meaning_global(token: &Token) {
2420  let key = meaning_key(token);
2421  state_mut!().meaning.remove(&key);
2422}
2423
2424// keep this in sync with `lookup_meaning`, it is copied over for optimization purposes
2425pub fn has_meaning(token: &Token) -> bool {
2426  if token.get_catcode().is_active_or_cs() && token.text != pin!("") {
2427    match state!().meaning.get(&meaning_key(token)) {
2428      Some(entry) => match entry.front() {
2429        None | Some(Stored::None) => false,
2430        Some(_) => true,
2431      },
2432      None => false,
2433    }
2434  } else {
2435    true
2436  }
2437}
2438
2439/// used for expansion & various queries
2440/// Since we're not doing digestion here, we don't need to handle mathactive,
2441/// nor cs let to executable tokens
2442/// This returns a definition object, or undef
2443pub fn lookup_definition(key: &Token) -> Result<Option<Rc<dyn Definition>>> {
2444  Ok(
2445    if let Some(defs) = state!().lookup_definition_internal(key) {
2446      match defs.front() {
2447        Some(Stored::Conditional(entry)) => Some(entry.clone()),
2448        Some(Stored::Constructor(entry)) => Some(entry.clone()),
2449        Some(Stored::Expandable(entry)) => Some(entry.clone()),
2450        Some(Stored::MathPrimitive(entry)) => Some(entry.clone()),
2451        Some(Stored::Primitive(entry)) => Some(entry.clone()),
2452        Some(Stored::Register(entry)) => Some(entry.clone()),
2453        Some(Stored::None) | Some(Stored::Token(_)) | None => None,
2454        Some(v) => {
2455          let message = s!("in lookup_definition for {:?}. Value was: {:?}", key, v);
2456          Error!("unexpected", "value", message);
2457          None
2458        },
2459      }
2460    } else {
2461      None
2462    },
2463  )
2464}
2465
2466/// Returns a definition as `Stored` so that one can call `.read_arguments`
2467///
2468/// This can't be specialized during compile-time over a trait object?
2469/// Instead we'll dispatch via `Stored` at runtime, to allow generic calls.
2470pub fn lookup_definition_stored(key: &Token) -> Result<Option<Stored>> {
2471  Ok(match state!().lookup_definition_internal(key) {
2472    Some(defs) => match defs.front() {
2473      // Still, good time to handle the Token case and catch weird storage errors
2474      Some(Stored::Conditional(entry)) => Some(Stored::Conditional(Rc::clone(entry))),
2475      Some(Stored::Constructor(entry)) => Some(Stored::Constructor(Rc::clone(entry))),
2476      Some(Stored::Expandable(entry)) => Some(Stored::Expandable(Rc::clone(entry))),
2477      Some(Stored::MathPrimitive(entry)) => Some(Stored::MathPrimitive(Rc::clone(entry))),
2478      Some(Stored::Primitive(entry)) => Some(Stored::Primitive(Rc::clone(entry))),
2479      Some(Stored::Register(entry)) => Some(Stored::Register(Rc::clone(entry))),
2480      Some(Stored::Token(entry)) => Some(Stored::Expandable(Rc::new(Expandable {
2481        cs: key.with_str(|k| T_CS!(k)),
2482        paramlist: None,
2483        expansion: (*entry).into(),
2484        ..Expandable::default()
2485      }))),
2486      Some(v) => {
2487        let message = s!("in lookup_definition for {:?}. Value was: {:?}", key, v);
2488        Error!("unexpected", "value", message);
2489        None
2490      },
2491      None => None,
2492    },
2493    _ => None,
2494  })
2495}
2496
2497/// A specialized version of `lookup_definition` for registers, since we can't adequately perform
2498/// multi-dispatch when we have a "Self: Sized" for the Definition trait object.
2499pub fn lookup_register_definition(key: &Token) -> Option<Rc<Register>> {
2500  match state!().lookup_definition_internal(key) {
2501    Some(defs) => match defs.front() {
2502      Some(Stored::Register(entry)) => Some(Rc::clone(entry)),
2503      _ => None,
2504    },
2505    _ => None,
2506  }
2507}
2508/// Recognizes mathactive tokens in math mode and also looks for
2509/// cs that have been let to other `executable' tokens.
2510/// Returns a definition object, or a "self inserting" token.
2511/// Used for digestion.
2512pub fn lookup_digestable_definition(token: &Token) -> Option<Stored> {
2513  let cc = token.get_catcode();
2514  let t_sym = token.get_sym();
2515  let is_active_or_cs = cc.is_active_or_cs();
2516  let lookup_sym = if is_active_or_cs
2517    || ((cc == Catcode::LETTER || (cc == Catcode::OTHER))
2518      && lookup_bool_sym(crate::pin!("IN_MATH"))
2519      && (lookup_mathcode_sym(t_sym).unwrap_or(0) == 0x8000))
2520  {
2521    // `\special_relax`-family tokens digest under the bare `\special_relax` no-op.
2522    meaning_key(token)
2523  } else {
2524    // Use cached SymStr from `Catcode::name_sym` instead of re-interning
2525    // `cc.name()` (a &'static str) on every non-active-or-cs token —
2526    // saves a hashmap probe per token on the digest hot path.
2527    cc.name_sym()
2528  };
2529  // Debug!("Looking up digestable {:?}", lookupname);
2530  let state = state!();
2531  let entry_opt = state.meaning.get(&lookup_sym);
2532  if lookup_sym != pin!("") && entry_opt.is_some() && !entry_opt.as_ref().unwrap().is_empty() {
2533    // Debug!("Found definition for: {:?}", lookupname);
2534    if let Some(entry) = entry_opt
2535      && let Some(front) = entry.front()
2536    {
2537      if let Stored::Token(t) = front {
2538        if let Some(lookup_name) = t.get_executable_primitive_name() {
2539          let lookup_sym = arena::pin(lookup_name);
2540          if let Some(retry_entry) = state!().meaning.get(&lookup_sym) {
2541            // special case,
2542            // If a cs has been let to an executable token, lookup ITS defn.
2543            return retry_entry.front().cloned();
2544          }
2545        }
2546        // Also follow \let chains for CS tokens: if \foo is \let to \bar,
2547        // resolve \bar's definition. This handles expl3 aliases like
2548        // \tex_long:D → \long, \tex_gdef:D → \gdef.
2549        if t.get_catcode() == Catcode::CS
2550          && let Some(target_entry) = state.meaning.get(&t.text)
2551          && let Some(target_front) = target_entry.front()
2552          && !matches!(target_front, Stored::Token(_) | Stored::None)
2553        {
2554          return Some(target_front.clone());
2555        }
2556      }
2557      // Perl State.pm:474 lookupDigestableDefinition: the guard
2558      // `($defn = $$entry[0])` is FALSE when the entry's value is undef, so
2559      // execution falls through to `return $token` (self-inserting) for a
2560      // LETTER/OTHER token and to `return undef` for an active/CS one. A
2561      // math-active LETTER/OTHER character whose active meaning was `\let`
2562      // to an undefined CS hits exactly this case — e.g. braket-style
2563      // `\Pr{A|B}`: the macro body does `\mathcode`\|=32768 \let|\SetVert`
2564      // with `\SetVert` itself undefined (neither our nor Perl's braket
2565      // binding defines it), leaving `|`'s meaning an explicit
2566      // `Stored::None`. Returning `Some(Stored::None)` here routed the `|`
2567      // to generateErrorStub ("The token T_OTHER[|] is not defined"); Perl
2568      // instead self-inserts the literal char. Mirror Perl: a None-valued
2569      // entry for a non-active/CS (math-active) char self-inserts; active/CS
2570      // tokens still fall to the `None` return below. Witness 1602.01342.
2571      if matches!(front, Stored::None) && !is_active_or_cs {
2572        return Some(token.into());
2573      }
2574      // if a regular definition, just return.
2575      return Some(front.clone());
2576    }
2577  } else if is_active_or_cs {
2578    return None;
2579  }
2580  Some(token.into())
2581}
2582
2583// NOTE: Common usage patterns seem to be to lookup
2584//   expandable definitions
2585//   register values
2586//   conditionals
2587//   digestibles
2588// or just variants on testing defined-ness
2589// May be will introduce more clarity (possibly efficiency)
2590// to collect those more uniformly and implement here, or in Package
2591
2592//======================================================================
2593/// Starts a new level of grouping.
2594/// Note that this is lower level than C<\bgroup>;
2595/// Diagnostic helper: dump the keys in undo`0`'s value table.
2596/// For temporary instrumentation only — no production callers should rely on this.
2597pub fn dump_top_frame_keys() -> String {
2598  let state = state!();
2599  let f0 = state.undo.front().expect("undo is non-empty");
2600  let mut entries: Vec<String> = Vec::new();
2601  for (k, v) in f0.table(TableName::Value).iter() {
2602    let val = state
2603      .value
2604      .get(k)
2605      .and_then(|vec| vec.front())
2606      .map(|s| format!("{s:?}"))
2607      .unwrap_or_else(|| "<none>".into());
2608    let ks: String = arena::with(*k, |s| s.to_string());
2609    entries.push(format!("{ks}=[{v}, {val}]"));
2610  }
2611  entries.sort();
2612  entries.join(", ")
2613}
2614
2615pub fn push_frame() {
2616  // Easy: just push a new undo frame.
2617  state_mut!().undo.push_front(UndoFrame::default());
2618}
2619
2620/// Snapshot of the keys currently bound at the topmost (calling) undo frame
2621/// for the Meaning table. Used by Perl-style autoload triggers that need to
2622/// promote everything a package's load just installed at this scope to
2623/// GLOBAL — without that promotion, sibling autoload triggers fired AFTER
2624/// a group pop would re-fire on a now-undefined sibling CS (the canonical
2625/// case is `\begin{subequations}` triggering amsmath autoload at depth=N,
2626/// then a later `\begin{align}` at depth=0 finding `\align` undefined
2627/// because amsmath's depth=N install was popped on `\end{subequations}`).
2628pub fn snapshot_top_frame_meaning_keys() -> Vec<SymStr> {
2629  state!()
2630    .undo
2631    .front()
2632    .map(|f| f.meaning.keys().copied().collect())
2633    .unwrap_or_default()
2634}
2635
2636/// Hoist every Meaning binding installed at the topmost frame since
2637/// `pre_snapshot` was taken to GLOBAL scope. Idempotent: keys already
2638/// in `pre_snapshot` are skipped. Operates on the Meaning table only —
2639/// callers that need to promote Value/Catcode/etc. should add parallel
2640/// helpers (none required so far).
2641pub fn hoist_top_frame_meaning_delta(pre_snapshot: &[SymStr]) {
2642  let pre: rustc_hash::FxHashSet<SymStr> = pre_snapshot.iter().copied().collect();
2643  let new_keys: Vec<SymStr> = {
2644    let state = state!();
2645    state
2646      .undo
2647      .front()
2648      .map(|f| {
2649        f.meaning
2650          .keys()
2651          .copied()
2652          .filter(|k| !pre.contains(k))
2653          .collect()
2654      })
2655      .unwrap_or_default()
2656  };
2657  for key in new_keys {
2658    let current = {
2659      let state = state!();
2660      state
2661        .meaning
2662        .get(&key)
2663        .and_then(|stack| stack.front().cloned())
2664    };
2665    if let Some(value) = current {
2666      // CONDITIONALS ONLY. The failure this exists for is a definition destroyed
2667      // while a GLOBAL document hook still reads it, and every witness is a
2668      // `\newif` conditional (`\ifpgf@external@grabshipout`, OXIDIZED_DESIGN
2669      // #65). Hoisting a package's ordinary macros too is what makes a second
2670      // sibling subfile render the FIRST one's content: promoting pkgA's
2671      // `\newcommand` to global makes pkgB's same-named `\newcommand` a silent
2672      // no-op, so sibling B shows A's body — silent wrong content, and worse
2673      // than Perl, which scopes both. `\newif` installs `\ifX` as a Conditional
2674      // (`\Xtrue`/`\Xfalse` are plain macros the hooks do not read), so this
2675      // filter keeps every witness working while leaving macros scoped.
2676      if !matches!(value, Stored::Conditional(_)) {
2677        continue;
2678      }
2679      // Direct re-bind via assign_internal so we don't need to round-trip a
2680      // full Token. The Meaning table is keyed by SymStr (the CS name);
2681      // any future read via `assign_meaning(token, ...)` would reach the
2682      // same cell. Scope::Global removes higher-frame undo entries and
2683      // installs at the lowest non-locked frame.
2684      state_mut!().assign_internal(TableName::Meaning, key, value, Some(Scope::Global));
2685    }
2686  }
2687}
2688/// Ends the current level of grouping.
2689/// Note that this is lower level than `\egroup`;
2690pub fn pop_frame() -> Result<()> {
2691  let mut state = state_mut!();
2692  if state.undo.front().as_ref().unwrap().locked {
2693    fatal!(
2694      TargetUnexpected,
2695      Endgroup,
2696      "attempt to pop last locked stack frame"
2697    );
2698  // Fatal('unexpected', '<endgroup>', $self->getStomach,
2699  // "Attempt to pop last locked stack frame"); }
2700  } else {
2701    let popped_frame = state.undo.pop_front().unwrap();
2702    for table_name in TableName::variants() {
2703      let undo_table = popped_frame.table(*table_name);
2704      let state_table = state.table_mut(*table_name);
2705      for (key, undo_count) in undo_table.iter() {
2706        // Typically only 1 value to shift off the table, unless scopes have been activated.
2707        let named_table = state_table.get_mut(key).unwrap();
2708        for _ in 0..*undo_count {
2709          named_table.pop_front();
2710        }
2711      }
2712    }
2713  }
2714  Ok(())
2715}
2716
2717/// Determine depth of group nesting.
2718///
2719/// nesting created by {,},\bgroup,\egroup,\begingroup,\endgroup
2720/// by counting all frames which are not Daemon frames (and thus don't possess _FRAME_LOCK_).
2721/// This may give incorrect results for some special environments (e.g. minipage)
2722pub fn get_frame_depth() -> usize { state!().undo.iter().filter(|frame| !frame.locked).count() }
2723
2724/// `true` when the CURRENT (front) stack frame is the locked bottom frame —
2725/// i.e. there is no openable group/mode frame to pop. Popping it would FATAL.
2726pub fn current_frame_locked() -> bool { state!().undo.front().map(|f| f.locked).unwrap_or(true) }
2727/// begins a semiverbatim frame, neutralizing the usual + requested characters
2728pub fn begin_semiverbatim(extraspecials: Option<&[char]>) {
2729  // Is this a good/safe enough shorthand, or should we really be doing beginMode?
2730  push_frame();
2731  assign_value("MODE", "restricted_horizontal", None);
2732  assign_value("IN_MATH", false, None);
2733  let mut all_specials: Vec<char> = Vec::new();
2734  if let Some(extra) = extraspecials {
2735    for special in extra {
2736      all_specials.push(*special);
2737    }
2738  }
2739  {
2740    if let Some(Stored::Chars(specials_store)) = state!().lookup_value("SPECIALS") {
2741      for special_char in &**specials_store {
2742        all_specials.push(*special_char);
2743      }
2744    }
2745  }
2746
2747  for special_char in all_specials {
2748    assign_catcode(special_char, Catcode::OTHER, Some(Scope::Local));
2749  }
2750  assign_mathcode('\'', 0x8000u16, Some(Scope::Local));
2751  // try to stay as ASCII as possible
2752  if let Some(ref current_font) = lookup_font() {
2753    let local_font = current_font.merge(fontmap!(encoding => "ASCII"));
2754    assign_font(Rc::new(local_font), Some(Scope::Local));
2755  }
2756}
2757/// end by just calling `pop_frame`
2758pub fn end_semiverbatim() -> Result<()> { pop_frame() }
2759
2760//   #======================================================================
2761
2762// PARTIAL port of Perl `LaTeXML::Core::State::push/popDaemonFrame`
2763// (used by the Perl `latexmls` daemon to reset bindings between runs while
2764// keeping the loaded Pool). `pop_daemon_frame` is faithful (pop unlocked
2765// frames, unlock + pop the daemon frame, Fatal on the last frame).
2766// `push_daemon_frame` is NOT yet: Perl (State.pm L607-627) additionally
2767// `daemon_copy`s every mutable HASH/ARRAY value binding into the new frame —
2768// so IN-PLACE mutations under the daemon frame (Rust: `with_value_mut` on
2769// `VecDequeStored`/`HashTagData`/... values) can't corrupt the pre-frame
2770// state — and records `_PRELOADED_POOL_`. Without that copy, a daemon reset
2771// only undoes frame-tracked ASSIGNMENTS, not in-place mutations. The Rust
2772// persistent server (`latexml_oxide --server`) instead isolates each
2773// conversion in a `fork()`ed child, so these are not currently wired into a
2774// caller — kept (with the round-trip test in `tests/00_unit_state.rs`) as the
2775// seed of an in-process reset primitive for a future thread-reusing daemon
2776// mode, which MUST add the deep-copy semantics before relying on it. See
2777// `lsp_server` for the chosen fork-isolation design.
2778pub fn push_daemon_frame() {
2779  let daemon_frame = UndoFrame {
2780    locked: true,
2781    ..UndoFrame::default()
2782  };
2783  state_mut!().undo.push_front(daemon_frame);
2784}
2785
2786pub fn pop_daemon_frame() -> Result<()> {
2787  let mut state = state_mut!();
2788  // `is_some_and(!locked)` rather than `unwrap()`: an (impossible-in-practice)
2789  // empty undo stack must fall through to the Fatal below, not panic.
2790  while state.undo.front().is_some_and(|f| !f.locked) {
2791    drop(state);
2792    pop_frame()?;
2793    state = state_mut!();
2794  }
2795  if state.undo.len() > 1 {
2796    state.undo.front_mut().unwrap().locked = false;
2797    drop(state);
2798    pop_frame()?;
2799  } else {
2800    fatal!(
2801      TargetUnexpected,
2802      Endgroup,
2803      "Daemon Attempt to pop last stack frame"
2804    );
2805  }
2806  Ok(())
2807}
2808
2809// ======================================================================
2810/// Set one of the definition prefixes global, etc (only global matters!)
2811pub fn set_prefix(prefix: &str) { state_mut!().prefixes.insert(arena::pin(prefix), true); }
2812/// gets the current value of a named prefix
2813pub fn get_prefix(prefix: &str) -> bool { state!().get_prefix(prefix) }
2814/// `get_prefix` with a pre-pinned SymStr key (see `crate::pin!`) — for the
2815/// per-`\def` prefix probes in `Expandable::new` and friends.
2816pub fn get_prefix_sym(prefix: SymStr) -> bool { state!().get_prefix_sym(prefix) }
2817
2818/// clears the global prefixes
2819pub fn clear_prefixes() { state_mut!().prefixes = HashMap::default(); }
2820
2821// #======================================================================
2822/// Named scope bracketing a subfile LaTeXML included itself — a `standalone`
2823/// child's preamble, an `\import`ed file. Real LaTeX has no group at either spot
2824/// (standalone *gobbles* the child preamble; import restores its paths by plain
2825/// `\def` after the `\input`), so a package loaded inside one is an artifact of
2826/// LaTeXML executing what the real packages skip. Bindings that open such a
2827/// bracket activate this scope; `require_package` reads it to decide whether a
2828/// load must outlive the bracket. See OXIDIZED_DESIGN #65.
2829///
2830/// The name carries the frame depth of the bracket that opened it — Perl's own
2831/// `section:4` / `label:foo` convention (State.pm L965-975) — because activity
2832/// alone is not enough: `StashActive` is `Scope::Local` at the bracket's frame,
2833/// so a plain "is the region active?" test is ALSO true at every deeper frame,
2834/// and an author's `{\usepackage{…}}` written *inside* a subfile preamble would
2835/// be hoisted as well. That is a downgrade: pdflatex and Perl both leave such a
2836/// package lost. Matching the depth confines the region to the bracket's own
2837/// level.
2838pub fn subfile_scope_at_depth(depth: usize) -> SymStr { arena::pin(format!("subfile:{depth}")) }
2839
2840/// The subfile scope for the CURRENT frame depth — what a bracket activates on
2841/// opening, and what `require_package` tests before hoisting.
2842pub fn subfile_scope_here() -> SymStr { subfile_scope_at_depth(get_frame_depth()) }
2843
2844/// Is the named scope currently active? See `scope_active_in` for why this is a
2845/// front-value test and not a presence test, and `subfile_scope_at_depth` for the region
2846/// marker it supports.
2847pub fn is_scope_active(scope: SymStr) -> bool { scope_active_in(&state!(), scope) }
2848
2849/// Perl's scope-activity test, shared by the three call sites that need it
2850/// (`is_scope_active`, `activate_scope`, `deactivate_scope`; `get_active_scopes`
2851/// deliberately still enumerates KEYS, faithful to Perl State.pm L722-725, which
2852/// has the same latent quirk — it has no callers):
2853/// the truthiness of the FRONT `stash_active` value — `$$self{stash_active}
2854/// {$scope}[0]` in `activateScope` (State.pm L682) and `deactivateScope`
2855/// (L700).
2856///
2857/// Presence is NOT the test, and cannot be: deactivation OVERWRITES the front
2858/// value with a falsy one rather than removing the key — an ordinary global
2859/// assignment (`assign_internal(… 'stash_active', $scope, 0, 'global')`,
2860/// State.pm L701; ours passes `Stored::Bool(false)`). A global assign replaces
2861/// rather than layers: it drops the per-frame counts down to the locked frame,
2862/// pops exactly that many values, and leaves ONE. So the front value is the
2863/// whole state. (A delete is not available anyway — `stash_active` rides the
2864/// generic table + undo machinery, whose per-frame pop counts a removed key
2865/// would desynchronise.) Pinned by
2866/// `reentrancy_tests::scope_activity_tracks_value_not_presence`.
2867///
2868/// Reach: the production consumers — `counter/dialect.rs` (a reference number's
2869/// `<ctr>:<refnum>` scope, deactivated then re-activated as the counter moves,
2870/// mirroring Perl `Package.pm` L774-779) and `latex_constructs.rs`'s `label:`
2871/// scopes — activate names that nothing currently STASHES into, so an activation
2872/// installs no bindings and the re-activation fix is correct but latent. It
2873/// becomes observable the moment a binding defines with `scope => "<ctr>:<n>"`.
2874/// That is why the guards are unit-level: there is no output difference to
2875/// assert end-to-end yet. (The one `Scope::Named` stash writer, `declare.rs`'s
2876/// `id:<section_id>`, is consumed by `rewrite.rs` by prefix, not by activation.)
2877///
2878/// The local/global asymmetry is deliberate (Perl's own note above
2879/// `deactivateScope`): activation is `local`, so it expires with its group
2880/// without a teardown call — which is what makes a named scope usable as a
2881/// region marker (see `subfile_scope_at_depth`) — while deactivation is `global` so it
2882/// survives group exit. A local deactivation would be undone by the very group
2883/// that contained it.
2884fn scope_active_in(state: &State, scope: SymStr) -> bool {
2885  state
2886    .stash_active
2887    .get(&scope)
2888    .and_then(|entry| entry.front())
2889    .is_some_and(|v| !matches!(v, Stored::Bool(false)))
2890}
2891
2892/// Activates all stashed definitions for the named scope. No-op if the scope is already active.
2893pub fn activate_scope(scope: SymStr) {
2894  let mut state = state_mut!();
2895  // Perl L682 `if (!$$self{stash_active}{$scope}[0])` — do not re-activate if
2896  // already active, but a scope that was DEACTIVATED must be activatable again.
2897  if scope_active_in(&state, scope) {
2898    return;
2899  }
2900
2901  state.assign_internal(
2902    TableName::StashActive,
2903    scope,
2904    Stored::Bool(true),
2905    Some(Scope::Local),
2906  );
2907  // Also, we need to take ownership of the stashed data, so that we can assign it.
2908  // TODO: Potential to optimize?
2909  // Also x2, we are using a shared "Stored" interface for all data that passes through
2910  // assign_internal, but that causes both uncertainty and overhead in the Stash table
2911  // specifically. TODO x2: Maybe a more ambitious refactor will separate out the Stash logic
2912  // and use "StashTable" directly instead of Stored::Stash(StashTable) ?
2913
2914  let mut actions = Vec::new();
2915
2916  if let Some(Some(Stored::Stash(defns))) = state.stash.get(&scope).map(|x| x.iter().next()) {
2917    for (table_name, key, value) in defns {
2918      // copy the values out from the stashed defns, so that Rust
2919      // is calm we are borrowing safely.
2920
2921      actions.push((*table_name, key.to_owned(), value.clone()));
2922    }
2923  }
2924  // Here we ALWAYS push the stashed values into the table
2925  // since they may be popped off by deactivateScope
2926  for (table_name, key, value) in actions {
2927    let frame = &mut state.undo[0];
2928    let frame_table = frame.table_mut(table_name);
2929    let entry = frame_table.entry(key).or_insert(0);
2930    *entry += 1; // Note that this many values must be undone
2931    let key_table = state.table_mut(table_name).entry(key).or_default();
2932    key_table.push_front(value); // And push new binding.
2933  }
2934}
2935
2936// Probably, in most cases, the assignments made by activateScope
2937// will be undone by egroup or popping frames.
2938// But they can also be undone explicitly
2939
2940/// Removes any definitions that were associated with the named `scope`.
2941/// Normally not needed, since a scopes definitions are locally bound anyway.
2942pub fn deactivate_scope(scope: SymStr) {
2943  let mut state = state_mut!();
2944  // Perl L700 `if ($$self{stash_active}{$scope}[0])` — only an ACTIVE scope is
2945  // deactivated; a second deactivation must not re-run the pop below.
2946  if !scope_active_in(&state, scope) {
2947    return;
2948  }
2949
2950  state.assign_internal(
2951    TableName::StashActive,
2952    scope,
2953    Stored::Bool(false),
2954    Some(Scope::Global),
2955  );
2956
2957  let mut collected = Vec::new();
2958  if let Some(Some(Stored::Stash(defns))) = state.stash.get(&scope).map(|x| x.iter().next()) {
2959    for (table_name, key, value) in defns {
2960      collected.push((table_name.to_owned(), key.to_owned(), value.to_owned()));
2961    }
2962  }
2963
2964  for (table_name, key, value) in collected {
2965    let front_is_value = if let Some(table_entry_peek) = state.table(table_name).get(&key) {
2966      if let Some(table_front) = table_entry_peek.front() {
2967        *table_front == value
2968      } else {
2969        false
2970      }
2971    } else {
2972      false
2973    };
2974    let table_entry = state.table_mut(table_name).entry(key).or_default();
2975    if front_is_value {
2976      // Here we're popping off the values pushed by activateScope
2977      // to (possibly) reveal a local assignment in the same frame, preceding activateScope.
2978      (*table_entry).pop_front();
2979
2980      if let Some(frame) = state.undo.front_mut() {
2981        let frame_table = frame.table_mut(table_name);
2982        let frame_count = frame_table.entry(key).or_default();
2983        *frame_count -= 1;
2984      }
2985    } else {
2986      let message = arena::with(key, |key_str| {
2987        s!(
2988          "Unassigning wrong value for {} from table {} in deactivateScopevalue is {:?} but stack \
2989          is {:?}",
2990          key_str,
2991          table_name,
2992          value,
2993          table_entry
2994            .iter()
2995            .map(ToString::to_string)
2996            .collect::<Vec<String>>()
2997            .join(", ")
2998        )
2999      });
3000      arena::with(key, |key_str| Warn!("internal", key_str, message));
3001    }
3002  }
3003}
3004/// return all known named scopes
3005pub fn get_known_scopes() -> Vec<SymStr> { state!().stash.keys().copied().collect::<Vec<_>>() }
3006/// return the currently activated named scopes
3007pub fn get_active_scopes() -> Vec<SymStr> {
3008  state!().stash_active.keys().copied().collect::<Vec<_>>()
3009}
3010
3011//======================================================================
3012// Units.
3013// Put here since it could concievably evolve to depend on the current font.
3014/// convert a unit name into a `f64` scaling factor over `sp`
3015pub fn convert_unit(unit_arg: &str) -> f64 {
3016  let unit = unit_arg.to_lowercase();
3017  // Font-relative units fall back to 10pt metrics when no current font is
3018  // set (e.g. pre-bootstrap unit conversion). Perl gets this via the
3019  // built-in default font; matching with a static fallback is cheaper
3020  // than forcing every caller to ensure a font frame exists.
3021  let font_metric =
3022    |getter: fn(&Font) -> i64| -> f64 { lookup_font().map(|f| getter(&f) as f64).unwrap_or(0.0) };
3023  match unit.as_str() {
3024    "em" => font_metric(|f| f.get_em_width()),
3025    "ex" => font_metric(|f| f.get_ex_height()),
3026    "mu" => font_metric(|f| f.get_mu_width()),
3027    u => match UNITS.get(u) {
3028      Some(sp) => *sp,
3029      None => {
3030        let message = s!("Illegal unit of measure {:?}, assuming pt.", u);
3031        Warn!("expected", "<unit>", message);
3032        *UNITS.get("pt").unwrap()
3033      },
3034    },
3035  }
3036}
3037
3038/// Convert a unit name into the exact TeX `(num, den)` fraction such that a
3039/// dimension of `value` units is `floor(round(value·65536)·num/den)` scaled
3040/// points (see `numeric_ops::fixpoint_unit`).
3041///
3042/// Physical units use TeX's `set_conversion(num)(denom)` fractions verbatim
3043/// (tex.web §458, lines 9020-9032): `in=7227/100, pc=12/1, cm=7227/254,
3044/// mm=7227/2540, bp=7227/7200, dd=1238/1157, cc=14856/1157`, plus `pt=1/1` and
3045/// `sp=1/65536`. `px` follows LaTeXML in aliasing `bp`. Font-relative units
3046/// (`em`/`ex`/`mu`) return `(metric_sp, 65536)`, matching tex.web §8983's
3047/// `nx_plus_y(_, v, xn_over_d(v, f, 65536))` for internal units. This is the
3048/// exact-integer counterpart of [`convert_unit`]; each physical entry satisfies
3049/// `convert_unit(u) == 65536·num/den`.
3050pub fn convert_unit_ratio(unit_arg: &str) -> (i64, i64) {
3051  let unit = unit_arg.to_lowercase();
3052  let font_metric =
3053    |getter: fn(&Font) -> i64| -> i64 { lookup_font().map(|f| getter(&f)).unwrap_or(0) };
3054  // UNITY == 65536 sp per pt; font-relative and `sp` units convert via the
3055  // `floor(fix·v/UNITY)` path (tex.web §8983 nx_plus_y/xn_over_d).
3056  match unit.as_str() {
3057    "em" => (font_metric(|f| f.get_em_width()), UNITY),
3058    "ex" => (font_metric(|f| f.get_ex_height()), UNITY),
3059    "mu" => (font_metric(|f| f.get_mu_width()), UNITY),
3060    "pt" => (1, 1),
3061    "pc" => (12, 1),
3062    "in" => (7227, 100),
3063    "bp" | "px" => (7227, 7200),
3064    "cm" => (7227, 254),
3065    "mm" => (7227, 2540),
3066    "dd" => (1238, 1157),
3067    "cc" => (14856, 1157),
3068    "sp" => (1, UNITY),
3069    u => {
3070      let message = s!("Illegal unit of measure {:?}, assuming pt.", u);
3071      Warn!("expected", "<unit>", message);
3072      (1, 1)
3073    },
3074  }
3075}
3076
3077// ======================================================================
3078
3079// sub getStatus {
3080//   my ($self, $type) = @_;
3081//   return $$self{status}{$type}; }
3082
3083// sub getStatusMessage {
3084//   my ($self) = @_;
3085//   my $status = $$self{status};
3086//   my @report = ();
3087// push(@report, colorizeString("$$status{warning} warning" . ($$status{warning} > 1 ? 's' :
3088// ''), 'warning'))     if $$status{warning};
3089// push(@report, colorizeString("$$status{error} error" . ($$status{error} > 1 ? 's' : ''),
3090// 'error'))     if $$status{error};
3091//   push(@report, "$$status{fatal} fatal error" . ($$status{fatal} > 1 ? 's' : ''))
3092
3093//     if $$status{fatal};
3094//   my @undef = ($$status{undefined} ? keys %{ $$status{undefined} } : ());
3095//   push(@report, colorizeString(scalar(@undef) . " undefined macro" . (@undef > 1 ? 's' : '')
3096//         . "[" . join(', ', @undef) . "]", 'details'))
3097//     if @undef;
3098//   my @miss = ($$status{missing} ? keys %{ $$status{missing} } : ());
3099//   push(@report, colorizeString(scalar(@miss) . " missing file" . (@miss > 1 ? 's' : '')
3100//         . "[" . join(', ', @miss) . "]", 'details'))
3101//     if @miss;
3102//   return join('; ', @report) || colorizeString('No obvious problems', 'success'); }
3103
3104// sub getStatusCode {
3105//   my ($self) = @_;
3106//   my $status = $$self{status};
3107//   my $code;
3108//   if ($$status{fatal} && $$status{fatal} > 0) {
3109//     $code = 3; }
3110//   elsif ($$status{error} && $$status{error} > 0) {
3111//     $code = 2; }
3112//   elsif ($$status{warning} && $$status{warning} > 0) {
3113//     $code = 1; }
3114//   else {
3115//     $code = 0; }
3116//   return $code; }
3117// #======================================================================
3118
3119// TODO: Continue here -- need to diagnose why the indirect model is not returning
3120// an intermediate "ltx:p" when asking for "#PCDATA" inside "ltx:_CaptureBlock_",
3121// instead getting an intermediate "ltx:para".
3122
3123/// The indirect model includes all elements allowed as direct children,
3124/// and all descendents of a node that can be inserted after autoOpen'ing intermediate elements.
3125///
3126/// This model therefor includes information from the Schema, as well as
3127/// `auto_open` information that may be introduced in binding files.
3128// [Thus it should NOT be modifying the Model object, which may cover several documents in Daemon]
3129// `imodel[tag][child] => inter` means if in `tag`, to open `child`, we must first open `inter`
3130pub fn compute_indirect_model() -> IndirectModel {
3131  let mut imodel: IndirectModel = SymHashMap::default();
3132  // Determine any indirect paths to each descendent via an `autoOpen-able' tag.
3133  // Perl Document.pm L196-199 maps the `autoOpen` property to a fractional
3134  // OPENABILITY. Most tags get 1.0; `ltx:picture` gets 0.5 (L4995) so it
3135  // loses path-priority against full auto-openers (para, p, text, item, …).
3136  // We scale to u32 (100 = full, 50 = half) to keep integer arithmetic; the
3137  // `desirability * openability / 100` recursion mirrors Perl's float math.
3138  let mut openability: SymHashMap<u32> = SymHashMap::default();
3139  // Collect all known tags: from the schema model AND from state tag_properties
3140  let mut all_tags: HashSet<SymStr> = model::get_tags().into_iter().collect();
3141  for tag in state!().tag_properties.keys() {
3142    all_tags.insert(*tag);
3143  }
3144  let picture_sym = pin!("ltx:picture");
3145  for tag in &all_tags {
3146    if let Some(x) = state!().tag_properties.get(tag)
3147      && let Some(true) = x.auto_open
3148    {
3149      // Perl: Tag('ltx:picture', autoOpen => 0.5). All other autoOpen
3150      // sites in the LaTeXML tree use `autoOpen => 1`, so a simple
3151      // `tag == ltx:picture` check reproduces the fraction faithfully.
3152      let priority = if *tag == picture_sym { 50u32 } else { 100u32 };
3153      openability.insert_sym(*tag, priority);
3154    }
3155  }
3156
3157  for tag in &all_tags {
3158    let tag = *tag;
3159    let mut desc: SymHashMap<SymHashMap<usize>> = SymHashMap::default();
3160    compute_indirect_model_aux(tag, None, 100, &mut openability, &mut desc);
3161    let desc_keys: Vec<SymStr> = desc.keys().copied().collect();
3162    for kid in desc_keys {
3163      // Find best path to `kid`.
3164      let mut best = 0;
3165      let mut desc_kid_keys: Vec<SymStr> =
3166        desc.entry_sym(kid).or_default().keys().copied().collect();
3167      // TODO: why sort?
3168      // Update: it appears that "ltx:p" and "ltx:para" in ltx:_CaptureBlock_ is one reason!!!
3169      desc_kid_keys.sort_by(|a, b| arena::with2(*a, *b, |astr, bstr| astr.cmp(bstr)));
3170      for start in desc_kid_keys {
3171        if tag != kid && tag != start {
3172          let start_entry = {
3173            let kid_entry = desc.entry_sym(kid).or_default();
3174            *kid_entry.entry_sym(start).or_insert(0)
3175          };
3176          if start_entry > best {
3177            imodel.entry_sym(tag).or_default().insert_sym(kid, start);
3178            {
3179              best = start_entry;
3180            }
3181          }
3182        }
3183      }
3184    }
3185  }
3186  // PATCHUP
3187  if model::is_permissive() {
3188    // !!! Alarm!!!
3189    imodel
3190      .entry("#Document")
3191      .or_default()
3192      .insert("#PCDATA", arena::pin_static("ltx:p"));
3193  }
3194
3195  imodel
3196}
3197
3198// Package helpers used in core need to be localized here -- as state methods
3199/// `Let` macro setter
3200pub fn let_i(token1: &Token, token2: &Token, scope: Option<Scope>) {
3201  let meaning =// if token2.get_dont_expand().is_some() {
3202  //   Stored::Token(token2.clone())
3203  // } else {
3204    lookup_meaning(token2)
3205      .unwrap_or(Stored::None);
3206  // };
3207  // Deep-copy the robust-wrapper pair.
3208  //
3209  // Our `DefConstructor`/`DefMacro` with `robust => true` stores the
3210  // public CS (e.g. `\ref`) as an Expandable wrapper that expands to
3211  // `\protect \<cs><space>`. The actual body lives under a SEPARATE
3212  // `\<cs><space>` slot. A plain `\let \origref \ref` would copy
3213  // only the wrapper — leaving the `\ref<space>` body shared between
3214  // `\origref` and `\ref`. A subsequent `\DeclareRobustCommand \ref
3215  // {...}` then overwrites `\ref<space>` and `\origref` silently
3216  // tracks the new body — often causing an infinite loop when the
3217  // new body references `\origref` itself (a common LaTeX idiom for
3218  // adding starred-form support: `\let\origref\ref
3219  // \DeclareRobustCommand\ref{\@ifstar\origref\origref}`).
3220  //
3221  // Match upstream LaTeX semantics by also `\let`ing the body half:
3222  // `\let \origref<space> \ref<space>` so the two CSes own
3223  // independent body slots and remain decoupled.
3224  //
3225  // Witnesses: canvas-3 stage-23 0810.0695 (PlanarMain.tex's
3226  // `\ifpdf...\else \let\origref\ref \DeclareRobustCommand\ref{
3227  // \@ifstar\origref\origref}\fi` triggers via the else-branch
3228  // because ifpdf.sty defaults `\ifpdf` to false in LaTeXML).
3229  // Recognize the robust-wrapper expansion `\protect \<name><space>`
3230  // by shape: a 2-token Expandable body matching exactly those tokens
3231  // where the second token's CS name equals `<token2-name><space>`.
3232  if let Stored::Expandable(ref defn) = meaning
3233    && let Some(ExpansionBody::Tokens(ref tks)) = defn.expansion
3234  {
3235    let body = tks.unlist_ref();
3236    if body.len() == 2 && body[0].with_str(|s| s == "\\protect") {
3237      let expected_body_name = token2.with_str(|s| s!("{s} "));
3238      if body[1].with_str(|s| s == expected_body_name) {
3239        // (1) Copy `\<token2><space>` body to `\<token1><space>`
3240        // so the two CSes have independent body slots.
3241        let token1_space = crate::T_CS!(token1.with_str(|s| s!("{s} ")));
3242        let token2_space = crate::T_CS!(expected_body_name);
3243        let body_meaning = lookup_meaning(&token2_space).unwrap_or(Stored::None);
3244        let body_csname_sym = token1_space.pin_cs_name();
3245        state_mut!().assign_internal(TableName::Meaning, body_csname_sym, body_meaning, scope);
3246        // (2) Install `\<token1>` as a NEW robust wrapper that
3247        // points to `\<token1><space>` (rather than reusing
3248        // `\<token2>`'s wrapper, which still hardcodes
3249        // `\<token2><space>` in its body and would silently
3250        // re-track any later `\DeclareRobustCommand\<token2>{...}`).
3251        let new_wrapper_body = Tokens::new(vec![crate::T_CS!("\\protect"), token1_space]);
3252        let new_wrapper = Expandable::new(
3253          *token1,
3254          None,
3255          Some(ExpansionBody::Tokens(new_wrapper_body)),
3256          Some(expandable::ExpandableOptions {
3257            robust: true,
3258            ..expandable::ExpandableOptions::default()
3259          }),
3260        );
3261        if let Ok(wrapper) = new_wrapper {
3262          install_definition(wrapper, scope);
3263          after_assignment();
3264          return;
3265        }
3266      }
3267    }
3268  }
3269  assign_meaning(token1, meaning, scope);
3270  after_assignment();
3271}
3272/// `XEquals` check for two token arguments
3273pub fn x_equals(token1: &Token, token2: &Token) -> bool {
3274  let def1_opt = lookup_meaning(token1); // # token, definition object or None
3275  let def2_opt = lookup_meaning(token2); // ditto
3276  match (def1_opt, def2_opt) {
3277    (Some(def1), Some(def2)) => def1 == def2, // If both have defns, must be same defn!
3278    (None, None) => true,                     // true if both undefined
3279    (..) => false,                            // False, if only one has 'meaning'
3280  }
3281}
3282
3283/// simple id generator for a ligature
3284pub fn generate_ligature_id() -> usize {
3285  let id = 1 + lookup_int("autogen_ligature_id");
3286  assign_value("autogen_ligature_id", Stored::Int(id), Scope::Global);
3287  id as usize
3288}
3289
3290/// run the accumulated directives from `\afterassignment`
3291pub fn after_assignment() {
3292  match remove_value_sym(pin!("afterAssignment")) {
3293    Some(Stored::Tokens(after)) => gullet::unread(after),
3294    Some(Stored::Token(after)) => gullet::unread_one(after),
3295    None | Some(Stored::None) => {},
3296    Some(other) => panic!("unexpected in after_assignment: {other:?}"),
3297  }
3298}
3299
3300// Ported from Perl's "local" declarations
3301
3302pub fn get_tag_property(tag: SymStr) -> TagOptions { state_mut!().ensure_tag_property(tag).clone() }
3303pub fn ensure_tag_property(tag: SymStr) { state_mut!().ensure_tag_property(tag); }
3304
3305pub fn with_tag_property<R, FnR>(tag: SymStr, caller: FnR) -> R
3306where FnR: FnOnce(Option<&TagOptions>) -> R {
3307  caller(state!().tag_properties.get(&tag))
3308}
3309pub fn with_tag_property_mut<R, FnR>(tag: SymStr, caller: FnR) -> R
3310where FnR: FnOnce(&mut TagOptions) -> R {
3311  ensure_tag_property(tag);
3312  caller(state_mut!().tag_properties.get_mut(&tag).unwrap())
3313}
3314
3315pub fn has_indirect_model() -> bool { state!().indirect_model.is_some() }
3316pub fn set_indirect_model(im: IndirectModel) {
3317  let mut state = state_mut!();
3318  state.indirect_model = Some(im);
3319}
3320pub fn get_nomathparse_flag() -> bool { state!().nomathparse }
3321pub fn set_nomathparse_flag(val: bool) {
3322  let mut state = state_mut!();
3323  state.nomathparse = val;
3324}
3325
3326/// Whether source-locator (`--source-map`) tracking + emission is on.
3327/// Read by the source-provenance machinery (mouth token-start capture,
3328/// `Document::absorb` `data-sourcepos` stamping) to stay zero-cost when off.
3329/// See `docs/performance/SOURCE_PROVENANCE.md`.
3330pub fn source_map_enabled() -> bool { state!().source_map }
3331pub fn set_source_map_flag(val: bool) {
3332  let mut state = state_mut!();
3333  state.source_map = val;
3334}
3335
3336/// Find-or-append a source file in the document-level `sources` table,
3337/// returning its integer `tag` (index). The per-element `data-sourcepos`
3338/// attribute carries this compact integer rather than a path — the
3339/// Source-Map-v3 `sources` convention (compact + anonymisable). Only
3340/// called on the source-map path. See `docs/performance/SOURCE_PROVENANCE.md` §0.1.
3341pub fn source_tag(source: SymStr) -> u32 {
3342  let mut state = state_mut!();
3343  if let Some(idx) = state.source_table.iter().position(|s| *s == source) {
3344    idx as u32
3345  } else {
3346    state.source_table.push(source);
3347    (state.source_table.len() - 1) as u32
3348  }
3349}
3350
3351/// Snapshot of the `sources` table (index = tag) for emitting the
3352/// document-level tag→file header.
3353pub fn source_table_snapshot() -> Vec<SymStr> { state!().source_table.clone() }
3354
3355/// Record a *named* source in the opened-sources read-log. Called from
3356/// `Mouth::create` for file and cached-content mouths — a cold path (one
3357/// call per file open, not per token).
3358pub fn record_opened_source(source: SymStr) { state_mut!().opened_sources.insert(source); }
3359
3360/// Snapshot of the opened-sources read-log (see `record_opened_source`).
3361pub fn opened_sources_snapshot() -> Vec<SymStr> {
3362  state!().opened_sources.iter().copied().collect()
3363}
3364
3365pub fn current_verbosity() -> i32 { state!().verbosity }
3366
3367pub fn push_pending_resource(value: Resource) { state_mut!().pending_resources.push(value); }
3368pub fn take_pending_resources() -> Vec<Resource> {
3369  std::mem::take(&mut state_mut!().pending_resources)
3370}
3371pub fn reset_pending_resources() { state_mut!().pending_resources = Vec::new(); }
3372pub fn get_indirect_model_relationship(tag: SymStr, childtag: SymStr) -> Option<SymStr> {
3373  match state!().indirect_model.as_ref().unwrap().get_sym(tag) {
3374    Some(sub_m) => sub_m.get_sym(childtag).copied(),
3375    None => None,
3376  }
3377}
3378
3379pub fn get_bindings_dispatch() -> Option<ResolvingBindingDispatcher> {
3380  state!().bindings_dispatch.clone()
3381}
3382pub fn get_extra_bindings_dispatch() -> Option<BindingDispatcher> {
3383  state!().extra_bindings_dispatch.clone()
3384}
3385pub fn set_bindings_dispatch(dispatcher: ResolvingBindingDispatcher) {
3386  let mut state = state_mut!();
3387  state.bindings_dispatch = Some(dispatcher);
3388}
3389pub fn set_extra_bindings_dispatch(dispatcher: BindingDispatcher) {
3390  let mut state = state_mut!();
3391  state.extra_bindings_dispatch = Some(dispatcher);
3392}
3393
3394/// Snapshot of all registered (name, ext) binding pairs across all
3395/// dispatchers. Used by `find_file(notex=true)` to detect compiled-binding
3396/// existence regardless of extension (cls/sty/def/pool/code.tex/...).
3397pub fn get_binding_names() -> Vec<&'static [(&'static str, &'static str)]> {
3398  state!().binding_names.clone()
3399}
3400/// Append one crate's `(name, ext)` slice. Companion to
3401/// `set_bindings_dispatch` / `set_extra_bindings_dispatch` — call alongside
3402/// dispatcher registration so `find_file` can resolve compile-time
3403/// bindings. Duplicates are deduplicated by pointer so repeated calls from
3404/// the same crate don't inflate the fallback pool.
3405pub fn add_binding_names(names: &'static [(&'static str, &'static str)]) {
3406  let mut state = state_mut!();
3407  let ptr = names.as_ptr();
3408  if state.binding_names.iter().any(|s| s.as_ptr() == ptr) {
3409    return;
3410  }
3411  state.binding_names.push(names);
3412}
3413
3414/// Filtered view of `get_binding_names()` returning ONLY class names
3415/// (without `.cls` suffix). Used by `load_class` for Perl's prefix-match
3416/// fallback (Package.pm L2702-2706). Returns a flat `Vec<&str>` rather
3417/// than per-crate slices — callers that need to preserve crate boundaries
3418/// should iterate `get_binding_names()` directly.
3419pub fn get_class_binding_names() -> Vec<&'static str> {
3420  state!()
3421    .binding_names
3422    .iter()
3423    .flat_map(|slice| slice.iter())
3424    .filter(|(_, ext)| *ext == "cls")
3425    .map(|(name, _)| *name)
3426    .collect()
3427}
3428
3429/// `true` when at least one registered binding declares `ext` as its
3430/// extension. Used by `\input`'s heuristic to decide whether
3431/// `\input{name.<ext>}` should consult the binding registry — e.g.
3432/// `.sty`, `.cls`, `.def`, `.pool`, `code.tex` are all valid binding
3433/// extensions, while `.eps`, `.png`, `.bib` are not. Matches by extension
3434/// only (the `name` is checked separately by `dispatch()`'s exact lookup).
3435pub fn is_binding_extension(ext: &str) -> bool {
3436  state!()
3437    .binding_names
3438    .iter()
3439    .any(|slice| slice.iter().any(|(_, e)| *e == ext))
3440}
3441
3442/// `true` when a binding is registered for the exact `(name, ext)` pair.
3443/// Convenience wrapper over the per-crate slices in `binding_names`.
3444/// Mirrors `dispatch()`'s lookup but without the side effect of loading.
3445pub fn binding_exists(name: &str, ext: &str) -> bool {
3446  state!()
3447    .binding_names
3448    .iter()
3449    .any(|slice| slice.iter().any(|(n, e)| *n == name && *e == ext))
3450}
3451
3452pub fn get_label_mapping_hook() -> Option<LabelMappingHook> { state!().label_mapping_hook.clone() }
3453pub fn set_label_mapping_hook(hook: LabelMappingHook) {
3454  let mut state = state_mut!();
3455  state.label_mapping_hook = Some(hook);
3456}
3457
3458/// Read SEARCHPATHS from the group-scoped value table (Perl
3459/// `LookupValue('SEARCHPATHS')`). Mirrors [`get_graphics_paths`]: the list is a
3460/// group-scoped value, not a plain field, so an `\import`/`\subimport` group
3461/// reverts its change at `}` and a package's global add persists.
3462pub fn get_search_paths() -> Vec<String> {
3463  lookup_value("SEARCHPATHS")
3464    .map(|v| match v {
3465      Stored::Strings(syms) => syms.iter().map(|s| arena::to_string(*s)).collect(),
3466      Stored::VecDequeStored(vdq) => vdq
3467        .iter()
3468        .filter_map(|item| match item {
3469          Stored::String(s) => Some(arena::to_string(*s)),
3470          _ => None,
3471        })
3472        .collect(),
3473      _ => Vec::new(),
3474    })
3475    .unwrap_or_default()
3476}
3477pub fn with_search_paths<R, FnR>(caller: FnR) -> R
3478where FnR: FnOnce(&[String]) -> R {
3479  caller(&get_search_paths())
3480}
3481/// Global append (Perl `PushValue(SEARCHPATHS)`) — a persistent search dir.
3482pub fn add_search_path(path: String) {
3483  let mut paths = get_search_paths();
3484  paths.push(path);
3485  set_search_paths(paths);
3486}
3487/// Global prepend (Perl `UnshiftValue(SEARCHPATHS)`) — a persistent search dir.
3488pub fn search_paths_push_front(path: String) {
3489  let mut paths = get_search_paths();
3490  paths.insert(0, path);
3491  set_search_paths(paths);
3492}
3493/// Replace SEARCHPATHS GLOBALLY (Perl `AssignValue(SEARCHPATHS => [...], 'global')`).
3494/// For the local-by-default `\import` scoping, use [`set_search_paths_local`].
3495pub fn set_search_paths(paths: Vec<String>) { assign_search_paths(paths, Scope::Global); }
3496/// Replace SEARCHPATHS in the CURRENT group only (Perl `AssignValue(SEARCHPATHS
3497/// => [...])` default-local): reverted when the enclosing `\import`/`\subimport`
3498/// group closes. This is what makes `import.sty` faithful without an explicit
3499/// save/restore stack.
3500pub fn set_search_paths_local(paths: Vec<String>) { assign_search_paths(paths, Scope::Local); }
3501fn assign_search_paths(paths: Vec<String>, scope: Scope) {
3502  let vdq: VecDeque<Stored> = paths
3503    .into_iter()
3504    .map(|p| Stored::String(arena::pin(&p)))
3505    .collect();
3506  assign_value("SEARCHPATHS", Stored::VecDequeStored(vdq), Some(scope));
3507}
3508pub fn has_search_paths() -> bool { !get_search_paths().is_empty() }
3509/// Mirror Perl's `LookupValue('GRAPHICSPATHS')` — a list value that all
3510/// `\graphicspath`, `\svgpath`, initial source-directory prepends, and
3511/// `image_candidates` consult. Always return as `Vec<String>` even if the
3512/// value was stored as `Strings` (initial assignValue) or `VecDequeStored`
3513/// (after any push/unshift).
3514pub fn get_graphics_paths() -> Vec<String> {
3515  lookup_value("GRAPHICSPATHS")
3516    .map(|v| match v {
3517      Stored::Strings(syms) => syms.iter().map(|s| arena::to_string(*s)).collect(),
3518      Stored::VecDequeStored(vdq) => vdq
3519        .iter()
3520        .filter_map(|item| match item {
3521          Stored::String(s) => Some(arena::to_string(*s)),
3522          _ => None,
3523        })
3524        .collect(),
3525      _ => Vec::new(),
3526    })
3527    .unwrap_or_default()
3528}
3529
3530/// Zero-alloc membership test for GRAPHICSPATHS. Mirrors the Perl idiom
3531/// `grep { $_ eq $dir } @{ $state->lookupValue('GRAPHICSPATHS') }` but
3532/// without allocating an owned `Vec<String>` for a single boolean — the
3533/// interned-symbol `with`/`with2` family resolves each path in place.
3534pub fn graphics_paths_contains(needle: &str) -> bool {
3535  lookup_value("GRAPHICSPATHS")
3536    .map(|v| match v {
3537      Stored::Strings(syms) => syms.iter().any(|s| arena::with(*s, |p| p == needle)),
3538      Stored::VecDequeStored(vdq) => vdq.iter().any(|item| match item {
3539        Stored::String(s) => arena::with(*s, |p| p == needle),
3540        _ => false,
3541      }),
3542      _ => false,
3543    })
3544    .unwrap_or(false)
3545}
3546
3547/// Mirror Perl's `$state->unshiftValue(GRAPHICSPATHS => $dir)`. Used by
3548/// Core.pm-style source-directory prepends.
3549pub fn graphics_paths_push_front(path: String) {
3550  let key = arena::pin("GRAPHICSPATHS");
3551  let entry = Stored::String(arena::pin(&path));
3552  let mut state = state_mut!();
3553  if !state.value.contains_key(&key) {
3554    state.assign_internal(
3555      TableName::Value,
3556      key,
3557      Stored::VecDequeStored(VecDeque::new()),
3558      Some(Scope::Global),
3559    );
3560  }
3561  let receiver = state.value.get_mut(&key).unwrap().front_mut();
3562  match receiver {
3563    Some(Stored::VecDequeStored(vdq)) => vdq.push_front(entry),
3564    Some(Stored::Strings(syms)) => {
3565      let mut vdq: VecDeque<Stored> = syms.iter().map(|s| Stored::String(*s)).collect();
3566      vdq.push_front(entry);
3567      state.assign_internal(
3568        TableName::Value,
3569        key,
3570        Stored::VecDequeStored(vdq),
3571        Some(Scope::Global),
3572      );
3573    },
3574    _ => {
3575      let mut vdq = VecDeque::new();
3576      vdq.push_front(entry);
3577      state.assign_internal(
3578        TableName::Value,
3579        key,
3580        Stored::VecDequeStored(vdq),
3581        Some(Scope::Global),
3582      );
3583    },
3584  }
3585}
3586
3587/// manage a (global) hash of values
3588pub fn with_mapping<R, FnR>(map: &str, key: &str, caller: FnR) -> R
3589where FnR: FnOnce(Option<&Stored>) -> R {
3590  let map_sym = arena::pin(map);
3591  caller(match state!().value.get(&map_sym) {
3592    None => None,
3593    Some(map_vec) => match map_vec.front() {
3594      Some(Stored::HashStored(h)) => h.get(key),
3595      _ => None,
3596    },
3597  })
3598}
3599
3600pub fn with_mapping_sym<R, FnR>(map: SymStr, key: SymStr, caller: FnR) -> R
3601where FnR: FnOnce(Option<&Stored>) -> R {
3602  caller(match state!().value.get(&map) {
3603    None => None,
3604    Some(map_vec) => match map_vec.front() {
3605      Some(Stored::HashStored(h)) => h.get_sym(key),
3606      _ => None,
3607    },
3608  })
3609}
3610
3611pub fn with_mapping_keys<R, FnR>(map: &str, caller: FnR) -> R
3612where FnR: FnOnce(Vec<SymStr>) -> R {
3613  caller(state!().lookup_mapping_keys(map))
3614}
3615
3616pub fn with_font_info<R, FnR>(key: &Token, caller: FnR) -> R
3617where FnR: FnOnce(Result<Option<&Stored>>) -> R {
3618  caller(state!().lookup_font_info(key))
3619}
3620
3621pub fn get_input_encoding() -> Option<SymStr> { state!().input_encoding.as_ref().map(arena::pin) }
3622pub fn set_input_encoding(val: Option<String>) {
3623  let mut state = state_mut!();
3624  state.input_encoding = val;
3625}
3626
3627pub fn with_stacked_values<R, FnR>(key: &str, caller: FnR) -> R
3628where FnR: FnOnce(Vec<&Stored>) -> R {
3629  caller(state!().lookup_stacked_values(key))
3630}
3631/// Sym-keyed variant of `with_stacked_values`.
3632pub fn with_stacked_values_sym<R, FnR>(key: SymStr, caller: FnR) -> R
3633where FnR: FnOnce(Vec<&Stored>) -> R {
3634  caller(state!().lookup_stacked_values_sym(key))
3635}
3636
3637pub fn set_state(incoming_state: State) {
3638  // Reset state rotation to Main to prevent stale Sty/Std state from previous runs
3639  STATE_IN_USE.set(RotateState::Main);
3640  let mut global_state = state_mut!();
3641  *global_state = incoming_state;
3642}
3643
3644/// Check whether a Stored value can be serialized for the kernel dump.
3645/// Values containing closures (Primitive, Constructor, Conditional, etc.)
3646/// cannot be serialized — they come from Rust engine code, not the dump.
3647/// This matches Perl's DumpFile which only serializes Expandable macros.
3648pub fn is_serializable(stored: &Stored) -> bool {
3649  use Stored::*;
3650  match stored {
3651    // Data types: always serializable
3652    None | Bool(_) | String(_) | Charcode(_) | Int(_) | Catcode(_) => true,
3653    Token(_) | Tokens(_) | Number(_) | Float(_) => true,
3654    Glue(_) | MuGlue(_) | Dimension(_) | MuDimension(_) => true,
3655    Reversion(_) | KeyVal(_) => true,
3656    Chars(_) | Strings(_) => true,
3657    // Expandable: serializable when body is Tokens OR None (regular
3658    // macros). Closure-bodied Expandables (e.g. `\expandafter`,
3659    // `\unexpanded`, `\the` — defined via `DefMacro!` with a closure
3660    // body) ALSO pass — dump_writer's `serialize_stored` emits a PA
3661    // alias to the canonical CS so `\let \tex_expandafter:D
3662    // \expandafter`-style aliases survive the dump. (Bug C parity fix
3663    // — see project_kernel_dump_tdd.md.) The writer's add-only policy
3664    // at load time skips entries whose key is already defined in the
3665    // compiled engine, so primary CSes don't double-bind.
3666    Expandable(_) => true,
3667    // Register: serializable (stores value + type, no closures)
3668    Register(_) => true,
3669    // Font: serializable (data only)
3670    Font(_) => true,
3671    // Primitives/MathPrimitives/Conditionals: the CLOSURE can't be
3672    // serialized, but each carries its own canonical CS name. If the
3673    // entry's key differs from that canonical CS, this is a `\let`-alias
3674    // we CAN capture (as a "PA" pointer) so the dump reader replays the
3675    // `\let` at load time. dump_writer returns the PA tag; dump_reader
3676    // re-applies via state::let_i. This is how \tex_let:D, \tex_def:D,
3677    // \tex_ifx:D, \if_meaning:w, and the hundreds of other expl3-renamed
3678    // primitives + conditionals survive the dump without needing to re-run
3679    // 36k lines of expl3-code.tex.
3680    //
3681    // Returning true here only means "pass to dump_writer"; the writer's
3682    // serialize_stored emits the PA target. Self-aliases (primary CSes
3683    // not yet aliased anywhere) typically don't appear in the diff because
3684    // they're in the pre-snapshot — but if they do, the dump reader skips
3685    // them by comparing key to target.
3686    Primitive(_) | MathPrimitive(_) | Conditional(_) => true,
3687    // Constructor: same logic as Primitive/Conditional. Constructors carry a
3688    // closure body the dump can't serialize, BUT they each carry a canonical
3689    // CS field. When the entry key differs from that CS, it's a `\let`-alias
3690    // (e.g. `\let \tex_par:D \par` where `\par` is itself a `Let!` alias to
3691    // `\lx@normal@par` — a Constructor). dump_writer emits `PA\t<cs>`;
3692    // dump_reader replays via `state::let_i`. Mirrors Perl's writer:
3693    // `dump_constructor` is undefined in `Dumper.pm`, but TeX_Job.pool
3694    // `DumpFile`'s let-detection branch (L184-198) catches the (key !=
3695    // value->getCSName) case and emits `Lt(key, letkey)`. Without this,
3696    // `\tex_par:D`, `\tex_cr:D`, `\tex_noindent:D`, etc. drop from the dump
3697    // because diff_from_snapshot filters them before the writer's
3698    // Constructor arm sees them.
3699    Constructor(_) => true,
3700    // Collections: serializable if contents are
3701    VecDequeStored(_) | HashStored(_) | HashString(_) => true,
3702    // Everything else: skip for safety
3703    _ => false,
3704  }
3705}
3706
3707/// Take a snapshot of the current State (for dump diff).
3708pub fn take_snapshot() -> rustc_hash::FxHashMap<(TableName, SymStr), Stored> { state!().snapshot() }
3709
3710/// Compute diff from snapshot and return changed serializable entries.
3711pub fn diff_snapshot(
3712  snap: &rustc_hash::FxHashMap<(TableName, SymStr), Stored>,
3713) -> Vec<(TableName, SymStr, Stored)> {
3714  state!().diff_from_snapshot(snap)
3715}
3716
3717// Thread-local holder for the snapshot taken at a named init phase.
3718// Currently only "bootstrap" is used: when `latex.rs` finishes loading
3719// `latex_bootstrap`, it stashes the state snapshot here. `ini_tex::dump_format`
3720// reads it so its diff matches Perl's `DumpFile` semantics — "what did raw
3721// latex.ltx + the rest of the engine init add on top of pure bootstrap".
3722// Without this hook the snapshot is taken after `_base.rs` + `_constructs.rs`
3723// have also run, making the diff far narrower than Perl's dump. See
3724// SYNC_STATUS D0 (d.1).
3725type StateSnapshot = rustc_hash::FxHashMap<(TableName, SymStr), Stored>;
3726type StagedSnapshotMap = rustc_hash::FxHashMap<&'static str, StateSnapshot>;
3727
3728thread_local! {
3729  static STAGED_SNAPSHOTS: RefCell<StagedSnapshotMap> =
3730    RefCell::new(rustc_hash::FxHashMap::default());
3731}
3732
3733/// Take a snapshot now and store it under a named key for later retrieval.
3734/// Intended for phased engine init (e.g. `stage_snapshot("bootstrap")` called
3735/// right after `latex_bootstrap` has loaded).
3736pub fn stage_snapshot(name: &'static str) {
3737  let snap = take_snapshot();
3738  STAGED_SNAPSHOTS.with(|m| {
3739    m.borrow_mut().insert(name, snap);
3740  });
3741}
3742
3743/// Stage an already-taken snapshot under a named key. Used by callers
3744/// (like `ini_tex`) that want to snapshot at a specific point without
3745/// waiting for a pool hook.
3746pub fn stage_snapshot_value(
3747  name: &'static str,
3748  snap: rustc_hash::FxHashMap<(TableName, SymStr), Stored>,
3749) {
3750  STAGED_SNAPSHOTS.with(|m| {
3751    m.borrow_mut().insert(name, snap);
3752  });
3753}
3754
3755/// Retrieve a previously staged snapshot, if present.
3756pub fn get_staged_snapshot(
3757  name: &str,
3758) -> Option<rustc_hash::FxHashMap<(TableName, SymStr), Stored>> {
3759  STAGED_SNAPSHOTS.with(|m| m.borrow().get(name).cloned())
3760}
3761
3762#[cfg(test)]
3763mod reentrancy_tests {
3764  use super::*;
3765
3766  /// `try_lookup_int` must degrade to `None` under a live mutable borrow
3767  /// (contention) instead of panicking, while behaving like `lookup_int`
3768  /// otherwise. This is the load-bearing primitive of the `Error!`-during-
3769  /// `state_mut()` fix (tikz-cd 2001.08973).
3770  #[test]
3771  fn try_lookup_int_degrades_on_contention() {
3772    // Absent key, no contention → Some(0), matching lookup_int's default.
3773    assert_eq!(try_lookup_int("p1a_absent_key_xyz"), Some(0));
3774    // A live mutable borrow → None (cannot read), no panic.
3775    let _guard = (*STATE).borrow_mut();
3776    assert_eq!(try_lookup_int("MAX_ERRORS"), None);
3777  }
3778
3779  /// Reproduces tikz-cd 2001.08973: `push_value` into a non-VecDeque field
3780  /// hits the BUG-path `Error!`, which reads `MAX_ERRORS`. Before the fix,
3781  /// `push_value` held `state_mut!()` across that `Error!`, panicking
3782  /// "RefCell already mutably borrowed". It must now report the BUG and
3783  /// return Ok without panicking.
3784  #[test]
3785  fn push_value_bug_path_is_borrow_safe() {
3786    assign_value("p1a_bug_key", Stored::Int(7), Some(Scope::Global));
3787    let r = push_value("p1a_bug_key", Stored::Int(1));
3788    assert!(r.is_ok());
3789    // Same guarantee for the pop side.
3790    let r2 = pop_value("p1a_bug_key");
3791    assert!(r2.is_ok());
3792  }
3793
3794  /// `Scope::InPlace` (Perl `State.pm:175` 'inplace') is the same-level
3795  /// reassignment behind the Rhai `LookupDefinition(cs).push*` hook-splice
3796  /// (`install_definition(d, Some(Scope::InPlace))`). It must be neither Global
3797  /// nor Local across a group boundary — this is exactly the divergence
3798  /// @xworld21 flagged in PR #333 (r3623947537). Exercised on the Value table,
3799  /// which funnels through the identical `assign_internal` arm.
3800  #[test]
3801  fn inplace_scope_keeps_the_bindings_level() {
3802    // Scenario 1: a value bound ABOVE the group, mutated in-place from INSIDE
3803    // the group, PERSISTS past group exit (Local would have reverted it). This
3804    // is BookML's real case: patch an already-global def, mutation stays.
3805    assign_value("ip_above", Stored::Int(1), Some(Scope::Global));
3806    push_frame();
3807    assign_value("ip_above", Stored::Int(2), Some(Scope::InPlace));
3808    assert_eq!(
3809      lookup_int("ip_above"),
3810      2,
3811      "in-place mutation is active at once"
3812    );
3813    pop_frame().expect("pop group");
3814    assert_eq!(
3815      lookup_int("ip_above"),
3816      2,
3817      "in-place patch of an outer-bound value rode the outer binding past group \
3818       exit (Local would revert to 1)"
3819    );
3820
3821    // Scenario 2: a value LOCALLY redefined in the group, then mutated in-place,
3822    // REVERTS to the outer value at group exit (Global would have kept the
3823    // patch). The in-place edit rode the LOCAL binding, which is discarded.
3824    assign_value("ip_local", Stored::Int(1), Some(Scope::Global));
3825    push_frame();
3826    assign_value("ip_local", Stored::Int(2), Some(Scope::Local));
3827    assign_value("ip_local", Stored::Int(3), Some(Scope::InPlace));
3828    assert_eq!(
3829      lookup_int("ip_local"),
3830      3,
3831      "in-place mutated the local front"
3832    );
3833    pop_frame().expect("pop group");
3834    assert_eq!(
3835      lookup_int("ip_local"),
3836      1,
3837      "in-place patch of a locally-bound value was discarded with the group \
3838       (Global would keep 3)"
3839    );
3840  }
3841
3842  /// `is_scope_active` must track the FRONT value's truthiness, not key presence:
3843  /// `deactivate_scope` OVERWRITES the front value with `Stored::Bool(false)`
3844  /// instead of removing the entry, so a presence test reports a deactivated
3845  /// scope as still active.
3846  /// Perl writes the same test inline as `$$self{stash_active}{$scope}[0]`
3847  /// (State.pm L682). The region property `subfile_scope_at_depth` relies on
3848  /// (OXIDIZED_DESIGN #65) is the second assertion: an activation made inside a
3849  /// group is undone by that group, with no matching teardown call.
3850  #[test]
3851  fn scope_activity_tracks_value_not_presence() {
3852    let scope = arena::pin("t@scope@activity");
3853    assert!(!is_scope_active(scope), "unknown scope must be inactive");
3854
3855    activate_scope(scope);
3856    assert!(is_scope_active(scope), "activated scope must read active");
3857
3858    deactivate_scope(scope);
3859    assert!(
3860      !is_scope_active(scope),
3861      "a deactivated scope must read INACTIVE — `stash_active` still holds the \
3862       key, carrying Stored::Bool(false)"
3863    );
3864    // Deactivation is a GLOBAL assign, which replaces rather than layers: the
3865    // stack collapses to exactly that one falsy value. This is why the front
3866    // value is the whole state, and why presence cannot be the activity test.
3867    let depth = state!()
3868      .stash_active
3869      .get(&scope)
3870      .map(|entry| entry.len())
3871      .unwrap_or(0);
3872    assert_eq!(
3873      depth, 1,
3874      "global assign must overwrite, leaving one value — not stack a second"
3875    );
3876
3877    // A second deactivation must be a silent no-op. Perl gates on `[0]` being
3878    // TRUE (State.pm L700) precisely so the binding-pop below it does not run
3879    // twice; re-running it pops values `activate_scope` never pushed, which is
3880    // what Perl's "Unassigning wrong value for KEY from table T in
3881    // deactivateScope" warning reports.
3882    deactivate_scope(scope);
3883    assert!(
3884      !is_scope_active(scope),
3885      "still inactive after a second deactivate"
3886    );
3887    let depth2 = state!()
3888      .stash_active
3889      .get(&scope)
3890      .map(|entry| entry.len())
3891      .unwrap_or(0);
3892    assert_eq!(
3893      depth2, 1,
3894      "a second deactivation must not stack another value"
3895    );
3896  }
3897
3898  /// A DEACTIVATED scope must be activatable again — Perl gates `activateScope`
3899  /// on `!$$self{stash_active}{$scope}[0]` (State.pm L682), the front value's
3900  /// truthiness. Gating on key presence instead made the first deactivation
3901  /// permanent, since `deactivate_scope` leaves `Stored::Bool(false)` behind.
3902  /// Reached in Perl by the counter/label scopes, which deactivate the old
3903  /// reference number before activating the next (`Package.pm` L774-779).
3904  #[test]
3905  fn a_deactivated_scope_can_be_reactivated() {
3906    let scope = arena::pin("t@scope@reactivate");
3907    activate_scope(scope);
3908    deactivate_scope(scope);
3909    assert!(!is_scope_active(scope), "precondition: deactivated");
3910
3911    activate_scope(scope);
3912    assert!(
3913      is_scope_active(scope),
3914      "re-activation after deactivation must take effect (Perl State.pm L682)"
3915    );
3916  }
3917
3918  /// The self-terminating half: `activate_scope` marks `StashActive` with
3919  /// `Scope::Local`, so the enclosing group ends the region by construction.
3920  #[test]
3921  fn scope_activation_is_bounded_by_its_group() {
3922    let scope = arena::pin("t@scope@bounded");
3923    push_frame();
3924    activate_scope(scope);
3925    assert!(
3926      is_scope_active(scope),
3927      "active inside the group that opened it"
3928    );
3929    pop_frame().expect("pop group");
3930    assert!(
3931      !is_scope_active(scope),
3932      "the region must end with its group — no explicit deactivate_scope"
3933    );
3934  }
3935}