Skip to main content

latexml_core/
gullet.rs

1use std::{
2  cell::{RefCell, RefMut},
3  collections::VecDeque,
4};
5
6use once_cell::sync::Lazy;
7use regex::Regex;
8use rustc_hash::FxHashSet as HashSet;
9
10// use std::mem;
11// use std::rc::Rc;
12use crate::alignment::Alignment;
13use crate::{
14  DigestedData,
15  common::{
16    arena::{self, SymStr},
17    dimension::Dimension,
18    error::*,
19    float::Float,
20    glue::{FillCode, Glue},
21    locator::Locator,
22    mudimension::MuDimension,
23    muglue::MuGlue,
24    number::Number,
25    numeric_ops::{NumericOps, UNITY, fixpoint, fixpoint_unit},
26    object::Object,
27    store::Stored,
28  },
29  definition::{
30    Definition,
31    conditional::ConditionalType,
32    register::{Register, RegisterType, RegisterValue},
33  },
34  mouth::Mouth,
35  state::*,
36  token::{Catcode, TOKEN_ENDCSNAME, TOKEN_RELAX, Token},
37  tokens::Tokens,
38};
39
40static DIGIT_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[0-9]").unwrap());
41static OCT_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[0-7]").unwrap());
42static HEX_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[0-9A-F]").unwrap());
43
44/// Cached snapshot of `LXML_TRACE_GROUP_END` env var, sampled exactly
45/// once per process. Inlining `std::env::var(...)` on the hot
46/// `read_x_token` path was triggering SIGSEGVs in `__GI_getenv` under
47/// concurrent test-thread execution: glibc's `getenv` walks the
48/// process-global `environ` array unprotected, and the volume of
49/// concurrent calls (millions/sec across N threads) made the unsafe
50/// concurrent walks visible. The fix is to read the env var ONCE at
51/// static-init time; subsequent checks are a free atomic load.
52pub static TRACE_GROUP_END: Lazy<bool> =
53  Lazy::new(|| std::env::var("LXML_TRACE_GROUP_END").is_ok());
54
55// `\noexpand`'d tokens are represented per-token by the `\special_relax` family
56// (`Token::is_noexpand_family` / `token::noexpand_family`): the shadowed token's
57// identity is encoded in the CS name, so it survives storage and dumps without a
58// global smuggle slot. The family resolves to `\relax` meaning via the
59// `state::lookup_meaning` fallback. Faithful to TeX's `no_expand_flag`, which
60// preserves the shadowed `cur_cs` while giving it relax meaning for one access.
61use std::cell::Cell;
62
63use crate::pin;
64
65/// True when `token` is a `\noexpand`'d form (`\special_relax` family) shadowing
66/// `target` — used by delimited-parameter / keyword matching so that, faithful to
67/// TeX, a `\noexpand`'d token still matches its underlying identity.
68fn special_relax_matches(token: &Token, target: &Token) -> bool {
69  token.noexpand_shadowed().as_ref() == Some(target)
70}
71#[thread_local]
72static DEFERRED_COMMANDS: Lazy<HashSet<SymStr>> = Lazy::new(|| {
73  set!(
74    pin!("\\the"),
75    pin!("\\showthe"),
76    pin!("\\unexpanded"),
77    pin!("\\detokenize")
78  )
79});
80
81// If it is a column ending token, Returns the token, a keyword and whether it is "hidden"
82#[thread_local]
83static COLUMN_ENDS: Lazy<[(Token, &'static str, bool); 6]> = Lazy::new(|| {
84  [
85    // besides T_ALIGN
86    (T_CS!("\\cr"), "cr", false),
87    (T_CS!("\\crcr"), "crcr", false),
88    (T_CS!("\\lx@hidden@cr"), "cr", true),
89    (T_CS!("\\lx@hidden@crcr"), "crcr", true),
90    (T_CS!("\\lx@hidden@align"), "insert", true),
91    (T_CS!("\\span"), "span", false),
92  ]
93});
94
95/// What a balanced read ([`read_balanced`]) does when it exhausts a mouth before
96/// the braces balance.
97///
98/// Perl has no such distinction: `Gullet.pm` L465-472 reads `$$self{mouth}
99/// ->readToken()` — the *current* mouth only — and `last`s at the boundary, so
100/// every Perl mouth is [`Opaque`](BalancedBoundary::Opaque). Rust crosses the
101/// boundary for token-level injections, which is a deliberate surpass-Perl
102/// divergence (xint, see [`read_balanced`]) and must stay narrow: crossing from a
103/// mouth that is *its own input* lets one runaway argument swallow everything
104/// after it.
105#[derive(PartialEq, Eq, Debug, Clone, Copy)]
106pub enum BalancedBoundary {
107  /// The mouth is a token-level continuation of the enclosing stream
108  /// (`\scantokens`, RawTeX): its `}` may legitimately live in the parent, so a
109  /// balanced read drains it and resumes there.
110  Transparent,
111  /// The mouth is a self-contained input. A balanced read stops at its end, as
112  /// Perl always does — an unbalanced argument loses the rest of *this* mouth
113  /// and nothing more.
114  Opaque,
115}
116
117#[derive(PartialEq, Debug)]
118pub struct MouthRuntime {
119  pub autoclose: bool,
120  pub mouth:     Mouth,
121  /// See [`BalancedBoundary`]. Only consulted when `autoclose` is set and the
122  /// mouth is not a file.
123  pub boundary:  BalancedBoundary,
124  /// Pushback LIFO stack: the "next to read" token is at `pushback.last()`.
125  /// Invariant: reading pops from the back; `unread_one` pushes to the back;
126  /// `unread_vec` iterates its input in reverse and pushes each — so the
127  /// first element of an unread Vec ends up on top (= next to read).
128  /// See `flush_mouth` for the rare FIFO-prepend semantics (\endinput).
129  ///
130  /// Previously a `VecDeque<Token>` — switched to a plain Vec because the
131  /// hot-path is pure LIFO and VecDeque's push_front/pop_front machinery
132  /// (head-pointer + wrap arithmetic) showed up at ~3.3% of total Ir in
133  /// callgrind on siunitx-heavy fixtures.
134  pub pushback:  Vec<Token>,
135}
136
137#[derive(Debug, Default)]
138pub struct Gullet {
139  pub runtime:              Option<MouthRuntime>,
140  pub mouthstack:           VecDeque<MouthRuntime>,
141  pub pending_comments:     VecDeque<Token>,
142  pub token_limit:          Option<usize>,
143  pub pushback_limit:       Option<usize>,
144  pub progress:             usize,
145  /// Token-progress floor above which [`cycle_guard`](Self::cycle_guard)
146  /// engages. Defaults to `CYCLE_GUARD_ACTIVATE` (20M). Graphics packages
147  /// whose healthy expansion legitimately runs to 100M+ tokens (pgf/tikz/xy)
148  /// raise it via [`raise_cycle_guard_activate`] so their streams stay out of
149  /// the per-token fingerprint regime; the 400M `token_limit` remains the hard
150  /// backstop. `#[derive(Default)]` would zero this (guard-always-on), so it is
151  /// set explicitly in the constructor and the per-conversion reset.
152  pub cycle_guard_activate: usize,
153  /// Windowed cycle detector over the expansion (read-token) stream — catches
154  /// small-period infinite expansion loops (`\def\x{a\x}` etc.) far earlier
155  /// and more cheaply than `token_limit`/`pushback_limit`. Gated on a high
156  /// `progress` so normal documents never touch it. See [`crate::cycle_guard`].
157  pub cycle_guard:          crate::cycle_guard::CycleGuard,
158  /// Reading-context serial, mixed into every cycle-guard fingerprint so that
159  /// windows never match ACROSS `reading_from_mouth` contexts: a cycle is only
160  /// a cycle within one expansion context. Each `reading_from_mouth` entry
161  /// allocates a fresh serial (from `ctx_next`) and restores the outer one on
162  /// exit (`ctx_stack`), so (a) consecutive IDENTICAL short expansions — the
163  /// math0402448 xymatrix per-cell `get_xmarg_id` stream — get distinct
164  /// serials and can never concatenate into a pseudo-periodic window (the
165  /// false positive an earlier blanket `reset()` suppressed), while (b) an
166  /// OUTER loop's tokens keep their serial across inner expansions, so a
167  /// runaway whose body calls `do_expand` each iteration (~164 call sites)
168  /// remains detectable — the blind spot the blanket reset had (PR #249
169  /// review P2-7).
170  pub ctx_serial:           u64,
171  ctx_next:                 u64,
172  ctx_stack:                Vec<u64>,
173}
174
175thread_local! {
176  /// Debug-only (LATEXML_DEBUG_FATAL): ring of the most recent read tokens,
177  /// dumped when the gullet cycle guard trips so the repeating window is
178  /// identifiable from logs (this is how the math0402448 xymatrix
179  /// false-positive was diagnosed).
180  static DEBUG_RECENT_TOKENS: RefCell<VecDeque<String>> =
181    RefCell::new(VecDeque::with_capacity(512));
182}
183
184/// Hoisted env probe for the LATEXML_DEBUG_FATAL diagnostics (shared seam in
185/// `common::error`; read once so the per-token hot path pays one bool test).
186static DEBUG_FATAL: Lazy<bool> = Lazy::new(debug_fatal_enabled);
187
188#[thread_local]
189pub static GULLET: Lazy<RefCell<Gullet>> = Lazy::new(|| {
190  RefCell::new(Gullet {
191    token_limit: default_token_limit(),
192    // Explicit: `#[derive(Default)]` would set this to 0 (guard active from the
193    // first token). Graphics packages raise it at load (see
194    // `raise_cycle_guard_activate`).
195    cycle_guard_activate: CYCLE_GUARD_ACTIVATE,
196    ..Gullet::default()
197  })
198});
199
200/// Eagerly initialize this thread's gullet-phase `#[thread_local]` roots
201/// (`DEFERRED_COMMANDS`, `COLUMN_ENDS`, `GULLET`). Their initializers intern
202/// `SymStr`s / build `Token`s via the arena, so force them AFTER
203/// [`arena::force_init`](crate::common::arena::force_init) /
204/// [`token::force_init`](crate::token::force_init). Forcing them at
205/// conversion entry keeps them from initializing re-entrantly from within
206/// another root's init mid-conversion — the macOS `#[thread_local]` hazard
207/// behind issue #217. No behavioral change on Linux.
208pub(crate) fn force_init() {
209  Lazy::force(&DEFERRED_COMMANDS);
210  Lazy::force(&COLUMN_ENDS);
211  Lazy::force(&GULLET);
212}
213
214macro_rules! gullet {
215  () => {
216    (*GULLET).borrow()
217  };
218}
219macro_rules! gullet_mut {
220  () => {
221    (*GULLET).borrow_mut()
222  };
223}
224/// Set the token limit and reset progress. Returns previous (limit, progress) for restoration.
225pub fn set_token_limit(limit: Option<usize>) -> (Option<usize>, usize) {
226  let mut g = gullet_mut!();
227  let prev = (g.token_limit, g.progress);
228  g.token_limit = limit;
229  g.progress = 0;
230  prev
231}
232
233/// Set the pushback limit (maximum pushback stack size before fatal error).
234pub fn set_pushback_limit(limit: Option<usize>) { gullet_mut!().pushback_limit = limit; }
235
236/// The conversion's final token-read progress (for end-of-run telemetry —
237/// the calibration basis for `token_limit` / `CYCLE_GUARD_ACTIVATE`).
238pub fn final_progress() -> usize { gullet!().progress }
239
240/// Restore the token limit and progress from a previous set_token_limit call.
241pub fn restore_token_limit(saved: (Option<usize>, usize)) {
242  let mut g = gullet_mut!();
243  g.token_limit = saved.0;
244  g.progress = saved.1;
245}
246
247macro_rules! runtime {
248  () => {
249    (*GULLET).borrow_mut().runtime
250  };
251}
252
253/// Initialize (or reset, if reentrant) a Gullet to its default empty state
254/// The runaway-token BACKSTOP's baseline: real runaways are cut far earlier
255/// by the cycle guards / pushback limit / byte budget, so erring high costs
256/// no detection latency. 400M = 5× the heaviest measured legit arXiv paper
257/// (math0402448, amsart + xy-pic, 80.2M end-of-run progress under the
258/// 2026-06-10 all-three-reader-loop accounting; the old "80M" figure
259/// predated that multi-counting). `LATEXML_TOKEN_LIMIT` overrides
260/// (0 disables). Book-scale sources raise it proportionally per conversion
261/// — see [`scale_token_limit_to_source`].
262fn default_token_limit() -> Option<usize> {
263  match std::env::var("LATEXML_TOKEN_LIMIT")
264    .ok()
265    .and_then(|v| v.parse::<usize>().ok())
266  {
267    Some(0) => None,
268    Some(n) => Some(n),
269    None => Some(400_000_000),
270  }
271}
272
273/// Scale the runaway-token backstop to the SOURCE size. The 400M baseline is
274/// sized for arXiv-scale inputs; a book-scale source legitimately expands
275/// past it (witness: a 131 MB flat-index compilation died at 400M
276/// mid-digestion with no loop in sight, at a measured ~3+ tokens/byte and
277/// paper-class documents measured up to ~160 tokens/byte). ×200/byte keeps
278/// the ceiling finite — a true infinite loop still trips — while never
279/// LOWERING the baseline for small documents. An explicit
280/// `LATEXML_TOKEN_LIMIT` wins unchanged (including 0 = disabled).
281pub fn scale_token_limit_to_source(source_bytes: usize) {
282  if std::env::var_os("LATEXML_TOKEN_LIMIT").is_some() {
283    return;
284  }
285  let scaled = source_bytes.saturating_mul(200).max(400_000_000);
286  let mut gullet = gullet_mut!();
287  if let Some(limit) = gullet.token_limit.as_mut() {
288    *limit = (*limit).max(scaled);
289  }
290}
291
292pub fn initialize_gullet() {
293  let mut gullet = gullet_mut!();
294  gullet.runtime = None;
295  gullet.mouthstack = VecDeque::new();
296  gullet.pending_comments = VecDeque::new();
297  // Fresh per-conversion backstop: a prior book-scale conversion in this
298  // reused thread-local engine must not leak its scaled token limit into
299  // the next document.
300  gullet.token_limit = default_token_limit();
301  // Fresh per-conversion progress + cycle-guard history (the engine is a
302  // thread-local singleton reused across conversions in the test harness).
303  gullet.progress = 0;
304  gullet.cycle_guard.reset();
305  // Reset the Cluster F expansion-depth counter + re-read its env limit
306  // (independent thread-locals, no GULLET borrow — safe to call here).
307  reset_expand_depth();
308  // Restore the default activation floor: a prior tikz/xy conversion in this
309  // reused thread-local engine must not leak its raised floor into the next doc.
310  gullet.cycle_guard_activate = CYCLE_GUARD_ACTIVATE;
311  gullet.ctx_serial = 0;
312  gullet.ctx_next = 0;
313  gullet.ctx_stack.clear();
314}
315
316/// Get the current location of input getting read
317pub fn get_locator() -> Locator {
318  let gullet = gullet!();
319  let mut runtime_opt = gullet.runtime.as_ref();
320  let mut mouthstack_iter = gullet.mouthstack.iter();
321  while runtime_opt.is_some() && runtime_opt.as_ref().unwrap().mouth.get_source().is_empty() {
322    runtime_opt = mouthstack_iter.next();
323  }
324  // The free fn stays `-> Locator` ("where the parser is now" — always a real
325  // position during digestion; the workhorse for errors + box creation). A
326  // Mouth's `get_locator` is `Option` (per the `Object` trait) but is always
327  // `Some`, so unwrap to the default only in the no-mouth backup.
328  if let Some(runtime) = runtime_opt {
329    // First exit condition: we found a mouth with a source, and asked it for a locator
330    runtime.mouth.get_locator().unwrap_or_default()
331  } else if let Some(runtime) = gullet.mouthstack.front() {
332    // Backup strategy: return the first locator in the mouthstack:
333    runtime.mouth.get_locator().unwrap_or_default()
334  } else {
335    // Final backup -- the default locator
336    Locator::default()
337  }
338}
339
340/// `get_locator`'s accurate-start sibling (§1, docs/performance/SOURCE_PROVENANCE.md): same
341/// mouthstack walk, but reads the mouth's `get_locator_from_start` (`from` = the
342/// last token's captured start) instead of the heuristic `from`. Used for the
343/// construct-START snapshot at constructor digest under `--source-map`.
344pub fn get_locator_from_start() -> Locator {
345  let gullet = gullet!();
346  let mut runtime_opt = gullet.runtime.as_ref();
347  let mut mouthstack_iter = gullet.mouthstack.iter();
348  while runtime_opt.is_some() && runtime_opt.as_ref().unwrap().mouth.get_source().is_empty() {
349    runtime_opt = mouthstack_iter.next();
350  }
351  if let Some(runtime) = runtime_opt {
352    runtime.mouth.get_locator_from_start()
353  } else if let Some(runtime) = gullet.mouthstack.front() {
354    runtime.mouth.get_locator_from_start()
355  } else {
356    Locator::default()
357  }
358}
359
360/// Comment-oriented location string, based on `get_locator`
361pub fn get_location() -> String {
362  let loc = get_locator();
363  s!("at {}", loc)
364}
365
366pub fn mouth_is_open(mouth: &Mouth) -> bool {
367  let gullet = gullet!();
368  if let Some(ref runtime) = gullet.runtime
369    && mouth == &runtime.mouth
370  {
371    return true;
372  }
373  gullet
374    .mouthstack
375    .iter()
376    .any(|runtime| &runtime.mouth == mouth)
377}
378
379/// Push the `tokens` back into the input stream to be re-read.
380pub fn unread(tokens: Tokens) { unread_vec(tokens.unlist()); }
381/// Variant of `unread`, but drains the contents of `tokens` without taking ownership.
382pub fn unread_mut(tokens: &mut Tokens) {
383  if let Some(ref mut runtime) = gullet_mut!().runtime {
384    // Iterate in reverse and push to the stack top — the first element
385    // of `tokens` ends up on top (= next to read). Same semantics as
386    // the old VecDeque push_front pattern.
387    for token in tokens.unlist_mut().drain(..).rev() {
388      runtime.pushback.push(token);
389    }
390  };
391}
392/// Unreads a single `Token` to the start of the token stream.
393/// Perl: unread() always adjusts $ALIGN_STATE when unreading { or } tokens.
394pub fn unread_one(token: Token) {
395  match token.get_catcode() {
396    Catcode::BEGIN => decrement_align_group_count(), // Retract scanned brace
397    Catcode::END => increment_align_group_count(),
398    _ => {},
399  }
400  if let Some(ref mut runtime) = gullet_mut!().runtime {
401    runtime.pushback.push(token);
402  };
403}
404/// Unreads a `Vec<Token>` to the start of the token stream
405/// Perl: also adjusts ALIGN_STATE by retracting scanned braces (Gullet.pm lines 343-358)
406pub fn unread_vec(tokens: Vec<Token>) {
407  let mut level: i64 = 0;
408  if let Some(ref mut runtime) = gullet_mut!().runtime {
409    // Reserve once, push each token in reverse-iteration order so the
410    // first element of `tokens` ends up at the stack top. Same
411    // semantics as the old VecDeque push_front loop, but without
412    // per-element head-pointer arithmetic.
413    runtime.pushback.reserve(tokens.len());
414    for token in tokens.into_iter().rev() {
415      match token.get_catcode() {
416        Catcode::BEGIN => level -= 1, // Retract scanned braces
417        Catcode::END => level += 1,
418        _ => {},
419      }
420      runtime.pushback.push(token);
421    }
422  }
423  if level != 0 {
424    set_align_group_count(align_group_count() + level as i32);
425  }
426}
427
428//**********************************************************************
429// Start reading tokens from a new Mouth.
430// This pushes the mouth as the current source that $gullet->readToken (etc) will read from.
431// Once this Mouth has been exhausted, readToken, etc, will return undef,
432// until you call $gullet->closeMouth to clear the source.
433// Exception: if $toplevel=1, readXToken will step to next source
434// Note that a Tokens can act as a Mouth.
435pub fn open_mouth(mouth: Mouth, autoclose: bool) {
436  open_mouth_with(mouth, autoclose, BalancedBoundary::Transparent);
437}
438
439/// [`open_mouth`], choosing how a balanced read treats the new mouth's end.
440///
441/// Pass [`BalancedBoundary::Opaque`] whenever the mouth carries a self-contained
442/// input rather than a continuation of the current line — see the type's docs.
443pub fn open_mouth_with(mouth: Mouth, autoclose: bool, boundary: BalancedBoundary) {
444  let mut gullet = gullet_mut!();
445  if let Some(runtime) = gullet.runtime.take() {
446    gullet.mouthstack.push_front(runtime);
447  };
448  gullet.runtime = Some(MouthRuntime {
449    mouth,
450    autoclose,
451    boundary,
452    pushback: Vec::with_capacity(128),
453  });
454}
455
456pub fn close_mouth(forced: bool) -> Result<()> {
457  let mut shift_from_mouthstack = false;
458  let mut error_has_more_input = false;
459  if let Some(ref mut runtime) = runtime!()
460    && !forced
461    && (!runtime.pushback.is_empty() || runtime.mouth.has_more_input())
462  {
463    error_has_more_input = true
464  }
465  if error_has_more_input {
466    let next = match read_token()? {
467      Some(t) => t.stringify(),
468      None => String::from("Empty"),
469    };
470    let message = s!("Closing mouth with input remaining '{}'", next);
471    Error!("unexpected", next, message);
472  }
473  let mut gullet = gullet_mut!();
474  if let Some(ref mut runtime) = gullet.runtime {
475    runtime.mouth.finish();
476    shift_from_mouthstack = true;
477  }
478  if shift_from_mouthstack {
479    gullet.runtime = gullet.mouthstack.pop_front();
480  }
481  Ok(())
482}
483/// This flushes a mouth so that it will be automatically closed, next time it's read
484/// Corresponds to TeX's \endinput
485pub fn flush_mouth() {
486  if let Some(ref mut runtime) = runtime!() {
487    // Collect remaining mouth tokens in mouth order (t1, t2, t3, …),
488    // then splice them into the stack's BOTTOM in reverse order so
489    // that after the stack's existing top is popped, the mouth tokens
490    // come out in the original mouth order (t1 first, then t2, …).
491    let mut trailer: Vec<Token> = Vec::new();
492    while !runtime.mouth.is_eol() {
493      if let Some(token) = runtime.mouth.read_token() {
494        trailer.push(token);
495      }
496    }
497    if !trailer.is_empty() {
498      trailer.reverse();
499      runtime.pushback.splice(0..0, trailer);
500    }
501    // Stop reading (clear buffers, close file) but do NOT restore catcodes.
502    // Catcodes are restored by close_mouth → finish() when the mouth is
503    // properly popped from the stack.
504    runtime.mouth.stop_reading();
505  }
506}
507
508//**********************************************************************
509// Low-level readers: read token, read expanded token
510//**********************************************************************
511// # Get the next pending comment token (if any)
512pub fn get_pending_comment() -> Option<Token> { gullet_mut!().pending_comments.pop_front() }
513
514/// Queue sizes `(mouthstack, pending_comments)` — pass-1 streaming telemetry.
515pub fn queue_sizes() -> (usize, usize) {
516  let gullet = gullet!();
517  (gullet.mouthstack.len(), gullet.pending_comments.len())
518}
519
520/// Note that every char (token) comes through here (maybe even twice, through args parsing),
521/// So, be Fast & Clean!  This method only reads from the current input stream (Mouth).
522fn handle_template(
523  mut alignment: RefMut<Alignment>,
524  token: Token,
525  vtype: &str,
526  hidden: bool,
527) -> Result<()> {
528  //  Append expansion to end!?!?!?!
529  local_current_token(token);
530  let post = alignment.get_column_after();
531  set_align_group_count(1000000);
532  // ### NOTE: Truly fishy smuggling w/ \lx@hidden@cr
533  let arg_opt = if (vtype == "cr") && hidden {
534    // \lx@hidden@cr gets an argument as payload!!!!!
535    Some(read_arg(ExpansionLevel::Off)?)
536  } else {
537    None
538  };
539  // eprintln!("Halign: column after {post}");// . ToString($post) if $LaTeXML::DEBUG{halign};
540  if (vtype == "cr" || vtype == "crcr")
541    && alignment.is_in_row()
542    && !alignment
543      .current_row()
544      .map(|v| v.is_pseudo())
545      .unwrap_or(false)
546  {
547    unread_one(T_CS!("\\lx@alignment@row@after"));
548  }
549  if let Some(arg) = arg_opt {
550    // slippery - to unread {arg} we first unread } then arg then {, as we push to the front.
551    unread_one(T_END!());
552    unread(arg);
553    unread_one(T_BEGIN!());
554  }
555  unread_one(token);
556  unread(post);
557  expire_current_token();
558  Ok(())
559}
560
561/// Where a combined read ([`read_internal_token_checked`]) routes COMMENT
562/// tokens: the shared `pending_comments` queue (`read_token` / `read_x_token` /
563/// `read_next_conditional`) or straight into a caller-owned buffer
564/// (`read_balanced`, which keeps comments in its result).
565enum CommentSink<'a> {
566  Pending,
567  Into(&'a mut Vec<Token>),
568}
569
570/// Outcome of [`read_internal_token_checked`].
571enum CheckedRead {
572  /// The gullet has no runtime — early shutdown / recovery from a fatal
573  /// error. Treated as end-of-input instead of panicking. Driver: 2404.06289
574  /// (natbib \NAT@@wrout cascade landed here after the conversion was already
575  /// in error-recovery mode).
576  NoRuntime,
577  /// The current mouth(s) yielded no token (exhausted); the caller decides
578  /// whether an autoclose boundary may be crossed.
579  Exhausted,
580  /// The next token, already progress-counted and cycle-fingerprinted.
581  Tok(Token),
582}
583
584/// Duty cycle for the ACTIVE cycle guard (`progress` above the activation
585/// floor): fingerprint + scan only [`CYCLE_GUARD_DUTY_ON`] of every
586/// [`CYCLE_GUARD_DUTY_PERIOD`] tokens. A genuine infinite expansion loop is
587/// *persistent*, so scanning periodic windows still detects it — worst case
588/// one duty period plus a ring fill later (~17k tokens), four orders of
589/// magnitude before the 400M `token_limit` backstop — while a legitimately
590/// huge healthy stream (pgfplots data plots routinely read 200M+ tokens, past
591/// even the raised graphics floor) stops paying a per-token fingerprint tax
592/// for the whole remainder of the run (`cycle_guard_checkpoint` was 6.07% of
593/// self-time on witness 2405.14114 before duty-cycling). The ring is reset at
594/// each ON-window start so a window never straddles an OFF gap (no cross-gap
595/// phase artifacts). The ON width covers the ring capacity
596/// (`MAX_WINDOW × REPEAT = 1000`) plus detection cadence (`CHECK_EVERY`) for
597/// every period, so any loop already running at an ON-window start is caught
598/// within that same window. Bursts that end on their own inside an OFF window
599/// are, by definition, not infinite loops — the guard's only target.
600const CYCLE_GUARD_DUTY_PERIOD: usize = 16_384; // power of two (masked below)
601const CYCLE_GUARD_DUTY_ON: usize = 2_048;
602
603/// Per-token combined step for all FOUR reader loops (`read_token`,
604/// `read_x_token`, `read_balanced`, `read_next_conditional`): resource
605/// checkpoint, low-level pushback/mouth read, and (duty-cycled) cycle-guard
606/// fingerprint in ONE `GULLET` borrow. The loops are siblings, NOT a
607/// delegation chain, so each runs this same step — otherwise full-expansion
608/// paths (`\edef`, csname construction, conditional skipping) bypass every
609/// gullet guard and a runaway grinds to the watchdog (gap found on
610/// math0402448). Previously three separate borrows per token
611/// (`read_resource_checkpoint` → `read_internal_token` →
612/// `cycle_guard_checkpoint`, together ~10% of self-time on the pgfplots
613/// witness 2405.14114); the single borrow is the point of the merge.
614///
615/// Resource accounting:
616/// - `progress` counts UNCONDITIONALLY: the cycle guard's activation gate
617///   feeds off it, so nesting the increment inside the token-limit branch
618///   made `LATEXML_TOKEN_LIMIT=0` (and `set_token_limit(None)` during format
619///   init) silently disable the cycle guard as well — exactly when an
620///   operator disables the limit to let a big document through is when the
621///   loop guard matters most. (PR #249 review P2-5.) Only the limit
622///   COMPARISON stays conditional.
623/// - Limit breaches Fatal via the `#[cold]` outlined helpers below, with the
624///   borrow dropped first (`Fatal!` runs error machinery). A breach consumes
625///   no token: the read is skipped, exactly as when the old checkpoint fired
626///   before the read.
627///
628/// Cycle guard: fingerprints the token AS READ (before alignment /
629/// `\dont_expand` special-casing), so the guard sees the same stream
630/// regardless of which loop reads it. The reading-context serial is mixed
631/// into the fingerprint (see `Gullet::ctx_serial`) so windows never match
632/// across `reading_from_mouth` contexts. Activation is gated on
633/// `progress > cycle_guard_activate` AND the duty window above.
634#[inline]
635fn read_internal_token_checked(mut sink: CommentSink) -> Result<CheckedRead> {
636  enum Breach {
637    TokenLimit(usize),
638    PushbackLimit(usize),
639    Cycle(usize, Token),
640  }
641  let mut breach: Option<Breach> = None;
642  let mut outcome = CheckedRead::Exhausted;
643  {
644    let mut borrow = gullet_mut!();
645    let g: &mut Gullet = &mut borrow;
646    if g.runtime.is_none() {
647      return Ok(CheckedRead::NoRuntime);
648    }
649    g.progress += 1;
650    let rt = g.runtime.as_mut().unwrap();
651    if let Some(limit) = g.token_limit
652      && g.progress > limit
653    {
654      breach = Some(Breach::TokenLimit(limit));
655    } else if let Some(limit) = g.pushback_limit
656      && rt.pushback.len() > limit
657    {
658      breach = Some(Breach::PushbackLimit(limit));
659    } else {
660      // Duty-cycled guard activation, computed BEFORE the read: the phase —
661      // and the ON-window-start ring reset — depends only on `progress`,
662      // which is already final for this iteration. An Exhausted read landing
663      // exactly on phase 1 must still reset the ring, or the next window
664      // would splice onto the previous ON-window's history (adversarial-
665      // review finding, 2026-07-29).
666      let duty_on = g.progress > g.cycle_guard_activate && {
667        let phase = (g.progress - g.cycle_guard_activate) & (CYCLE_GUARD_DUTY_PERIOD - 1);
668        if phase == 1 {
669          // ON-window start: drop any pre-gap history.
670          g.cycle_guard.reset();
671        }
672        (1..=CYCLE_GUARD_DUTY_ON).contains(&phase)
673      };
674      if let CommentSink::Into(ref mut out) = sink {
675        // read_balanced keeps comments in its result: flush comments stashed
676        // by earlier reads before reading fresh ones (same order as the old
677        // inline loop's `pending_comments` drain at its top).
678        if !g.pending_comments.is_empty() {
679          out.extend(g.pending_comments.drain(..));
680        }
681      }
682      // The raw read: pushback first, then the current Mouth. COMMENT tokens
683      // go to the sink, MARKER tokens are handled inline (handle_marker only
684      // touches the align-count thread-locals, never GULLET — it already ran
685      // under this borrow in the old `read_internal_token`). `route` is the
686      // shared per-token filter: `Some(t)` = a real token, `None` = consumed
687      // (comment/marker).
688      let pending = &mut g.pending_comments;
689      let mut route = |t: Token| -> Option<Token> {
690        match t.get_catcode() {
691          Catcode::COMMENT => {
692            match sink {
693              CommentSink::Pending => pending.push_back(t),
694              CommentSink::Into(ref mut out) => out.push(t),
695            }
696            None
697          },
698          Catcode::MARKER => {
699            handle_marker(t);
700            None
701          },
702          _ => Some(t),
703        }
704      };
705      let mut next_token: Option<Token> = None;
706      while let Some(t) = rt.pushback.pop() {
707        if let Some(t) = route(t) {
708          next_token = Some(t);
709          break;
710        }
711      }
712      if next_token.is_none() {
713        while let Some(t) = rt.mouth.read_token() {
714          if let Some(t) = route(t) {
715            next_token = Some(t);
716            break;
717          }
718        }
719      }
720      if let Some(token) = next_token {
721        // Record BEFORE the activation gate: the ring is also what the
722        // token-limit fatal dumps, and that fatal fires precisely for
723        // runaways the cycle guard never recognised — which includes ones
724        // that trip a lowered `LATEXML_TOKEN_LIMIT` before the guard ever
725        // activates. Gating the ring on activation left it empty in exactly
726        // that case. Debug-only, so free in production.
727        if *DEBUG_FATAL {
728          DEBUG_RECENT_TOKENS.with(|ring| {
729            let mut ring = ring.borrow_mut();
730            if ring.len() >= 512 {
731              ring.pop_front();
732            }
733            ring.push_back(format!("{token:?}"));
734          });
735        }
736        if duty_on {
737          // Mix the reading-context serial into the fingerprint (see the
738          // `Gullet::ctx_serial` field doc): tokens read in different
739          // `reading_from_mouth` contexts can then never form a matching
740          // window, scoping cycle detection to ONE expansion context
741          // without destroying the outer context's history. The multiplier
742          // spreads the serial across the hash bits (splitmix64's odd
743          // constant).
744          let fp = token.cycle_fingerprint() ^ g.ctx_serial.wrapping_mul(0x9E37_79B9_7F4A_7C15);
745          if let Some(period) = g.cycle_guard.push(fp) {
746            breach = Some(Breach::Cycle(period, token));
747          }
748        }
749        if breach.is_none() {
750          outcome = CheckedRead::Tok(token);
751        }
752      }
753    }
754  }
755  // Borrow dropped; cold outlined fatal paths only from here on.
756  match breach {
757    None => Ok(outcome),
758    Some(Breach::TokenLimit(limit)) => token_limit_fatal(limit),
759    Some(Breach::PushbackLimit(limit)) => pushback_limit_fatal(limit),
760    Some(Breach::Cycle(period, token)) => cycle_trip_fatal(period, &token),
761  }
762}
763
764/// Token-limit breach ([`read_internal_token_checked`]). The cycle guard
765/// already dumps its window when it trips, but a runaway that reaches THIS
766/// limit is by definition one the cycle guard did NOT recognise — an aperiodic
767/// grind (a counter that keeps advancing, a list that keeps growing). Those
768/// are exactly the ones with no other clue in the log, so dump the same
769/// recent-token ring here. Without it a TokenLimit fatal says only "infinite
770/// loop?" and every investigation starts from zero (witness 2606.21610, a
771/// 42 s silent grind).
772#[cold]
773#[inline(never)]
774fn token_limit_fatal(limit: usize) -> Result<CheckedRead> {
775  let msg = s!("Token limit of {} exceeded, infinite loop?", limit);
776  if *DEBUG_FATAL {
777    eprintln!("[debug-fatal] token limit tripped: {msg}");
778    DEBUG_RECENT_TOKENS.with(|ring| {
779      let ring = ring.borrow();
780      let recent: Vec<&str> = ring.iter().map(String::as_str).collect();
781      eprintln!(
782        "[debug-fatal] last {} read tokens: {}",
783        ring.len(),
784        recent.join(" ")
785      );
786    });
787  }
788  Fatal!(Timeout, TokenLimit, msg);
789}
790
791/// Pushback-limit breach ([`read_internal_token_checked`]). Diagnostic: the
792/// looping token window is right here in the pushback — dump its head so the
793/// cycle is identifiable from logs.
794#[cold]
795#[inline(never)]
796fn pushback_limit_fatal(limit: usize) -> Result<CheckedRead> {
797  if *DEBUG_FATAL && let Some(rt) = gullet!().runtime.as_ref() {
798    let head: Vec<String> = rt
799      .pushback
800      .iter()
801      .take(48)
802      .map(|t| format!("{t:?}"))
803      .collect();
804    eprintln!("[debug-fatal] pushback head: {}", head.join(" "));
805  }
806  let msg = s!("Pushback limit of {} exceeded, infinite loop?", limit);
807  Fatal!(Timeout, PushbackLimit, msg);
808}
809
810/// Cycle-guard trip ([`read_internal_token_checked`]): a small-period infinite
811/// expansion loop, cut with a clean Fatal in O(window) extra tokens instead of
812/// grinding to the token limit (and the gigabytes of RSS that implies).
813#[cold]
814#[inline(never)]
815fn cycle_trip_fatal(period: usize, nextt: &Token) -> Result<CheckedRead> {
816  let msg = s!(
817    "Infinite expansion loop: a window of {} token(s) repeated {}+ times",
818    period,
819    crate::cycle_guard::REPEAT
820  );
821  if *DEBUG_FATAL {
822    eprintln!("[debug-fatal] gullet cycle guard tripping on token {nextt:?}: {msg}");
823    DEBUG_RECENT_TOKENS.with(|ring| {
824      let ring = ring.borrow();
825      let recent: Vec<&str> = ring.iter().map(String::as_str).collect();
826      eprintln!("[debug-fatal] last 512 read tokens: {}", recent.join(" "));
827    });
828  }
829  Fatal!(Timeout, Recursion, msg);
830}
831
832/// Read a token that the calling macro/primitive REQUIRES, holding the
833/// "argument expected but input ended" diagnostic in one place.
834///
835/// `read_token()` returning `None` (input exhausted) is a normal, expected
836/// control-flow signal in most contexts (end of file/group/optional-arg scan) —
837/// so the primitive deliberately keeps the `Option`. But for a caller that
838/// genuinely requires a token here, `None` is TeX's *"File ended while scanning
839/// use of \cs"* error state (real `pdftex` raises an `! Emergency stop`). This
840/// helper emits that parity `Error!` once, centrally, instead of every call site
841/// `.unwrap()`-panicking on the `None`. It STILL returns the `Option` (it does
842/// NOT fabricate a token), so the type system keeps each caller honest about how
843/// it degrades — close its group, substitute a default, etc.
844pub fn read_token_required(what: &str) -> Result<Option<Token>> {
845  let tok = read_token()?;
846  if tok.is_none() {
847    Error!(
848      "expected",
849      what,
850      format!("input ended while scanning use of {what}")
851    );
852  }
853  Ok(tok)
854}
855
856pub fn read_token() -> Result<Option<Token>> {
857  let mut next_token: Option<Token>;
858  loop {
859    // Combined checkpoint + raw read + cycle fingerprint, one borrow.
860    next_token = match read_internal_token_checked(CommentSink::Pending)? {
861      CheckedRead::NoRuntime => return Ok(None),
862      CheckedRead::Exhausted => None,
863      CheckedRead::Tok(t) => Some(t),
864    };
865    // ProgressStep() if ($$self{progress}++ % $TOKEN_PROGRESS_QUANTUM) == 0;
866
867    // Strict-Perl translation of Gullet.pm `readToken`:
868    //   alignment column-end check → \dont_expand check → break
869    //   ALIGN_STATE tracking happens AFTER the loop, on the FINAL
870    //   token to be returned (Perl L320-324).
871    if let Some(ref nextt) = next_token {
872      if (align_group_count() == 0)
873        && has_reading_alignment()
874        && let Some((atoken, atype, ahidden)) = is_column_end(nextt)
875      {
876        let reading_alignment = get_reading_alignment().unwrap();
877        if let DigestedData::Alignment(data) = reading_alignment.data() {
878          handle_template(data.borrow_mut(), atoken, atype, ahidden)?;
879        } else {
880          return Err("reading_alignment should always contain DigestedData::Alignment".into());
881        }
882        continue; // Perl: handleTemplate then continue while(1) loop
883      }
884      if nextt.code == Catcode::CS && nextt.text == pin!("\\dont_expand") {
885        // `\noexpand <tok>`: collapse to the per-token `\special_relax` family,
886        // encoding <tok>'s identity in the name (faithful to TeX's
887        // `no_expand_flag`, which keeps `cur_cs`). End-of-input ⇒ bare
888        // `\special_relax` (nothing to shadow).
889        next_token = Some(match read_token()? {
890          Some(tok) => crate::token::noexpand_family(&tok),
891          None => T_CS!("\\special_relax"),
892        });
893      }
894      break;
895    } else {
896      break;
897    }
898  }
899  // Perl Gullet.pm L320-324: ALIGN_STATE tracking happens AFTER the loop,
900  // applied only to the FINAL returned token. Previously this was inside
901  // the loop BEFORE the alignment check, which prevented column-end
902  // template handling on `{` tokens (count became 1 before the check).
903  if let Some(ref nextt) = next_token {
904    match nextt.get_catcode() {
905      Catcode::BEGIN => increment_align_group_count(),
906      Catcode::END => decrement_align_group_count(),
907      _ => {},
908    }
909  }
910  Ok(next_token)
911}
912
913/// Engage the expansion-stream cycle guard only after this many tokens — above
914/// the ordinary range (measured known-good papers 0.6–7.5M under the 2026-06-10
915/// all-three-loop accounting; 20M keeps them fingerprint-free with ~2.7×
916/// headroom), so only a runaway (heading for the 400M `token_limit` / RSS cap)
917/// records fingerprints, cut off in O(window) tokens (false positives guarded by
918/// the period-`REPEAT` requirement, not this bound). DEFAULT floor; graphics-heavy
919/// packages legitimately reach ~100–155M (math0402448 xy-pic, 1805.03265 tikz-cd)
920/// and raise it to [`CYCLE_GUARD_ACTIVATE_GRAPHICS`] at load via
921/// [`raise_cycle_guard_activate`], the 400M `token_limit` staying the backstop.
922const CYCLE_GUARD_ACTIVATE: usize = 20_000_000;
923
924/// Cycle-guard activation floor for graphics-heavy bindings (pgf/tikz/xy).
925/// These packages legitimately expand 100M+ tokens; this floor sits above the
926/// heaviest measured healthy graphics doc (1805.03265 tikz-cd ~155M) so they
927/// stay out of the per-token fingerprint regime, while remaining far below the
928/// 400M `token_limit` backstop. Raised — never lowered — per [`raise_cycle_guard_activate`].
929pub const CYCLE_GUARD_ACTIVATE_GRAPHICS: usize = 150_000_000;
930
931/// Raise the cycle-guard activation floor for the current (thread-local) gullet,
932/// only ever upward. Called from graphics package bindings (pgf/tikz/xy) whose
933/// healthy expansion runs to 100M+ tokens — see [`CYCLE_GUARD_ACTIVATE_GRAPHICS`].
934/// Idempotent and order-independent: loading several graphics packages just
935/// re-asserts the same floor. The per-conversion reset (`initialize_gullet`)
936/// restores the default so the raise does not leak across documents.
937pub fn raise_cycle_guard_activate(floor: usize) {
938  let mut g = gullet_mut!();
939  if floor > g.cycle_guard_activate {
940    g.cycle_guard_activate = floor;
941    // The duty-window phase is derived from `progress - cycle_guard_activate`
942    // (see `read_internal_token_checked`); raising the floor while the guard
943    // is already active shifts that phase, which could splice a previous
944    // ON-window's ring history against a later window with no reset at the
945    // seam. The history only ever describes the pre-raise regime — drop it.
946    g.cycle_guard.reset();
947  }
948}
949
950// Cluster F: bound gullet expansion-recursion depth (= `read_x_token`
951// re-entrancy) so a runaway — an xint number-arg chain, a self-referential
952// `\csname`/`\number`/`\romannumeral` — raises a fast `Fatal:Timeout:Recursion`
953// rather than grinding to the watchdog / RSS fuse. Legit docs nest ≲20; the cap
954// is 12_000. Env override `LATEXML_EXPAND_DEPTH_LIMIT` (0 disables).
955#[thread_local]
956static EXPAND_DEPTH: Cell<usize> = Cell::new(0);
957#[thread_local]
958static EXPAND_DEPTH_LIMIT: Cell<usize> = Cell::new(12_000);
959
960/// Reset the counter + re-read the env limit each conversion (the thread-local
961/// engine is reused; a caught unwind could otherwise leave the counter high).
962fn reset_expand_depth() {
963  EXPAND_DEPTH.set(0);
964  EXPAND_DEPTH_LIMIT.set(
965    std::env::var("LATEXML_EXPAND_DEPTH_LIMIT")
966      .ok()
967      .and_then(|v| v.trim().parse().ok())
968      .unwrap_or(12_000),
969  );
970}
971
972/// RAII depth counter for `read_x_token`: `enter` increments (Fatals past the
973/// limit), drop decrements — so every return path stays balanced.
974struct ExpandDepthGuard;
975impl ExpandDepthGuard {
976  #[inline]
977  fn enter() -> Result<ExpandDepthGuard> {
978    let d = EXPAND_DEPTH.get() + 1;
979    EXPAND_DEPTH.set(d);
980    let limit = EXPAND_DEPTH_LIMIT.get();
981    if limit != 0 && d > limit {
982      EXPAND_DEPTH.set(d - 1); // Drop won't run — decrement here.
983      Fatal!(
984        Timeout,
985        Recursion,
986        format!("Excessive expansion recursion (depth {d} > {limit}); infinite macro loop?")
987      );
988    }
989    Ok(ExpandDepthGuard)
990  }
991}
992impl Drop for ExpandDepthGuard {
993  #[inline]
994  fn drop(&mut self) { EXPAND_DEPTH.set(EXPAND_DEPTH.get().saturating_sub(1)); }
995}
996
997/// Read the next non-expandable token, expanding until one appears. Hot path —
998/// `read_token` is folded in. `toplevel` (default true): on mouth exhaustion,
999/// step to the containing mouth. `fully_expand` (default = toplevel): expand
1000/// even protected defns ("for execution"). Unlike `read_balanced`, does NOT
1001/// defer `\the` & friends; `\noexpand`'d tokens act like `\relax`. For `\if`/
1002/// `\ifx` arguments pass `for_conditional=true` (handles `\noexpand` and CS
1003/// `\let` to tokens specially).
1004pub fn read_x_token(
1005  toplevel_opt: Option<bool>,
1006  for_conditional: bool,
1007  fully_expand_opt: Option<bool>,
1008) -> Result<Option<Token>> {
1009  // toplevel should be true by default
1010  let toplevel = toplevel_opt.unwrap_or(true);
1011  let fully_expand = fully_expand_opt.unwrap_or(toplevel);
1012  let _depth_guard = ExpandDepthGuard::enter()?; // Cluster F expansion-depth cap
1013  loop {
1014    // Combined checkpoint + raw read: this loop reads via the low-level
1015    // `read_internal_token_checked` (NOT `read_token`), so it runs the same
1016    // guards itself — otherwise every full-expansion read path (`\edef`,
1017    // `read_balanced`, csname construction) bypasses the token/pushback
1018    // limits and the expansion cycle guard entirely, and a `\def\x{a\x}`
1019    // runaway grinds to the multi-GB watchdog instead of a clean Fatal.
1020    let next_token = match read_internal_token_checked(CommentSink::Pending)? {
1021      // No runtime ≡ nothing more to read. (The pre-merge code fell through
1022      // to the exhausted branch below, whose autoclose peek — runtime gone ⇒
1023      // not autoclose — concluded Ok(None); return that directly.)
1024      CheckedRead::NoRuntime => return Ok(None),
1025      CheckedRead::Exhausted => None,
1026      CheckedRead::Tok(t) => Some(t),
1027    };
1028    //ProgressStep() if ($$self{progress}++ % $TOKEN_PROGRESS_QUANTUM) == 0;
1029    if next_token.is_none() {
1030      {
1031        let gullet = gullet!();
1032        let current_is_autoclose = gullet
1033          .runtime
1034          .as_ref()
1035          .map(|r| r.autoclose)
1036          .unwrap_or(false);
1037        // Drain a *transparent autoclose injection* (\scantokens, raw_tex) and
1038        // resume the enclosing mouth even for a BOUNDED reader (toplevel==false,
1039        // e.g. the InputDefinitions file loop) — these are part of the current
1040        // logical stream. Faithful to tex.web `get_next` §362-365 (exhausting any
1041        // input level resumes the enclosing one; \scantokens is a pseudo-file
1042        // level). DIVERGES from Perl `Gullet.pm` readXToken, which gates on
1043        // `autoclose = toplevel` and so returns at the first exhausted mouth —
1044        // truncating a `.sty` whenever `\scantokens` runs mid-load (witness
1045        // 1906.03240: real babel.sty `\selectlanguage` dropped every later def →
1046        // undefined-CS cascade; Perl's hand-written babel.ltxml dodges it).
1047        // A non-autoclose boundary is left to its owner (return None).
1048        if !current_is_autoclose || gullet.mouthstack.is_empty() {
1049          return Ok(None);
1050        }
1051      }
1052      close_mouth(false)?; // Drain the autoclose injection; resume the parent.
1053      continue;
1054    }
1055    // we got a token
1056    let token = next_token.unwrap();
1057    if token.get_catcode() == Catcode::CS && token.text == pin!("\\dont_expand") {
1058      let unexpanded = match read_token()? {
1059        Some(t) => t,
1060        None => return Ok(Some(T_CS!("\\special_relax"))), // \dont_expand at end-of-input
1061      };
1062      if for_conditional && unexpanded.code == Catcode::ACTIVE {
1063        return Ok(Some(unexpanded));
1064      } else {
1065        // `\noexpand <tok>`: per-token `\special_relax` family encoding <tok>'s
1066        // identity in the name — faithful to TeX's `no_expand_flag`, which keeps
1067        // `cur_cs` while giving relax meaning for this one access. (Perl
1068        // readXToken returns a bare `\special_relax`, dropping the identity;
1069        // recovering it is a deliberate, SURPASS-PERL fidelity fix so a
1070        // `\noexpand`'d delimiter — e.g. xint's `\XINTfstop`, witness
1071        // 1804.01117 — survives a number/macro scan for the surrounding parser.)
1072        return Ok(Some(crate::token::noexpand_family(&unexpanded)));
1073      }
1074    }
1075    // Wow!!!!! See TeX the Program \S 309
1076    // SHOULD count nesting of { }!!! when SCANNED (not digested)
1077    let check_alignment_data = {
1078      if has_reading_alignment() && align_group_count() == 0 {
1079        if let Some((_atoken, atype, ahidden)) = is_column_end(&token) {
1080          let reading_alignment = get_reading_alignment().unwrap();
1081          Some((reading_alignment, atype, ahidden))
1082        } else {
1083          None
1084        }
1085      } else {
1086        None
1087      }
1088    };
1089    if let Some((reading_alignment, atype, ahidden)) = check_alignment_data {
1090      if let DigestedData::Alignment(data) = reading_alignment.data() {
1091        handle_template(data.borrow_mut(), token, atype, ahidden)?;
1092      } else {
1093        panic!("malformed alignmed was stored?");
1094      }
1095      // And *then* continue the main loop checks
1096    } else if token.get_catcode().is_active_or_cs() {
1097      // Read the meaning via closure so we can branch on the borrowed
1098      // Stored without cloning (Stored::clone was ~1% of total on
1099      // siunitx-heavy profiles; this site fires on every CS/ACTIVE
1100      // expansion — the hottest lookup_meaning caller).
1101      enum Outcome {
1102        LetTo(Token),
1103        Undefined,
1104        NonExpandable,
1105        Invoke(std::rc::Rc<dyn Definition>),
1106      }
1107      let outcome = with_meaning(&token, |defn_opt| match defn_opt {
1108        Some(Stored::Token(t)) => Outcome::LetTo(*t),
1109        Some(Stored::None) | None => Outcome::Undefined,
1110        Some(other) => match other.to_definition() {
1111          Some(defn) => {
1112            if !defn.is_expandable() || (defn.is_protected() && !fully_expand) {
1113              Outcome::NonExpandable
1114            } else {
1115              Outcome::Invoke(defn)
1116            }
1117          },
1118          None => Outcome::Undefined,
1119        },
1120      });
1121      match outcome {
1122        Outcome::LetTo(let_token) => {
1123          return Ok(Some(if for_conditional { let_token } else { token }));
1124        },
1125        Outcome::Undefined => {
1126          if token.get_catcode() == Catcode::CS {
1127            // The LaTeX format may not be loaded yet (a document may use a
1128            // kernel CS before `\documentclass` — real LaTeX has no "before
1129            // the kernel"). If this is a kernel CS, pull the format in and
1130            // re-resolve rather than stubbing it as `<ltx:ERROR/>`. Fires at
1131            // most once per session; see `binding::kernel_autoload`.
1132            if crate::binding::kernel_autoload::try_autoload(&token) {
1133              unread_one(token); // Retry, now that the kernel is in state.
1134              continue;
1135            }
1136            return Ok(Some(generate_error_stub(&token)?));
1137          } else {
1138            return Ok(Some(token));
1139          }
1140        },
1141        Outcome::NonExpandable => {
1142          return Ok(Some(token));
1143        },
1144        Outcome::Invoke(defn) => {
1145          local_current_token(token);
1146          // Grow the native stack ahead of deep expansion recursion (xint
1147          // `\XINT_…` number-arg chains nest tens of thousands deep) so
1148          // finite-deep recursion completes instead of overflowing the conversion
1149          // thread's 256 MB stack → SIGABRT (Perl degrades via `$MAXSTACK`). This
1150          // only grows the stack; the depth CAP is `ExpandDepthGuard` at the top
1151          // of `read_x_token`. Same idiom as the recursive walks in `document.rs`
1152          // / the math parser; params in `crate::stack_guard`.
1153          #[cfg_attr(not(feature = "token-locators"), allow(unused_mut))]
1154          let mut invoked = crate::stack_guard::maybe_grow(|| defn.invoke(false))?;
1155          // token-locators: fill-only origin inheritance. A macro that
1156          // expands into synthesized tokens with no origin — e.g.
1157          // `\today → ExplodeText!(Today!())` yielding "May 25, 2026" —
1158          // would leave its output unlocatable. Attribute such tokens to
1159          // the invocation site so the rendered text is source-mapped.
1160          // The inherited handle is flagged `inherited` (one push per
1161          // expansion, shared by every result token), so `child_span`'s
1162          // genuine-origin-first scan never lets a macro's structural body
1163          // literals widen its arguments' content-exact span. We also never
1164          // overwrite a token that already carries an origin. See
1165          // SOURCE_PROVENANCE.md §3.1.3.
1166          #[cfg(feature = "token-locators")]
1167          {
1168            let inv_loc = token.loc;
1169            if inv_loc != 0 {
1170              let mut inherited = 0u32;
1171              for t in invoked.unlist_mut() {
1172                if t.loc == 0 {
1173                  if inherited == 0 {
1174                    inherited = crate::token::push_inherited_origin(inv_loc);
1175                  }
1176                  t.loc = inherited;
1177                }
1178              }
1179            }
1180          }
1181          if *TRACE_GROUP_END {
1182            // Print per-event {macro, delta} so post-processing can sum
1183            // by-macro to find which expandable CS contributes net +/- 1
1184            // imbalance across the run. Format: TRACE_GE delta CS
1185            let (mut begs, mut ends) = (0, 0);
1186            for t in invoked.unlist_ref() {
1187              if *t == T_CS!("\\group_begin:") || *t == T_CS!("\\begingroup") {
1188                begs += 1;
1189              } else if *t == T_CS!("\\group_end:") || *t == T_CS!("\\endgroup") {
1190                ends += 1;
1191              }
1192            }
1193            if begs > 0 || ends > 0 {
1194              eprintln!(
1195                "TRACE_GE delta={} begs={} ends={} cs={}",
1196                begs - ends,
1197                begs,
1198                ends,
1199                token
1200              );
1201            }
1202          }
1203          unread(invoked);
1204          expire_current_token();
1205          continue;
1206        },
1207      }
1208    } else {
1209      // Perl Gullet.pm L421-422: track { and } at scan level for ALIGN_STATE
1210      match token.get_catcode() {
1211        Catcode::BEGIN => increment_align_group_count(),
1212        Catcode::END => decrement_align_group_count(),
1213        _ => {},
1214      }
1215      return Ok(Some(token));
1216    }
1217  }
1218}
1219
1220/// Read the next raw line (string);
1221/// primarily to read from the Mouth, but keep any unread input!
1222pub fn read_raw_line() -> Option<String> {
1223  // If we've got unread tokens, they presumably should come before the Mouth's raw data
1224  // but we'll convert them back to string.
1225  let mut gullet = gullet_mut!();
1226  if let Some(ref mut runtime) = gullet.runtime {
1227    // Vec-as-stack stores bottom-to-top, but the caller expects
1228    // "next to read" first — reverse the drained order to match the
1229    // old VecDeque drain(..) which was front-to-back (= next-to-read).
1230    let tokens: Vec<Token> = runtime.pushback.drain(..).rev().collect();
1231
1232    // TODO
1233    // let markers : Vec<&Token> = tokens.iter().filter(|t:Token| t.get_catcode() ==
1234    // Catcode::MARKER).collect(); if !markers.is_empty() {    // Whoops, profiling markers!
1235
1236    // @tokens = grep { $_->getCatcode != Catcode::MARKER } @tokens;    // Remove
1237    // map { LaTeXML::Core::Definition::stopProfiling($_, 'expand') } @markers;
1238    // }
1239
1240    // If we still have peeked tokens, we ONLY want to combine it with the remainder
1241    // of the current line from the Mouth (NOT reading a new line)
1242    if !tokens.is_empty() {
1243      Some(Tokens::new(tokens).to_string() + &runtime.mouth.read_raw_line(true).unwrap_or_default())
1244    } else {
1245      // Otherwise, read the next line from the Mouth.
1246      runtime.mouth.read_raw_line(false)
1247    }
1248  } else {
1249    None
1250  }
1251}
1252
1253//**********************************************************************
1254// Mid-level readers: checking and matching tokens, strings etc.
1255//**********************************************************************
1256// The following higher-level parsing methods are built upon readToken & `.
1257
1258/// Read a single non-space token
1259pub fn read_non_space() -> Result<Option<Token>> {
1260  loop {
1261    match read_token()? {
1262      None => return Ok(None),
1263      Some(t) => {
1264        if t.get_catcode() != Catcode::SPACE {
1265          return Ok(Some(t));
1266        }
1267      },
1268    }
1269  }
1270}
1271
1272/// Read a single expanded, non-space, token
1273pub fn read_x_non_space() -> Result<Option<Token>> {
1274  loop {
1275    match read_x_token(Some(false), false, None)? {
1276      None => return Ok(None),
1277      Some(t) => {
1278        if t.get_catcode() != Catcode::SPACE {
1279          return Ok(Some(t));
1280        }
1281      },
1282    }
1283  }
1284}
1285
1286/// A directive describing to what degree a gullet reader should perform TeX's expansion
1287#[derive(Copy, Debug, Clone, PartialEq, Default)]
1288pub enum ExpansionLevel {
1289  // No expansion, reads currently present tokens
1290  #[default]
1291  Off,
1292  /// Expands while reading, but deferring `\the` and `\protected`
1293  Partial,
1294  /// Expands completely while reading
1295  Full,
1296}
1297
1298/// Approximates TeX's scan_toks (but doesn't parse \def parameter lists)
1299/// and only optionally requires the openning "{".
1300///
1301/// It may return comments in the token lists.
1302/// The `is_macrodef` flag affects whether # parameters are "packed" for macro bodies.
1303/// If `require_open` is true, the opening T_BEGIN has not yet been read, and is required.
1304///
1305/// If `toplevel` is true, it will automatically close empty mouths as it reads,
1306/// and will also fully expand macros (unless overridden by `expansion_level` being explicitly Off).
1307pub fn read_balanced(
1308  expansion_level: ExpansionLevel,
1309  is_macrodef: bool,
1310  require_open: bool,
1311) -> Result<Tokens> {
1312  use ExpansionLevel::*;
1313  if !require_open {
1314    decrement_align_group_count();
1315  }
1316  local_align_group_count(1000000);
1317  // let startloc = if lookup_verbosity() > 0 { Some(get_locator()) } else { None };
1318  // Do we need to expand to get the { ???
1319  if require_open {
1320    let token_opt = if expansion_level != Off {
1321      read_x_token(Some(false), false, None)?
1322    } else {
1323      read_token()?
1324    };
1325    let is_open = match token_opt {
1326      None => false,
1327      Some(token) => {
1328        token.get_catcode() == Catcode::BEGIN
1329          || with_meaning(
1330            &token,
1331            |m| matches!(m, Some(Stored::Token(t)) if *t == T_BEGIN!()),
1332          )
1333      },
1334    };
1335    if !is_open {
1336      // Push the token back so subsequent reads (especially alignment `&`,
1337      // newline `\\`, or `\end{tabular}`) recover gracefully when an
1338      // upstream macro had a required `{}` arg with no `{...}` available
1339      // (e.g. mn2e/multirow with a missing 3rd arg — 0903.4199 cascade).
1340      // Without this, the consumed `&` was lost from alignment context,
1341      // turning a 1-error "missing arg" into a 10001-cap `&`-cascade.
1342      if let Some(t) = token_opt {
1343        unread_one(t);
1344      }
1345      Error!("expected", "{", s!("Expected opening '{{'"));
1346      return Ok(Tokens!());
1347    }
1348  }
1349  // Pre-size the token accumulator: most balanced reads are short
1350  // macro arguments (~4–16 tokens). This skips the Vec's early
1351  // doublings that the callgrind profile attributes to
1352  // `raw_vec::finish_grow` (1% of total instructions in read_balanced
1353  // alone).
1354  let mut tokens: Vec<Token> = Vec::with_capacity(16);
1355  let mut level = 1;
1356  loop {
1357    // Combined checkpoint + raw read: this loop reads RAW (pushback / mouth,
1358    // not via read_token/read_x_token) — without its own checkpoint,
1359    // `\edef`-body expansion loops (`\def\x{a\x}\edef\y{\x}`) bypass the
1360    // token/pushback limits AND the expansion cycle guard, and grind to the
1361    // multi-GB process watchdog instead of a clean early Fatal. Comments
1362    // (pending ones included) are kept in the result via the `Into` sink.
1363    let next_token = match read_internal_token_checked(CommentSink::Into(&mut tokens))? {
1364      // No runtime mid-balanced-read (fatal recovery): degrade as an
1365      // exhausted opaque boundary below. (The old inline read would have
1366      // panicked on a vanished runtime; end-of-input is the graceful
1367      // equivalent — unreachable in a healthy conversion.)
1368      CheckedRead::NoRuntime | CheckedRead::Exhausted => None,
1369      CheckedRead::Tok(t) => Some(t),
1370    };
1371    // ProgressStep() if ($$self{progress}++ % $TOKEN_PROGRESS_QUANTUM) == 0;
1372    match next_token {
1373      // Mouth exhausted mid-balanced-read: mirror read_x_token / tex.web get_next
1374      // §362-365 — a transparent autoclose injection (\scantokens, raw_tex) is
1375      // part of the current logical stream, so drain it and resume the enclosing
1376      // mouth rather than reporting an unbalanced read. xint's
1377      // `\edef\X{\scantokens{...}}` opens an autoclose mouth mid-edef whose
1378      // matching `}` lives in the PARENT file; not crossing breaks the read at the
1379      // boundary and leaks `\xintexprSafeCatcodes`' `\begingroup`, corrupting
1380      // everything after. SURPASS-PERL: Perl readBalanced (Gullet.pm:466) `last`s
1381      // here and also fails this xint input.
1382      //
1383      // Gate on the mouth KIND, not just the autoclose bit: `\input` file
1384      // mouths are ALSO opened autoclose, and a truncated/unbalanced included
1385      // file must ERROR like TeX ("File ended while scanning use of …") and
1386      // Perl — not silently absorb the parent document into the argument
1387      // (PR_READINESS should-fix 9). Only string/literal injections
1388      // (\scantokens, RawTeX) are transparent to a balanced read.
1389      //
1390      // The `boundary` check narrows it further, because "literal mouth" is not
1391      // by itself evidence of a continuation: `\ProcessBibTeXEntry` also hands
1392      // an entry to `Mouth::new` (bibtex.rs, Perl BibTeX.pool L165-166), and
1393      // there one runaway field — a bare `%` in a title is enough — used to
1394      // swallow every following entry AND `\end{bibtex@bibliography}`, emptying
1395      // the whole bibliography while reporting a single error. Perl keeps all
1396      // the other entries. Such a mouth declares itself
1397      // [`BalancedBoundary::Opaque`].
1398      None => {
1399        let cross = {
1400          let gullet = gullet!();
1401          gullet
1402            .runtime
1403            .as_ref()
1404            .map(|r| {
1405              r.autoclose
1406                && r.boundary == BalancedBoundary::Transparent
1407                && r.mouth.foodtype() != crate::mouth::FoodType::File
1408            })
1409            .unwrap_or(false)
1410            && !gullet.mouthstack.is_empty()
1411        };
1412        if cross {
1413          close_mouth(false)?;
1414          continue;
1415        }
1416        break;
1417      },
1418      Some(token) => match token.get_catcode() {
1419        Catcode::CS if token.text == pin!("\\dont_expand") => {
1420          if let Some(next_t) = read_token()? {
1421            tokens.push(next_t); // Pass on NEXT token, unchanged.
1422          }
1423        },
1424        Catcode::END => {
1425          // Perl Gullet.pm L476: track ALIGN_STATE for } inside readBalanced
1426          decrement_align_group_count();
1427          level -= 1;
1428          if level <= 0 {
1429            break;
1430          }
1431          tokens.push(token);
1432        },
1433        Catcode::BEGIN => {
1434          // Perl Gullet.pm L482: track ALIGN_STATE for { inside readBalanced
1435          increment_align_group_count();
1436          level += 1;
1437          tokens.push(token);
1438        },
1439        cc => {
1440          // Wow!!!!! See TeX the Program \S 309
1441          // Not sure if this code still applies within scan_toks???
1442          // SHOULD count nesting of { }!!! when SCANNED (not digested)
1443          if has_reading_alignment()
1444            && align_group_count() == 0
1445            && let Some((_atoken, atype, ahidden)) = is_column_end(&token)
1446          {
1447            match get_reading_alignment().unwrap().data() {
1448              DigestedData::Alignment(data) => {
1449                handle_template(data.borrow_mut(), token, atype, ahidden)?;
1450              },
1451              _ => {
1452                panic!("malformed alignmed was stored?");
1453              },
1454            }
1455            continue;
1456          }
1457          // Note: use general-purpose lookup, since we may reexamine $defn below
1458          if expansion_level != Off && cc.is_active_or_cs() {
1459            // Borrow the stored meaning via with_meaning so the Stored
1460            // enum is not cloned per token. We extract (a) whether a
1461            // meaning exists at all (for the undefined-CS diagnostic
1462            // below) and (b) the Rc<dyn Definition> if it's a proper
1463            // definition — both are cheap (bool + Rc-clone).
1464            let (has_meaning, defn_opt) =
1465              with_meaning(&token, |m| (m.is_some(), m.and_then(|s| s.to_definition())));
1466            if let Some(defn) = defn_opt {
1467              if defn.is_expandable() && (!defn.is_protected() || expansion_level == Full) {
1468                local_current_token(token);
1469                let expansion = defn.invoke(false)?;
1470                if expansion.is_empty() {
1471                  expire_current_token();
1472                  continue;
1473                }
1474                // If a special \the type command, push the expansion directly into the result
1475                // Well, almost directly: handle any MARKER tokens now, and possibly un-pack T_PARAM
1476                //
1477                // Perl `Gullet.pm:505` checks `$$defn{cs}[0]` — but in Perl the Lt-aliases
1478                // (e.g. `Lt('\\exp_not:n','\\unexpanded')`) share the SAME Definition, so its
1479                // cs field IS `\unexpanded`. In Rust the dump-writer emits `\exp_not:n` as a
1480                // separate Expandable with alias=`\unexpanded`; check the alias too so the
1481                // DEFERRED_COMMANDS gate fires for `\exp_not:n {…}` inside `\edef` bodies.
1482                // Without this, expl3's `\seq_gpush:Nn` (which uses `\exp_not:n` to wrap
1483                // `\__seq_item:n {…}`) loses its item — the item gets re-expanded into the
1484                // expandable-error trap, leaving the seq stack empty and triggering
1485                // `extra-pop-label`/`\q_no_value`-recursion cascades during `\@pushfilename`.
1486                let cs_matches = DEFERRED_COMMANDS.contains(&defn.get_cs().text);
1487                let alias_matches = defn
1488                  .get_alias()
1489                  .map(|a| DEFERRED_COMMANDS.contains(&arena::pin(a)))
1490                  .unwrap_or(false);
1491                if expansion_level != Full && (cs_matches || alias_matches) {
1492                  for t in expansion.unlist() {
1493                    match t.get_catcode() {
1494                      Catcode::MARKER => handle_marker(t),
1495                      Catcode::PARAM if is_macrodef => {
1496                        // "unpack" to cover the packParameters at end!
1497                        tokens.push(t);
1498                        tokens.push(t);
1499                      },
1500                      _ => tokens.push(t),
1501                    }
1502                  }
1503                } else {
1504                  // otherwise, prepend to pushback to be expanded further.
1505                  unread(expansion);
1506                }
1507                expire_current_token();
1508                continue;
1509              }
1510            } else if cc == Catcode::CS && !has_meaning {
1511              // cs SHOULD have defn by now; report early!
1512              generate_error_stub(&token)?;
1513            }
1514          }
1515          // Return the token — EXCEPT a `\special_relax` (noexpand'd) family token
1516          // collected into an expanded token list reverts to its plain shadowed
1517          // identity: TeX's no_expand_flag is transient (tex.web §1149-1153), so
1518          // `\edef`/`\xdef` store the PLAIN token, not a relax marker (etex ground
1519          // truth: `\def\s{\noexpand\s}\edef\r{\romannumeral0\s}` → `\meaning\r` =
1520          // "macro:->\s", xint's f-stop idiom). Otherwise the family token persists
1521          // into the `\edef` body and a later number scan hits "Missing number".
1522          // Gated on CS/active so the hot per-token push pays only a catcode check.
1523          if cc.is_active_or_cs() {
1524            tokens.push(token.noexpand_shadowed().unwrap_or(token));
1525          } else {
1526            tokens.push(token);
1527          }
1528        },
1529      },
1530    }
1531  }
1532  if level > 0 {
1533    // Reached for a genuinely unbalanced read: a balancing end in a LITERAL
1534    // (string-injection) mouth IS recognized via the autoclose crossing above;
1535    // a FILE boundary deliberately is not (TeX/Perl parity — "file ended
1536    // while scanning"), so a truncated \input lands here with the loud Error.
1537    // TODO: add the startloc details
1538    // my $loc_message = $startloc ? ("Started at " . ToString($startloc)) : ("Ended at " .
1539    // ToString($self->getLocator));
1540    Error!(
1541      "expected",
1542      "}",
1543      "Gullet->readBalanced ran out of input in an unbalanced state"
1544    );
1545  }
1546  expire_align_group_count();
1547  if tokens.is_empty() {
1548    Ok(Tokens!())
1549  } else {
1550    Ok(if is_macrodef {
1551      Tokens::new(tokens).pack_parameters()?
1552    } else {
1553      Tokens::new(tokens)
1554    })
1555  }
1556}
1557
1558/// Match the input against a set of keywords; Similar to readMatch, but the keywords are strings,
1559/// and Case and catcodes are ignored; additionally, leading spaces are skipped.
1560/// AND, macros are expanded.
1561///
1562/// Perf: zero-allocation char-wise comparison against each keyword.
1563/// The previous version allocated two Strings per char-match (via `to_uppercase()`
1564/// and `char::to_string()`), which was expensive in hot parameter parsing loops.
1565pub fn read_keyword(keywords: &[&str]) -> Result<Option<String>> {
1566  skip_spaces()?;
1567  for keyword in keywords.iter() {
1568    // Pre-size to the keyword length — `matched` holds one token per
1569    // matched char, and we unread them on no-match. Keyword-match
1570    // runs on every parameter/keyword read; small win per call.
1571    let mut matched = Vec::with_capacity(keyword.len());
1572    let mut ok = true;
1573    for expected in keyword.chars() {
1574      let Some(tok) = read_x_token(Some(false), false, None)? else {
1575        ok = false;
1576        break;
1577      };
1578      // Compare char-by-char against the token's text, case-insensitively.
1579      let eq = tok.with_str(|s| {
1580        let mut it = s.chars();
1581        match it.next() {
1582          Some(c) if it.next().is_none() => {
1583            // single-char token: case-insensitive compare
1584            c.to_uppercase().eq(expected.to_uppercase())
1585          },
1586          _ => false,
1587        }
1588      });
1589      matched.push(tok);
1590      if !eq {
1591        ok = false;
1592        break;
1593      }
1594    }
1595    if ok {
1596      return Ok(Some(keyword.to_string()));
1597    } else {
1598      unread(matched.into());
1599    }
1600  }
1601  Ok(None)
1602}
1603
1604/// Return a (balanced) sequence tokens until a match against one of the Tokens in @delims.
1605///
1606/// Note that Braces on input hides the contents from matching,
1607/// so this assumes there wont be braces in $delim!
1608/// But, see readUntilBrace for that case.
1609pub fn read_until(delim: &Tokens) -> Result<Tokens> {
1610  // Pre-size like `read_balanced`: the accumulator is grown one token at a time
1611  // in the loops below, so an unsized `Vec::new()` pays the 0→1→2→4→8 doubling
1612  // reallocations on every call. 16 covers the common short delimited read in a
1613  // single allocation (a top `grow_one` site in the allocation profile).
1614  let mut tokens: Vec<Token> = Vec::with_capacity(16);
1615  let mut nbraces = 0;
1616  let want = delim.unlist_ref();
1617  let ntomatch = want.len();
1618  let mut has_matched;
1619
1620  if ntomatch == 1 {
1621    let want = &want[0];
1622    loop {
1623      let token = match read_token()? {
1624        Some(t) => t,
1625        None => {
1626          // Ran out!
1627          unread(Tokens::new(tokens));
1628          return Ok(Tokens!()); // Not more correct, but maybe less confusing?
1629        },
1630      };
1631      // Perl: check direct match OR \special_relax smuggling (Gullet.pm line 662)
1632      if token == *want || special_relax_matches(&token, want) {
1633        break;
1634      }
1635      match token.get_catcode() {
1636        Catcode::MARKER => {
1637          // would have been handled by readToken, but we're bypassing
1638          handle_marker(token);
1639        },
1640        Catcode::BEGIN => {
1641          // And if it's a BEGIN, copy till balanced END
1642          nbraces += 1;
1643          tokens.push(token);
1644          let balanced_arg = read_balanced(ExpansionLevel::Off, false, false)?;
1645          if !balanced_arg.is_empty() {
1646            tokens.extend(balanced_arg.unlist());
1647          }
1648          tokens.push(T_END!());
1649        },
1650        _ => {
1651          tokens.push(token);
1652        },
1653      }
1654    }
1655  } else {
1656    let mut ring = VecDeque::new();
1657    loop {
1658      // prefill the required number of tokens
1659      while ring.len() < ntomatch {
1660        let token = match read_token()? {
1661          Some(t) => t,
1662          None => {
1663            // Ran out!
1664            unread(Tokens::new(tokens));
1665            return Ok(Tokens!()); // Not more correct, but maybe less confusing?
1666          },
1667        };
1668        // Perl: $$token[1] == CC_BEGIN — direct catcode check
1669        if token.get_catcode() == Catcode::BEGIN {
1670          // read balanced, and refill ring.
1671          nbraces += 1;
1672          for r_token in ring {
1673            tokens.push(r_token);
1674          }
1675          tokens.push(token);
1676          let balanced_arg = read_balanced(ExpansionLevel::Off, false, false)?;
1677          if !balanced_arg.is_empty() {
1678            tokens.append(&mut balanced_arg.unlist());
1679          }
1680          tokens.push(T_END!()); // Copy directly to result
1681          ring = VecDeque::new(); // and retry
1682        } else {
1683          ring.push_back(token);
1684        }
1685      }
1686      has_matched = &ring == want; // Test match
1687      if has_matched {
1688        break;
1689      } // Matched all!
1690      if let Some(ring_token) = ring.pop_front() {
1691        tokens.push(ring_token);
1692      }
1693    }
1694  }
1695  // Notice that IFF the arg looks like {balanced}, the outer braces are stripped
1696  // so that delimited arguments behave more similarly to simple, undelimited arguments.
1697  // Perl: ($nbraces == 1) && ($tokens[0][1] == CC_BEGIN) && ($tokens[-1][1] == CC_END)
1698  if nbraces == 1
1699    && tokens.first().unwrap().get_catcode() == Catcode::BEGIN
1700    && tokens.last().unwrap().get_catcode() == Catcode::END
1701  {
1702    tokens.remove(0);
1703    tokens.pop();
1704  }
1705  Ok(Tokens::new(tokens))
1706}
1707
1708/// Convenience method wrapping around `read_until`
1709/// TODO: This seems to be the wrong Rust type interface, we need to rework...
1710pub fn read_until_token(t: Token) -> Result<Tokens> { read_until(&Tokens!(t)) }
1711/// reads until it encounters a Catcode::BEGIN token
1712/// Note: Perl uses `$$token[1] == CC_BEGIN` (catcode check, not defined_as)
1713pub fn read_until_brace() -> Result<Option<Tokens>> {
1714  let mut tokens = Vec::new();
1715  while let Some(token) = read_token()? {
1716    if token.get_catcode() == Catcode::BEGIN {
1717      unread_one(token); // Unread with proper agc adjustment
1718      break;
1719    } else {
1720      tokens.push(token);
1721    }
1722  }
1723  if tokens.is_empty() {
1724    Ok(None)
1725  } else {
1726    let tks = Tokens::new(tokens);
1727    Ok(Some(tks))
1728  }
1729}
1730
1731pub fn read_cs_name() -> Result<Token> { read_cs_name_inner(false) }
1732
1733/// Quiet version of read_cs_name — used by \ifcsname.
1734/// In TeX, \ifcsname silently skips non-expandable CS tokens and returns the constructed name
1735/// without emitting errors (unlike \csname which DOES emit errors).
1736pub fn read_cs_name_quiet() -> Result<Token> { read_cs_name_inner(true) }
1737
1738fn read_cs_name_inner(quiet: bool) -> Result<Token> {
1739  // TeX does NOT store the csname with the leading `\`, BUT stores active chars with a flag
1740  // However, so long as the Mouth's CS and \string properly respect \escapechar, all's well!
1741
1742  // Safety bound: a real CS name fits in well under 256 chars. We've seen
1743  // pathological cases (lipsum.sty with malformed \cs_set_nopar:Npe expansion,
1744  // or expl3 raw-load before \endcsname is bound) where `\csname` reads
1745  // thousands of tokens accumulating into `cs`, eventually OOMing the
1746  // `read_x_token` pushback Vec. Cap at 4096 bytes — beyond that there's no
1747  // legitimate CS name, just a runaway. Emit one clear error and break.
1748  const MAX_CS_NAME_BYTES: usize = 4096;
1749  let mut cs = String::from("\\");
1750  // keep newlines from having \n inside!
1751  while let Some(token) = read_x_token(Some(true), false, None)? {
1752    if token.defined_as(&TOKEN_ENDCSNAME) {
1753      break;
1754    }
1755    if cs.len() > MAX_CS_NAME_BYTES {
1756      Error!(
1757        "runaway",
1758        "csname",
1759        format!(
1760          "CS-name read exceeded {MAX_CS_NAME_BYTES} bytes; aborting at partial cs: {:?}",
1761          // Truncate by CHARS, not bytes — a byte slice at 200 can split a
1762          // multi-byte UTF-8 char (e.g. 'ä') and panic. Witness 2601.03403.
1763          cs.chars().take(200).collect::<String>()
1764        )
1765      );
1766      break;
1767    }
1768    match token.get_catcode() {
1769      Catcode::CS => {
1770        // Soft-substitute the underlying char for a character-equivalent CS
1771        // token in the \csname stream — a documented divergence from Knuth TeX
1772        // (tex.web L7745-7758 hard-errors "Missing \endcsname inserted" for any
1773        // CS that isn't \endcsname). Our expansion pipeline surfaces PA-aliased
1774        // `Stored::Token` CSes (expl3 `\exp_stop_f:` = frozen space, `\lx@NBSP`
1775        // from CLUSTER-NBSP) into the csname stream where real TeX wouldn't reach
1776        // this state; erroring like Knuth would break real expl3/mhchem/glossaries
1777        // loads, so we substitute the char the author meant. Witnesses: `\lx@NBSP`
1778        // (CLUSTER-NBSP, 18 papers; `~`→U+00A0 in `\csname r@LABEL\endcsname`),
1779        // `\exp_stop_f:` (mhchem raw-load). The `\lx@NBSP` carve-out below stays
1780        // for clarity; the general `Stored::Token` case handles the rest uniformly.
1781        let cs_str = token.with_str(|s| s.to_string());
1782        // Well-known `\text…` primitives that map to a single char in real
1783        // pdflatex's csname-stream interpretation. The `DefPrimitive!(name,
1784        // "char")` body is a closure that wraps a Tbox — not statically
1785        // inspectable from here — so we maintain an explicit table for the
1786        // canonical set. Witnesses (stage-1..3 of 100k warning corpus):
1787        //   \\textquoteright surfacing in `\twemoji flag: Côte d` cluster
1788        //   (≥4 papers across 2603.08303, 2604.13899, 2604.17338, 2604.20621).
1789        let soft_char: Option<char> = match cs_str.as_str() {
1790          "\\lx@NBSP" | "\\lx@nobreakspace" | "\\nobreakspace" => Some('\u{00A0}'),
1791          "\\textquoteright" => Some('\u{2019}'),
1792          "\\textquoteleft" => Some('\u{2018}'),
1793          "\\textquotedblright" => Some('\u{201D}'),
1794          "\\textquotedblleft" => Some('\u{201C}'),
1795          "\\textquotedbl" => Some('"'),
1796          "\\textemdash" => Some('\u{2014}'),
1797          "\\textendash" => Some('\u{2013}'),
1798          "\\textbackslash" => Some('\u{005C}'),
1799          "\\textbar" => Some('|'),
1800          "\\textbraceleft" => Some('{'),
1801          "\\textbraceright" => Some('}'),
1802          "\\textless" => Some('<'),
1803          "\\textgreater" => Some('>'),
1804          "\\textdollar" => Some('$'),
1805          "\\textasciigrave" => Some('`'),
1806          "\\textasciicircum" => Some('^'),
1807          "\\textasciitilde" => Some('~'),
1808          "\\textunderscore" => Some('_'),
1809          "\\textasteriskcentered" => Some('*'),
1810          // NFSS encoding-specific glyph CS names (`\<encoding>\<glyph>`)
1811          // built by \DeclareTextSymbol for the i/j dotless letters. These
1812          // surface when a paper composes `\'\i` style accented chars
1813          // that travel through `\lx@applyaccent` and a downstream
1814          // encoding-specific dispatcher. Substitute the dotless glyph
1815          // (U+0131 / U+0237) so the constructed csname carries the
1816          // character the author meant.
1817          // Witnesses: arXiv:2603.22193, 2603.23433, 2604.20621 (twemoji
1818          // São Tomé & Príncipe / St. Barthélemy / Côte d'Ivoire cluster).
1819          "\\T1\\i" | "\\OT1\\i" | "\\LY1\\i" => Some('\u{0131}'),
1820          "\\T1\\j" | "\\OT1\\j" | "\\LY1\\j" => Some('\u{0237}'),
1821          _ => None,
1822        };
1823        if let Some(c) = soft_char {
1824          cs.push(c);
1825        } else if cs_str == "\\lx@applyaccent" {
1826          // Accent macros (`\'`, `\"`, `\^`, …) expand to
1827          // `\lx@applyaccent <accent> <combining> <standalone> {<letter>}`
1828          // (tex_character.rs::accent_def). pdflatex's `\csname` skips the
1829          // accented char (the accent runs in the gullet); ours is a stomach
1830          // `DefPrimitive`, so a literal `\lx@applyaccent` surfaces in the csname
1831          // stream and aborts the read (witnesses: twemoji.sty, arXiv:2603.22193 /
1832          // 2603.23433). Faithful fix: peek the 4 args, append the standalone char
1833          // (arg 3, T_OTHER!) to the name, discard the rest — mirrors the
1834          // implicit-character substitution above.
1835          let _accent = read_x_token(Some(true), false, None)?;
1836          let _combiner = read_x_token(Some(true), false, None)?;
1837          let standalone = read_x_token(Some(true), false, None)?;
1838          // The 4th arg is a brace group `{<letter>}` — consume the
1839          // T_BEGIN, then read tokens until matching T_END.
1840          if let Some(t) = read_x_token(Some(true), false, None)?
1841            && t.get_catcode() == Catcode::BEGIN
1842          {
1843            let mut depth: i32 = 1;
1844            while depth > 0 {
1845              match read_x_token(Some(true), false, None)? {
1846                Some(t2) => match t2.get_catcode() {
1847                  Catcode::BEGIN => depth += 1,
1848                  Catcode::END => depth -= 1,
1849                  _ => {},
1850                },
1851                None => break,
1852              }
1853            }
1854          }
1855          if let Some(c) = standalone {
1856            c.with_str(|s| cs.push_str(s));
1857          }
1858        } else {
1859          match lookup_meaning(&token) {
1860            Some(Stored::Token(letted)) => {
1861              // CS is \let-equivalent to a single token. If that token is
1862              // a character (LETTER/OTHER/SPACE), append its string repr
1863              // to the constructed csname — mirrors real TeX's behaviour
1864              // of substituting the let-target into the csname stream.
1865              // Non-character lets (Catcode::CS, MATH, etc.) fall through
1866              // to the error branches below.
1867              let target_cc = letted.get_catcode();
1868              if matches!(target_cc, Catcode::LETTER | Catcode::OTHER | Catcode::SPACE) {
1869                if target_cc == Catcode::SPACE {
1870                  cs.push(' ');
1871                } else {
1872                  letted.with_str(|s| cs.push_str(s));
1873                }
1874              } else if !quiet {
1875                let message = s!(
1876                  "The control sequence {:?} should not appear between \\csname and \\endcsname (partial cs so far: {:?})",
1877                  token,
1878                  cs
1879                );
1880                Error!("unexpected", token, message);
1881              }
1882            },
1883            _ => {
1884              if !quiet {
1885                if lookup_definition(&token)?.is_some() {
1886                  let message = s!(
1887                    "The control sequence {:?} should not appear between \\csname and \\endcsname (partial cs so far: {:?})",
1888                    token,
1889                    cs
1890                  );
1891                  Error!("unexpected", token, message);
1892                } else {
1893                  let message = s!("The token {:?} is not defined", token);
1894                  Error!("undefined", token, message);
1895                }
1896              }
1897            },
1898          }
1899        }
1900        // In quiet mode (ifcsname), just skip the CS token
1901      },
1902      Catcode::SPACE => cs.push(' '), // Keep newlines from having \n!
1903      _ => {
1904        token.with_str(|s| cs.push_str(s));
1905      },
1906    };
1907  }
1908  Ok(T_CS!(cs))
1909}
1910
1911/// reads and discards tokens, until it encounters a conditional, if any.
1912/// Perl: skipConditionalBody inner loop (Conditional.pm L127-133) reads tokens directly
1913/// from pushback/mouth (NOT through readToken) and manually tracks
1914/// `$LaTeXML::ALIGN_STATE` for `{` and `}`. Critically, this bypasses the
1915/// "alignment-template trigger" check that fires `handleTemplate` on `&`/`\cr`
1916/// when align_group_count==0 — that check belongs to digestion, not to
1917/// `\else`-skip. Rust's `read_token` includes the trigger; calling it from
1918/// here would let pmatrix's `&` get treated as the OUTER alignment's
1919/// column-end during `\ifx.#1.\else…\fi` skip when `#1` contains
1920/// `\begin{pmatrix}…&…\end{pmatrix}`. (REG-2 / math-ph0501074: the 9-line
1921/// `\nonumber+\lefteqn+\pmatrix` repro.) Use `read_internal_token` instead
1922/// and track BEGIN/END manually, matching Perl byte-for-byte.
1923pub fn read_next_conditional() -> Result<Option<(Token, ConditionalType)>> {
1924  loop {
1925    // The FOURTH reader loop over the combined checked read (the `\else`/`\fi`
1926    // skipper — it must not delegate to read_token, whose alignment trigger
1927    // misfires during conditional-skip; see the fn doc). It still needs the
1928    // resource/cycle checkpoints: a skip over a pathologically large stream
1929    // must count progress and remain loop-detectable like every other read
1930    // path (PR #249 review P2-9).
1931    match read_internal_token_checked(CommentSink::Pending)? {
1932      CheckedRead::NoRuntime => return Ok(None),
1933      CheckedRead::Tok(token) => {
1934        let cc = token.get_catcode();
1935        // Perl L128-130: manual ALIGN_STATE tracking for `{` / `}` (avoids
1936        // the trigger check that read_token applies).
1937        match cc {
1938          Catcode::BEGIN => increment_align_group_count(),
1939          Catcode::END => decrement_align_group_count(),
1940          _ => {},
1941        }
1942        if cc.is_active_or_cs()
1943          && let Some(cond_type) = lookup_conditional(&token)
1944        {
1945          return Ok(Some((token, cond_type)));
1946        }
1947      },
1948      CheckedRead::Exhausted => {
1949        // Current mouth exhausted. Try closing if autoclosable and there are
1950        // more mouths on the stack (TeX continues reading across input boundaries).
1951        let (autoclose, stack_len) = {
1952          let gullet = gullet!();
1953          let ac = gullet
1954            .runtime
1955            .as_ref()
1956            .map(|r| r.autoclose)
1957            .unwrap_or(false);
1958          let sl = gullet.mouthstack.len();
1959          (ac, sl)
1960        };
1961        if autoclose && stack_len > 0 {
1962          close_mouth(false)?;
1963          continue;
1964        }
1965        return Ok(None);
1966      },
1967    }
1968  }
1969}
1970
1971//**********************************************************************
1972// Higher-level readers: Read various types of things from the input:
1973//  tokens, non-expandable tokens, args, Numbers, ...
1974//**********************************************************************
1975
1976/// Read and return a "normal" TeX argument
1977///
1978/// The next Token or Tokens (if surrounded by braces).
1979/// `expansion_level` controls expansion as if the argument were read
1980///  and then expanded in isolation:
1981///
1982/// In the case of a single unbraced expandable token,
1983///  it will **not** read any macro arguments from the following input!
1984pub fn read_arg(expansion_level: ExpansionLevel) -> Result<Tokens> {
1985  match read_non_space()? {
1986    None => Ok(Tokens!()),
1987    Some(token) => {
1988      // Perl: $$token[1] == CC_BEGIN — checks actual catcode, NOT defined_as.
1989      // \bgroup (catcode CS) does NOT match here; only literal { does.
1990      if token.get_catcode() == Catcode::BEGIN {
1991        read_balanced(expansion_level, false, false)
1992      } else if matches!(expansion_level, ExpansionLevel::Off) {
1993        // A `\noexpand`'d token captured as an (undelimited) macro argument
1994        // reverts to its plain shadowed identity — faithful to TeX, where the
1995        // `no_expand_flag` is transient and never stored in the captured arg
1996        // (`#1` is `cur_cs`, the plain token). This lets `\ifx\X#1` match (xint's
1997        // `\def\XINTfstop{\noexpand\XINTfstop}` f-stop, witness 1804.01117).
1998        // `\let`/`\ifx` of the LIVE token read via `read_token` (the `Token`
1999        // param), NOT here, so their relax-meaning capture is unaffected.
2000        Ok(Tokens!(token.noexpand_shadowed().unwrap_or(token)))
2001      } else {
2002        // Perl Gullet.pm `readArg`:
2003        //   return $self->readingFromMouth(Tokens(T_BEGIN, $token, T_END), sub {
2004        //       readBalanced($self, $expanded, 0, 1); });
2005        // Use an isolated mouth so leftover tokens (e.g. an extra `}` when
2006        // `$token` itself happens to be T_END) cannot leak back into the
2007        // caller's stream. `unread_vec` here would pollute the parent mouth.
2008        let synth = Tokens::new(vec![T_BEGIN!(), token, T_END!()]);
2009        reading_from_mouth(Mouth::default(), move || -> Result<Tokens> {
2010          unread(synth);
2011          read_balanced(expansion_level, false, true)
2012        })
2013      }
2014    },
2015  }
2016}
2017/// Read and return a LaTeX optional argument
2018///
2019/// returns `default` if there is no '[', otherwise the contents of the array.
2020/// Note that this returns an empty array if `[]` is present,
2021/// i.e. `[contents]` in TeX will lead to `Tokens(contents)`, otherwise returns `None`
2022pub fn read_optional(default: Option<Tokens>) -> Result<Option<Tokens>> {
2023  read_optional_delimited(T_OTHER!("["), T_OTHER!("]"), default)
2024}
2025
2026/// The angle-bracket twin of [`read_optional`].
2027///
2028/// Perl `beamer.cls.ltxml` L50-57 `readBeamerAngled` (backing its
2029/// `BeamerAngled` / `OptionalBeamerAngled` parameter types) does exactly this.
2030/// The angle-bracket optional is not beamer-specific — apacite spells its
2031/// citation pre-note that way (`\cite<see>[p.5]{key}`, apacite.sty L313
2032/// `\def\@cite<#1>`), so it lives here beside `read_optional`.
2033pub fn read_optional_angled(default: Option<Tokens>) -> Result<Option<Tokens>> {
2034  read_optional_delimited(T_OTHER!("<"), T_OTHER!(">"), default)
2035}
2036
2037/// Shared core of [`read_optional`] / [`read_optional_angled`]: if the next
2038/// non-space token is the `open` delimiter, read up to (and consuming) `close`;
2039/// otherwise unread the token and yield `default`.
2040///
2041/// The peek/unread contract is the whole point, and is why an optional
2042/// delimited argument must NOT be spelled `OptionalMatch:x OptionalUntil:y`:
2043/// `Until` never checks for the OPENING delimiter, so when the argument is
2044/// absent it scans to the next `y` ANYWHERE downstream (`\citeA{Smith} and
2045/// $a > b$` swallows the cite and the math, yielding the key `b`).
2046fn read_optional_delimited(
2047  open: Token,
2048  close: Token,
2049  default: Option<Tokens>,
2050) -> Result<Option<Tokens>> {
2051  match read_non_space()? {
2052    None => Ok(None),
2053    // `Token`'s `PartialEq` compares catcode + text (and deliberately NOT the
2054    // token-locators origin handle), so this is the catcode-and-symbol match
2055    // the delimiters need.
2056    Some(t) => {
2057      if t == open {
2058        Ok(Some(read_until(&Tokens!(close))?))
2059      } else {
2060        unread_one(t);
2061        Ok(default)
2062      }
2063    },
2064  }
2065}
2066
2067/// `<filler> = <optional spaces> | <filler>\relax<optional spaces>`
2068///
2069/// TeX Book p.276 "`<left brace>` can be implicit", and experimentation, indicate Expansion!!!
2070pub fn skip_filler() -> Result<()> {
2071  while let Some(tok) = read_x_non_space()? {
2072    if !tok.defined_as(&TOKEN_RELAX) {
2073      unread_one(tok);
2074      break;
2075    }
2076  }
2077  Ok(())
2078}
2079
2080pub fn if_next(token: Token) -> Result<bool> {
2081  let mut is_next = false;
2082  if let Some(tok) = read_token()? {
2083    is_next = tok == token;
2084    unread_one(tok);
2085  }
2086  Ok(is_next)
2087}
2088
2089/// Perl: peekToken — peek at the next token without triggering alignment
2090/// Sets ALIGN_STATE to 1000000 to suppress alignment template handling (Perl line 331-337)
2091pub fn peek_token() -> Result<Option<Token>> {
2092  local_align_group_count(1000000);
2093  let result = read_token()?;
2094  if let Some(ref tok) = result {
2095    unread_one(*tok);
2096  }
2097  expire_align_group_count();
2098  Ok(result)
2099}
2100
2101/// Perl: showUnexpected — returns a debug message about the next available token
2102pub fn show_unexpected() -> String {
2103  match peek_token() {
2104    Ok(Some(token)) => {
2105      let meaning = lookup_meaning(&token)
2106        .map(|m| format!("{:?}", m))
2107        .unwrap_or_else(|| "undef".to_string());
2108      s!("Next token is {} ( == {})", token.stringify(), meaning)
2109    },
2110    _ => "Input is empty".to_string(),
2111  }
2112}
2113
2114//**********************************************************************
2115//  Numbers, Dimensions, Glue
2116// See TeXBook, Ch.24, pp.269-271.
2117//**********************************************************************
2118
2119pub fn read_value(value_type: RegisterType) -> Result<RegisterValue> {
2120  match value_type {
2121    RegisterType::Number => Ok(read_number()?.into()),
2122    RegisterType::Dimension => Ok(read_dimension()?.into()),
2123    RegisterType::MuDimension => Ok(read_mu_dimension()?.into()),
2124    RegisterType::Glue => Ok(read_glue()?.into()),
2125    RegisterType::MuGlue => Ok(read_mu_glue()?.into()),
2126    RegisterType::Tokens => Ok(read_tokens_value()?.into()),
2127    RegisterType::Token => {
2128      // Perl: readValue('Token') checks for \csname (Gullet.pm line 770-775)
2129      #[thread_local]
2130      static TOKEN_CSNAME: Lazy<Token> = Lazy::new(|| T_CS!("\\csname"));
2131      let token = read_non_space()?.unwrap_or(*TOKEN_RELAX);
2132      if token.defined_as(&TOKEN_CSNAME) {
2133        Ok(read_cs_name()?.into())
2134      } else {
2135        Ok(token.into())
2136      }
2137    },
2138    RegisterType::CharDef => Ok(read_number()?.into()),
2139    RegisterType::Any => Ok(read_arg(ExpansionLevel::Off)?.into()),
2140  }
2141}
2142
2143/// A package-installed resolver for control sequences that yield an *internal
2144/// dimension* by reading their own argument — e.g. calc.sty's `\widthof{box}`.
2145/// Given the already-read CS token, it either consumes that CS's argument(s)
2146/// from the gullet and returns the measured value (`Ok(Some(_))`), or leaves
2147/// the stream untouched and declines (`Ok(None)`), in which case the caller
2148/// un-reads the token. Keeps core calc-agnostic: the base dimension reader
2149/// consults this seam before giving up with "Missing number", so
2150/// `\makebox[\widthof{X}]` / `\rule{\widthof{X}}{…}` resolve like real LaTeX
2151/// without `\widthof` having to be a register (which would change its bare
2152/// digestion). See `calc_sty.rs` / OXIDIZED_DESIGN #115.
2153pub type InternalDimensionFn = std::rc::Rc<dyn Fn(&Token) -> Result<Option<RegisterValue>>>;
2154
2155thread_local! {
2156  static INTERNAL_DIMENSION_FN: RefCell<Option<InternalDimensionFn>> = const { RefCell::new(None) };
2157}
2158
2159/// Install the internal-dimension resolver (calc.sty at load time).
2160pub fn set_internal_dimension_fn(f: InternalDimensionFn) {
2161  INTERNAL_DIMENSION_FN.with(|c| *c.borrow_mut() = Some(f));
2162}
2163
2164/// Consult the installed resolver for `tok`. `Ok(None)` when none is installed
2165/// or it declines; `tok` is left for the caller to un-read in that case.
2166fn resolve_internal_dimension(tok: &Token) -> Result<Option<RegisterValue>> {
2167  let f = INTERNAL_DIMENSION_FN.with(|c| c.borrow().clone());
2168  match f {
2169    Some(f) => f(tok),
2170    None => Ok(None),
2171  }
2172}
2173
2174pub fn read_register_value(value_type: RegisterType) -> Result<Option<RegisterValue>> {
2175  read_register_value_coerce(value_type, false)
2176}
2177
2178/// Read a register value, optionally coercing from a compatible larger type.
2179/// Perl: readRegisterValue($self, $type, $sign, $coerce)
2180/// Coercion rules (from Perl %RegisterCoercionTypes):
2181///   Number    <- Dimension, Glue     (extract raw i64)
2182///   Dimension <- Glue                (extract skip as Dimension)
2183///   MuDimension <- MuGlue            (extract skip as MuDimension)
2184pub fn read_register_value_coerce(
2185  value_type: RegisterType,
2186  coerce: bool,
2187) -> Result<Option<RegisterValue>> {
2188  match read_x_token(None, false, None)? {
2189    None => Ok(None),
2190    Some(token) => {
2191      let _is_fontdimen = token.with_str(|s| s == "\\fontdimen");
2192      match lookup_register_definition(&token) {
2193        Some(defn) => {
2194          if let Some(mut register_type) = defn.register_type() {
2195            if register_type == RegisterType::CharDef {
2196              // CharDefs treated as numbers here
2197              register_type = RegisterType::Number;
2198            }
2199            if register_type == value_type {
2200              let args = defn.read_arguments()?;
2201              Ok(defn.value_of(args))
2202            } else if coerce {
2203              // Try type coercion per Perl's %RegisterCoercionTypes
2204              if let Some(coerced) = coerce_register(value_type, register_type, &defn)? {
2205                Ok(Some(coerced))
2206              } else {
2207                unread_one(token);
2208                Ok(None)
2209              }
2210            } else {
2211              unread_one(token); // Unread
2212              Ok(None)
2213            }
2214          } else {
2215            unread_one(token); // Unread
2216            Ok(None)
2217          }
2218        },
2219        _ => {
2220          // calc.sty seam: `\widthof{box}` &friends yield an internal dimension
2221          // by reading their own argument. Only in dimension-like contexts
2222          // (calc errors "not expected here" for a Number). On decline the
2223          // resolver leaves the stream untouched, so we un-read the token.
2224          // OXIDIZED_DESIGN #115.
2225          if value_type != RegisterType::Number
2226            && let Some(rv) = resolve_internal_dimension(&token)?
2227          {
2228            return Ok(Some(rv));
2229          }
2230          unread_one(token); // Unread
2231          Ok(None)
2232        },
2233      }
2234    },
2235  }
2236}
2237
2238/// Attempt to coerce a register value from `source_type` to `target_type`.
2239fn coerce_register(
2240  target_type: RegisterType,
2241  source_type: RegisterType,
2242  defn: &Register,
2243) -> Result<Option<RegisterValue>> {
2244  use crate::common::numeric_ops::NumericOps;
2245  // Perl fix 50f0061d: include self-coercions (Number→Number, etc.)
2246  // so \number \fam works when \fam is already a Number register
2247  let can_coerce = matches!(
2248    (target_type, source_type),
2249    (RegisterType::Number, RegisterType::Number)
2250      | (RegisterType::Number, RegisterType::Dimension)
2251      | (RegisterType::Number, RegisterType::Glue)
2252      | (RegisterType::Dimension, RegisterType::Dimension)
2253      | (RegisterType::Dimension, RegisterType::Glue)
2254      | (RegisterType::MuDimension, RegisterType::MuDimension)
2255      | (RegisterType::MuDimension, RegisterType::MuGlue)
2256      | (RegisterType::Glue, RegisterType::Glue)
2257      | (RegisterType::MuGlue, RegisterType::MuGlue)
2258  );
2259  if !can_coerce {
2260    return Ok(None);
2261  }
2262  let args = defn.read_arguments()?;
2263  if let Some(val) = defn.value_of(args) {
2264    let raw = match val {
2265      RegisterValue::Dimension(d) => d.value_of(),
2266      RegisterValue::Glue(g) => g.value_of(),
2267      RegisterValue::MuGlue(mg) => mg.value_of(),
2268      RegisterValue::Number(n) => n.value_of(),
2269      RegisterValue::MuDimension(md) => md.value_of(),
2270      _ => return Ok(None),
2271    };
2272    let coerced = match target_type {
2273      RegisterType::Number => RegisterValue::Number(Number::new(raw)),
2274      RegisterType::Dimension => RegisterValue::Dimension(Dimension::new(raw)),
2275      RegisterType::MuDimension => RegisterValue::MuDimension(MuDimension::new(raw)),
2276      _ => return Ok(None),
2277    };
2278    Ok(Some(coerced))
2279  } else {
2280    Ok(None)
2281  }
2282}
2283
2284/// Match the input against one of the Token or Tokens in @choices; return the matching one or
2285/// undef.
2286pub fn read_match(choices: &[&Tokens]) -> Result<Option<Tokens>> {
2287  for choice in choices {
2288    let mut to_match: Vec<&Token> = choice.unlist_ref().iter().rev().collect();
2289    // `matched` accumulates tokens read so far, bounded by `choice.len()`.
2290    // Pre-size to avoid reallocations on multi-token match attempts.
2291    let mut matched = Vec::with_capacity(choice.unlist_ref().len());
2292    while !to_match.is_empty() {
2293      match read_token()? {
2294        None => break,
2295        Some(token) => {
2296          let cc = token.get_catcode();
2297          // Perl: also check smuggled \special_relax token (Gullet.pm line 612)
2298          let was_last_match = if let Some(&&want) = to_match.last() {
2299            token == want || special_relax_matches(&token, &want)
2300          } else {
2301            false
2302          };
2303          matched.push(token);
2304          if was_last_match {
2305            to_match.pop();
2306          } else {
2307            break;
2308          }
2309
2310          if cc == Catcode::SPACE {
2311            // If this was space, SKIP any following!!!
2312            while let Some(space_token) = read_token()? {
2313              if space_token.get_catcode() != Catcode::SPACE {
2314                // Unread non-space and end — use unread_one for proper agc adjustment
2315                unread_one(space_token);
2316                break;
2317              } else {
2318                matched.push(space_token);
2319              }
2320            }
2321          }
2322        },
2323      }
2324    }
2325    if to_match.is_empty() {
2326      return Ok(Some((*choice).clone())); // All matched!!!
2327    } else {
2328      // Put 'em back and try next — use unread_vec for proper agc adjustment
2329      unread_vec(matched);
2330    }
2331  }
2332  Ok(None)
2333}
2334
2335//======================================================================
2336// Integer, Number
2337//======================================================================
2338// ```
2339// <number> = <optional signs><unsigned number>
2340// <unsigned number> = <normal integer> | <coerced integer>
2341// <coerced integer> = <internal dimen> | <internal glue>
2342// ```
2343pub fn read_number() -> Result<Number> {
2344  let is_negative = read_optional_signs()?;
2345  let s = if is_negative { -1 } else { 1 };
2346  if let Some(n) = read_normal_integer()? {
2347    if is_negative { Ok(n.negate()) } else { Ok(n) }
2348  } else if let Some(n) = read_internal_dimension()? {
2349    Ok(Number::new(s * n.value_of()))
2350  } else if let Some(n) = read_internal_glue()? {
2351    Ok(Number::new(s * n.value_of()))
2352  } else {
2353    let next = read_token()?;
2354    // Perl Gullet.pm:904-905: the primary message is just "Missing number,
2355    // treated as zero"; the processing context and the unexpected-token
2356    // (showUnexpected) are SEPARATE Error details rendered on their own
2357    // lines. Render the tokens with ToString/Stringify, not Rust-Debug
2358    // (which leaks `Some("\\relax")` into user-facing diagnostics).
2359    let current = get_current_token()
2360      .map(|t| t.to_string())
2361      .unwrap_or_default();
2362    let unexpected = match next {
2363      Some(t) => s!("Next token is {}", t.stringify()),
2364      None => s!("Input is empty"),
2365    };
2366    Warn!(
2367      "expected",
2368      "<number>",
2369      "Missing number, treated as zero",
2370      s!("while processing {current}"),
2371      unexpected
2372    );
2373    if let Some(next) = next {
2374      unread_one(next);
2375    }
2376    Ok(Number::new(0))
2377  }
2378}
2379
2380/// ```bnf
2381/// <normal integer> = <internal integer> | <integer constant>
2382///   | '<octal constant><one optional space> | "<hexadecimal constant><one optional space>
2383///   | `<character token><one optional space>
2384/// ```
2385pub fn read_normal_integer() -> Result<Option<Number>> {
2386  match read_x_token(None, false, None)? {
2387    None => Ok(None),
2388    Some(token) => {
2389      let cc = token.get_catcode();
2390      let mut text = token.to_string();
2391      if cc == Catcode::OTHER && text.chars().all(|c| c.is_ascii_digit()) {
2392        // Read decimal literal. Overflow is rare but possible on weird
2393        // input (digit runs wider than i64::MAX); Perl's TeX silently
2394        // truncates such values, so we fall back to i64::MAX / MIN on
2395        // parse failure rather than panicking with .expect().
2396        text.push_str(&read_digits(&DIGIT_RE, true)?);
2397        let n = text.parse::<i64>().unwrap_or_else(|_| {
2398          if text.starts_with('-') {
2399            i64::MIN
2400          } else {
2401            i64::MAX
2402          }
2403        });
2404        Ok(Some(Number::new(n)))
2405      } else if token == T_OTHER!("'") {
2406        // Read Octal literal. Perl: `Number(oct(readDigits(...)))`, and
2407        // Perl's `oct("")` is 0 — so a `'` with no octal digit following
2408        // yields 0 (TeX's "Missing number, treated as zero"), NOT a fatal
2409        // error. Mirror that, and clamp overflow to i64::MAX like the
2410        // decimal arm rather than propagating a ParseIntError.
2411        let digits = read_digits(&OCT_RE, true)?;
2412        let decimal = if digits.is_empty() {
2413          0
2414        } else {
2415          i64::from_str_radix(&digits, 8).unwrap_or(i64::MAX)
2416        };
2417        Ok(Some(Number::new(decimal)))
2418      } else if token == T_OTHER!("\"") {
2419        //  Read Hex literal. Perl: `Number(hex(readDigits(...)))`, and
2420        // Perl's `hex("")` is 0 — so a `"` with no hex digit following
2421        // yields 0, NOT a fatal error. (Witness 2008.10843: mdwmath.sty
2422        // raw-load reads a bare `"` with no hex digit → previously a
2423        // `Fatal:Document:Generic(ParseIntError)` aborting the run.)
2424        let digits = read_digits(&HEX_RE, true)?;
2425        let decimal = if digits.is_empty() {
2426          0
2427        } else {
2428          i64::from_str_radix(&digits, 16).unwrap_or(i64::MAX)
2429        };
2430        Ok(Some(Number::new(decimal)))
2431      } else if token == T_OTHER!("`") {
2432        //  Read Charcode: `<character token><one optional space>
2433        let mut s = match read_token()? {
2434          None => String::new(),
2435          Some(next) => next.to_string(),
2436        };
2437        if s.starts_with('\\') {
2438          s.remove(0);
2439        }
2440        let s_char = s.chars().next().unwrap_or('\0');
2441        // Perl: skip1Space($self, 1); — expanded space-skip after charcode
2442        skip_one_space(true)?;
2443        Ok(Some(Number::new(s_char as i64))) //  Only a character token!!! NOT expanded!!!!
2444      } else {
2445        unread_one(token); // Unread
2446        read_internal_integer()
2447      }
2448    },
2449  }
2450}
2451
2452///======================================================================
2453/// Float, a floating point number.
2454/// Similar to factor, but does NOT accept comma!
2455/// This is NOT part of TeX, but is convenient.
2456pub fn read_float() -> Result<Float> {
2457  let is_negative = read_optional_signs()?;
2458  let s = if is_negative { -1.0 } else { 1.0 };
2459  let mut string = read_digits(&DIGIT_RE, true)?;
2460  let mut token = read_x_token(None, false, None)?;
2461  if token.is_some() && token.as_ref().unwrap().get_sym() == pin!(".") {
2462    string = s!("{string}.{}", read_digits(&DIGIT_RE, true)?);
2463    token = read_x_token(None, false, None)?;
2464  }
2465  let n_opt: Option<f64> = if !string.is_empty() {
2466    if let Some(t) = token
2467      && t.get_catcode() != Catcode::SPACE
2468    {
2469      unread_one(t);
2470    }
2471    // Same rationale as read_normal_integer above: malformed float
2472    // literals (e.g. very long digit runs, "1e" without exponent)
2473    // should degrade to 0.0 rather than panic.
2474    Some(string.parse::<f64>().unwrap_or(0.0))
2475  } else {
2476    if let Some(t) = token {
2477      unread_one(t); // Unread
2478    }
2479    read_normal_integer()?.map(|v| v.value_of() as f64)
2480  };
2481
2482  if let Some(n) = n_opt {
2483    Ok(Float::new_f64(s * n))
2484  } else {
2485    Ok(Float::new_f64(0.0))
2486  }
2487}
2488
2489fn read_internal_integer() -> Result<Option<Number>> {
2490  match read_register_value(RegisterType::Number)? {
2491    None => Ok(None),
2492    Some(val) => Ok(Some(val.into())),
2493  }
2494}
2495fn read_internal_dimension() -> Result<Option<Dimension>> {
2496  match read_register_value(RegisterType::Dimension)? {
2497    None => Ok(None),
2498    Some(val) => Ok(Some(val.into())),
2499  }
2500}
2501fn read_internal_glue() -> Result<Option<Glue>> {
2502  match read_register_value(RegisterType::Glue)? {
2503    None => Ok(None),
2504    Some(val) => Ok(Some(val.into())),
2505  }
2506}
2507
2508//======================================================================
2509// Dimensions
2510//======================================================================
2511// ```
2512// <dimen> = <optional signs><unsigned dimen>
2513// <unsigned dimen> = <normal dimen> | <coerced dimen>
2514// <coerced dimen> = <internal glue>
2515// ```
2516pub fn read_dimension() -> Result<Dimension> {
2517  let is_negative = read_optional_signs()?;
2518  if let Some(d) = read_internal_dimension()? {
2519    Ok(if is_negative { d.negate() } else { d })
2520  } else if let Some(d) = read_internal_glue()? {
2521    Ok(Dimension::new(if is_negative {
2522      d.negate().value_of()
2523    } else {
2524      d.value_of()
2525    }))
2526  } else if let Some(d) = read_factor()? {
2527    let (num, den) = match read_unit()? {
2528      Some(ratio) => ratio,
2529      None => {
2530        Warn!(
2531          "expected",
2532          "<unit>",
2533          "Illegal unit of measure (pt inserted)."
2534        );
2535        (1, 1)
2536      },
2537    };
2538    let d_signed = if is_negative { -d } else { d };
2539    Ok(Dimension::new(fixpoint_unit(d_signed, num, den)))
2540  } else {
2541    // Perl Gullet.pm:972: the type is named in the primary message
2542    // ("(Dimension)") and "while processing X" is a separate detail
2543    // (ToString of the current token, not Rust-Debug).
2544    let cur = get_current_token()
2545      .map(|t| t.to_string())
2546      .unwrap_or_default();
2547    Warn!(
2548      "expected",
2549      "<number>",
2550      "Missing number (Dimension), treated as zero.",
2551      s!("while processing {cur}")
2552    );
2553    Ok(Dimension::new(0))
2554  }
2555}
2556
2557// ```
2558// <unit of measure> = <optional spaces><internal unit>
2559//     | <optional true><physical unit><one optional space>
2560// <internal unit> = em <one optional space> | ex <one optional space>
2561//     | <internal integer> | <internal dimen> | <internal glue>
2562// <physical unit> = pt | pc | in | bp | cm | mm | dd | cc | sp
2563// ```
2564
2565/// Read a unit, returning the exact TeX `(num, den)` conversion fraction (see
2566/// [`convert_unit_ratio`] / `numeric_ops::fixpoint_unit`). Internal/coerced units
2567/// (`\wd0`, `\dimen`, glue) yield `(value_sp, 65536)` — the `floor(fix·v/65536)`
2568/// path of tex.web §8983, exact in integer arithmetic.
2569pub fn read_unit() -> Result<Option<(i64, i64)>> {
2570  let unit_opt = if let Some(u) = read_keyword(&["ex", "em"])? {
2571    skip_one_space(true)?;
2572    Some(convert_unit_ratio(&u))
2573  } else if let Some(u) = read_internal_integer()? {
2574    Some((u.value_of(), UNITY)) // These are coerced to number=>sp
2575  } else if let Some(u) = read_internal_dimension()? {
2576    Some((u.value_of(), UNITY))
2577  } else if let Some(u) = read_internal_glue()? {
2578    Some((u.value_of(), UNITY))
2579  } else {
2580    read_keyword(&["true"])?; // But ignore, we're not bothering with mag...
2581    if let Some(u) = read_keyword(&["pt", "pc", "in", "bp", "cm", "mm", "dd", "cc", "sp", "px"])? {
2582      skip_one_space(true)?;
2583      Some(convert_unit_ratio(&u))
2584    } else {
2585      None
2586    }
2587  };
2588  Ok(unit_opt)
2589}
2590
2591//======================================================================
2592// Glue
2593//======================================================================
2594// <glue> = <optional signs><internal glue> | <dimen><stretch><shrink>
2595// <stretch> = plus <dimen> | plus <fil dimen> | <optional spaces>
2596// <shrink>  = minus <dimen> | minus <fil dimen> | <optional spaces>
2597pub fn read_glue() -> Result<Glue> {
2598  let is_negative = read_optional_signs()?;
2599  if let Some(n) = read_internal_glue()? {
2600    if is_negative { Ok(n.negate()) } else { Ok(n) }
2601  } else {
2602    let mut d = read_dimension()?;
2603    if is_negative {
2604      d = d.negate();
2605    }
2606    let (r1, f1) = match read_keyword(&["plus"])? {
2607      Some(_) => read_rubber(false)?,
2608      None => (None, None),
2609    };
2610    let (r2, f2) = match read_keyword(&["minus"])? {
2611      Some(_) => read_rubber(false)?,
2612      None => (None, None),
2613    };
2614
2615    Ok(Glue::new_spec(
2616      &d.value_of().to_string(),
2617      r1.map(|v| v as f64),
2618      f1,
2619      r2.map(|v| v as f64),
2620      f2,
2621    ))
2622  }
2623}
2624
2625pub fn read_rubber(mu: bool) -> Result<(Option<i64>, Option<FillCode>)> {
2626  let is_negative = read_optional_signs()?;
2627  let s = if is_negative { -1 } else { 1 };
2628  match read_factor()? {
2629    None => {
2630      let f = if mu {
2631        read_mu_dimension()?.value_of()
2632      } else {
2633        read_dimension()?.value_of()
2634      };
2635      Ok((Some(f * s), None))
2636    },
2637    Some(f) => match read_keyword(&["filll", "fill", "fil"])? {
2638      Some(fil) => Ok((Some(fixpoint(s as f64 * f, None)), FillCode::from(&fil))),
2639      None => {
2640        let ratio = if mu {
2641          match read_mu_unit()? {
2642            None => {
2643              Warn!(
2644                "expected",
2645                "<unit>",
2646                "Illegal unit of measure (mu inserted)."
2647              );
2648              None
2649            },
2650            some => some,
2651          }
2652        } else {
2653          match read_unit()? {
2654            None => {
2655              Warn!(
2656                "expected",
2657                "<unit>",
2658                "Illegal unit of measure (pt inserted)."
2659              );
2660              None
2661            },
2662            some => some,
2663          }
2664        };
2665        let val = s as f64 * f;
2666        let sp = match ratio {
2667          Some((num, den)) => fixpoint_unit(val, num, den),
2668          None => fixpoint(val, None),
2669        };
2670        Ok((Some(sp), None))
2671      },
2672    },
2673  }
2674}
2675
2676//======================================================================
2677// Mu Glue
2678//======================================================================
2679// <muglue> = <optional signs><internal muglue> | <mudimen><mustretch><mushrink>
2680// <mustretch> = plus <mudimen> | plus <fil dimen> | <optional spaces>
2681// <mushrink> = minus <mudimen> | minus <fil dimen> | <optional spaces>
2682pub fn read_mu_glue() -> Result<MuGlue> {
2683  let is_negative = read_optional_signs()?;
2684  if let Some(n) = read_internal_mu_glue()? {
2685    Ok(if is_negative { n.negate() } else { n })
2686  } else {
2687    let mut d = read_mu_dimension()?;
2688    if is_negative {
2689      d = d.negate()
2690    }
2691    let (r1, f1) = if read_keyword(&["plus"])?.is_some() {
2692      read_rubber(true)?
2693    } else {
2694      (None, None)
2695    };
2696    let (r2, f2) = if read_keyword(&["minus"])?.is_some() {
2697      read_rubber(true)?
2698    } else {
2699      (None, None)
2700    };
2701    Ok(MuGlue::new_full(d.value_of(), r1, f1, r2, f2))
2702  }
2703}
2704
2705//======================================================================
2706// Mu Dimensions
2707//======================================================================
2708// <mudimen> = <optional signs><unsigned mudimem>
2709// <unsigned mudimen> = <normal mudimen> | <coerced mudimen>
2710// <normal mudimen> = <factor><mu unit>
2711// <mu unit> = <optional spaces><internal muglue> | mu <one optional space>
2712// <coerced mudimen> = <internal muglue>
2713pub fn read_mu_dimension() -> Result<MuDimension> {
2714  let is_negative = read_optional_signs()?;
2715  if let Some(mut m) = read_factor()? {
2716    let munit = read_mu_unit()?;
2717    if munit.is_none() {
2718      Warn!(
2719        "expected",
2720        "<unit>",
2721        "Illegal unit of measure (mu inserted)."
2722      );
2723    }
2724    if is_negative {
2725      m *= -1.0;
2726    }
2727    let sp = match munit {
2728      Some((num, den)) => fixpoint_unit(m, num, den),
2729      None => fixpoint(m, None),
2730    };
2731    Ok(MuDimension::new(sp))
2732  } else if let Some(mglue) = read_internal_mu_glue()? {
2733    let m = if is_negative { mglue.negate() } else { mglue };
2734    Ok(MuDimension::new(m.value_of()))
2735  } else {
2736    Warn!("expected", "<mudimen>", "Expecting mudimen; assuming 0");
2737    Ok(MuDimension::new(0))
2738  }
2739}
2740
2741pub fn read_mu_unit() -> Result<Option<(i64, i64)>> {
2742  if read_keyword(&["mu"])?.is_some() {
2743    skip_one_space(true)?;
2744    Ok(Some((UNITY, UNITY))) // effectively, scaled mu
2745  } else if let Some(m) = read_internal_mu_glue()? {
2746    Ok(Some((m.value_of(), UNITY)))
2747  } else {
2748    Ok(None)
2749  }
2750}
2751
2752fn read_internal_mu_glue() -> Result<Option<MuGlue>> {
2753  match read_register_value(RegisterType::MuGlue)? {
2754    None => Ok(None),
2755    Some(val) => Ok(Some(val.into())),
2756  }
2757}
2758
2759/// Apparent behaviour of a token value (ie `\toks#=<arg>`)
2760pub fn read_tokens_value() -> Result<Tokens> {
2761  match read_non_space()? {
2762    None => Ok(Tokens!()),
2763    Some(token) => {
2764      // Perl: $$token[1] == CC_BEGIN — direct catcode check
2765      if token.get_catcode() == Catcode::BEGIN {
2766        Ok(read_balanced(ExpansionLevel::Off, false, false)?)
2767      } else {
2768        match lookup_register_definition(&token) {
2769          Some(defn) => {
2770            match defn.register_type() {
2771              Some(RegisterType::Tokens) | Some(RegisterType::Token) => {
2772                // TODO: The mismatch between Vec<Tokens> for read_arguments and Vec<Token> for
2773                // value_of feels incorrect       but in which direction should it be
2774                // resolved?
2775                let args = defn.read_arguments()?;
2776                match defn.value_of(args) {
2777                  None => Ok(Tokens!()),
2778                  Some(v) => Ok(v.into()),
2779                }
2780              },
2781              _ => Ok(Tokens!(token)),
2782            }
2783          },
2784          _ => {
2785            match lookup_definition(&token)? {
2786              Some(defn) => {
2787                // TODO: we are doing two lookups to avoid the type restriction of .read_arguments,
2788                // any way to circumvent? Is it slow in the first place?
2789                if defn.is_expandable() {
2790                  let x = defn.invoke(false)?;
2791                  if !x.is_empty() {
2792                    unread(x);
2793                  }
2794                  read_tokens_value()
2795                } else {
2796                  Ok(Tokens!(token))
2797                }
2798              },
2799              _ => Ok(Tokens!(token)),
2800            }
2801          },
2802        }
2803      }
2804    },
2805  }
2806}
2807
2808/// Discard any run of spaces at the head of the input — Perl
2809/// `Package.pm:SkipSpaces`.
2810///
2811/// Reads past the spaces via [`read_non_space`] and pushes the first
2812/// non-space token back, so the caller's next read starts on real content.
2813/// End of input is not an error: there is simply nothing to unread.
2814pub fn skip_spaces() -> Result<()> {
2815  if let Some(t) = read_non_space()? {
2816    unread_one(t);
2817  }
2818  Ok(())
2819}
2820
2821/// Check if a token is a space token (catcode SPACE) or an "implicit space"
2822/// (a CS or ACTIVE token `\let` to a space token).
2823/// See TeXbook p269: `<one optional space>` absorbs both explicit and implicit spaces.
2824fn is_space_or_implicit_space(token: &Token) -> bool {
2825  if token.get_catcode() == Catcode::SPACE {
2826    return true;
2827  }
2828  // Check for implicit space: CS/ACTIVE let to a space token
2829  if token.get_catcode() == Catcode::CS || token.get_catcode() == Catcode::ACTIVE {
2830    return with_meaning(
2831      token,
2832      |m| matches!(m, Some(Stored::Token(t)) if t.get_catcode() == Catcode::SPACE),
2833    );
2834  }
2835  false
2836}
2837
2838/// Skip one optional space.
2839/// If `expanded` is true, acts like `<one optional space>` and expands tokens (readXToken).
2840/// Perl: skip1Space($self, $expanded)
2841pub fn skip_one_space(expanded: bool) -> Result<()> {
2842  let token = if expanded {
2843    read_x_token(None, false, None)?
2844  } else {
2845    read_token()?
2846  };
2847  if let Some(t) = token
2848    && !is_space_or_implicit_space(&t)
2849  {
2850    unread_one(t);
2851  }
2852  Ok(())
2853}
2854
2855//======================================================================
2856// some helpers...
2857
2858// <optional signs> = <optional spaces> | <optional signs><plus or minus><optional spaces>
2859// returns false if None, or positive, true if negative
2860pub fn read_optional_signs() -> Result<bool> {
2861  let mut sign = false;
2862  while let Some(t) = read_x_token(None, false, None)? {
2863    let sym = t.get_sym();
2864    if sym == pin!("-") {
2865      sign = !sign;
2866    } else if (sym != pin!("+")) && !is_space_or_implicit_space(&t) {
2867      unread_one(t); // Unread and end
2868      break;
2869    }
2870  }
2871  Ok(sign)
2872}
2873
2874fn read_digits(range_regex: &Regex, skip: bool) -> Result<String> {
2875  let mut result = String::new();
2876  while let Some(token) = read_x_token(None, false, None)? {
2877    let digit_opt = token.with_str(|s| {
2878      if s.len() == 1 && range_regex.is_match(s) {
2879        s.chars().next()
2880      } else {
2881        None
2882      }
2883    });
2884    if let Some(digit) = digit_opt {
2885      result.push(digit);
2886    } else {
2887      if !(skip && is_space_or_implicit_space(&token)) {
2888        unread_one(token);
2889      }
2890      break;
2891    }
2892  }
2893  Ok(result)
2894}
2895
2896// ```
2897// <factor> = <normal integer> | <decimal constant>
2898// <decimal constant> = . | , | <digit><decimal constant> | <decimal constant><digit>
2899// ```
2900/// Return a number (Rust f64 number)
2901pub fn read_factor() -> Result<Option<f64>> {
2902  let mut factor = read_digits(&DIGIT_RE, false)?;
2903  let mut token_opt = read_x_token(None, false, None)?;
2904  if let Some(ref token) = token_opt {
2905    let sym = token.get_sym();
2906    if sym == pin!(".") || sym == pin!(",") {
2907      factor = s!("{}.{}", factor, read_digits(&DIGIT_RE, false)?);
2908      token_opt = read_x_token(None, false, None)?;
2909    }
2910  }
2911
2912  // Note: zero is an edge case with the unwrap_or fallback, handle it
2913  if !factor.is_empty() {
2914    let factor_f64: f64 = factor.parse::<f64>().unwrap_or(0.0);
2915    if let Some(token) = token_opt
2916      && token.get_catcode() != Catcode::SPACE
2917    {
2918      unread_one(token);
2919    }
2920    Ok(Some(factor_f64))
2921  } else {
2922    if let Some(token) = token_opt {
2923      unread_one(token);
2924    }
2925    match read_normal_integer()? {
2926      None => Ok(None),
2927      Some(n) => Ok(Some(n.value_of() as f64)),
2928    }
2929  }
2930}
2931
2932/// Fully expand `tokens` and return the result, without digesting them.
2933///
2934/// Port of Perl `Package.pm:Expand` L950-955. The tokens are read from a fresh
2935/// mouth wrapped in `{`…`}` and consumed with `readBalanced` — the braces are
2936/// what bound the expansion to exactly this material, so an unbalanced or
2937/// runaway body cannot walk off into whatever follows in the real input.
2938///
2939/// See [`do_expand_partially`] for the expand-until-unexpandable variant.
2940pub fn do_expand<T: Into<Tokens>>(tokens: T) -> Result<Tokens> {
2941  let tokens: Tokens = tokens.into();
2942  reading_from_mouth(Mouth::default(), move || -> Result<Tokens> {
2943    {
2944      unread_one(T_END!());
2945      unread(tokens);
2946      unread_one(T_BEGIN!());
2947    }
2948    read_balanced(ExpansionLevel::Full, false, true)
2949  })
2950}
2951
2952/// As [`do_expand`], but stopping at the first unexpandable token of each
2953/// branch ([`ExpansionLevel::Partial`]) instead of expanding to the bitter end.
2954///
2955/// What a binding wants when it needs to *look* at what a macro produces —
2956/// resolving a `\newcommand` alias, say — without forcing the primitives
2957/// underneath it, whose expansion has side effects the caller is not ready for.
2958pub fn do_expand_partially<T: Into<Tokens>>(tokens: T) -> Result<Tokens> {
2959  let tokens: Tokens = tokens.into();
2960  reading_from_mouth(Mouth::default(), move || -> Result<Tokens> {
2961    {
2962      unread_one(T_END!());
2963      unread(tokens);
2964      unread_one(T_BEGIN!());
2965    }
2966    read_balanced(ExpansionLevel::Partial, false, true)
2967  })
2968}
2969
2970pub fn is_column_end(token: &Token) -> Option<(Token, &'static str, bool)> {
2971  match token.get_catcode() {
2972    Catcode::ALIGN => Some((*token, "align", false)),
2973    Catcode::CS | Catcode::ACTIVE => {
2974      // Embedded version of Equals, knowing both are tokens
2975      let defn = lookup_meaning(token).unwrap_or_else(|| Stored::Token(*token));
2976      // Perl Gullet.pm L273: if meaning is a Token with CC_ALIGN, treat as alignment tab
2977      if let Stored::Token(t) = &defn
2978        && t.get_catcode() == Catcode::ALIGN
2979      {
2980        return Some((*token, "align", false));
2981      }
2982      for end in *COLUMN_ENDS {
2983        let e = &end.0;
2984        // Would be nice to cache the defns, but don't know when they're present & constant!
2985        if defn == lookup_meaning(e).unwrap_or_else(|| Stored::Token(*e)) {
2986          return Some(end);
2987        }
2988      }
2989      None
2990    },
2991    _ => None,
2992  }
2993}
2994/// Handle a marker token, by updating the current alignment group count
2995fn handle_marker(marker_token: Token) {
2996  marker_token.with_str(|arg| match arg {
2997    "before-column" => {
2998      // Were in before-column template
2999      set_align_group_count(0);
3000    }, // switch to column proper!
3001    "after-column" => { // Were in before-column template
3002      // let alignment = lookup_alignment();
3003      // Debug("Halign $alignment: alignment  after column") if $LaTeXML::DEBUG{halign};
3004    },
3005    _ => {},
3006  });
3007}
3008
3009/// Do something, while reading tokens from a specific Mouth.
3010///
3011/// This reads ONLY from that mouth (or any mouth openned by code in that source),
3012/// and the mouth should end up empty afterwards, and only be closed here.
3013pub fn reading_from_mouth<R, FnR>(mouth: Mouth, reader: FnR) -> Result<R>
3014where FnR: FnOnce() -> Result<R> {
3015  let context_mouth_source = arena::pin(mouth.get_source());
3016  // A cycle is only a cycle WITHIN one expansion context. Rather than
3017  // resetting the guard history here (an earlier fix that also BLINDED the
3018  // guard to outer loops whose body calls `do_expand` each iteration), give
3019  // this reading context a fresh SERIAL that `read_internal_token_checked`
3020  // mixes into every fingerprint: windows can never match across contexts, so
3021  // consecutive identical short expansions — the math0402448 xymatrix
3022  // per-cell `get_xmarg_id` stream that false-positived as a "loop" — stay
3023  // inert, while the OUTER context's serial (restored on exit) keeps outer
3024  // periodicity intact and detectable. PR #249 review P2-7.
3025  {
3026    let mut g = gullet_mut!();
3027    let outer = g.ctx_serial;
3028    g.ctx_stack.push(outer);
3029    g.ctx_next += 1;
3030    g.ctx_serial = g.ctx_next;
3031  }
3032  open_mouth(mouth, false); // only allow mouth to be explicitly closed here.
3033  let reader_result = reader();
3034  // Reading in this context is over (whether Ok or Err): restore the outer
3035  // context's serial before any cleanup/return path below.
3036  {
3037    let mut g = gullet_mut!();
3038    let restored = g.ctx_stack.pop().unwrap_or(0);
3039    g.ctx_serial = restored;
3040  }
3041  // If the reader returned an error (e.g., Fatal from token limit),
3042  // we STILL need to clean up the mouth to preserve the caller's state.
3043  let results: R = match reader_result {
3044    Ok(v) => v,
3045    Err(e) => {
3046      // Force-close our mouth and any autoclosable mouths above it
3047      loop {
3048        let current = gullet!()
3049          .runtime
3050          .as_ref()
3051          .map(|r| arena::pin(r.mouth.get_source()));
3052        if current == Some(context_mouth_source) {
3053          close_mouth(true).ok();
3054          break;
3055        } else if gullet!().mouthstack.is_empty() {
3056          break; // Our mouth was already consumed
3057        } else {
3058          close_mouth(true).ok(); // Close stale mouth above ours
3059        }
3060      }
3061      // Reset progress counter so subsequent processing isn't immediately killed
3062      gullet_mut!().progress = 0;
3063      return Err(e);
3064    },
3065  };
3066  // `mouth` must still be open, with (at worst) empty autoclosable mouths in front of it.
3067  // Rate-limit the "mouth closed" error — when the gullet gets into a state
3068  // where the cleanup loop keeps finding stale mouths above the target, the
3069  // same error can fire on EVERY caller of reading_from_mouth. Arxiv 0906.1883
3070  // (birkmult + local .cls) can trigger 10K+ such firings, one per stack frame.
3071  // Fatal out after 50 repeat firings so the process surfaces a clear "we lost
3072  // the mouth stack" signal instead of filling the log with identical messages.
3073  thread_local! {
3074    static MOUTH_CLOSED_ERRORS: Cell<usize> = const { Cell::new(0) };
3075  }
3076  fn record_mouth_closed_error() { MOUTH_CLOSED_ERRORS.with(|c| c.set(c.get().saturating_add(1))); }
3077  fn should_emit_mouth_closed() -> bool { MOUTH_CLOSED_ERRORS.with(|c| c.get() < 10) }
3078  fn mouth_closed_budget_exhausted() -> bool { MOUTH_CLOSED_ERRORS.with(|c| c.get() >= 50) }
3079  loop {
3080    let mouth_source = gullet!()
3081      .runtime
3082      .as_ref()
3083      .map(|r| arena::pin(r.mouth.get_source()));
3084    if mouth_source == Some(context_mouth_source) {
3085      close_mouth(true)?;
3086      break;
3087    } else if gullet!().mouthstack.is_empty() {
3088      if should_emit_mouth_closed() {
3089        // `arena::to_string` clones the resolved &str into an owned String
3090        // BEFORE we hand it to Error! — a following `arena::pin` triggered
3091        // deep inside generate_message!/get_location() can grow the
3092        // interner's buffer and invalidate a borrowed &str (observed as
3093        // garbled, buffer-adjacent symbol content in 0906.1883 errors).
3094        let src = arena::to_string(context_mouth_source);
3095        Error!(
3096          "unexpected",
3097          "<closed>",
3098          "Mouth is unexpectedly already closed",
3099          s!("Reading from {src}, but it has already been closed.")
3100        );
3101      }
3102      record_mouth_closed_error();
3103      if mouth_closed_budget_exhausted() {
3104        Fatal!(
3105          Stomach,
3106          Recursion,
3107          "Too many unexpectedly-closed mouth errors (>50); gullet mouth-stack state is inconsistent"
3108        );
3109      }
3110      break;
3111    } else {
3112      let is_autoclosable = gullet!()
3113        .runtime
3114        .as_ref()
3115        .map(|r| r.autoclose)
3116        .unwrap_or(false);
3117      if is_autoclosable {
3118        // Auto-closable mouth (e.g. from \scantokens, raw_tex) — safe to close
3119        close_mouth(true)?;
3120      } else {
3121        // Non-autoclosable mouth that isn't our target — this means our target
3122        // mouth was already consumed. Don't close this mouth (it belongs to an
3123        // outer reading_from_mouth call). Just error and stop.
3124        if should_emit_mouth_closed() {
3125          let src = arena::to_string(context_mouth_source);
3126          Error!(
3127            "unexpected",
3128            "<closed>",
3129            "Mouth is unexpectedly already closed",
3130            s!(
3131              "Reading from {src}, but it has already been closed (found different non-closable mouth on top)."
3132            )
3133          );
3134        }
3135        record_mouth_closed_error();
3136        if mouth_closed_budget_exhausted() {
3137          Fatal!(
3138            Stomach,
3139            Recursion,
3140            "Too many unexpectedly-closed mouth errors (>50); gullet mouth-stack state is inconsistent"
3141          );
3142        }
3143        break;
3144      }
3145    }
3146  }
3147  Ok(results)
3148}
3149
3150/// Check if there is more input to be read from the current mouth
3151pub fn has_more_input() -> bool {
3152  match runtime!() {
3153    Some(ref mut runtime) => runtime.mouth.has_more_input(),
3154    None => false,
3155  }
3156}
3157
3158/// Obscure, but the only way I can think of to End!! (see \bye or \end{document})
3159/// Flush all sources (close all pending mouth's)
3160pub fn flush() {
3161  let mut g = gullet_mut!();
3162  if let Some(ref mut runtime) = g.runtime {
3163    runtime.mouth.finish();
3164  }
3165  while !g.mouthstack.is_empty() {
3166    if let Some(mut entry) = g.mouthstack.pop_front() {
3167      entry.mouth.finish();
3168    }
3169  }
3170  g.runtime = Some(MouthRuntime {
3171    mouth:     Mouth::default(),
3172    pushback:  Vec::with_capacity(128),
3173    autoclose: true,
3174    boundary:  BalancedBoundary::Transparent,
3175  });
3176  g.mouthstack = VecDeque::new();
3177}
3178
3179/// Execute a function with a mutable reference to the current mouth
3180pub fn with_mouth_mut<FnR, R>(caller: FnR) -> R
3181where FnR: FnOnce(Option<&mut Mouth>) -> R {
3182  let mut gullet = gullet_mut!();
3183  let mouth_opt = match gullet.runtime {
3184    None => None,
3185    Some(ref mut runtime) => Some(&mut runtime.mouth),
3186  };
3187  caller(mouth_opt)
3188}