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