Skip to main content

latexml_core/
dump_reader.rs

1//! Reader for Rust-native kernel dump files (produced by dump_writer.rs).
2//!
3//! Loads a dump file produced by `latexml_oxide --init=latex.ltx --dest=dump`
4//! and replays the state assignments into the engine.
5//!
6//! **Loading policy:** `M` and `V` entries replay with Perl-style global
7//! assignment semantics, matching `Core/Dumper.pm`'s `I()` / `V()` helpers.
8//! Runtime-state filters below are narrow exceptions for entries that should
9//! never be useful from a format dump.
10//!
11//! Format: tab-separated lines:
12//!   V\tKEY\tTYPE\tDATA             — value assignment
13//!   M\tKEY\tE\tCS\tNARGS\tFLAGS\tTOKENS — Expandable definition
14//!   M\tKEY\tN                       — None meaning (undefined)
15//!   M\tKEY\tT\tCC:TEXT              — Token meaning (let-assignment)
16//!   C\tCHAR\tCC\tVALUE             — catcode assignment
17//!   LC\tCHAR\tCH\tVALUE            — lccode assignment
18//!   UC\tCHAR\tCH\tVALUE            — uccode assignment
19//!   SC\tCHAR\tCH\tVALUE            — sfcode assignment
20//!   DC\tCHAR\tCH\tVALUE            — delcode assignment
21//!   MC\tCHAR\tCH\tVALUE            — mathcode assignment
22
23use std::path::Path;
24
25use crate::{
26  common::{arena, numeric_ops::NumericOps, store::Stored},
27  definition::expandable::{Expandable, ExpandableOptions},
28  state::{self, Scope, TableName},
29  token::{Catcode, Token},
30  tokens::Tokens,
31};
32
33/// Load a Rust-native dump file into the current State.
34/// Returns the number of entries loaded.
35pub fn load_native_dump(path: &Path) -> Result<usize, String> {
36  let content = std::fs::read_to_string(path)
37    .map_err(|e| format!("Failed to read dump file {}: {}", path.display(), e))?;
38  let count = load_from_str_internal(&content, &path.display().to_string())?;
39  Ok(count)
40}
41
42/// Load dump data from a string (used by the embedded LaTeX kernel dump
43/// module — `latexml_engine/src/latex_dump.rs`).
44/// Returns the number of entries loaded.
45pub fn load_from_str(content: &str) -> Result<usize, String> {
46  // Labelled `<embedded:latex>` (mirroring `<embedded:plain>`) so the
47  // "Loaded N entries from ..." info line names which dump was loaded.
48  load_from_str_internal(content, "<embedded:latex>")
49}
50
51/// Backwards-compat alias kept until call sites are migrated. Both
52/// entry points now load unconditionally, mirroring Perl `I(...)` /
53/// `V(...)` (Core/Dumper.pm) which call `assign_internal('global')`
54/// without filters.
55pub fn load_from_str_plain(content: &str) -> Result<usize, String> {
56  load_from_str_internal(content, "<embedded:plain>")
57}
58
59/// Load a dump, naming `source` (a real path or an `<embedded TLyyyy>` label) in
60/// the single `dump_reader:loaded` Info line. The dump wrappers
61/// (`plain_dump`/`latex_dump`) use this so there is exactly ONE "loaded N
62/// entries from <source>" message per dump — they no longer emit a second,
63/// redundant `*_dump:loaded` line of their own.
64pub fn load_from_str_labeled(content: &str, source: &str) -> Result<usize, String> {
65  load_from_str_internal(content, source)
66}
67
68// Per-load context used to attach a nominal Locator to dump-installed
69// Expandables. Matches Perl #aaacdba2 (2026): dump-loaded definitions
70// should be traceable to the dump file + line, not report the arena's
71// internal location. Thread-local so concurrent loads (there are none
72// today, but the state is cooperative) don't clobber each other.
73thread_local! {
74  static CURRENT_LOAD_CTX: std::cell::Cell<Option<(arena::SymStr, u32)>> =
75    const { std::cell::Cell::new(None) };
76  /// PA/MPA aliases whose target wasn't defined at dump-load time.
77  /// Populated by `load_meaning`'s PA arm, drained by
78  /// `flush_deferred_aliases()` after `_constructs` finishes.
79  static DEFERRED_ALIASES: std::cell::RefCell<Vec<(Token, Token)>> =
80    const { std::cell::RefCell::new(Vec::new()) };
81}
82
83/// Replay any PA/MPA aliases that were deferred during dump load
84/// because their target was not yet defined. Call once after the
85/// post-dump definition pass (`_constructs`) has loaded.
86/// Returns `(applied, skipped)`.
87pub fn flush_deferred_aliases() -> (usize, usize) {
88  let pending: Vec<(Token, Token)> =
89    DEFERRED_ALIASES.with(|cell| std::mem::take(&mut *cell.borrow_mut()));
90  let mut applied = 0usize;
91  let mut skipped = 0usize;
92  for (cs_tok, target_tok) in pending {
93    // Target still undefined — the alias's target must be defined
94    // in some source we never load (e.g. expl3 intarrays that the
95    // short-circuit skips). Leave the key undefined; the engine's
96    // undefined-CS handler will cope at runtime.
97    if !state::has_meaning(&target_tok) {
98      skipped += 1;
99      continue;
100    }
101    // Perl `Lt()` parity: look up target's meaning, write it at
102    // alias key via `assign_internal('meaning', ..., 'global')`.
103    match state::lookup_meaning(&target_tok) {
104      Some(meaning) => {
105        state::assign_internal(
106          TableName::Meaning,
107          cs_tok.get_cs_name(),
108          meaning,
109          Some(Scope::Global),
110        );
111        applied += 1;
112      },
113      _ => {
114        skipped += 1;
115      },
116    }
117  }
118  (applied, skipped)
119}
120
121fn current_dump_locator() -> crate::common::locator::Locator {
122  if let Some((source, lineno)) = CURRENT_LOAD_CTX.with(|c| c.get()) {
123    crate::common::locator::Locator {
124      source,
125      from_line: lineno,
126      to_line: lineno,
127      from_column: 1,
128      to_column: 1,
129    }
130  } else {
131    crate::common::locator::Locator::default()
132  }
133}
134
135fn load_from_str_internal(content: &str, source_name: &str) -> Result<usize, String> {
136  let mut count = 0;
137  let mut skipped = 0;
138  let mut errors = 0;
139  let source_sym = arena::pin(source_name);
140
141  for (lineno, line) in content.lines().enumerate() {
142    // Trim only CR (from CRLF line endings); `lines()` already strips LF.
143    // Do NOT use `trim()` here — it strips trailing tabs, which are part of
144    // the tab-separated format for entries with empty trailing fields (e.g.
145    // E-entries with empty body: `E\t<cs>\t<nargs>\t<flags>\t`).
146    let line = line.trim_end_matches('\r');
147    if line.is_empty() || line.starts_with('#') {
148      continue;
149    }
150
151    CURRENT_LOAD_CTX.with(|c| c.set(Some((source_sym, (lineno + 1) as u32))));
152
153    match parse_and_load(line) {
154      Ok(true) => count += 1,
155      Ok(false) => skipped += 1,
156      Err(e) => {
157        errors += 1;
158        if errors <= 10 {
159          Warn!(
160            "dump_reader",
161            "line",
162            s!(
163              "Line {}: {}: {}",
164              lineno + 1,
165              e,
166              &line[..line.len().min(80)]
167            )
168          );
169        }
170      },
171    }
172  }
173
174  if errors > 10 {
175    Warn!(
176      "dump_reader",
177      "errors",
178      s!("... and {} more errors", errors - 10)
179    );
180  }
181  Info!(
182    "dump_reader",
183    "loaded",
184    s!(
185      "Loaded {} entries from {} ({} skipped, {} errors)",
186      count,
187      source_name,
188      skipped,
189      errors
190    )
191  );
192
193  Ok(count)
194}
195
196/// Collect the control-sequence keys of every `M` (meaning) record in a dump,
197/// **without applying anything to State**.
198///
199/// This is a pure index scan over the same text [`load_from_str_labeled`]
200/// replays: one `splitn(3, '\t')` per line, keeping field 1 of the `M` rows and
201/// dropping everything else. No token bodies are parsed, no arena interning
202/// happens, and the State is not touched — so it is safe to call in a session
203/// that has deliberately *not* loaded the dump (see
204/// `latexml_engine::latex_kernel`, which uses it to answer "does the LaTeX
205/// kernel define this CS?" before committing to a pool load).
206///
207/// Keys are url-decoded exactly as `parse_and_load` decodes them, so a name
208/// found here compares equal to the CS name the loader would install.
209pub fn collect_meaning_keys(content: &str) -> rustc_hash::FxHashSet<Box<str>> {
210  let mut keys = rustc_hash::FxHashSet::default();
211  for line in content.lines() {
212    // `M` is the only table code whose keys are control-sequence names;
213    // the char-table rows (`C`/`LC`/…) and value rows (`V`/`IA`) are keyed
214    // by character or by internal parameter name.
215    let Some(rest) = line.strip_prefix("M\t") else {
216      continue;
217    };
218    let raw_key = rest.split('\t').next().unwrap_or("");
219    if raw_key.is_empty() {
220      continue;
221    }
222    if raw_key.contains('%') {
223      keys.insert(url_decode(raw_key).into_boxed_str());
224    } else {
225      keys.insert(Box::from(raw_key));
226    }
227  }
228  keys
229}
230
231/// Parse a single dump line and load it. Returns Ok(true) if loaded,
232/// Ok(false) if filtered (e.g. corrupt MC/DC), Err on parse error.
233fn parse_and_load(line: &str) -> Result<bool, String> {
234  // Direct splitn iteration — saves the per-line Vec<&str> allocation
235  // that splitn(3).collect() was doing × 110k dump entries.
236  let mut it = line.splitn(3, '\t');
237  let table = it.next().ok_or("Too few fields")?;
238  let raw_key = match it.next() {
239    Some(k) => k,
240    None => return Err("Too few fields".into()),
241  };
242  // Key decode: Cow borrows the original &str when no `%` escape is
243  // present (the overwhelming majority). Saves a per-line allocation
244  // for the ~25k dump entries that have plain CS-name keys.
245  let key_cow: std::borrow::Cow<'_, str> = if raw_key.contains('%') {
246    std::borrow::Cow::Owned(url_decode(raw_key))
247  } else {
248    std::borrow::Cow::Borrowed(raw_key)
249  };
250  let key = key_cow.as_ref();
251  let data = it.next().unwrap_or("");
252
253  match table {
254    // V: Value entries (registers, fontdimen, font metadata).
255    // Add-only policy: only loads if key has no existing value.
256    //
257    // Skip MAX_ERRORS: it was set to 1_000_000 in `ini_tex.rs` during
258    // dump-build (to let raw latex.ltx run through transient errors)
259    // and got captured into the dump. Loading that into a regular
260    // conversion lets runaway error cascades (e.g. AmS-TeX `\cases`
261    // mis-parse → 1M `\hbox`/`&` errors per paper) bypass the 10000
262    // default cap. Filter at read time so existing dumps are clean.
263    "V" if key == "MAX_ERRORS" => Ok(false),
264    "V" => load_value(key, data),
265    // IA: consolidated expl3 intarray (one record per (font, size); dump_writer
266    // collapses ~17k V-records into one IA). Body is `<len>\t<rle>` where rle
267    // is a comma-list of `v` or `v*n` runs. Expansion assigns the same V
268    // entries that the per-slot records would have, so the runtime state
269    // post-replay is identical.
270    "IA" => load_intarray(key, data),
271    // M: Meaning entries (Expandable, Let-alias, Register, etc.).
272    //
273    // Perl-faithful: `plain_dump.pool.ltxml` and `latex_dump.pool.ltxml`
274    // emit one `I(...)` per Meaning entry, which is
275    // `assign_internal($STATE, 'meaning', $cs, $def, 'global')` —
276    // unconditional global write. No admission gate, no skip-if-defined.
277    // Match it: route every M entry to `load_meaning` directly.
278    "M" => load_meaning(key, data),
279    // LC/UC: case-mapping codes — safe, always load
280    "LC" => load_lccode(key, data),
281    "UC" => load_uccode(key, data),
282    // SC: space factor codes — safe, always load
283    "SC" => load_sfcode(key, data),
284    // C: catcodes — only for non-ASCII (>127). ASCII catcodes are set by
285    // the engine; loading from dump would conflict.
286    "C" => {
287      let ch = decode_char_key(key);
288      if ch.is_some_and(|c| c as u32 > 127) {
289        load_catcode(key, data)
290      } else {
291        Ok(false)
292      }
293    },
294    // Perl `Core/Dumper.pm:dump_mathcode/dump_delcode` write MC/DC for
295    // every state-set entry; the matching reader is unconditional apply
296    // (CLAUDE.md "Unconditional dump apply"). plain.tex / latex.ltx need
297    // letter mathcodes and `\delcode\(="0028300` etc. replayed from dump
298    // so `\cal abc` (cmsy fam 2) and delimited symbols decode correctly
299    // — without this, `decode_math_char` never fires for letters in the
300    // dump path and they get default-decoded to ASCII (no meaning/role).
301    "MC" => load_mathcode(key, data),
302    "DC" => load_delcode(key, data),
303    _ => Ok(false),
304  }
305}
306
307/// V entries to unconditionally skip (runtime state, never useful from dump).
308const SKIP_VALUE_KEYS: &[&str] = &[
309  "INTERPRETING_DEFINITIONS",
310  "if_count",
311  "absorb_count",
312  "if_stack",
313  "INCLUDE_COMMENTS",
314  "INCLUDE_STYLES",
315  "INPUT_ENCODING",
316  "CURRENT_INPUT_ENCODING",
317  "SUPPRESS_UNEXPECTED_ERRORS",
318  "SUPPRESS_UNDEFINED_ERRORS",
319  // Upstream Perl IGNORED_SYMBOLS (TeX_Job.pool.ltxml): runtime-only
320  // tables that re-populate from the engine — can't meaningfully round-
321  // trip through the dump.
322  "DOCUMENT_REWRITE_RULES",
323  "PARAMETER_TYPES",
324  "TAG_PROPERTIES",
325  "MATH_LIGATURES",
326  "TEXT_LIGATURES",
327];
328
329/// V entry key prefixes to skip.
330const SKIP_VALUE_PREFIXES: &[&str] = &["input_file:", "output_file:", "texsys"];
331
332/// V entry key substrings to skip.
333///
334/// Note: `_loaded` / `_raw_loaded` flags are present in the dump (correctly,
335/// since `--init=latex.ltx` sees expl3-code.tex, hyphenation patterns, and
336/// hundreds of other raw-loaded files). But carrying them through into state
337/// at dump-load time blows up in practice:
338///
339///  - Hyphenation `loadhyph-*.tex_loaded` flags make subsequent raw-loading of babel's language.def
340///    skip files that set `\l@<lang>` registers our engine then discovers aren't present,
341///    triggering a flood of error recovery that can consume gigabytes of RAM.
342///  - `expl3.ltx_loaded=1` plus `expl3.sty_loaded=` NOT being set means `\usepackage{expl3}`
343///    doesn't short-circuit AT the .sty layer, but the raw .ltx re-load now enters a stranger code
344///    path with partial flags.
345///
346/// The proper fix, tracked as the "dump/_base mutual-exclusivity" item in
347/// SYNC_STATUS D0, is to have exactly ONE loading path (dump-cache or raw-load)
348/// active at a time, mirroring Perl's `LoadFormat` branching. Until that lands,
349/// keep the skip list conservative so mixed paths don't trigger recovery loops.
350const SKIP_VALUE_CONTAINS: &[&str] = &[
351  "_loaded", /* Package loading flags — see doc comment above.
352             * Substring also matches `_raw_loaded` (OXIDIZED_DESIGN #23). */
353];
354
355/// Load a value entry: V\tKEY\tTYPE\tDATA
356///
357/// Uses add-only policy: only loads if the key does not already have a value.
358/// This ensures compiled engine state takes priority over dump state.
359fn load_value(key: &str, data: &str) -> Result<bool, String> {
360  // Skip unconditional keys
361  for skip in SKIP_VALUE_KEYS {
362    if key == *skip {
363      return Ok(false);
364    }
365  }
366  // Skip by prefix
367  for prefix in SKIP_VALUE_PREFIXES {
368    if key.starts_with(prefix) {
369      return Ok(false);
370    }
371  }
372  // Skip by substring.
373  for substr in SKIP_VALUE_CONTAINS {
374    if key.contains(substr) {
375      return Ok(false);
376    }
377  }
378
379  // Perl `V()` parity (`Core/Dumper.pm` L59): every dumped Value entry
380  // maps to `assign_internal($STATE, 'value', $key, $val, 'global')` —
381  // unconditional global write. No skip-if-defined.
382
383  // Avoid the per-line Vec<&str> allocation — direct iter destructure
384  // matches the pattern used in load_meaning and parse_and_load.
385  let mut top_it = data.splitn(2, '\t');
386  let kind = top_it.next().ok_or("Missing value type")?;
387  let rest = top_it.next().unwrap_or("");
388  // Helper to default to "0" for numeric parses (the prior code used
389  // `rest_or_zero.parse()`).
390  let rest_or_zero = if rest.is_empty() { "0" } else { rest };
391
392  let value = match kind {
393    "N" => return Ok(false), // Don't load None values (would erase existing)
394    "B" => Stored::Bool(rest == "1"),
395    "I" => {
396      let n: i64 = rest_or_zero
397        .parse()
398        .map_err(|e| format!("Bad int: {}", e))?;
399      Stored::Int(n)
400    },
401    // "Nm": Stored::Number marker (distinct from "I" Stored::Int) —
402    // see dump_writer's Number serializer for rationale.
403    "Nm" => {
404      let n: i64 = rest_or_zero
405        .parse()
406        .map_err(|e| format!("Bad number: {}", e))?;
407      Stored::Number(crate::common::number::Number(n))
408    },
409    "S" => Stored::from(url_decode(rest)),
410    "F" => {
411      // Stored::Font — written by dump_writer's Stored::Font arm.
412      // Format: F\tname=...\x1fsize=...\x1ffamily=...\x1f...
413      // Each unit-separator-delimited segment is `key=urlencoded_value`.
414      // Mirrors Perl `dump_font` (Core/Dumper.pm L281-284).
415      use std::borrow::Cow;
416
417      use crate::common::font::Font;
418      let mut font = Font::default();
419      for kv in rest.split('\x1f') {
420        if let Some((k, v)) = kv.split_once('=') {
421          let v_dec = url_decode(v);
422          match k {
423            "name" => font.name = Some(Cow::Owned(v_dec)),
424            "family" => font.family = Some(Cow::Owned(v_dec)),
425            "series" => font.series = Some(Cow::Owned(v_dec)),
426            "shape" => font.shape = Some(Cow::Owned(v_dec)),
427            "encoding" => font.encoding = Some(Cow::Owned(v_dec)),
428            "language" => font.language = Some(Cow::Owned(v_dec)),
429            "mathstyle" => font.mathstyle = Some(Cow::Owned(v_dec)),
430            "opacity" => font.opacity = Some(Cow::Owned(v_dec)),
431            "size" => font.size = v_dec.parse().ok(),
432            "scale" => font.scale = v_dec.parse().ok(),
433            "emph" => font.emph = Some(v_dec == "1"),
434            "scripted" => font.scripted = Some(v_dec == "1"),
435            "mathstylestep" => font.mathstylestep = v_dec.parse().ok(),
436            "flags" => font.flags = v_dec.parse().ok(),
437            _ => {},
438          }
439        }
440      }
441      Stored::Font(std::rc::Rc::new(font))
442    },
443    "CH" => {
444      let n: u16 = rest_or_zero
445        .parse()
446        .map_err(|e| format!("Bad charcode: {}", e))?;
447      Stored::Charcode(n)
448    },
449    "CC" => {
450      let n: u8 = rest_or_zero
451        .parse()
452        .map_err(|e| format!("Bad catcode: {}", e))?;
453      Stored::Catcode(Catcode::from(n))
454    },
455    "T" => {
456      let tok = parse_token(rest)?;
457      Stored::Token(tok)
458    },
459    "TK" => {
460      let toks = parse_token_list(rest)?;
461      Stored::Tokens(Tokens::from(toks))
462    },
463    "D" => {
464      let n: i64 = rest_or_zero
465        .parse()
466        .map_err(|e| format!("Bad dimension: {}", e))?;
467      Stored::Dimension(crate::common::dimension::Dimension(n))
468    },
469    "G" => Stored::Glue(parse_glue(rest_or_zero)?),
470    "MD" => {
471      let n: i64 = rest_or_zero
472        .parse()
473        .map_err(|e| format!("Bad mudimension: {}", e))?;
474      Stored::MuDimension(crate::common::mudimension::MuDimension(n))
475    },
476    "MG" => Stored::MuGlue(parse_muglue(rest_or_zero)?),
477    "VD" => return Ok(false), // Don't load empty VecDeque (runtime state)
478    _ => return Ok(false),    // Unknown value type
479  };
480
481  // Perl `V()` (`Core/Dumper.pm` L59):
482  //   sub V { State::assign_internal($STATE,'value',$_[0],$_[1],'global'); }
483  // Direct table mutation, no dialect.
484  state::assign_internal(
485    TableName::Value,
486    arena::pin(key),
487    value,
488    Some(Scope::Global),
489  );
490  Ok(true)
491}
492
493/// Expand an `IA` (intarray) record into the per-slot Dimension V entries
494/// that the runtime expects. Format: key = `<prefix>` (e.g.
495/// `fontdimen_fontinfo_cmr10 at 15sp`), data = `<len>\t<rle>`. RLE tokens
496/// are comma-separated; each is either `<v>` (one entry) or `<v>x<n>`
497/// (n consecutive entries of value v). Slots are written at indices
498/// 1..=len. Mismatched RLE-length vs declared len is an error.
499fn load_intarray(key: &str, data: &str) -> Result<bool, String> {
500  let mut it = data.splitn(2, '\t');
501  let len_s = it.next().unwrap_or("");
502  let rle = it.next().unwrap_or("");
503  let len: usize = len_s.parse().map_err(|e| format!("Bad IA length: {}", e))?;
504  let values = rle_decode_i64(rle)?;
505  if values.len() != len {
506    return Err(format!(
507      "IA length mismatch for {:?}: declared {} but RLE decoded to {}",
508      key,
509      len,
510      values.len()
511    ));
512  }
513  for (i, val) in values.into_iter().enumerate() {
514    let slot_key = format!("{}_{}", key, i + 1);
515    state::assign_internal(
516      TableName::Value,
517      arena::pin(&slot_key),
518      Stored::Dimension(crate::common::dimension::Dimension(val)),
519      Some(Scope::Global),
520    );
521  }
522  Ok(true)
523}
524
525/// Inverse of `dump_writer::rle_encode_i64`. Parses a comma-separated
526/// list of tokens, each `v` (single) or `vxn` (n copies of v). Empty
527/// input decodes to an empty vector.
528fn rle_decode_i64(s: &str) -> Result<Vec<i64>, String> {
529  let mut out = Vec::new();
530  if s.is_empty() {
531    return Ok(out);
532  }
533  for tok in s.split(',') {
534    if let Some(xi) = tok.find('x') {
535      let val: i64 = tok[..xi]
536        .parse()
537        .map_err(|e| format!("Bad RLE value in {:?}: {}", tok, e))?;
538      let cnt: usize = tok[xi + 1..]
539        .parse()
540        .map_err(|e| format!("Bad RLE count in {:?}: {}", tok, e))?;
541      for _ in 0..cnt {
542        out.push(val);
543      }
544    } else {
545      let val: i64 = tok
546        .parse()
547        .map_err(|e| format!("Bad RLE value {:?}: {}", tok, e))?;
548      out.push(val);
549    }
550  }
551  Ok(out)
552}
553
554/// Load a meaning entry: M\tKEY\tTYPE\t...
555///
556/// Uses add-only policy: skip if the CS already has a meaning.
557/// Additionally, only loads "safe" definitions — those that won't interfere
558/// with the compiled engine's processing during normal LaTeX operation:
559/// - expl3 internals (contain `:`) — safe because `:` is OTHER under normal catcodes
560/// - Private LaTeX internals (contain `@`) — only invoked by other macros
561/// - Skip all "public" macros that could be invoked during normal expansion and might reference
562///   hooks/primitives not supported by our engine
563fn load_meaning(key: &str, data: &str) -> Result<bool, String> {
564  let cs_tok = Token {
565    text: arena::pin(key),
566    code: Catcode::CS,
567    #[cfg(feature = "token-locators")]
568    loc: 0,
569  };
570
571  // Perl `I(...)` parity (`Core/Dumper.pm` L67): every dumped Meaning
572  // entry maps to `assign_internal($STATE, 'meaning', $cs, $def,
573  // 'global')` — unconditional global write. No skip-if-defined, no
574  // admission filter.
575
576  // Avoid the per-line Vec<&str> allocation — this fn runs ~80k times
577  // during latex.dump load (every M entry).
578  let mut top_it = data.splitn(2, '\t');
579  let kind = top_it.next().ok_or("Missing meaning type")?;
580  let rest = top_it.next().unwrap_or("");
581
582  match kind {
583    "N" => {
584      // None meaning — skip (don't define as undefined)
585      Ok(false)
586    },
587    "E" => {
588      // Expandable: E\tCSNAME\tNARGS\tFLAGS\tTOKENS[\tPROTO[\tV3_PARAMS]]
589      //
590      // Three historical shapes, read in fallback order:
591      //   v3 — 6th field present: structured Parameter records; bypasses
592      //        parse_parameters entirely. Only format that round-trips
593      //        Until:/Match: with catcoded delimiter tokens intact.
594      //   v2 — 5th field present: url-decoded prototype string fed to
595      //        parse_parameters. Good for {} / [] / DefToken / simple
596      //        typed params; loses brace-in-delimiter forms.
597      //   v1 — nargs only: "{}".repeat(nargs), all params flattened to
598      //        Plain. Kept as last resort so ancient dumps still load.
599      // Direct iter destructure — saves the Vec<&str>::collect()
600      // allocation × ~80k E entries.
601      let mut eit = rest.splitn(6, '\t');
602      let alias_field = eit.next().ok_or("Incomplete Expandable entry")?;
603      let nargs_field = eit.next().ok_or("Incomplete Expandable entry")?;
604      let flags_field = eit.next().ok_or("Incomplete Expandable entry")?;
605      let tok_field = eit.next().ok_or("Incomplete Expandable entry")?;
606      let proto_field = eit.next();
607      let v3_field = eit.next();
608
609      // eparts[0] is the alias-cs from the dump (Perl-side: the cs of
610      // the Definition object that this entry was let-aliased from).
611      //
612      // We propagate the alias ONLY when the target is a known deferred
613      // command (`\unexpanded`, `\the`, `\detokenize`, `\showthe`) — that
614      // narrow case is what makes `\exp_not:n {…}` inside `\edef` bodies
615      // correctly skip re-expansion (Perl `Gullet.pm:505`'s DEFERRED
616      // path), preserving `\__seq_item:n {…}` inside `\seq_gpush:Nn`'s
617      // `\unexpanded`-wrapped body. Without this, the seq stack stays
618      // empty after push, leading to `extra-pop-label` and the
619      // `\q_no_value` recursion cascade during `\@pushfilename`.
620      //
621      // We DON'T propagate alias for the ~1k other Lt-aliased entries
622      // (e.g. `\bool_if_exist:NTF` → `\cs_if_exist:NTF`) — those would
623      // change `defn.get_cs_name()`'s return value, which feeds into
624      // many lookup paths and triggers infinite-loop regressions in
625      // `\@nil` handling, etc. Keep blast radius tight.
626      const DEFERRED_NAMES: &[&str] = &["\\unexpanded", "\\the", "\\detokenize", "\\showthe"];
627      let alias_decoded = url_decode(alias_field);
628      let is_alias_diff = cs_tok.with_cs_name(|s| s != alias_decoded.as_str());
629      let alias_for_traits = if is_alias_diff && DEFERRED_NAMES.contains(&alias_decoded.as_str()) {
630        Some(alias_decoded)
631      } else {
632        None
633      };
634      let nargs: usize = nargs_field.parse().unwrap_or(0);
635      let flags = flags_field;
636      let tok_data = tok_field;
637      let proto_opt = proto_field.map(url_decode).filter(|s| !s.is_empty());
638      let v3_opt = v3_field.filter(|s| !s.is_empty());
639
640      let is_long = flags.contains('L');
641      let is_protected = flags.contains('P');
642
643      let expansion = parse_token_list(tok_data)?;
644
645      // Build parameter spec, preferring v3 structured → v2 proto →
646      // v1 nargs-repeat fallback. init_flag=true for both fallbacks:
647      // state is live at runtime so Parameter::init() can resolve
648      // readers via PARAMETER_TYPES.
649      let paramlist = if let Some(v3) = v3_opt {
650        match parse_parameters_v3(v3) {
651          Ok(pl) => pl,
652          Err(_) => proto_opt
653            .as_ref()
654            .and_then(|p| crate::common::def_parser::parse_parameters(p, &cs_tok, true).ok())
655            .flatten()
656            .or_else(|| {
657              if nargs > 0 {
658                let fallback = "{}".repeat(nargs);
659                crate::common::def_parser::parse_parameters(&fallback, &cs_tok, true)
660                  .ok()
661                  .flatten()
662              } else {
663                None
664              }
665            }),
666        }
667      } else {
668        // v2 path: no v3 field, fall back to proto-parsing (with the
669        // original silent-degrade-to-nargs behavior).
670        match proto_opt {
671          Some(proto) => match crate::common::def_parser::parse_parameters(&proto, &cs_tok, true) {
672            Ok(pl) => pl,
673            Err(_) if nargs > 0 => {
674              let fallback = "{}".repeat(nargs);
675              crate::common::def_parser::parse_parameters(&fallback, &cs_tok, true)
676                .map_err(|e| format!("Param parse fallback: {}", e))?
677            },
678            Err(_) => None,
679          },
680          None if nargs > 0 => {
681            let proto = "{}".repeat(nargs);
682            crate::common::def_parser::parse_parameters(&proto, &cs_tok, true)
683              .map_err(|e| format!("Param parse: {}", e))?
684          },
685          None => None,
686        }
687      };
688
689      let options = Some(ExpandableOptions {
690        long: is_long,
691        protected: is_protected,
692        nopack_parameters: true, // tokens already have ARG catcode
693        alias: alias_for_traits,
694        ..ExpandableOptions::default()
695      });
696
697      let expansion_body = Tokens::from(expansion).into();
698      match Expandable::new(cs_tok, paramlist, Some(expansion_body), options) {
699        Ok(mut exp) => {
700          // Perl #aaacdba2: stamp dump-loaded definitions with a
701          // nominal Locator pointing at the dump file + line. Helps
702          // diagnostics attribute errors to the dump source rather
703          // than the arena's compile-site default.
704          exp.locator = current_dump_locator();
705          // Perl `I()` (`Core/Dumper.pm` L67):
706          //   sub I { State::assign_internal($STATE,'meaning',
707          //           $_[0]->getCSName, $_[0], 'global'); }
708          // Direct table mutation — no `:locked` gate, no add-only.
709          // CONFIRMED via probe (2026-04-27): Perl dump load bypasses
710          // the :locked gate. `installDefinition` (State.pm L502-517)
711          // checks :locked and refuses; `assign_internal` (State.pm
712          // L140) does not. Dumper's `I` shorthand calls `assign_internal`
713          // directly, so locked defs ARE silently overwritten by dump.
714          // Rust matches: this code calls `state::assign_internal`, not
715          // `install_definition`. Verified `\hidewidth` and `\leavevmode`
716          // get overwritten by dump entries despite earlier bootstrap defs.
717          state::assign_internal(
718            TableName::Meaning,
719            cs_tok.get_cs_name(),
720            Stored::from(exp),
721            Some(Scope::Global),
722          );
723          Ok(true)
724        },
725        Err(e) => Err(format!("Expandable creation failed: {}", e)),
726      }
727    },
728    "T" => {
729      // Token meaning. Perl `Im()` (`Core/Dumper.pm` L66):
730      //   sub Im { State::assign_internal($STATE,'meaning',
731      //            $_[0], $_[1], 'global'); }
732      // Direct write — no `\let`-chase, no chain follow.
733      let tok = parse_token(rest)?;
734      state::assign_internal(
735        TableName::Meaning,
736        cs_tok.get_cs_name(),
737        Stored::Token(tok),
738        Some(Scope::Global),
739      );
740      Ok(true)
741    },
742    "FD" => {
743      // FontDef: `FD\t<font_id>` — Perl `dump_primitive` (Core/Dumper.pm L383-389)
744      // emits this for `\font`-defined primitives. Install a Primitive whose
745      // `before_digest` mirrors `LaTeXML::Core::Definition::FontDef::invoke`
746      // (FontDef.pm L38-45):
747      //   1. lookup the fontinfo hash at <font_id>
748      //   2. assignValue(current_FontDef => $cs)
749      //   3. merge the font into $STATE->lookupValue('font')
750      // The fontinfo Stored::Font rides through the dump as a `V` entry with
751      // `F\t...` payload (see Stored::Font arm in dump_writer + the `F` arm
752      // in parse_value above).
753      use crate::definition::{BeforeDigestClosure, primitive::Primitive};
754      let font_id_raw = url_decode(rest);
755      let font_id_pin = arena::pin(&font_id_raw);
756      let font_id_str = font_id_raw;
757      let cs_for_fontdef = cs_tok;
758      let merge_closure: BeforeDigestClosure = std::rc::Rc::new(move || {
759        state::assign_value("current_FontDef", Stored::Token(cs_for_fontdef), None);
760        if let Some(Stored::Font(f)) = state::lookup_value(&font_id_str) {
761          crate::binding::content::merge_font((*f).clone());
762        }
763        Ok(Vec::new())
764      });
765      let prim = Primitive {
766        cs: cs_tok,
767        before_digest: vec![merge_closure],
768        font_id: Some(font_id_pin),
769        ..Primitive::default()
770      };
771      state::assign_internal(
772        TableName::Meaning,
773        cs_tok.get_cs_name(),
774        Stored::Primitive(std::rc::Rc::new(prim)),
775        Some(Scope::Global),
776      );
777      Ok(true)
778    },
779    "PA" | "MPA" => {
780      // Primitive alias: PA\t<target_cs> — the entry's meaning is an
781      // Rc<Primitive> whose own cs is <target_cs>. If <target_cs> == key
782      // this is the "primary" entry (already provided by compiled bindings
783      // in _base.rs etc.); skip. Otherwise, replay `\let <key> <target>`
784      // so the Rc<Primitive> is shared just as it was when the dump was
785      // generated. This is how \tex_let:D, \tex_def:D, etc. survive the
786      // dump — without this the expl3.sty short-circuit guard
787      // `\ifx\csname tex_let:D\endcsname\relax` never fires and the 36k
788      // lines of expl3-code.tex get reprocessed on every run.
789      let target_cs_raw = url_decode(rest);
790      if target_cs_raw == key {
791        return Ok(false);
792      }
793      let target_tok = Token {
794        text: arena::pin(&target_cs_raw),
795        code: Catcode::CS,
796        #[cfg(feature = "token-locators")]
797        loc: 0,
798      };
799      // Perl `Lt()` (`Core/Dumper.pm` L69-72):
800      //   sub Lt { my $d = State::lookupDefinition($STATE, T_CS($_[1]));
801      //            State::assign_internal($STATE,'meaning',$_[0],$d,'global'); }
802      // Look up the target's current Meaning entry, then writes that
803      // very Stored value at the alias key. Sharing the Rc preserves
804      // identity (e.g. \let\tex_let:D\let keeps the same Primitive Rc).
805      //
806      // If the target is not yet defined (load order has _constructs
807      // running after the dump for some let-aliases — e.g.
808      // `\let\a=\@tabacckludge`), defer until flush_deferred_aliases().
809      if !state::has_meaning(&target_tok) {
810        DEFERRED_ALIASES.with(|cell| {
811          cell.borrow_mut().push((cs_tok, target_tok));
812        });
813        return Ok(false);
814      }
815      let target_meaning = state::lookup_meaning(&target_tok);
816      if let Some(meaning) = target_meaning {
817        state::assign_internal(
818          TableName::Meaning,
819          cs_tok.get_cs_name(),
820          meaning,
821          Some(Scope::Global),
822        );
823      }
824      Ok(true)
825    },
826    "R" => {
827      // Register: R\tCS\tTYPE\tVALUE[\tMATHGLYPH][\tADDRESS]
828      // rparts[0] (internal CS name) is redundant with the outer key —
829      // same reasoning as the E arm; we skip the decode + alloc.
830      // ADDRESS field is a url-encoded address-slot key for allocated
831      // registers (Perl `\newcount\m@ne` → address='\count22'). When
832      // absent, address defaults to the CS name. Without this, dump_reader
833      // wrote `\m@ne`'s -1 to its CS-name slot, but `\m@ne`'s actual
834      // address (`\count22`) held the default 0 — `\settabs 20\columns`
835      // looped infinitely because `\m@ne == 0` never advanced `\count@`
836      // toward 0 in `\loop\ifnum\count@>\z@\@nother\repeat`.
837      // Direct iter destructure, mirroring the E-branch pattern.
838      let mut rit = rest.splitn(5, '\t');
839      let r_cs = rit.next().ok_or("Incomplete Register entry")?;
840      let r_type = rit.next().ok_or("Incomplete Register entry")?;
841      let r_value = rit.next().ok_or("Incomplete Register entry")?;
842      let r_glyph_field = rit.next();
843      let r_addr_field = rit.next();
844      let rtype = r_type;
845      let value_str = r_value;
846      let mathglyph = r_glyph_field
847        .filter(|s| !s.is_empty())
848        .and_then(|s| s.parse::<u32>().ok())
849        .and_then(char::from_u32);
850      let dump_address: Option<String> = r_addr_field.filter(|s| !s.is_empty()).map(url_decode);
851      // For register-aliases (M-line key != register's internal cs), the
852      // storage slot lives at the cs name, not the alias key. e.g.
853      //   M  \tex_endlinechar:D  R  \endlinechar  N  0
854      // means "\tex_endlinechar:D" is meaning-installed but the underlying
855      // register storage is at "\endlinechar". Without this, assignments
856      // through the alias (\tex_endlinechar:D = 32) write to a separate
857      // slot and the real \endlinechar stays unchanged — breaking
858      // \ExplSyntaxOn's `\tex_endlinechar:D = 32 \scan_stop:` line, which
859      // in turn breaks the entire dump-path expl3 whitespace handling
860      // (8 expl3 tests). Mirror Perl's address-via-internal-cs semantics.
861      let internal_cs_decoded = url_decode(r_cs);
862      let dump_address: Option<String> = dump_address.or_else(|| {
863        if internal_cs_decoded != *key && !internal_cs_decoded.is_empty() {
864          Some(internal_cs_decoded)
865        } else {
866          None
867        }
868      });
869
870      use crate::{
871        common::number::Number,
872        definition::register::{Register, RegisterType, RegisterValue},
873      };
874
875      let (reg_type, reg_value) = match rtype {
876        "N" | "CD" => {
877          let n: i64 = value_str.parse().unwrap_or(0);
878          let rt = if rtype == "CD" {
879            RegisterType::CharDef
880          } else {
881            RegisterType::Number
882          };
883          (rt, Some(RegisterValue::Number(Number::new(n))))
884        },
885        "D" => {
886          let n: i64 = value_str.parse().unwrap_or(0);
887          (
888            RegisterType::Dimension,
889            Some(RegisterValue::Dimension(
890              crate::common::dimension::Dimension(n),
891            )),
892          )
893        },
894        "G" => (
895          RegisterType::Glue,
896          Some(RegisterValue::Glue(parse_glue(value_str)?)),
897        ),
898        "MG" => (
899          RegisterType::MuGlue,
900          Some(RegisterValue::MuGlue(parse_muglue(value_str)?)),
901        ),
902        "TK" => {
903          // Token register: value is comma-separated token list, or "0" for empty
904          let toks = if value_str == "0" || value_str.is_empty() {
905            Vec::new()
906          } else {
907            parse_token_list(value_str)?
908          };
909          (
910            RegisterType::Tokens,
911            Some(RegisterValue::Tokens(Tokens::from(toks))),
912          )
913        },
914        _ => return Ok(false),
915      };
916
917      // Perl-parity with def_register (binding/def/dialect.rs): store the
918      // initial value at the Register's `address` slot AND set `default`, so
919      // a subsequent `value_of` lookup — which reads state::with_value(address)
920      // and falls back to `default` — actually sees the dump's initial value.
921      // CharDefs read their immediate `value` field instead, so we skip the
922      // storage write for them.
923      let mut reg = Register {
924        cs: cs_tok,
925        register_type: reg_type,
926        value: reg_value.clone(),
927        default: if matches!(reg_type, RegisterType::CharDef) {
928          None
929        } else {
930          reg_value.clone()
931        },
932        mathglyph,
933        locator: current_dump_locator(),
934        ..Register::default()
935      };
936      // Set address: prefer dump-supplied address (allocated registers),
937      // fall back to CS name (direct registers like `\count1`).
938      let has_explicit_address = dump_address.is_some();
939      reg.address = dump_address.unwrap_or_else(|| key.to_string());
940      // Copy parameters from the base register at the address slot if
941      // present. The dump R-line carries no parameter spec, so without
942      // this an alias like `\tex_skip:D` (R \skip G 0) loses the
943      // `Number` index parameter that the base `\skip` register has.
944      // At digest time this caused `\tex_skip:D 0 = ... sp \scan_stop:`
945      // to skip the index reading entirely — the `0`, `=`, and rest got
946      // treated as a glue value and stranded tokens. Driver: expl3
947      // regex VM through `\__tl_analysis_a_store:`. See
948      // project_expl3_regex_vm_engine.md item #2.
949      if reg.parameters.is_none() && reg.address != key {
950        let address_tok = Token {
951          text: arena::pin(&reg.address),
952          code: Catcode::CS,
953          #[cfg(feature = "token-locators")]
954          loc: 0,
955        };
956        if let Some(base_defn) = state::lookup_register_definition(&address_tok)
957          && let Some(params) = base_defn.parameters.clone()
958        {
959          reg.parameters = Some(params);
960        }
961      }
962      if !matches!(reg_type, RegisterType::CharDef)
963        && let Some(ref rv) = reg_value
964      {
965        // Perl `R(...)` register dump-restore: the address slot
966        // gets the initial value via `assign_internal('value', ...,
967        // 'global')`. Mirror Perl `def_register` behavior: when the
968        // address is allocated (different from CS) AND already has a
969        // value (from an earlier V entry), DO NOT overwrite — the V
970        // entry holds the runtime value (e.g. `\m@ne`'s `\count22 =
971        // -1`), and the Register's `value` field is just the default
972        // (typically 0). Without this guard, the M entry resets
973        // `\count22` to 0, breaking `\settabs 20\columns` (loops
974        // because `\m@ne` reads as 0 instead of -1, so
975        // `\advance\count@\m@ne` doesn't decrement).
976        let should_assign = !has_explicit_address || !state::has_value(&reg.address);
977        if should_assign {
978          state::assign_internal(
979            TableName::Value,
980            arena::pin(&reg.address),
981            rv.clone(),
982            Some(Scope::Global),
983          );
984        }
985      }
986      // Perl `I(...)` for the Register meaning entry — direct
987      // `assign_internal('meaning', ..., 'global')`, bypassing the
988      // `:locked` and add-only checks of install_definition.
989      state::assign_internal(
990        TableName::Meaning,
991        cs_tok.get_cs_name(),
992        Stored::from(reg),
993        Some(Scope::Global),
994      );
995      Ok(true)
996    },
997    _ => Ok(false),
998  }
999}
1000
1001/// Decode a character key from the dump. Handles:
1002/// - Single characters: "A", "è"
1003/// - URL-encoded control chars: "%19" (→ char 0x19), "%0A" (→ char 0x0A)
1004fn decode_char_key(key: &str) -> Option<char> {
1005  let decoded = url_decode(key);
1006  decoded.chars().next()
1007}
1008
1009/// Char-keyed table key: dump uses the single character as the key.
1010/// `assign_internal` wants a SymStr — pin the single-char string.
1011fn char_key(ch: char) -> arena::SymStr {
1012  let mut buf = [0u8; 4];
1013  arena::pin(ch.encode_utf8(&mut buf))
1014}
1015
1016/// Load a catcode entry: C\tCHAR\tCC\tVALUE.
1017/// Perl `Cc()` (`Core/Dumper.pm` L60): `assign_internal('catcode', ..., 'global')`.
1018fn load_catcode(key: &str, data: &str) -> Result<bool, String> {
1019  let ch = decode_char_key(key).ok_or_else(|| format!("Bad catcode char: {}", key))?;
1020  let (tag, val_str) = data
1021    .split_once('\t')
1022    .ok_or_else(|| format!("Bad catcode data: {}", data))?;
1023  if tag != "CC" {
1024    return Err(format!("Bad catcode data: {}", data));
1025  }
1026  let cc: u8 = val_str
1027    .parse()
1028    .map_err(|e| format!("Bad catcode value: {}", e))?;
1029  state::assign_internal(
1030    TableName::Catcode,
1031    char_key(ch),
1032    Stored::Catcode(Catcode::from(cc)),
1033    Some(Scope::Global),
1034  );
1035  Ok(true)
1036}
1037
1038/// Load a lccode entry: LC\tCHAR\tCH\tVALUE.
1039/// Perl `Lc()` (`Core/Dumper.pm` L63): `assign_internal('lccode', ..., 'global')`.
1040fn load_lccode(key: &str, data: &str) -> Result<bool, String> {
1041  let ch = decode_char_key(key).ok_or_else(|| format!("Bad lccode char: {}", key))?;
1042  let (tag, val_str) = data
1043    .split_once('\t')
1044    .ok_or_else(|| format!("Bad lccode data: {}", data))?;
1045  if tag != "CH" {
1046    return Err(format!("Bad lccode data: {}", data));
1047  }
1048  let val: u16 = val_str
1049    .parse()
1050    .map_err(|e| format!("Bad lccode value: {}", e))?;
1051  state::assign_internal(
1052    TableName::Lccode,
1053    char_key(ch),
1054    Stored::Charcode(val),
1055    Some(Scope::Global),
1056  );
1057  Ok(true)
1058}
1059
1060/// Load a uccode entry: UC\tCHAR\tCH\tVALUE.
1061/// Perl `Uc()` (`Core/Dumper.pm` L64): `assign_internal('uccode', ..., 'global')`.
1062fn load_uccode(key: &str, data: &str) -> Result<bool, String> {
1063  let ch = decode_char_key(key).ok_or_else(|| format!("Bad uccode char: {}", key))?;
1064  let (tag, val_str) = data
1065    .split_once('\t')
1066    .ok_or_else(|| format!("Bad uccode data: {}", data))?;
1067  if tag != "CH" {
1068    return Err(format!("Bad uccode data: {}", data));
1069  }
1070  let val: u16 = val_str
1071    .parse()
1072    .map_err(|e| format!("Bad uccode value: {}", e))?;
1073  state::assign_internal(
1074    TableName::Uccode,
1075    char_key(ch),
1076    Stored::Charcode(val),
1077    Some(Scope::Global),
1078  );
1079  Ok(true)
1080}
1081
1082/// Load an sfcode entry: SC\tCHAR\tCH\tVALUE.
1083/// Perl `Sc()` (`Core/Dumper.pm` L62): `assign_internal('sfcode', ..., 'global')`.
1084fn load_sfcode(key: &str, data: &str) -> Result<bool, String> {
1085  let ch = decode_char_key(key).ok_or_else(|| format!("Bad sfcode char: {}", key))?;
1086  let (tag, val_str) = data
1087    .split_once('\t')
1088    .ok_or_else(|| format!("Bad sfcode data: {}", data))?;
1089  if tag != "CH" {
1090    return Err(format!("Bad sfcode data: {}", data));
1091  }
1092  let val: u16 = val_str
1093    .parse()
1094    .map_err(|e| format!("Bad sfcode value: {}", e))?;
1095  state::assign_internal(
1096    TableName::Sfcode,
1097    char_key(ch),
1098    Stored::Charcode(val),
1099    Some(Scope::Global),
1100  );
1101  Ok(true)
1102}
1103
1104/// Load a delcode entry: DC\tCHAR\tCH\tVALUE
1105/// Mirrors Perl `Core/Dumper.pm:dump_delcode` round-trip.
1106fn load_delcode(key: &str, data: &str) -> Result<bool, String> {
1107  let ch = decode_char_key(key).ok_or_else(|| format!("Bad delcode char: {}", key))?;
1108  let (tag, val_str) = data
1109    .split_once('\t')
1110    .ok_or_else(|| format!("Bad delcode data: {}", data))?;
1111  if tag != "CH" {
1112    return Err(format!("Bad delcode data: {}", data));
1113  }
1114  let val: u16 = val_str
1115    .parse()
1116    .map_err(|e| format!("Bad delcode value: {}", e))?;
1117  state::assign_delcode(ch, val, Some(Scope::Global));
1118  Ok(true)
1119}
1120
1121/// Load a mathcode entry: MC\tCHAR\tCH\tVALUE
1122/// Mirrors Perl `Core/Dumper.pm:dump_mathcode` round-trip.
1123fn load_mathcode(key: &str, data: &str) -> Result<bool, String> {
1124  let ch = decode_char_key(key).ok_or_else(|| format!("Bad mathcode char: {}", key))?;
1125  let (tag, val_str) = data
1126    .split_once('\t')
1127    .ok_or_else(|| format!("Bad mathcode data: {}", data))?;
1128  if tag != "CH" {
1129    return Err(format!("Bad mathcode data: {}", data));
1130  }
1131  let val: u16 = val_str
1132    .parse()
1133    .map_err(|e| format!("Bad mathcode value: {}", e))?;
1134  state::assign_mathcode(ch, val, Some(Scope::Global));
1135  Ok(true)
1136}
1137
1138/// Parse a single token from "CC:TEXT" format
1139fn parse_token(s: &str) -> Result<Token, String> {
1140  let (cc_str, text) = s.split_once(':').ok_or("Missing ':' in token")?;
1141  let cc: u8 = cc_str.parse().map_err(|e| format!("Bad CC: {}", e))?;
1142  // Fast path: most token text fields have no `%` escapes, so pin the
1143  // &str directly — avoids the String allocation url_decode would make
1144  // even on its own fast path. Parsing the expl3 kernel alone produces
1145  // hundreds of thousands of token entries; every `to_owned()` avoided
1146  // here matters.
1147  let text_sym = if text.contains('%') {
1148    arena::pin(url_decode(text))
1149  } else {
1150    arena::pin(text)
1151  };
1152  Ok(Token {
1153    text: text_sym,
1154    code: Catcode::from(cc),
1155    #[cfg(feature = "token-locators")]
1156    loc: 0,
1157  })
1158}
1159
1160/// Parse comma-separated token list
1161fn parse_token_list(s: &str) -> Result<Vec<Token>, String> {
1162  if s.is_empty() {
1163    return Ok(Vec::new());
1164  }
1165  // Pre-size the Vec. Avg ~19.6 tokens/list across the 16k E-entries
1166  // in latex.dump; the default `.collect()` size_hint is (0, None) so
1167  // Vec resizes ~log2(19) ≈ 5 times per call. Counting commas first
1168  // (one extra pass over the str) eliminates those re-allocs.
1169  let n = s.bytes().filter(|b| *b == b',').count() + 1;
1170  let mut out = Vec::with_capacity(n);
1171  for tok in s.split(',') {
1172    out.push(parse_token(tok)?);
1173  }
1174  Ok(out)
1175}
1176
1177/// Decode the v3 structured Parameters encoding emitted by
1178/// `dump_writer::serialize_parameters_v3` (see that function's docstring
1179/// and `docs/archive/DUMP_FORMAT_PERL_ANALYSIS_2026-04-30.md` for the layout).
1180///
1181/// Returns `Ok(None)` for an empty record (no parameters); `Ok(Some(ps))`
1182/// on success; `Err` if any record is malformed. Each Parameter is
1183/// constructed via `Parameter::new(name, spec, Some(extras))`, which
1184/// calls `init()` — the reader function is resolved against the live
1185/// PARAMETER_TYPES table, mirroring the runtime path.
1186pub(crate) fn parse_parameters_v3(
1187  v3: &str,
1188) -> Result<Option<crate::parameter::Parameters>, String> {
1189  if v3.is_empty() {
1190    return Ok(None);
1191  }
1192  let mut params = Vec::new();
1193  for record in v3.split('\x1e') {
1194    // <name>\x1f<spec>\x1f<flags>\x1f<extras>
1195    let fields: Vec<&str> = record.splitn(4, '\x1f').collect();
1196    if fields.len() != 4 {
1197      return Err(format!(
1198        "v3 Parameter record has {} fields, expected 4",
1199        fields.len()
1200      ));
1201    }
1202    let name = url_decode(fields[0]);
1203    let spec = url_decode(fields[1]);
1204    let flags = fields[2];
1205    let extras_str = fields[3];
1206
1207    let extras = if extras_str.is_empty() {
1208      Vec::new()
1209    } else {
1210      extras_str
1211        .split('\x1d')
1212        .map(|tok_list| parse_token_list(tok_list).map(Tokens::new))
1213        .collect::<Result<Vec<_>, _>>()?
1214    };
1215
1216    let mut param = crate::parameter::Parameter::new(name, spec, Some(extras))
1217      .map_err(|e| format!("Parameter::new failed: {}", e))?;
1218
1219    // Apply flags after construction — Parameter::new + init() handle
1220    // the reader side, but novalue/optional are struct-level booleans
1221    // that the spec-driven init() may or may not have set (e.g. "Optional"
1222    // prefix auto-sets optional, but we want explicit round-trip).
1223    for flag in flags.split(';').filter(|s| !s.is_empty()) {
1224      match flag {
1225        "n=1" => param.novalue = true,
1226        "o=1" => param.optional = true,
1227        _ => {
1228          // Unknown flag — ignore for forward compat; future flags
1229          // added to the writer shouldn't break older readers.
1230        },
1231      }
1232    }
1233    params.push(param);
1234  }
1235  if params.is_empty() {
1236    Ok(None)
1237  } else {
1238    Ok(Some(crate::parameter::Parameters::new(params)))
1239  }
1240}
1241
1242pub(crate) fn url_decode(s: &str) -> String {
1243  // Fast path: the overwhelming majority of dump entries have no `%`
1244  // escapes in their key or proto fields, so a single memcpy via
1245  // `to_owned()` beats char-by-char iteration for ~19k key loads.
1246  if !s.contains('%') {
1247    return s.to_owned();
1248  }
1249  let mut result = String::with_capacity(s.len());
1250  let mut chars = s.chars();
1251  while let Some(ch) = chars.next() {
1252    if ch == '%' {
1253      let hex: String = chars.by_ref().take(2).collect();
1254      if let Ok(byte) = u8::from_str_radix(&hex, 16) {
1255        result.push(byte as char);
1256      }
1257    } else {
1258      result.push(ch);
1259    }
1260  }
1261  result
1262}
1263
1264/// Parse a serialized Glue value: "skip,pN,pfN,mN,mfN"
1265fn parse_glue(s: &str) -> Result<crate::common::glue::Glue, String> {
1266  use crate::common::glue::{FillCode, Glue};
1267  let mut skip = 0i64;
1268  let mut plus = None;
1269  let mut pfill = None;
1270  let mut minus = None;
1271  let mut mfill = None;
1272  for (i, part) in s.split(',').enumerate() {
1273    if i == 0 {
1274      skip = part.parse().map_err(|e| format!("Bad glue skip: {}", e))?;
1275    } else if let Some(rest) = part.strip_prefix("pf") {
1276      pfill = FillCode::new(rest.parse::<usize>().unwrap_or(0));
1277    } else if let Some(rest) = part.strip_prefix('p') {
1278      plus = Some(rest.parse().map_err(|e| format!("Bad glue plus: {}", e))?);
1279    } else if let Some(rest) = part.strip_prefix("mf") {
1280      mfill = FillCode::new(rest.parse::<usize>().unwrap_or(0));
1281    } else if let Some(rest) = part.strip_prefix('m') {
1282      minus = Some(rest.parse().map_err(|e| format!("Bad glue minus: {}", e))?);
1283    }
1284  }
1285  Ok(Glue {
1286    skip,
1287    plus,
1288    pfill,
1289    minus,
1290    mfill,
1291  })
1292}
1293
1294/// Parse a serialized MuGlue value (same format as Glue)
1295fn parse_muglue(s: &str) -> Result<crate::common::muglue::MuGlue, String> {
1296  use crate::common::{glue::FillCode, muglue::MuGlue};
1297  let mut skip = 0i64;
1298  let mut plus = None;
1299  let mut pfill = None;
1300  let mut minus = None;
1301  let mut mfill = None;
1302  for (i, part) in s.split(',').enumerate() {
1303    if i == 0 {
1304      skip = part
1305        .parse()
1306        .map_err(|e| format!("Bad muglue skip: {}", e))?;
1307    } else if let Some(rest) = part.strip_prefix("pf") {
1308      pfill = FillCode::new(rest.parse::<usize>().unwrap_or(0));
1309    } else if let Some(rest) = part.strip_prefix('p') {
1310      plus = Some(
1311        rest
1312          .parse()
1313          .map_err(|e| format!("Bad muglue plus: {}", e))?,
1314      );
1315    } else if let Some(rest) = part.strip_prefix("mf") {
1316      mfill = FillCode::new(rest.parse::<usize>().unwrap_or(0));
1317    } else if let Some(rest) = part.strip_prefix('m') {
1318      minus = Some(
1319        rest
1320          .parse()
1321          .map_err(|e| format!("Bad muglue minus: {}", e))?,
1322      );
1323    }
1324  }
1325  Ok(MuGlue {
1326    skip,
1327    plus,
1328    pfill,
1329    minus,
1330    mfill,
1331  })
1332}
1333
1334#[cfg(test)]
1335mod tests {
1336  use super::*;
1337
1338  #[test]
1339  fn test_load_native_dump_inline() {
1340    // Test with inline tab-separated dump content (no external file dependency)
1341    let content = "V\tcount@\tI\t42\nM\t\\mymacro\tE\t\\mymacro\t1\t\t6:1,6:2\n";
1342    let count = load_from_str(content).unwrap();
1343    assert!(
1344      count > 0,
1345      "Expected entries loaded from inline dump, got {}",
1346      count
1347    );
1348  }
1349
1350  #[test]
1351  fn test_catcode_loading_nonascii() {
1352    // Only non-ASCII catcodes are loaded from the dump
1353    let content = "C\t\u{00e8}\tCC\t12\n"; // è → catcode 12 (OTHER)
1354    let count = load_from_str(content).unwrap();
1355    assert!(count > 0, "Expected non-ASCII catcode entry loaded");
1356  }
1357
1358  #[test]
1359  fn test_lccode_loading() {
1360    let content = "LC\t\u{00c8}\tCH\t232\n"; // È → lccode 232 (è)
1361    let count = load_from_str(content).unwrap();
1362    assert!(count > 0, "Expected lccode entry loaded");
1363  }
1364
1365  // --- RLE decoder tests (intarray consolidation) ---
1366
1367  #[test]
1368  fn rle_decode_empty() {
1369    assert_eq!(rle_decode_i64("").unwrap(), Vec::<i64>::new());
1370  }
1371
1372  #[test]
1373  fn rle_decode_single() {
1374    assert_eq!(rle_decode_i64("5").unwrap(), vec![5]);
1375  }
1376
1377  #[test]
1378  fn rle_decode_single_run() {
1379    assert_eq!(rle_decode_i64("5x3").unwrap(), vec![5, 5, 5]);
1380  }
1381
1382  #[test]
1383  fn rle_decode_mixed() {
1384    assert_eq!(rle_decode_i64("1,2x2,3x3,1").unwrap(), vec![
1385      1, 2, 2, 3, 3, 3, 1
1386    ]);
1387  }
1388
1389  #[test]
1390  fn rle_decode_negative() {
1391    assert_eq!(rle_decode_i64("-5").unwrap(), vec![-5]);
1392    assert_eq!(rle_decode_i64("-5x3").unwrap(), vec![-5, -5, -5]);
1393  }
1394
1395  #[test]
1396  fn rle_decode_long_run() {
1397    let v = rle_decode_i64("218x10000").unwrap();
1398    assert_eq!(v.len(), 10000);
1399    assert!(v.iter().all(|&x| x == 218));
1400  }
1401
1402  #[test]
1403  fn rle_decode_malformed_returns_err() {
1404    assert!(rle_decode_i64("abc").is_err());
1405    assert!(rle_decode_i64("5xabc").is_err());
1406    assert!(rle_decode_i64("5x").is_err());
1407  }
1408
1409  // --- IA load → state assignment tests ---
1410
1411  #[test]
1412  fn ia_load_writes_per_slot_values() {
1413    // Use a unique prefix so the test doesn't collide with the engine's
1414    // ambient state (other tests may have populated fontdimen_* keys).
1415    let prefix = "ia_test_prefix";
1416    let content = format!("IA\t{}\t3\t10,20x2\n", prefix);
1417    load_from_str(&content).unwrap();
1418
1419    use crate::{
1420      common::{dimension::Dimension, store::Stored},
1421      state,
1422    };
1423
1424    assert_eq!(
1425      state::lookup_value(&format!("{}_1", prefix)),
1426      Some(Stored::Dimension(Dimension(10)))
1427    );
1428    assert_eq!(
1429      state::lookup_value(&format!("{}_2", prefix)),
1430      Some(Stored::Dimension(Dimension(20)))
1431    );
1432    assert_eq!(
1433      state::lookup_value(&format!("{}_3", prefix)),
1434      Some(Stored::Dimension(Dimension(20)))
1435    );
1436    // One past the end should NOT be set by the IA record.
1437    assert_eq!(state::lookup_value(&format!("{}_4", prefix)), None);
1438  }
1439
1440  #[test]
1441  fn ia_load_length_mismatch_errors() {
1442    // Declared len 5 but RLE only decodes to 3 → error
1443    let content = "IA\tia_mismatch_prefix\t5\t10,20,30\n";
1444    // load_from_str collects per-line errors; verify the malformed IA
1445    // line did NOT successfully load anything.
1446    let count = load_from_str(content).unwrap_or(0);
1447    assert_eq!(count, 0, "Length-mismatch IA should not load");
1448  }
1449
1450  // --- Backward-compat: V-records-only dumps (pre-IA format) ---
1451
1452  #[test]
1453  fn v_record_dimension_still_loads() {
1454    // This is the pre-IA storage format: one V record per slot.
1455    // dump_reader must still accept these so older / partner-machine
1456    // dumps load correctly.
1457    let prefix = "v_backcompat_prefix";
1458    let content = format!("V\t{}_1\tD\t111\nV\t{}_2\tD\t222\n", prefix, prefix);
1459    load_from_str(&content).unwrap();
1460
1461    use crate::{
1462      common::{dimension::Dimension, store::Stored},
1463      state,
1464    };
1465
1466    assert_eq!(
1467      state::lookup_value(&format!("{}_1", prefix)),
1468      Some(Stored::Dimension(Dimension(111)))
1469    );
1470    assert_eq!(
1471      state::lookup_value(&format!("{}_2", prefix)),
1472      Some(Stored::Dimension(Dimension(222)))
1473    );
1474  }
1475}