Skip to main content

rustyfi_lang/
lib.rs

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