latexml_core/binding/kernel_autoload.rs
1//! Autoload the LaTeX format when an *undefined* control sequence turns out to
2//! be one the LaTeX kernel defines.
3//!
4//! # Why this exists
5//!
6//! In real LaTeX there is no such thing as "before the kernel": `latex.ltx`
7//! **is** the format, so every kernel command is live from token one. LaTeXML
8//! (Perl and Rust alike) instead loads `LaTeX.pool` lazily, on first sight of a
9//! *trigger* control sequence — the list ported from Perl `TeX.pool.ltxml`
10//! L33-56 (`\documentclass`, `\newcommand`, `\begin`, …), installed in
11//! `latexml_engine::tex`.
12//!
13//! A curated trigger list is incomplete by construction. Any kernel command
14//! that is *not* on it — and a document may legitimately use one before
15//! `\documentclass` — is simply undefined, gets an `<ltx:ERROR/>` stub, and the
16//! document derails. The canonical case is the "use this class if installed"
17//! idiom
18//!
19//! ```tex
20//! \IfFileExists{proc-l.cls}{\documentclass{proc-l}}{\documentclass{amsproc}}
21//! ```
22//!
23//! where the collapsed conditional means *no class is ever selected* and the
24//! run cascades into `Fatal:TooManyErrors`. Same-host Perl LaTeXML fails
25//! identically (see `docs/parity/KNOWN_PERL_ERRORS.md` — shared defect, upstream
26//! candidate), so this is a "at parity, still a bug" fix rather than a
27//! divergence repair.
28//!
29//! # What this module is
30//!
31//! The single funnel through which the undefined-CS paths ask "should the LaTeX
32//! kernel be loaded for this token instead of erroring?". It holds no policy of
33//! its own: the answer comes from a hook the engine registers at `TeX.pool`
34//! load time ([`set_hook`]), because deciding it needs the kernel dump and the
35//! pool loader, neither of which `latexml_core` owns.
36//!
37//! The eager Perl trigger list is *kept* — it fires on a legitimate use before
38//! any error is raised, which this hook cannot do. This is the safety net
39//! beneath it, not a replacement.
40//!
41//! # Call sites: two, deliberately not three
42//!
43//! `read_x_token`'s `Outcome::Undefined` arm and `invoke_token_undefined` are
44//! the paths a CS reaches when it is actually being *used*. `read_balanced`'s
45//! "cs SHOULD have defn by now; report early!" branch — the third
46//! `generate_error_stub` caller, inside token-list scanning — is left alone on
47//! purpose: it fires while collecting an `\edef`-style body rather than
48//! executing it, and loading a format mid-scan buys a rare case
49//! (`\edef\x{\IfFileExists…}` before `\documentclass`) at the price of running
50//! the whole pool from inside a partially-read token list. Wire it up only with
51//! a reproducer that needs it.
52
53use std::sync::OnceLock;
54
55use crate::token::Token;
56
57/// Engine-supplied decision procedure for "is `token` a LaTeX kernel control
58/// sequence, and if so load the kernel and report whether it is now defined".
59///
60/// Returning `true` means the caller must **retry** `token` (it now has a real
61/// meaning); returning `false` means "carry on and report it undefined exactly
62/// as before". Implementations own the once-only guard — see
63/// `latexml_engine::latex_kernel::autoload_latex_kernel`.
64pub type KernelAutoloadHook = fn(&Token) -> bool;
65
66/// Process-global because the hook is a plain `fn` pointer with no state: the
67/// per-session bookkeeping (already-loaded, already-attempted) lives in the
68/// State, so re-registering across sessions is a no-op.
69static HOOK: OnceLock<KernelAutoloadHook> = OnceLock::new();
70
71/// Register the engine's kernel-autoload decision procedure. Called from
72/// `TeX.pool`'s definition load, which precedes every conversion. Repeat calls
73/// are ignored (the first registration wins).
74pub fn set_hook(hook: KernelAutoloadHook) { let _ = HOOK.set(hook); }
75
76/// Ask the registered hook whether `token` should pull the LaTeX kernel in.
77///
78/// `true` ⇒ the kernel was loaded *and* `token` now has a meaning, so the
79/// caller must push it back and re-resolve. `false` ⇒ nothing happened; take
80/// the ordinary bounded `Error:undefined` path.
81///
82/// Cold path only — every caller is a site that was already about to raise an
83/// undefined-CS error. With no hook registered (a bare `latexml_core` embedding)
84/// this is an atomic load and a `false`.
85pub fn try_autoload(token: &Token) -> bool {
86 match HOOK.get() {
87 Some(hook) => hook(token),
88 None => false,
89 }
90}