Skip to main content

rustyfi_lang/
lib.rs

1//! Abstract syntax tree, elaboration, evaluator, and primitives — the
2//! language core of the SATySFi port.
3
4pub mod ast;
5pub(crate) mod compile;
6pub mod crossref;
7pub mod elaborate;
8pub mod eval;
9pub mod exhaustive;
10pub mod hyphenation;
11pub mod prim_types;
12pub mod primitives;
13pub mod quoted;
14pub mod symbol;
15pub mod typecheck;
16pub mod types;
17pub mod unify;
18pub mod v1;
19pub mod value;
20pub mod visit;
21
22use crossref::{CrossRefs, Verdict};
23use rustyfi_backend::{
24    place_block_at, placed_line_extent, shift_graphics, DecoId, FontMetrics, GraphicsElem, Length,
25    PureHorzBox, VertBox,
26};
27use std::cell::RefCell;
28use std::collections::{BTreeMap, BTreeSet};
29use std::rc::Rc;
30use value::{DocumentValue, Value};
31
32#[derive(Debug, thiserror::Error)]
33pub enum CompileError {
34    #[error(transparent)]
35    Parse(#[from] rustyfi_syntax::ParseFileError),
36    #[error(transparent)]
37    Elaborate(#[from] elaborate::ElabError),
38    #[error(transparent)]
39    Type(#[from] typecheck::TypeError),
40    #[error(transparent)]
41    Eval(#[from] eval::EvalError),
42    #[error("the file's expression evaluated to {0}, not a document")]
43    NotADocument(&'static str),
44    #[error(transparent)]
45    Lower(#[from] v1::lower::LowerError),
46    /// A `V0_0` dependency spliced into a `V0_1` program referenced `name`, a
47    /// builtin primitive/type that is version-forked (bound, or shaped,
48    /// differently between `V0_0` and `V0_1` — see
49    /// `typecheck::forked_type_names`). The
50    /// merged program's single `base_env_with_version(V0_1)` can only bind
51    /// ONE closure per name, so accepting this would silently mis-resolve
52    /// `name` to the WRONG version's primitive.
53    ///
54    /// The trailing `— {}` is `v1::xver_adapt::forked_note`, keyed on
55    /// `name`: WHY this particular name cannot cross — a missing bridge
56    /// feature (a wrapper could be written), or a REPRESENTATION fork
57    /// (`page`, `font`) where the generations disagree about what the
58    /// runtime value IS and no amount of bridge work helps.
59    #[error(
60        "cross-version import ({slice}): dependency {dep} references `{name}`, a \
61         version-forked builtin — {}",
62        v1::xver_adapt::forked_note(.name)
63    )]
64    CrossVersionUnsupportedName {
65        name: String,
66        dep: String,
67        slice: &'static str,
68    },
69}
70
71/// Compile a `.saty` source string down to a typeset document:
72/// lex → parse → elaborate → evaluate.
73pub fn compile_document(
74    src: &str,
75    metrics: &dyn FontMetrics,
76) -> Result<std::rc::Rc<DocumentValue>, CompileError> {
77    let file = rustyfi_syntax::parse_file(src)?;
78    compile_document_cst(&file, metrics)
79}
80
81/// Compile an already-parsed (possibly loader-merged) file. The multi-file
82/// loader concatenates library preludes into one synthetic `cst::File` and
83/// enters here.
84///
85/// Thin wrapper over [`compile_document_cst_with_trials`] that drops the
86/// trial count — the stable entry point for the CLI (`main.rs`).
87pub fn compile_document_cst(
88    file: &rustyfi_syntax::cst::File,
89    metrics: &dyn FontMetrics,
90) -> Result<std::rc::Rc<DocumentValue>, CompileError> {
91    compile_document_cst_with_trials(file, metrics).map(|(doc, _trials)| doc)
92}
93
94/// Same as [`compile_document_cst`], but also returns how many fixpoint
95/// trials it took (& the fixpoint) — exposed for tests that must confirm the
96/// fixpoint actually iterated, not just that it produced the right answer on
97/// a lucky first pass.
98pub fn compile_document_cst_with_trials(
99    file: &rustyfi_syntax::cst::File,
100    metrics: &dyn FontMetrics,
101) -> Result<(std::rc::Rc<DocumentValue>, u32), CompileError> {
102    compile_document_cst_with_aux(file, metrics, &mut crossref::AuxTable::new())
103}
104
105/// [`compile_document_cst_with_trials`] threading an AUXILIARY cross-reference table: `aux` seeds the
106/// fixpoint from a previous run and is overwritten with the final table.
107/// Seeding only affects how fast the fixpoint converges — see
108/// [`crossref::CrossRefs::seeded`] and [`crossref::CrossRefs::seed_unvalidated`],
109/// which together guarantee the output is the same as a cold run's.
110pub fn compile_document_cst_with_aux(
111    file: &rustyfi_syntax::cst::File,
112    metrics: &dyn FontMetrics,
113    aux: &mut crossref::AuxTable,
114) -> Result<(std::rc::Rc<DocumentValue>, u32), CompileError> {
115    compile_document_cst_with_stages(file, metrics, aux, &std::collections::HashMap::new())
116}
117
118/// The stage a file's `@stage:` header declares, if any.
119///
120/// The loader merges every library's prelude into one file and drops the
121/// headers, so each caller that merges has to read this off first and record
122/// which entries it covers -- see [`compile_document_cst_with_stages`].
123pub fn declared_stage(file: &rustyfi_syntax::cst::File) -> Option<types::Stage> {
124    use rustyfi_syntax::token::Token;
125    file.headers.iter().find_map(|h| match h {
126        rustyfi_syntax::cst::Header::Stage(st) => match st.tok {
127            Token::HeaderPersistent0 => Some(types::Stage::Persistent0),
128            Token::HeaderStage0 => Some(types::Stage::Stage0),
129            Token::HeaderStage1 => Some(types::Stage::Stage1),
130            _ => None,
131        },
132        _ => None,
133    })
134}
135
136/// Record `file`'s declared stage against the prelude slots `start..end` its
137/// bindings just landed in, when that stage is not the default.
138fn note_stage(
139    stages: &mut std::collections::HashMap<usize, types::Stage>,
140    file: &rustyfi_syntax::cst::File,
141    start: usize,
142    end: usize,
143) {
144    if let Some(stage) = declared_stage(file).filter(|s| *s != types::Stage::default()) {
145        stages.extend((start..end).map(|i| (i, stage)));
146    }
147}
148
149/// Splice compiler-generated cross-version glue at the END of `prelude` and
150/// tag it with `stage`, the DECLARED stage of the dependency whose bindings
151/// the glue names.
152///
153/// The stage is load-bearing, not bookkeeping. `Stage::can_reference` is not
154/// symmetric: a `@stage: persistent` binding may not read a default-stage one.
155/// Every generated wrapper/shadow here re-applies a dependency's own export by
156/// name, so splicing it at the default stage silently makes it unreadable from
157/// the very consumers the forward deco/paren wrapper and its view-scheduling
158/// exist to serve — the failure surfaces as
159/// `invalid occurrence of variable .. as to stage`, which reads like a user
160/// error in a document that mentions neither binding. A FIXED stage would be
161/// as wrong for a `@stage: 0`/default dependency as the default is for a
162/// persistent one (cf. `unite_helper_prelude`'s explicit `Persistent0`).
163fn splice_staged(
164    prelude: &mut Vec<rustyfi_syntax::cst::TopBinding>,
165    stages: &mut std::collections::HashMap<usize, types::Stage>,
166    stage: Option<types::Stage>,
167    bindings: Vec<rustyfi_syntax::cst::TopBinding>,
168) {
169    let start = prelude.len();
170    prelude.extend(bindings);
171    if let Some(st) = stage {
172        stages.extend((start..prelude.len()).map(|i| (i, st)));
173    }
174}
175
176/// One [`v1::xver_adapt::deco_upgrade_prelude`] call per DISTINCT declared
177/// stage among `exports`, each spliced at that stage.
178///
179/// Grouping rather than one flat call is what keeps [`splice_staged`]'s
180/// argument true when a program crosses exports from dependencies that
181/// declared DIFFERENT `@stage:` headers: the glue for each is emitted in its
182/// own contiguous, correctly-tagged run. With one stage (every real program so
183/// far) it is exactly one call, in `exports` order.
184fn splice_upgrade_glue(
185    prelude: &mut Vec<rustyfi_syntax::cst::TopBinding>,
186    stages: &mut std::collections::HashMap<usize, types::Stage>,
187    exports: &[(v1::xver_adapt::DecoExport, Option<types::Stage>)],
188    step: v1::xver_adapt::UpgradeStep,
189) {
190    let mut order: Vec<Option<types::Stage>> = Vec::new();
191    for (_, st) in exports {
192        if !order.contains(st) {
193            order.push(*st);
194        }
195    }
196    for st in order {
197        let group: Vec<v1::xver_adapt::DecoExport> = exports
198            .iter()
199            .filter(|(_, s)| *s == st)
200            .map(|(e, _)| e.clone())
201            .collect();
202        splice_staged(
203            prelude,
204            stages,
205            st,
206            v1::xver_adapt::deco_upgrade_prelude(&group, step),
207        );
208    }
209}
210
211/// A phase stopwatch that only exists when `$RUSTYFI_TIMING` asked for one.
212///
213/// The point is the `None` case. `Instant::now()` has no implementation on
214/// `wasm32-unknown-unknown` — there is no clock to read, and the call PANICS —
215/// so taking one unconditionally meant the browser build could not compile a
216/// document at all, however little anyone wanted the timing. An untimed run
217/// has no use for the value either way, so not taking it costs nothing.
218#[derive(Clone, Copy)]
219struct Phase(Option<std::time::Instant>);
220
221impl Phase {
222    fn start(timing: bool) -> Self {
223        Phase(timing.then(std::time::Instant::now))
224    }
225
226    /// Milliseconds since [`Self::start`]. `0.0` when untimed — every caller
227    /// is inside an `if timing` that will not print it.
228    fn ms(self) -> f64 {
229        self.0
230            .map(|t| t.elapsed().as_secs_f64() * 1e3)
231            .unwrap_or(0.0)
232    }
233}
234
235/// [`compile_document_cst_with_aux`] told which merged prelude entries came
236/// from a file that declared a non-default `@stage:`.
237///
238/// The loader concatenates every library's prelude into one file, which loses
239/// the per-file header; this hands that back, so a `@stage: 0` library is
240/// typechecked at stage 0 (where `&e` is legal) while the document around it
241/// stays at stage 1 (where it is not).
242pub fn compile_document_cst_with_stages(
243    file: &rustyfi_syntax::cst::File,
244    metrics: &dyn FontMetrics,
245    aux: &mut crossref::AuxTable,
246    stages: &std::collections::HashMap<usize, types::Stage>,
247) -> Result<(std::rc::Rc<DocumentValue>, u32), CompileError> {
248    let timing = std::env::var_os("RUSTYFI_TIMING").is_some();
249    let t = Phase::start(timing);
250    let env0 = primitives::base_env();
251    // The BRANDED front half lives in its own scope: the `SymbolStore`, the
252    // elaborated `Ast<Symbol>` and the typechecker's tables are all dead by
253    // the time the fixpoint trials run below.
254    //
255    // The DE-BRANDED `body` it yields, however, must stay alive until after
256    // `eval_document_trials` returns — `Interp::eval_arg` memoizes compiled
257    // command arguments by `&Ast` ADDRESS (`eval.rs`'s `arg_cache`), which is
258    // sound only while every node it can reach is pinned. Binding it to a
259    // local (rather than passing `&debrand(..)` as a temporary) is what pins
260    // it.
261    let body = {
262        let store = symbol::SymbolStore::new();
263        let scope = elaborate::Scope::new(&store, env0.names());
264        let program = elaborate::elaborate_program_with_stages(file, &scope, stages)?;
265        if timing {
266            eprintln!("TIMING   elaborate        {:>8.1}ms", t.ms());
267        }
268        let t = Phase::start(timing);
269        typecheck::typecheck(&program)?;
270        if timing {
271            eprintln!("TIMING   typecheck        {:>8.1}ms", t.ms());
272        }
273        // The compile membrane: resolve every `Symbol` back to its text, so
274        // nothing downstream (the `CompiledExpr`, the per-trial `Env`s,
275        // `Value`) carries the store's borrow. See `ast::debrand`.
276        ast::debrand(&program.body, &store)
277    };
278    // Compile the elaborated body into a closure tree ONCE. Each trial below
279    // re-runs this same `compiled` against a fresh env + a fresh (except
280    // `crossrefs`) `Interp` — safe because `CompiledExpr::run` takes `&self`
281    // and re-executes the whole tree from scratch, reproducing upstream's
282    // `eval_main i env_freezed ast` per trial (`main.ml:337-397`).
283    let t = Phase::start(timing);
284    let compiled = compile::compile_program(&body, &env0);
285    if timing {
286        eprintln!("TIMING   compile-tree     {:>8.1}ms", t.ms());
287    }
288    eval_document_trials(
289        &compiled,
290        metrics,
291        rustyfi_syntax::RustyfiVersion::V0_0,
292        aux,
293    )
294}
295
296/// [`compile_document_cst_with_stages`]'s front half and nothing else:
297/// elaborate and typecheck, then STOP — no closure tree, no font store, no
298/// evaluation. The 0.0.6 sibling of [`check_document_v1`]; see that function
299/// for what the distinction is for.
300pub fn check_document_cst_with_stages(
301    file: &rustyfi_syntax::cst::File,
302    stages: &std::collections::HashMap<usize, types::Stage>,
303) -> Result<(), CompileError> {
304    let env0 = primitives::base_env();
305    let store = symbol::SymbolStore::new();
306    let scope = elaborate::Scope::new(&store, env0.names());
307    let program = elaborate::elaborate_program_with_stages(file, &scope, stages)?;
308    typecheck::typecheck(&program)?;
309    Ok(())
310}
311
312/// Concatenate a loader-resolved 0.0.6 program's dependency-ordered library
313/// preludes ahead of the entry document's own, producing the one synthetic
314/// file [`compile_document_cst_with_stages`] and
315/// [`check_document_cst_with_stages`] elaborate — plus the per-slot `@stage:`
316/// table the concatenation would otherwise lose.
317///
318/// The 0.0.6 counterpart of `assemble_v1`, and the CLI's `merge_program`:
319/// both the compiler and the language server need it, so it lives here rather
320/// than in either of them.
321///
322/// Panics if handed a `LoadedCst::V0_1` file — a mixed-generation 0.0.6-rooted
323/// load belongs on [`compile_document_v006_xver_with_aux`]'s (or
324/// [`check_document_v006_xver`]'s) path, which the caller selects by testing
325/// for a `V0_1` file before calling this.
326pub fn merge_v006_program(
327    program: rustyfi_loader::LoadedProgram,
328) -> (
329    rustyfi_syntax::cst::File,
330    std::collections::HashMap<usize, types::Stage>,
331) {
332    fn as_v006(cst: rustyfi_loader::LoadedCst) -> rustyfi_syntax::cst::File {
333        match cst {
334            rustyfi_loader::LoadedCst::V0_0(f) => f,
335            rustyfi_loader::LoadedCst::V0_1(_) => unreachable!(
336                "merge_v006_program is the V0_0-only path; a V0_1 file belongs \
337                 on compile_document_v1's or compile_document_v006_xver's"
338            ),
339        }
340    }
341
342    let mut files = program.files;
343    let entry = files.pop().expect("loader always yields the entry last");
344    let entry_cst = as_v006(entry.cst);
345    let mut prelude = Vec::new();
346    // Concatenation drops each file's headers, so `@stage:` — a property of
347    // its BINDINGS, not of the file as a document — is recorded here
348    // against the slots they land in. The entry document is stage 1 by
349    // definition and contributes nothing.
350    let mut stages = std::collections::HashMap::new();
351    for lib in files {
352        let mut cst = as_v006(lib.cst);
353        let start = prelude.len();
354        prelude.extend(std::mem::take(&mut cst.prelude));
355        note_stage(&mut stages, &cst, start, prelude.len());
356    }
357    prelude.extend(entry_cst.prelude);
358    (
359        rustyfi_syntax::cst::File {
360            headers: Vec::new(),
361            prelude,
362            in_kw: entry_cst.in_kw,
363            body: entry_cst.body,
364            eoi: entry_cst.eoi,
365        },
366        stages,
367    )
368}
369
370/// Typecheck a whole loader-resolved program, whichever of the three shapes it
371/// is, without evaluating it.
372///
373/// The dispatch mirrors the CLI's own (`rustyfi`'s `cmd_compile`) exactly, and
374/// for the same reasons: a `V0_1` load is a module program; a `V0_0` load that
375/// carries a foreign `V0_1` dependency is a cross-version splice; anything
376/// else is the flat 0.0.6 prelude merge. `version` is the load's own
377/// [`rustyfi_loader::LoadOptions::version`] — Axis A, the entry's generation —
378/// not any individual file's.
379pub fn check_document_program(
380    program: rustyfi_loader::LoadedProgram,
381    version: rustyfi_syntax::RustyfiVersion,
382) -> Result<(), CompileError> {
383    match version {
384        rustyfi_syntax::RustyfiVersion::V0_1 => check_document_v1(&program.files),
385        _ => {
386            let has_v01_dep = program
387                .files
388                .iter()
389                .any(|f| matches!(f.cst, rustyfi_loader::LoadedCst::V0_1(_)));
390            if has_v01_dep {
391                check_document_v006_xver(&program.files)
392            } else {
393                let (merged, stages) = merge_v006_program(program);
394                check_document_cst_with_stages(&merged, &stages)
395            }
396        }
397    }
398}
399
400/// The compile-once + fixpoint-trial tail shared by the `V0_0` and `V0_1`
401/// entry points (`compile_document_cst_with_trials` above and
402/// `compile_document_v1_with_trials` below). The only version-sensitive step
403/// is the fresh per-trial env (`primitives::base_env_with_version(version)`);
404/// everything else (crossrefs persistence, `fire_hooks`, `DocExtras` attach)
405/// is identical regardless of which SATySFi generation produced `compiled`.
406fn eval_document_trials(
407    compiled: &compile::CompiledExpr,
408    metrics: &dyn FontMetrics,
409    version: rustyfi_syntax::RustyfiVersion,
410    aux: &mut crossref::AuxTable,
411) -> Result<(std::rc::Rc<DocumentValue>, u32), CompileError> {
412    // Seed the fixpoint from the previous run's auxiliary table, if any; if
413    // the final trial read a seeded value it never re-derived, redo cold
414    // instead (see `CrossRefs::seed_unvalidated`) — this is what keeps a warm
415    // build byte-identical to a cold one.
416    if !aux.is_empty() {
417        let (doc, trials, table, unvalidated) =
418            eval_trials_seeded(compiled, metrics, version, aux.clone())?;
419        if !unvalidated {
420            *aux = table;
421            return Ok((doc, trials));
422        }
423    }
424    let (doc, trials, table, _) =
425        eval_trials_seeded(compiled, metrics, version, crossref::AuxTable::new())?;
426    *aux = table;
427    Ok((doc, trials))
428}
429
430/// One complete fixpoint run against `seed`. Returns the final cross-reference
431/// table alongside the document, plus whether the seed turned out to be
432/// load-bearing but unverified ([`CrossRefs::seed_unvalidated`]).
433fn eval_trials_seeded(
434    compiled: &compile::CompiledExpr,
435    metrics: &dyn FontMetrics,
436    version: rustyfi_syntax::RustyfiVersion,
437    seed: crossref::AuxTable,
438) -> Result<(std::rc::Rc<DocumentValue>, u32, crossref::AuxTable, bool), CompileError> {
439    let timing = std::env::var_os("RUSTYFI_TIMING").is_some();
440    let crossrefs = Rc::new(RefCell::new(CrossRefs::seeded(seed)));
441    let mut trials = 0u32;
442    loop {
443        trials += 1;
444        let t_trial = Phase::start(timing);
445        // Fresh per trial: `let-mutable` store state resets (== upstream's
446        // `env_freezed` re-eval), and a fresh `Interp` resets `hooks`/
447        // `images` too — only `crossrefs` is threaded through.
448        //
449        // The runtime environment is just an empty root frame: the base
450        // environment is a COMPILE-time table already folded into the
451        // compiled tree, and top-level bindings live in the compiler's slot
452        // table, which the spine rewrites as it re-executes each trial.
453        // Nothing resolves a name here.
454        let env = value::Env::root();
455        let mut interp = eval::Interp::new(metrics);
456        interp.crossrefs = crossrefs.clone();
457        // Threads `version` onto the `Interp` so `read_inline`'s `EmbedMath`
458        // fallback arm (no installed math command — unit-test contexts
459        // only) can dispatch between `reflect_math_elem`/
460        // `reflect_math_elem_v01`.
461        interp.version = version;
462        let doc = match compiled.run(&env, &mut interp)? {
463            Value::Document(doc) => doc,
464            other => return Err(CompileError::NotADocument(other.type_name())),
465        };
466        let t_hooks = Phase::start(timing);
467        let run_ms = t_trial.ms();
468        // Fire every placed page-break hook now that `break_pages` has given
469        // every one of them its final page number/point; hooks mutate
470        // `crossrefs` (the only place that seam is legally crossed — see
471        // `fire_hooks`'s doc comment).
472        fire_hooks(&mut interp, &doc)?;
473        if timing {
474            eprintln!(
475                "TIMING   trial {trials}: run(eval+layout) {:>8.1}ms  fire_hooks {:>6.1}ms",
476                run_ms,
477                t_hooks.ms()
478            );
479        }
480        let verdict = crossrefs.borrow_mut().verdict();
481        match verdict {
482            Verdict::NeedsAnotherTrial => continue,
483            Verdict::CanTerminate(_) | Verdict::CountMax => {
484                // Attach the final trial's accumulated extras. `doc` is
485                // usually uniquely held here; if the program's env still
486                // holds a clone, fall back to a one-time deep clone.
487                let mut final_doc = Rc::try_unwrap(doc).unwrap_or_else(|rc| (*rc).clone());
488                final_doc.extras = rustyfi_backend::DocExtras {
489                    annotations: std::mem::take(&mut interp.annotations),
490                    destinations: std::mem::take(&mut interp.destinations),
491                    outline: std::mem::take(&mut interp.outline),
492                    page_graphics: std::mem::take(&mut interp.page_graphics),
493                    doc_info: interp.doc_info.take(),
494                };
495                // The DecoId-keyed link/destination side-channel, same
496                // timing as `extras` above (only known once `fire_hooks`
497                // has run).
498                final_doc.reflow_links = std::mem::take(&mut interp.link_decos);
499                final_doc.reflow_dests = std::mem::take(&mut interp.dest_decos);
500                final_doc.reflow_frame_decos = std::mem::take(&mut interp.frame_decos);
501                let refs = crossrefs.borrow();
502                return Ok((
503                    Rc::new(final_doc),
504                    trials,
505                    refs.export(),
506                    refs.seed_unvalidated(),
507                ));
508            }
509        }
510    }
511}
512
513/// Compile a loader-resolved SATySFi 0.1 program (`LoadOptions { version:
514/// V0_1, .. }`): dependency libraries (`files[..n-1]`, loader
515/// dependency-first order) are each lowered to one `TopBinding::Module`
516/// (qualified exports — see `v1/lower.rs`'s module doc) via
517/// [`v1::lower::lower_file_v1`], the entry (`files[n-1]`, always last —
518/// `LoadedProgram::files`'s contract) via [`v1::lower::lower_document_v1`],
519/// assembled into ONE synthetic `cst::File` — the same shape the CLI's
520/// `merge_program` builds for 0.0.6 — and pushed through the SHARED
521/// elaborate -> typecheck(V0_1) -> compile -> fixpoint-eval pipeline.
522/// Signature ascriptions (`:>`) are enforced per binding by
523/// `v1::module_check::check_program`.
524pub fn compile_document_v1(
525    files: &[rustyfi_loader::LoadedFile],
526    metrics: &dyn FontMetrics,
527) -> Result<std::rc::Rc<DocumentValue>, CompileError> {
528    compile_document_v1_with_trials(files, metrics).map(|(doc, _trials)| doc)
529}
530
531/// Trial-count-reporting sibling, mirroring
532/// `compile_document_cst_with_trials` (same rationale: fixture tests that
533/// must see the fixpoint iterate).
534pub fn compile_document_v1_with_trials(
535    files: &[rustyfi_loader::LoadedFile],
536    metrics: &dyn FontMetrics,
537) -> Result<(std::rc::Rc<DocumentValue>, u32), CompileError> {
538    compile_document_v1_with_aux(files, metrics, &mut crossref::AuxTable::new())
539}
540
541/// [`compile_document_v1_with_trials`] threading an AUXILIARY cross-reference
542/// table — see [`compile_document_cst_with_aux`]'s doc comment for what
543/// seeding `aux` does and why it can't change the output.
544pub fn compile_document_v1_with_aux(
545    files: &[rustyfi_loader::LoadedFile],
546    metrics: &dyn FontMetrics,
547    aux: &mut crossref::AuxTable,
548) -> Result<(std::rc::Rc<DocumentValue>, u32), CompileError> {
549    use rustyfi_syntax::RustyfiVersion;
550
551    let asm = assemble_v1(files)?;
552    let env0 = primitives::base_env_with_version(RustyfiVersion::V0_1);
553    // Branded front half scoped so the store, the `Ast<Symbol>` tree and the
554    // module checker's tables are dead before the fixpoint trials run; the
555    // de-branded `body` stays pinned for the trials' sake — see
556    // `compile_document_cst_with_trials` for both halves of that contract.
557    let body = {
558        let store = symbol::SymbolStore::new();
559        let program = check_v1(&asm, &store, &env0)?;
560        ast::debrand(&program.body, &store)
561    };
562    let compiled = if asm.v006_indices.is_empty() {
563        compile::compile_program(&body, &env0)
564    } else {
565        let env0_v006 = primitives::base_env_with_version(RustyfiVersion::V0_0);
566        compile::compile_program_xver(&body, &env0, &env0_v006)
567    };
568    eval_document_trials(&compiled, metrics, RustyfiVersion::V0_1, aux)
569}
570
571/// [`compile_document_v1_with_aux`]'s front half and nothing else: assemble,
572/// elaborate, typecheck, enforce every `:>` seal — then STOP, without
573/// compiling a closure tree, without a font store, and without evaluating
574/// anything.
575///
576/// What it is for is a language server. Elaboration, typechecking and sealing
577/// are what take a document from "parses" to "would compile", and they are
578/// also the phases that need the whole resolved program rather than one
579/// buffer. What follows them — `compile::compile_program` and the fixpoint
580/// trials — produces pages, which an editor has no use for and which cost far
581/// more than the answer is worth on every keystroke.
582pub fn check_document_v1(files: &[rustyfi_loader::LoadedFile]) -> Result<(), CompileError> {
583    let asm = assemble_v1(files)?;
584    let env0 = primitives::base_env_with_version(rustyfi_syntax::RustyfiVersion::V0_1);
585    let store = symbol::SymbolStore::new();
586    check_v1(&asm, &store, &env0).map(|_program| ())
587}
588
589/// The synthetic single-file program a loader-resolved 0.1 load is compiled
590/// through, plus the three side tables the elaborator and the module checker
591/// need to read it correctly. Produced by [`assemble_v1`], consumed by
592/// [`check_v1`].
593struct AssembledV1<'a> {
594    /// The merged `cst::File` — every dependency's bindings, dependency-first,
595    /// then the entry's own body.
596    file: rustyfi_syntax::cst::File,
597    /// The 0.1 dependencies' own `cst_v1` trees, for `:>` seal enforcement.
598    dep_csts: Vec<&'a rustyfi_syntax::cst_v1::FileV1>,
599    /// Which top-level `file.prelude` slots a spliced 0.0.6 dependency
600    /// contributed (`Ast::VersionScope(V0_0, _)`).
601    v006_indices: std::collections::HashSet<usize>,
602    /// Which slots came from a file that declared a non-default `@stage:`.
603    stages: std::collections::HashMap<usize, types::Stage>,
604}
605
606/// Elaborate + typecheck + `:>`-check an [`AssembledV1`], returning the
607/// elaborated program (which the compile path then de-brands, and the check
608/// path drops).
609fn check_v1<'s>(
610    asm: &AssembledV1<'_>,
611    store: &'s symbol::SymbolStore,
612    env0: &value::BaseEnv,
613) -> Result<elaborate::Program<'s>, CompileError> {
614    use rustyfi_syntax::RustyfiVersion;
615
616    // A spliced `V0_0` dependency may name a `V0_0`-ONLY primitive
617    // (`text-in-math`, `get-axis-height`, `math-color`, …). Elaboration
618    // resolves names against ONE flat set built from the ambient version,
619    // and it runs BEFORE `Ast::VersionScope` can mean anything — the scope
620    // wraps an already-elaborated RHS — so such a name was simply
621    // "unbound variable" at elaborate time, no matter how correctly the
622    // later phases were version-scoped.
623    //
624    // So when (and ONLY when) a `V0_0` dependency was actually spliced,
625    // widen the elaboration name set to the UNION of both versions'
626    // primitives. This set answers one question — "is this a known global
627    // rather than a free variable?" — and version-correct resolution still
628    // happens downstream: `compile.rs`'s fold picks the `V0_0` `PrimDef`
629    // inside a `VersionScope(V0_0, _)`, and `typecheck.rs` picks that
630    // version's scheme. A pure `V0_1` program takes the other branch and
631    // keeps its "unbound variable" diagnostics for `V0_0`-only names.
632    let scope_names: Vec<String> = if asm.v006_indices.is_empty() {
633        env0.names()
634    } else {
635        let mut n = env0.names();
636        n.extend(primitives::base_env_with_version(RustyfiVersion::V0_0).names());
637        n.sort();
638        n.dedup();
639        n
640    };
641    let scope = elaborate::Scope::new_with_version(store, scope_names, RustyfiVersion::V0_1);
642    // `v006_indices` is empty whenever no `V0_0` dependency was
643    // spliced above, and `elaborate_program_with_versions` then emits no
644    // `Ast::VersionScope` node at all — so a `V0_1`-only load's
645    // `program`/`compiled` are structurally identical to a plain
646    // `elaborate_program`/`compile_program` pair's.
647    let program = elaborate::elaborate_program_with_versions(
648        &asm.file,
649        &scope,
650        &asm.v006_indices,
651        &asm.stages,
652        None,
653    )?;
654    v1::module_check::check_program(&asm.dep_csts, &program)?;
655    Ok(program)
656}
657
658/// Assemble a loader-resolved 0.1 program into the one synthetic `cst::File`
659/// the shared pipeline runs on — `merge_program`'s V0_1 analogue, plus the
660/// whole cross-version splice (X1/X3/X3b/X3c).
661fn assemble_v1<'a>(
662    files: &'a [rustyfi_loader::LoadedFile],
663) -> Result<AssembledV1<'a>, CompileError> {
664    use rustyfi_syntax::RustyfiVersion;
665
666    // -- assemble the synthetic cst::File (merge_program's V0_1 analogue) --
667    let (entry, deps) = files
668        .split_last()
669        .expect("loader always yields at least the entry file");
670    // Only ever called on the entry: under `compile_document_v1`, the entry
671    // is ALWAYS `V0_1` (the loader's own contract — `load_legacy`'s per-file
672    // version-detection rule
673    // only ever downgrades a DEPENDENCY to `V0_0`, never the entry; see
674    // `LoadedFile::version`'s doc comment). A `V0_0` dependency is instead
675    // routed through the cross-version splice arm below — it never
676    // reaches this helper.
677    fn as_v01(f: &rustyfi_loader::LoadedFile) -> &rustyfi_syntax::cst_v1::FileV1 {
678        match &f.cst {
679            rustyfi_loader::LoadedCst::V0_1(cst) => cst,
680            rustyfi_loader::LoadedCst::V0_0(_) => unreachable!(
681                "as_v01 called on a V0_0-parsed file — the entry is always \
682                 V0_1 under compile_document_v1, and every V0_0 dependency \
683                 is routed through the X1 cross-version splice arm instead"
684            ),
685        }
686    }
687    // One `SurfaceEnv` threaded across every V0_1 dependency
688    // in load order, so a module alias/named-signature reference in a
689    // LATER-loaded library can resolve an EARLIER one (`module M =
690    // OtherLib`, `:> OtherLib.S`). `build_file_surface` runs (pure `cst_v1`
691    // walk, no lowering needed) BEFORE each dep is lowered, so a dep's own
692    // internal aliases/named signatures resolve too.
693    //
694    // `deps` is a MIXED-version list (`LoadedFile::version`). A `V0_1`
695    // dep is lowered as usual; a `V0_0` dep contributes its `cst::File.prelude`
696    // bindings DIRECTLY (they are already `cst::TopBinding`s — no syntactic
697    // bridge needed), positioned dependency-first (loader order).
698    // `v006_indices` records which TOP-LEVEL `prelude` slots a V0_0 dep
699    // contributed, so `elaborate::elaborate_program_with_versions` (below) can
700    // wrap those bindings' RHS in `Ast::VersionScope(V0_0, _)` — the mechanism
701    // that makes a version-forked primitive referenced INSIDE such a dependency
702    // (`page-break`, `math-*`, …) resolve against `V0_0`'s
703    // `PrimDef`/type/runtime-version instead of the merged program's ambient
704    // `V0_1`. `dep_csts` collects the V0_1 subset only — `check_program`
705    // (below) has no `cst_v1` vocabulary for a `V0_0` file.
706    let mut surfaces = v1::surface::SurfaceEnv::default();
707    let mut prelude = Vec::new();
708    let mut dep_csts: Vec<&rustyfi_syntax::cst_v1::FileV1> = Vec::new();
709    let mut v006_indices: std::collections::HashSet<usize> = std::collections::HashSet::new();
710    // A spliced 0.0.6 dependency brings its `@stage:` with it, exactly as it
711    // would on the 0.0-rooted path -- a `@stage: 0` library must be readable
712    // from a 0.1 document too, or the same library compiles from one
713    // generation and not the other.
714    let mut stages: std::collections::HashMap<usize, types::Stage> =
715        std::collections::HashMap::new();
716    // Placement state for the forward view-scheduling mechanism: every
717    // `deco`/`deco-set`/`paren` export the forward deco/paren wrapper has
718    // adapted so far, and whether the 0.0.6-shaped (UNWRAPPED) view of them is
719    // the one currently installed at this point in the merged prelude.
720    //
721    // The forward wrapper installs the 0.1-shaped view by shadowing the
722    // export's own name,
723    // and a shadow is permanent — the prelude is one flat `Ast::LetIn`
724    // chain, and `Ast::VersionScope(V0_0, _)` wraps a binding's RHS, not the
725    // continuation after it. So the view is SCHEDULED (as the reverse deco
726    // coercion schedules the
727    // reverse one): captured once under a private name while the wrapped
728    // view is in force, the ORIGINAL restored on entering a 0.0.6-authored
729    // block, the wrapped view re-installed on entering a 0.1-authored one
730    // (the entry always is, and is always last). Without this, a LATER
731    // 0.0.6-authored dependency reads the export at 0.1's shape:
732    // `math.satyh`'s `val paren-right : paren` against `latexcmds`'
733    // five-argument call, and the `graphics list`/`graphics` mismatch for
734    // `deco`.
735    //
736    // Both transitions are lazy, so a program whose 0.0.6 dependencies
737    // never consume each other's crossed exports emits NOTHING extra.
738    // `deco_view_captured` counts how many of `deco_exports` already have a
739    // private `Capture` of their WRAPPED view in the prelude — an `Install`
740    // may only name a view that has been captured.
741    //
742    // Each export is carried with the DECLARED STAGE of the dependency that
743    // exported it, because the glue NAMES that dependency's own binding and
744    // `Stage::can_reference` is not symmetric: a `@stage: persistent`
745    // dependency may not read a default-stage binding, so a `Restore` of
746    // `M.frame` spliced at the default stage would make the very consumer
747    // this mechanism exists for fail with a STAGE error instead of a type one.
748    let mut deco_exports: Vec<(v1::xver_adapt::DecoExport, Option<types::Stage>)> = Vec::new();
749    let mut v006_view_installed = false;
750    let mut deco_view_captured: usize = 0;
751    for dep in deps {
752        match &dep.cst {
753            rustyfi_loader::LoadedCst::V0_1(cst) => {
754                // Transition back INTO 0.1-authored code: this dependency
755                // reads any crossed export at the adapted 0.1 shape, which is
756                // what the forward deco/paren wrapper is for.
757                if v006_view_installed {
758                    splice_upgrade_glue(
759                        &mut prelude,
760                        &mut stages,
761                        &deco_exports[..deco_view_captured],
762                        v1::xver_adapt::UpgradeStep::Install,
763                    );
764                    v006_view_installed = false;
765                }
766                v1::surface::build_file_surface(cst, &mut surfaces);
767                prelude.extend(v1::lower::lower_file_v1_with_surfaces(cst, &surfaces)?);
768                dep_csts.push(cst);
769            }
770            rustyfi_loader::LoadedCst::V0_0(cst) => {
771                // Transition INTO 0.0.6-authored code: this
772                // dependency means 0.0.6's shape by every name it writes, so
773                // any export the forward wrapper has already adapted must
774                // read as its
775                // UNWRAPPED original here. Deliberately emitted BEFORE `start`
776                // is taken below, so this 0.1-authored glue never lands in
777                // `v006_indices`/`stages`.
778                //
779                // The `Capture` rides along with the FIRST such transition
780                // rather than being emitted per-dependency: this is the last
781                // position at which naming the export's own key still yields
782                // the forward wrapper's wrapped view, and emitting it lazily is what keeps a
783                // program with no 0.0.6-to-0.0.6 consumption byte-identical to
784                // the splice as it stood before this view-scheduling was added.
785                if !v006_view_installed || deco_view_captured < deco_exports.len() {
786                    if deco_view_captured < deco_exports.len() {
787                        splice_upgrade_glue(
788                            &mut prelude,
789                            &mut stages,
790                            &deco_exports[deco_view_captured..],
791                            v1::xver_adapt::UpgradeStep::Capture,
792                        );
793                        deco_view_captured = deco_exports.len();
794                    }
795                    splice_upgrade_glue(
796                        &mut prelude,
797                        &mut stages,
798                        &deco_exports,
799                        v1::xver_adapt::UpgradeStep::Restore,
800                    );
801                    v006_view_installed = !deco_exports.is_empty();
802                }
803                // `collect_free_globals` below only checks `free.types`
804                // against `forked_type_names` (see the "guard-narrowing"
805                // banner comment for which surface sites count and why).
806                // Residual gap (no per-name PolyType table): an UNANNOTATED
807                // top-level binding whose inferred type carries a forked
808                // shape has no syntactic site to catch. Genuine misuse still
809                // fails whole-program HM unification at the use site.
810                //
811                // `math` is representationally IDENTICAL to `V0_1`'s
812                // `math-text` (both `Base(MathText)`, `types.rs`; the same
813                // shared `Value::MathText`/`Value::Math` runtime rep,
814                // value.rs:39-56), so it RELABELS with zero value-level
815                // coercion. `reject_type_names()` is `forked_type_names()`
816                // PLUS `page`, whose bare name lowers identically under both
817                // versions (never appearing in the automatic diff) but whose
818                // runtime rep forks (9-ctor ADT vs a tuple), so it is
819                // rejected explicitly.
820                //
821                // `deco`/`deco-set` cross too
822                // (`classify_deco_exports`/`deco_coercion_prelude`). Their
823                // bare NAME already means the right thing
824                // (`typecheck::name_to_mono("deco", V0_1)` is
825                // `t_deco(V0_1)` unconditionally), so a textual mention with
826                // no attached VALUE (a `type .. = deco` synonym) is already
827                // safe. The VALUE needs adapting: a `V0_0` `deco` closure
828                // returns `graphics list`
829                // (`prim_types::t_graphics_output`/`coerce_graphics_result`)
830                // but every `V0_1` call site applying a `deco`
831                // (`primitives::apply_deco`) expects a SINGLE `graphics`.
832                // For a bare top-level `let-rec name : deco | patbot* = ..`/
833                // `: deco-set` export, splice a SECOND, un-scoped binding of
834                // the SAME name shadowing the original: it re-applies the
835                // still-unshadowed original positionally and unites its
836                // `graphics list` into one `graphics` via the real `V0_1`
837                // `unite-graphics` (`primitives::prim_unite_graphics`).
838                // HM-checked, so a wrapper that doesn't fit fails to
839                // typecheck rather than mis-rendering. Every OTHER forked
840                // name stays rejected.
841                //
842                // `reject_type_names_from_v006`, not the shared
843                // `reject_type_names`: this dependency's text is
844                // 0.0.6-AUTHORED, and `code` forks only in that reading
845                // (0.0.6 has no `code` spelling, so `τ code` is an opaque
846                // nominal there, while the merged program's hard-coded
847                // `V0_1` `Checker` reads the same text as the real staged
848                // type). The reverse arm keeps the shared set — a foreign
849                // 0.1 dependency's `code` is already in the ambient
850                // vocabulary.
851                let free = collect_free_globals(&cst.prelude);
852                let reject_t = v1::xver_adapt::reject_type_names_from_v006();
853                let touched: std::collections::BTreeSet<String> =
854                    free.types.intersection(&reject_t).cloned().collect();
855                // Anything outside the combined whitelist above (`math`,
856                // `deco`, `deco-set`) rejects the WHOLE dependency — no
857                // partial acceptance.
858                if let Some(name) = touched
859                    .iter()
860                    .find(|n| !matches!(n.as_str(), "math" | "deco" | "deco-set" | "paren"))
861                {
862                    return Err(CompileError::CrossVersionUnsupportedName {
863                        name: name.clone(),
864                        dep: dep.path.display().to_string(),
865                        slice: "X3",
866                    });
867                }
868                // A module-scoped deco wrapper lives INSIDE the spliced
869                // dependency, hence inside its `VersionScope(V0_0, _)`,
870                // where `unite-graphics` does not exist. Bind the V0_1
871                // primitive to a plain name FIRST, outside the range
872                // `v006_indices` is about to cover, so the scoped wrapper can
873                // reach it as an ordinary variable.
874                if touched.contains("deco")
875                    || touched.contains("deco-set")
876                    || touched.contains("paren")
877                {
878                    let probe = v1::xver_adapt::classify_deco_exports(
879                        &cst.prelude,
880                        RustyfiVersion::V0_0,
881                        RustyfiVersion::V0_1,
882                    );
883                    if probe
884                        .as_ref()
885                        .map(|e| v1::xver_adapt::needs_unite_helper(e))
886                        == Ok(true)
887                    {
888                        let helper_start = prelude.len();
889                        prelude.extend(v1::xver_adapt::unite_helper_prelude());
890                        // Persistent, so the wrapper that calls it can name it
891                        // from whatever stage the DEPENDENCY declared: these
892                        // helpers are compiler-generated machinery spliced
893                        // outside the dependency's own `@stage:` range, and a
894                        // `@stage: persistent` dependency may not name a
895                        // stage-1 binding (`Stage::can_reference`). Persistent
896                        // is the one stage every other stage may reach, which
897                        // is exactly the property a generated helper needs.
898                        stages.extend(
899                            (helper_start..prelude.len())
900                                .map(|i| (i, types::Stage::Persistent0)),
901                        );
902                    }
903                }
904                let start = prelude.len();
905                if touched.is_empty() {
906                    // No forked-type-name text anywhere in this dep —
907                    // splice verbatim (the GOLDEN/non-regression fast path).
908                    prelude.extend(cst.prelude.iter().cloned());
909                } else if touched.contains("math") {
910                    // Relabel every `math` leaf inside a `type` declaration's
911                    // body to `math-text` (the note above) and splice the
912                    // adapted prelude. `deco`/`deco-set`, if also touched,
913                    // need no textual relabel — so this one call covers the
914                    // whole prelude regardless of which combination of the
915                    // two is touched.
916                    let adapted = v1::xver_adapt::relabel_type_decls(
917                        &cst.prelude,
918                        RustyfiVersion::V0_0,
919                        RustyfiVersion::V0_1,
920                    )
921                    .map_err(|be| {
922                        CompileError::CrossVersionUnsupportedName {
923                            name: match &be {
924                                v1::xver_adapt::BoundaryError::ForkedTypeExport {
925                                    ty_name, ..
926                                } => ty_name.clone(),
927                            },
928                            dep: dep.path.display().to_string(),
929                            slice: "X3",
930                        }
931                    })?;
932                    prelude.extend(adapted);
933                } else {
934                    // Only `deco`/`deco-set` (no `math`) is touched — no
935                    // textual relabel needed, splice verbatim (the value-
936                    // level coercion, if any, is appended separately below).
937                    prelude.extend(cst.prelude.iter().cloned());
938                }
939                v006_indices.extend(start..prelude.len());
940                note_stage(&mut stages, cst, start, prelude.len());
941
942                if touched.contains("deco")
943                    || touched.contains("deco-set")
944                    || touched.contains("paren")
945                {
946                    let exports = v1::xver_adapt::classify_deco_exports(
947                        &cst.prelude,
948                        RustyfiVersion::V0_0,
949                        RustyfiVersion::V0_1,
950                    )
951                    .map_err(|be| {
952                        CompileError::CrossVersionUnsupportedName {
953                            name: match &be {
954                                v1::xver_adapt::BoundaryError::ForkedTypeExport {
955                                    ty_name, ..
956                                } => ty_name.clone(),
957                            },
958                            dep: dep.path.display().to_string(),
959                            slice: "X3b",
960                        }
961                    })?;
962                    // Deliberately NOT added to `v006_indices` (structural
963                    // honesty, not a soundness requirement): this synthetic
964                    // code is genuinely `V0_1`-authored (it calls
965                    // `unite-graphics`, a `V0_1`-only primitive) — no `V0_0`
966                    // `PrimDef` shares this name, so even inside a
967                    // `VersionScope(V0_0, _)` the fold cursor would miss and
968                    // `compile.rs` would fall back to the eval-time
969                    // `env.lookup` against the ambient `V0_1` runtime env.
970                    // Two halves: a TOP-LEVEL export is shadowed by a new
971                    // top-level binding appended after the dependency
972                    // (`deco_coercion_prelude`); a MODULE-scoped one cannot
973                    // be (`let Deco.simple-frame` is not syntax), so its
974                    // wrapper is appended inside that module's own `decls`
975                    // (`inject_module_deco_wrappers`), one scope deeper.
976                    v1::xver_adapt::inject_module_deco_wrappers(&mut prelude[start..], &exports);
977                    // At the DEPENDENCY's own stage, not the default one: this
978                    // top-level wrapper re-applies the dependency's export by
979                    // name, and a `@stage: persistent` dependency's binding is
980                    // unreadable from a default-stage one (`splice_staged`).
981                    // The in-module wrappers above need no such care — they
982                    // are spliced INSIDE `prelude[start..]`, already covered by
983                    // this dependency's own `note_stage` range.
984                    let dep_stage =
985                        declared_stage(cst).filter(|s| *s != types::Stage::default());
986                    splice_staged(
987                        &mut prelude,
988                        &mut stages,
989                        dep_stage,
990                        v1::xver_adapt::deco_coercion_prelude(&exports),
991                    );
992                    // From here on this export has TWO views in the
993                    // program — the wrapper just spliced, and the unwrapped
994                    // original the two injectors above kept reachable under
995                    // `xver-fwd-orig-`. Record it so the transitions can pick
996                    // the right one for whatever block comes next.
997                    deco_exports.extend(exports.into_iter().map(|e| (e, dep_stage)));
998                }
999            }
1000        }
1001    }
1002    // The last (and, for every single-generation dependency set, the ONLY)
1003    // transition back into 0.1-authored code: the entry itself, which is
1004    // always `V0_1` here and reads every crossed export at the forward
1005    // wrapper's adapted
1006    // shape. Emitted only if some intervening 0.0.6 dependency restored the
1007    // originals; with no such dependency the whole schedule stays silent.
1008    if v006_view_installed {
1009        splice_upgrade_glue(
1010            &mut prelude,
1011            &mut stages,
1012            &deco_exports[..deco_view_captured],
1013            v1::xver_adapt::UpgradeStep::Install,
1014        );
1015    }
1016    let entry_cst = as_v01(entry);
1017    let body = v1::lower::lower_document_v1(entry_cst)?;
1018    let eoi = match entry_cst {
1019        rustyfi_syntax::cst_v1::FileV1::Document { eoi, .. } => eoi.clone(),
1020        _ => unreachable!("lower_document_v1 already rejected a Library entry"),
1021    };
1022    let file = rustyfi_syntax::cst::File {
1023        headers: Vec::new(),
1024        prelude,
1025        in_kw: Some(rustyfi_syntax::leaf::KwIn(rustyfi_syntax::Span::default())),
1026        body: Some(body),
1027        eoi,
1028    };
1029
1030    Ok(AssembledV1 {
1031        file,
1032        dep_csts,
1033        v006_indices,
1034        stages,
1035    })
1036}
1037
1038/// Compile a loader-resolved SATySFi 0.0.6 program (`LoadOptions { version:
1039/// V0_0, .. }`) whose entry (or one of its native 0.0.6 co-dependencies)
1040/// `@require:`s at least one **foreign 0.1** package.
1041///
1042/// This is the REVERSE of [`compile_document_v1_with_trials`]'s direction, but
1043/// reuses its exact polarity rather than flipping it: the AMBIENT
1044/// elaborate/typecheck/compile tag stays `V0_1` (0.1's grammar is a strict
1045/// syntactic superset of 0.0.6's, so elaborating genuinely 0.0.6-authored code
1046/// under an ambient `V0_1` scope never rejects it), and it is the
1047/// 0.0.6-authored code — the ENTRY's own top-level bindings and document tail,
1048/// plus any native 0.0.6 co-dependency's bindings — that gets wrapped in
1049/// [`ast::Ast::VersionScope`]`(V0_0, _)`. A foreign 0.1 dependency splices in
1050/// UNWRAPPED, exactly like a native 0.1 dependency does in
1051/// `compile_document_v1_with_trials`; its own `:>`-sealed exports are enforced
1052/// by `v1::module_check::check_program` exactly as for a pure-0.1 consumer.
1053///
1054/// A pure-0.0.6 load (no 0.1 dependency) never reaches this function — the
1055/// CLI/loader only route here once a `V0_0`-rooted load's dependency graph
1056/// actually contains a `LoadedCst::V0_1` node.
1057pub fn compile_document_v006_xver(
1058    files: &[rustyfi_loader::LoadedFile],
1059    metrics: &dyn FontMetrics,
1060) -> Result<std::rc::Rc<DocumentValue>, CompileError> {
1061    compile_document_v006_xver_with_trials(files, metrics).map(|(doc, _trials)| doc)
1062}
1063
1064/// Trial-count-reporting sibling, mirroring `compile_document_v1_with_trials`.
1065pub fn compile_document_v006_xver_with_trials(
1066    files: &[rustyfi_loader::LoadedFile],
1067    metrics: &dyn FontMetrics,
1068) -> Result<(std::rc::Rc<DocumentValue>, u32), CompileError> {
1069    compile_document_v006_xver_with_aux(files, metrics, &mut crossref::AuxTable::new())
1070}
1071
1072/// [`compile_document_v006_xver_with_trials`] threading an AUXILIARY
1073/// cross-reference table — see [`compile_document_cst_with_aux`]'s doc
1074/// comment for what seeding `aux` does and why it can't change the output.
1075pub fn compile_document_v006_xver_with_aux(
1076    files: &[rustyfi_loader::LoadedFile],
1077    metrics: &dyn FontMetrics,
1078    aux: &mut crossref::AuxTable,
1079) -> Result<(std::rc::Rc<DocumentValue>, u32), CompileError> {
1080    use rustyfi_syntax::RustyfiVersion;
1081
1082    let asm = assemble_v006_xver(files)?;
1083    let env0 = primitives::base_env_with_version(RustyfiVersion::V0_1);
1084    let store = symbol::SymbolStore::new();
1085    let program = check_v006_xver(&asm, &store, &env0)?;
1086    let env0_v006 = primitives::base_env_with_version(RustyfiVersion::V0_0);
1087    // `v006_indices` is NEVER empty here (the entry's own bindings are
1088    // always indexed into it above), so this always takes the `_xver` fold
1089    // path — matching `compile_document_v1_with_trials`'s own `if v006_
1090    // indices.is_empty() { .. } else { compile_program_xver }` branch,
1091    // specialized since the `else` arm is the only reachable one.
1092    // Bound to a local, not passed as a temporary: `Interp::eval_arg`
1093    // memoizes by `&Ast` address, so the de-branded tree must outlive the
1094    // trials (see `compile_document_cst_with_trials`).
1095    let body = ast::debrand(&program.body, &store);
1096    let compiled = compile::compile_program_xver(&body, &env0, &env0_v006);
1097    eval_document_trials(&compiled, metrics, RustyfiVersion::V0_0, aux)
1098}
1099
1100/// [`compile_document_v006_xver_with_aux`]'s front half and nothing else —
1101/// the reverse-direction sibling of [`check_document_v1`], with the same
1102/// rationale.
1103pub fn check_document_v006_xver(files: &[rustyfi_loader::LoadedFile]) -> Result<(), CompileError> {
1104    let asm = assemble_v006_xver(files)?;
1105    let env0 = primitives::base_env_with_version(rustyfi_syntax::RustyfiVersion::V0_1);
1106    let store = symbol::SymbolStore::new();
1107    check_v006_xver(&asm, &store, &env0).map(|_program| ())
1108}
1109
1110/// [`AssembledV1`]'s reverse-direction counterpart: additionally carries the
1111/// qualified member keys the reverse deco coercion rebound, which the `:>`
1112/// seal check must exempt from a second conformance test.
1113struct AssembledXver<'a> {
1114    file: rustyfi_syntax::cst::File,
1115    dep_csts: Vec<&'a rustyfi_syntax::cst_v1::FileV1>,
1116    v006_indices: std::collections::HashSet<usize>,
1117    stages: std::collections::HashMap<usize, types::Stage>,
1118    xver_shadows: std::collections::HashSet<String>,
1119}
1120
1121/// Elaborate + typecheck + `:>`-check an [`AssembledXver`] — [`check_v1`]'s
1122/// reverse-direction counterpart.
1123fn check_v006_xver<'s>(
1124    asm: &AssembledXver<'_>,
1125    store: &'s symbol::SymbolStore,
1126    env0: &value::BaseEnv,
1127) -> Result<elaborate::Program<'s>, CompileError> {
1128    use rustyfi_syntax::RustyfiVersion;
1129
1130    let scope = elaborate::Scope::new_with_version(store, env0.names(), RustyfiVersion::V0_1);
1131    // `wrap_body_version = Some(V0_0)`: the ENTRY's own document tail
1132    // (`file.body`, always 0.0.6-authored here) is wrapped in
1133    // `Ast::VersionScope(V0_0, _)` too — the one new elaborate.rs
1134    // capability this reverse direction adds beyond wrapping dependency bindings.
1135    let program = elaborate::elaborate_program_with_versions(
1136        &asm.file,
1137        &scope,
1138        &asm.v006_indices,
1139        &asm.stages,
1140        Some(RustyfiVersion::V0_0),
1141    )?;
1142    // `dep_csts` here is the foreign 0.1 dependencies' OWN `cst_v1` trees, so
1143    // a `:>`-sealed export (e.g. `V01Sealed.t`) is enforced against the WHOLE
1144    // merged spine exactly as it would be for a pure-0.1 consumer.
1145    //
1146    // `xver_shadows` is the ONE thing this arm asks the checker
1147    // to treat differently, and only for names it has itself just rebound:
1148    // the exporting module's own alias is still conformance-checked, and
1149    // only the coercion shadow that FOLLOWS it is exempted from a second
1150    // check against a signature it deliberately does not match. Empty
1151    // whenever no 0.1 `deco` export crossed.
1152    v1::module_check::check_program_with_xver_shadows(
1153        &asm.dep_csts,
1154        &program,
1155        &asm.xver_shadows,
1156    )?;
1157    Ok(program)
1158}
1159
1160/// Assemble a loader-resolved 0.0.6-rooted program that carries at least one
1161/// foreign 0.1 dependency — [`assemble_v1`]'s reverse-direction counterpart.
1162fn assemble_v006_xver<'a>(
1163    files: &'a [rustyfi_loader::LoadedFile],
1164) -> Result<AssembledXver<'a>, CompileError> {
1165    // The entry is whichever file is a document (`LoadedCst::is_document`) —
1166    // NOT necessarily `files.last()` (that assumption is specific to
1167    // `compile_document_v1_with_trials`'s pure-V0_1-entry contract); scan
1168    // defensively.
1169    let (entry_idx, entry) = files
1170        .iter()
1171        .enumerate()
1172        .find(|(_, f)| f.cst.is_document())
1173        .expect("loader validated exactly one document (the entry)");
1174    let entry_cst = match &entry.cst {
1175        rustyfi_loader::LoadedCst::V0_0(f) => f,
1176        rustyfi_loader::LoadedCst::V0_1(_) => unreachable!(
1177            "compile_document_v006_xver is the V0_0-entry sibling of \
1178             compile_document_v1 — a V0_1 entry belongs there instead"
1179        ),
1180    };
1181
1182    let mut surfaces = v1::surface::SurfaceEnv::default();
1183    let mut prelude = Vec::new();
1184    let mut dep_csts: Vec<&rustyfi_syntax::cst_v1::FileV1> = Vec::new();
1185    let mut v006_indices: std::collections::HashSet<usize> = std::collections::HashSet::new();
1186    // A spliced 0.0.6 dependency brings its `@stage:` with it, exactly as it
1187    // would on the 0.0-rooted path -- a `@stage: 0` library must be readable
1188    // from a 0.1 document too, or the same library compiles from one
1189    // generation and not the other.
1190    let mut stages: std::collections::HashMap<usize, types::Stage> =
1191        std::collections::HashMap::new();
1192    // The qualified member keys (`"M.frame"`) this arm rebinds to
1193    // a version-adapted view, exempted from a SECOND `:>` seal check below.
1194    let mut xver_shadows: std::collections::HashSet<String> = std::collections::HashSet::new();
1195    // Placement state for the reverse deco coercion: every `deco`/`deco-set` export crossed so far, and
1196    // whether the 0.0.6-shaped VIEW of them is the one currently installed at
1197    // this point in the merged prelude.
1198    //
1199    // The prelude is one flat `Ast::LetIn` chain and `Ast::VersionScope(V0_0,
1200    // _)` wraps a binding's RHS, not the continuation after it, so a
1201    // rebinding of `M.frame` is visible to EVERYTHING that follows
1202    // regardless of which generation authored it. A position-indexed view
1203    // is sufficient because each block spliced below is homogeneous — a
1204    // `V0_0` dependency's whole `prelude` goes into `v006_indices`, a `V0_1`
1205    // dependency's whole `lowered` stays out of it, the entry (always
1206    // 0.0.6-authored) is last — and the loader orders dependencies
1207    // topologically, so a consumer's block always follows what it
1208    // `@require:`s. So the coerced view installs lazily on entering a
1209    // 0.0.6-authored block and is put back on entering a 0.1-authored one.
1210    //
1211    // Both transitions are lazy, so the common case (every 0.1 dependency,
1212    // then the 0.0.6 entry — every bundled package) emits exactly one install
1213    // and no restore at all.
1214    let mut deco_exports: Vec<v1::xver_adapt::DecoExport> = Vec::new();
1215    let mut v006_view_installed = false;
1216
1217    for (i, dep) in files.iter().enumerate() {
1218        if i == entry_idx {
1219            continue;
1220        }
1221        match &dep.cst {
1222            // Native 0.0.6 co-dependency (e.g. the entry ALSO `@require:`s
1223            // an ordinary 0.0.6 package `list.satyg`-style): splice + wrap.
1224            // Its VALUE half is unrestricted — every binding here is
1225            // `Ast::VersionScope(V0_0, _)`-wrapped, so a forked primitive
1226            // resolves against 0.0.6's own `PrimDef`. Its TYPE-DECLARATION
1227            // half is NOT unrestricted: the reverse arm's 0.0.6 type-text guard
1228            // (`guard_v006_type_text`,
1229            // and the banner above it) refuses/relabels 0.0.6-authored
1230            // `type` text that a merged program's hard-coded-`V0_1` `Checker`
1231            // would otherwise re-read with the wrong vocabulary.
1232            rustyfi_loader::LoadedCst::V0_0(cst) => {
1233                let adapted = guard_v006_type_text(&cst.prelude, &dep.path)?;
1234                // Transition INTO 0.0.6-authored code: this dependency reads
1235                // any crossed `deco` export at 0.0.6's `graphics list` shape.
1236                // Deliberately BEFORE `start` is taken, so the generated glue
1237                // (0.1-authored) never lands in `v006_indices`/`stages`.
1238                if !v006_view_installed && !deco_exports.is_empty() {
1239                    prelude.extend(v1::xver_adapt::deco_downgrade_prelude(
1240                        &deco_exports,
1241                        v1::xver_adapt::DowngradeStep::Install,
1242                    ));
1243                    v006_view_installed = true;
1244                }
1245                let start = prelude.len();
1246                prelude.extend(adapted);
1247                v006_indices.extend(start..prelude.len());
1248                note_stage(&mut stages, cst, start, prelude.len());
1249            }
1250            // Foreign 0.1 dependency: lower (exactly like a native V0_1 dep
1251            // in `compile_document_v1_with_trials`) and splice UNWRAPPED
1252            // (ambient V0_1), PLUS the reverse import guard on what it EXPORTS
1253            // (the forward arm's forked-name guard — narrowed to export
1254            // position, with the whitelist adaptation — reversed).
1255            //
1256            // `Checker.version` for TYPE DECLARATIONS
1257            // (`v1::module_check::check_program_inner`'s
1258            // `ck.declare_synonym`/`declare_variant`, and the
1259            // `base_type_env_with_version` seeding the phase-D spine walk;
1260            // `module_check.rs:238-239,271`) is HARD-CODED to
1261            // `RustyfiVersion::V0_1` on BOTH arms — every type declaration in
1262            // the merged program is read under V0_1 vocabulary
1263            // unconditionally. That is why the FORWARD arm's
1264            // `relabel_type_decls(dep.prelude, V0_0, V0_1)` (above) is
1265            // necessary: a 0.0.6 dependency's own "math" spelling must
1266            // become "math-text" before it reaches `program.type_decls`, or
1267            // the V0_1 lookup resolves it to an unrelated unbound nominal.
1268            //
1269            // The REVERSE consequence is NOT the naive mirror: a foreign 0.1
1270            // dependency's own "math-text"/"math-boxes" spelling is ALREADY
1271            // the ambient vocabulary, so no relabeling is needed or wanted —
1272            // renaming it to 0.0.6's "math" would corrupt text the
1273            // hard-coded-V0_1 `Checker` must read natively, turning a
1274            // working type into an unbound-nominal mismatch. So this arm
1275            // calls `collect_free_globals` purely as a WHITELIST GUARD: any
1276            // export-boundary forked type name outside `{"math-text",
1277            // "math-boxes"}` — a proven-identical-representation set
1278            // (shared `Value::MathText`/`Value::Math` runtime rep; 0.0.6
1279            // code has no syntax that could observe the lost distinction) —
1280            // rejects the WHOLE dependency. False-reject is safe,
1281            // false-accept is not. `page`/`graphics`/`deco`/`pre-path`/
1282            // `path`/`image`/`font`/`paren` all still reject in THIS
1283            // direction too. Past the whitelist, the dependency splices
1284            // VERBATIM.
1285            //
1286            // `deco`/`deco-set` CROSS in this direction too — the
1287            // reverse mirror of the forward wrapper's `unite-graphics` wrap, coercing the
1288            // OPPOSITE way. A crossing `V0_1` deco returns a single
1289            // `graphics`; every `V0_0`-authored consumer call site (and
1290            // every `V0_0`-scoped `inline-frame-outer`/`inline-frame-
1291            // breakable` TYPE) expects a `graphics list`, so the wrap is a
1292            // SINGLETON LIST, `[name p w h d]`. Three steps:
1293            //
1294            //   1. `classify_deco_exports_v01_sig` reads the dependency's
1295            //      PRE-lowering `cst_v1` sig (the ONE textual site 0.1's
1296            //      grammar can name a `deco` export's type at all — lowering
1297            //      DROPS `sig_annot` entirely, so this scan must happen here
1298            //      and not off `lowered`). It descends through nested
1299            //      `module`/`include` decls and dereferences named signature
1300            //      references against `surfaces`, which is exactly why
1301            //      `build_file_surface` above must run FIRST. Anything it
1302            //      still cannot express — a `paren`, a `deco` buried in a
1303            //      compound, an OPEN optional row, or a `deco` behind a
1304            //      functor signature member (whose members have no member
1305            //      path until some later file APPLIES it) — REJECTS.
1306            //   2. `deco_downgrade_prelude` generates the coercion glue: a
1307            //      private `Capture` of the 0.1 original immediately after
1308            //      the dependency, then an `Install` — a top-level rebinding
1309            //      of each export's own qualified key (`M.frame`) that
1310            //      re-applies the captured original positionally and wraps
1311            //      the result in a singleton list — deferred to the next
1312            //      0.0.6-authored block, and a `Restore` on the way back
1313            //      into a 0.1-authored one (see `deco_exports`/
1314            //      `v006_view_installed` above). None of it is added to
1315            //      `v006_indices` — this is `V0_1`-authored glue, exactly
1316            //      like the forward arm's `deco_coercion_prelude`.
1317            //   3. those qualified keys are collected into `xver_shadows`
1318            //      and handed to `check_program_with_xver_shadows` below,
1319            //      which exempts the SECOND `Ast::LetIn` of each from the
1320            //      `:>` seal re-check (the module's own alias is still
1321            //      checked; see that function's doc comment for why that
1322            //      exemption cannot hide a real violation).
1323            //
1324            // A BARE `type foo = deco` synonym (no value attached, safe with
1325            // zero coercion — same reasoning as the forward direction's
1326            // `type xver-deco-alias = deco`) is UNAFFECTED: it is not a sig
1327            // `val` item, so this scan never sees it and it splices verbatim.
1328            rustyfi_loader::LoadedCst::V0_1(cst) => {
1329                // Transition back INTO 0.1-authored code: this dependency
1330                // reads any crossed `deco` export at 0.1's own single-
1331                // `graphics` shape, which is the whole point of the schedule.
1332                if v006_view_installed {
1333                    prelude.extend(v1::xver_adapt::deco_downgrade_prelude(
1334                        &deco_exports,
1335                        v1::xver_adapt::DowngradeStep::Restore,
1336                    ));
1337                    v006_view_installed = false;
1338                }
1339                v1::surface::build_file_surface(cst, &mut surfaces);
1340                let lowered = v1::lower::lower_file_v1_with_surfaces(cst, &surfaces)?;
1341
1342                let free = collect_free_globals(&lowered);
1343                let reject_t = v1::xver_adapt::reject_type_names();
1344                let touched: BTreeSet<String> =
1345                    free.types.intersection(&reject_t).cloned().collect();
1346                if let Some(name) = touched.iter().find(|n| {
1347                    !matches!(n.as_str(), "math-text" | "math-boxes" | "deco" | "deco-set")
1348                }) {
1349                    return Err(CompileError::CrossVersionUnsupportedName {
1350                        name: name.clone(),
1351                        dep: dep.path.display().to_string(),
1352                        slice: "X4a",
1353                    });
1354                }
1355                // `touched.contains("deco"/"deco-set")` here can only mean
1356                // the SAFE, no-coercion-needed case (a bare `type foo =
1357                // deco` synonym — no value attached; this arm's own doc
1358                // comment above): a REAL sig-declared VALUE export is
1359                // invisible to this POST-lowering scan (sig is dropped) and
1360                // is instead classified by the PRE-lowering scan just below,
1361                // independently of `touched`.
1362                let dep_deco_exports = v1::xver_adapt::classify_deco_exports_v01_sig(
1363                    cst, &surfaces,
1364                )
1365                .map_err(|be| CompileError::CrossVersionUnsupportedName {
1366                    name: match &be {
1367                        v1::xver_adapt::BoundaryError::ForkedTypeExport { ty_name, .. } => {
1368                            ty_name.clone()
1369                        }
1370                    },
1371                    dep: dep.path.display().to_string(),
1372                    slice: "X4b",
1373                })?;
1374
1375                prelude.extend(lowered);
1376                // The private capture goes here and only here: `M.frame` is
1377                // bound by `lowered` just above, and the 0.1 view is in force
1378                // at this point (the `Restore` above guarantees it), so this
1379                // is the one position where naming `M.frame` yields the
1380                // uncoerced original every later `Install` has to wrap.
1381                prelude.extend(v1::xver_adapt::deco_downgrade_prelude(
1382                    &dep_deco_exports,
1383                    v1::xver_adapt::DowngradeStep::Capture,
1384                ));
1385                for exp in &dep_deco_exports {
1386                    xver_shadows.insert(v1::xver_adapt::deco_export_qualified_name(exp));
1387                }
1388                deco_exports.extend(dep_deco_exports);
1389                dep_csts.push(cst);
1390            }
1391        }
1392    }
1393
1394    // The last (and, for every bundled package, the ONLY) transition into
1395    // 0.0.6-authored code: the entry's own prelude AND its document tail are
1396    // both wrapped in `Ast::VersionScope(V0_0, _)` below, so both read a
1397    // crossed `deco` export at 0.0.6's `graphics list` shape.
1398    if !v006_view_installed && !deco_exports.is_empty() {
1399        prelude.extend(v1::xver_adapt::deco_downgrade_prelude(
1400            &deco_exports,
1401            v1::xver_adapt::DowngradeStep::Install,
1402        ));
1403    }
1404
1405    // Entry's OWN top-level lets: splice + wrap, same as a native 0.0.6 dep
1406    // (a new source of V0_0-tagged items beyond dependency splicing: not just
1407    // dependencies, but the entry itself) — and through the same
1408    // 0.0.6-authored type-text guard, for the same reason: the entry's
1409    // `type` declarations are hoisted into `Program::type_decls` alongside
1410    // everyone else's and read under the one hard-coded `V0_1` `Checker`,
1411    // with no `Ast::VersionScope` in reach.
1412    let entry_adapted = guard_v006_type_text(&entry_cst.prelude, &entry.path)?;
1413    let entry_start = prelude.len();
1414    prelude.extend(entry_adapted);
1415    v006_indices.extend(entry_start..prelude.len());
1416
1417    let file = rustyfi_syntax::cst::File {
1418        headers: Vec::new(),
1419        prelude,
1420        in_kw: entry_cst.in_kw.clone(),
1421        body: entry_cst.body.clone(),
1422        eoi: entry_cst.eoi.clone(),
1423    };
1424
1425    Ok(AssembledXver {
1426        file,
1427        dep_csts,
1428        v006_indices,
1429        stages,
1430        xver_shadows,
1431    })
1432}
1433
1434// ============================================================================
1435// Forked-name guard: before splicing a V0_0 dependency's `prelude` into a
1436// V0_1 program (above), walk it for the free (unqualified, unshadowed)
1437// primitive/type names it references and hard-reject any that is
1438// version-forked. This is what keeps the splice sound rather than silently wrong —
1439// see `compile_document_v1_with_trials`'s dep loop for the actual check.
1440//
1441// There is no generic CST visitor in this crate (the closest precedent,
1442// `typecheck.rs`'s `walk_atom`/`walk_expr` quartet, walks only
1443// `ast::TypeExpr` for tyvars); this is modeled on it but covers the FULL
1444// `cst::TopBinding`/`ast::Expr`/`ast::Pattern`/`ast::TypeExpr` grammar.
1445//
1446// Guard-narrowing: `free.values` is checked against nothing. For
1447// `free.types` the walk collects EXPORT-POSITION surface sites only — the
1448// ones a `V0_1` consumer of this dependency can actually observe:
1449//   - a TOP-LEVEL `TopBinding::LetRec`'s (or `and` sibling's) own `: ty`
1450//     ascription (`walk_top_binding`'s `LetRec` arm, `boundary = true`);
1451//   - a `TopBinding::Module`'s `sig` items (`walk_sig_annot` — a `module ..`
1452//     is only ever a top-level/struct-decl construct, never nested inside an
1453//     expression, so every site it is walked from is already boundary);
1454//   - a `TopBinding::Type` declaration's body (`walk_type_decl`, kept
1455//     UNCONDITIONALLY boundary: a `type` declaration's ctor payload/synonym
1456//     body is registered ONCE under the merged program's single ambient
1457//     `V0_1` `Checker`, never inside an `Ast::VersionScope`, and a flat
1458//     splice makes the declared name visible to the consumer — so "unused
1459//     within this dependency" is not provable-safe here).
1460// An INTERNAL `Expr::LetRecIn` ascription is SKIPPED (`boundary = false`,
1461// `walk_expr`'s `LetRecIn` arm); it is the one place a forked type name can
1462// appear buried in an expression body in this port's 0.0.6 grammar, which has
1463// no local-lambda-parameter or local-`type` ascription syntax at all. See
1464// `walk_rec_binding_body`'s doc comment for the mechanism and the residual
1465// risk.
1466// ============================================================================
1467
1468/// The free, unqualified global names a spliced V0_0 dependency's
1469/// `prelude` references, split by namespace (values/commands vs. types)
1470/// because they are checked against DIFFERENT forked-name sets. See
1471/// `collect_free_globals`'s doc comment for the walk itself.
1472#[derive(Default, Debug)]
1473struct FreeGlobals {
1474    /// Value-position occurrences that could resolve to `base_env`:
1475    /// `Atomic::Var`/`Ctor`/`OpRef`/`Command`, the `Plain` arm of an
1476    /// `AnyHorz`/`Vert`/`MathCmdTok` reference, and an unqualified
1477    /// `…Elem::Embed`/`MathBot::Embed`. (No longer checked against
1478    /// anything — collected for completeness/tests only, see this module's
1479    /// banner comment above `walk_top_binding`.)
1480    values: BTreeSet<String>,
1481    /// EXPORT-BOUNDARY type-position occurrences only (see this
1482    /// module's "Guard-narrowing" banner comment above): `TypeAtom::Name`
1483    /// and the `ctor` of `TypeApp::Applied`, collected ONLY from a top-level
1484    /// binding's own ascription, a module's `sig`, or a `type` declaration's
1485    /// body — never from a purely-internal/local ascription.
1486    types: BTreeSet<String>,
1487}
1488
1489/// The binder scope threaded through `collect_free_globals`'s walk: two
1490/// independent namespaces (values/commands vs. types), each a plain stack of
1491/// names — pushing a name shadows an outer/global name of the SAME
1492/// namespace for the extent of whatever construct introduced it (`mark`/
1493/// `truncate_to` bound that extent, mirroring a lexical block's entry/exit).
1494///
1495/// **Soundness note.** This is a rejection GUARD, so over-approximation is
1496/// the safe direction: failing to push a genuine local binder just makes a
1497/// local look "free" (over-reporting — at worst an over-*rejection*, never
1498/// silently accepting something unsound). The one thing the walk must never
1499/// do is drop a binder scope *too early* / push something that ISN'T really
1500/// bound at that point, which would hide a genuine reference to a
1501/// version-forked global (see `Expr::OpenIn`'s arm below, which deliberately
1502/// binds NOTHING for an `open Mod in …` rather than guess at Mod's members).
1503#[derive(Default)]
1504struct XverScope {
1505    values: Vec<String>,
1506    types: Vec<String>,
1507}
1508
1509impl XverScope {
1510    fn mark(&self) -> (usize, usize) {
1511        (self.values.len(), self.types.len())
1512    }
1513
1514    fn truncate_to(&mut self, mark: (usize, usize)) {
1515        self.values.truncate(mark.0);
1516        self.types.truncate(mark.1);
1517    }
1518
1519    fn push_value(&mut self, name: &str) {
1520        self.values.push(name.to_string());
1521    }
1522
1523    fn push_type(&mut self, name: &str) {
1524        self.types.push(name.to_string());
1525    }
1526
1527    fn has_value(&self, name: &str) -> bool {
1528        self.values.iter().any(|v| v == name)
1529    }
1530
1531    fn has_type(&self, name: &str) -> bool {
1532        self.types.iter().any(|v| v == name)
1533    }
1534}
1535
1536fn emit_value(scope: &XverScope, out: &mut FreeGlobals, name: &str) {
1537    if !scope.has_value(name) {
1538        out.values.insert(name.to_string());
1539    }
1540}
1541
1542fn emit_type(scope: &XverScope, out: &mut FreeGlobals, name: &str) {
1543    if !scope.has_type(name) {
1544        out.types.insert(name.to_string());
1545    }
1546}
1547
1548/// Enumerate the *free, unqualified* global names a spliced V0_0
1549/// dependency references — `TopBinding`/`ast::Expr`/`ast::Pattern`/
1550/// `ast::TypeExpr`, each threading a binder scope stack so a locally-bound
1551/// name shadows a primitive of the same name (per `XverScope`'s doc
1552/// comment). A module-qualified reference (`Atomic::VarWithMod`,
1553/// `\Mod.cmd`/`+Mod.cmd`/`#Mod.var`) is deliberately SKIPPED: a primitive or
1554/// builtin type is only ever reachable by a BARE name, so a qualified
1555/// reference resolves inside a module and can never collide with a forked
1556/// primitive (0.0.6 has no qualified *type*-name form at all, so every type
1557/// reference is in scope for this check).
1558fn collect_free_globals(prelude: &[rustyfi_syntax::cst::TopBinding]) -> FreeGlobals {
1559    let mut out = FreeGlobals::default();
1560    let mut scope = XverScope::default();
1561    for tb in prelude {
1562        walk_top_binding(tb, &mut scope, &mut out);
1563    }
1564    out
1565}
1566
1567fn walk_top_binding(
1568    tb: &rustyfi_syntax::cst::TopBinding,
1569    scope: &mut XverScope,
1570    out: &mut FreeGlobals,
1571) {
1572    use rustyfi_syntax::cst::TopBinding;
1573    match tb {
1574        // Recursive: every clause's own name is bound BEFORE any clause body
1575        // is walked (and stays bound for every sibling `and` clause too).
1576        TopBinding::LetRec { first, ands, .. } => {
1577            scope.push_value(&first.name.name);
1578            for and in ands {
1579                scope.push_value(&and.binding.name.name);
1580            }
1581            // TOP-LEVEL — a consumer-observable export; `boundary = true`
1582            // (this binding's own `: ty` ascription IS export-position
1583            // text).
1584            walk_rec_binding_body(first, true, scope, out);
1585            for and in ands {
1586                walk_rec_binding_body(&and.binding, true, scope, out);
1587            }
1588        }
1589        TopBinding::Let(tl) => {
1590            // TOP-LEVEL, so this binding's own `: ty` ascription is
1591            // export-position text, exactly as `LetRec`'s is. Skipping it
1592            // would let `let x : page = ...` cross silently while `type
1593            // alias = page` was rejected — the same forked name, caught or
1594            // not depending on which way the package spelled it.
1595            if let Some(asc) = &tl.ascription {
1596                walk_type_expr(&asc.ty, scope, out);
1597            }
1598            let mark = scope.mark();
1599            for p in &tl.params {
1600                walk_param_binder(p, scope, out);
1601            }
1602            walk_expr(&tl.value, scope, out);
1603            scope.truncate_to(mark);
1604            scope.push_value(&tl.name.name);
1605        }
1606        TopBinding::LetPattern { value, .. } => {
1607            // Destructuring `let pat = value`: only the scrutinee references
1608            // free globals. The pattern-bound names become new bindings; not
1609            // pushing them here is sound (this walk over-approximates the free
1610            // set — see the module banner).
1611            walk_expr(value, scope, out);
1612        }
1613        TopBinding::LetInline {
1614            ctx,
1615            cmd,
1616            params,
1617            value,
1618            ..
1619        } => {
1620            let mark = scope.mark();
1621            if let Some(c) = ctx {
1622                scope.push_value(&c.name);
1623            }
1624            for p in params {
1625                walk_param_binder(p, scope, out);
1626            }
1627            walk_expr(value, scope, out);
1628            scope.truncate_to(mark);
1629            scope.push_value(&cmd.name);
1630        }
1631        TopBinding::LetBlock {
1632            ctx,
1633            cmd,
1634            params,
1635            value,
1636            ..
1637        } => {
1638            let mark = scope.mark();
1639            if let Some(c) = ctx {
1640                scope.push_value(&c.name);
1641            }
1642            for p in params {
1643                walk_param_binder(p, scope, out);
1644            }
1645            walk_expr(value, scope, out);
1646            scope.truncate_to(mark);
1647            scope.push_value(&cmd.name);
1648        }
1649        TopBinding::LetMath {
1650            cmd, params, value, ..
1651        } => {
1652            let mark = scope.mark();
1653            for p in params {
1654                walk_param_binder(p, scope, out);
1655            }
1656            walk_expr(value, scope, out);
1657            scope.truncate_to(mark);
1658            scope.push_value(&cmd.name);
1659        }
1660        TopBinding::Type(td) => {
1661            walk_type_decl(td, scope, out);
1662            scope.push_type(&td.name.name);
1663        }
1664        TopBinding::LetMutable { name, value, .. } => {
1665            walk_expr(value, scope, out);
1666            scope.push_value(&name.name);
1667        }
1668        TopBinding::Module { sig, decls, .. } => {
1669            if let Some(sig) = sig {
1670                walk_sig_annot(sig, scope, out);
1671            }
1672            // A nested module's own decls get a scope extent of their own —
1673            // its LOCAL bindings must not leak to a sibling top binding
1674            // outside the module.
1675            let mark = scope.mark();
1676            for d in decls {
1677                walk_top_binding(&d.0, scope, out);
1678            }
1679            scope.truncate_to(mark);
1680            // The module's own NAME (a `CtorTok`, uppercase-initial) is a
1681            // third namespace this guard doesn't track — it can never
1682            // collide with a lowercase primitive/type name.
1683        }
1684        // `open Mod` unqualified-imports Mod's members — unknowable
1685        // statically here (no elaboration has run yet), so this
1686        // conservatively binds NOTHING new: see `XverScope`'s doc comment
1687        // for why that is the safe direction (may over-reject, never hides
1688        // a real forked-name reference).
1689        TopBinding::Open { .. } => {}
1690    }
1691}
1692
1693/// Walk one `RecBinding`'s own params/value/`extra` clauses (shared by
1694/// `TopBinding::LetRec` and `Expr::LetRecIn`) — every clause's parameters are
1695/// scoped to that clause alone.
1696///
1697/// `boundary`: whether THIS `RecBinding` is a TOP-LEVEL,
1698/// consumer-observable export (`TopBinding::LetRec`/its `and` siblings —
1699/// `true`) or a purely LOCAL binding nested inside another binding's
1700/// expression body (`Expr::LetRecIn` — `false`). Only when `boundary` is
1701/// true does the binding's OWN `: ty` ascription get walked into
1702/// `out.types` — see the "Guard-narrowing" banner above
1703/// `collect_free_globals`.
1704fn walk_rec_binding_body(
1705    rb: &rustyfi_syntax::cst::ast::RecBinding,
1706    boundary: bool,
1707    scope: &mut XverScope,
1708    out: &mut FreeGlobals,
1709) {
1710    if boundary {
1711        if let Some(asc) = &rb.ascription {
1712            walk_type_expr(&asc.ty, scope, out);
1713        }
1714    }
1715    let mark = scope.mark();
1716    for p in &rb.params {
1717        walk_patbot_binder(p, scope, out);
1718    }
1719    walk_expr(&rb.value.0, scope, out);
1720    scope.truncate_to(mark);
1721    for clause in &rb.extra {
1722        let mark = scope.mark();
1723        for p in &clause.params {
1724            walk_patbot_binder(p, scope, out);
1725        }
1726        walk_expr(&clause.value.0, scope, out);
1727        scope.truncate_to(mark);
1728    }
1729}
1730
1731fn walk_param_binder(
1732    p: &rustyfi_syntax::cst::ast::Param,
1733    scope: &mut XverScope,
1734    out: &mut FreeGlobals,
1735) {
1736    use rustyfi_syntax::cst::ast::Param;
1737    match p {
1738        Param::Optional { name, .. } => scope.push_value(&name.name),
1739        Param::Pat(pb) => walk_patbot_binder(pb, scope, out),
1740        Param::Bundled { opts, body } => {
1741            for e in &opts.entries {
1742                scope.push_value(&e.var.name);
1743            }
1744            walk_patbot_binder(body, scope, out);
1745        }
1746    }
1747}
1748
1749/// Walk a full `patas` (a pattern plus its optional `as name` binding) in
1750/// BINDER mode: every `Var`/`AsClause.name` is pushed (never emitted); every
1751/// `Ctor`/`CtorApplied.ctor` is a REFERENCE — emitted for completeness (the
1752/// corpus's constructors are neutral, but this keeps the walk total).
1753fn walk_pattern_binder(
1754    pat: &rustyfi_syntax::cst::ast::Pattern,
1755    scope: &mut XverScope,
1756    out: &mut FreeGlobals,
1757) {
1758    walk_patcons_binder(&pat.head, scope, out);
1759    if let Some(ac) = &pat.as_clause {
1760        scope.push_value(&ac.name.name);
1761    }
1762}
1763
1764fn walk_patcons_binder(
1765    pc: &rustyfi_syntax::cst::ast::PatCons,
1766    scope: &mut XverScope,
1767    out: &mut FreeGlobals,
1768) {
1769    walk_patbot_binder(&pc.head, scope, out);
1770    for seg in &pc.tail {
1771        walk_patbot_binder(&seg.tail, scope, out);
1772    }
1773}
1774
1775fn walk_patbot_binder(
1776    pb: &rustyfi_syntax::cst::ast::PatBot,
1777    scope: &mut XverScope,
1778    out: &mut FreeGlobals,
1779) {
1780    use rustyfi_syntax::cst::ast::PatBot;
1781    match pb {
1782        PatBot::CtorApplied { ctor, arg } => {
1783            emit_value(scope, out, &ctor.name);
1784            walk_patbot_binder(arg, scope, out);
1785        }
1786        PatBot::Ctor(ctor) => emit_value(scope, out, &ctor.name),
1787        PatBot::Int(_) | PatBot::True(_) | PatBot::False(_) | PatBot::Str(_) | PatBot::Wild(_) => {}
1788        PatBot::Var(v) => scope.push_value(&v.name),
1789        PatBot::Unit { .. } => {}
1790        PatBot::Paren { inner, .. } => {
1791            walk_pattern_binder(&inner.first.0, scope, out);
1792            for cp in &inner.rest {
1793                walk_pattern_binder(&cp.value.0, scope, out);
1794            }
1795        }
1796        PatBot::List { items, .. } => {
1797            for it in items {
1798                walk_pattern_binder(&it.value.0, scope, out);
1799            }
1800        }
1801    }
1802}
1803
1804// ============================================================================
1805// The REVERSE arm's guard on **0.0.6-authored** type text
1806// (`compile_document_v006_xver_with_aux`'s `LoadedCst::V0_0` branch and the
1807// entry's own prelude).
1808//
1809// **The misreading**, reached from the other side of the forward arm's: a
1810// merged cross-version program has exactly one `Checker`, hard-coded to
1811// `V0_1` (`v1::module_check::check_program_inner`'s `ck.set_version`) on
1812// BOTH arms, because `elaborate` hoists every `type` declaration out of the
1813// `Ast` spine into `Program::type_decls`/`synonym_decls` — never inside an
1814// `Ast::VersionScope`. Forward, the 0.0.6 text re-read under 0.1's
1815// vocabulary is a spliced dependency's; reverse, it is the ENTRY's own
1816// prelude plus every native 0.0.6 co-dependency — potentially the whole
1817// 0.0.6 corpus.
1818//
1819// So `math` takes the **same** relabel here as forward, `math` ->
1820// `math-text` (`xver_adapt::relabel_type_decls(_, V0_0, V0_1)`), NOT the
1821// mirror `math-text` -> `math`: the target vocabulary is `V0_1` either way.
1822// (`relabel_or_reject_name`'s mirror arm is deliberately not wired to the
1823// reverse arm's `LoadedCst::V0_1` branch — a foreign 0.1 dependency's text
1824// is already in the ambient vocabulary.)
1825//
1826// **Why this scan is narrower than `collect_free_globals`.** The forward
1827// arm over-approximates on purpose, also collecting from a `let-rec`'s
1828// `: ty` ascription and a `module .. : sig .. end`'s `val` items — both
1829// parsed and then ignored by `elaborate.rs`, so over-rejecting on them only
1830// costs a 0.1 document a 0.0.6 package it could have had. Reversed, the
1831// same over-approximation would be WRONG, not conservative: the bundled
1832// 0.0.6 corpus writes forked names in exactly those decorative positions
1833// all the time (`vdecoset.satyh`'s `val paper : deco-set`, `math.satyh`'s
1834// `direct \frac : [math; math] math-cmd`), so rejecting on them would
1835// refuse ordinary 0.0.6 documents for text no phase reads. This walk
1836// instead collects from `TopBinding::Type` bodies alone (recursing through
1837// `TopBinding::Module`'s nested `decls`) — exactly the site set
1838// `xver_adapt::relabel_type_decls` rewrites, and the text that reaches
1839// `declare_variant`/`declare_synonym`.
1840//
1841// **What is refused.** `reject_type_names_from_v006()` — the same
1842// producer-keyed set the forward arm's `V0_0` branch uses, so `code`
1843// refuses here too (a foreign 0.1 dependency's `code`, the reverse arm's
1844// OTHER branch, keeps the shared `reject_type_names()` and does not). The
1845// whitelist is `{"math"}` alone: this branch has no
1846// `classify_deco_exports`/`deco_coercion_prelude` pairing to make a
1847// `deco`/`deco-set`/`paren` mention safe, and the 0.1 reading of those names
1848// is wrong for a 0.0.6-authored consumer anyway (0.0.6's `deco` returns
1849// `graphics list`; `name_to_mono("deco", V0_1)` types it as a single
1850// `graphics`). `page` is the sharp one: its bare name lowers to the
1851// same nominal `Variant("page",[])` under both versions, so a mismatch is
1852// not a type error at all — a 9-ctor `Value::Ctor` meeting a `length *
1853// length` `Value::Product`.
1854// ============================================================================
1855
1856/// The free type names a 0.0.6-authored `prelude`'s `type` DECLARATIONS
1857/// mention — the whole of that prelude's text a merged cross-version
1858/// program's single hard-coded-`V0_1` `Checker` actually reads (see this
1859/// module's banner above for why the decorative
1860/// ascription/`sig` sites are deliberately NOT collected here, though
1861/// `collect_free_globals` does collect them for the forward arm).
1862fn collect_type_decl_globals(
1863    prelude: &[rustyfi_syntax::cst::TopBinding],
1864) -> std::collections::BTreeSet<String> {
1865    let mut out = FreeGlobals::default();
1866    let mut scope = XverScope::default();
1867    for tb in prelude {
1868        walk_type_decls_only(tb, &mut scope, &mut out);
1869    }
1870    out.types
1871}
1872
1873fn walk_type_decls_only(
1874    tb: &rustyfi_syntax::cst::TopBinding,
1875    scope: &mut XverScope,
1876    out: &mut FreeGlobals,
1877) {
1878    use rustyfi_syntax::cst::TopBinding;
1879    match tb {
1880        TopBinding::Type(td) => {
1881            walk_type_decl(td, scope, out);
1882            scope.push_type(&td.name.name);
1883        }
1884        // A nested `type` declaration is hoisted into the SAME
1885        // `Program::type_decls` as a top-level one (`elaborate::
1886        // walk_bindings` threads one `type_decls` sink through every level),
1887        // so it is read under the same hard-coded `V0_1` `Checker` and must
1888        // be scanned too. Its locally-declared names stay local, matching
1889        // `walk_top_binding`'s own `Module` arm.
1890        TopBinding::Module { decls, .. } => {
1891            let mark = scope.mark();
1892            for d in decls {
1893                walk_type_decls_only(&d.0, scope, out);
1894            }
1895            scope.truncate_to(mark);
1896        }
1897        _ => {}
1898    }
1899}
1900
1901/// Check one 0.0.6-authored `prelude` on the REVERSE arm and
1902/// return the bindings to splice — relabeled (`math` -> `math-text`) when
1903/// that is all it touches, cloned verbatim when it touches nothing, and a
1904/// `CompileError::CrossVersionUnsupportedName` naming the offending type
1905/// otherwise. `path` is the file the text was authored in (the 0.0.6 entry,
1906/// or a native 0.0.6 co-dependency); the resulting error records which
1907/// DIRECTION refused, since the forward arm's guard checks the same
1908/// producer-keyed set under its own tag.
1909fn guard_v006_type_text(
1910    prelude: &[rustyfi_syntax::cst::TopBinding],
1911    path: &std::path::Path,
1912) -> Result<Vec<rustyfi_syntax::cst::TopBinding>, CompileError> {
1913    use rustyfi_syntax::RustyfiVersion;
1914    let reject_t = v1::xver_adapt::reject_type_names_from_v006();
1915    let touched: BTreeSet<String> = collect_type_decl_globals(prelude)
1916        .intersection(&reject_t)
1917        .cloned()
1918        .collect();
1919    // `math` is the whole whitelist here — see the banner above.
1920    if let Some(name) = touched.iter().find(|n| n.as_str() != "math") {
1921        return Err(CompileError::CrossVersionUnsupportedName {
1922            name: name.clone(),
1923            dep: path.display().to_string(),
1924            slice: "X4c",
1925        });
1926    }
1927    if touched.is_empty() {
1928        // Byte-identical to the `prelude.extend(cst.prelude.iter()
1929        // .cloned())` fast path every non-`math` 0.0.6 file takes.
1930        return Ok(prelude.to_vec());
1931    }
1932    v1::xver_adapt::relabel_type_decls(prelude, RustyfiVersion::V0_0, RustyfiVersion::V0_1).map_err(
1933        |be| CompileError::CrossVersionUnsupportedName {
1934            name: match &be {
1935                v1::xver_adapt::BoundaryError::ForkedTypeExport { ty_name, .. } => ty_name.clone(),
1936            },
1937            dep: path.display().to_string(),
1938            slice: "X4c",
1939        },
1940    )
1941}
1942
1943fn walk_type_decl(
1944    td: &rustyfi_syntax::cst::TypeDecl,
1945    scope: &mut XverScope,
1946    out: &mut FreeGlobals,
1947) {
1948    walk_type_decl_body(&td.body, scope, out);
1949    for a in &td.ands {
1950        walk_type_decl_body(&a.body, scope, out);
1951    }
1952}
1953
1954fn walk_type_decl_body(
1955    body: &rustyfi_syntax::cst::TypeDeclBody,
1956    scope: &mut XverScope,
1957    out: &mut FreeGlobals,
1958) {
1959    use rustyfi_syntax::cst::TypeDeclBody;
1960    match body {
1961        TypeDeclBody::Variant { first, rest, .. } => {
1962            walk_variant_def(first, scope, out);
1963            for bv in rest {
1964                walk_variant_def(&bv.def, scope, out);
1965            }
1966        }
1967        TypeDeclBody::Synonym(ty) => walk_type_expr(ty, scope, out),
1968    }
1969}
1970
1971fn walk_variant_def(
1972    vd: &rustyfi_syntax::cst::VariantDef,
1973    scope: &mut XverScope,
1974    out: &mut FreeGlobals,
1975) {
1976    // `vd.ctor` DECLARES a new constructor — not a reference, nothing to
1977    // emit for it.
1978    if let Some(of_ty) = &vd.of_ty {
1979        walk_type_expr(&of_ty.ty, scope, out);
1980    }
1981}
1982
1983fn walk_sig_annot(
1984    sig: &rustyfi_syntax::cst::SigAnnot,
1985    scope: &mut XverScope,
1986    out: &mut FreeGlobals,
1987) {
1988    use rustyfi_syntax::cst::SigItem;
1989    for item in &sig.items {
1990        match item {
1991            SigItem::ValHorzCmd { ty, .. }
1992            | SigItem::ValVertCmd { ty, .. }
1993            | SigItem::Val { ty, .. }
1994            | SigItem::DirectHorzCmd { ty, .. }
1995            | SigItem::DirectVertCmd { ty, .. } => walk_type_expr(ty, scope, out),
1996            SigItem::Type { .. } => {}
1997        }
1998    }
1999}
2000
2001fn walk_expr(e: &rustyfi_syntax::cst::ast::Expr, scope: &mut XverScope, out: &mut FreeGlobals) {
2002    use rustyfi_syntax::cst::ast::Expr;
2003    match e {
2004        Expr::LetRecIn {
2005            first, ands, body, ..
2006        } => {
2007            let mark = scope.mark();
2008            scope.push_value(&first.name.name);
2009            for and in ands {
2010                scope.push_value(&and.binding.name.name);
2011            }
2012            // INTERNAL — a local binding nested inside some enclosing
2013            // binding's own body; `boundary = false` (this `let rec`'s
2014            // OWN `: ty` ascription is not, by itself, any export's
2015            // observable signature text — see `walk_rec_binding_body`'s doc
2016            // comment).
2017            walk_rec_binding_body(first, false, scope, out);
2018            for and in ands {
2019                walk_rec_binding_body(&and.binding, false, scope, out);
2020            }
2021            walk_expr(body, scope, out);
2022            scope.truncate_to(mark);
2023        }
2024        Expr::LetIn {
2025            name,
2026            params,
2027            value,
2028            body,
2029            ..
2030        } => {
2031            let mark = scope.mark();
2032            for p in params {
2033                walk_param_binder(p, scope, out);
2034            }
2035            walk_expr(value, scope, out);
2036            scope.truncate_to(mark);
2037            let mark = scope.mark();
2038            scope.push_value(&name.name);
2039            walk_expr(body, scope, out);
2040            scope.truncate_to(mark);
2041        }
2042        Expr::LetPatternIn {
2043            pat, value, body, ..
2044        } => {
2045            walk_expr(value, scope, out);
2046            let mark = scope.mark();
2047            walk_pattern_binder(&pat.0, scope, out);
2048            walk_expr(body, scope, out);
2049            scope.truncate_to(mark);
2050        }
2051        Expr::If {
2052            cond,
2053            then_branch,
2054            else_branch,
2055            ..
2056        } => {
2057            walk_expr(cond, scope, out);
2058            walk_expr(then_branch, scope, out);
2059            walk_expr(else_branch, scope, out);
2060        }
2061        Expr::Fun { params, body, .. } => {
2062            let mark = scope.mark();
2063            for p in params {
2064                walk_patbot_binder(p, scope, out);
2065            }
2066            walk_expr(body, scope, out);
2067            scope.truncate_to(mark);
2068        }
2069        Expr::FunRows {
2070            opts, param, body, ..
2071        } => {
2072            let mark = scope.mark();
2073            for e in &opts.entries {
2074                scope.push_value(&e.var.name);
2075            }
2076            walk_patbot_binder(param, scope, out);
2077            walk_expr(body, scope, out);
2078            scope.truncate_to(mark);
2079        }
2080        Expr::Match {
2081            scrutinee,
2082            first,
2083            rest,
2084            ..
2085        } => {
2086            walk_expr(scrutinee, scope, out);
2087            walk_match_arm(first, scope, out);
2088            for ba in rest {
2089                walk_match_arm(&ba.arm, scope, out);
2090            }
2091        }
2092        Expr::LetMutableIn {
2093            name, init, body, ..
2094        } => {
2095            walk_expr(init, scope, out);
2096            let mark = scope.mark();
2097            scope.push_value(&name.name);
2098            walk_expr(body, scope, out);
2099            scope.truncate_to(mark);
2100        }
2101        Expr::LetMathIn {
2102            cmd,
2103            params,
2104            value,
2105            body,
2106            ..
2107        } => {
2108            let mark = scope.mark();
2109            for p in params {
2110                walk_param_binder(p, scope, out);
2111            }
2112            walk_expr(value, scope, out);
2113            scope.truncate_to(mark);
2114            let mark = scope.mark();
2115            scope.push_value(&cmd.name);
2116            walk_expr(body, scope, out);
2117            scope.truncate_to(mark);
2118        }
2119        // `open Mod in body` — see `TopBinding::Open`'s arm for why this
2120        // binds nothing new.
2121        Expr::OpenIn { body, .. } => walk_expr(body, scope, out),
2122        Expr::WhileDo { cond, body, .. } => {
2123            walk_expr(cond, scope, out);
2124            walk_expr(body, scope, out);
2125        }
2126        Expr::Overwrite { name, value, .. } => {
2127            emit_value(scope, out, &name.name);
2128            walk_expr(&value.0, scope, out);
2129        }
2130        Expr::Ops(chain) => walk_opchain(chain, scope, out),
2131    }
2132}
2133
2134fn walk_match_arm(
2135    arm: &rustyfi_syntax::cst::ast::MatchArm,
2136    scope: &mut XverScope,
2137    out: &mut FreeGlobals,
2138) {
2139    let mark = scope.mark();
2140    walk_pattern_binder(&arm.pat.0, scope, out);
2141    if let Some(g) = &arm.guard {
2142        walk_expr(&g.cond.0, scope, out);
2143    }
2144    walk_expr(&arm.body.0, scope, out);
2145    scope.truncate_to(mark);
2146}
2147
2148fn walk_opchain(
2149    oc: &rustyfi_syntax::cst::ast::OpChain,
2150    scope: &mut XverScope,
2151    out: &mut FreeGlobals,
2152) {
2153    walk_appexpr(&oc.head, scope, out);
2154    for r in &oc.tail {
2155        walk_appexpr(&r.rhs, scope, out);
2156    }
2157    if let Some(bt) = &oc.before {
2158        walk_expr(&bt.body.0, scope, out);
2159    }
2160}
2161
2162fn walk_appexpr(
2163    ae: &rustyfi_syntax::cst::ast::AppExpr,
2164    scope: &mut XverScope,
2165    out: &mut FreeGlobals,
2166) {
2167    walk_atomic(&ae.head, scope, out);
2168    // `head_accesses`: `#label` record-field accesses — field labels, not
2169    // globals, skip.
2170    for arg in &ae.args {
2171        walk_apparg(arg, scope, out);
2172    }
2173}
2174
2175fn walk_apparg(a: &rustyfi_syntax::cst::ast::AppArg, scope: &mut XverScope, out: &mut FreeGlobals) {
2176    use rustyfi_syntax::cst::ast::AppArg;
2177    match a {
2178        AppArg::Optional { value, .. } => walk_atomic(value, scope, out),
2179        AppArg::Omission(_) => {}
2180        AppArg::Atom { atom, .. } => walk_atomic(atom, scope, out),
2181        AppArg::Ctor(c) => emit_value(scope, out, &c.name),
2182        AppArg::Bundled { opts, atom, .. } => {
2183            for e in &opts.entries {
2184                walk_expr(&e.value.0, scope, out);
2185            }
2186            walk_atomic(atom, scope, out);
2187        }
2188        AppArg::BundledCtor { opts, ctor } => {
2189            for e in &opts.entries {
2190                walk_expr(&e.value.0, scope, out);
2191            }
2192            emit_value(scope, out, &ctor.name);
2193        }
2194    }
2195}
2196
2197fn walk_atomic(a: &rustyfi_syntax::cst::ast::Atomic, scope: &mut XverScope, out: &mut FreeGlobals) {
2198    use rustyfi_syntax::cst::ast::Atomic;
2199    match a {
2200        Atomic::Length(_)
2201        | Atomic::Float(_)
2202        | Atomic::Int(_)
2203        | Atomic::Literal(_)
2204        | Atomic::True(_)
2205        | Atomic::False(_) => {}
2206        Atomic::Ctor(c) => emit_value(scope, out, &c.name),
2207        Atomic::Var(v) => emit_value(scope, out, &v.name),
2208        // Qualified — resolves inside the module, never against `base_env`.
2209        Atomic::VarWithMod(_) => {}
2210        Atomic::OpRef(op) => emit_value(scope, out, &op.name),
2211        Atomic::Command { name, .. } => walk_any_horz_cmd_ref(name, scope, out),
2212        Atomic::Unit { .. } => {}
2213        Atomic::Paren { inner, .. } => walk_paren_body(inner, scope, out),
2214        Atomic::OpenModule { body, .. } => walk_paren_body(body, scope, out),
2215        Atomic::Record { body, .. } => walk_record_body(body, scope, out),
2216        Atomic::List { items, .. } => {
2217            for it in items {
2218                walk_expr(&it.value.0, scope, out);
2219            }
2220        }
2221        Atomic::InlineText { elems, .. } => {
2222            for el in elems {
2223                walk_inline_elem(el, scope, out);
2224            }
2225        }
2226        Atomic::BlockText { elems, .. } => {
2227            for el in elems {
2228                walk_block_elem(el, scope, out);
2229            }
2230        }
2231        Atomic::MathText { elems, .. } => {
2232            for el in elems {
2233                walk_math_elem(&el.0, scope, out);
2234            }
2235        }
2236    }
2237}
2238
2239fn walk_any_horz_cmd_ref(
2240    n: &rustyfi_syntax::leaf::AnyHorzCmdTok,
2241    scope: &XverScope,
2242    out: &mut FreeGlobals,
2243) {
2244    use rustyfi_syntax::leaf::AnyHorzCmdTok;
2245    match n {
2246        AnyHorzCmdTok::Plain(t) => emit_value(scope, out, &t.name),
2247        AnyHorzCmdTok::Mod(_) => {} // qualified — skip
2248    }
2249}
2250
2251fn walk_any_vert_cmd_ref(
2252    n: &rustyfi_syntax::leaf::AnyVertCmdTok,
2253    scope: &XverScope,
2254    out: &mut FreeGlobals,
2255) {
2256    use rustyfi_syntax::leaf::AnyVertCmdTok;
2257    match n {
2258        AnyVertCmdTok::Plain(t) => emit_value(scope, out, &t.name),
2259        AnyVertCmdTok::Mod(_) => {} // qualified — skip
2260    }
2261}
2262
2263fn walk_any_math_cmd_ref(
2264    n: &rustyfi_syntax::leaf::AnyMathCmdTok,
2265    scope: &XverScope,
2266    out: &mut FreeGlobals,
2267) {
2268    use rustyfi_syntax::leaf::AnyMathCmdTok;
2269    match n {
2270        AnyMathCmdTok::Plain(t) => emit_value(scope, out, &t.name),
2271        AnyMathCmdTok::Mod(_) => {} // qualified — skip
2272    }
2273}
2274
2275fn walk_paren_body(
2276    pb: &rustyfi_syntax::cst::ast::ParenBody,
2277    scope: &mut XverScope,
2278    out: &mut FreeGlobals,
2279) {
2280    walk_expr(&pb.first.0, scope, out);
2281    for ce in &pb.rest {
2282        walk_expr(&ce.value.0, scope, out);
2283    }
2284}
2285
2286fn walk_record_body(
2287    rb: &rustyfi_syntax::cst::ast::RecordBody,
2288    scope: &mut XverScope,
2289    out: &mut FreeGlobals,
2290) {
2291    use rustyfi_syntax::cst::ast::RecordBody;
2292    match rb {
2293        RecordBody::Update { base, fields, .. } => {
2294            walk_expr(&base.0, scope, out);
2295            for f in fields {
2296                walk_expr(&f.value.0, scope, out);
2297            }
2298        }
2299        RecordBody::Fields(fields) => {
2300            for f in fields {
2301                walk_expr(&f.value.0, scope, out);
2302            }
2303        }
2304    }
2305}
2306
2307fn walk_inline_elem(
2308    el: &rustyfi_syntax::cst::ast::InlineElem,
2309    scope: &mut XverScope,
2310    out: &mut FreeGlobals,
2311) {
2312    use rustyfi_syntax::cst::ast::InlineElem;
2313    match el {
2314        InlineElem::Char(_)
2315        | InlineElem::CodeText(_)
2316        | InlineElem::Space(_)
2317        | InlineElem::Break(_) => {}
2318        InlineElem::Embed { var, .. } => {
2319            if var.mods.is_empty() {
2320                emit_value(scope, out, &var.name);
2321            }
2322        }
2323        InlineElem::EmbedMath { elems, .. } => {
2324            for m in elems {
2325                walk_math_elem(&m.0, scope, out);
2326            }
2327        }
2328        InlineElem::Cmd { name, tail } => {
2329            walk_any_horz_cmd_ref(name, scope, out);
2330            walk_cmd_tail(tail, scope, out);
2331        }
2332        InlineElem::ItemBullet(_) | InlineElem::Sep(_) => {}
2333    }
2334}
2335
2336fn walk_block_elem(
2337    el: &rustyfi_syntax::cst::ast::BlockElem,
2338    scope: &mut XverScope,
2339    out: &mut FreeGlobals,
2340) {
2341    use rustyfi_syntax::cst::ast::BlockElem;
2342    match el {
2343        BlockElem::Embed { var, .. } => {
2344            if var.mods.is_empty() {
2345                emit_value(scope, out, &var.name);
2346            }
2347        }
2348        BlockElem::Cmd { name, tail } => {
2349            walk_any_vert_cmd_ref(name, scope, out);
2350            walk_cmd_tail(tail, scope, out);
2351        }
2352    }
2353}
2354
2355fn walk_cmd_tail(
2356    t: &rustyfi_syntax::cst::ast::CmdTail,
2357    scope: &mut XverScope,
2358    out: &mut FreeGlobals,
2359) {
2360    use rustyfi_syntax::cst::ast::CmdTail;
2361    match t {
2362        CmdTail::Semi(_) => {}
2363        CmdTail::Args { first, rest, .. } => {
2364            walk_apparg(&first.0, scope, out);
2365            for a in rest {
2366                walk_apparg(&a.0, scope, out);
2367            }
2368        }
2369    }
2370}
2371
2372fn walk_math_elem(
2373    m: &rustyfi_syntax::cst::ast::MathElemCst,
2374    scope: &mut XverScope,
2375    out: &mut FreeGlobals,
2376) {
2377    walk_math_bot(&m.base, scope, out);
2378    for s in &m.scripts {
2379        walk_math_script(s, scope, out);
2380    }
2381}
2382
2383fn walk_math_script(
2384    s: &rustyfi_syntax::cst::ast::MathScript,
2385    scope: &mut XverScope,
2386    out: &mut FreeGlobals,
2387) {
2388    use rustyfi_syntax::cst::ast::MathScript;
2389    match s {
2390        MathScript::Super { group, .. } | MathScript::Sub { group, .. } => {
2391            walk_math_group_arg(group, scope, out)
2392        }
2393        MathScript::Primes(_) => {}
2394    }
2395}
2396
2397fn walk_math_group_arg(
2398    g: &rustyfi_syntax::cst::ast::MathGroupArg,
2399    scope: &mut XverScope,
2400    out: &mut FreeGlobals,
2401) {
2402    use rustyfi_syntax::cst::ast::MathGroupArg;
2403    match g {
2404        MathGroupArg::Group { elems, .. } => {
2405            for m in elems {
2406                walk_math_elem(&m.0, scope, out);
2407            }
2408        }
2409        MathGroupArg::Bot(b) => walk_math_bot(b, scope, out),
2410    }
2411}
2412
2413fn walk_math_bot(
2414    b: &rustyfi_syntax::cst::ast::MathBot,
2415    scope: &mut XverScope,
2416    out: &mut FreeGlobals,
2417) {
2418    use rustyfi_syntax::cst::ast::MathBot;
2419    match b {
2420        MathBot::Cmd { name, args } => {
2421            walk_any_math_cmd_ref(name, scope, out);
2422            for a in args {
2423                walk_math_arg(a, scope, out);
2424            }
2425        }
2426        MathBot::Chars(_) => {}
2427        MathBot::Embed(v) => {
2428            if v.mods.is_empty() {
2429                emit_value(scope, out, &v.name);
2430            }
2431        }
2432        MathBot::Sep(_) => {}
2433        MathBot::Group { elems, .. } => {
2434            for m in elems {
2435                walk_math_elem(&m.0, scope, out);
2436            }
2437        }
2438    }
2439}
2440
2441fn walk_math_arg(
2442    a: &rustyfi_syntax::cst::ast::MathArg,
2443    scope: &mut XverScope,
2444    out: &mut FreeGlobals,
2445) {
2446    use rustyfi_syntax::cst::ast::MathArg;
2447    match a {
2448        MathArg::Optional { body, .. } => walk_math_arg_body(body, scope, out),
2449        MathArg::Omission(_) => {}
2450        MathArg::Plain(body) => walk_math_arg_body(body, scope, out),
2451    }
2452}
2453
2454fn walk_math_arg_body(
2455    b: &rustyfi_syntax::cst::ast::MathArgBody,
2456    scope: &mut XverScope,
2457    out: &mut FreeGlobals,
2458) {
2459    use rustyfi_syntax::cst::ast::MathArgBody;
2460    match b {
2461        MathArgBody::Math { elems, .. } => {
2462            for m in elems {
2463                walk_math_elem(&m.0, scope, out);
2464            }
2465        }
2466        MathArgBody::Inline { elems, .. } => {
2467            for el in elems {
2468                walk_inline_elem(el, scope, out);
2469            }
2470        }
2471        MathArgBody::Block { elems, .. } => {
2472            for el in elems {
2473                walk_block_elem(el, scope, out);
2474            }
2475        }
2476        MathArgBody::ParenEscape { inner, .. } => walk_paren_body(inner, scope, out),
2477        MathArgBody::ListEscape { items, .. } => {
2478            for it in items {
2479                walk_expr(&it.value.0, scope, out);
2480            }
2481        }
2482        MathArgBody::RecordEscape { body, .. } => walk_record_body(body, scope, out),
2483    }
2484}
2485
2486fn walk_type_expr(
2487    te: &rustyfi_syntax::cst::ast::TypeExpr,
2488    scope: &mut XverScope,
2489    out: &mut FreeGlobals,
2490) {
2491    use rustyfi_syntax::cst::ast::TypeExpr;
2492    match te {
2493        TypeExpr::Fun { opts, dom, cod, .. } => {
2494            for o in opts {
2495                walk_type_prod(&o.ty, scope, out);
2496            }
2497            walk_type_prod(dom, scope, out);
2498            walk_type_expr(cod, scope, out);
2499        }
2500        TypeExpr::Atom(prod) => walk_type_prod(prod, scope, out),
2501        TypeExpr::OptRowFun {
2502            opt_dom, dom, cod, ..
2503        } => {
2504            for e in &opt_dom.entries {
2505                walk_type_expr(&e.ty.0, scope, out);
2506            }
2507            walk_type_prod(dom, scope, out);
2508            walk_type_expr(cod, scope, out);
2509        }
2510    }
2511}
2512
2513fn walk_type_prod(
2514    tp: &rustyfi_syntax::cst::ast::TypeProd,
2515    scope: &mut XverScope,
2516    out: &mut FreeGlobals,
2517) {
2518    walk_type_app(&tp.first, scope, out);
2519    for st in &tp.rest {
2520        walk_type_app(&st.ty, scope, out);
2521    }
2522}
2523
2524fn walk_type_app(
2525    ta: &rustyfi_syntax::cst::ast::TypeApp,
2526    scope: &mut XverScope,
2527    out: &mut FreeGlobals,
2528) {
2529    // Every atom of a postfix application `arg1 … ctor` — including the final
2530    // constructor — is a `TypeAtom`, and `walk_type_atom` already emits a bare
2531    // `Name` (and skips a module-qualified `NameMod`) as an
2532    // export-boundary type reference, so walking the whole run reproduces the
2533    // old per-arg-then-ctor behavior exactly.
2534    walk_type_atom(&ta.head, scope, out);
2535    for a in &ta.rest {
2536        walk_type_atom(a, scope, out);
2537    }
2538}
2539
2540fn walk_type_atom(
2541    atom: &rustyfi_syntax::cst::ast::TypeAtom,
2542    scope: &mut XverScope,
2543    out: &mut FreeGlobals,
2544) {
2545    use rustyfi_syntax::cst::ast::TypeAtom;
2546    match atom {
2547        TypeAtom::Cmd { args, .. } => {
2548            for a in args {
2549                for l in &a.opt_labels {
2550                    walk_type_expr(&l.ty.0, scope, out);
2551                }
2552                walk_type_expr(&a.ty.0, scope, out);
2553            }
2554        }
2555        TypeAtom::Paren { inner, .. } => walk_type_expr(&inner.0, scope, out),
2556        TypeAtom::Record { fields, .. } => {
2557            for f in fields {
2558                walk_type_expr(&f.ty.0, scope, out);
2559            }
2560        }
2561        // A bound type variable — never a forked-name candidate.
2562        TypeAtom::Var(_) => {}
2563        TypeAtom::Name(n) => emit_type(scope, out, &n.name),
2564        // `Mod.t` — already qualified, not a free unqualified global.
2565        TypeAtom::NameMod(_) => {}
2566        TypeAtom::RecordOpen { inner, .. } => {
2567            for f in &inner.fields {
2568                walk_type_expr(&f.ty.0, scope, out);
2569            }
2570        }
2571    }
2572}
2573
2574/// One `block-frame-breakable` frame currently between its `FrameStart`/
2575/// `FrameEnd` markers on the page being walked.
2576struct OpenFrame {
2577    id: DecoId,
2578    /// The `FrameStart` marker's own `PlacedLine.x` — the frame's left edge.
2579    x: Length,
2580    /// The `FrameStart` marker's own baseline — the degenerate-rect fallback
2581    /// used at close time when NO real line ever appeared between Start/End
2582    /// (an empty frame). NOT used to seed `top`/`bottom` directly (a
2583    /// marker's own baseline is just wherever the previous line happened to
2584    /// end, unrelated to real content extent).
2585    marker_baseline: Length,
2586    /// Running (top, bottom) extent in page (y-down) coordinates, `None`
2587    /// until the first real line is seen between this frame's Start/End.
2588    top: Option<Length>,
2589    bottom: Option<Length>,
2590    /// Insertion order — used to sort same-page fires back into outer-before-
2591    /// inner document order (see the ordering note below).
2592    open_seq: usize,
2593    /// `true` once this frame has already emitted a head (`decoH`) or middle
2594    /// (`decoM`) fragment on an EARLIER page — i.e. its `FrameStart` landed on
2595    /// a previous page and it is still open. Drives the S/H/M/T choice: a
2596    /// non-carried frame closing on its start page fires `decoS`; a carried one
2597    /// fires `decoT`. At each page boundary a still-open frame fires `decoH`
2598    /// (first spanned page) or `decoM` (subsequent) and its per-page extent is
2599    /// reset. `false` for the common single-page frame (unchanged behaviour).
2600    carried: bool,
2601}
2602
2603/// The inline twin of [`OpenFrame`]: one `inline-frame-breakable` that is open
2604/// at some point during the placed-line walk. Where a block frame's fragments
2605/// are delimited by PAGE boundaries, an inline frame's are delimited by LINE
2606/// boundaries (upstream `append_framed_lines`, `lineBreak.ml:695`), so one of
2607/// these can open and close within a single line — the common case, and the
2608/// one that fires `decoS`.
2609#[derive(Clone)]
2610struct OpenInlineFrame {
2611    id: DecoId,
2612    /// Left edge of the fragment currently being accumulated, in absolute page
2613    /// coordinates: the start marker's own x on the line that opened the
2614    /// frame, and the line's own left edge on every continuation line.
2615    x: Length,
2616    /// Baseline of the line the current fragment sits on.
2617    baseline_y: Length,
2618    /// The frame's padded vertical extent, carried on its markers — see
2619    /// `PureHorzBox::InlineFrameMarker` for why it is the whole frame's rather
2620    /// than this fragment's.
2621    height: Length,
2622    depth: Length,
2623    /// `true` once an earlier fragment of this frame has already fired, i.e.
2624    /// the frame really did split — same S/H/M/T choice as `OpenFrame`'s
2625    /// `carried` field above.
2626    carried: bool,
2627}
2628
2629/// The absolute x just past a placed line's last box — where a fragment that
2630/// runs off the end of this line has to stop.
2631///
2632/// Uses each box's NATURAL width rather than its justified advance: the only
2633/// boxes whose two differ are glue, `line_content` trims a line's trailing
2634/// glue away, and an `inline-fil` that survives (the `… ++ inline-fil`
2635/// flush-left idiom) is exactly the case where the fragment should stop at the
2636/// ink rather than at the stretched fil.
2637fn placed_line_right_edge(line: &rustyfi_backend::PlacedLine) -> Length {
2638    let mut edge = Length::ZERO;
2639    for (dx, bx) in &line.contents {
2640        let right = *dx + bx.natural_width();
2641        if right > edge {
2642            edge = right;
2643        }
2644    }
2645    line.x + edge
2646}
2647
2648/// Fire one placed `inline-frame-breakable` fragment's decoration
2649/// (`decoS`/`decoH`/`decoM`/`decoT` picked by `deco_idx` — 0/1/2/3,
2650/// evalUtil.ml:169 `get_decoset` order), spanning `frame.x` to `right`.
2651///
2652/// The vertical padding is already folded into `frame.height`/`frame.depth`
2653/// (the markers carry the padded extent), so unlike the block twin this takes
2654/// no per-fragment pad selection: upstream's `append_vert_padding`
2655/// (`lineBreak.ml:74`) applies `paddingT`/`paddingB` to EVERY fragment of an
2656/// inline frame, not just the first and last — the split is horizontal, so
2657/// each fragment has its own full-height top and bottom edge.
2658fn fire_inline_frame_fragment(
2659    interp: &mut eval::Interp,
2660    doc: &DocumentValue,
2661    page: usize,
2662    frame: &OpenInlineFrame,
2663    right: Length,
2664    deco_idx: usize,
2665) -> Result<(), eval::EvalError> {
2666    let (deco, deco_version) = match &interp.decos[frame.id.0] {
2667        eval::DecoEntry::InlineBreakable {
2668            decoset, version, ..
2669        } => (decoset[deco_idx].clone(), *version),
2670        _ => {
2671            return eval::eval_error("BUG: non-breakable deco behind an inline frame marker");
2672        }
2673    };
2674    let width = right - frame.x;
2675    let pt = (frame.x, doc.geometry.paper_height - frame.baseline_y);
2676    // See the block-frame call site's identical comment — `annot.satyh`'s
2677    // `\href` fires `register-link-to-uri` from exactly this closure.
2678    interp.current_deco_id = Some(frame.id);
2679    let gr = primitives::apply_deco(
2680        interp,
2681        deco_version,
2682        deco,
2683        pt,
2684        width,
2685        frame.height,
2686        frame.depth,
2687    )?;
2688    interp.current_deco_id = None;
2689    interp.page_graphics[page].extend(gr);
2690    Ok(())
2691}
2692
2693/// Fire every placed page-break hook and decoration, in document order, now
2694/// that final page numbers and points are known. This is the port's
2695/// callback architecture: `make_hook` + `handlePdf.ml:234/337`'s invocation
2696/// (hooks) and `EvHorzFrame`/`EvVertFrame` (decos), relocated to the one
2697/// place that legally holds `&mut Interp` — the backend produced the
2698/// geometry (POD `HookId`/`DecoId` tokens riding inside placed boxes, per
2699/// `hbox.rs`); this reads them back and re-enters the evaluator.
2700///
2701/// Sets `interp.current_page` to `Some(i)` for the duration of page `i`'s
2702/// walk (the "during page break" window: `register-destination`/
2703/// `register-link-to-*` — called directly by a hook or, more commonly,
2704/// transitively by a fired deco closure, e.g. `annot.satyh`'s `\href` —
2705/// only succeed inside this window) and back to `None` once every page is
2706/// done.
2707///
2708/// **Known scope cuts** (documented deviations):
2709/// - Frames nested inside a `Tabular` cell or an `EmbeddedBlock`'s stacked
2710///   lines are NOT discovered by this walk — their placed positions would
2711///   need the writers' cell/stack arithmetic replicated lang-side. No
2712///   bundled package puts an `\href`/frame inside one today.
2713/// - A `block-frame-breakable` frame whose `FrameStart` and `FrameEnd` land
2714///   on DIFFERENT pages now fires per-page fragments: `decoS` if it opens and
2715///   closes on one page, else `decoH` on its first (opening) page, `decoM` on
2716///   each fully-contained middle page, and `decoT` on its closing page — the
2717///   `pageBreak.ml` fragment split. Each fragment's rect spans only that
2718///   page's content extent, plus the frame's TOP pad (which `chop_page`
2719///   re-applies on every continuation page, `pageBreak.ml:322`) and, on the
2720///   tail/single fragment, the bottom pad. This is
2721///   what lets `figbox`'s `+fig-on-right`/`+fig-on-left` (which draw their
2722///   image in `decoH`) render on figures whose surrounding text wraps across a
2723///   page break.
2724///
2725/// `pub` (rather than crate-private) so unit tests can drive it directly
2726/// against a hand-built `DocumentValue`, without going through a full
2727/// `compile_document_cst` fixpoint.
2728pub fn fire_hooks(interp: &mut eval::Interp, doc: &DocumentValue) -> Result<(), eval::EvalError> {
2729    interp.page_graphics = doc.pages.iter().map(|_| Vec::new()).collect();
2730    let mut next_open_seq: usize = 0;
2731    // Frames persist ACROSS pages: a `block-frame-breakable` whose `FrameStart`
2732    // and `FrameEnd` straddle a page break stays in `open` between pages so its
2733    // head/middle fragments fire at each boundary and its tail fires when the
2734    // `FrameEnd` finally arrives. Single-page frames are pushed and removed
2735    // within one page's walk.
2736    let mut open: Vec<OpenFrame> = Vec::new();
2737    // Inline frames persist across LINES the same way, and across pages too
2738    // (the line a frame continues onto can be the first line of the next
2739    // page), so this lives outside the page loop as well.
2740    let mut open_inline: Vec<OpenInlineFrame> = Vec::new();
2741    for (i, page) in doc.pages.iter().enumerate() {
2742        interp.current_page = Some(i);
2743        let page_number = (i + 1) as i64; // 1-based, = pbinfo#page-number
2744                                          // Frames carried over from a previous page start a fresh per-page
2745                                          // extent: their fragment on THIS page spans only this page's lines.
2746        for f in &mut open {
2747            f.top = None;
2748            f.bottom = None;
2749        }
2750        // (open_seq, graphics) per block-frame fragment fired on this page,
2751        // sorted by open order before being appended to the page's underlay
2752        // — see the doc comment on the ordering this preserves.
2753        let mut closings: Vec<(usize, Vec<GraphicsElem>)> = Vec::new();
2754
2755        for (line_idx, line) in page.lines.iter().enumerate() {
2756            // An inline frame that was still open when the previous line ended
2757            // continues here: re-anchor it to THIS line's left edge and
2758            // baseline, so its next fragment measures from where it resumes.
2759            // Body lines only — the header and footer are appended after the
2760            // columns and belong to their own line-break runs, so a frame
2761            // straddling the body/header boundary must not paint across them
2762            // (the same reason `Page::body_lines` exists for block frames).
2763            let is_body = line_idx < page.body_lines;
2764            if is_body {
2765                for f in &mut open_inline {
2766                    f.x = line.x;
2767                    f.baseline_y = line.baseline_y;
2768                }
2769            }
2770            for (dx, bx) in &line.contents {
2771                match bx {
2772                    PureHorzBox::HookPageBreak { id } => {
2773                        fire_page_break_hook(
2774                            interp,
2775                            doc,
2776                            page_number,
2777                            line.x + *dx,
2778                            line.baseline_y,
2779                            *id,
2780                        )?;
2781                    }
2782                    // `Tabular`/`Graphics` for the same reason `Frame` is here:
2783                    // a cell's boxes, and the inline run a `draw-text` carries,
2784                    // never appear in the page flow, so a `\href` or a
2785                    // `hook-page-break` inside one is only reachable through
2786                    // this recursion.
2787                    PureHorzBox::Frame { .. }
2788                    | PureHorzBox::Tabular(_)
2789                    | PureHorzBox::Graphics { .. } => {
2790                        fire_inline_frame(interp, doc, i, line.x + *dx, line.baseline_y, bx)?;
2791                    }
2792                    PureHorzBox::InlineFrameMarker {
2793                        id,
2794                        end: false,
2795                        height,
2796                        depth,
2797                    } => {
2798                        open_inline.push(OpenInlineFrame {
2799                            id: *id,
2800                            x: line.x + *dx,
2801                            baseline_y: line.baseline_y,
2802                            height: *height,
2803                            depth: *depth,
2804                            carried: false,
2805                        });
2806                    }
2807                    PureHorzBox::InlineFrameMarker { id, end: true, .. } => {
2808                        // Innermost still-open frame with this id (well-nested
2809                        // by construction — `prim_inline_frame_breakable`
2810                        // always splices a matched pair around its own
2811                        // contents). Closing on the line it opened on is an
2812                        // UNBROKEN frame: `decoS`. Closing on a later line
2813                        // makes this the last of several fragments: `decoT`.
2814                        if let Some(pos) = open_inline.iter().rposition(|f| f.id == *id) {
2815                            let frame = open_inline.remove(pos);
2816                            let deco_idx = if frame.carried { 3 } else { 0 };
2817                            fire_inline_frame_fragment(
2818                                interp,
2819                                doc,
2820                                i,
2821                                &frame,
2822                                line.x + *dx,
2823                                deco_idx,
2824                            )?;
2825                        }
2826                    }
2827                    PureHorzBox::FrameMarker { id, end: false } => {
2828                        open.push(OpenFrame {
2829                            id: *id,
2830                            x: line.x + *dx,
2831                            marker_baseline: line.baseline_y,
2832                            top: None,
2833                            bottom: None,
2834                            open_seq: next_open_seq,
2835                            carried: false,
2836                        });
2837                        next_open_seq += 1;
2838                    }
2839                    PureHorzBox::FrameMarker { id, end: true } => {
2840                        // Close the innermost still-open frame with this id
2841                        // (well-nested by construction: `prim_block_frame_
2842                        // breakable` always emits a matched Start/End pair
2843                        // around its own contents). A frame carried over from
2844                        // an earlier page fires its TAIL fragment (`decoT`,
2845                        // bottom pad only); one that opened on this page fires
2846                        // the single-fragment `decoS` (both pads).
2847                        if let Some(pos) = open.iter().rposition(|f| f.id == *id) {
2848                            let frame = open.remove(pos);
2849                            // EVERY fragment carries the top pad, not just the
2850                            // first: `chop_page` re-applies a still-open
2851                            // frame's `paddingT` at the top of each
2852                            // continuation page (upstream `pageBreak.ml:322`),
2853                            // so the pad is real space on this page too and the
2854                            // rect has to cover it — upstream's
2855                            // `handlePdf.ml:325-330` spans the rect from the
2856                            // fragment's `ypos`, above the `-% paddingT` shift
2857                            // it lays the contents out at.
2858                            let deco_idx = if frame.carried { 3 } else { 0 };
2859                            let incl_top = true;
2860                            let gr = fire_block_frame_fragment(
2861                                interp, doc, &frame, deco_idx, incl_top, true,
2862                            )?;
2863                            closings.push((frame.open_seq, gr));
2864                        }
2865                        // An End with no matching open frame can't happen given
2866                        // well-nested markers; ignored if it did.
2867                    }
2868                    PureHorzBox::EmbeddedBlock {
2869                        block, anchor_last, ..
2870                    } => {
2871                        // A `block-frame-breakable` can also hide INSIDE an
2872                        // `embed-block-breakable` (figbox's inline
2873                        // `\fig-on-right`/`\fig-on-left`, which draw their image
2874                        // from the frame's deco): its `FrameStart`/`FrameEnd`
2875                        // markers live in this atomic box's own placed lines, not
2876                        // the page flow, so the walk above never sees them. Fire
2877                        // those nested decos with absolute coordinates.
2878                        fire_embedded_block_frames(
2879                            interp,
2880                            doc,
2881                            i,
2882                            line.x + *dx,
2883                            line.baseline_y,
2884                            block,
2885                            *anchor_last,
2886                            &mut next_open_seq,
2887                            &mut closings,
2888                        )?;
2889                    }
2890                    _ => {}
2891                }
2892            }
2893            // Every REAL line (one with non-marker content) extends every
2894            // currently-open frame's (top, bottom) — pad Skips don't create
2895            // a `PlacedLine` at all, so they're naturally excluded here; the
2896            // ±pad compensation happens once, at close time, above.
2897            //
2898            // BODY lines only (`Page::body_lines`). The header and footer are
2899            // appended after the columns, and a frame carried across a page
2900            // boundary is open for this whole walk, so counting them stretched
2901            // every such frame's fragment from the header baseline to the
2902            // footer — easytable's `+code` blocks painted their grey background
2903            // over entire pages (4, 11, 12) instead of over their own lines.
2904            if line_idx < page.body_lines {
2905                if let Some((height, depth)) = placed_line_extent(line) {
2906                    let top = line.baseline_y - height;
2907                    let bottom = line.baseline_y + depth;
2908                    for f in &mut open {
2909                        f.top = Some(f.top.map_or(top, |t| t.min(top)));
2910                        f.bottom = Some(f.bottom.map_or(bottom, |b| b.max(bottom)));
2911                    }
2912                }
2913            }
2914            // An inline frame still open at the end of a line really did split
2915            // (upstream `append_framed_lines`' non-final `PureLine` arms): fire
2916            // this line's fragment — `decoH` for the first, `decoM` for every
2917            // later one. The frame stays open; the re-anchor at the top of the
2918            // next body line's turn moves it on.
2919            if is_body && !open_inline.is_empty() {
2920                let right = placed_line_right_edge(line);
2921                let pending: Vec<OpenInlineFrame> = open_inline.clone();
2922                for frame in &pending {
2923                    let deco_idx = if frame.carried { 2 } else { 1 };
2924                    fire_inline_frame_fragment(interp, doc, i, frame, right, deco_idx)?;
2925                }
2926                for f in &mut open_inline {
2927                    f.carried = true;
2928                }
2929            }
2930        }
2931        // Frames still open at page end straddle the following page break: fire
2932        // this page's fragment — a HEAD (`decoH`, top pad only) the first time a
2933        // frame spans, a MIDDLE (`decoM`, no pads) on every later page — and
2934        // keep the frame open so its remaining fragments (and eventual `decoT`)
2935        // fire on the pages ahead. A frame that never accumulated a real line
2936        // on this page (top/bottom still `None`) contributes nothing and does
2937        // NOT advance its fragment state: it stays `carried` as it was, so a
2938        // frame that opened at the very bottom of a page (no room for a line)
2939        // still fires its HEAD (or, if it also closes with content on a single
2940        // later page, a `decoS`) on the first page that actually holds its
2941        // content. Collect fires first (can't hold `&open` across the `&mut
2942        // interp` deco call), then mark exactly the frames that fired.
2943        let mut page_end_fires: Vec<(usize, Vec<GraphicsElem>)> = Vec::new();
2944        let mut fired_seqs: Vec<usize> = Vec::new();
2945        for frame in &open {
2946            if frame.top.is_none() && frame.bottom.is_none() {
2947                continue;
2948            }
2949            // Top pad on every fragment — see the `FrameMarker { end: true }`
2950            // arm above for why a carried fragment has one too.
2951            let deco_idx = if frame.carried { 2 } else { 1 };
2952            let gr = fire_block_frame_fragment(interp, doc, frame, deco_idx, true, false)?;
2953            page_end_fires.push((frame.open_seq, gr));
2954            fired_seqs.push(frame.open_seq);
2955        }
2956        for f in &mut open {
2957            if fired_seqs.contains(&f.open_seq) {
2958                f.carried = true;
2959            }
2960        }
2961        closings.extend(page_end_fires);
2962
2963        closings.sort_by_key(|(seq, _)| *seq);
2964        for (_, gr) in closings {
2965            interp.page_graphics[i].extend(gr);
2966        }
2967    }
2968    interp.current_page = None;
2969    Ok(())
2970}
2971
2972/// Fire one placed `block-frame-breakable` fragment's decoration (`decoS`/
2973/// `decoH`/`decoM`/`decoT` picked by `deco_idx` — 0/1/2/3, evalUtil.ml:169
2974/// `get_decoset` order) with its final geometry, returning the graphics it
2975/// draws (absolute page coordinates). `incl_top_pad`/`incl_bot_pad` select
2976/// which of the frame's `pads.t`/`pads.b` this fragment carries: the single
2977/// (`decoS`) fragment carries both, the head only the top, the tail only the
2978/// bottom, and a middle neither — matching `pageBreak.ml`'s per-fragment
2979/// padding. The rect spans the frame's accumulated (top, bottom) extent on the
2980/// current page; an empty frame (no real line between Start/End) falls back to
2981/// the Start marker's own baseline for a degenerate zero-height rect.
2982fn fire_block_frame_fragment(
2983    interp: &mut eval::Interp,
2984    doc: &DocumentValue,
2985    frame: &OpenFrame,
2986    deco_idx: usize,
2987    incl_top_pad: bool,
2988    incl_bot_pad: bool,
2989) -> Result<Vec<GraphicsElem>, eval::EvalError> {
2990    let (pads, width, deco, deco_version) = match &interp.decos[frame.id.0] {
2991        eval::DecoEntry::Block {
2992            pads,
2993            width,
2994            decoset,
2995            version,
2996        } => (*pads, *width, decoset[deco_idx].clone(), *version),
2997        eval::DecoEntry::Inline { .. } | eval::DecoEntry::InlineBreakable { .. } => {
2998            return eval::eval_error("BUG: inline deco behind a block-frame marker")
2999        }
3000    };
3001    let top = frame.top.unwrap_or(frame.marker_baseline);
3002    let bottom = frame.bottom.unwrap_or(frame.marker_baseline);
3003    let frame_top = if incl_top_pad { top - pads.t } else { top };
3004    let frame_bottom = if incl_bot_pad {
3005        bottom + pads.b
3006    } else {
3007        bottom
3008    };
3009    let pt = (frame.x, doc.geometry.paper_height - frame_bottom);
3010    // Record which DecoId is firing so a `register-destination` call
3011    // inside the deco (annot.satyh's `register-location-frame`) can tag
3012    // itself with it — see `Interp::current_deco_id`'s doc comment.
3013    interp.current_deco_id = Some(frame.id);
3014    let height = frame_bottom - frame_top;
3015    let gr = primitives::apply_deco(interp, deco_version, deco, pt, width, height, Length::ZERO)?;
3016    interp.current_deco_id = None;
3017    // A WHOLE-frame decoration (`decoS`, index 0), recorded box-local for the
3018    // reflow backend — see `FrameDecoration`. The deco drew at `pt`, so
3019    // shifting by `-pt` puts the origin back at the frame's own bottom-left.
3020    // A frame split across pages fires `decoH`/`decoM`/`decoT` instead and is
3021    // deliberately not recorded: there is no single drawing to scale.
3022    if deco_idx == 0 && !gr.is_empty() {
3023        let back = (Length::ZERO - pt.0, Length::ZERO - pt.1);
3024        interp.frame_decos.push((
3025            frame.id,
3026            rustyfi_backend::FrameDecoration {
3027                width,
3028                height,
3029                pads: (pads.l, pads.r, pads.t, pads.b),
3030                elems: gr.iter().map(|e| shift_graphics(back, e)).collect(),
3031            },
3032        ));
3033    }
3034    Ok(gr)
3035}
3036
3037/// Fire block-frame decorations that live INSIDE an `EmbeddedBlock` inline box.
3038///
3039/// figbox's inline `\fig-on-right`/`\fig-on-left` wrap a `block-frame-breakable`
3040/// (whose deco draws the figure image) in `embed-block-breakable`, so the
3041/// frame's `FrameStart`/`FrameEnd` markers end up in the embedded block's OWN
3042/// placed lines, never the page flow that [`fire_hooks`] walks — the image
3043/// would silently never render. Replicate `place_embedded_block`'s transform
3044/// (`rustyfi-pdf`): `place_block_at` from a zero origin, then shift so the
3045/// anchor line (first for top-anchor, last for bottom) sits at the box's inline
3046/// baseline. Converting that writer y-up geometry back to page y-down, inner
3047/// line `i`'s absolute baseline is `baseline_ydown + (bl_i - anchor_offset)` and
3048/// its x is `tx + line.x + dx`. Over those absolute lines we run the same
3049/// frame-open/close tracking and `fire_block_frame_fragment` as the main walk.
3050/// The box is atomic (one inline box, not page-broken), so every nested frame
3051/// opens and closes within it and fires a single-fragment `decoS`. Nested
3052/// `EmbeddedBlock`s (a figbox inside a figbox) recurse.
3053#[allow(clippy::too_many_arguments)]
3054fn fire_embedded_block_frames(
3055    interp: &mut eval::Interp,
3056    doc: &DocumentValue,
3057    page: usize,
3058    tx: Length,
3059    baseline_ydown: Length,
3060    block: &[VertBox],
3061    anchor_last: bool,
3062    next_open_seq: &mut usize,
3063    out: &mut Vec<(usize, Vec<GraphicsElem>)>,
3064) -> Result<(), eval::EvalError> {
3065    let placed = place_block_at((Length::ZERO, Length::ZERO), block.to_vec());
3066    let anchor = if anchor_last {
3067        placed.last()
3068    } else {
3069        placed.first()
3070    };
3071    let Some(anchor) = anchor else {
3072        return Ok(());
3073    };
3074    let anchor_offset = anchor.baseline_y;
3075    let mut open: Vec<OpenFrame> = Vec::new();
3076    // `inline-frame-breakable` inside an embedded block, same story as the
3077    // `PureHorzBox::Frame` arm below: latexcmds' `\fbox`/`\doublebox`/
3078    // `\ovalbox`/`\shadowbox` all go through the BREAKABLE primitive, and a
3079    // `+listing` item's lines live here rather than in the page flow.
3080    let mut open_inline: Vec<OpenInlineFrame> = Vec::new();
3081    for pl in &placed {
3082        let abs_baseline = baseline_ydown + (pl.baseline_y - anchor_offset);
3083        for f in &mut open_inline {
3084            f.x = tx + pl.x;
3085            f.baseline_y = abs_baseline;
3086        }
3087        for (dx, bx) in &pl.contents {
3088            match bx {
3089                PureHorzBox::InlineFrameMarker {
3090                    id,
3091                    end: false,
3092                    height,
3093                    depth,
3094                } => {
3095                    open_inline.push(OpenInlineFrame {
3096                        id: *id,
3097                        x: tx + pl.x + *dx,
3098                        baseline_y: abs_baseline,
3099                        height: *height,
3100                        depth: *depth,
3101                        carried: false,
3102                    });
3103                }
3104                PureHorzBox::InlineFrameMarker { id, end: true, .. } => {
3105                    if let Some(pos) = open_inline.iter().rposition(|f| f.id == *id) {
3106                        let frame = open_inline.remove(pos);
3107                        let deco_idx = if frame.carried { 3 } else { 0 };
3108                        fire_inline_frame_fragment(
3109                            interp,
3110                            doc,
3111                            page,
3112                            &frame,
3113                            tx + pl.x + *dx,
3114                            deco_idx,
3115                        )?;
3116                    }
3117                }
3118                PureHorzBox::FrameMarker { id, end: false } => {
3119                    open.push(OpenFrame {
3120                        id: *id,
3121                        x: tx + pl.x + *dx,
3122                        marker_baseline: abs_baseline,
3123                        top: None,
3124                        bottom: None,
3125                        open_seq: *next_open_seq,
3126                        carried: false,
3127                    });
3128                    *next_open_seq += 1;
3129                }
3130                PureHorzBox::FrameMarker { id, end: true } => {
3131                    if let Some(pos) = open.iter().rposition(|f| f.id == *id) {
3132                        let frame = open.remove(pos);
3133                        let gr = fire_block_frame_fragment(interp, doc, &frame, 0, true, true)?;
3134                        out.push((frame.open_seq, gr));
3135                    }
3136                }
3137                // An INLINE frame (`inline-frame-outer`/`-inner`/`-breakable`)
3138                // can hide in here too — latexcmds' `\fbox`/`\doublebox`/
3139                // `\ovalbox`/`\shadowbox` used inside `+listing` items, whose
3140                // lines live in an embedded block rather than the page flow
3141                // (26 of 144 inline frames in one document went undrawn
3142                // without this). `Tabular`/`Graphics` too — see the identical
3143                // arm in `fire_hooks`.
3144                PureHorzBox::Frame { .. }
3145                | PureHorzBox::Tabular(_)
3146                | PureHorzBox::Graphics { .. } => {
3147                    fire_inline_frame(interp, doc, page, tx + pl.x + *dx, abs_baseline, bx)?;
3148                }
3149                PureHorzBox::EmbeddedBlock {
3150                    block: inner,
3151                    anchor_last: al,
3152                    ..
3153                } => {
3154                    fire_embedded_block_frames(
3155                        interp,
3156                        doc,
3157                        page,
3158                        tx + pl.x + *dx,
3159                        abs_baseline,
3160                        inner,
3161                        *al,
3162                        next_open_seq,
3163                        out,
3164                    )?;
3165                }
3166                _ => {}
3167            }
3168        }
3169        if let Some((height, depth)) = placed_line_extent(pl) {
3170            let top = abs_baseline - height;
3171            let bottom = abs_baseline + depth;
3172            for f in &mut open {
3173                f.top = Some(f.top.map_or(top, |t| t.min(top)));
3174                f.bottom = Some(f.bottom.map_or(bottom, |b| b.max(bottom)));
3175            }
3176        }
3177        if !open_inline.is_empty() {
3178            let right = tx + placed_line_right_edge(pl);
3179            let pending: Vec<OpenInlineFrame> = open_inline.clone();
3180            for frame in &pending {
3181                let deco_idx = if frame.carried { 2 } else { 1 };
3182                fire_inline_frame_fragment(interp, doc, page, frame, right, deco_idx)?;
3183            }
3184            for f in &mut open_inline {
3185                f.carried = true;
3186            }
3187        }
3188    }
3189    Ok(())
3190}
3191
3192/// Apply one `hook-page-break` closure to `(pbinfo, point)`.
3193///
3194/// Extracted so the walk can fire a hook wherever it is found, not only at
3195/// the top level of a placed line: `stdja`'s `+section` appends its
3196/// `hook-page-break` to the heading's inline boxes, wrapped in the title
3197/// deco's inline FRAME, so all 7 of this manual's hooks sat one level down
3198/// and none fired without this. `stdja.satyh:448` registers `<label>:page`
3199/// from inside this closure, so an unfired hook left `get-cross-reference`
3200/// rendering `?`: 11 such `?` for easytable and 21 for enumitem on pages
3201/// 1-2 alone, where SATySFi emits none.
3202fn fire_page_break_hook(
3203    interp: &mut eval::Interp,
3204    doc: &DocumentValue,
3205    page_number: i64,
3206    x: Length,
3207    baseline_y: Length,
3208    id: rustyfi_backend::HookId,
3209) -> Result<(), eval::EvalError> {
3210    let closure = interp.hooks[id.0].clone();
3211    let mut fields = BTreeMap::new();
3212    fields.insert("page-number".to_string(), Value::Int(page_number));
3213    let pbinfo = Value::Record(fields);
3214    // PDF page space is y-up; placed geometry (`baseline_y`) is page space
3215    // y-down from the paper top — the same flip the writers apply.
3216    let point = Value::Tuple(vec![
3217        Value::Length(x),
3218        Value::Length(doc.geometry.paper_height - baseline_y),
3219    ]);
3220    let applied = interp.apply(closure, pbinfo)?;
3221    match interp.apply(applied, point)? {
3222        Value::Unit => Ok(()),
3223        other => eval::eval_error(format!(
3224            "hook-page-break closure returned {}, expected unit",
3225            other.type_name()
3226        )),
3227    }
3228}
3229
3230/// Fire one placed inline frame's deco (and any frames nested in its
3231/// contents) with its final geometry — the port of `EvHorzFrame`'s
3232/// `deco (xpos, yposbaseline) wid hgt dpt` (handlePdf.ml:123-129), point
3233/// pre-flipped to PDF y-up exactly like the hook point above. The returned
3234/// `graphics list` (absolute page coordinates, `make_frame_deco`'s contract)
3235/// is accumulated onto this page's underlay.
3236///
3237/// `interp.current_page` is already `Some(page)` here (set by `fire_hooks`'s
3238/// caller), so a deco body calling `register-link-to-uri` (exactly
3239/// `annot.satyh:11-14`) lands its `Annot` on the right page — this is the
3240/// entire `\href` unlock.
3241///
3242/// Also the entry point for firing whatever is nested INSIDE a placed box:
3243/// besides an inline frame's own contents this descends into a
3244/// `Tabular`'s cells, since a cell's boxes never reach the page flow that
3245/// [`fire_hooks`] walks. Everything a cell can carry — a `\href`, a `\ref`,
3246/// a `hook-page-break` — was silently inert before: a `\href` in an
3247/// easytable cell produced no `/Link` annotation at all, which is 3 of
3248/// slydifi's 4 links. `bx` that is neither a frame nor a tabular is a no-op,
3249/// so callers can hand it every box on a line.
3250fn fire_inline_frame(
3251    interp: &mut eval::Interp,
3252    doc: &DocumentValue,
3253    page: usize,
3254    x: Length,
3255    baseline_y: Length,
3256    bx: &PureHorzBox,
3257) -> Result<(), eval::EvalError> {
3258    let contents = match bx {
3259        PureHorzBox::Frame {
3260            width,
3261            height,
3262            depth,
3263            deco,
3264            contents,
3265        } => {
3266            let (deco_v, deco_version) = match &interp.decos[deco.0] {
3267                eval::DecoEntry::Inline { deco, version } => (deco.clone(), *version),
3268                eval::DecoEntry::Block { .. } | eval::DecoEntry::InlineBreakable { .. } => {
3269                    return eval::eval_error("BUG: block deco behind an inline frame")
3270                }
3271            };
3272            let pt = (x, doc.geometry.paper_height - baseline_y);
3273            // See the block-frame call site's identical comment —
3274            // `annot.satyh`'s `\href` fires `register-link-to-uri` from exactly
3275            // this closure.
3276            interp.current_deco_id = Some(*deco);
3277            let gr =
3278                primitives::apply_deco(interp, deco_version, deco_v, pt, *width, *height, *depth)?;
3279            interp.current_deco_id = None;
3280            interp.page_graphics[page].extend(gr);
3281            contents
3282        }
3283        PureHorzBox::Tabular(tab) => {
3284            // Each cell is its own placed run on its own baseline. The
3285            // writers' convention (`rustyfi-pdf`'s `emit_box`, `ty +
3286            // cell.baseline_y` in PDF y-UP space) means `cell.baseline_y` is
3287            // measured upward from the tabular box's own baseline, so in this
3288            // walk's y-DOWN page coordinates it subtracts.
3289            for cell in &tab.cells {
3290                fire_nested_in_contents(
3291                    interp,
3292                    doc,
3293                    page,
3294                    x + cell.x,
3295                    baseline_y - cell.baseline_y,
3296                    &cell.contents,
3297                )?;
3298            }
3299            return Ok(());
3300        }
3301        PureHorzBox::Graphics {
3302            elems,
3303            origin_independent,
3304            ..
3305        } => {
3306            // `draw-text` runs (`GraphicsElem::Text`) carry real inline boxes,
3307            // and figbox's `textbox` puts whole tables in one — slydifi's
3308            // theme table reaches its `\link`s only through here. Element
3309            // coordinates are box-local PDF y-up from the box's placed anchor,
3310            // except for an `origin_independent` box whose callback already
3311            // produced page-absolute ones: exactly `rustyfi-pdf`'s own
3312            // `(ax, ay)` choice, kept in step with it.
3313            let anchor_y = if *origin_independent {
3314                doc.geometry.paper_height
3315            } else {
3316                baseline_y
3317            };
3318            let anchor_x = if *origin_independent { Length::ZERO } else { x };
3319            fire_nested_in_graphics(interp, doc, page, anchor_x, anchor_y, elems)?;
3320            return Ok(());
3321        }
3322        _ => return Ok(()),
3323    };
3324    fire_nested_in_contents(interp, doc, page, x, baseline_y, contents)
3325}
3326
3327/// Fire every hook, decoration and deferred destination carried by a graphics
3328/// box's elements — the inline runs a `draw-text` (`GraphicsElem::Text`) holds,
3329/// and the `GraphicsElem::Destination` markers an `inline-graphics` callback
3330/// left behind — recursing through `Group`/`Clip`. `anchor_x`/`anchor_y` are
3331/// the box's placed origin in this walk's (x, y-DOWN) page coordinates.
3332///
3333/// A `Text` element's own `transform` (from `rotate-graphics`/
3334/// `scale-graphics`) is deliberately NOT applied: a decoration's rect is an
3335/// axis-aligned rectangle, so a rotated run has no faithful rect to report,
3336/// and firing at the untransformed anchor at least puts a `\href`'s link
3337/// where the run starts rather than nowhere at all.
3338fn fire_nested_in_graphics(
3339    interp: &mut eval::Interp,
3340    doc: &DocumentValue,
3341    page: usize,
3342    anchor_x: Length,
3343    anchor_y: Length,
3344    elems: &[GraphicsElem],
3345) -> Result<(), eval::EvalError> {
3346    for elem in elems {
3347        match elem {
3348            GraphicsElem::Text { pt, contents, .. } => {
3349                // `pt` is PDF y-UP relative to the anchor; this walk is y-down.
3350                fire_nested_in_contents(
3351                    interp,
3352                    doc,
3353                    page,
3354                    anchor_x + pt.0,
3355                    anchor_y - pt.1,
3356                    contents,
3357                )?;
3358            }
3359            GraphicsElem::Group(inner) | GraphicsElem::Clip(_, inner) => {
3360                fire_nested_in_graphics(interp, doc, page, anchor_x, anchor_y, inner)?;
3361            }
3362            // `pt` is y-UP from the anchor, as a `Text`'s is, and a
3363            // `NamedDest`'s `y` is y-up too, so lifting the anchor out of this
3364            // walk's y-DOWN frame is the only arithmetic needed. An
3365            // `origin_independent` box arrives with `anchor_x = 0`/`anchor_y =
3366            // paper_height` (see the caller), making that the identity — the
3367            // same page-absolute reading its ink gets from the writers.
3368            GraphicsElem::Destination { key, pt } => {
3369                let name = interp.dest_name(key);
3370                let x = anchor_x + pt.0;
3371                let y = doc.geometry.paper_height - anchor_y + pt.1;
3372                // As in a direct call: the reflow backend resolves a
3373                // destination to its Frame through this id.
3374                if let Some(deco_id) = interp.current_deco_id {
3375                    interp.dest_decos.push((deco_id, name.clone()));
3376                }
3377                interp.destinations.push(rustyfi_backend::NamedDest {
3378                    page,
3379                    name,
3380                    x,
3381                    y,
3382                });
3383            }
3384            GraphicsElem::Fill(..) | GraphicsElem::Stroke(..) | GraphicsElem::DashedStroke(..) => {}
3385        }
3386    }
3387    Ok(())
3388}
3389
3390/// Fire every hook and decoration inside one placed content run — an inline
3391/// frame's contents or a tabular cell's — with `x0`/`baseline_y` as the run's
3392/// own absolute origin.
3393///
3394/// An `inline-frame-breakable` reached through here is spliced into the run as
3395/// a marker pair (see `prim_inline_frame_breakable`). Such a run is a single
3396/// `fit_cell` line, so the frame is always unbroken and always fires `decoS` —
3397/// which is also what upstream does in this position, since a breakable frame
3398/// reached through a *pure* box degrades to an atomic `LBOuterFrame`
3399/// (`convert_list_for_line_breaking_pure`, lineBreak.ml:335).
3400fn fire_nested_in_contents(
3401    interp: &mut eval::Interp,
3402    doc: &DocumentValue,
3403    page: usize,
3404    x0: Length,
3405    baseline_y: Length,
3406    contents: &[(Length, PureHorzBox)],
3407) -> Result<(), eval::EvalError> {
3408    let mut open_inline: Vec<OpenInlineFrame> = Vec::new();
3409    for (dx, child) in contents {
3410        // A `hook-page-break` can sit INSIDE the frame — `stdja`'s `+section`
3411        // appends one to a heading that the title deco then wraps in a frame —
3412        // and the top-level walk never sees it. See `fire_page_break_hook`.
3413        if let PureHorzBox::HookPageBreak { id } = child {
3414            fire_page_break_hook(interp, doc, (page + 1) as i64, x0 + *dx, baseline_y, *id)?;
3415        }
3416        match child {
3417            PureHorzBox::InlineFrameMarker {
3418                id,
3419                end: false,
3420                height,
3421                depth,
3422            } => open_inline.push(OpenInlineFrame {
3423                id: *id,
3424                x: x0 + *dx,
3425                baseline_y,
3426                height: *height,
3427                depth: *depth,
3428                carried: false,
3429            }),
3430            PureHorzBox::InlineFrameMarker { id, end: true, .. } => {
3431                if let Some(pos) = open_inline.iter().rposition(|f| f.id == *id) {
3432                    let frame = open_inline.remove(pos);
3433                    fire_inline_frame_fragment(interp, doc, page, &frame, x0 + *dx, 0)?;
3434                }
3435            }
3436            _ => {}
3437        }
3438        fire_inline_frame(interp, doc, page, x0 + *dx, baseline_y, child)?;
3439    }
3440    Ok(())
3441}