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}