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