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(¤t) {
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}