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