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