Skip to main content

latexml_core/binding/
content.rs

1use std::{borrow::Cow, collections::VecDeque, path::Path, rc::Rc};
2
3use once_cell::sync::Lazy;
4use regex::Regex;
5use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
6
7// use crate::util::pathname::PathnameFindOptions;
8use crate::Digested;
9use crate::{
10  binding::def::dialect::def_macro,
11  common::{
12    BindingSource, arena,
13    arena::SymStr,
14    error::{emit_error, *},
15    font::{Font, Fontmap},
16    model,
17  },
18  definition::expandable::ExpandableOptions,
19  document::{
20    resource::*,
21    tag::{TagOptionName, TagOptions},
22  },
23  gullet,
24  gullet::do_expand,
25  mouth::{Mouth, MouthOptions},
26  parameter::{Parameter, Parameters},
27  pin,
28  state::{let_i, *},
29  stomach::*,
30  token::*,
31  tokens::{TeXString, Tokens},
32  util::pathname::{self, PathnameFindOptions},
33};
34
35static QUOTE_WRAPPED: Lazy<Regex> = Lazy::new(|| Regex::new("^\"(.+)\"$").unwrap());
36
37/// Maximum nesting depth for package/class loading to prevent infinite recursion.
38/// Perl LaTeXML has no explicit limit but rarely exceeds 20 levels in practice.
39const MAX_INPUT_DEPTH: usize = 500;
40
41thread_local! {
42  /// Current nesting depth of input_definitions calls.
43  static INPUT_DEPTH: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
44  /// Stack of each active input_definitions frame's `grandparent_in_expl3` flag.
45  /// A native binding that force-raw-loads its own `\ProvidesExplPackage` .sty via
46  /// `InputDefinitions!(noltxml => true)` (e.g. derivative_sty.rs, #630) re-enters
47  /// input_definitions AFTER the outer `\@pushfilename` already ran `\ExplSyntaxOff`
48  /// (`_` -> SUB). A fresh snapshot then reads false, so the exit cleanup over-fires
49  /// `\ExplSyntaxOff` and corrupts `\l__expl_status_stack_tl`. The inner frame
50  /// inherits the outer's flag instead. Witness arXiv:2605.21946 (pomegranate.sty
51  /// -> `\RequirePackage{derivative}`).
52  static INPUT_DEF_EXPL3: std::cell::RefCell<Vec<bool>> =
53    const { std::cell::RefCell::new(Vec::new()) };
54}
55
56/// a configuration for loading LaTeX definition files (such as .sty, .cls, and their bindings)
57pub struct InputDefinitionOptions {
58  /// an optional extension (such as "sty")
59  pub extension:        Option<Cow<'static, str>>,
60  /// package options to pass into the loaded library
61  pub options:          Vec<String>,
62  /// Tokens to process after the definition is loaded
63  pub after:            Tokens,
64  /// flag to forbid raw TeX sources
65  pub notex:            bool,
66  /// flag to forbid errors ?
67  pub noerror:          bool,
68  /// flag to forbid binding dispatch
69  pub noltxml:          bool,
70  /// collection of (package) options to process when loading the dependency
71  pub withoptions:      Option<Vec<String>>,
72  /// flag to handle options, or ignore them
73  pub handleoptions:    bool,
74  /// flag to process in .cls mode (default: false)
75  pub as_class:         bool,
76  /// flag to indicate reading the file raw in Gullet
77  pub raw:              bool,
78  /// flag to allow reloading a previously loaded definitions file
79  pub reloadable:       bool,
80  /// flag: set @ catcode to LETTER during loading (default true).
81  /// Set to false for packages like xy.tex that need @ to stay as OTHER.
82  pub at_letter:        bool,
83  /// When set, raw-file lookup is restricted to the user-supplied
84  /// SEARCHPATHS (SOURCEDIRECTORY + graphicspaths), skipping the
85  /// kpsewhich fallback into system texmf. Matches Perl Package.pm's
86  /// `searchpaths_only => 1` — enabled by the `localrawstyles` option
87  /// to latexml.sty. Perl ref: Package.pm L2135, L2674.
88  pub searchpaths_only: bool,
89}
90impl Default for InputDefinitionOptions {
91  fn default() -> Self {
92    InputDefinitionOptions {
93      extension:        None,
94      options:          Vec::new(),
95      after:            Tokens!(),
96      notex:            false,
97      noerror:          false,
98      noltxml:          false,
99      raw:              false,
100      reloadable:       false,
101      withoptions:      None,
102      handleoptions:    false,
103      as_class:         false,
104      at_letter:        true,
105      searchpaths_only: false,
106    }
107  }
108}
109
110/// TODO: Flesh out with the full infrastructure, incremental functionality for now.
111pub fn input_definitions(raw_file: &str, mut options: InputDefinitionOptions) -> Result<()> {
112  let trimmed = raw_file.trim();
113  // A compiled binding keyed by BASENAME raw-loads its own name (`noltxml`), but
114  // the dispatch dropped any directory the user wrote — so `\usepackage{DIR/pkg}`
115  // would raw-load a bare `pkg.sty` and miss the author's bundled `DIR/pkg.sty`.
116  // `\@currname` still holds the full request (`DIR/pkg`) from the OUTER load
117  // (set at `before_input_handle_options`, later than here), so load the file the
118  // user actually asked for — exactly as Perl does (it has no such binding and
119  // raw-loads the dir-prefixed path directly, 0 errors; witness 2510.09534,
120  // `\usepackage[preprint]{AISTATS/aistats2026}`). Scoped to `noltxml` + a bare
121  // basename that matches `\@currname`'s basename, so an unrelated `\input` in the
122  // package is NOT auto-resolved in the subdir (stock LaTeX does not do that —
123  // that is what the `import` package is for). This replaces the former
124  // SearchPathGuard, which injected the subdir into SEARCHPATHS instead.
125  let currname_request: Option<String> = if options.noltxml && !trimmed.contains(['/', '\\']) {
126    do_expand(T_CS!("\\@currname"))
127      .ok()
128      .map(|toks| toks.to_string())
129      .filter(|currname| {
130        currname
131          .rsplit_once(['/', '\\'])
132          .is_some_and(|(dir, base)| base == trimmed && !dir.is_empty())
133      })
134  } else {
135    None
136  };
137  let name: &str = currname_request.as_deref().unwrap_or(trimmed);
138
139  // Guard: prevent infinite recursion from circular or runaway package loading.
140  // When a binding is missing, raw TeX loading can trigger macro loops.
141  let depth = INPUT_DEPTH.with(|d| {
142    let current = d.get();
143    d.set(current + 1);
144    current + 1
145  });
146  if depth > MAX_INPUT_DEPTH {
147    INPUT_DEPTH.with(|d| d.set(d.get() - 1));
148    Fatal!(
149      Stomach,
150      Recursion,
151      s!(
152        "Package loading depth exceeded {} (loading '{}').\
153        This usually means a missing binding causes infinite recursion.",
154        MAX_INPUT_DEPTH,
155        name
156      )
157    );
158  }
159
160  // Ensure depth cleanup on all exit paths via a guard
161  struct InputDepthGuard;
162  impl Drop for InputDepthGuard {
163    fn drop(&mut self) { INPUT_DEPTH.with(|d| d.set(d.get() - 1)); }
164  }
165  let _guard = InputDepthGuard;
166
167  // (A directory-prefixed package name — `\usepackage{DIR/pkg}` — that dispatches
168  // to a basename-keyed binding is resolved above by rewriting `name` to the
169  // `\@currname` request, so the binding's own raw-load targets `DIR/pkg`
170  // directly, as Perl does. No SEARCHPATHS injection is needed.)
171
172  // Note: we always need a gullet to expand, and we sometimes need a stomach to load_definitions...
173  // so let's make stomach a mandatory option.
174  //
175  // Snapshot \@currname/\@currext only when handleoptions=true. Perl
176  // Package.pm:2549-2550 does the same — both prevname and prevext are
177  // gated on options{handleoptions}. The handleoptions=false branch
178  // does NOT mutate \@currname/\@currext (mirrors plain LaTeX `\input`
179  // semantics; Perl L2580 likewise only mutates them inside its
180  // handleoptions=true branch).
181  let prevname = if options.handleoptions && lookup_definition(&T_CS!("\\@currname"))?.is_some() {
182    do_expand(T_CS!("\\@currname"))?.to_string()
183  } else {
184    String::new()
185  };
186  let prevext = if options.handleoptions && lookup_definition(&T_CS!("\\@currext"))?.is_some() {
187    do_expand(T_CS!("\\@currext"))?.to_string()
188  } else {
189    String::new()
190  };
191  // This file will be treated somewhat as if it were a class
192  // IF as_class is true
193  // OR if it is loaded by such a class, and has withoptions true!!! (yikes)
194  if options.handleoptions && options.withoptions.is_some() {
195    with_vecdeque("@masquerading@as@class", |vdq_opt| {
196      if let Some(vdq) = vdq_opt
197        && vdq.iter().any(|x| {
198          if let Stored::String(v) = x {
199            arena::with(*v, |str| str == prevname)
200          } else {
201            false
202          }
203        })
204      {
205        options.as_class = true;
206      }
207    });
208  }
209  if options.noltxml {
210    options.raw = true; // so it will be read as raw by Gullet.
211  }
212  let as_type = if options.as_class {
213    Cow::Borrowed("cls")
214  } else {
215    options
216      .extension
217      .as_ref()
218      .cloned()
219      .unwrap_or(Cow::Borrowed(""))
220  };
221
222  // If loading a class, store class options (Perl Package.pm lines 2561-2564).
223  // Perl L2561: `if ($astype eq 'cls' and $options{options})` — only
224  // (re)define `\@classoptionslist` when THIS cls load actually carries
225  // options. A nested `\LoadClass` with empty options (e.g. amsart →
226  // ams_core; our `*_cls.rs` bindings pass `Tokens!()` rather than forwarding
227  // the outer options as Perl's `withoptions=>1` does) must NOT clobber the
228  // document class's option list — babel iterates `\@classoptionslist`
229  // (`\bbl@foreach`, babel.sty L4270) to pick up a GLOBAL language option such
230  // as `\documentclass[french]{amsart}` and only then declares/loads that
231  // language. Clobbering it to empty silently dropped global babel languages
232  // for every bound class. Witness 1911.07001 (`[oneside,french,titlepage]
233  // {amsart}` + bare `\usepackage{babel}` → french.ldf must load).
234  //
235  // DIVERGENCE retained from Perl: still define `\@classoptionslist` as EMPTY
236  // for an option-less *document* class so babel's
237  // `\csname\ds@\@classoptionslist\endcsname` doesn't run away (the kernel
238  // default `\let\@classoptionslist\relax`; witness 2504.00009). We gate that
239  // on "no class options recorded yet", so it fires for the first/outermost
240  // class load but never clobbers an already-populated list on a nested load.
241  if as_type == "cls" {
242    for opt in &options.options {
243      push_value("class_options", arena::pin(opt))?;
244    }
245    let class_opts_str = options.options.join(",");
246    let have_recorded_opts = lookup_vecdeque("class_options")
247      .map(|v| !v.is_empty())
248      .unwrap_or(false);
249    if !class_opts_str.is_empty() || !have_recorded_opts {
250      def_macro(
251        T_CS!("\\@classoptionslist"),
252        None,
253        Tokens!(Explode!(class_opts_str)),
254        None,
255      )?;
256    }
257  }
258
259  // Compute the exact name based on the type
260  let filename = match &options.extension {
261    None => name.to_string(),
262    Some(ext) => s!("{}.{}", name, ext),
263  };
264  // Store the document class filename for xkeyval's isInClassFile check
265  if as_type == "cls" && options.handleoptions {
266    assign_value(
267      "document_class_filename",
268      filename.clone(),
269      Some(Scope::Global),
270    );
271  }
272  let current_options = options.options.join(",");
273  if !current_options.is_empty()
274    && let Some(Stored::String(prevoptions)) = lookup_value(&s!("{filename}_loaded_with_options"))
275    && arena::with(prevoptions, |prev_str| current_options != prev_str)
276  {
277    let message = s!(
278      "Option clash for file {} with options {:?}, previously loaded with {:?}",
279      filename,
280      current_options,
281      prevoptions
282    );
283    Info!("unexpected", "options", message);
284  }
285
286  // TODO: This needs reorganization, bindings are not found as "files" in rust,
287  // we need to have a registry (we don't yet)
288
289  // Perl: early-stop if already loaded (checks request_loaded, name_loaded, etc.)
290  // This prevents double-loading and breaks circular loading chains.
291  // IMPORTANT: check BEFORE printing "Loading..." message to avoid spurious output.
292  //
293  // Per OXIDIZED_DESIGN #23: gate on the flag matching the load path
294  // we'll actually take. CRITICAL invariant: a binding `<file>.rs` is
295  // allowed to call `InputDefinitions(noltxml=>1)` for its same-named
296  // raw .sty/.cls/.def AFTER its own `_loaded` flag was set — the raw
297  // load gates on `_raw_loaded`, not `_loaded`. Examples: babel_sty
298  // → raw babel.sty; cite_sty → raw cite.sty.
299  let opt_noltxml = options.noltxml;
300  let opt_notex = options.notex;
301  // Rust-only `_load_attempted` flag: set in the miss-handler below to
302  // prevent retry loops while keeping `_loaded` reserved for genuine
303  // binding success. Without this split the `_loaded`-on-miss hack
304  // shadowed `require_package`'s `!_loaded && !_raw_loaded`
305  // post-call check, disabling `maybe_require_dependencies` for any
306  // package that had no binding (e.g. paper-local `jinstpub.sty`).
307  let already_handled = |fkey: &str| -> bool {
308    if opt_noltxml {
309      lookup_bool(&s!("{fkey}_raw_loaded"))
310    } else if opt_notex {
311      lookup_bool(&s!("{fkey}_loaded")) || lookup_bool(&s!("{fkey}_load_attempted"))
312    } else {
313      lookup_bool(&s!("{fkey}_loaded"))
314        || lookup_bool(&s!("{fkey}_raw_loaded"))
315        || lookup_bool(&s!("{fkey}_load_attempted"))
316    }
317  };
318  // Modern-kernel repeat-load option semantics (OXIDIZED_DESIGN #43).
319  // Classic \DeclareOption packages: after ProcessOptions the `\ds@<opt>`
320  // handlers are \relax, so digesting them on a repeat load is a NO-OP —
321  // i.e. LaTeX's own clash-and-drop outcome (and Perl's silent skip; the
322  // Info above is the shared diagnostic). Packages using the key-value
323  // option processor (\ProcessKeyOptions, LaTeX kernel 2022-06+; e.g.
324  // xcolor v3.02 whose `table` key loads colortbl) raise NO clash in real
325  // LaTeX — the repeat load PROCESSES the new keys. A binding models that
326  // by re-asserting a durable `\ds@<opt>` after ProcessOptions! (first
327  // adopter: xcolor's `table`; witness 2605.00310 — \usepackage{xcolor}
328  // then \usepackage[table]{xcolor} builds cleanly under pdflatex, and a
329  // ~483-paper \cellcolor error cluster in sandbox-arxiv-2605). For each
330  // option of a repeat load that the first load did not have, digest the
331  // surviving handler.
332  let apply_new_options_on_reload = |fkey: &str| -> Result<()> {
333    if options.options.is_empty() || !options.handleoptions {
334      return Ok(());
335    }
336    let prev: HashSet<String> =
337      if let Some(Stored::String(prevoptions)) = lookup_value(&s!("{fkey}_loaded_with_options")) {
338        arena::with(prevoptions, |p| {
339          p.split(',').map(|o| o.trim().to_string()).collect()
340        })
341      } else {
342        HashSet::default()
343      };
344    for opt in &options.options {
345      let opt = opt.trim();
346      if opt.is_empty() || prev.contains(opt) {
347        continue;
348      }
349      let handler = T_CS!(s!("\\ds@{}", opt));
350      if lookup_definition(&handler)?.is_some() {
351        Info!(
352          "unexpected",
353          "options",
354          s!("Applying option '{}' from a repeat load of {}", opt, fkey)
355        );
356        digest(Tokens!(handler))?;
357      }
358    }
359    Ok(())
360  };
361  if !options.reloadable && already_handled(&filename) {
362    apply_new_options_on_reload(&filename)?;
363    return Ok(());
364  }
365  // Also check without extension (Perl checks name_loaded too)
366  if !options.reloadable && name != filename && already_handled(name) {
367    apply_new_options_on_reload(&filename)?;
368    return Ok(());
369  }
370
371  // The per-file load note is emitted ONLY when a load truly happens, so
372  // CorTeX's `loaded_file` extraction (which parses the log) counts genuine
373  // loads and nothing else:
374  //   * a NATIVE BINDING load announces `(Loading <name>…)` at its success
375  //     point below (Perl loadLTXML "(Loading <path>…)" analog);
376  //   * a RAW .sty/.cls/.def load is announced by its Mouth's
377  //     `(Processing definitions <path>…)` (fires only on a real read);
378  //   * the early-return paths — already-loaded skip and missing-file — reach
379  //     NEITHER, so they emit no note (matching Perl, whose loadLTXML returns
380  //     before its note when the binding was already loaded).
381  // (Previously a `(Loading …)` banner was emitted unconditionally here,
382  // before those early-returns — over-reporting already-loaded/missing files.)
383
384  // Snapshot options.after / options.options BEFORE handleoptions consumes
385  // them so the fallback-binding recursive call (Step 3 below) can forward
386  // both to the fallback. Without this snapshot, mn1 → mn.cls.ltxml fallback
387  // ran with empty options/after and the user's `[epsfig]` was lost (see
388  // astro-ph0002213 root cause).
389  let original_after = options.after.clone();
390  let original_options = options.options.clone();
391  // Snapshot the GRANDPARENT's expl3 state BEFORE `\@pushfilename`'s
392  // `\ExplSyntaxOff` flips `_` to SUB. The post-load cleanup hook in
393  // load_tex_definitions uses this to know whether the calling context
394  // was in expl3 mode (so it can skip the `\ExplSyntaxOff` cleanup that
395  // would otherwise stick post-`\@popfilename`). Witness cluster:
396  // arXiv:2509.05997 / .07893 / .02344, 2510.13206/.13942/.17317
397  // (xsavebox + sys_load_backend + l3backend-dvips.def chain — minimal
398  // repro: \usepackage{xsavebox}).
399  let self_snapshot = lookup_catcode('_') == Some(Catcode::LETTER);
400  // Inherit the immediate parent frame's flag only when the parent WAS in expl3 but
401  // our own fresh read is false — the double-nested same-file case (an outer
402  // `\RequirePackage{X}` whose X-binding force-raw-loads its own expl3 .sty, so this
403  // inner call snapshots after the outer `\@pushfilename` already flipped `_` to SUB).
404  // Never overrides a correct fresh read, so xsavebox/tcolorbox/lipsum are unaffected.
405  let grandparent_in_expl3 = INPUT_DEF_EXPL3
406    .with(|s| {
407      s.borrow()
408        .last()
409        .copied()
410        .filter(|&parent| parent && !self_snapshot)
411    })
412    .unwrap_or(self_snapshot);
413  INPUT_DEF_EXPL3.with(|s| s.borrow_mut().push(grandparent_in_expl3));
414  struct Expl3FrameGuard;
415  impl Drop for Expl3FrameGuard {
416    fn drop(&mut self) {
417      INPUT_DEF_EXPL3.with(|s| {
418        s.borrow_mut().pop();
419      });
420    }
421  }
422  let _expl3_frame = Expl3FrameGuard;
423  // Strict-LaTeX-kernel order (latex.ltx `\@onefilewithoptions`, L15518-L15519):
424  //   \@pushfilename                        % capture OLD \@currname / \@currext
425  //   \xdef\@currname{ <new name> }         % then update to NEW
426  //
427  // We previously set \@currname/\@currext to the new file's name BEFORE
428  // calling `before_input_handle_options` (which performs the push). That
429  // captured the NEW name in the pushed triple, so `\@currnamestack` never
430  // held the empty `{}{}{<catcode>}` initial-state triple that
431  // expl3-code.tex's `\__file_tmp:w` recursion uses as its termination
432  // sentinel. Result: under raw expl3-code.tex load (LATEXML_NODUMP=1),
433  // the recursion ate past `\group_end:` into subsequent
434  // `\seq_new:N` / `\cs_new:Npn …` lines, producing the cs_end:
435  // cascade documented in .investigation/cs_end_bisect_round22/.
436  //
437  // Now: push first (uses current/OLD \@currname), then update inside
438  // before_input_handle_options (line 756-757). For the
439  // `handleoptions == false` path no push happens, so set the names
440  // directly here.
441  if options.handleoptions {
442    before_input_handle_options(&mut options, &prevname, &prevext, name, &as_type)?;
443    def_macro(
444      T_CS!(s!("\\{}.{}-h@@k", name, as_type)),
445      None,
446      options.after,
447      None,
448    )?;
449  }
450  // No `else` branch: Perl Package.pm L2580-2611 only mutates
451  // \@currname/\@currext inside the handleoptions=true block. The
452  // handleoptions=false path mirrors plain LaTeX `\input`, which leaves
453  // them untouched. Mutating them here breaks \@currnamestack
454  // discipline: a subsequent inner \RequirePackage's \@pushfilename
455  // captures the leaked name instead of the empty initial-state value,
456  // and expl3-code.tex's \__file_tmp:w stack walk over-reads.
457  // Witnesses: 0805.4519 (inputenc+ansinew), 1705.00041
458  // (\usetikzlibrary{calligraphy}+spath3+expl3).
459
460  if !current_options.is_empty() {
461    assign_value(
462      &s!("{}_loaded_with_options", filename),
463      current_options,
464      Some(Scope::Global),
465    );
466  }
467
468  // Track loaded files in \@filelist BEFORE loading (Perl: Package.pm calls
469  // \@addtofilelist before reading the file, so \@filelist is available inside)
470  if options.handleoptions && lookup_definition(&T_CS!("\\@addtofilelist"))?.is_some() {
471    digest(Tokens!(
472      T_CS!("\\@addtofilelist"),
473      T_BEGIN!(),
474      Explode!(filename),
475      T_END!()
476    ))?;
477  }
478
479  // Skip loading entirely if already loaded (unless reloadable)
480  // This prevents double-loading when e.g. smfart calls load_class("amsart")
481  // after the binding already set the _loaded flag.
482  // Per OXIDIZED_DESIGN #23: gate by the load path's flag — same
483  // path-aware logic as the early-skip above. Allows a binding to
484  // load its same-named raw counterpart via `noltxml=>1`.
485  if !options.reloadable && already_handled(&filename) {
486    // Already-loaded early return: emit no load note (nothing was loaded).
487    return Ok(());
488  }
489
490  // Catch Fatal errors during binding loading (e.g., token limit exceeded during
491  // expl3 kernel loading). Convert to non-fatal so document processing continues.
492  // `Some(source)` = a binding was loaded, carrying its on-disk source path
493  // when it is a runtime file binding (`.rhai`) or `None` for a compiled-in one;
494  // outer `None` = no binding loaded. The two `_load_binding` attempts (extra
495  // slot, then the main resolving chain) short-circuit on the first that loads.
496  let loaded = if options.noltxml {
497    None
498  } else {
499    match _load_binding(false, &filename, options.reloadable).and_then(|ext| match ext {
500      Some(source) => Ok(Some(source)),
501      None => _load_binding(true, &filename, options.reloadable),
502    }) {
503      Ok(v) => v,
504      Err(e) => {
505        Error!(
506          "unexpected",
507          &filename,
508          s!("Error loading binding for '{}': {}", filename, e)
509        );
510        // Mark as loaded even on error to prevent re-loading via raw path
511        assign_value(&s!("{filename}_loaded"), true, Some(Scope::Global));
512        None
513      },
514    }
515  };
516  let mut is_found_raw = false;
517  // Retain the "a binding loaded" fact for the later hook-firing check (`loaded`
518  // is consumed by the announce branch just below).
519  let is_binding = loaded.is_some();
520  if let Some(binding_source) = loaded {
521    // A native binding was truly loaded: announce it (Perl loadLTXML
522    // "(Loading <path>…)" analog) so CorTeX's `loaded_file` log-parser counts
523    // it. Self-contained begin+end note (timing is currently disabled).
524    // A runtime `.rhai` file binding threads its resolved on-disk path through
525    // the dispatch result (`BindingSource`, converter.rs `rhai_dispatch`), so it
526    // is announced by that real path — more useful, and closer to Perl, whose
527    // note names the actual binding file (#560). A compiled-in binding carries
528    // no source, so it is announced under its module-proxy name — `tcolorbox.sty`
529    // → `tcolorbox_sty.rs`, `aa.cls` → `aa_cls.rs` — mirroring Perl's distinct
530    // `.ltxml` path. This keeps the entry distinguishable from the raw twin's
531    // "(Processing definitions <path>…)" when a binding raw-loads its same-named
532    // file, so cortex's loaded_file stats record two artifacts as two entries
533    // instead of double-counting one file (user, 2026-07-04).
534    let binding_name = if let Some(path) = binding_source {
535      path
536    } else if let Some(stem) = filename.strip_suffix(".sty") {
537      s!("{stem}_sty.rs")
538    } else if let Some(stem) = filename.strip_suffix(".cls") {
539      s!("{stem}_cls.rs")
540    } else {
541      filename.clone()
542    };
543    note_begin(&s!("Loading {binding_name}"));
544    note_end("");
545    // We found and loaded a binding successfully, mark it as such.
546    // Perl Package.pm::loadLTXML L2315-2316 sets TWO flags: `$request`_loaded
547    // (e.g. `color.sty_loaded`) AND `$ltxname`_loaded (`color.sty.ltxml_loaded`),
548    // where `.ltxml` is the suffix of the Perl binding file. Rust's port
549    // keeps only the former — `.ltxml` is not a suffix in the Rust world, so
550    // binding-vs-raw-tex distinction is queryable via `*_loaded` directly.
551    // See OXIDIZED_DESIGN.md. Callers of the legacy `.ltxml_loaded` form
552    // must be migrated to `_loaded`.
553    // Per OXIDIZED_DESIGN #23: binding success → `<filename>_loaded`.
554    // Raw load tracks separately via `<filename>_raw_loaded` (see
555    // load_tex_definitions). The `_found_loaded` Rust-only flag is
556    // dropped — read sites check `_loaded || _raw_loaded` instead.
557    let loaded_flag = format!("{filename}_loaded");
558    assign_value(&loaded_flag, true, Some(Scope::Global));
559    // Also set the Perl-equivalent `<filename>.ltxml_loaded` flag —
560    // some callers (e.g. require_package's deps-scan gate) need to
561    // distinguish binding-loaded from raw-loaded. Without this,
562    // paper-bundled .sty files that load natbib (which has a
563    // binding) re-trigger deps-scan on natbib.sty, which re-finds
564    // \usepackage{natbib} in natbib.sty's own warning text, looping
565    // infinitely. Witness 2111.01269 (TIMEOUT from natbib deps loop).
566    assign_value(&s!("{filename}.ltxml_loaded"), true, Some(Scope::Global));
567    // Perl L2326: Let(T_CS('\ver@'.$trequest), T_CS('\fmtversion'), 'global');
568    // Set \ver@name.ext to \fmtversion so LaTeX's \RequirePackage guard works.
569    // Without this, \RequirePackage date checks fail and packages get re-loaded.
570    if options.handleoptions {
571      let ver_cs = T_CS!(s!("\\ver@{}", filename));
572      if lookup_definition(&ver_cs).ok().flatten().is_none() {
573        let fmtversion_cs = T_CS!("\\fmtversion");
574        let_i(&ver_cs, &fmtversion_cs, Some(Scope::Global));
575      }
576    }
577  } else {
578    // We're inverting the control flow, because it is near-instant to check whether we have an
579    // available binding dispatcher, in both contributed and core binding names
580    // Now that we have ensured there is no compiled target of this name, we can start the file
581    // system search dance, call to kpsewhich, etc.
582    //
583    // Perl Package.pm FindFile search order (L2109-2139):
584    //   1. .ltxml binding (handled above by load_binding/load_external_binding)
585    //   2. Raw TeX in search paths, BUT only if INTERPRETING_DEFINITIONS is true (i.e. we're inside
586    //      recursive loading from another raw TeX file)
587    //   3. FindFile_fallback — strip version suffixes, find generic .ltxml binding (e.g.
588    //      icml2024.sty → icml.sty.ltxml)
589    //   4. Raw TeX in search paths (without INTERPRETING_DEFINITIONS gate)
590    //   5. kpsewhich
591    //
592    // This ordering ensures versioned-package fallback bindings take priority
593    // over raw .sty files that may contain layout checks (like ICML's \ifdim
594    // page-margin checks) that produce spurious warnings.
595    let interpreting = lookup_bool_sym(crate::pin!("INTERPRETING_DEFINITIONS"));
596    // Perl Package.pm:FindFile_aux L2107: `$interpretable =
597    // LookupMapping('INTERPRETABLE_SOURCES', $file)`. A binding may
598    // register specific raw sources (keyed by `name.ext`) that MUST be
599    // raw-loaded directly rather than version-stripped to a fallback
600    // binding. Driver: xparse.sty.ltxml registers `xparse-2018-04-12.sty`
601    // so the rollback `\file_input` from the real xparse.sty loads the
602    // actual definitions (`\NewDocumentCommand`) — WITHOUT this, the
603    // version-suffix fallback strips `xparse-2018-04-12` → `xparse`, which
604    // re-enters the in-progress xparse binding (short-circuited) and the
605    // rollback file is never loaded (`\NewDocumentCommand` undefined →
606    // xparse/l3 cascade; canvas witness 2309.17288, tests xparse/regex_match).
607    let interpretable = lookup_mapping("INTERPRETABLE_SOURCES", &filename).is_some();
608
609    // Step 2: If we're interpreting raw TeX definitions (or this file is
610    // explicitly INTERPRETABLE), look for the file directly.
611    // Perl L2115: `(!notex && ($interpreting || $interpretable) && pathname_find)`.
612    // Perl Package.pm L2117-2119: `pathname_find($file, paths => $paths)` —
613    // LOCAL PATHS ONLY, no kpsewhich. Rust must mirror this: kpsewhich
614    // here would short-circuit Step 3 (fallback ltxml) for any TeX-Live-
615    // shipped raw file. Witness: `\RequirePackage{caption3}` from raw
616    // floatrow.sty — Perl finds caption3.sty NOT in user paths, falls
617    // through to Step 3 → caption.sty.ltxml. Rust with kpsewhich here
618    // returned the real caption3.sty from TL, raw-loading it and
619    // triggering the `\DeclareCaptionFormat{hang}[#1#2#3\par]{...}`
620    // PARAM-leak cascade (arXiv:2506.19291: Rust=30 vs Perl=2).
621    let found_raw = if (interpreting || interpretable) && !options.notex {
622      find_file(
623        &filename,
624        Some(FindFileOptions {
625          forbid_ltxml:      options.noltxml,
626          notex:             false,
627          ext_type:          options.extension.as_ref().cloned(),
628          search_paths_only: true,
629        }),
630      )
631    } else {
632      None
633    };
634
635    // Step 3: Try fallback (strip version suffixes / dir prefix) before raw TeX.
636    // Perl Package.pm L2118-2121: FindFile_fallback.
637    //
638    // Design policy: bindings ALWAYS win over local raw .sty/.cls files.
639    // The `.rs` bindings are hand-tuned for the conversion, so if a
640    // fallback name resolves to a registered binding we dispatch there
641    // unconditionally. Raw TeX is the last-resort path (Step 4).
642    //
643    // Two flavors are recorded via [`FallbackKind`] for informational
644    // log messages only — both always fire when the binding exists:
645    //   - Versioned: suffix/prefix actually stripped (Perl-faithful). Drivers: 1206.0536 (mysvjour3
646    //     → svjour3), astro-ph0005021 (./aaspp4 → ./aaspp — aaspp4.sty ships locally with plain-TeX
647    //     `\startdata`; the engine's alignment-aware binding still wins, matching Perl).
648    //   - BasenameOnly: only directory prefix removed. Rust-specific extension keyed to our
649    //     contrib-binding registry. Drivers: 2105.02087 (misc/ieeetran → IEEEtran binding);
650    //     2405.18387 (assets/equations → equations binding, because we ship a tuned binding for
651    //     this name).
652    let found_raw = if found_raw.is_some() {
653      found_raw
654    } else if !options.noltxml && !interpretable {
655      // Perl L2119: `(!noltxml && !$interpretable && FindFile_fallback)` —
656      // an INTERPRETABLE source must NOT be version-stripped to a fallback
657      // binding; it falls through to the raw-TeX/kpsewhich path below.
658      if let Some((fallback, _kind)) = find_file_fallback(name, &as_type) {
659        Info!(
660          "fallback",
661          name,
662          s!("Interpreted as versioned package, falling back to {fallback}")
663        );
664        // Load the fallback binding — use reloadable since we already marked original as "loaded"
665        let ext_suffix = if as_type == "sty" { ".sty" } else { ".cls" };
666        let fallback_name = fallback.trim_end_matches(ext_suffix).to_string();
667        // Forward the original options + after-hook so fallback bindings see
668        // user-supplied class/package options (Perl-faithful: in Perl FindFile
669        // returns a path and the caller's options/after stay attached to the
670        // ORIGINAL `\@currname`-frame). Without this, `\documentstyle[epsfig]{mn1}`
671        // fell back to mn.cls.ltxml with empty options → mn.cls's option-handler
672        // never saw `epsfig` → `\compat@loadpackages` after-hook never fired
673        // → `\psfig` undefined. Witness: astro-ph0002213.
674        let fb_result = input_definitions(&fallback_name, InputDefinitionOptions {
675          extension: Some(Cow::Borrowed(if as_type == "sty" { "sty" } else { "cls" })),
676          options: original_options,
677          after: original_after,
678          handleoptions: options.handleoptions,
679          noerror: true,
680          reloadable: true,
681          ..InputDefinitionOptions::default()
682        });
683        if fb_result.is_ok() {
684          assign_value(&s!("{filename}_loaded"), true, Some(Scope::Global));
685          // NOTE: do NOT set `{filename}.ltxml_loaded` here. The
686          // fallback name is a DIFFERENT binding (e.g. article for
687          // myclass); the original `{filename}` (myclass.cls) has
688          // no binding. Setting it would suppress the downstream
689          // deps-scan check that picks up myclass's
690          // \RequirePackage{caption}.
691        }
692        None // fallback handled the loading; no raw file to load
693      } else {
694        None
695      }
696    } else {
697      None
698    };
699
700    // Step 4: Raw TeX in search paths (without INTERPRETING_DEFINITIONS gate)
701    // Perl Package.pm L2122-2125
702    //
703    // Per OXIDIZED_DESIGN #23: gate by `_raw_loaded` only — when a binding
704    // explicitly loads its raw counterpart via `noltxml=>1`, the binding's
705    // own `_loaded` flag is already set, but we MUST still proceed.
706    //
707    // EXCEPTION: if Step 3 (fallback ltxml binding) just succeeded, Perl's
708    // `if/elsif` flow (Package.pm:2118-2125) RETURNS on success and skips
709    // the raw-tex branch entirely. Rust's port uses sequential `let`
710    // bindings, so we must explicitly check `_loaded` here. Without this
711    // gate, `\RequirePackage{caption2}` loads `caption.sty.ltxml` via
712    // `find_file_fallback` (caption2 → caption strips trailing digit) AND
713    // then ALSO loads raw `caption2.sty`, which fires its
714    // `\@ifpackageloaded{caption}` mutual-exclusivity error. Same pattern
715    // applies to any package whose name ends in `[vV]?[-_.\d]+` and whose
716    // unsuffixed form has its own .ltxml binding.
717    let found_raw = if found_raw.is_some() {
718      found_raw
719    } else if lookup_bool(&s!("{filename}_loaded")) {
720      // Fallback ltxml binding already loaded — don't double-load the raw.
721      None
722    } else if !options.notex && (options.reloadable || !lookup_bool(&s!("{filename}_raw_loaded"))) {
723      // Perl Package.pm L2121-2125 + L2131-2136: combined raw-search
724      // step. Tries local paths first, then kpsewhich. Mirrors Perl's
725      // Step 4 (`!interpreting` local raw) PLUS Step 5 (kpsewhich
726      // unconditionally — note Perl's kpsewhich block lacks the
727      // interpreting gate). The previous `!interpreting` guard here
728      // was wrong: Step 2 now uses `search_paths_only=true`, so
729      // under interpreting=true we still need kpsewhich for raw
730      // files that have no fallback ltxml binding.
731      find_file(
732        &filename,
733        Some(FindFileOptions {
734          forbid_ltxml:      options.noltxml,
735          notex:             false,
736          ext_type:          options.extension.as_ref().cloned(),
737          search_paths_only: options.searchpaths_only,
738        }),
739      )
740    } else {
741      None
742    };
743
744    if let Some(file) = found_raw {
745      is_found_raw = true;
746      // The raw load itself sets `<filename>_raw_loaded` via
747      // load_tex_definitions (per OXIDIZED_DESIGN #23). Read sites
748      // check `_loaded || _raw_loaded` to detect "any load happened".
749      load_tex_definitions(
750        &filename,
751        &file,
752        options.reloadable,
753        options.at_letter,
754        grandparent_in_expl3,
755      )?;
756    } else if !lookup_bool(&s!("{filename}_loaded")) && !lookup_bool(&s!("{filename}_raw_loaded")) {
757      if options.noerror {
758        // With noerror: don't mark as loaded and return Err so callers can
759        // try fallback names (e.g. tikzlibrary → pgflibrary). Matches Perl's
760        // InputDefinitions which returns undef on not-found even with noerror=>1.
761        // Nothing loaded → emit no load note.
762        return Err(s!("File not found: {}", filename).into());
763      }
764      // Perl Package.pm L2679 / L2715: maybeRequireDependencies($name, $type)
765      // is invoked when InputDefinitions returned undef ($success false).
766      // We mirror that here in the miss-handler, which is the only point
767      // where we know neither binding nor raw load occurred. Doing the
768      // dependency-scan BEFORE marking `_load_attempted` keeps the call
769      // exactly once-per-package and lets paper-local `.sty` files
770      // (e.g. jinstpub.sty bundling natbib + amsmath dependencies) wire
771      // up their transitively-bound prerequisites even when raw .sty
772      // loading is disabled (`INCLUDE_STYLES=false`, the default).
773      let scan_type =
774        options
775          .extension
776          .as_deref()
777          .unwrap_or(if options.as_class { "cls" } else { "sty" });
778      maybe_require_dependencies(name, scan_type);
779      // Rust-only retry guard: prevents re-attempting a missing file in
780      // a loop (raw TeX repeatedly calling \RequirePackage). Use a
781      // dedicated `_load_attempted` flag — NOT `_loaded` — so the
782      // post-input_definitions success check in `require_package`
783      // remains honest about whether anything actually loaded.
784      //
785      // Gate the guard on raw-loading having actually been POSSIBLE
786      // (INCLUDE_STYLES on, or noltxml forced). A miss while raw `.sty` loading
787      // was OFF is a deliberate DEFERRAL, not a genuine "file absent" — and must
788      // NOT block a later load once INCLUDE_STYLES turns on (e.g. inside another
789      // package's raw read). Otherwise a bare `\RequirePackage{pgfcore}` (no
790      // binding, INCLUDE_STYLES off) permanently STARVES tcolorbox's `skins`
791      // library, which raw-loads pgfcore under INCLUDE_STYLES=true — whereas
792      // pdflatex loads pgfcore fine in any order. The guard still fires exactly
793      // where it is needed: the loop it prevents happens DURING a raw read,
794      // which is itself INCLUDE_STYLES=true. Witness: nicematrix-then-
795      // tcolorbox[most] (fairmeta.cls, ar5iv #520/#567/#576): 49 → 0 pgf errors.
796      if lookup_bool("INCLUDE_STYLES") || options.noltxml {
797        assign_value(&s!("{filename}_load_attempted"), true, Some(Scope::Global));
798      }
799      // Say which of the two things actually happened. `notex` (the DEFAULT
800      // whenever `INCLUDE_STYLES` is off — see `require_package`, Perl
801      // `Package.pm` L2671-2672) gates the raw-search branch above, so in that
802      // case NO disk lookup was performed at all. Claiming "no raw file found
803      // on disk" there asserts a search that never ran, and the package is
804      // usually installed and findable: `\usepackage{xstring}` on arXiv
805      // 2607.21760 warned exactly that while `kpsewhich xstring.sty` resolved
806      // it fine, sending a reader hunting a file-resolution bug that does not
807      // exist. A diagnostic must not overclaim what it checked.
808      let why = if options.notex {
809        "no dispatcher entry, and raw TeX loading is off (enable with --includestyles)"
810      } else {
811        "no dispatcher entry, and no raw file found on disk"
812      };
813      Warn!(
814        "missing_file",
815        name,
816        s!("Can't find binding or file for '{filename}'. {why}.")
817      );
818    }
819  }
820
821  if options.handleoptions {
822    if is_binding || is_found_raw {
823      digest(T_CS!(s!("\\{name}.{as_type}-h@@k")))?;
824    }
825    // Always restore @currname/@currext and pop filename stack,
826    // even when no binding was found, to keep the stack balanced.
827    // Note: @popfilename uses \gdef to restore @currname/@currext from the stack,
828    // so it takes precedence. We also set them with def_macro as a fallback
829    // (matches Perl Package.pm lines 2635-2637).
830    if !prevname.is_empty() {
831      def_macro(
832        T_CS!("\\@currname"),
833        None,
834        Tokens!(ExplodeText!(prevname)),
835        None,
836      )?;
837    }
838    if !prevext.is_empty() {
839      def_macro(
840        T_CS!("\\@currext"),
841        None,
842        Tokens!(ExplodeText!(prevext)),
843        None,
844      )?;
845    }
846    // Perl-faithful: Package.pm:2637 —
847    //   Digest(($pushpop ? T_CS('\@popfilename') : T_CS('\lx@popfilename')));
848    // Pair with the dispatched push above. Using `\@popfilename` (dump's
849    // expl3-wrapped) when both push/pop are defined; else `\lx@popfilename`
850    // (LaTeXML safe internal). The push site re-checks `\@pushfilename` and
851    // `\@popfilename` definedness independently (state may have changed
852    // mid-load); here we re-check too rather than threading a flag.
853    let pop_use_expl = lookup_definition(&T_CS!("\\@pushfilename"))?.is_some()
854      && lookup_definition(&T_CS!("\\@popfilename"))?.is_some();
855    if pop_use_expl {
856      digest(T_CS!("\\@popfilename"))?;
857    } else {
858      digest(T_CS!("\\lx@popfilename"))?;
859    }
860    // Verify @currname was correctly restored, and force-fix if not
861    let restored_name = if lookup_definition(&T_CS!("\\@currname"))?.is_some() {
862      do_expand(T_CS!("\\@currname"))?.to_string()
863    } else {
864      String::new()
865    };
866    if !prevname.is_empty() && restored_name != prevname {
867      // @popfilename may have popped a stale entry; force correct value
868      def_macro(
869        T_CS!("\\@currname"),
870        None,
871        Tokens!(ExplodeText!(prevname)),
872        Some(ExpandableOptions {
873          scope: Some(Scope::Global),
874          ..ExpandableOptions::default()
875        }),
876      )?;
877    }
878    if !prevext.is_empty() {
879      let restored_ext = if lookup_definition(&T_CS!("\\@currext"))?.is_some() {
880        do_expand(T_CS!("\\@currext"))?.to_string()
881      } else {
882        String::new()
883      };
884      if restored_ext != prevext {
885        def_macro(
886          T_CS!("\\@currext"),
887          None,
888          Tokens!(ExplodeText!(prevext)),
889          Some(ExpandableOptions {
890            scope: Some(Scope::Global),
891            ..ExpandableOptions::default()
892          }),
893        )?;
894      }
895    }
896    reset_options()?;
897  }
898  // No handleoptions=false cleanup needed: we never mutated
899  // \@currname/\@currext on that path (matching Perl).
900  Ok(())
901}
902
903/// loads a binding from the main binding dispatcher, if available+found.
904/// `Ok(Some(source))` on load — `source` is the on-disk path for a runtime
905/// `.rhai` file binding, `None` for a compiled-in one; `Ok(None)` = not loaded.
906pub fn load_binding(file: &str) -> Result<Option<BindingSource>> {
907  _load_binding(true, file, false)
908}
909/// loads a binding from an external binding dispatcher, if available+found
910pub fn load_external_binding(file: &str) -> Result<Option<BindingSource>> {
911  _load_binding(false, file, false)
912}
913// in the spirit of Perl's Package::loadLTXML
914fn _load_binding(internal: bool, request: &str, reloadable: bool) -> Result<Option<BindingSource>> {
915  // Perl loadLTXML L2311-2313: skip if already loaded, unless reloadable
916  // (e.g. `\inputencoding{cp1251}` re-invokes cp1251.def to re-register
917  // DeclareInputText mappings after `set_input_encoding` reset them).
918  // OXIDIZED_DESIGN #23: binding load gates ONLY on the binding-specific
919  // `_loaded` flag (set on success below). A prior raw load
920  // (`_raw_loaded`) does NOT preclude the binding from loading — they
921  // are independent paths. Mirrors Perl `loadLTXML` (Package.pm L2311).
922  let loaded_key = s!("{request}_loaded");
923  if !reloadable && lookup_bool(&loaded_key) {
924    // Already loaded; the source path is not retained across loads, and the
925    // announce site short-circuits on `already_handled` before reaching here,
926    // so reporting an unknown (`None`) source is correct.
927    return Ok(Some(None));
928  }
929
930  // Re-entrance guard for binding loads: track which bindings are
931  // currently mid-load on this thread, so that if a binding body
932  // transitively calls require_package(SAME_NAME) (e.g. via
933  // \citet → OmniBus closure → require_package(natbib) firing
934  // during natbib's own ProcessOptions chain), we short-circuit
935  // instead of re-entering the dispatcher and looping. Task #260.
936  thread_local! {
937    static IN_PROGRESS: std::cell::RefCell<rustc_hash::FxHashSet<String>> =
938      std::cell::RefCell::new(rustc_hash::FxHashSet::default());
939  }
940  let request_key = request.to_string();
941  let already_loading = IN_PROGRESS.with(|s| s.borrow().contains(&request_key));
942  if already_loading {
943    Warn!(
944      "recursion",
945      &request_key,
946      s!(
947        "Binding-load re-entrance for '{}' (transitive require_package \
948         during its own LoadDefinitions). Short-circuiting to break the \
949         loop; binding's pending side-effects (DefMacro, Let, etc.) \
950         will still complete in the outer frame.",
951        request_key
952      )
953    );
954    return Ok(Some(None));
955  }
956
957  // Normalize both dispatcher slots to one resolving closure so the shared
958  // unlock / in-progress guards wrap a single call. The main slot already
959  // reports a `BindingSource`; the extra (compiled) slot never has one, so its
960  // success maps to `None`.
961  type Resolver = Box<dyn Fn(&str) -> Option<Result<BindingSource>>>;
962  let taken_dispatcher: Option<Resolver> = if internal {
963    get_bindings_dispatch().map(|d| Box::new(move |r: &str| d(r)) as Resolver)
964  } else {
965    get_extra_bindings_dispatch()
966      .map(|d| Box::new(move |r: &str| d(r).map(|res| res.map(|()| None))) as Resolver)
967  };
968  match taken_dispatcher {
969    Some(ref dispatcher) => {
970      // Perl `Package.pm:loadLTXML L2318` wraps the binding-load body in
971      // `local $UNLOCKED = 1`, allowing bindings to override prior
972      // (locked) definitions. The guard auto-pops on drop.
973      let _unlock_guard = local_state_unlocked_guard(true);
974      // Mark in-progress for the duration of this dispatcher call.
975      IN_PROGRESS.with(|s| {
976        s.borrow_mut().insert(request_key.clone());
977      });
978      struct InProgressGuard(String);
979      impl Drop for InProgressGuard {
980        fn drop(&mut self) {
981          let key = self.0.clone();
982          IN_PROGRESS.with(|s| {
983            s.borrow_mut().remove(&key);
984          });
985        }
986      }
987      let _in_progress_guard = InProgressGuard(request_key);
988      let result_opt = dispatcher(request);
989      match result_opt {
990        Some(result) => {
991          // Here and only here we are certain we have binding support.
992          // Preemptively mark as loaded to avoid recursion.
993
994          // Mark binding as loaded (raw `<request>_raw_loaded` is tracked
995          // separately by load_tex_definitions). Per OXIDIZED_DESIGN #23.
996          assign_value(&loaded_key, true, Some(Scope::Global));
997          // `result` is `Result<BindingSource>`; wrap the success in `Some` to
998          // signal "a binding loaded" (carrying its source path, if any).
999          result.map(Some)
1000        },
1001        None => Ok(None),
1002      }
1003    },
1004    None => Ok(None),
1005  }
1006}
1007
1008// Factor out handling and passing loading options from input_content,
1009// to simplify main routine
1010fn before_input_handle_options(
1011  options: &mut InputDefinitionOptions,
1012  prevname: &str,
1013  prevext: &str,
1014  name: &str,
1015  as_type: &str,
1016) -> Result<()> {
1017  // Perl-faithful translation of Package.pm:2578-2591:
1018  //
1019  //   my $pushpop = LookupDefinition(T_CS('\@pushfilename'))
1020  //              && LookupDefinition(T_CS('\@popfilename'));
1021  //   if ($pushpop) {
1022  //     Digest(Tokens(T_CS('\@pushfilename'),
1023  //         T_BEGIN, T_END, T_BEGIN, T_END, T_BEGIN, Explode($name), T_END));
1024  //   } else {
1025  //     Digest(T_CS('\lx@pushfilename'));
1026  //   }
1027  //
1028  // The 3 trailing brace-arg pairs `{}{}{name}` feed
1029  // `\@expl@push@filename@aux@@` (which the dump's `\@pushfilename`
1030  // body chains into) — that aux takes 3 args. Without them it reads
1031  // 3 garbage tokens from the input stream, corrupting the
1032  // `\g__hook_name_stack_seq` push. Subsequent `\@popfilename`
1033  // then sees an empty/corrupt seq, fires `\msg_error:nn{hooks}{extra-pop-label}`,
1034  // whose `\use:e` (=`\edef`) chain expands `\q_no_value` and triggers
1035  // recursion-detect. See docs/sandbox_failures_SYNC_STATUS.md
1036  // "\q_no_value cascade" for the full investigation.
1037  let push_defined = lookup_definition(&T_CS!("\\@pushfilename"))?.is_some();
1038  let pop_defined = lookup_definition(&T_CS!("\\@popfilename"))?.is_some();
1039  if push_defined && pop_defined {
1040    let mut pushtoks = vec![
1041      T_CS!("\\@pushfilename"),
1042      T_BEGIN!(),
1043      T_END!(),
1044      T_BEGIN!(),
1045      T_END!(),
1046      T_BEGIN!(),
1047    ];
1048    pushtoks.extend(Explode!(name));
1049    pushtoks.push(T_END!());
1050    digest(Tokens::new(pushtoks))?;
1051  } else {
1052    digest(T_CS!("\\lx@pushfilename"))?;
1053  }
1054
1055  // For \RequirePackageWithOptions, pass the options from the outer class/style to the inner one.
1056  if let Some(with_options_to_pass) = options.withoptions.take()
1057    && !prevname.is_empty()
1058    && has_value(&s!("opt@{}.{}", prevname, prevext))
1059  {
1060    // Only pass those class options that are declared by the package!
1061    let mut topass = Vec::new();
1062    with_vecdeque("@declaredoptions", |vdq_opt| {
1063      if let Some(declared_options) = vdq_opt {
1064        for op in with_options_to_pass.into_iter() {
1065          if declared_options.iter().any(|x| {
1066            if let Stored::String(val) = x {
1067              arena::with(*val, |str| str == op)
1068            } else {
1069              false
1070            }
1071          }) {
1072            topass.push(op)
1073          }
1074        }
1075      }
1076    });
1077    if !topass.is_empty() {
1078      pass_options(name, as_type, topass)?;
1079    }
1080  }
1081  // Use letter-catcode (`ExplodeText`) for `\@currext` / `\@currname` so
1082  // they match `\@pkgextension`-style build-time-tokenized macros under
1083  // `\ifx`. Without this the catcodes diverge — `\@pkgextension` from a
1084  // compile-time `DefMacro!("\\@pkgextension", "sty")` tokenizes "sty"
1085  // as letters (default LaTeX catcode 11), but the previous `Explode!`
1086  // used here produces OTHER catcode tokens, so kvoptions's
1087  // `\ifx\@currext\@pkgextension` always returned false — vendor
1088  // `\PackageError{kvoptions}{\ProcessLocalKeyvalOptions is intended
1089  // for packages only}` then fired on every package that uses kvoptions
1090  // (rerunfilecheck reaches this via the hyperref backend `.def` chain).
1091  // Witnesses: arXiv:cond-mat/9611206, math/9904040, math/9904041.
1092  def_macro(
1093    T_CS!("\\@currname"),
1094    None,
1095    Tokens!(ExplodeText!(name)),
1096    None,
1097  )?;
1098  def_macro(
1099    T_CS!("\\@currext"),
1100    None,
1101    Tokens!(ExplodeText!(as_type)),
1102    None,
1103  )?;
1104  // reset options (Note reset & pass were in opposite order in LoadClass ????)
1105  reset_options()?;
1106  pass_options(name, as_type, options.options.clone())?;
1107
1108  // Note which packages are pretending to be classes.
1109  if options.as_class {
1110    push_value("@masquerading@as@class", arena::pin(name))?;
1111  }
1112  let current_opt_val = with_vecdeque(&s!("opt@{}.{}", name, as_type), |vdq_opt| match vdq_opt {
1113    Some(vdq) => {
1114      let mut pieces = String::new();
1115      for x in vdq.iter() {
1116        if let Stored::String(val) = x {
1117          arena::with(*val, |str| pieces.push_str(str));
1118        }
1119        pieces.push(',');
1120      }
1121      pieces.pop();
1122      pieces
1123    },
1124    None => String::new(),
1125  });
1126  // Use letter-catcode (`ExplodeText`) for the stored option list, same
1127  // reason as `\@currname`/`\@currext` above: real LaTeX stores the
1128  // `\usepackage[...]` option tokens with alphabetic chars as LETTER
1129  // (catcode 11). kvoptions/keyval `\setkeys` then binds a `\DeclareString-
1130  // Option` value (e.g. `\axp@bibliography`) from these tokens, and packages
1131  // validate it with the catcode-SENSITIVE `ifthen` `\equal` (or `\ifx`).
1132  // The previous `Explode!` produced OTHER (catcode 12) letters, so
1133  // `\equal{\axp@bibliography}{common}` spuriously failed and apxproof
1134  // raised `unsupported option bibliography=common`. Witness:
1135  // docs/known_crashes — gdsm.tex (apxproof + biblatex).
1136  def_macro(
1137    T_CS!(s!("\\opt@{}.{}", name, as_type)),
1138    None,
1139    Tokens!(ExplodeText!(current_opt_val)),
1140    None,
1141  )?;
1142  Ok(())
1143}
1144
1145/// configuration for input of a TeX source (content files mostly)
1146#[derive(Debug, Default, Clone)]
1147pub struct InputOptions {
1148  pub noerror:    bool,
1149  pub reloadable: bool,
1150  pub file_type:  Option<String>,
1151}
1152
1153/// Input for cases when the file (or data)
1154/// is plain TeX material that is expected to contribute content
1155/// to the document (as opposed to pure definitions).
1156///
1157/// A Mouth is opened onto the file, and subsequent reading
1158/// and/or digestion will pull Tokens from that Mouth until it is
1159/// exhausted, or closed.
1160///
1161/// In some circumstances it may be useful to provide a string containing
1162/// the TeX material explicitly, rather than referencing a file.
1163/// In this case, the `literal` pseudo-protocal may be used.
1164pub fn input_content(request: &str, options: InputOptions) -> Result<()> {
1165  let filepath = find_file(request, None);
1166  match filepath {
1167    // TODO: type => $options{type}, noltxml => 1
1168    Some(path) => load_tex_content(&path, options),
1169    None => {
1170      // Perl Package.pm L2227-2233: `if (FindFile(...)) { loadTeXContent(...); }
1171      // elsif (!$options{noerror}) { Error('missing_file', $request, ..., ...); }`
1172      // Recoverable Error, NOT Fatal. Pre-fix, the Rust port emitted a
1173      // `fatal!(Package, MissingFile)` that terminated the conversion on any
1174      // missing-but-non-critical input — over-fatal-izing relative to Perl.
1175      if !options.noerror {
1176        Error!(
1177          "missing_file",
1178          request,
1179          format!("Can't find TeX file {request}")
1180        );
1181      }
1182      Ok(())
1183    },
1184  }
1185}
1186
1187/// This is essentially the `\input` equivalent
1188///
1189/// we are most likely expecting to get actual content,
1190/// (possibly with definitions included, as well)
1191/// but might actually be getting pure definitions,
1192/// (like a proper style file)
1193/// in which case we may really want to load a binding.
1194/// Note that generic style files (non-latex) often have a .tex extension.
1195pub fn input(request: &str, options: InputOptions) -> Result<()> {
1196  // unwrap if in quotes \input{"file name"} — Perl parity:
1197  // `$request =~ s/^("+)(.+)\g1$/$2/;` (single-pass strip of a matching
1198  // leading+trailing run of quotes). The previous `while` loop checked
1199  // the unchanged `request`, which spun forever on any quoted input
1200  // since the replacement only touches `clean_req`.
1201  let clean_req = QUOTE_WRAPPED.replace(request, "$1");
1202  // HEURISTIC! First check if equivalent style file, but only under very specific circumstances
1203  // if pathname_is_literaldata(request) {
1204  //   let (dir, name, ftype) = pathname_split(request);
1205  //   let file = name;
1206  //   if !ftype.is_empty() {
1207  //     file += format!(".{}",ftype);
1208  //   }
1209  //   let path;
1210  //   // Firstly, check if we are going to OVERRIDE the requested raw .tex file
1211  //   // with a latexml binding to a style file.
1212  //   if ((dir.is_empty() && (ftype.is_empty() || (ftype == "tex"))  // No SPECIFIC directory, but
1213  // a raw tex file.       // AND, in preamble; SHOULD be style file, OR also if we can't find the
1214  // raw file.     && (LookupValue!("inPreamble") || !FindFile(file))
1215  //     && (path = FindFile(name, type => 'sty', notex => 1))) { // AND there IS such a style file
1216  //     Info!("ignore", request, stomach.get_gullet(),
1217  //       s!("Ignoring input of tex {}, using package {} instead", request, name));
1218  //     RequirePackage!(name); // Then override, assuming we'll find name as a package file!
1219  //     return;
1220  //   }
1221  // }
1222  // // Next special case: If we were currently reading a "known" style or binding file,
1223  // // then this file, even if .tex, must also be definitions rather than content.!!(?)
1224  // Check for *.latexml source-level bindings first — these are always handled
1225  // as definitions regardless of INTERPRETING_DEFINITIONS state.
1226  // Mirrors Perl's automatic .latexml file loading mechanism.
1227  if clean_req.ends_with(".latexml") {
1228    return input_definitions(&clean_req, InputDefinitionOptions::default());
1229  }
1230  if lookup_bool_sym(crate::pin!("INTERPRETING_DEFINITIONS")) {
1231    // Split a binding extension off the request so input_definitions sees
1232    // (name, extension) — matches Perl Package.pm `FindFile` / `Input`
1233    // semantics. Without the split, `find_file_fallback` runs with
1234    // `ext_type=""` and reconstructs `"<base>."` (no extension), which
1235    // never matches a registered binding. Witness: hep-ph9911514 — the
1236    // raw-loaded `elsartwb.sty` issues `\input elsart12\@ptsize.sty` →
1237    // `\input{elsart12.sty}`; the version-strip fallback (elsart12 →
1238    // elsart) needs `ext_type="sty"` to reconstruct `"elsart.sty"` for
1239    // the binding lookup. Perl recovers `\ack` cleanly via this path; the
1240    // earlier Rust port dropped the extension and the fallback never
1241    // resolved.
1242    let has_dir = clean_req.contains('/') || clean_req.contains('\\');
1243    if !has_dir
1244      && let Some((stem, ext)) = clean_req.rsplit_once('.')
1245      && is_binding_extension(ext)
1246    {
1247      return input_definitions(stem, InputDefinitionOptions {
1248        extension: Some(Cow::Owned(ext.to_string())),
1249        ..InputDefinitionOptions::default()
1250      });
1251    }
1252    return input_definitions(&clean_req, InputDefinitionOptions::default());
1253  }
1254  // Perl Package.pm L2109-2113: FindFile_aux checks for `"$file.ltxml"` in
1255  // $ltxml_paths BEFORE consulting raw TeX paths. In Rust the bindings are
1256  // compile-time dispatch tables rather than on-disk .ltxml files, so the
1257  // equivalent check is: if a binding dispatcher responds to `<name>.tex`,
1258  // load it (matching `\input harvmac` → `harvmac.tex.ltxml` preference
1259  // over a local `harvmac.tex`). Skip when the request carries a directory
1260  // (explicit local path).
1261  let binding_loaded = {
1262    let has_dir = clean_req.contains('/') || clean_req.contains('\\');
1263    // Perl Package.pm:2109-2113 + 2255-2270: when `\input{name}` or
1264    // `\input{name.<ext>}` resolves to a known binding extension AND a
1265    // binding for `(name, ext)` is reachable, route to the binding
1266    // instead of the on-disk raw file. Without this, papers using
1267    // literal `\input{psfig.sty}` (common 1996-2005 idiom) fail because
1268    // TL2025 dropped the on-disk file even though Rust has the binding.
1269    //
1270    // Extensions handled dynamically via `is_binding_extension`: any
1271    // extension registered by `latexml_package` or `latexml_contrib`
1272    // (cls / sty / def / fontmap / ldf / ltx / lua / pool / tex /
1273    // code.tex / ...) is admitted, gating out `\input{foo.eps}`-style
1274    // content paths.
1275    //
1276    // For .tex / no-extension paths we still use `load_binding` (exact
1277    // dispatch lookup on `<name>.tex`) — a `<name>.tex` request is
1278    // semantically "include this content", so suffix-stripping fallback
1279    // (e.g. `mysetup.tex` → `setup.tex.ltxml`) would surprise more than
1280    // it helps.
1281    //
1282    // For .sty / .cls / .def / etc — the binding-extension cases — we
1283    // route through `input_definitions`, which gives us the full Step
1284    // 1 → Step 3 → Step 4 ladder including `find_file_fallback`'s
1285    // version-suffix strip. This is what makes `\input{psfig.sty}`
1286    // pick up `psfig_sty.rs` AND `\input{caption2.sty}` fall back to
1287    // `caption_sty.rs` exactly as Perl Package.pm:2266 does via
1288    // `RequirePackage($name)`.
1289    if !has_dir {
1290      let ext = clean_req.rsplit('.').next().unwrap_or("");
1291      let no_ext = ext == clean_req.as_ref();
1292      if no_ext || ext == "tex" {
1293        let tex_name = if ext == "tex" {
1294          clean_req.to_string()
1295        } else {
1296          s!("{}.tex", clean_req)
1297        };
1298        load_binding(&tex_name)?.is_some() || load_external_binding(&tex_name)?.is_some()
1299      } else if is_binding_extension(ext) {
1300        // Route through input_definitions for fallback-aware dispatch.
1301        // The `name` arg expects no extension, so split it off.
1302        let name = clean_req
1303          .strip_suffix(&format!(".{}", ext))
1304          .unwrap_or(&clean_req)
1305          .to_string();
1306        let result = input_definitions(&name, InputDefinitionOptions {
1307          extension: Some(Cow::Owned(ext.to_string())),
1308          noerror: true,
1309          reloadable: true,
1310          ..InputDefinitionOptions::default()
1311        });
1312        // input_definitions returns Err on not-found with noerror=true;
1313        // treat that as "binding not loaded, fall through to raw".
1314        result.is_ok()
1315      } else {
1316        false
1317      }
1318    } else {
1319      false
1320    }
1321  };
1322  if binding_loaded {
1323    Ok(())
1324  } else if let Some(path) = find_file(&clean_req, None) {
1325    // Found something plausible..
1326    // let ftype = if pathname_is_literaldata(path) { "tex" } else {
1327    //   pathname_type(path)
1328    // };
1329
1330    //   // Should we be doing anything about options in the next 2 cases?..... I kinda think not,
1331    // but?   if (ftype == "rs") {                  // it's a LaTeXML binding.
1332    //     load_latexml(request, path);
1333    //   }
1334    //   // Else some sort of "known" definitions type file, but not simply 'tex'
1335    //   else if (ftype != "tex") && (pathname_is_raw(path)) {
1336    //     load_tex_definitions(request, path);
1337    //   } else {
1338    load_tex_content(&path, options)
1339  //   }
1340  } else {
1341    // Perl heuristic: if the file has no directory, and is a .tex or no extension,
1342    // try loading it as definitions (which checks for binding dispatchers).
1343    // This handles cases like \input tcilatex where tcilatex.tex.ltxml exists.
1344    let has_dir = clean_req.contains('/') || clean_req.contains('\\');
1345    let ext = clean_req.rsplit('.').next().unwrap_or("");
1346    let is_tex_like = ext == clean_req.as_ref() || ext == "tex"; // no extension or .tex
1347    if !has_dir && is_tex_like {
1348      // Try loading as a .tex binding (e.g. tcilatex → tcilatex.tex)
1349      let tex_name = if ext == "tex" {
1350        clean_req.to_string()
1351      } else {
1352        s!("{}.tex", clean_req)
1353      };
1354      if load_binding(&tex_name)?.is_some() {
1355        return Ok(());
1356      }
1357    }
1358    // Couldn't find anything?
1359    note_status(LogStatus::Missing, Some(request));
1360    Error!(
1361      "missing_file",
1362      request,
1363      s!("Can't find TeX file {}", request)
1364    );
1365    Ok(())
1366  }
1367}
1368
1369fn load_tex_definitions(
1370  request: &str,
1371  pathname: &str,
1372  reloadable: bool,
1373  at_letter: bool,
1374  grandparent_in_expl3: bool,
1375) -> Result<()> {
1376  // Perl Package.pm L2334: $STATE->getStomach->leaveHorizontal_internal;
1377  // Defensive cleanup before reading definitions — if we're somehow in
1378  // horizontal mode while bound to vertical (e.g. after \par-less inline
1379  // text), repack and flip MODE in-place. No-op in the common case but
1380  // matches Perl's pre-load state hygiene.
1381  leave_horizontal_internal();
1382
1383  // Snapshot expl3-state at load entry. The cleanup hook below should
1384  // only restore catcodes if THIS load activated expl3; if the calling
1385  // context was already in expl3 mode (e.g. tasks.sty has run
1386  // `\ExplSyntaxOn` and is now `\file_input:n` ing a child file like
1387  // tasks.cfg), we must preserve the active state for the caller.
1388  // Without this guard, the nested cleanup would reset `_` and `:` to
1389  // OTHER/SUB inside the parent's processing, breaking everything past
1390  // the nested load (e.g. tasks.sty line 817's `\file_input_stop:`).
1391  // Witness for this exact failure: arXiv:2602.21210, 2604.21347,
1392  // 2604.22630, 2604.23234, 2604.22528 (tasks.sty + expl3 cluster,
1393  // Task #20).
1394  let entered_expl3 = lookup_catcode('_') == Some(Catcode::LETTER);
1395
1396  if !pathname::is_literaldata(pathname) {
1397    // We can't analyze literal data's pathnames!
1398    // let (dir, name, extension) = pathname::split(pathname);
1399
1400    // Don't load if we've already loaded it before.
1401    // Note that we'll still load it if we've already loaded only the ltxml version
1402    // since someone's presumably asking _explicitly_ for the raw TeX version.
1403    // It's probably even the ltxml version is asking for it!!
1404    // Of course, now it will be marked and wont get reloaded!
1405    // Per OXIDIZED_DESIGN #23: raw .sty/.cls/.def load tracks
1406    // `<request>_raw_loaded`, separate from the binding `<request>_loaded`.
1407    // This lets a binding .rs load the raw file of the same name without
1408    // the flags clobbering each other.
1409    if lookup_bool(&s!("{request}_raw_loaded")) && !reloadable && !pathname::is_reloadable(pathname)
1410    {
1411      return Ok(());
1412    }
1413    assign_value(&s!("{request}_raw_loaded"), true, Some(Scope::Global));
1414  }
1415
1416  // Note that we are reading definitions (and recursive input is assumed also definitions)
1417  let was_interpreting = lookup_bool_sym(crate::pin!("INTERPRETING_DEFINITIONS"));
1418  // And that if we're interpreting this TeX file of definitions,
1419  // we probably should interpret any TeX files IT loads.
1420  let was_including_styles = lookup_bool("INCLUDE_STYLES");
1421  assign_value_sym(crate::pin!("INTERPRETING_DEFINITIONS"), true, None);
1422  // If we're reading in these definitions, probaly will accept included ones?
1423  // (but not forbid ltxml ?)
1424  assign_value("INCLUDE_STYLES", true, None);
1425  // When set, this variable allows redefinitions of locked defns.
1426  // It is set in before/after methods to allow local rebinding of commands
1427  // but loading of sources & bindings is typically done in before/after methods of constructors!
1428  // This re-locks defns during reading of TeX packages.
1429  local_state_unlocked(false);
1430  let content_str = lookup_string(&s!("{pathname}_contents"));
1431  let content = if content_str.is_empty() {
1432    None
1433  } else {
1434    Some(content_str)
1435  };
1436  let pathname_mouth = Mouth::create(pathname, MouthOptions {
1437    fordefinitions: true,
1438    at_letter,
1439    notes: true,
1440    content,
1441    ..MouthOptions::default()
1442  })?;
1443
1444  gullet::reading_from_mouth(pathname_mouth, move || -> Result<()> {
1445    while let Some(token) = gullet::read_x_token(Some(false), false, None)? {
1446      if token != T_SPACE!() {
1447        invoke_token(&token)?;
1448      }
1449    }
1450    Ok(())
1451  })?;
1452
1453  // Expl3 scope-exit cleanup: if a raw .sty load activated expl3 catcodes
1454  // via `\ProvidesExplPackage` or explicit `\ExplSyntaxOn` and forgot to
1455  // pair it with `\ExplSyntaxOff` (e.g. lipsum.sty, which relies on an
1456  // `\AtEndOfPackage`-style hook the autoload chain doesn't register),
1457  // digest `\ExplSyntaxOff` now so the pending `\group_begin:` frame pops
1458  // and catcodes restore before the next package loads.
1459  //
1460  // Perl's `TeX.pool.ltxml` L44-47 acknowledges this as a known edge-
1461  // case of the `\ProvidesExplPackage` autoload pattern.
1462  //
1463  // Skip expl3 / xparse / l3keys2e / expl3-code — those legitimately
1464  // leave expl3 active for their callers.
1465  {
1466    let (_, base, _ext) = pathname::split(pathname);
1467    let is_expl3_core = matches!(
1468      base.as_str(),
1469      "expl3" | "xparse" | "l3keys2e" | "expl3-code"
1470    );
1471    // Use grandparent_in_expl3 (snapshotted before `\@pushfilename`)
1472    // rather than entered_expl3 (snapshotted after the push flipped `_`
1473    // to SUB). Without this, sub-loads inside an active expl3 frame
1474    // saw entered_expl3=false (because of the push's `\ExplSyntaxOff`)
1475    // and over-fired `\ExplSyntaxOff` at exit, which then leaks SUB
1476    // into the grandparent's continued reading once `\@popfilename`
1477    // pops the status stack and would otherwise restore `\ExplSyntaxOn`.
1478    // Witness: `\usepackage{xsavebox}` minimal repro (xsavebox →
1479    // sys_load_backend → l3backend-dvips.def); arXiv:2509.05997/.07893/
1480    // .02344, 2510.13206/.13942/.17317.
1481    if !is_expl3_core
1482      && !grandparent_in_expl3
1483      && lookup_catcode('_') == Some(Catcode::LETTER)
1484      && lookup_definition(&T_CS!("\\ExplSyntaxOff"))?.is_some()
1485    {
1486      let _ = invoke_token(&T_CS!("\\ExplSyntaxOff"));
1487    }
1488    let _ = entered_expl3; // kept for historical context
1489  }
1490
1491  assign_value_sym(
1492    crate::pin!("INTERPRETING_DEFINITIONS"),
1493    was_interpreting,
1494    None,
1495  );
1496  assign_value("INCLUDE_STYLES", was_including_styles, None);
1497  expire_state_unlocked();
1498
1499  // Perl Package.pm L2376: Let(T_CS('\ver@'.$request), T_CS('\fmtversion'), 'global');
1500  // Mark the raw .sty/.tex as loaded so LaTeX's `\@ifpackageloaded` and
1501  // `\RequirePackage` date-version guards work after a raw TeX load. Perl
1502  // unconditionally Lets here (in contrast to the LTXML loader at line 339,
1503  // which only Lets when undefined).
1504  let ver_cs = T_CS!(s!("\\ver@{}", request));
1505  let_i(&ver_cs, &T_CS!("\\fmtversion"), Some(Scope::Global));
1506
1507  Ok(())
1508}
1509
1510pub fn load_tex_content(path: &str, _options: InputOptions) -> Result<()> {
1511  // If there is a file-specific declaration file (name_tex.rs), load it first!
1512  // TODO: is this `.latexml` variation still relevant in the Rust port?
1513  let _has_binding = if !pathname::is_literaldata(path) {
1514    let (_dir, base, _ext) = pathname::split(path);
1515    load_external_binding(&base)?.is_some() || load_binding(&base)?.is_some()
1516  } else {
1517    false
1518  };
1519
1520  // Open a mouth for that TeX content
1521  let cached = lookup_string(&s!("{path}_contents"));
1522  let cached_opt = if cached.is_empty() {
1523    None
1524  } else {
1525    Some(cached)
1526  };
1527  gullet::open_mouth(
1528    Mouth::create(path, MouthOptions {
1529      notes: true,
1530      content: cached_opt,
1531      ..MouthOptions::default()
1532    })?,
1533    true,
1534  );
1535  Ok(())
1536}
1537
1538/// Pass the sequence of @options to the package $name (if $ext is 'sty'),
1539/// or class $name (if $ext is 'cls').
1540/// Perl Package.pm: PassOptions($name, $ext, @options)
1541/// Stores options to be processed when the package/class is loaded.
1542pub fn pass_options(name: &str, ext: &str, options: Vec<String>) -> Result<()> {
1543  let key = s!("opt@{}.{}", name, ext);
1544  for opt in options {
1545    push_value(&key, arena::pin(&opt))?;
1546  }
1547  Ok(())
1548}
1549
1550/// Perl Package.pm L2430-2465: ProcessOptions / ProcessOptions*
1551/// `inorder=false` (\ProcessOptions) — execute in declared order, default handler for undeclared
1552/// `inorder=true` (\ProcessOptions*) — execute in order passed, class options silently skipped
1553pub fn process_options(inorder: bool, keysets: &[&str]) -> Result<()> {
1554  let currname_token = T_CS!("\\@currname");
1555  let currext_token = T_CS!("\\@currext");
1556  let name = if lookup_definition(&currname_token)?.is_some() {
1557    do_expand(currname_token)?.to_string()
1558  } else {
1559    String::new()
1560  };
1561  let ext = if lookup_definition(&currext_token)?.is_some() {
1562    do_expand(currext_token)?.to_string()
1563  } else {
1564    String::new()
1565  };
1566  let declared_options: VecDeque<Stored> = lookup_vecdeque("@declaredoptions").unwrap_or_default();
1567  let opt_key = s!("opt@{}.{}", name, ext);
1568  let current_options = lookup_vecdeque(&opt_key).unwrap_or_default();
1569  let class_options = lookup_vecdeque("class_options").unwrap_or_default();
1570
1571  let collect_syms = |vdq: &VecDeque<Stored>| -> Vec<SymStr> {
1572    let mut list = Vec::new();
1573    for item in vdq.iter() {
1574      match item {
1575        Stored::String(s) => {
1576          list.push(*s);
1577        },
1578        Stored::Strings(ss) => {
1579          for s in ss.iter() {
1580            list.push(*s);
1581          }
1582        },
1583        _ => {},
1584      }
1585    }
1586    list
1587  };
1588  let cur_options_list = collect_syms(&current_options);
1589  let cls_options_list = collect_syms(&class_options);
1590
1591  if inorder {
1592    // Perl L2447-2453: ProcessOptions* — execute in the order passed
1593    // Class options: try executeOption_internal only (no default fallback)
1594    for option in &cls_options_list {
1595      let _ = execute_option_internal(*option, keysets)?;
1596    }
1597    // Current options: try executeOption, then default handler
1598    for option in &cur_options_list {
1599      if !execute_option_internal(*option, keysets)? {
1600        execute_default_option_internal(*option)?;
1601      }
1602    }
1603  } else {
1604    // Perl L2454-2461: ProcessOptions — execute in declared order
1605    let mut cur_set: HashSet<SymStr> = cur_options_list.iter().copied().collect();
1606    let mut cls_set: HashSet<SymStr> = cls_options_list.iter().copied().collect();
1607
1608    for option in declared_options.iter() {
1609      match option {
1610        Stored::String(content) if cur_set.remove(content) || cls_set.remove(content) => {
1611          execute_option_internal(*content, keysets)?;
1612        },
1613        Stored::Strings(contents) => {
1614          for content in contents.iter() {
1615            if cur_set.remove(content) || cls_set.remove(content) {
1616              execute_option_internal(*content, keysets)?;
1617            }
1618          }
1619        },
1620        _ => {},
1621      }
1622    }
1623    // Only undeclared CURRENT options go to default handler (not class options).
1624    // Perl L2460-2461: "foreach my $option (@curroptions)" — class options excluded.
1625    // Iterate cur_options_list (Vec, ordered) instead of cur_set (HashSet,
1626    // unordered) so unknown options enter `@unusedoptionlist` in source
1627    // order. Otherwise `\documentstyle[a,b,c]` produces an arbitrary
1628    // dispatch order, which breaks paper-local option chains that depend
1629    // on left-to-right evaluation (e.g. `[aaspp4,tighten]` requires
1630    // aaspp4's bindings — \tightenlines — to be defined before tighten.sty
1631    // body fires; driver: astro-ph9707180).
1632    for option in &cur_options_list {
1633      if cur_set.contains(option) {
1634        execute_default_option_internal(*option)?;
1635      }
1636    }
1637  }
1638  // Now, undefine the handlers
1639  for option in declared_options.iter() {
1640    let_i(&T_CS!(s!("\\ds@{}", option)), &T_RELAX!(), None);
1641  }
1642  Ok(())
1643}
1644
1645fn execute_option_internal(option: SymStr, keysets: &[&str]) -> Result<bool> {
1646  if let Some((qname, value)) = keyval_option_qname(option, keysets) {
1647    // Perl Package.pm handles `key=value` package options before normal
1648    // `\ds@...` lookup when ProcessOptions was given keysets. It digests
1649    // `\KV@<keyset>@<key>{<value>}`. Rust DefKeyVal entries without code
1650    // are ordinary macros, so also store the value under the qname for
1651    // package bindings that apply keyvals after ProcessOptions.
1652    assign_value(
1653      &qname,
1654      Stored::String(arena::pin(value.trim())),
1655      Some(Scope::Global),
1656    );
1657    digest(Tokens!(
1658      T_CS!(s!("\\{qname}")),
1659      T_BEGIN!(),
1660      ExplodeText!(&value),
1661      T_END!()
1662    ))?;
1663    return Ok(true);
1664  }
1665
1666  let cs = T_CS!(arena::with(option, |opt| s!("\\ds@{opt}")));
1667  if lookup_definition(&cs)?.is_some() {
1668    // Perl Package.pm L2482: `DefMacroI('\CurrentOption', undef, $option)` —
1669    // tokenizes `$option` via Tokens(Explode($option)) so letters get
1670    // catcode LETTER and others OTHER. Babel's `\ifx\CurrentOption\bbl@tempa`
1671    // (where `\bbl@tempa{frenchb}` produces LETTER tokens) only matches when
1672    // our `\CurrentOption` body has the same catcodes — packing the whole
1673    // option string into one OTHER-catcode "string" token would make the
1674    // \ifx silently false. Use SymExplodeText! to split per-character.
1675    def_macro(
1676      T_CS!("\\CurrentOption"),
1677      None,
1678      Tokens!(SymExplodeText!(option)),
1679      None,
1680    )?;
1681
1682    let unused = match remove_vecdeque("@unusedoptionlist") {
1683      Some(list) => list
1684        .into_iter()
1685        .filter(|item| {
1686          if let Stored::String(content) = item {
1687            *content != option
1688          } else {
1689            false
1690          }
1691        })
1692        .collect(),
1693      None => VecDeque::new(),
1694    };
1695    assign_value("@unusedoptionlist", Stored::VecDequeStored(unused), None);
1696    digest(cs)?;
1697    Ok(true)
1698  } else {
1699    Ok(false)
1700  }
1701}
1702
1703fn keyval_option_qname(option: SymStr, keysets: &[&str]) -> Option<(String, String)> {
1704  if keysets.is_empty() {
1705    return None;
1706  }
1707  let (key, value) = arena::with(option, |opt| {
1708    opt
1709      .split_once('=')
1710      .map(|(key, value)| (key.trim().to_string(), value.trim().to_string()))
1711  })?;
1712  if key.is_empty() {
1713    return None;
1714  }
1715  for keyset in keysets {
1716    let qname = crate::keyval::keyval_qname("KV", keyset, &key);
1717    if crate::keyval::keyval_get(&qname, "type").is_some() {
1718      return Some((qname, value));
1719    }
1720  }
1721  None
1722}
1723
1724fn execute_default_option_internal(option: SymStr) -> Result<bool> {
1725  // Perl Package.pm L2494: `DefMacroI('\CurrentOption', undef, $option)`.
1726  // Same catcode-faithful tokenization as execute_option_internal.
1727  def_macro(
1728    T_CS!("\\CurrentOption"),
1729    None,
1730    Tokens!(SymExplodeText!(option)),
1731    None,
1732  )?;
1733  digest(T_CS!("\\default@ds"))?;
1734  Ok(true)
1735}
1736
1737fn reset_options() -> Result<()> {
1738  assign_value(
1739    "@declaredoptions",
1740    Stored::VecDequeStored(VecDeque::new()),
1741    None,
1742  );
1743  let opt_unused_cs = if do_expand(T_CS!("\\@currext"))?.eq_text("cls") {
1744    "\\OptionNotUsed"
1745  } else {
1746    "\\@unknownoptionerror"
1747  };
1748  let_i(&T_CS!("\\default@ds"), &T_CS!(opt_unused_cs), None);
1749  Ok(())
1750}
1751
1752/// Execute a list of options (Perl: ExecuteOptions).
1753/// Tries each option's \ds@{option} definition; logs unexpected ones.
1754pub fn execute_options(options: &[&str]) -> Result<()> {
1755  let mut unhandled = Vec::new();
1756  for option in options {
1757    let sym = arena::pin(*option);
1758    if !execute_option_internal(sym, &[])? {
1759      unhandled.push(*option);
1760    }
1761  }
1762  for option in &unhandled {
1763    Info!(
1764      "unexpected",
1765      *option,
1766      s!("Unexpected options passed to ExecuteOptions '{option}'")
1767    );
1768  }
1769  Ok(())
1770}
1771
1772pub struct RequireOptions {
1773  pub options:          Vec<String>,
1774  pub withoptions:      Option<Vec<String>>,
1775  pub extension:        Option<Cow<'static, str>>,
1776  pub searchpaths_only: bool,
1777  pub as_class:         bool,
1778  pub noltxml:          Option<bool>,
1779  pub notex:            Option<bool>,
1780  pub after:            Tokens,
1781}
1782impl Default for RequireOptions {
1783  fn default() -> Self {
1784    RequireOptions {
1785      options:          Vec::new(),
1786      withoptions:      None,
1787      extension:        None,
1788      notex:            None,
1789      noltxml:          None,
1790      as_class:         false,
1791      searchpaths_only: false,
1792      after:            Tokens!(),
1793    }
1794  }
1795}
1796
1797/// An opinionated binding for \RequirePackage.
1798///
1799/// This (and `FindFile`) needs to evolve a bit to support reading raw .sty (.def, etc) files from
1800/// the standard texmf directories.  Maybe even use kpsewhich itself (INSTEAD of `pathname_find`
1801/// ???) Another potentially useful option might be that if we are reading a raw file,
1802/// perhaps it should just get digested immediately, since it shouldn't contribute any boxes.
1803pub fn require_package(name: &str, mut options: RequireOptions) -> Result<()> {
1804  // Perl Package.pm L2671-2672: notex defaults to true unless the user
1805  // explicitly set it, or INCLUDE_STYLES is true, or noltxml was passed
1806  // (a raw-only load explicitly requests raw TeX).
1807  if options.notex.is_none()
1808    && !lookup_bool("INCLUDE_STYLES")
1809    && !matches!(options.noltxml, Some(true))
1810  {
1811    options.notex = Some(true);
1812  }
1813  // Perl Package.pm L2674: top-level \RequirePackage can be limited to
1814  // local sources via searchpaths_only. Triggered by the `localrawstyles`
1815  // option to latexml.sty (sets `INCLUDE_STYLES => 'searchpaths'`).
1816  // Only applies when raw TeX is allowed (notex==false); otherwise the
1817  // gate is moot since find_file won't search on-disk anyway.
1818  if !options.searchpaths_only
1819    && !matches!(options.notex, Some(true))
1820    && lookup_string("INCLUDE_STYLES") == "searchpaths"
1821  {
1822    options.searchpaths_only = true;
1823  }
1824  if options.extension.is_none() {
1825    options.extension = Some("sty".into());
1826  }
1827  // OXIDIZED_DESIGN #65 (#311): a package loaded inside a bracket LaTeXML ITSELF
1828  // opened must still end up defined at the outermost level, so hoist the load's
1829  // meaning-delta past it. Real LaTeX has no such bracket — `\@fileswithoptions`
1830  // (latex.ltx L18700) refuses to load at `\currentgrouplevel > 0` — but we
1831  // execute a subfile preamble the real `standalone.sty` gobbles (divergence
1832  // #63), which splits the package in half: frame-local definitions, global
1833  // hooks. Witness: `pgfcoreexternal.code.tex` L152
1834  // `\newif\ifpgf@external@grabshipout` popped with the child while its L171-179
1835  // `\AtEndDocument` reaches the parent's `\end{document}`. `tex.rs::def_autoload`
1836  // already used this snapshot/hoist pair for the mirror-image autoload failure
1837  // (witness 1711.11576).
1838  //
1839  // `subfile:<depth>`, not a bare depth test: a group the AUTHOR wrote must keep real
1840  // LaTeX's verdict — `{\usepackage{amsthm}}` leaves `\theoremstyle` undefined in
1841  // pdflatex and Perl alike, and rescuing it would emit fewer errors than Perl on
1842  // an authoring mistake (guards
1843  // `06_cluster_regressions::author_written_group_around_usepackage_still_loses_the_package`,
1844  // and `100_stale_autoload_no_runaway` from a fresh process). The
1845  // two cases are indistinguishable by anything cheaper: both are a group opened
1846  // inside the current file, while `inPreamble` is true, at `\currentgrouplevel`
1847  // 1. Refuted alternatives in #65 — dropping the brackets, and `\globaldefs=1`.
1848  let pre_keys = if is_scope_active(subfile_scope_here()) {
1849    Some(snapshot_top_frame_meaning_keys())
1850  } else {
1851    None
1852  };
1853  let result = input_definitions(name, InputDefinitionOptions {
1854    extension: options.extension,
1855    handleoptions: true,
1856    // Pass classes options if we have NONE!
1857    withoptions: if options.options.is_empty() {
1858      Some(Vec::new())
1859    } else {
1860      None
1861    }, // fake boolean use, multi-type in latexml... refactor?
1862    options: options.options,
1863    as_class: options.as_class,
1864    noltxml: options.noltxml.unwrap_or(false),
1865    notex: options.notex.unwrap_or(false),
1866    searchpaths_only: options.searchpaths_only,
1867    after: options.after,
1868    ..InputDefinitionOptions::default()
1869  });
1870  if let Some(pre_keys) = pre_keys {
1871    hoist_top_frame_meaning_delta(&pre_keys);
1872  }
1873  // Perl Package.pm L2679 maybeRequireDependencies is invoked from
1874  // input_definitions's miss-handler. But that handler only runs when
1875  // the file was NOT found at all. For paper-bundled .sty files that
1876  // raw-load successfully without a .sty.ltxml binding, we still need
1877  // to scan transitive \RequirePackage so that bound deps fire too.
1878  // Mirrors the same fix for load_class (search "cls.ltxml_loaded").
1879  // Witness 2208.07400 (paper-bundled emnlp2022.sty has
1880  // \RequirePackage{caption} + others; without scan, \captionsetup
1881  // and similar are undefined).
1882  if !lookup_bool(&s!("{name}.sty.ltxml_loaded")) {
1883    maybe_require_dependencies(name, "sty");
1884  }
1885  result
1886}
1887
1888/// Perl: `RequirePackage($name, withoptions => 1)` — forward the current
1889/// package/class's options to the required child package. Reads
1890/// `\@currname` / `\@currext` to identify the caller, looks up its
1891/// `opt@<name>.<ext>` options, and passes them explicitly as the child's
1892/// options list. Mirrors `load_class_with_options` for the package path.
1893pub fn require_package_with_options(name: &str) -> Result<()> {
1894  let currname = if lookup_definition(&T_CS!("\\@currname"))?.is_some() {
1895    do_expand(T_CS!("\\@currname"))?.to_string()
1896  } else {
1897    String::new()
1898  };
1899  let currext = if lookup_definition(&T_CS!("\\@currext"))?.is_some() {
1900    do_expand(T_CS!("\\@currext"))?.to_string()
1901  } else {
1902    String::new()
1903  };
1904  let options: Vec<String> = if !currname.is_empty() {
1905    let key = s!("opt@{}.{}", currname, currext);
1906    lookup_vecdeque(&key)
1907      .unwrap_or_default()
1908      .iter()
1909      .filter_map(|item| match item {
1910        Stored::String(s) => Some(arena::to_string(*s)),
1911        _ => None,
1912      })
1913      .collect()
1914  } else {
1915    Vec::new()
1916  };
1917  require_package(name, RequireOptions {
1918    options,
1919    ..RequireOptions::default()
1920  })
1921}
1922
1923/// Perl Package.pm L2759-2796: maybeRequireDependencies
1924/// When a package/class file has no binding AND raw TeX loading is disabled,
1925/// scan the raw file for \RequirePackage/\usepackage/\LoadClass declarations
1926/// and load any dependencies that DO have bindings. This is a "best effort"
1927/// fallback that gives us the dependency chain without interpreting raw TeX.
1928// Strict translation of Perl `Package.pm:maybeRequireDependencies`
1929// (L2759-L2796). Scan a raw .sty/.cls file for transitive
1930// `\RequirePackage`, `\usepackage`, and (for classes) `\LoadClass`
1931// declarations and route them through `require_package` / `load_class`
1932// so the corresponding bindings get pulled in even when the original
1933// file has no .ltxml binding.
1934/// The `\addbibresource` names declared in a raw `.sty`/`.cls` body.
1935///
1936/// Split out of [`maybe_require_dependencies`] so the extraction is testable
1937/// without state: it is the whole of the BEYOND-PERL behaviour described there.
1938/// A comma-separated argument declares several resources at once, as biblatex
1939/// allows.
1940fn scan_bib_resources(code: &str) -> Vec<String> {
1941  use once_cell::sync::Lazy;
1942  use regex::Regex;
1943  static BIBRES_RE: Lazy<Regex> =
1944    Lazy::new(|| Regex::new(r"\\addbibresource\s*(?:\[[^\]]*\])?\s*\{([^\}]*)\}").unwrap());
1945  let mut out = Vec::new();
1946  for cap in BIBRES_RE.captures_iter(code) {
1947    for part in cap[1].split(',') {
1948      let name = part.trim();
1949      if !name.is_empty() && !out.iter().any(|o: &String| o == name) {
1950        out.push(name.to_string());
1951      }
1952    }
1953  }
1954  out
1955}
1956
1957#[cfg(test)]
1958mod bib_resource_scan_tests {
1959  use super::scan_bib_resources;
1960
1961  /// A journal class shipped with the paper declares the bibliography itself —
1962  /// `journaleducation.cls` (witness 2605.23724, 0 -> 35 entries),
1963  /// `cai26.cls` (2605.00270, 0 -> 25), `tau.cls` (2605.02720, 0 -> 31) — and
1964  /// the document then only writes `\printbibliography`. We never interpret
1965  /// that class, so this scan is the only place the declaration is visible.
1966  #[test]
1967  fn harvests_addbibresource_from_a_shipped_class() {
1968    assert_eq!(
1969      scan_bib_resources(r"\addbibresource{references.bib}"),
1970      vec!["references.bib"]
1971    );
1972    // A path, as journaleducation.cls writes it.
1973    assert_eq!(
1974      scan_bib_resources(r"\addbibresource{letters/refs.bib}"),
1975      vec!["letters/refs.bib"]
1976    );
1977    // biblatex's optional argument is skipped, not treated as the resource.
1978    assert_eq!(
1979      scan_bib_resources(r"\addbibresource[location=local]{tau.bib}"),
1980      vec!["tau.bib"]
1981    );
1982    // Several resources, and repeats collapse — naming a `.bib` twice would
1983    // make `\lx@bibliography` read it twice and double every entry.
1984    assert_eq!(
1985      scan_bib_resources("\\addbibresource{a.bib, b.bib}\n\\addbibresource{a.bib}"),
1986      vec!["a.bib", "b.bib"]
1987    );
1988    // Nothing to harvest is the common case and must stay empty.
1989    assert!(scan_bib_resources(r"\RequirePackage{biblatex}").is_empty());
1990  }
1991}
1992
1993fn maybe_require_dependencies(file: &str, ext_type: &str) {
1994  use once_cell::sync::Lazy;
1995  use regex::Regex;
1996
1997  // Rust-only re-entrancy guard. Perl avoids this case by other means
1998  // (the call-site of `maybeRequireDependencies` is the only entry).
1999  thread_local! { static SCANNING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) }; }
2000  if SCANNING.with(|s| s.get()) {
2001    return;
2002  }
2003  SCANNING.with(|s| s.set(true));
2004  struct ResetGuard;
2005  impl Drop for ResetGuard {
2006    fn drop(&mut self) { SCANNING.with(|s| s.set(false)); }
2007  }
2008  let _guard = ResetGuard;
2009
2010  // EXECUTED-SET GATE (Rust-only, more-robust-than-Perl). When this file was
2011  // actually RAW-LOADED, its `\usepackage`/`\RequirePackage` constructors ran
2012  // for every require the load REACHED — recording `<pkg>.usepackage_executed`.
2013  // A candidate the regex finds in the text but that is NOT in that set is one
2014  // whose `\usepackage` never executed — i.e. it sits inside a FALSE `\if…\fi`
2015  // the raw-load already skipped (e.g. `\ifpdf … \usepackage{hyperref} … \fi`
2016  // with `\ifpdf` false). Perl never dep-scans a raw-loaded file, so it never
2017  // anticipates such a package; mirror that by skipping it. The gate applies
2018  // ONLY when the file raw-loaded (key `<file>.<ext>_raw_loaded`): in the
2019  // miss-handler / `INCLUDE_STYLES=false` path no constructor ran, so the set
2020  // is empty and we must NOT filter (keep the anticipation). The flag is
2021  // cumulative+global, so a candidate that DID execute anywhere is kept — only
2022  // never-executed ones are dropped, which can never be a false skip.
2023  // Witnesses 1910.05586 (hyperref in false `\ifpdf` → cleveref "must be loaded
2024  // after hyperref"), 1804.09301 (xcolor in false `\ifacl@hyperref`).
2025  let raw_loaded = lookup_bool(&s!("{file}.{ext_type}_raw_loaded"));
2026
2027  // Perl L2776: `s/%[^\n]*\n//gs` — drop comment AND its trailing newline,
2028  // replacement is the empty string.
2029  static COMMENT_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"%[^\n]*\n").unwrap());
2030  // `comment`-package block: `\begin{comment}…\end{comment}` is a verbatim-SKIP
2031  // environment, so a `\usepackage`/`\RequirePackage` inside it is NEVER loaded
2032  // by LaTeX. The dep-scan must not anticipate it. Same "more-robust than Perl"
2033  // rationale as the macro-def-body skip below. Witness 1901.05713: thesis.sty
2034  // has a commented-out `\usepackage{hyperref}` inside `\begin{comment}`, which
2035  // the scan otherwise loaded — tripping cleveref's "must be loaded after
2036  // hyperref" `\AtBeginDocument` order-check (hyperref appears loaded though the
2037  // author commented it out). `comment` doesn't nest, so a non-greedy match to
2038  // the first `\end{comment}` is correct.
2039  static COMMENT_ENV_RE: Lazy<Regex> =
2040    Lazy::new(|| Regex::new(r"(?s)\\begin\s*\{comment\}.*?\\end\s*\{comment\}").unwrap());
2041  // Perl L2777-2779 runs two separate substitutions, in this order:
2042  // first `\RequirePackage`, then `\usepackage`. Use two regexes so that
2043  // collected order matches Perl's call order to `$collect`.
2044  static REQ_RE: Lazy<Regex> =
2045    Lazy::new(|| Regex::new(r"\\RequirePackage\s*(?:\[([^\]]*)\])?\s*\{([^\}]*)\}").unwrap());
2046  static USE_RE: Lazy<Regex> =
2047    Lazy::new(|| Regex::new(r"\\usepackage\s*(?:\[([^\]]*)\])?\s*\{([^\}]*)\}").unwrap());
2048  static CLS_RE: Lazy<Regex> =
2049    Lazy::new(|| Regex::new(r"\\LoadClass\s*(?:\[([^\]]*)\])?\s*\{([^\}]*)\}").unwrap());
2050  // Matches a `\newcommand`/`\def`-family DEFINITION HEADER ending exactly at a
2051  // `{` — i.e. the brace that follows opens the macro BODY. Used to detect a
2052  // `\usepackage` that lives in a deferred macro body (loads only when the
2053  // macro is later expanded) vs one inside a load-time conditional.
2054  static DEF_BODY_HEADER_RE: Lazy<Regex> = Lazy::new(|| {
2055    Regex::new(
2056      r"(?s)(?:\\(?:re|provide)?newcommand|\\DeclareRobustCommand)\*?\s*(?:\{\s*\\[A-Za-z@]+\s*\}|\\[A-Za-z@]+)\s*(?:\[[^\]]*\]\s*)*$|\\[egx]?def\s*\\[A-Za-z@]+[^{}]*$",
2057    )
2058    .unwrap()
2059  });
2060  // The `\def`-family sub-case of DEF_BODY_HEADER_RE, capturing the defined
2061  // macro NAME (without backslash). A `\def\<m>{… \RequirePackage{P} …}` body
2062  // is only truly deferred if `\<m>` is never invoked; the AMS-class idiom
2063  // `\def\@tempa{\RequirePackage{amsmath}}…\@tempa` invokes it immediately and
2064  // so DOES load P (witness ijnam.cls → amsmath → `{aligned}`, 1911.03415).
2065  static DEF_NAME_RE: Lazy<Regex> =
2066    Lazy::new(|| Regex::new(r"(?s)\\[egx]?def\s*\\([A-Za-z@]+)[^{}]*$").unwrap());
2067  // A require inside a `\DeclareOption{<opt>}{…}` (or `\DeclareOption*{…}`)
2068  // code-arm is CONDITIONAL on the option being selected (resolved later by
2069  // `\ProcessOptions`), so the dep-scan must not eagerly load it. Matches when a
2070  // group's preceding window ends at `…\DeclareOption{<opt>}` / `…\DeclareOption*`
2071  // — i.e. the `{` it precedes opens the option's code arm. (Does NOT match
2072  // `\DeclareOptionX`/`\ExecuteOptions`/`\ProcessOptions`.)
2073  static DECLARE_OPTION_RE: Lazy<Regex> =
2074    Lazy::new(|| Regex::new(r"(?s)\\DeclareOption\*?\s*(?:\{[^{}]*\})?\s*$").unwrap());
2075
2076  // Perl L2761: `FindFile($file, type => $type, noltxml => 1)`. `$file`
2077  // is BARE — `FindFile` glues on `.$type` itself per L2073-2076.
2078  let raw_path = find_file(
2079    file,
2080    Some(FindFileOptions {
2081      ext_type: Some(Cow::Owned(ext_type.to_string())),
2082      forbid_ltxml: true, // Perl `noltxml => 1`
2083      ..FindFileOptions::default()
2084    }),
2085  );
2086  let Some(path) = raw_path else { return };
2087
2088  // Perl L2762-2766: slurp file. Check filecontents-cache first for the
2089  // inline-cls/sty case (e.g. `\begin{filecontents}{alggeom.cls}`), then
2090  // fall through to disk. Without the cache check, papers that bundle
2091  // their .cls inline via filecontents miss the dep-scan and downstream
2092  // CSes that the (now-cached) cls would have hand-loaded stay
2093  // undefined. Witness: arXiv:2604.09738.
2094  let cached = lookup_string(&s!("{}_contents", path));
2095  let code = if !cached.is_empty() {
2096    cached
2097  } else {
2098    // Use read (bytes) + UTF-8 conversion so non-UTF-8 cls/sty files
2099    // (ISO-8859 with vendor copyright headers, e.g. cpc-hepnp.cls with
2100    // Chinese comments) still get scanned. read_to_string strict UTF-8
2101    // validation would error out, leaving \RequirePackage{fancyhdr} and
2102    // friends silently undiscovered. Witness 2203.16500.
2103    //
2104    // Fast path for valid UTF-8 (the overwhelming majority): `str::from_utf8`
2105    // SIMD-validates the whole slice in one pass, where `from_utf8_lossy`
2106    // walks it through the grapheme-aware `Utf8Chunks` iterator hunting for
2107    // invalid bytes — measurable on a 197 KB class file like ieeeconf.cls.
2108    // Byte-identical to the lossy path for valid UTF-8; fall back to lossy
2109    // only on invalid bytes. Same pattern as the per-line reader in `mouth.rs`.
2110    match std::fs::read(&path) {
2111      Ok(bytes) => match str::from_utf8(&bytes) {
2112        Ok(s) => s.to_string(),
2113        Err(_) => String::from_utf8_lossy(&bytes).into_owned(),
2114      },
2115      Err(_) => {
2116        Warn!(
2117          "I/O",
2118          "read",
2119          s!("Couldn't open {} to scan dependencies, $!", path)
2120        );
2121        return;
2122      },
2123    }
2124  };
2125
2126  // Perl L2776: strip comments (replacement empty).
2127  let code = COMMENT_RE.replace_all(&code, "");
2128  // Strip `\begin{comment}…\end{comment}` blocks (see COMMENT_ENV_RE above).
2129  let code = COMMENT_ENV_RE.replace_all(&code, "");
2130
2131  // DIVERGENCE FROM PERL (deliberate, more-robust): do NOT dep-load a
2132  // `\usepackage` / `\RequirePackage` that sits in a DEFERRED macro-definition
2133  // body (a `\newcommand` / `\def`-family body) — it loads only if that macro
2134  // is later expanded, which the dep-scan must not eagerly force. Requires
2135  // inside LOAD-TIME conditionals (`\IfFileExists{X.sty}{\usepackage{X}}`,
2136  // `\@ifundefined{..}{..}`, `\if…\usepackage…\fi`) DO execute during raw-load
2137  // and MUST still be picked up. (Perl never force-loads the deferred ones: for
2138  // a normally raw-loaded file it doesn't dep-scan at all, and the bundled deps
2139  // it wants in the binding-bypass case run at load.) Witnesses:
2140  //   - 1506.06200 — categorytheory.sty `\newcommand{\usediagrams}{\usepackage {diagrams}}` (never
2141  //     invoked) must be SKIPPED (else the `diagrams` stub's `locked` `\begin{diagram}` shadows the
2142  //     paper's tikz `{diagram}`).
2143  //   - 1703.03673 — iau.cls `\IfFileExists{amssymb.sty}{…\usepackage{amssymb}…}` must be KEPT
2144  //     (else `\bigstar` is undefined). An earlier brace-DEPTH filter wrongly skipped this
2145  //     conditional; the def-body check is precise.
2146  // A `\usepackage` is deferred iff ANY enclosing `{…}` group is opened directly
2147  // by a `\newcommand`/`\def` definition header.
2148  // Is `\<name>` INVOKED (not merely defined) somewhere in the file? A bare
2149  // control-sequence occurrence not directly preceded by a `\…def`/`\let`
2150  // introducer counts as an invocation. Used to tell an executed deferred-load
2151  // idiom (`\def\@tempa{…\RequirePackage{P}…}\@tempa`) from a never-called one.
2152  let is_invoked = |name: &str| -> bool {
2153    let pat = s!("\\{name}");
2154    let mut from = 0usize;
2155    while let Some(rel) = code[from..].find(&pat) {
2156      let at = from + rel;
2157      from = at + pat.len();
2158      // Full control sequence: next char must not extend the name.
2159      let after_ok = code[at + pat.len()..]
2160        .chars()
2161        .next()
2162        .is_none_or(|c| !c.is_ascii_alphabetic() && c != '@');
2163      if !after_ok {
2164        continue;
2165      }
2166      // Not a (re)definition: the chars just before `\name` aren't `…def`/`…let`.
2167      let before = code[at.saturating_sub(8)..at].trim_end();
2168      if before.ends_with("def") || before.ends_with("let") {
2169        continue;
2170      }
2171      return true;
2172    }
2173    false
2174  };
2175  let in_macro_def_body = |start: usize| -> bool {
2176    let bytes = code.as_bytes();
2177    let mut stack: Vec<usize> = Vec::new();
2178    let mut i = 0usize;
2179    while i < start {
2180      match bytes[i] {
2181        b'\\' => {
2182          i += 2;
2183          continue;
2184        },
2185        b'{' => stack.push(i),
2186        b'}' => {
2187          stack.pop();
2188        },
2189        _ => {},
2190      }
2191      i += 1;
2192    }
2193    stack.iter().any(|&ob| {
2194      let lo = ob.saturating_sub(400);
2195      let window = code.get(lo..ob).unwrap_or(&code[..ob]);
2196      // A require inside a `\DeclareOption` code-arm is conditional (only the
2197      // option-selected arm runs, via `\ProcessOptions`) → defer it, even when
2198      // the arm `\def`s an INVOKED macro: aa/myaa.cls have
2199      //   \DeclareOption{ascii}{\def\aa@inputenc{\RequirePackage[ascii]{inputenc}}}
2200      //   …\DeclareOption{utf8}{…[utf8]…}  …  \aa@inputenc
2201      // where the single `\aa@inputenc` call would otherwise un-defer the FIRST
2202      // (ascii) arm via the `is_invoked` rule below and wrongly force
2203      // inputenc[ascii] (→ UTF-8 `ç` rejected). Perl defers all such (loads no
2204      // inputenc; the actual `utf8` default is the right answer and `ç` passes
2205      // through either way). Witness 1504.05963 (Perl 0 / Rust 1, now parity).
2206      if DECLARE_OPTION_RE.is_match(window) {
2207        return true;
2208      }
2209      // A `\def\<m>{…}` body only defers if `\<m>` is never invoked; an invoked
2210      // scratch macro (`\@tempa`) runs its body at load, so its require loads.
2211      if let Some(caps) = DEF_NAME_RE.captures(window) {
2212        return !is_invoked(&caps[1]);
2213      }
2214      // `\newcommand`/`\DeclareRobustCommand` user-command bodies stay deferred
2215      // (witness 1506.06200 `\newcommand{\usediagrams}{\usepackage{diagrams}}`).
2216      DEF_BODY_HEADER_RE.is_match(window)
2217    })
2218  };
2219  let top_level = |cap: &regex::Captures| -> bool {
2220    cap
2221      .get(0)
2222      .map(|m| !in_macro_def_body(m.start()))
2223      .unwrap_or(true)
2224  };
2225
2226  // NOTE — a former Rust-only "conflicting option sets" heuristic was REMOVED
2227  // here (was: drop a package `\RequirePackage`'d / `\usepackage`'d with two or
2228  // more DIFFERENT option sets, as the signature of a deferred require inside a
2229  // `\def`/`\DeclareOption` body, e.g. aa.cls's
2230  // `\DeclareOption{ascii}{\def\aa@inputenc{\RequirePackage[ascii]{inputenc}}}`
2231  // …`{utf8}{…[utf8]…}`). It over-fired on a package required in BOTH arms of a
2232  // load-time `\if…\else…\fi` with different options — mutually exclusive, so
2233  // exactly one branch loads and the package MUST be kept. Witness: rist.cls's
2234  // `\ifpdf \RequirePackage[pdftex,…]{hyperref} \else \RequirePackage[dvipdfm,…]
2235  // {hyperref} \fi` (1912.00781: hyperref dropped → `\url` undefined). The
2236  // heuristic was also provably REDUNDANT: it only ever saw TOP-LEVEL requires
2237  // (the captures feeding it were `top_level`-filtered), but aa.cls's inputenc
2238  // lives in a `\def`/`\DeclareOption` BODY — already dropped by the def-body
2239  // `top_level` skip below, so it never entered the conflicting set at all. Perl
2240  // has no such gate (L2767-2774 is a plain dedup); the def-body `top_level` skip
2241  // plus the executed-set gate cover the legitimate cases faithfully.
2242  static OPT_SPLIT: Lazy<Regex> = Lazy::new(|| Regex::new(r"\s*,\s*").unwrap());
2243
2244  // Perl L2767-2774: shared `%dups` map, $collect closure splits on
2245  // `\s*,\s*` and only enrolls a package once, AND only if its
2246  // `.sty.ltxml_loaded` flag is unset.
2247  let mut packages: Vec<(String, Option<String>)> = Vec::new();
2248  let mut dups: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
2249  let mut collect = |pkg_csv: &str, raw_options: Option<&str>| {
2250    for p in OPT_SPLIT.split(pkg_csv) {
2251      if p.is_empty() {
2252        continue;
2253      }
2254      // Executed-set gate (see top of fn): when this file raw-loaded, drop a
2255      // candidate whose `\usepackage` never executed anywhere — it was inside a
2256      // false conditional the raw-load skipped.
2257      if raw_loaded && !lookup_bool(&s!("{p}.usepackage_executed")) {
2258        continue;
2259      }
2260      // Perl L2773: `!$dups{$p} && !LookupValue($p . '.sty.ltxml_loaded')`
2261      if !dups.contains(p) && !lookup_bool(&s!("{p}.sty.ltxml_loaded")) {
2262        packages.push((p.to_string(), raw_options.map(|s| s.to_string())));
2263        dups.insert(p.to_string());
2264      }
2265    }
2266  };
2267
2268  // Perl L2777: `\RequirePackage` first.
2269  for cap in REQ_RE.captures_iter(&code) {
2270    if !top_level(&cap) {
2271      continue;
2272    }
2273    collect(&cap[2], cap.get(1).map(|m| m.as_str()));
2274  }
2275  // Perl L2778-2779: `\usepackage` second.
2276  for cap in USE_RE.captures_iter(&code) {
2277    if !top_level(&cap) {
2278      continue;
2279    }
2280    collect(&cap[2], cap.get(1).map(|m| m.as_str()));
2281  }
2282
2283  // Perl L2767/L2781-2782: `@classes` is class-only, NO dup-check.
2284  let mut classes: Vec<(String, Option<String>)> = Vec::new();
2285  if ext_type == "cls" {
2286    for cap in CLS_RE.captures_iter(&code) {
2287      if !top_level(&cap) {
2288        continue;
2289      }
2290      let class = cap[2].to_string();
2291      if !class.is_empty() {
2292        classes.push((class, cap.get(1).map(|m| m.as_str().to_string())));
2293      }
2294    }
2295  }
2296
2297  // BEYOND PERL: harvest `\addbibresource` too.
2298  //
2299  // A journal class shipped with the paper routinely declares the bibliography
2300  // itself — `journaleducation.cls` has `\addbibresource{letters/refs.bib}`,
2301  // `cai26.cls` has `references.bib`, `tau.cls` has `tau.bib` — and the
2302  // document then only writes `\printbibliography`. We do not interpret that
2303  // class (no binding, OmniBus fallback), so the resource was never registered
2304  // and `\printbibliography` had nothing to print: no `MakeBibliography` line
2305  // at all and an empty References section, even though the `.bib` ships with
2306  // the paper. This scanner is already reading the file for its dependencies,
2307  // so it is the one place that can see the declaration.
2308  //
2309  // Registering the name is enough — `\biblatex@printbibliography` pops the
2310  // list and routes it through `\lx@bibliography`, which resolves the file (and
2311  // deduplicates, so a document that ALSO declares the same resource does not
2312  // get a doubled bibliography). Papers that never load biblatex simply leave
2313  // the value unread. Audit family F4(c); witnesses 2605.23724, 2605.00270,
2314  // 2605.02720.
2315  for res in scan_bib_resources(&code) {
2316    let _ = push_value("biblatex_resources", Stored::String(arena::pin(res)));
2317  }
2318
2319  // Perl L2784-2785: Info iff EITHER list is non-empty; message lists
2320  // class names then package names, separated by ',' (no space).
2321  if !classes.is_empty() || !packages.is_empty() {
2322    let names: Vec<&str> = classes
2323      .iter()
2324      .map(|(n, _)| n.as_str())
2325      .chain(packages.iter().map(|(n, _)| n.as_str()))
2326      .collect();
2327    Info!(
2328      "dependencies",
2329      "dependencies",
2330      s!("Loading dependencies for {}: {}", path, names.join(","))
2331    );
2332  }
2333
2334  // Perl L2786-2789: foreach class — gate by `FindFile($class, type=>'cls',
2335  // notex=>1)`, then `LoadClass(..., options=>[split ...])`.
2336  for (class, raw_opts) in classes {
2337    if find_file(
2338      &class,
2339      Some(FindFileOptions {
2340        ext_type: Some(Cow::Borrowed("cls")),
2341        notex: true, // Perl `notex => 1`
2342        ..FindFileOptions::default()
2343      }),
2344    )
2345    .is_some()
2346    {
2347      let opts: Vec<String> = raw_opts
2348        .as_deref()
2349        .map(|s| OPT_SPLIT.split(s).map(|x| x.to_string()).collect())
2350        .unwrap_or_default();
2351      let _ = load_class(&class, opts, Tokens::default());
2352    }
2353  }
2354
2355  // Perl L2790-2793: foreach package — gate by `FindFile($pkg, type=>'sty',
2356  // notex=>1)`, then `RequirePackage(..., options=>[split ...])`.
2357  for (pkg, raw_opts) in packages {
2358    if find_file(
2359      &pkg,
2360      Some(FindFileOptions {
2361        ext_type: Some(Cow::Borrowed("sty")),
2362        notex: true, // Perl `notex => 1`
2363        ..FindFileOptions::default()
2364      }),
2365    )
2366    .is_some()
2367    {
2368      let opts: Vec<String> = raw_opts
2369        .as_deref()
2370        .map(|s| OPT_SPLIT.split(s).map(|x| x.to_string()).collect())
2371        .unwrap_or_default();
2372      let _ = require_package(&pkg, RequireOptions {
2373        options: opts,
2374        ..RequireOptions::default()
2375      });
2376    }
2377  }
2378}
2379
2380/// Attach a resource (CSS, JavaScript, …) to the document being built.
2381///
2382/// Port of Perl `Package.pm:RequireResource` L3139-3158. A resource needs
2383/// either a pathname or inline content, and a mime-type: an absent type is
2384/// inferred from the pathname's extension (case-sensitively, as Perl's
2385/// `$resource_types{$ext}` lookup is), and a resource still lacking one after
2386/// that is skipped with a warning rather than emitted untyped.
2387///
2388/// The resource is queued as *pending* and folded into the document later,
2389/// covering Perl's `$LaTeXML::DOCUMENT ? addResource : PushValue(PENDING_RESOURCES)`
2390/// split — bindings routinely call this from a preamble, before any document
2391/// exists.
2392pub fn require_resource(mut resource: Resource) {
2393  if resource.name.is_empty() && resource.content.is_empty() {
2394    Warn!(
2395      "expected",
2396      "resource",
2397      "Resource must have a resource pathname or content; skipping"
2398    );
2399    return;
2400  }
2401  if resource.mimetype.is_empty() && !resource.name.is_empty() {
2402    // Perl Package.pm L3129: `my $ext = pathname_type($resource);` — no
2403    // case-folding; `$resource_types{$ext}` is a case-sensitive lookup.
2404    let ext = pathname::extension(&resource.name);
2405    resource.mimetype = resource_type(&ext);
2406  }
2407  if resource.mimetype.is_empty() {
2408    Warn!(
2409      "expected",
2410      "mime-type",
2411      "Resource must have a mime-type; skipping"
2412    );
2413    return;
2414  }
2415
2416  // If we've got a document, go ahead & put the resource in.
2417  // if (document.is_some()) {
2418  //   document.as_mut().unwrap().add_resource(resource, resource);
2419  // } else {
2420  push_pending_resource(resource);
2421  // }
2422}
2423
2424/// Perl: `LoadClass($name, withoptions => 1)` — load a class passing the
2425/// caller's class options through to the child. Reads `class_options` from
2426/// state (populated by the outer `\documentclass` invocation) and forwards
2427/// those as the child's options list, matching Perl Package.pm LoadClass's
2428/// `withoptions` branch.
2429pub fn load_class_with_options(name: &str, after: Tokens) -> Result<()> {
2430  let class_opts = lookup_vecdeque("class_options").unwrap_or_default();
2431  let options: Vec<String> = class_opts
2432    .iter()
2433    .filter_map(|item| match item {
2434      Stored::String(s) => Some(arena::to_string(*s)),
2435      _ => None,
2436    })
2437    .collect();
2438  load_class(name, options, after)
2439}
2440
2441/// Load a document class — the `\documentclass` / `\LoadClass` implementation,
2442/// port of Perl `Package.pm:LoadClass` L2702-2730.
2443///
2444/// Resolution runs [`input_definitions`] for `<name>.cls`, then Perl's
2445/// three-step fallback when that yields no binding: the longest known
2446/// `.cls.ltxml` binding whose name PREFIXES the requested class (so an
2447/// author-renamed `IEEEtranTCOM.cls` still gets the `IEEEtran` binding), and
2448/// failing that `OmniBus`, the generic class supplying frontmatter, counter and
2449/// theorem definitions. Whether the raw `.cls` may be read at all is the
2450/// `INCLUDE_CLASSES` value's call (`searchpaths` restricts it to local
2451/// sources); by default it may not, because a raw load that "succeeds" would
2452/// suppress the OmniBus fallback while providing none of its bindings.
2453///
2454/// See [`load_class_with_options`] for Perl's `withoptions => 1` variant. The
2455/// per-branch reasoning, and the arXiv witnesses behind it, are in the body
2456/// comments.
2457pub fn load_class(name: &str, options: Vec<String>, after: Tokens) -> Result<()> {
2458  // Perl Package.pm LoadClass: $options{notex}=1 unless LookupValue('INCLUDE_CLASSES').
2459  // Defaults to NOT loading raw .cls. Only .cls.ltxml bindings are considered;
2460  // if the binding is missing, fall through to OmniBus (below). Allowing raw
2461  // .cls to "succeed" the load prevents the OmniBus fallback that provides
2462  // generic frontmatter / counter / theorem bindings.
2463  //
2464  // PERL-FAITHFUL (2026-05-30): a directory prefix does NOT force a raw .cls
2465  // load. Perl resolves a path-prefixed class to its basename BINDING when one
2466  // exists, else OmniBus — it loads `IEEEtran.cls.ltxml` for
2467  // `\documentclass{misc/ieeetran}` and falls to OmniBus for
2468  // `\documentclass{JINST-Sample-files/JINST}`. The basename→binding match is
2469  // handled in the `alternate` search below (it strips the path). Forcing a raw
2470  // load for any path-prefixed name (the old `&& !has_path_prefix` exception)
2471  // broke bundled classes whose raw load is semantically incomplete — JINST's
2472  // begin-document `\author`/`\abstract` checks fire and `\abstract@cs` is left
2473  // undefined, where Perl (OmniBus) is clean. Witness 1504.01965; the
2474  // misc/ieeetran case (2105.02087) now matches Perl via the basename binding.
2475  let notex_default = !lookup_bool("INCLUDE_CLASSES");
2476  // Perl Package.pm L2690: LoadClass can be limited to local SEARCHPATHS when
2477  // `localrawclasses` option sets `INCLUDE_CLASSES => 'searchpaths'`.
2478  let searchpaths_only = !notex_default && lookup_string("INCLUDE_CLASSES") == "searchpaths";
2479
2480  let result = input_definitions(name, InputDefinitionOptions {
2481    extension: Some(Cow::Borrowed("cls")),
2482    options: options.clone(),
2483    after: after.clone(),
2484    notex: notex_default,
2485    searchpaths_only,
2486    handleoptions: true,
2487    noerror: true,
2488    ..InputDefinitionOptions::default()
2489  });
2490  // Perl Package.pm L2700-2716: if no direct binding, try a prefix-match fallback.
2491  // Scan all known cls bindings (longest-first), pick the first whose name is a
2492  // prefix of the requested class. This catches author-renamed classes like
2493  //   mysvjour3.cls → ProvidesClass{svjour3} → binding: svjour3
2494  //   mn2ebis.cls   → starts with "mn2e"   → binding: mn2e
2495  //   IEEEtranTCOM.cls → starts with "IEEEtran" → binding: IEEEtran
2496  // Fall through to OmniBus only when nothing matches.
2497  let will_fallback = (result.is_err()
2498    || (!lookup_bool(&format!("{name}.cls_loaded"))
2499      && !lookup_bool(&format!("{name}.cls_raw_loaded"))))
2500    && name != "OmniBus"
2501    && name != "article"
2502    && !lookup_bool("OmniBus.cls_loaded")
2503    && !lookup_bool("OmniBus.cls_raw_loaded");
2504
2505  // Perl Package.pm L2679 (LoadClass branch): scan the raw .cls for
2506  // \usepackage/\RequirePackage/\LoadClass dependencies when no .cls.ltxml
2507  // binding was found. This matters for unknown classes that nonetheless
2508  // pull in well-known packages (e.g. ijms-preprint.cls loads amsmath);
2509  // without it, downstream code like `\eqref{foo_bar}` sees `\eqref` as
2510  // undefined and the `_` characters then reach the stomach as subscript
2511  // catcodes, triggering runaway error recovery (arxiv 1003.0934 OOM).
2512  // Skip deps-scan only when a real `.cls.ltxml` binding has been
2513  // loaded — that binding is responsible for its own
2514  // `\RequirePackage` calls. The `cls_loaded` flag is set even for
2515  // a successful raw .cls load (no binding), so we MUST check the
2516  // binding-specific `cls.ltxml_loaded` flag instead. Without
2517  // this, paper-bundled .cls files (e.g. myclass.cls bundling
2518  // caption + many others, witness 2202.11535) raw-load
2519  // successfully but their `\RequirePackage` calls do NOT trigger
2520  // our binding loaders, leaving \captionsetup / \href / \affil
2521  // undefined.
2522  //
2523  // PERL-FAITHFUL ORDER: when we will fall through to an alternate
2524  // class binding (OmniBus or a prefix-match), DEFER the deps-scan
2525  // until AFTER the alternate is loaded. Otherwise the deps-scan
2526  // pulls natbib (et al.) ahead of OmniBus, and OmniBus's later
2527  // `Let('\lx@OmniBus@saved@bibitem', '\bibitem')` +
2528  // `DefMacro('\bibitem', ...)` clobbers natbib's `\lx@nat@bibitem`
2529  // binding — infinite-loop chain on
2530  // `\bibitem[\protect\citeauthoryear{...}{...}{...}]{key}`.
2531  // Witness: 1001.1919, 1001.5004, 0809.4358 (statsoc.cls),
2532  // 0904.3132, 0912.1617 (ectj.cls), 0904.3938 (compositio.cls),
2533  // 0908.3882 (third-input timeout), 0911.1590 (\tag\textsc cascade).
2534  // Perl's load order in this branch: warn missing-binding → load
2535  // alternate (OmniBus) → deps-scan pulls natbib LAST → natbib's Let
2536  // overrides OmniBus correctly.
2537  if !lookup_bool(&s!("{name}.cls.ltxml_loaded")) && !will_fallback {
2538    maybe_require_dependencies(name, "cls");
2539  }
2540  if will_fallback {
2541    note_status(LogStatus::Missing, Some(&format!("{name}.cls")));
2542
2543    // Perl: @classes = sort { -(length($a) <=> length($b)) } available_cls_names
2544    //       my ($alternate) = grep { $class =~ /^\Q$_\E/ } @classes;
2545    // Flatten across ALL registered binding crates (latexml_package +
2546    // latexml_contrib + any future extensions) so contrib classes like
2547    // `memoir`, `siamltex`, `scrbook` are eligible alternates too.
2548    let alternate = {
2549      let mut sorted: Vec<&str> = get_class_binding_names()
2550        .into_iter()
2551        .filter(|n| *n != "OmniBus" && *n != name)
2552        .collect();
2553      sorted.sort_by_key(|n| std::cmp::Reverse(n.len()));
2554      // Strict-case prefix first (Perl-faithful: `$class =~ /^\Q$_\E/`), then
2555      // a case-insensitive fallback for binding entries that differ from the
2556      // class name ONLY in capitalization (e.g. `WileyNJDv5` class vs a
2557      // `wileyNJDv5` binding entry, witness 2406.08163). The ci fallback uses
2558      // FULL equality, NOT prefix: a ci-PREFIX match wrongly fired
2559      // `AAAI-Std` → `aa` (the 2-char A&A astronomy binding) because
2560      // `"aaai-std".starts_with("aa")` — but Perl's case-SENSITIVE `/^aa/`
2561      // against `AAAI-Std` finds nothing, so Perl falls back to OmniBus
2562      // (which defines `\address`). Full ci-equality keeps the
2563      // capitalization-only Wiley case while matching Perl's no-match →
2564      // OmniBus for AAAI-Std. Witness 2008.08548.
2565      sorted
2566        .iter()
2567        .copied()
2568        .find(|candidate| name.starts_with(candidate))
2569        .or_else(|| {
2570          sorted
2571            .iter()
2572            .copied()
2573            .find(|candidate| name.eq_ignore_ascii_case(candidate))
2574        })
2575        .or_else(|| {
2576          // Path-prefixed class (`misc/ieeetran`, `JINST-Sample-files/JINST`):
2577          // strip the directory and match the basename against a binding, so
2578          // `misc/ieeetran` → IEEEtran (Perl loads IEEEtran.cls.ltxml for it)
2579          // while `JINST-Sample-files/JINST` → no basename binding → OmniBus.
2580          // Case-insensitive FULL equality only (not prefix) — mirrors the
2581          // capitalization-only ci fallback above and avoids a basename like
2582          // `AAAI-Std` wrongly prefix-matching the 2-char `aa` binding.
2583          // Witnesses 1504.01965 (JINST→OmniBus), 2105.02087 (misc/ieeetran).
2584          let basename = name.rsplit(['/', '\\']).next().unwrap_or(name);
2585          if basename != name {
2586            sorted
2587              .iter()
2588              .copied()
2589              .find(|candidate| basename.eq_ignore_ascii_case(candidate))
2590          } else {
2591            None
2592          }
2593        })
2594    };
2595
2596    let target = alternate.unwrap_or("OmniBus");
2597    Warn!(
2598      "missing_file",
2599      name,
2600      format!("Can't find binding for class {name} (using {target})"),
2601      "Anticipate undefined macros or environments"
2602    );
2603    let loaded = input_definitions(target, InputDefinitionOptions {
2604      extension: Some(Cow::Borrowed("cls")),
2605      options,
2606      after,
2607      notex: true,
2608      handleoptions: true,
2609      noerror: true,
2610      ..InputDefinitionOptions::default()
2611    });
2612    // Perl Package.pm L2715: after loading the alternate class binding, scan
2613    // the raw class file for \usepackage/\RequirePackage/\LoadClass — the
2614    // alternate rarely covers all dependencies the renamed class adds.
2615    // Run for BOTH a real prefix-match alternate AND the pure-OmniBus
2616    // fallback (the deps were deferred above so their require_package
2617    // calls fire AFTER OmniBus is in place).
2618    maybe_require_dependencies(name, "cls");
2619    return loaded;
2620  }
2621  result
2622}
2623
2624/// configuration for searching for a file in the local filesystem
2625#[derive(Default)]
2626pub struct FindFileOptions {
2627  // TODO: this is no longer used in find_file, rather a level earlier
2628  pub forbid_ltxml:      bool,
2629  pub notex:             bool,
2630  pub ext_type:          Option<Cow<'static, str>>,
2631  pub search_paths_only: bool,
2632}
2633
2634/// search for a file as prescribed by a `FindFileOptions` configuration
2635pub fn find_file(file: &str, options: Option<FindFileOptions>) -> Option<String> {
2636  let options = options.unwrap_or_default();
2637  if pathname::is_literaldata(file) {
2638    // If literal protocol return immediately (unless notex!)
2639    if options.notex {
2640      None
2641    } else {
2642      // TODO: Consider returning a Cow<str> instead to optimize
2643      Some(file.to_string())
2644    }
2645  } else if pathname::is_literaldata(file) || pathname::is_url(file) {
2646    // If a known special protocol return immediately
2647    Some(file.to_string())
2648  } else if let Some(ref ext) = options.ext_type {
2649    // Otherwise, it's some kind of "real" file, and we might have to search for it
2650    // Specific type requested? Search for it.
2651    // Add the extension, if it isn't already there. Perl tests with the DOT
2652    // (`/\.\Q$type\E$/`): a bare `\bibliography{mybib}` must retry
2653    // `mybib.bib` — the dotless ends_with("bib") matched the basename itself
2654    // and searched the literal `mybib`, silently disabling the .bib fallback
2655    // (PR_READINESS must-fix 6).
2656    let dotted = s!(".{}", ext);
2657    let aux_file = if file.ends_with(&dotted) {
2658      file.to_string()
2659    } else {
2660      s!("{}.{}", file, ext)
2661    };
2662    find_file_aux(&aux_file, &options)
2663  } else if file.ends_with(".tex") {
2664    // If no type given, we MAY expect .tex, or maybe NOT!!
2665    // No requested type, then .tex; Of course, it may already have it!
2666    find_file_aux(file, &options)
2667  } else {
2668    match find_file_aux(&s!("{}.tex", file), &options) {
2669      None => find_file_aux(file, &options),
2670      Some(f) => Some(f),
2671    }
2672  }
2673}
2674
2675/// Perl Package.pm L2141-2210: FindFile_fallback
2676/// Pure-check variant of [`find_file_fallback`]: strips the same
2677/// suffix/prefix patterns and reports whether the fallback name has a
2678/// registered binding, but DOES NOT eagerly invoke the binding's
2679/// `load_definitions`. Use this from pre-flight existence checks (e.g.
2680/// `class_cls_via_fallback` in tex_job.rs) where firing the binding's
2681/// body has side effects (\LoadClass, \RequirePackage) that contaminate
2682/// the subsequent real load. Witness: astro-ph0002213 — the `\psfig`
2683/// cluster fix (mn1 → mn fallback was eagerly running mn.cls's
2684/// `\LoadClass{article}` before the `\documentstyle[epsfig]{mn1}`
2685/// option-pass-through machinery had a chance to enqueue `epsfig` into
2686/// `opt@article.cls`).
2687pub fn find_file_fallback_exists(name: &str, ext_type: &str) -> bool {
2688  use regex::Regex;
2689
2690  use crate::state::binding_exists;
2691  // Mirror find_file_fallback's regex set exactly.
2692  let suffix_rx = match Regex::new(
2693    r"(?i)[._-](arx|arxiv|conference|workshop|tmp|alternate|preprint|fixed|[vV]?[-_.\d]+|old|new|final|clean|mine|priv|rev|mod|modified|edited|custom|altered|rtx)$",
2694  ) {
2695    Ok(rx) => rx,
2696    Err(_) => return false,
2697  };
2698  let glued_rx = match Regex::new(r"(?i)([vV]?[-_.\d]+|arxiv)$") {
2699    Ok(rx) => rx,
2700    Err(_) => return false,
2701  };
2702  let prefix_rx = match Regex::new(r"(?i)^((?:rw|my|preprint)[-_.]?)") {
2703    Ok(rx) => rx,
2704    Err(_) => return false,
2705  };
2706  let basename = pathname::file_name(name);
2707  let mut base = if basename.is_empty() {
2708    name.to_string()
2709  } else {
2710    basename
2711  };
2712  // Mirror find_file_fallback: a directory strip ALONE must not qualify (a `subdir/<name>` is a
2713  // path, not a version — it must not name-match `<name>`'s binding). Only a real version
2714  // suffix/prefix strip counts; guard the `^`-anchored prefix strip behind `!dir_stripped` so
2715  // `sty/myunits` doesn't become `units`.
2716  let dir_stripped = base != name;
2717  let mut suffix_stripped = false;
2718  loop {
2719    if let Some(m) = suffix_rx.find(&base) {
2720      base = base[..m.start()].to_string();
2721      suffix_stripped = true;
2722      continue;
2723    }
2724    if let Some(m) = glued_rx.find(&base) {
2725      base = base[..m.start()].to_string();
2726      suffix_stripped = true;
2727      continue;
2728    }
2729    if !dir_stripped && let Some(m) = prefix_rx.find(&base) {
2730      base = base[m.end()..].to_string();
2731      suffix_stripped = true;
2732      continue;
2733    }
2734    break;
2735  }
2736  if !suffix_stripped || base.is_empty() || base == name {
2737    return false;
2738  }
2739  binding_exists(&base, ext_type)
2740}
2741
2742/// Kind of fallback that matched, returned by [`find_file_fallback`].
2743///
2744/// `Versioned` — suffix/prefix stripping changed the basename (Perl
2745/// FindFile_fallback's core function). Drivers: 1206.0536 (mysvjour3
2746/// → svjour3), astro-ph0005021 (./aaspp4 → ./aaspp).
2747///
2748/// `BasenameOnly` — the *only* change was directory-prefix removal,
2749/// matching the binding registry by leaf name. This is a Rust-specific
2750/// extension on top of Perl's FindFile_fallback that exists because
2751/// our contrib-binding registry is keyed by basename. Drivers:
2752/// 2105.02087 (misc/ieeetran → IEEEtran binding); 2405.18387
2753/// (assets/equations → equations binding).
2754///
2755/// Both kinds win unconditionally over local raw `.sty`/`.cls` files
2756/// at the call site (see `input_definitions` Step 3): the `.rs`
2757/// bindings are hand-tuned for the conversion, so a binding match
2758/// always supersedes a co-located vendored copy. The variant is
2759/// preserved for diagnostics and potential future policy tweaks.
2760#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2761pub enum FallbackKind {
2762  Versioned,
2763  BasenameOnly,
2764}
2765
2766/// Strip version/arxiv suffixes from package names to find existing bindings.
2767/// Returns the fallback filename (with extension) and which kind of fallback fired.
2768pub fn find_file_fallback(name: &str, ext_type: &str) -> Option<(String, FallbackKind)> {
2769  use regex::Regex;
2770  // Suffixes with separator (Perl @find_fallback_suffixes)
2771  let suffix_rx = Regex::new(
2772    r"(?i)[._-](arx|arxiv|conference|workshop|tmp|alternate|preprint|fixed|[vV]?[-_.\d]+|old|new|final|clean|mine|priv|rev|mod|modified|edited|custom|altered|rtx)$"
2773  ).ok()?;
2774  // Glued suffixes without separator
2775  let glued_rx = Regex::new(r"(?i)([vV]?[-_.\d]+|arxiv)$").ok()?;
2776  // Prefixes. Perl Package.pm L2182: `^((?:rw|my|preprint)[-_.]?)` —
2777  // separator is OPTIONAL, so `mysvjour3` strips to `svjour3` (not just
2778  // `mysvjour`). Caught on arxiv 1206.0536 (\documentclass{mysvjour3}).
2779  let prefix_rx = Regex::new(r"(?i)^((?:rw|my|preprint)[-_.]?)").ok()?;
2780
2781  // Strip a leading directory path (Perl Package.pm L2167-2170: FindFile_fallback
2782  // calls `pathname_name($name)` first, so e.g. `\documentclass{./sty/IEEEtran}`
2783  // routes the basename `IEEEtran` through the binding-name registry. Without
2784  // this, `IEEEtran.cls.ltxml` is missed because `./sty/IEEEtran.cls.ltxml`
2785  // never matches the @ltxml_paths registry. Driver paper: arXiv:1308.6663.
2786  let basename = pathname::file_name(name);
2787  let mut base = if basename.is_empty() {
2788    name.to_string()
2789  } else {
2790    basename
2791  };
2792  let dir_stripped = base != name;
2793  let mut suffix_stripped = false;
2794  // Iteratively strip suffixes, then glued, then prefixes.
2795  //
2796  // Perl's FindFile_fallback (Package.pm:2174) version-strips the FULL `$file`
2797  // *with its directory prefix intact*, so the prefix regex `^(rw|my|preprint)`
2798  // — anchored at the very START of the string — never matches a name that
2799  // begins with a directory (e.g. `sty/myunits` starts with `sty/`, not `my`).
2800  // We strip the directory FIRST (to allow a basename-exact binding match like
2801  // `misc/ieeetran` → IEEEtran), so we must NOT then apply the `^`-anchored
2802  // prefix strip to the basename, or `sty/myunits` wrongly becomes `units` and
2803  // loads the stock units.sty instead of the paper-local myunits.sty (which
2804  // defines `\T`/`\fC`/`\Cm` via its `\defUnit` mechanism). Witness 1702.05093.
2805  // The suffix/glued strips ARE `$`-anchored, so they still match the tail of a
2806  // dir-prefixed name in Perl (`./aaspp4` → `./aaspp`); keep applying them.
2807  loop {
2808    if let Some(m) = suffix_rx.find(&base) {
2809      base = base[..m.start()].to_string();
2810      suffix_stripped = true;
2811      continue;
2812    }
2813    if let Some(m) = glued_rx.find(&base) {
2814      base = base[..m.start()].to_string();
2815      suffix_stripped = true;
2816      continue;
2817    }
2818    if !dir_stripped && let Some(m) = prefix_rx.find(&base) {
2819      base = base[m.end()..].to_string();
2820      suffix_stripped = true;
2821      continue;
2822    }
2823    break;
2824  }
2825
2826  // A directory strip ALONE no longer triggers a fallback: `subdir/<name>` is a PATH, not a
2827  // version, so it must not name-match `<name>`'s binding (that shadowed paper-local subdir
2828  // packages, e.g. `utils/mathenv` -> the unrelated CTAN mathenv binding, leaving the local
2829  // file — and its cleveref/theorem defs — unloaded). Only a real VERSION suffix/prefix strip
2830  // (neurips_2026->neurips, mysvjour3->svjour3) qualifies, matching Perl's FindFile_fallback
2831  // (which never strips a directory). The retired `BasenameOnly` convenience (2105.02087
2832  // misc/ieeetran, 2405.18387 assets/equations) now falls to raw-load/OmniBus like Perl.
2833  if !suffix_stripped {
2834    return None;
2835  }
2836  if base.is_empty() || base == name {
2837    return None;
2838  }
2839
2840  let kind = FallbackKind::Versioned;
2841
2842  let fallback_filename = format!("{base}.{ext_type}");
2843  // Check if fallback binding exists
2844  if matches!(load_binding(&fallback_filename), Ok(Some(_))) {
2845    // Binding exists but was loaded by the check — it's OK, the caller will mark loaded
2846    Some((fallback_filename, kind))
2847  } else if matches!(load_external_binding(&fallback_filename), Ok(Some(_))) {
2848    Some((fallback_filename, kind))
2849  } else {
2850    None
2851  }
2852}
2853
2854fn find_file_aux(file: &str, options: &FindFileOptions) -> Option<String> {
2855  // If cached, return simple path (it's a key into the cache)
2856  let cached = lookup_string(&s!("{}_contents", file));
2857  if !cached.is_empty() {
2858    return Some(file.to_string());
2859  }
2860  if pathname::is_absolute(file) {
2861    // Perl Package.pm L2089-2093:
2862    //   if pathname_is_absolute($file) {
2863    //     if (!$options{noltxml}) {
2864    //       return $file . '.ltxml' if -f ($file . '.ltxml'); }
2865    //     return $file if -f $file;
2866    //     return; }
2867    // No `<file>.ltxml` lookup (Perl checked one here): a `.ltxml` is a Perl
2868    // LaTeXML binding latexml-oxide can never read. Binding availability is
2869    // decided by the DISPATCHER (compiled + Rhai) in the relative-path branch's
2870    // `notex` fast-path, not by a file on disk.
2871    if Path::new(file).exists() {
2872      Some(file.to_string())
2873    } else {
2874      None
2875    }
2876  } else if pathname::is_nasty(file) {
2877    // If it is a nasty filename, we won't touch it.
2878    // we DO NOT want to pass this to kpathse or such!
2879    None
2880  } else {
2881    // Note that the strategy is complicated by the fact that
2882    // (1) we prefer .ltxml bindings, if present
2883    // (2) those MAY be present in kpsewhich's DB (although our searchpaths take precedence!)
2884    // (3) BUT we want to avoid kpsewhich if we can, since it's slower
2885    // (4) depending on switches we may EXCLUDE .ltxml OR raw tex OR allow both.
2886    let paths: Vec<String> = get_search_paths();
2887    // let _urlbase = state!().lookup_value("URLBASE");
2888    // let _nopaths = lookup_bool("REMOTE_REQUEST");
2889    // let _ltxml_paths: Vec<String> = if nopaths { vec![] } else { paths.clone() };
2890
2891    // Rust equivalent of Perl's ".ltxml" check: if the binding dispatcher
2892    // has an entry for this file, consider it "found". This is how Perl's
2893    // FindFile discovers pgfsys-latexml.def.ltxml etc.
2894    //
2895    // Two registry kinds are consulted (in order of cost):
2896    //  1. The per-call `{file}_binding_available` runtime flag, which packages can set to
2897    //     pre-announce their availability (used by pgf_sty for `pgfsys-latexml.def`).
2898    //  2. The compile-time class registry (latexml_package's `BINDINGS`) surfaced via
2899    //     `state::get_class_binding_names()`. Without this, `find_file("revtex4-1.cls",
2900    //     notex=true)` returned None for compiled-in bindings — so AIAA.cls's
2901    //     `\LoadClass{revtex4-1}` was silently skipped, breaking the eager natbib transitive load
2902    //     (1709.05096 / AIAA → 60s wall-clock SIGABRT in the autoload- trapped-by-abstract loop).
2903    // Binding-marker fast paths. ONLY fire when caller has requested
2904    // `notex=true` (i.e. caller wants binding-only search, not a real
2905    // disk path). Without this gate, raw `\openin` /`\IfFileExists`
2906    // calls (notex=false) get a literal binding name back as if it were
2907    // a path — `Mouth::open_file` then fails / produces an empty mouth
2908    // and `\ifeof` returns true, masking the file as missing. Mirrors
2909    // Perl `pathname_find`: only `noltxml=>0,notex=>1` returns binding
2910    // names; the disk-search variant only resolves real files.
2911    // Triggered by 2026-04-26 t1enc.def-cascade investigation: raw
2912    // fonttext.ltx's `\input  {t1enc.def}` opens via raw `\openin` /
2913    // `\IfFileExists`; without this gate find_file returned literal
2914    // "t1enc.def" → empty mouth → kernel's `\@missingfileerror` → 1M
2915    // TooManyErrors during latex.ltx dump-build.
2916    if !options.forbid_ltxml && options.notex {
2917      if lookup_bool(&s!("{file}_binding_available")) {
2918        return Some(file.to_string());
2919      }
2920      // Check the compile-time binding registries from latexml_package and
2921      // latexml_contrib for ANY (name, ext) pair that matches `file` (split
2922      // on the FIRST `.`, mirroring `dispatch()`'s split rule so multi-dot
2923      // names like `pgfmath.code.tex` resolve as `("pgfmath", "code.tex")`).
2924      if let Some((base, ext)) = file.split_once('.') {
2925        // Perl pathname_find L383-389: strict-case first, then case-insensitive
2926        // fallback (mirrors the dispatcher's lookup). Without this, requests
2927        // like `find_file("jhep.cls", notex=true)` would miss `("JHEP","cls")`
2928        // entries that derive from Perl's `JHEP.cls.ltxml` filename.
2929        let exact = get_binding_names()
2930          .iter()
2931          .any(|slice| slice.iter().any(|(n, e)| *n == base && *e == ext));
2932        let nocase = exact
2933          || get_binding_names().iter().any(|slice| {
2934            slice
2935              .iter()
2936              .any(|(n, e)| n.eq_ignore_ascii_case(base) && e.eq_ignore_ascii_case(ext))
2937          });
2938        if nocase {
2939          return Some(file.to_string());
2940        }
2941      }
2942    } else if !options.forbid_ltxml {
2943      // Narrow notex=false (disk-search) fallback: ONLY honor explicit
2944      // `<file>_binding_available` runtime flags, NOT the broad compile-time
2945      // registry. Mirrors Perl `\openin` calling default-args FindFile —
2946      // see `TeX_FileIO.pool.ltxml:50-64` "we SHOULD find an .ltxml version!"
2947      //
2948      // Use case: pgf.sty's pgfsys.code.tex `\pgfutil@InputIfFileExists{\pgfsysdriver}`
2949      // → `\pgfutil@IfFileExists{pgfsys-latexml.def}` → `\openin` with notex=false.
2950      // The openin impl in tex_file_io.rs creates an empty Mouth on
2951      // Mouth::create failure, so \ifeof=false → pgf inputs the driver.
2952      // pgf_sty.rs sets `pgfsys-latexml.def_binding_available=true` to
2953      // enable this; without the flag we don't fake-find arbitrary binding
2954      // names (which would re-introduce the t1enc.def `\@missingfileerror`
2955      // cascade documented above the notex=true branch).
2956      if lookup_bool(&s!("{file}_binding_available")) {
2957        return Some(file.to_string());
2958      }
2959    }
2960    // Perl L2123-2125: `elsif !notex && !interpreting && pathname_find($file,
2961    // paths=>$paths)` — search local paths for raw TeX.
2962    // (Rust does not yet honour `INTERPRETING_DEFINITIONS` — minor TODO,
2963    //  acknowledged in the audit.)
2964    if !options.notex
2965      && let Some(path) = pathname::find(file, PathnameFindOptions {
2966        paths: Some(paths),
2967        ..PathnameFindOptions::default()
2968      })
2969    {
2970      return Some(path);
2971    }
2972    // Perl L2131-2136: build kpsewhich candidate list:
2973    //   @candidates = ( "$file.ltxml" if !noltxml && !nopaths,
2974    //                   $file        if !notex );
2975    //   if (!searchpaths_only) && pathname_kpsewhich(@candidates) → -f $result
2976    // Perl gates the kpsewhich call only on `!searchpaths_only`; `notex`
2977    // and `noltxml` instead control which candidate names are tried.
2978    if options.search_paths_only {
2979      return None;
2980    }
2981    // NB: we do NOT add a `<file>.ltxml` candidate. That was Perl's logic —
2982    // a `.ltxml` is a Perl LaTeXML binding, and latexml-oxide can never read
2983    // one (as TeX or otherwise). The Rust equivalent of "is there a binding for
2984    // this name?" is the BINDING DISPATCHER (the `get_binding_names()` /
2985    // `{file}_binding_available` fast-path above, gated on `notex`), not a
2986    // `.ltxml` on disk. Otherwise kpsewhich returns e.g. `stex.sty.ltxml` (it
2987    // ships in TeX Live) ahead of the raw `stex.sty`, and the raw-loader
2988    // tokenizes the Perl source as TeX (`$out =~ s/^\s+//;` → "Script ^…",
2989    // `\DefMacroI` undefined, …). A missing binding falls through to the raw
2990    // `.sty`, matching pdflatex.
2991    let mut candidates: Vec<String> = Vec::new();
2992    if !options.notex {
2993      candidates.push(file.to_string());
2994    }
2995    if candidates.is_empty() {
2996      return None;
2997    }
2998    let refs: Vec<&str> = candidates.iter().map(|s| s.as_str()).collect();
2999    match pathname::kpsewhich(&refs) {
3000      // Perl L2136: `(-f $result ? $result : undef)` — re-confirm existence.
3001      Some(p) if Path::new(&p).exists() => Some(p),
3002      _ => None,
3003    }
3004  }
3005}
3006
3007//======================================================================
3008// Declaring and Adjusting the Document Model.
3009//======================================================================
3010
3011/// Declare document-model properties for one element tag — Perl's `Tag`
3012/// (`Package.pm` L2015-2030).
3013///
3014/// Merges `properties` into the tag's existing entry rather than replacing it,
3015/// which is what lets a class, a package and the engine each contribute to the
3016/// same tag. How a property merges depends on the property: `auto_open` /
3017/// `auto_close` overwrite when given, while the accumulating properties
3018/// (`afterOpen`, `afterClose`, …) prepend or append per
3019/// [`TagOptionName::is_prepend`]/[`TagOptionName::is_append`] — Perl's
3020/// `$tag_prepend_options` / `$tag_append_options` tables, whose order decides
3021/// whether a late binding's hook runs before or after the engine's.
3022pub fn install_tag(tag: &str, mut properties: TagOptions) {
3023  let tag_ticket = arena::pin(tag);
3024  with_tag_property_mut(tag_ticket, |options| {
3025    if properties.auto_open.is_some() {
3026      options.auto_open = properties.auto_open;
3027    }
3028    if properties.auto_close.is_some() {
3029      options.auto_close = properties.auto_close;
3030    }
3031    for name in &TagOptionName::all() {
3032      if name.is_prepend() {
3033        options.prepend(name, properties.remove(name));
3034      } else if name.is_append() {
3035        options.append(name, properties.remove(name));
3036      } else {
3037        // we'll handle the regular ones out of the loop
3038      }
3039    }
3040  });
3041}
3042
3043/// Selects the RelaxNG schema defining the XML output language
3044pub fn select_relaxng_schema(schema: &str, namespaces: Option<HashMap<String, String>>) {
3045  // What verb here? Set, Choose,...
3046  model::set_relaxng_schema(schema);
3047  if let Some(namespaces) = namespaces {
3048    for (prefix, value) in namespaces {
3049      model::register_document_namespace(&prefix, Some(&value));
3050    }
3051  }
3052}
3053
3054/// Merge font attributes into the current font, **locally** — the change
3055/// reverts with the enclosing TeX group.
3056///
3057/// Port of Perl `Package.pm:MergeFont` L441-444
3058/// (`AssignValue(font => LookupValue('font')->merge(@kv), 'local')`). Only the
3059/// attributes `font` actually specifies are taken; the rest are inherited from
3060/// the font in force. See [`merge_font_ref`] to merge without moving the font.
3061pub fn merge_font(font: Font) {
3062  let new_font = lookup_font().unwrap().merge_ref(&font);
3063  assign_font(Rc::new(new_font), Some(Scope::Local));
3064}
3065
3066/// Like `merge_font` but borrows the font. Saves a clone when the caller
3067/// has a shared reference (e.g. via Rc) to the font being merged.
3068pub fn merge_font_ref(font: &Font) {
3069  let new_font = lookup_font().unwrap().merge_ref(font);
3070  assign_font(Rc::new(new_font), Some(Scope::Local));
3071}
3072
3073/// Define a named color (Perl: DefColor).
3074/// Stores as color_{name} and also defines \\color@{name} macro.
3075pub fn def_color(
3076  name: &str,
3077  color: &crate::common::color::Color,
3078  scope: Option<Scope>,
3079) -> Result<()> {
3080  use crate::common::color;
3081  // Check ifglobalcolors — Perl: $scope='global' if lookupDefinition(\ifglobalcolors) &&
3082  // IfCondition(\ifglobalcolors) Guard with lookup first: xcolor may not be loaded (e.g.
3083  // colordvi-only documents).
3084  //
3085  // ALSO force global when `color_force_global` is set: color_sty.rs's lazy
3086  // dvipsnam.def loader (recovering a dropped `\usepackage[dvipsnames]{color}`
3087  // option, e.g. when hyperref preloaded `color` first) fires from WITHIN a
3088  // grouped digestion (`\textcolor{Blue}{…}` / a listings keywordstyle), so the
3089  // 68 dvips colors would otherwise be defined in that local group and revert
3090  // before the next `\color{…}` — making only the FIRST color resolve. Perl
3091  // loads dvipsnam at the preamble (top level), so its colors persist; the flag
3092  // reproduces that global effect. Witness 1705.06183.
3093  let effective_scope = if (lookup_definition(&T_CS!("\\ifglobalcolors"))?.is_some()
3094    && if_condition(&T_CS!("\\ifglobalcolors"))? == Some(true))
3095    || lookup_bool("color_force_global")
3096  {
3097    Some(Scope::Global)
3098  } else {
3099    scope
3100  };
3101  // Store in state as "model c1 c2 ..."
3102  let stored = color.to_stored();
3103  assign_value(
3104    &s!("color_{name}"),
3105    Stored::String(arena::pin(stored)),
3106    effective_scope,
3107  );
3108  // Define \\color@{name} macro for reversion
3109  // Perl: DefMacroI('\\color@'.$name, undef,
3110  //   '\relax\relax{model spec}{model}{spec_commas}')
3111  let model = color.model();
3112  let comps = color.components();
3113  let spec_parts: Vec<String> = comps.iter().map(|c| color::format_component(*c)).collect();
3114  let spec_space = spec_parts.join(" ");
3115  let spec_comma = spec_parts.join(",");
3116  let model_spec = s!("\\relax\\relax{{{model} {spec_space}}}{{{model}}}{{{spec_comma}}}");
3117  def_macro(
3118    T_CS!(s!("\\\\color@{name}")),
3119    None,
3120    crate::mouth::tokenize_internal(TeXString::assembled(model_spec)),
3121    Some(ExpandableOptions {
3122      scope: effective_scope,
3123      ..Default::default()
3124    }),
3125  )?;
3126  Ok(())
3127}
3128
3129/// Define a derived color model (Perl: DefColorModel).
3130/// Stores conversion functions for a derived color model.
3131pub fn def_color_model(model: &str, coremodel: &str) {
3132  assign_value(
3133    &s!("derived_color_model_{model}"),
3134    Stored::String(arena::pin(coremodel)),
3135    Some(Scope::Global),
3136  );
3137}
3138
3139/// Digest tokens in text mode, whatever mode the caller is in.
3140///
3141/// Port of Perl `Package.pm:DigestText` L405-411: brackets the digestion in
3142/// `beginMode`/`endMode` so that material which must come out as text — a
3143/// title, a caption, an alt string — does so even when it is being read from
3144/// inside math. Compare [`digest_literal`], which additionally forces an ASCII
3145/// encoding and leaves the mode's other side-effects out.
3146pub fn digest_text(stuff: Tokens) -> Result<Digested> {
3147  begin_mode("text")?;
3148  let value = digest(stuff);
3149  end_mode("text")?;
3150  value
3151}
3152
3153pub fn digest_literal<T: Into<Tokens>>(stuff: T) -> Result<Digested> {
3154  let stuff: Tokens = stuff.into();
3155  // Perhaps should do StartSemiverbatim, but is it safe to push a frame? (we might cover over
3156  // valid changes of state!)
3157  begin_mode("text")?;
3158
3159  // Fall back to the global text default if no font is currently assigned —
3160  // avoids a panic at this hot path (called from e.g. RefStepID / label
3161  // digestion) when the state's "font" slot hasn't been initialised. Matches
3162  // the same fallback `assign_value("font", Font::text_default(), …)` that
3163  // stomach::init uses at startup.
3164  let font = lookup_font().unwrap_or_else(|| Rc::new(Font::text_default()));
3165  assign_font(
3166    Rc::new(font.merge(fontmap!(encoding => "ASCII"))),
3167    Some(Scope::Local),
3168  ); // try to stay as ASCII as possible
3169
3170  let value = digest(stuff);
3171  assign_font(font, None);
3172  end_mode("text")?;
3173  value
3174}
3175
3176pub fn digest_if(token: Token) -> Result<Option<Digested>> {
3177  if lookup_definition(&token)?.is_some() {
3178    match digest(Tokens!(token)) {
3179      Ok(t) => Ok(Some(t)),
3180      Err(e) => Err(e),
3181    }
3182  } else {
3183    Ok(None)
3184  }
3185}
3186
3187/// Test a conditional `\ifXXX` and return its boolean result (Perl: IfCondition).
3188/// Looks up the conditional's test closure and invokes it.
3189pub fn if_condition(if_token: &Token) -> Result<Option<bool>> {
3190  use crate::definition::conditional::ConditionalType;
3191  if let Some(defn) = lookup_definition(if_token)?
3192    && defn.get_conditional_type() == Some(ConditionalType::If)
3193    && let Some(test) = defn.get_test()
3194  {
3195    // Read arguments for the conditional test
3196    let args = match defn.get_parameters() {
3197      Some(params) => params.read_arguments(Some(defn.as_ref()))?,
3198      None => Vec::new(),
3199    };
3200    return Ok(Some(test(args)?));
3201  }
3202  if x_equals(if_token, &T_CS!("\\iftrue")) {
3203    return Ok(Some(true));
3204  }
3205  if x_equals(if_token, &T_CS!("\\iffalse")) {
3206    return Ok(Some(false));
3207  }
3208  Error!(
3209    "expected",
3210    "conditional",
3211    s!("Expected a conditional, got '{}'", if_token.stringify())
3212  );
3213  Ok(None)
3214}
3215
3216/// Set the boolean value of a `\newif`-type conditional (Perl: SetCondition).
3217/// This is only for simple conditionals taking no arguments.
3218pub fn set_condition(if_token: &Token, value: bool, scope: Option<Scope>) {
3219  if let Ok(Some(defn)) = lookup_definition(if_token)
3220    && defn.get_parameters().is_none()
3221  {
3222    let target = if value {
3223      T_CS!("\\iftrue")
3224    } else {
3225      T_CS!("\\iffalse")
3226    };
3227    let_i(if_token, &target, scope);
3228    return;
3229  }
3230  emit_error(
3231    "expected",
3232    "newif",
3233    &format!(
3234      "Expected a conditional defined by \\newif, got '{}'",
3235      if_token.stringify()
3236    ),
3237  );
3238}
3239
3240/// Creates a single `Tokens` representing a TeX invocation of the lead `token` over a list of
3241/// arguments.
3242///
3243/// Note: currently this is near the `Mouth` representation of data, and deals purely with tokens.
3244/// A more generic version of this method may be able to support `ArgWrap` for the argument list.
3245/// Indeed, the return type may also be lifted to a new generic ArgWrap::Invocation, if there was
3246/// benefit.
3247pub fn build_invocation<T: Into<Token>>(token: T, args: Vec<Option<Tokens>>) -> Result<Tokens> {
3248  let token: Token = token.into();
3249  build_invocation_token(token, args)
3250}
3251
3252/// String entry point for `Invocation` (Perl: `Invocation($token,@args)` with a string
3253/// first argument). The string is tokenized via `TokenizeInternal`; if it yields a single
3254/// token, this reduces to `build_invocation` on that token. Otherwise the tokens are
3255/// treated as an "anonymous macro" containing parameter markers like `#1`, and the
3256/// arguments are substituted in.
3257pub fn build_invocation_str(
3258  spec: impl Into<TeXString>,
3259  args: Vec<Option<Tokens>>,
3260) -> Result<Tokens> {
3261  let spec = spec.into();
3262  let tokens = crate::mouth::tokenize_internal(spec.clone());
3263  let mut list = tokens.unlist();
3264  if list.len() > 1 {
3265    // Treat as anonymous macro
3266    let cow_args: Vec<Option<Cow<Tokens>>> = args.into_iter().map(|a| a.map(Cow::Owned)).collect();
3267    return Ok(
3268      Tokens::new(list)
3269        .pack_parameters()?
3270        .substitute_parameters(&cow_args),
3271    );
3272  }
3273  match list.pop() {
3274    Some(cs) => build_invocation_token(cs, args), // reduce to single token.
3275    None => {
3276      Error!(
3277        "unexpected",
3278        "invocation",
3279        s!("Can't invoke empty token spec '{}'", spec)
3280      );
3281      Ok(Tokens::default())
3282    },
3283  }
3284}
3285
3286fn build_invocation_token(token: Token, args: Vec<Option<Tokens>>) -> Result<Tokens> {
3287  // Note: token may have been \let to another defn!
3288  match lookup_definition(&token)? {
3289    Some(defn) => {
3290      let mut invoked_tokens = vec![token];
3291      if let Some(params) = defn.get_parameters() {
3292        invoked_tokens.extend(params.revert_arguments(args)?);
3293      }
3294      Ok(Tokens::new(invoked_tokens))
3295    },
3296    _ => {
3297      let message = s!("Can't invoke {:?}; it is undefined", token.stringify());
3298      token.with_cs_name(|csname| {
3299        Error!("undefined", csname, message);
3300        Ok(())
3301      })?;
3302      let mut invoked_tokens = vec![token];
3303      // DefConstructor!(token, convert_latex_args(args.len(), 0),
3304      // sub { LaTeXML::Core::Stomach::makeError($_[0], 'undefined', token); });
3305      let wrapped_args: Vec<Token> = args
3306        .into_iter()
3307        .flat_map(|arg_opt| {
3308          let mut wrapped = vec![T_BEGIN!()];
3309          if let Some(arg) = arg_opt {
3310            wrapped.extend(arg.unlist());
3311          }
3312          wrapped.push(T_END!());
3313          wrapped
3314        })
3315        .collect();
3316      invoked_tokens.extend(wrapped_args);
3317      Ok(Tokens::new(invoked_tokens))
3318    },
3319  }
3320}
3321
3322/// Convert a LaTeX-style argument spec to our Package form.
3323/// Ie. given $nargs and $optional, being the two optional arguments to
3324/// something like \newcommand, convert it to the form we use
3325pub fn convert_latex_args(
3326  mut nargs: usize,
3327  optional: Option<Tokens>,
3328) -> Result<Option<Parameters>> {
3329  let mut params = Vec::new();
3330  if let Some(tks) = optional {
3331    params.push(
3332      Parameter {
3333        name: pin!("Optional"),
3334        spec: arena::pin(s!("[Default:{}]", tks.clone().untex())),
3335        extra: vec![tks],
3336        ..Parameter::default()
3337      }
3338      .init()?,
3339    );
3340    // Perl `convertLaTeXArgs` does `$nargs-- if $optional`; with `$nargs==0`
3341    // that just yields an empty `1..$nargs` range. Saturate so the malformed
3342    // `\newcommand\foo[0][d]{}` (0 args yet an optional default — outside
3343    // LaTeX's 0..9 domain) can't underflow `usize` to ~2^64 and spin the loop
3344    // below. Mirrors the sibling `convert_twoopt_args`.
3345    nargs = nargs.saturating_sub(1);
3346  }
3347
3348  for _ in 1..=nargs {
3349    params.push(
3350      Parameter {
3351        name: pin!("Plain"),
3352        spec: pin!("{}"),
3353        ..Parameter::default()
3354      }
3355      .init()?,
3356    );
3357  }
3358  if params.is_empty() {
3359    Ok(None)
3360  } else {
3361    Ok(Some(Parameters::new(params)))
3362  }
3363}
3364
3365/// Two-optional variant of `convert_latex_args` — mirrors Perl's
3366/// `convert2optArgs` helper in twoopt.sty.ltxml. `\newcommandtwoopt{\cs}[n][d1][d2]{…}`
3367/// builds a signature of `[Default d1][Default d2]{…}…` where the remaining
3368/// `n - 2` args are plain required.
3369pub fn convert_twoopt_args(
3370  mut nargs: usize,
3371  opt1: Option<Tokens>,
3372  opt2: Option<Tokens>,
3373) -> Result<Option<Parameters>> {
3374  let mut params = Vec::new();
3375  if let Some(tks) = opt1 {
3376    params.push(
3377      Parameter {
3378        name: pin!("Optional"),
3379        spec: arena::pin(s!("[Default:{}]", tks.clone().untex())),
3380        extra: vec![tks],
3381        ..Parameter::default()
3382      }
3383      .init()?,
3384    );
3385    nargs = nargs.saturating_sub(1);
3386  }
3387  if let Some(tks) = opt2 {
3388    params.push(
3389      Parameter {
3390        name: pin!("Optional"),
3391        spec: arena::pin(s!("[Default:{}]", tks.clone().untex())),
3392        extra: vec![tks],
3393        ..Parameter::default()
3394      }
3395      .init()?,
3396    );
3397    nargs = nargs.saturating_sub(1);
3398  }
3399  for _ in 1..=nargs {
3400    params.push(
3401      Parameter {
3402        name: pin!("Plain"),
3403        spec: pin!("{}"),
3404        ..Parameter::default()
3405      }
3406      .init()?,
3407    );
3408  }
3409  if params.is_empty() {
3410    Ok(None)
3411  } else {
3412    Ok(Some(Parameters::new(params)))
3413  }
3414}
3415
3416/// Decode a codepoint using the fontmap for a given font and/or encoding (Perl: FontDecode).
3417/// Returns the decoded glyph (if any) and the possibly-adjusted font.
3418pub fn font_decode(
3419  code: i32,
3420  encoding_opt: Option<&str>,
3421  font_opt: Option<Rc<Font>>,
3422) -> (Option<char>, Option<Rc<Font>>) {
3423  if code < 0 {
3424    return (None, font_opt);
3425  }
3426  let font = font_opt.unwrap_or_else(|| lookup_font().unwrap());
3427  let encoding = match encoding_opt {
3428    Some(enc) => enc.to_string(),
3429    None => font
3430      .get_encoding()
3431      .map_or_else(|| "OT1".to_string(), |c| c.to_string()),
3432  };
3433  let map = load_font_map(&encoding);
3434  // Check for family-specific map. Use with_value to avoid cloning the
3435  // Stored envelope when the From<&Stored>→Option<Fontmap> impl only
3436  // needs the enum variant discriminant + an Rc bump.
3437  let (effective_map, _effective_enc) = if let Some(family) = font.get_family() {
3438    let fam_key = s!("{}_{}_fontmap", encoding, family);
3439    let fam_map: Option<Fontmap> = with_value(&fam_key, |v| v.and_then(|s| s.into()));
3440    if let Some(fm) = fam_map {
3441      (Some(fm), s!("{}_{}", encoding, family))
3442    } else {
3443      (map, encoding)
3444    }
3445  } else {
3446    (map, encoding)
3447  };
3448  let glyph = effective_map
3449    .as_ref()
3450    .and_then(|m| m.get(code as usize).copied().flatten());
3451  // In-math alphanumeric mathstyle handling is done in decodeMathChar instead
3452  (glyph, Some(font))
3453}
3454
3455/// Decode a string using the fontmap for a given encoding (Perl: FontDecodeString).
3456/// If `implicit` is true, codepoints missing from the map decode to themselves.
3457pub fn font_decode_string(string: &str, encoding_opt: Option<&str>, implicit: bool) -> String {
3458  let font = lookup_font().unwrap();
3459  let encoding = match encoding_opt {
3460    Some(enc) => enc.to_string(),
3461    None => font
3462      .get_encoding()
3463      .map_or_else(|| "OT1".to_string(), |c| c.to_string()),
3464  };
3465  let map = load_font_map(&encoding);
3466  // Check for family-specific map — same with_value motivation as above.
3467  let effective_map = if let Some(family) = font.get_family() {
3468    let fam_key = s!("{}_{}_fontmap", encoding, family);
3469    let fam_map: Option<Fontmap> = with_value(&fam_key, |v| v.and_then(|s| s.into()));
3470    fam_map.or(map)
3471  } else {
3472    map
3473  };
3474  let input_enc = lookup_string("INPUT_ENCODING");
3475  let map_max: usize = if input_enc == "utf8" { 128 } else { 256 };
3476  // Also limit for short font maps
3477  let map_max = if let Some(ref m) = effective_map {
3478    if m.len() < map_max { m.len() } else { map_max }
3479  } else {
3480    map_max
3481  };
3482
3483  let mut result = String::new();
3484  for ch in string.chars() {
3485    let code = ch as usize;
3486    if implicit {
3487      if let Some(ref m) = effective_map {
3488        if code < map_max {
3489          if let Some(Some(glyph)) = m.get(code) {
3490            result.push(*glyph);
3491          }
3492        } else {
3493          result.push(ch);
3494        }
3495      } else {
3496        result.push(ch);
3497      }
3498    } else if let Some(ref m) = effective_map
3499      && let Some(Some(glyph)) = m.get(code)
3500    {
3501      result.push(*glyph);
3502    }
3503  }
3504  result
3505}
3506
3507// Memoized key symbols for the per-encoding fontmap Value-table entries.
3508// `decode_string` runs per digested character run and used to rebuild (format!
3509// + arena probe) the `"{encoding}_fontmap"`-family keys on every call — ~1M
3510// allocations on a 1.3 s paper (2026-08-23 audit R6, dhat). Encodings are few
3511// (OT1/T1/OML/…), so the (encoding → key syms) maps stay tiny. Value lookups
3512// still go through the state tables — only the key STRINGS are memoized.
3513// Keyed by arena SymStr, so MUST be cleared with `arena::reset()`
3514// (`reset_fontmap_key_memo`, called from `reset_thread_engine`).
3515thread_local! {
3516  static FONTMAP_KEY_MEMO: std::cell::RefCell<HashMap<String, FontmapKeySyms>> =
3517    std::cell::RefCell::new(HashMap::default());
3518  static FONTMAP_FAMILY_KEY_MEMO: std::cell::RefCell<HashMap<(String, String), SymStr>> =
3519    std::cell::RefCell::new(HashMap::default());
3520}
3521
3522#[derive(Clone, Copy)]
3523pub struct FontmapKeySyms {
3524  /// `"{encoding}_fontmap"`
3525  pub map:       SymStr,
3526  /// `"{encoding}_fontmap_failed_to_load"`
3527  pub fail:      SymStr,
3528  /// `"{encoding}_fontmap_multichar"`
3529  pub multichar: SymStr,
3530}
3531
3532/// The memoized key symbols for `encoding`'s fontmap Value-table entries.
3533pub fn fontmap_key_syms(encoding: &str) -> FontmapKeySyms {
3534  FONTMAP_KEY_MEMO.with(|m| {
3535    if let Some(keys) = m.borrow().get(encoding) {
3536      return *keys;
3537    }
3538    let keys = FontmapKeySyms {
3539      map:       arena::pin(s!("{encoding}_fontmap")),
3540      fail:      arena::pin(s!("{encoding}_fontmap_failed_to_load")),
3541      multichar: arena::pin(s!("{encoding}_fontmap_multichar")),
3542    };
3543    m.borrow_mut().insert(encoding.to_string(), keys);
3544    keys
3545  })
3546}
3547
3548/// The memoized `"{encoding}_{family}_fontmap"` key symbol.
3549pub fn fontmap_family_key_sym(encoding: &str, family: &str) -> SymStr {
3550  FONTMAP_FAMILY_KEY_MEMO.with(|m| {
3551    if let Some(sym) = m.borrow().get(&(encoding.to_string(), family.to_string())) {
3552      return *sym;
3553    }
3554    let sym = arena::pin(s!("{encoding}_{family}_fontmap"));
3555    m.borrow_mut()
3556      .insert((encoding.to_string(), family.to_string()), sym);
3557    sym
3558  })
3559}
3560
3561/// Clear the fontmap key-symbol memos. Companion to `arena::reset()` — the
3562/// cached `SymStr`s dangle after a reset renumbers the arena.
3563pub fn reset_fontmap_key_memo() {
3564  FONTMAP_KEY_MEMO.with(|m| m.borrow_mut().clear());
3565  FONTMAP_FAMILY_KEY_MEMO.with(|m| m.borrow_mut().clear());
3566}
3567
3568pub fn load_font_map(encoding: &str) -> Option<Fontmap> {
3569  let keys = fontmap_key_syms(encoding);
3570  let _ = preload_font_map_keyed(encoding, keys); // infallible in practice; swallow Result
3571  // with_value avoids the Stored::clone; the Fontmap extraction is a cheap
3572  // Rc bump on the inner slice regardless.
3573  with_value_sym(keys.map, |v| v.and_then(|s| s.into()))
3574}
3575pub fn preload_font_map(encoding: &str) -> Result<()> {
3576  preload_font_map_keyed(encoding, fontmap_key_syms(encoding))
3577}
3578fn preload_font_map_keyed(encoding: &str, keys: FontmapKeySyms) -> Result<()> {
3579  // This check is done as a "preload" step for mutability reasons.
3580  if has_value_sym(keys.map) {
3581    return Ok(());
3582  }
3583  let failed_flag = lookup_bool_sym(keys.fail);
3584  if !failed_flag {
3585    assign_value_sym(keys.fail, true, None); // Stop recursion?
3586    let _ = input_definitions(&encoding.to_lowercase(), InputDefinitionOptions {
3587      extension: Some(Cow::Borrowed("fontmap")),
3588      noerror: true,
3589      ..InputDefinitionOptions::default()
3590    });
3591    if has_value_sym(keys.map) {
3592      // Got map?
3593      assign_value_sym(keys.fail, false, None);
3594    } else {
3595      assign_value_sym(keys.fail, true, Some(Scope::Global));
3596    }
3597  }
3598  Ok(())
3599}