Skip to main content

rustyfi_lang/
typecheck.rs

1//! The Hindley–Milner type inferencer: walks an
2//! [`crate::elaborate::Program`] and reports the first type error it finds,
3//! mirroring v0.0.6's `typecheck`/`typecheck_sub`
4//! (`src/frontend/typechecker.ml`) — unification itself lives in
5//! `crate::unify`, generalization/instantiation in `crate::types`, this
6//! module only walks the AST applying those primitives at each rule, exactly
7//! as `typechecker.ml` does over its own `unify`/`Typeenv`.
8//!
9//! This is validation only: `typecheck` returns `Result<(), TypeError>` and
10//! never touches the untyped evaluator.
11//!
12//! **Deviations from v0.0.6 and permissive corners** are called out inline
13//! at each rule with a `PERMISSIVE:` comment. The short version: math-mode
14//! command/embed typing, and unbound type-name/type-variable references
15//! inside a `type` declaration's payload, are accepted with a fresh/nominal
16//! stand-in type rather than rejected.
17
18use crate::ast::branded::{Ast, BText, CmdArg, IText, MathElem, Pattern};
19use crate::elaborate::{Program, UserSynonymDecl, UserTypeDecl};
20pub use crate::exhaustive::MatchWarning;
21use crate::prim_types::{
22    self, arrow, builtin_variants_with_version, labeled, list, mandatory, optional, product, reff,
23    t_block_boxes, t_block_text, t_bool, t_context, t_deco, t_decoset, t_document, t_float,
24    t_font_key, t_graphics, t_image, t_inline_boxes, t_inline_text, t_int, t_length, t_math_boxes,
25    t_math_text, t_option, t_paren, t_path, t_prepath, t_string, t_unit, VariantDecl,
26};
27use crate::symbol::{Symbol, SymbolStore};
28use crate::types::{
29    self, generalize, instantiate, resolve, resolve_row, BaseType, CmdArgType, Kind, MonoType,
30    PolyType, Row, Stage, TypeContext,
31};
32use crate::unify::{unify, UnifyError};
33use rustyfi_syntax::cst::ast::{CmdTypeKind, TypeApp, TypeAtom, TypeExpr, TypeProd};
34use rustyfi_syntax::cst::{RecordKind, SigConstraint, SigItem};
35use rustyfi_syntax::span::Span;
36use rustyfi_syntax::RustyfiVersion;
37use std::collections::{BTreeSet, HashMap};
38use std::fmt;
39use std::rc::Rc;
40
41// ============================================================================
42// Errors
43// ============================================================================
44
45/// A type error: a best-effort span (see `ast.rs`'s module doc comment — only
46/// `Var`/command/embed nodes carry spans, so most rules fall back to `None`),
47/// a "while typing …" context message, and — for anything that actually came
48/// from a failed [`unify`] call — the [`UnifyError`] itself, whose `Display`
49/// already renders both types involved.
50#[derive(Debug)]
51pub struct TypeError {
52    pub span: Option<Span>,
53    pub message: String,
54    pub source: Option<UnifyError>,
55}
56
57impl TypeError {
58    fn from_unify(span: Option<Span>, what: impl Into<String>, source: UnifyError) -> TypeError {
59        TypeError {
60            span,
61            message: format!("while typing {}", what.into()),
62            source: Some(source),
63        }
64    }
65
66    fn simple(span: Option<Span>, message: impl Into<String>) -> TypeError {
67        TypeError {
68            span,
69            message: message.into(),
70            source: None,
71        }
72    }
73}
74
75impl fmt::Display for TypeError {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        match self.span {
78            Some(span) => write!(f, "{span}: {}", self.message)?,
79            None => write!(f, "{}", self.message)?,
80        }
81        if let Some(src) = &self.source {
82            write!(f, ": {src}")?;
83        }
84        Ok(())
85    }
86}
87
88impl std::error::Error for TypeError {
89    fn source<'s>(&self) -> Option<&(dyn std::error::Error + 'static)> {
90        self.source
91            .as_ref()
92            .map(|e| e as &(dyn std::error::Error + 'static))
93    }
94}
95
96// ============================================================================
97// The primitive name table.
98//
99// `prim_types::primitive_type` is a pure name -> scheme lookup with no way to
100// enumerate its own domain, and `primitives.rs`'s `PRIM_DEFS` table (the
101// actual source of truth) is private to that module, so this list is
102// hand-kept in sync and cross-checked against `primitives.rs`'s source text
103// by a test (`tests/typecheck.rs`) rather than derived mechanically. It
104// matches `types_unify.rs`'s `every_registered_primitive_has_a_type` test's
105// own `NAMES` list.
106// ============================================================================
107
108pub const PRIMITIVE_NAMES: &[&str] = &[
109    "read-inline",
110    "read-block",
111    "line-break",
112    "page-break",
113    "page-break-multicolumn",
114    "page-break-two-column",
115    "+",
116    "-",
117    "*",
118    "/",
119    "mod",
120    "==",
121    "<>",
122    "<",
123    ">",
124    "<=",
125    ">=",
126    "&&",
127    "||",
128    "not",
129    "+.",
130    "-.",
131    "*.",
132    "/.",
133    "float",
134    "round",
135    "+'",
136    "-'",
137    "*'",
138    "/'",
139    "<'",
140    ">'",
141    "^",
142    "arabic",
143    "string-same",
144    "::",
145    "!",
146    "string-length",
147    "string-sub",
148    "string-explode",
149    "regexp-of-string",
150    "string-match",
151    "string-scan",
152    "split-on-regexp",
153    "embed-string",
154    "inline-fil",
155    // ---- context ops / box combinators ----
156    "set-font-size",
157    "get-font-size",
158    "set-leading",
159    "set-paragraph-margin",
160    "get-text-width",
161    "get-initial-context",
162    "++",
163    "+++",
164    "inline-nil",
165    "block-nil",
166    "inline-skip",
167    "inline-glue",
168    "block-skip",
169    // ---- the reflow marker-box
170    // constructors (`primitives.rs`'s `prims!` table, `Both` versions) ----
171    "list-mark",
172    "inline-mark",
173    // ---- see primitives.rs's `prims!` table comment on `"set-font-key"` ----
174    "set-font-key",
175    // ---- the 0.1 `font` build-out: the LOCAL stand-in for upstream's
176    // internal `LoadSingleFont` node (see `primitives.rs`'s
177    // `prim_load_single_font`). V0_1-only, so
178    // `primitive_type_with_version` returns `None` for it under V0_0 and
179    // the seeding loops skip it there. ----
180    "load-single-font",
181    // ---- the ~18 pure primitives ----
182    // (`|>` excluded — see primitives.rs's `prims!` table comment; it has
183    // no primitive of its own, so it never belongs in this list).
184    "sin",
185    "asin",
186    "cos",
187    "acos",
188    "tan",
189    "atan",
190    "atan2",
191    "log",
192    "exp",
193    "ceil",
194    "floor",
195    "show-float",
196    "string-byte-length",
197    "string-sub-bytes",
198    "string-unexplode",
199    "display-message",
200    "abort-with-message",
201    // ---- raster images ----
202    "load-image",
203    "load-pdf-image",
204    "use-image-by-width",
205    // ---- graphics primitives ----
206    "start-path",
207    "line-to",
208    "terminate-path",
209    "close-with-line",
210    "fill",
211    "stroke",
212    "inline-graphics",
213    // ---- tables ----
214    "tabular",
215    "inline-graphics-outer",
216    // ---- gr.satyh primitives ----
217    "bezier-to",
218    "close-with-bezier",
219    "shift-path",
220    "linear-transform-path",
221    "shift-graphics",
222    "linear-transform-graphics",
223    "get-graphics-bbox",
224    "get-path-bbox",
225    "dashed-stroke",
226    "draw-text",
227    // ---- pervasives.satyh unblockers ----
228    "get-natural-metrics",
229    "inline-frame-outer",
230    // vminst.ml:1807 `BackendInnerFrame` — same signature as
231    // `inline-frame-outer` above; the primitive (`primitives.rs`) and its
232    // type (`prim_types.rs`) were both already registered for both
233    // versions, this list simply omitted the name.
234    "inline-frame-inner",
235    "set-manual-rising",
236    "script-guard",
237    "discretionary",
238    // ---- Tier-2 decoration/graphics packages ----
239    "get-axis-height",
240    // ---- hooks / annotations / cross-references ----
241    "hook-page-break",
242    "hook-page-break-block",
243    "register-cross-reference",
244    "get-cross-reference",
245    // ---- the hooks/annotations/cross-reference closer ----
246    "probe-cross-reference",
247    // ---- (annot.satyh) ----
248    "get-leftmost-script",
249    "get-rightmost-script",
250    "inline-frame-breakable",
251    "register-destination",
252    "register-link-to-uri",
253    "register-link-to-location",
254    // ---- the faithful math primitive layer ----
255    "math-char",
256    "math-big-char",
257    "math-char-with-kern",
258    "math-big-char-with-kern",
259    "math-concat",
260    "math-group",
261    "math-sup",
262    "math-sub",
263    "math-frac",
264    "math-radical",
265    "math-lower",
266    "math-upper",
267    "math-pull-in-scripts",
268    "math-color",
269    "math-char-class",
270    "math-variant-char",
271    "set-math-variant-char",
272    "get-left-math-class",
273    "get-right-math-class",
274    "math-paren",
275    "math-paren-with-middle",
276    "text-in-math",
277    "convert-string-for-math",
278    "embed-math",
279    "set-math-command",
280    "set-math-font",
281    "space-between-maths",
282    "raise-inline",
283    "embed-block-breakable",
284    "unite-path",
285    "set-min-gap-of-lines",
286    "omit-skip-after",
287    "set-text-color",
288    "get-text-color",
289    "set-hyphen-penalty",
290    "set-hyphen-min",
291    "set-space-ratio",
292    "set-space-ratio-between-scripts",
293    "split-into-lines",
294    "block-frame-breakable",
295    "embed-block-top",
296    "set-font",
297    // vminstdef.yaml:1350 `PrimitiveGetFont` — the reader for the slot
298    // `set-font` writes (`ruby` and `quotation` want the CJK face's size
299    // ratio); forked at the result head exactly like `set-font`'s second
300    // argument.
301    "get-font",
302    "set-code-text-command",
303    "get-natural-length",
304    "set-dominant-wide-script",
305    "set-dominant-narrow-script",
306    "set-language",
307    "set-every-word-break",
308    "register-outline",
309    "extract-string",
310    // ---- dominant-script/language getters ----
311    "get-dominant-wide-script",
312    "get-dominant-narrow-script",
313    "get-language",
314    // ---- text-mode-context sliver ----
315    "get-initial-text-info",
316    "deepen-indent",
317    "break",
318    // ---- proof.satyh/footnote-scheme.satyh unblockers ----
319    "embed-block-bottom",
320    "line-stack-bottom",
321    // vminstdef.yaml:1109 `BackendLineStackTop` — the top-anchored twin
322    // (`ruby` stacks its annotation above the base run).
323    "line-stack-top",
324    "add-footnote",
325    // ---- page-level prims blocking mitou-report/stdjareport ----
326    "clear-page",
327    // ---- added in 0.1 (`math-text`/`math-boxes` split + `read-math`) ----
328    "read-math",
329    "stringify-math",
330    "set-math-char",
331    "set-math-char-class",
332    "get-math-char-class",
333    "embed-inline-to-math",
334    "get-math-axis-height-ratio",
335    "%math-attach-scripts",
336    // ---- hyphenation/unidata loader + setter stand-ins, and the `here`
337    // lex-time-constant stand-in. All 5 are V0_1-only
338    // (`primitive_type_with_version` returns `None` for them under V0_0,
339    // same pattern as the bitwise/Unicode-string comment below documents). ----
340    "load-hyphenation-dictionary",
341    "load-unicode-char-database",
342    "set-hyphenation-dictionary",
343    "set-unicode-char-database",
344    "here",
345    // ---- added in 0.1 — bitwise ops, Unicode string ops,
346    // `register-document-information`. All 10
347    // unbound under V0_0 (`base_type_env_with_version`'s
348    // `primitive_type_with_version` filter skips them there, same as the
349    // 8 math-split names just above). `get-initial-text-info` is NOT
350    // listed again here — it's already present above as one
351    // shared name whose *type* forks per version (`prim_types.rs`).
352    // `read-file` sits in this block for grouping only — it is bound under
353    // BOTH versions, having been added on the 0.0.6 dev line (see
354    // `prim_types.rs`), so it is NOT one of the 10. ----
355    "<<",
356    ">>",
357    "band",
358    "bor",
359    "bxor",
360    "bnot",
361    "normalize-string-to-nfc",
362    "normalize-string-to-nfd",
363    "split-grapheme-cluster",
364    "read-file",
365    "register-document-information",
366    // ---- added in 0.1 — the graphics-
367    // collection sweep's 2 added prims, unbound under V0_0. The 3 named + 6
368    // hidden retypes (`tabular`, `get-graphics-bbox`, `inline-graphics`,
369    // `inline-graphics-outer`, `inline-frame-outer/-inner/-breakable`,
370    // `block-frame-breakable`) are NOT listed again here — each is one
371    // shared name whose *type* forks per version (`prim_types.rs`),
372    // already present above. ----
373    "unite-graphics",
374    "clip-graphics-by-path",
375    // ---- language-completeness sweep: 0.1 float comparisons
376    // (`primitives.rs`'s `prims!` table comment on ">."/"<."/">=."/"<=.").
377    // Unbound under V0_0 — confirmed genuinely absent from 0.0.6 upstream,
378    // unlike "+."/"-."/"*."/"/." which both generations share.
379    ">.",
380    "<.",
381    ">=.",
382    "<=.",
383];
384
385// `#[allow(dead_code)]`: kept as the back-compat sibling of
386// `base_type_env_with_version` (mirrors `primitives::base_env`'s public
387// wrapper) even though every current internal caller goes straight to
388// `base_type_env_with_version` — `typecheck_verbose`/`typecheck` bypass this
389// fn directly via `typecheck_verbose_with_version`/`typecheck_with_version`.
390#[allow(dead_code)]
391fn base_type_env<'s>(store: &'s SymbolStore) -> TypeEnv<'s> {
392    base_type_env_with_version(store, RustyfiVersion::V0_0)
393}
394
395pub(crate) fn base_type_env_with_version<'s>(
396    store: &'s SymbolStore,
397    version: RustyfiVersion,
398) -> TypeEnv<'s> {
399    let mut env = TypeEnv::default();
400    for name in PRIMITIVE_NAMES {
401        if let Some(poly) = prim_types::primitive_type_with_version(name, version) {
402            // `with_primitive`, not `with`: this is seeding the BASE env,
403            // not a user binding, so it must not mark `name` as
404            // user-shadowed (see `TypeEnv::with_primitive`'s doc comment).
405            env = env.with_primitive(store.intern(name), poly);
406        }
407    }
408    env
409}
410
411/// The `Ast::VersionScope(version, _)` typecheck arm's env
412/// swap. `TypeEnv` is a flat, persistent-clone name -> scheme map (no
413/// separate "local scope stack" the way `compile.rs`'s `Compiler` has), so
414/// this clones `env` and OVERWRITES every `PRIMITIVE_NAMES` entry with
415/// `version`'s primitive type, leaving every other (user/local) binding
416/// untouched — a version-forked primitive used INSIDE the returned env
417/// (e.g. a spliced 0.0.6 dependency building a `page` ADT for
418/// `page-break`) then checks against `version`'s shapes, while a binder
419/// introduced AFTER this call still shadows normally.
420///
421/// A primitive name already shadowed by a USER binding from BEFORE
422/// this `VersionScope` must NOT be re-stomped. Provenance is tracked on
423/// `TypeEnv` directly since it has no lexical-scope stack: every REAL
424/// binding goes through `TypeEnv::with`/`with_all`, which records the name
425/// in `TypeEnv::shadowed`; the primitive-seeding loops use
426/// `TypeEnv::with_primitive`, which does not. So `e.shadowed.contains(name)`
427/// means "a real user binding rebound this primitive anywhere on the path
428/// to here" — skip the overwrite for those.
429fn version_scoped_type_env<'s>(
430    store: &'s SymbolStore,
431    env: &TypeEnv<'s>,
432    version: RustyfiVersion,
433) -> TypeEnv<'s> {
434    let mut e = env.clone();
435    for name in PRIMITIVE_NAMES {
436        let sym = store.intern(name);
437        if e.is_shadowed(sym) {
438            // A user binding shadows this primitive name already — respect
439            // it instead of re-stomping it with `version`'s builtin scheme.
440            continue;
441        }
442        if let Some(poly) = prim_types::primitive_type_with_version(name, version) {
443            e = e.with_primitive(sym, poly);
444        }
445    }
446    e
447}
448
449// ============================================================================
450// The type environment — the same persistent base/overlay split as
451// `elaborate::Scope` (see its doc comment).
452// ============================================================================
453
454/// Overlay size at which a `TypeEnv` folds its recent bindings down into a
455/// fresh shared base (see [`TypeEnv::maybe_promote`]). Bounds the per-`with`
456/// clone cost: every `with` clones only the ≤ this-many-entry overlays plus two
457/// cheap `Rc` bumps of the (large, shared) base, so type inference is O(program
458/// size) instead of the old O(program × env) — the previous flat-`HashMap`-per-
459/// binding clone was ~16M entry-clones on a corpus doc, ~85% of compile time.
460const OVERLAY_CAP: usize = 64;
461
462/// One [`TypeEnv`] slot: a scheme plus the STAGE its binder was read at
463/// (upstream's `Typeenv.add tyenv varnm (pty, evid, pre.stage)` — every
464/// binder upstream registers carries the stage of the expression that
465/// introduced it, `typechecker.ml:129/136/731/1509/1574`, and its `val_stage`
466/// field in 0.1). Held behind one `Rc` per binding.
467struct EnvEntry {
468    poly: PolyType,
469    /// Where a reference to this name is legal from — see
470    /// [`Stage::can_reference`].
471    stage: Stage,
472}
473
474/// A persistent name → scheme environment split into a large SHARED base
475/// (`Rc`, the accumulated prelude/package bindings — cloned by an `Rc` bump)
476/// and a small mutable OVERLAY of the most recent bindings (cloned in full per
477/// `with`, but capped at [`OVERLAY_CAP`]). Lookups check the overlay first,
478/// then the base; later bindings shadow earlier ones, exactly as a flat map
479/// would — but `with`/`with_all` never copy the whole environment.
480#[derive(Clone, Default)]
481pub(crate) struct TypeEnv<'s> {
482    // Schemes are held by `Rc`, not by value: a `PolyType` clone is a DEEP
483    // copy of its whole type tree — measured at 0.6M-1.8M type nodes copied
484    // per corpus document, an order of magnitude more than `instantiate`
485    // and `generalize` together, and the largest single cost left in
486    // typechecking — so an `Rc` makes every `with`'s overlay clone a
487    // refcount bump instead. The stage rides inside that same `Rc` (see
488    // [`EnvEntry`]), costing no extra allocation or word per overlay slot.
489    //
490    // Sharing is sound because a `PolyType`'s structure is immutable once
491    // built; its `TyVarRef`s are `Rc<RefCell<..>>`, so the mutable cells
492    // unification writes through were already common to every copy.
493    base: std::rc::Rc<HashMap<Symbol<'s>, std::rc::Rc<EnvEntry>>>,
494    overlay: HashMap<Symbol<'s>, std::rc::Rc<EnvEntry>>,
495    /// The set of names bound by a REAL program binding
496    /// (`with`/`with_all`, not the `with_primitive` primitive-seeding loops).
497    /// `version_scoped_type_env` consults it (via [`TypeEnv::is_shadowed`]) to
498    /// tell an untouched builtin scheme apart from a user shadow. Same
499    /// base/overlay split as `vars`.
500    base_shadowed: std::rc::Rc<std::collections::HashSet<Symbol<'s>>>,
501    overlay_shadowed: std::collections::HashSet<Symbol<'s>>,
502}
503
504impl<'s> TypeEnv<'s> {
505    /// Fold the overlays into fresh shared bases once the vars overlay reaches
506    /// [`OVERLAY_CAP`], keeping every `with`'s overlay clone bounded. Amortized
507    /// O(1) per binding (each promotion is O(base) but only every `OVERLAY_CAP`
508    /// bindings along a path); inference is tree-shaped, so no single env is a
509    /// hot branch point where a boundary promotion could recur.
510    fn maybe_promote(&mut self) {
511        if self.overlay.len() < OVERLAY_CAP {
512            return;
513        }
514        let mut base = (*self.base).clone();
515        for (k, v) in self.overlay.drain() {
516            base.insert(k, v);
517        }
518        self.base = std::rc::Rc::new(base);
519        if !self.overlay_shadowed.is_empty() {
520            let mut sh = (*self.base_shadowed).clone();
521            for k in self.overlay_shadowed.drain() {
522                sh.insert(k);
523            }
524            self.base_shadowed = std::rc::Rc::new(sh);
525        }
526    }
527
528    /// Bind `name` at `stage` — always the stage the BINDER was read at
529    /// (`Checker::binding_stage`), never a guess. The parameter is explicit
530    /// rather than defaulted precisely because a wrong stage here is silent:
531    /// it does not fail to compile, it just lets a reference through that
532    /// upstream refuses.
533    pub(crate) fn with(&self, name: Symbol<'s>, poly: PolyType, stage: Stage) -> TypeEnv<'s> {
534        let mut e = self.clone();
535        e.overlay_shadowed.insert(name);
536        e.overlay.insert(name, std::rc::Rc::new(EnvEntry { poly, stage }));
537        e.maybe_promote();
538        e
539    }
540
541    /// Install/refresh a BASE PRIMITIVE scheme without recording `name` in
542    /// `shadowed` — used ONLY by the two call sites that seed or refresh a
543    /// `PRIMITIVE_NAMES` entry directly (`base_type_env_with_version`,
544    /// `version_scoped_type_env`), as opposed to a real program binding
545    /// (`Ast::LetIn`/lambda param/pattern bind/`LetRecIn`/top-level decl),
546    /// which always goes through `with`/`with_all` instead. This is exactly
547    /// what lets `version_scoped_type_env` distinguish "still untouched
548    /// builtin" from "user-shadowed" (see its doc comment).
549    /// A primitive is `Persistent0`, so every stage may name it — upstream
550    /// registers the whole primitive table that way (`primitives.cppo.ml:596`,
551    /// `Typeenv.add tyenv varnm (pty, evid, Persistent0)`). Anything else
552    /// would make `\emph` unreachable from a `@stage: 0` library.
553    fn with_primitive(&self, name: Symbol<'s>, poly: PolyType) -> TypeEnv<'s> {
554        let mut e = self.clone();
555        e.overlay.insert(
556            name,
557            std::rc::Rc::new(EnvEntry {
558                poly,
559                stage: Stage::Persistent0,
560            }),
561        );
562        e.maybe_promote();
563        e
564    }
565
566    /// The raw slot. Every OCCURRENCE goes through `Checker::staged` instead,
567    /// which reads this and then enforces the staging matrix — there is
568    /// deliberately no stage-blind `get`, so a new reference site cannot
569    /// silently skip the check.
570    fn entry(&self, name: Symbol<'s>) -> Option<&EnvEntry> {
571        self.overlay
572            .get(&name)
573            .or_else(|| self.base.get(&name))
574            .map(|p| &**p)
575    }
576
577    /// Whether `name` was bound by a real program binding.
578    fn is_shadowed(&self, name: Symbol<'s>) -> bool {
579        self.overlay_shadowed.contains(&name) || self.base_shadowed.contains(&name)
580    }
581
582    /// Extend with each scheme in order (later shadows earlier) — the
583    /// canonical way to commit `infer_binding`'s result.
584    pub(crate) fn with_all(
585        &self,
586        schemes: Vec<(Symbol<'s>, PolyType)>,
587        stage: Stage,
588    ) -> TypeEnv<'s> {
589        let mut e = self.clone();
590        for (name, poly) in schemes {
591            e.overlay_shadowed.insert(name);
592            e.overlay
593                .insert(name, std::rc::Rc::new(EnvEntry { poly, stage }));
594            e.maybe_promote();
595        }
596        e
597    }
598
599    /// Remove a set of bindings: a parent seal revoking a
600    /// nested module's outer-hidden members at the parent's seal point — see
601    /// `v1/module_check.rs`'s `member_revoke_triggers`. V0_1-only. Flattens
602    /// both layers then removes — cold path, so the one-time flatten is fine.
603    #[allow(dead_code)]
604    pub(crate) fn without_all(&self, names: &[Symbol<'s>]) -> TypeEnv<'s> {
605        let mut vars: HashMap<Symbol<'s>, std::rc::Rc<EnvEntry>> = (*self.base).clone();
606        for (k, v) in &self.overlay {
607            vars.insert(*k, v.clone());
608        }
609        let mut sh = (*self.base_shadowed).clone();
610        sh.extend(self.overlay_shadowed.iter().copied());
611        for n in names {
612            vars.remove(n);
613            sh.remove(n);
614        }
615        TypeEnv {
616            base: std::rc::Rc::new(vars),
617            overlay: HashMap::new(),
618            base_shadowed: std::rc::Rc::new(sh),
619            overlay_shadowed: std::collections::HashSet::new(),
620        }
621    }
622}
623
624// ============================================================================
625// Lowering CST `TypeExpr` (a `type` declaration's ctor payload syntax, a
626// synonym's own body, and a `sig .. end`'s `val` annotations — the last
627// parsed but not yet consulted, see `elaborate.rs`'s module doc comment) to
628// `MonoType`. The grammar
629// (`rustyfi_syntax::cst::ast::TypeExpr`/`TypeProd`/`TypeApp`/`TypeAtom`)
630// supports function arrows, parens, type variables, bare names, 2+-way
631// product types (`*`), and a SINGLE-argument postfix type-constructor
632// application (`'a option`, `'a list`) — no record/list-literal/command
633// types or N-ary applied constructors — so this lowering is total (never
634// fails) and needs no arity checking of its own. `list`/`ref` map to this
635// port's dedicated `MonoType::List`/`Ref` formers (mirroring
636// `prim_types::list`/`reff`); every other applied name becomes a
637// one-argument `MonoType::Variant`. A *synonym* reference is left exactly
638// as `name_to_mono` produces it (indistinguishable from an unresolved
639// variant name) — transparently replacing it with the synonym's body is
640// `expand_synonyms`'s job, below.
641// ============================================================================
642
643/// Map a `type` declaration's bare type name to a `MonoType`. Every base
644/// type this port's primitives use is recognized by its surface name;
645/// anything else becomes a nominal, zero-argument `Variant` reference — the
646/// only shape a bare name in this minimal grammar could sensibly mean (no
647/// applied-constructor syntax exists to give it arguments), which is exactly
648/// what makes mutually-recursive user variant types (`type t = .. of t`),
649/// forward references (a later declaration's name used by an earlier one),
650/// and type *synonyms* (a synonym reference is resolved the same nominal
651/// way — see `expand_synonyms`) "just work": the name is resolved nominally,
652/// not by looking anything up at lowering time.
653fn name_to_mono(name: &str, version: RustyfiVersion) -> MonoType {
654    match name {
655        "unit" => t_unit(),
656        "bool" => t_bool(),
657        "int" => t_int(),
658        "float" => t_float(),
659        "length" => t_length(),
660        "string" => t_string(),
661        "inline-text" => t_inline_text(),
662        "block-text" => t_block_text(),
663        // The surface-name fork lives HERE, and only
664        // here. `BaseType::MathText` is reused byte-identically as both
665        // 0.0.6's `math` and V0_1's `math-text` (upstream's own rename);
666        // `math-boxes` is V0_1-only, new. Under V0_1 the word `math` in a
667        // type position is deliberately NOT recognized (0.1 has no `math`
668        // type, `types.cppo.ml:148-155`) — it falls through to the nominal-
669        // `Variant` default below, matching upstream's unbound-type error;
670        // under V0_0, `math-text`/`math-boxes` likewise stay unbound.
671        "math" if !version.math_is_split() => MonoType::Base(BaseType::MathText),
672        "math-text" if version.math_is_split() => MonoType::Base(BaseType::MathText),
673        "math-boxes" if version.math_is_split() => MonoType::Base(BaseType::MathBoxes),
674        "inline-boxes" => t_inline_boxes(),
675        "block-boxes" => t_block_boxes(),
676        "context" => t_context(),
677        "document" => t_document(),
678        "text-info" => MonoType::Base(BaseType::TextInfo),
679        // The graphics- tier base/synonym types upstream's
680        // `types.cppo.ml` base_type_map registers (`pre-path`/`path`/
681        // `graphics`/`image`, plus `deco`/`deco-set`
682        // (`primitives.cppo.ml:275-276`), plus the pragmatic `font`
683        // stand-in — see below).
684        //
685        // `pre-path`/`path`/`graphics`/`image` resolve the SAME WAY under
686        // both versions: upstream maps all four to the same base types in
687        // both generations (0.0.6 `types.cppo.ml:295-298`, dev-0-1-0
688        // `types.cppo.ml:157`, neither cppo-gated), so the port's formers
689        // take no version either.
690        //
691        // The VALUE rep does differ upstream (0.0.6's `BCGraphics` holds one
692        // `GraphicD.element`, dev-0-1-0's a list — `graphics_is_collection`,
693        // and why 0.0.6's `deco` returns `graphics list` where 0.1's returns
694        // one `graphics`), but it's a subset relation, not an
695        // incompatibility: the port spells both with one
696        // `Value::Graphics(GraphicsElem)`, using `GraphicsElem::Group` for
697        // 0.1's collection form. 0.0.6 can only ever produce a leaf
698        // (`unite-graphics`, the sole `Group` constructor, is 0.1-only), and
699        // every consumer (`shift_graphics`, `graphics_bbox`, the PDF/SVG
700        // writers) handles `Group` uniformly, so values cross safely both
701        // ways.
702        //
703        // Do NOT gate these four on `V0_1`: a gate makes `name_to_mono`
704        // disagree across versions, which is exactly what
705        // `forked_type_names()` (below) diffs — all four would report as
706        // forked, and the boundary guard would then reject every 0.1
707        // document importing a 0.0.6 dependency that so much as mentions
708        // `graphics` in export position.
709        //
710        // Deliberately NOT ungated: `deco`/`deco-set` (their EXPANSION really
711        // does fork; a value-level adapter handles it instead), `font` and `paren`
712        // (stand-ins, see below).
713        "pre-path" => t_prepath(),
714        "path" => t_path(),
715        "graphics" => t_graphics(),
716        "image" => t_image(),
717        "deco" if version == RustyfiVersion::V0_1 => t_deco(version),
718        "deco-set" if version == RustyfiVersion::V0_1 => t_decoset(version),
719        // `font` — a REAL base type under V0_1. Do NOT make it `t_string()`
720        // again: that stand-in made `font * float * float` in a 0.1 sig
721        // accidentally coincide with 0.0.6's `string * float * float`,
722        // so the cross-version boundary accepted a coincidence. Upstream
723        // `saphe-split` registers `("font", FontType)`
724        // (`types.cppo.ml`'s `base_type_hash_table:175`, spelled
725        // `tFONTKEY` at `primitives.cppo.ml:45`); its values are opaque
726        // `BCFontKey of FontKey.t` handles a font envelope mints
727        // (`envelopeChecker.ml`'s `check_font_envelope`) — `t_font_key()`/
728        // `Value::Font(FontKey)` are that type/value.
729        //
730        // Under `V0_0` the name keeps falling through to the nominal
731        // `Variant("font", [])` default: upstream 0.0.6 has no `font` type
732        // (no such row in `types.cppo.ml:280-303`, no `type font` in its
733        // bundled `.satyh` packages), so a 0.0.6 program writing `font`
734        // means an unrelated opaque user nominal — why it stays in
735        // `forked_type_names()` and does not cross
736        // (`v1::xver_adapt::forked_note` states the fork).
737        "font" if version == RustyfiVersion::V0_1 => t_font_key(),
738        // math-package completion: upstream's sig writes `val paren-left
739        // : paren` (+18 more) and `val angle-left : length -> paren`.
740        // Structural like `deco`/`deco-set` above — the sealed decl's
741        // declared type unifies with `paren-left`/`-right`'s inferred
742        // `length -> length -> context -> inline-boxes * (length ->
743        // length)` by construction. Under V0_0, `paren` keeps falling to
744        // the nominal `Variant("paren", [])` default (synonym-expansion via
745        // `pervasives.satyh`, unchanged).
746        "paren" if version == RustyfiVersion::V0_1 => t_paren(version),
747        other => MonoType::Variant(other.to_string(), Vec::new()),
748    }
749}
750
751// ============================================================================
752// Forked-name guard: builtin TYPE
753// names that resolve differently — or not at all — between `V0_0` and
754// `V0_1`. Builtin TYPE forks have no dedicated table — they live as inline
755// version guards in `primitive_type_with_version` (`prim_types.rs`) and
756// `name_to_mono` (just above) — so `forked_type_names` derives the set by
757// literally diffing their output per name, rather than filtering a table.
758// ============================================================================
759
760/// Structural (alpha-equivalence) comparison of two [`MonoType`]s, used by
761/// [`forked_type_names`]'s diff below. Plain `MonoType`/`PolyType` derive no
762/// `PartialEq` (a hard constraint keeps `types.rs`/`unify.rs`
763/// untouched), and even if
764/// they did, a naive field compare would be WRONG here: every polymorphic
765/// primitive's scheme mints brand-new [`TyVarRef`](crate::types::TyVarRef)s
766/// from a process-wide counter (`types.rs`'s `FRESH_ID`) on each call, so two
767/// calls to the very SAME (unforked) primitive under two DIFFERENT versions
768/// already carry different fresh-variable identities and would falsely
769/// "differ" under any identity-sensitive comparison. This instead walks both
770/// types in lockstep, building a positional bijection between the two sides'
771/// variables (keyed by `ptr_key()`, `types.rs`'s own union-find identity) —
772/// two types are equal iff they have the same shape up to a CONSISTENT
773/// renaming of variables, exactly "the same type" should mean here.
774fn mono_type_alpha_eq(a: &MonoType, b: &MonoType) -> bool {
775    let mut vmap = HashMap::new();
776    let mut vmap_rev = HashMap::new();
777    let mut rmap = HashMap::new();
778    let mut rmap_rev = HashMap::new();
779    mono_alpha_eq(a, b, &mut vmap, &mut vmap_rev, &mut rmap, &mut rmap_rev)
780}
781
782fn mono_alpha_eq(
783    a: &MonoType,
784    b: &MonoType,
785    vmap: &mut HashMap<usize, usize>,
786    vmap_rev: &mut HashMap<usize, usize>,
787    rmap: &mut HashMap<usize, usize>,
788    rmap_rev: &mut HashMap<usize, usize>,
789) -> bool {
790    match (&*resolve(a), &*resolve(b)) {
791        (MonoType::Var(va), MonoType::Var(vb)) => {
792            bijective_pair(va.ptr_key(), vb.ptr_key(), vmap, vmap_rev)
793        }
794        (MonoType::Base(ba), MonoType::Base(bb)) => ba == bb,
795        (MonoType::Func(ra, da, ca), MonoType::Func(rb, db, cb)) => {
796            row_alpha_eq(&ra, &rb, vmap, vmap_rev, rmap, rmap_rev)
797                && mono_alpha_eq(&da, &db, vmap, vmap_rev, rmap, rmap_rev)
798                && mono_alpha_eq(&ca, &cb, vmap, vmap_rev, rmap, rmap_rev)
799        }
800        (MonoType::Product(ta), MonoType::Product(tb)) => {
801            ta.len() == tb.len()
802                && ta
803                    .iter()
804                    .zip(tb.iter())
805                    .all(|(x, y)| mono_alpha_eq(x, y, vmap, vmap_rev, rmap, rmap_rev))
806        }
807        (MonoType::List(ta), MonoType::List(tb))
808        | (MonoType::Ref(ta), MonoType::Ref(tb))
809        | (MonoType::Code(ta), MonoType::Code(tb)) => {
810            mono_alpha_eq(&ta, &tb, vmap, vmap_rev, rmap, rmap_rev)
811        }
812        (MonoType::Record(ra), MonoType::Record(rb)) => {
813            row_alpha_eq(&ra, &rb, vmap, vmap_rev, rmap, rmap_rev)
814        }
815        (MonoType::Variant(na, aa), MonoType::Variant(nb, ab)) => {
816            na == nb
817                && aa.len() == ab.len()
818                && aa
819                    .iter()
820                    .zip(ab.iter())
821                    .all(|(x, y)| mono_alpha_eq(x, y, vmap, vmap_rev, rmap, rmap_rev))
822        }
823        (MonoType::InlineCmd(ca), MonoType::InlineCmd(cb))
824        | (MonoType::BlockCmd(ca), MonoType::BlockCmd(cb))
825        | (MonoType::MathCmd(ca), MonoType::MathCmd(cb)) => {
826            cmd_args_alpha_eq(&ca, &cb, vmap, vmap_rev, rmap, rmap_rev)
827        }
828        _ => false,
829    }
830}
831
832fn row_alpha_eq(
833    a: &Row,
834    b: &Row,
835    vmap: &mut HashMap<usize, usize>,
836    vmap_rev: &mut HashMap<usize, usize>,
837    rmap: &mut HashMap<usize, usize>,
838    rmap_rev: &mut HashMap<usize, usize>,
839) -> bool {
840    match (&*resolve_row(a), &*resolve_row(b)) {
841        (Row::Empty, Row::Empty) => true,
842        (Row::Var(va), Row::Var(vb)) => bijective_pair(va.ptr_key(), vb.ptr_key(), rmap, rmap_rev),
843        (Row::Cons(la, ta, ra), Row::Cons(lb, tb, rb)) => {
844            la == lb
845                && mono_alpha_eq(&ta, &tb, vmap, vmap_rev, rmap, rmap_rev)
846                && row_alpha_eq(&ra, &rb, vmap, vmap_rev, rmap, rmap_rev)
847        }
848        _ => false,
849    }
850}
851
852fn cmd_args_alpha_eq(
853    a: &[CmdArgType],
854    b: &[CmdArgType],
855    vmap: &mut HashMap<usize, usize>,
856    vmap_rev: &mut HashMap<usize, usize>,
857    rmap: &mut HashMap<usize, usize>,
858    rmap_rev: &mut HashMap<usize, usize>,
859) -> bool {
860    a.len() == b.len()
861        && a.iter().zip(b.iter()).all(|(ca, cb)| {
862            ca.optional == cb.optional
863                && ca.opt_labels.len() == cb.opt_labels.len()
864                && ca
865                    .opt_labels
866                    .iter()
867                    .zip(cb.opt_labels.iter())
868                    .all(|((la, tya), (lb, tyb))| {
869                        la == lb && mono_alpha_eq(tya, tyb, vmap, vmap_rev, rmap, rmap_rev)
870                    })
871                && mono_alpha_eq(&ca.ty, &cb.ty, vmap, vmap_rev, rmap, rmap_rev)
872        })
873}
874
875/// Record (or check) that pointer-key `ka` (side A) and `kb` (side B)
876/// correspond under the bijection being built; `false` if either side is
877/// already mapped to something else (a genuine structural mismatch).
878fn bijective_pair(
879    ka: usize,
880    kb: usize,
881    map: &mut HashMap<usize, usize>,
882    map_rev: &mut HashMap<usize, usize>,
883) -> bool {
884    match (map.get(&ka).copied(), map_rev.get(&kb).copied()) {
885        (Some(mapped_b), Some(mapped_a)) => mapped_b == kb && mapped_a == ka,
886        (None, None) => {
887            map.insert(ka, kb);
888            map_rev.insert(kb, ka);
889            true
890        }
891        _ => false,
892    }
893}
894
895/// Every name that means a DIFFERENT TYPE under `V0_0` than under `V0_1`,
896/// i.e. whose `name_to_mono` lowering differs. Its sole consumer is the
897/// cross-version type-boundary guard (`v1::xver_adapt::reject_type_names`,
898/// via `lib.rs`'s `free.types` check): if a 0.0.6 dependency writes this
899/// name in a type, does the 0.1 consumer read it as the same type?
900///
901/// Do NOT also admit names whose PRIMITIVE scheme forks
902/// (`primitive_type_with_version`). `PRIMITIVE_NAMES` is a list of VALUE
903/// names, none of which is a type, but there is a real collision:
904/// `math-char-class` is both a builtin type (nominal, version-blind) and a
905/// primitive whose scheme mentions the genuinely-forked `math`. Pulling the
906/// TYPE name in anyway makes the guard reject `math.satyh`'s perfectly safe
907/// sig mention (`\math-style : [math-char-class; math] math-cmd`) and,
908/// through it, every 0.1 document reaching the 0.0.6 math package: only
909/// its constructor set forks, so it stays out of this guard's reject set.
910///
911/// A forked primitive VALUE is not this guard's problem: each
912/// 0.0.6 dependency's bindings are wrapped in `Ast::VersionScope(V0_0, _)`, so a forked
913/// primitive referenced inside one resolves against 0.0.6's own `PrimDef`
914/// and runs under `Interp::version = V0_0` — why `lib.rs` has no value half
915/// of the guard. The names below are just the builtin TYPE names, filtered
916/// to those that really do lower differently.
917pub(crate) fn forked_type_names() -> BTreeSet<String> {
918    [
919        "math",
920        "math-text",
921        "math-boxes",
922        "pre-path",
923        "path",
924        "graphics",
925        "image",
926        "deco",
927        "deco-set",
928        "font",
929        "paren",
930        "math-char-class",
931    ]
932    .into_iter()
933    .filter(|&n| {
934        !mono_type_alpha_eq(
935            &name_to_mono(n, RustyfiVersion::V0_0),
936            &name_to_mono(n, RustyfiVersion::V0_1),
937        )
938    })
939    .map(str::to_string)
940    .collect()
941}
942
943fn lower_type_atom(
944    atom: &TypeAtom,
945    tyvars: &HashMap<String, MonoType>,
946    version: RustyfiVersion,
947) -> MonoType {
948    match atom {
949        // `[ty; ty?; ..] inline-cmd`/`block-cmd`/`math-cmd` — the direct
950        // wire-up to the existing `CmdArgType.optional` field:
951        // each bracketed element lowers to one `CmdArgType`,
952        // `optional` set exactly when the element carried a trailing `?`.
953        TypeAtom::Cmd { args, kind, .. } => {
954            let cmd_args: Vec<CmdArgType> = args
955                .iter()
956                .map(|a| {
957                    let ty = lower_type_expr(&a.ty, tyvars, version);
958                    // SATySFi 0.1 closed command optional-label map:
959                    // `?(l:τ,…)` prefixing
960                    // this slot's mandatory `ty`. Sorted by label so
961                    // `unify_cmd_args`'s zip-equal equal-domain test is
962                    // order-insensitive against whatever surface order the
963                    // sig was written in (`command_scheme`'s harvest sorts
964                    // the same way). Every 0.0.6-reachable item has
965                    // `opt_labels == []` (that grammar has no such prefix at
966                    // all), so it takes the `optional`/`mandatory` split on
967                    // the 0.0.6 positional `?` suffix marker instead.
968                    if a.opt_labels.is_empty() {
969                        if a.opt.is_some() {
970                            optional(ty)
971                        } else {
972                            mandatory(ty)
973                        }
974                    } else {
975                        let mut labels: Vec<(String, MonoType)> = a
976                            .opt_labels
977                            .iter()
978                            .map(|f| {
979                                (
980                                    f.label.name.clone(),
981                                    lower_type_expr(&f.ty, tyvars, version),
982                                )
983                            })
984                            .collect();
985                        labels.sort_by(|x, y| x.0.cmp(&y.0));
986                        labeled(labels, ty)
987                    }
988                })
989                .collect();
990            match kind {
991                CmdTypeKind::Inline(_) => MonoType::InlineCmd(cmd_args),
992                CmdTypeKind::Block(_) => MonoType::BlockCmd(cmd_args),
993                CmdTypeKind::Math(_) => MonoType::MathCmd(cmd_args),
994            }
995        }
996        TypeAtom::Paren { inner, .. } => lower_type_expr(inner, tyvars, version),
997        // `(| l1 : ty1; l2 : ty2; … |)` — a CLOSED record type: fold the
998        // fields into a `Row::Cons` chain (in source order) ending in
999        // `Row::Empty`, matching `MonoType::Record`'s row representation
1000        // (`types.rs`'s module doc comment) — distinct from `RecordKind`'s
1001        // label-only `Kind::Record` bound (`lower_record_kind`, below),
1002        // which drops field types entirely; a type-position record keeps
1003        // them, since it's a concrete type, not a lower-bound obligation.
1004        TypeAtom::Record { fields, .. } => {
1005            let row = fields.iter().rev().fold(Row::Empty, |rest, f| {
1006                Row::Cons(
1007                    f.name.name.clone(),
1008                    Box::new(lower_type_expr(&f.ty, tyvars, version)),
1009                    Box::new(rest),
1010                )
1011            });
1012            MonoType::Record(row)
1013        }
1014        // `(| l1 : ty1, … | ?'r |)` — a SATySFi 0.1 OPEN record type:
1015        // same fold as the closed form
1016        // above, but the row's TAIL is a fresh `Row::Var` rather than
1017        // `Row::Empty` — reusing the existing generic `Row`/`RowVarRef`/
1018        // `unify_row` machinery (no new type machinery). The row variable's
1019        // *name* (`?'r`) is not itself tracked anywhere past this point (the
1020        // same permissive-fallback philosophy `TypeAtom::Var`'s "should not
1021        // happen" case above already uses for an untracked name) — this
1022        // models one open record type at a time, not
1023        // cross-signature shared-row polymorphism (that needs the `rowquant`
1024        // grammar this track deliberately defers, see `cst.rs`'s
1025        // `TypeAtom::RecordOpen` doc comment). No `V0_0` version gate is
1026        // needed: `TypeAtom::RecordOpen` is unreachable from a `V0_0` token
1027        // stream by construction (see that variant's doc comment).
1028        TypeAtom::RecordOpen { inner, .. } => {
1029            let row = inner
1030                .fields
1031                .iter()
1032                .rev()
1033                .fold(Row::Var(types::new_row_var(0)), |rest, f| {
1034                    Row::Cons(
1035                        f.name.name.clone(),
1036                        Box::new(lower_type_expr(&f.ty, tyvars, version)),
1037                        Box::new(rest),
1038                    )
1039                });
1040            MonoType::Record(row)
1041        }
1042        TypeAtom::Var(tv) => match tyvars.get(&tv.name) {
1043            Some(v) => v.clone(),
1044            // PERMISSIVE: a type variable not among the declaration's own
1045            // `tyvars` (should not happen for anything the parser accepts,
1046            // since `TypeAtom::Var` can only ever spell one of them here —
1047            // there is no scoping construct in this grammar that could
1048            // introduce any other free type variable) — treat it as its own
1049            // fresh, ungeneralized variable rather than rejecting the whole
1050            // declaration.
1051            None => MonoType::Var(types::new_ty_var(0)),
1052        },
1053        TypeAtom::Name(name) => name_to_mono(&name.name, version),
1054        // `Mod.t` (bare, 0-ary): full module-signature-aware resolution is
1055        // out of scope for this port's parser-level fix (`cst.rs`'s
1056        // `TypeAtom::NameMod` doc comment) — permissively treat it as its
1057        // own nominal type keyed by the qualified spelling, exactly like
1058        // `name_to_mono`'s own fallback for an unregistered unqualified
1059        // name (`MonoType::Variant(name, [])`).
1060        TypeAtom::NameMod(qn) => {
1061            MonoType::Variant(format!("{}.{}", qn.mods.join("."), qn.name), Vec::new())
1062        }
1063    }
1064}
1065
1066/// `txprod`: a [`TypeProd`] is either a single [`TypeApp`] (returned as-is)
1067/// or a genuine `*`-separated product (`MonoType::Product`, always 2+ items
1068/// by construction — see [`prim_types::product`]).
1069fn lower_type_prod(
1070    prod: &TypeProd,
1071    tyvars: &HashMap<String, MonoType>,
1072    version: RustyfiVersion,
1073) -> MonoType {
1074    if prod.rest.is_empty() {
1075        lower_type_app(&prod.first, tyvars, version)
1076    } else {
1077        let mut items = Vec::with_capacity(1 + prod.rest.len());
1078        items.push(lower_type_app(&prod.first, tyvars, version));
1079        for st in &prod.rest {
1080            items.push(lower_type_app(&st.ty, tyvars, version));
1081        }
1082        product(items)
1083    }
1084}
1085
1086/// `txapppre`/`txapp` (restricted to a single argument — see [`TypeApp`]'s
1087/// doc comment): either a bare atom, or one atom applied to a single postfix
1088/// type-constructor name (`'a option`, `('a list) list`).
1089fn lower_type_app(
1090    app: &TypeApp,
1091    tyvars: &HashMap<String, MonoType>,
1092    version: RustyfiVersion,
1093) -> MonoType {
1094    // A bare atom (no postfix constructor).
1095    if app.rest.is_empty() {
1096        return lower_type_atom(&app.head, tyvars, version);
1097    }
1098    // `arg1 … argN ctor`: the last atom is the constructor, the rest are its
1099    // arguments (see `TypeApp`'s doc comment).
1100    let (ctor, args): (&TypeAtom, Vec<MonoType>) = {
1101        let n = app.rest.len();
1102        let arg_tys = std::iter::once(&app.head)
1103            .chain(app.rest[..n - 1].iter())
1104            .map(|a| lower_type_atom(a, tyvars, version))
1105            .collect();
1106        (&app.rest[n - 1], arg_tys)
1107    };
1108    match ctor {
1109        TypeAtom::Name(name) => {
1110            let single = if args.len() == 1 {
1111                Some(args[0].clone())
1112            } else {
1113                None
1114            };
1115            match name.name.as_str() {
1116                "list" if single.is_some() => list(single.unwrap()),
1117                "ref" if single.is_some() => reff(single.unwrap()),
1118                // `code τ` — the staged-value type, spelled in SOURCE only by
1119                // 0.1 (`dev-0-1-0 src/frontend/manualTypeDecoder.ml:31-36`,
1120                // decoded as a one-argument application right beside `list`
1121                // at `:37-42` and `ref` at `:44-49`). 0.0.6's own manual-type
1122                // decoder (`v0.0.6 src/frontend/typeenv.ml:527-530`) knows
1123                // only `list` and `ref`, so under `V0_0` an `int code`
1124                // annotation stays what it has always been here: an unknown
1125                // nominal `Variant("code", [int])`, which unifies with
1126                // nothing and is refused just as upstream's
1127                // `UndefinedTypeName` refuses it.
1128                //
1129                // 0.1 writes types PREFIX (`code int`); `v1/lower.rs` has
1130                // already flipped that into this cst's postfix
1131                // `TypeApp { head, rest }` shape by the time it arrives.
1132                "code" if single.is_some() && version.has_code_type_syntax() => {
1133                    MonoType::Code(Box::new(single.unwrap()))
1134                }
1135                // `T implicit` — `satysfi-base`'s typeclass-dictionary marker —
1136                // is transparent: an implicit `T` argument just has type `T`.
1137                "implicit" if single.is_some() => single.unwrap(),
1138                other => MonoType::Variant(other.to_string(), args),
1139            }
1140        }
1141        // `… Mod.t` — permissive nominal-type fallback, qualified-name-keyed
1142        // (see `lower_type_atom`'s `NameMod` arm).
1143        TypeAtom::NameMod(qn) => {
1144            MonoType::Variant(format!("{}.{}", qn.mods.join("."), qn.name), args)
1145        }
1146        // A non-name final atom is not a real constructor (unreachable in valid
1147        // source); lower it alone to keep this total.
1148        _ => lower_type_atom(ctor, tyvars, version),
1149    }
1150}
1151
1152/// `dom -> cod`, with `?->`'s optional-argument prefix (`opts`) folded in as
1153/// leading `option`-wrapped mandatory domains — a stand-in:
1154/// `config ?-> block-text -> document` lowers to `Func(option(config),
1155/// Func(block-text, document))`, exactly the shape the call-site model
1156/// produces (`Some`/`None` applied to a plain,
1157/// `option`-typed domain — see `elaborate.rs`'s `app_arg_to_ast`) — one
1158/// consistent optional-arg model. Not upstream's real
1159/// `option_row`/arity-changing encoding; this only needs the two encodings
1160/// to *unify*, which a plain `option` domain already does. `pub(crate)`:
1161/// `v1/module_check.rs`
1162/// reuses this exact lowering for a sig `val`'s declared type, twice per decl
1163/// (skolemize-by-lowering — a flexible-var map for the committed scheme, a
1164/// rigid-stamp map for the subsumption check).
1165pub(crate) fn lower_type_expr(
1166    ty: &TypeExpr,
1167    tyvars: &HashMap<String, MonoType>,
1168    version: RustyfiVersion,
1169) -> MonoType {
1170    match ty {
1171        TypeExpr::Fun { opts, dom, cod, .. } => {
1172            let result = arrow(
1173                lower_type_prod(dom, tyvars, version),
1174                lower_type_expr(cod, tyvars, version),
1175            );
1176            opts.iter().rev().fold(result, |acc, opt| {
1177                arrow(t_option(lower_type_prod(&opt.ty, tyvars, version)), acc)
1178            })
1179        }
1180        TypeExpr::Atom(prod) => lower_type_prod(prod, tyvars, version),
1181        // `?(l1 : ty1, …) dom -> cod` — a
1182        // CLOSED row (`Row::Cons(l1, ty1, … Row::Empty)`), matching what
1183        // `Ast::LambdaOpt` infers — see this fn's own callers
1184        // (`declare_synonym`/`build_variant_decl`), which reject this node
1185        // under `V0_0` via `check_type_expr_v0_1_only` BEFORE ever
1186        // reaching here, so by the time this arm runs the version is always
1187        // `V0_1` for any input that could legally have parsed this node from
1188        // real 0.1 source (a 0.0.6 source hitting this arm is caught by that
1189        // earlier gate, with a clear version-error message rather than
1190        // silently building a nonsense type here).
1191        TypeExpr::OptRowFun {
1192            opt_dom, dom, cod, ..
1193        } => {
1194            let row = opt_dom.entries.iter().rev().fold(Row::Empty, |acc, e| {
1195                Row::Cons(
1196                    e.label.name.clone(),
1197                    Box::new(lower_type_expr(&e.ty, tyvars, version)),
1198                    Box::new(acc),
1199                )
1200            });
1201            MonoType::Func(
1202                Box::new(row),
1203                Box::new(lower_type_prod(dom, tyvars, version)),
1204                Box::new(lower_type_expr(cod, tyvars, version)),
1205            )
1206        }
1207    }
1208}
1209
1210/// Reject a `?(l : ty) -> ...`
1211/// labeled-optional-argument type domain under `V0_0` with a clear version
1212/// error, mirroring `elaborate.rs`'s `Expr::FunRows`/`AppArg::Bundled`
1213/// value-level gates — the TYPE-level analogue. It lives
1214/// here (not `elaborate.rs`) because a `type`/ctor-payload `TypeExpr` is
1215/// never routed through the elaborator at all: `UserTypeDecl`/
1216/// `UserSynonymDecl` (`elaborate.rs`) carry a raw CST `TypeExpr` fragment
1217/// straight through to [`Checker::declare_variant`]/[`Checker::
1218/// declare_synonym`], the first (and only) place `Checker.version` is in
1219/// scope for it. A cheap existence walk, not a lowering pass —
1220/// [`lower_type_expr`] itself stays total/infallible for every other caller
1221/// (its own doc comment) — this is called BEFORE it, at each dual-version
1222/// entry point.
1223fn check_type_expr_v0_1_only(ty: &TypeExpr, version: RustyfiVersion) -> Result<(), TypeError> {
1224    if version.has_row_polymorphism() {
1225        return Ok(());
1226    }
1227    if let Some(span) = find_opt_row_fun_in_expr(ty) {
1228        return Err(TypeError::simple(
1229            Some(span),
1230            "`?(l : ty) -> ...` labeled-optional-argument type domains are SATySFi \
1231             0.1 syntax — this file is compiled as 0.0.6",
1232        ));
1233    }
1234    Ok(())
1235}
1236
1237fn find_opt_row_fun_in_expr(ty: &TypeExpr) -> Option<Span> {
1238    match ty {
1239        TypeExpr::OptRowFun { opt_dom, .. } => Some(opt_dom.q.0),
1240        TypeExpr::Fun { dom, cod, .. } => {
1241            find_opt_row_fun_in_prod(dom).or_else(|| find_opt_row_fun_in_expr(cod))
1242        }
1243        TypeExpr::Atom(p) => find_opt_row_fun_in_prod(p),
1244    }
1245}
1246
1247fn find_opt_row_fun_in_prod(p: &TypeProd) -> Option<Span> {
1248    find_opt_row_fun_in_app(&p.first)
1249        .or_else(|| p.rest.iter().find_map(|s| find_opt_row_fun_in_app(&s.ty)))
1250}
1251
1252fn find_opt_row_fun_in_app(a: &TypeApp) -> Option<Span> {
1253    std::iter::once(&a.head)
1254        .chain(a.rest.iter())
1255        .find_map(find_opt_row_fun_in_atom)
1256}
1257
1258fn find_opt_row_fun_in_atom(a: &TypeAtom) -> Option<Span> {
1259    match a {
1260        TypeAtom::Cmd { args, .. } => args.iter().find_map(|it| find_opt_row_fun_in_expr(&it.ty)),
1261        TypeAtom::Paren { inner, .. } => find_opt_row_fun_in_expr(inner),
1262        TypeAtom::Record { fields, .. } => {
1263            fields.iter().find_map(|f| find_opt_row_fun_in_expr(&f.ty))
1264        }
1265        TypeAtom::RecordOpen { inner, .. } => inner
1266            .fields
1267            .iter()
1268            .find_map(|f| find_opt_row_fun_in_expr(&f.ty)),
1269        TypeAtom::Var(_) | TypeAtom::Name(_) | TypeAtom::NameMod(_) => None,
1270    }
1271}
1272
1273// ============================================================================
1274// Signature items: the `constraint 'a :: (| l1; l2; … |)`
1275// per-item suffix.
1276// ============================================================================
1277
1278/// Every distinct type-variable name occurring in `ty`, in first-occurrence
1279/// order. Unlike a `type` declaration, a [`SigItem`] has no upfront `tyvars`
1280/// list (`val document : 'a -> …` names `'a` inline, mid-type), so building
1281/// its lowering's `tyvars` map requires walking the type first — every
1282/// occurrence of the same name must resolve to the *same* fresh variable,
1283/// which is also the one a matching `constraint` suffix (naming that same
1284/// `'a`) attaches its `Kind::Record` bound to.
1285///
1286/// `#[allow(dead_code)]`: only exercised by this module's own tests today —
1287/// no sig-enforcement pass calls [`lower_sig_item`] yet.
1288#[allow(dead_code)]
1289fn collect_type_vars(ty: &TypeExpr, out: &mut Vec<String>) {
1290    fn push(name: &str, out: &mut Vec<String>) {
1291        if !out.iter().any(|n| n == name) {
1292            out.push(name.to_string());
1293        }
1294    }
1295    fn walk_atom(atom: &TypeAtom, out: &mut Vec<String>) {
1296        match atom {
1297            TypeAtom::Cmd { args, .. } => {
1298                for a in args {
1299                    walk_expr(&a.ty, out);
1300                }
1301            }
1302            TypeAtom::Paren { inner, .. } => walk_expr(inner, out),
1303            TypeAtom::Record { fields, .. } => {
1304                for f in fields {
1305                    walk_expr(&f.ty, out);
1306                }
1307            }
1308            TypeAtom::Var(tv) => push(&tv.name, out),
1309            TypeAtom::Name(_) => {}
1310            TypeAtom::NameMod(_) => {}
1311            // An open record's fields carry
1312            // type vars same as a closed record's; its row-variable tail
1313            // (`?'r`) is a ROW var, a different namespace this fn doesn't
1314            // track (it collects `TypeAtom::Var`/`'a`-style tyvars only).
1315            TypeAtom::RecordOpen { inner, .. } => {
1316                for f in &inner.fields {
1317                    walk_expr(&f.ty, out);
1318                }
1319            }
1320        }
1321    }
1322    fn walk_app(app: &TypeApp, out: &mut Vec<String>) {
1323        walk_atom(&app.head, out);
1324        for a in &app.rest {
1325            walk_atom(a, out);
1326        }
1327    }
1328    fn walk_prod(prod: &TypeProd, out: &mut Vec<String>) {
1329        walk_app(&prod.first, out);
1330        for st in &prod.rest {
1331            walk_app(&st.ty, out);
1332        }
1333    }
1334    fn walk_expr(ty: &TypeExpr, out: &mut Vec<String>) {
1335        match ty {
1336            TypeExpr::Fun { opts, dom, cod, .. } => {
1337                for opt in opts {
1338                    walk_prod(&opt.ty, out);
1339                }
1340                walk_prod(dom, out);
1341                walk_expr(cod, out);
1342            }
1343            TypeExpr::Atom(prod) => walk_prod(prod, out),
1344            // A labeled-optional domain's
1345            // entry types can mention tyvars same as any other domain.
1346            TypeExpr::OptRowFun {
1347                opt_dom, dom, cod, ..
1348            } => {
1349                for e in &opt_dom.entries {
1350                    walk_expr(&e.ty, out);
1351                }
1352                walk_prod(dom, out);
1353                walk_expr(cod, out);
1354            }
1355        }
1356    }
1357    walk_expr(ty, out);
1358}
1359
1360/// Lower a [`RecordKind`]'s field list to its label set, dropping field
1361/// *types* — `Kind::Record` (`types.rs`) stores labels only, so
1362/// `constraint 'a :: (| title : inline-text; … |)` checks label
1363/// *presence*, not the field's declared type. A documented
1364/// limitation, not a grammar gap: the
1365/// impl's row still gets its own field types from ordinary usage, so this
1366/// is unlikely to admit a wrong program in practice.
1367#[allow(dead_code)]
1368fn lower_record_kind(rk: &RecordKind) -> BTreeSet<String> {
1369    rk.fields.iter().map(|f| f.name.name.clone()).collect()
1370}
1371
1372/// Lower one value/direct [`SigItem`] to its name and [`MonoType`],
1373/// attaching each `constraint 'a :: (| … |)` suffix as a `Kind::Record`
1374/// bound on `'a`'s freshly-minted variable — "this variable must be a
1375/// record containing at least these labels", built on the existing
1376/// Rémy-style row machinery. Returns `None` for a bare `SigItem::Type`
1377/// item (a type *name* declaration, not a value with a `MonoType` of its
1378/// own).
1379///
1380/// **The obligation check itself rides on existing code, for free.** Once
1381/// this variable is ever unified against a concrete `MonoType::Record`
1382/// (which is exactly what enforcing the signature against a real `struct`
1383/// implementation would do), `unify::bind_var`'s `Kind::Record` branch
1384/// already rejects a row missing any declared label via
1385/// `row_require_label`.
1386#[allow(dead_code)]
1387pub(crate) fn lower_sig_item(
1388    item: &SigItem,
1389    ctx: &mut TypeContext,
1390    version: RustyfiVersion,
1391) -> Option<(String, MonoType)> {
1392    let (name, ty, constraints): (&str, &TypeExpr, &[SigConstraint]) = match item {
1393        SigItem::ValHorzCmd {
1394            name,
1395            ty,
1396            constraints,
1397            ..
1398        } => (&name.name, ty, constraints),
1399        SigItem::ValVertCmd {
1400            name,
1401            ty,
1402            constraints,
1403            ..
1404        } => (&name.name, ty, constraints),
1405        SigItem::Val {
1406            name,
1407            ty,
1408            constraints,
1409            ..
1410        } => (&name.name, ty, constraints),
1411        SigItem::DirectHorzCmd {
1412            name,
1413            ty,
1414            constraints,
1415            ..
1416        } => (&name.name, ty, constraints),
1417        SigItem::DirectVertCmd {
1418            name,
1419            ty,
1420            constraints,
1421            ..
1422        } => (&name.name, ty, constraints),
1423        SigItem::Type { .. } => return None,
1424    };
1425    let mut names = Vec::new();
1426    collect_type_vars(ty, &mut names);
1427    let mut tyvars = HashMap::new();
1428    for n in names {
1429        let found = constraints.iter().find(|c| c.tyvar.name == n);
1430        let v = match found {
1431            Some(c) => ctx.fresh_var_with_kind(Kind::Record(lower_record_kind(&c.kind))),
1432            None => ctx.fresh_var_with_kind(Kind::Universal),
1433        };
1434        tyvars.insert(n, MonoType::Var(v));
1435    }
1436    Some((name.to_string(), lower_type_expr(ty, &tyvars, version)))
1437}
1438
1439/// Lower one [`UserTypeDecl`] (surfaced by `elaborate::elaborate_program`)
1440/// into a [`VariantDecl`], the same shape `prim_types::builtin_variants_with_version`
1441/// produces for `option`/`itemize` — see that struct's doc comment for how
1442/// `param_vars` and `instantiate_ctor` fit together. Each ctor's payload is
1443/// passed through [`expand_synonyms`] so a payload that names a synonym
1444/// (`type wrap = | W of point`) is stored already-transparent — `unify`
1445/// never has to know synonyms exist.
1446fn build_variant_decl(
1447    decl: &UserTypeDecl,
1448    synonyms: &HashMap<String, SynonymDecl>,
1449    version: RustyfiVersion,
1450) -> Result<VariantDecl, TypeError> {
1451    let param_vars: Vec<types::TyVarRef> =
1452        decl.params.iter().map(|_| types::new_ty_var(0)).collect();
1453    let tyvar_map: HashMap<String, MonoType> = decl
1454        .params
1455        .iter()
1456        .cloned()
1457        .zip(param_vars.iter().cloned().map(MonoType::Var))
1458        .collect();
1459    let mut ctors = Vec::with_capacity(decl.ctors.len());
1460    for (name, ty) in &decl.ctors {
1461        let payload = match ty {
1462            None => None,
1463            Some(t) => {
1464                check_type_expr_v0_1_only(t, version)?;
1465                Some(expand_synonyms(
1466                    &lower_type_expr(t, &tyvar_map, version),
1467                    synonyms,
1468                )?)
1469            }
1470        };
1471        ctors.push((name.clone(), payload));
1472    }
1473    Ok(VariantDecl {
1474        name: decl.name.clone(),
1475        params: decl.params.len(),
1476        ctors,
1477        param_vars,
1478    })
1479}
1480
1481// ============================================================================
1482// Type synonyms (`type point = length * length`): registration, plus the
1483// transparent expansion that keeps a synonym's name from ever reaching
1484// `unify` — mirrors upstream's `SynonymType`/`add_synonym`
1485// (`typechecker.ml`), just against this port's `MonoType`/`substitute`
1486// machinery instead of a substitution-on-the-fly unifier case.
1487// ============================================================================
1488
1489/// A user type-synonym declaration, lowered from [`UserSynonymDecl`] — the
1490/// transparent-expansion counterpart of [`VariantDecl`] (`prim_types.rs`).
1491/// Unlike a variant, a synonym never gets a runtime tag or a
1492/// `Checker::ctors` entry; the only thing that ever looks at one is
1493/// [`expand_synonyms`].
1494struct SynonymDecl {
1495    /// The declaration's own type-parameter placeholders — the same
1496    /// technique as `VariantDecl::param_vars` (matched by pointer identity
1497    /// via `types::substitute`). Realistically always empty: this grammar
1498    /// has no applied-type-constructor syntax to *reference* a synonym with
1499    /// arguments (`TypeAtom`'s doc comment), so a real reference site always
1500    /// supplies zero args — kept general anyway, so a nonzero-param synonym
1501    /// fails with a clear arity error rather than silently misbehaving if
1502    /// that ever changes.
1503    param_vars: Vec<types::TyVarRef>,
1504    /// The synonym's body, lowered exactly once via `lower_type_expr`. Any
1505    /// *other* synonym name it mentions is still an opaque
1506    /// `MonoType::Variant(name, [])` at this point (`lower_type_expr` has no
1507    /// notion of the synonym table) — [`expand_synonyms`] resolves those
1508    /// lazily, on demand, at each reference site.
1509    body: MonoType,
1510}
1511
1512fn build_synonym_decl(decl: &UserSynonymDecl, version: RustyfiVersion) -> SynonymDecl {
1513    let param_vars: Vec<types::TyVarRef> =
1514        decl.params.iter().map(|_| types::new_ty_var(0)).collect();
1515    let tyvar_map: HashMap<String, MonoType> = decl
1516        .params
1517        .iter()
1518        .cloned()
1519        .zip(param_vars.iter().cloned().map(MonoType::Var))
1520        .collect();
1521    SynonymDecl {
1522        param_vars,
1523        body: lower_type_expr(&decl.body, &tyvar_map, version),
1524    }
1525}
1526
1527/// Collect the name of every *synonym* (i.e. present in `synonyms`) directly
1528/// mentioned inside `ty`, ignoring argument count — used only to build the
1529/// "synonym references synonym" graph for [`check_synonym_cycles`], where
1530/// arity is irrelevant (a cycle is a cycle no matter how many arguments each
1531/// step is nominally applied to). Pre-order, duplicates included.
1532///
1533/// **Derived, not hand-written** ([`crate::visit`]). This walk was the live
1534/// instance of this port's recurring bug: its `InlineCmd`/`BlockCmd`/
1535/// `MathCmd` arm recursed into each slot's `ty` and not into that slot's
1536/// `opt_labels`, so a cycle routed through a `?(l : τ)` optional-label map
1537/// was invisible here while `expand_synonyms_cmd_args` expanded straight
1538/// into it — a stack overflow instead of a `cyclic type synonym` error
1539/// (`tests/synonym_cycle_opt_labels.rs`).
1540///
1541/// Semantics are unchanged otherwise: the traversal is structural (it does
1542/// **not** `resolve` through a bound type variable), pre-order, and
1543/// unconditional, exactly as the hand-written pair was.
1544fn synonym_refs(ty: &MonoType, synonyms: &HashMap<String, SynonymDecl>, out: &mut Vec<String>) {
1545    ty.visit(|t: &MonoType| {
1546        if let MonoType::Variant(name, _) = t {
1547            if synonyms.contains_key(name) {
1548                out.push(name.clone());
1549            }
1550        }
1551    });
1552}
1553
1554/// Reject a cyclic synonym (`type a = b` / `type b = a`, or a directly
1555/// self-referential `type a = a * a`) with a clear error instead of letting
1556/// [`expand_synonyms`] recurse forever. Run unconditionally over every
1557/// registered synonym at [`Checker::new`] time, so a cyclic pair is caught
1558/// even if nothing in the program actually references it.
1559fn check_synonym_cycles(synonyms: &HashMap<String, SynonymDecl>) -> Result<(), TypeError> {
1560    for start in synonyms.keys() {
1561        let mut stack = vec![start.clone()];
1562        check_synonym_cycles_from(start, synonyms, &mut stack)?;
1563    }
1564    Ok(())
1565}
1566
1567fn check_synonym_cycles_from(
1568    name: &str,
1569    synonyms: &HashMap<String, SynonymDecl>,
1570    stack: &mut Vec<String>,
1571) -> Result<(), TypeError> {
1572    let mut refs = Vec::new();
1573    synonym_refs(&synonyms[name].body, synonyms, &mut refs);
1574    for r in refs {
1575        if stack.contains(&r) {
1576            let mut cycle = stack.clone();
1577            cycle.push(r);
1578            return Err(TypeError::simple(
1579                None,
1580                format!("cyclic type synonym: {}", cycle.join(" -> ")),
1581            ));
1582        }
1583        stack.push(r.clone());
1584        check_synonym_cycles_from(&r, synonyms, stack)?;
1585        stack.pop();
1586    }
1587    Ok(())
1588}
1589
1590/// Recursively and transparently expand every synonym reference inside
1591/// `ty`, so `unify` never sees a synonym's name — only real base/product/
1592/// function/variant types. `synonyms` was already validated acyclic by
1593/// [`check_synonym_cycles`] (at `Checker::new` time), so the recursion here
1594/// is guaranteed to terminate; arity (a reference's argument count against
1595/// the synonym's own parameter count) is checked here, the one place that
1596/// actually has concrete arguments to check it against.
1597fn expand_synonyms(
1598    ty: &MonoType,
1599    synonyms: &HashMap<String, SynonymDecl>,
1600) -> Result<MonoType, TypeError> {
1601    match ty {
1602        MonoType::Var(_) | MonoType::Base(_) => Ok(ty.clone()),
1603        MonoType::Func(row, dom, cod) => Ok(MonoType::Func(
1604            Box::new(expand_synonyms_row(row, synonyms)?),
1605            Box::new(expand_synonyms(dom, synonyms)?),
1606            Box::new(expand_synonyms(cod, synonyms)?),
1607        )),
1608        MonoType::Product(ts) => Ok(MonoType::Product(
1609            ts.iter()
1610                .map(|t| expand_synonyms(t, synonyms))
1611                .collect::<Result<_, _>>()?,
1612        )),
1613        MonoType::List(t) => Ok(MonoType::List(Box::new(expand_synonyms(t, synonyms)?))),
1614        MonoType::Ref(t) => Ok(MonoType::Ref(Box::new(expand_synonyms(t, synonyms)?))),
1615        MonoType::Code(t) => Ok(MonoType::Code(Box::new(expand_synonyms(t, synonyms)?))),
1616        MonoType::Record(row) => Ok(MonoType::Record(expand_synonyms_row(row, synonyms)?)),
1617        MonoType::Variant(name, args) => {
1618            let args: Vec<MonoType> = args
1619                .iter()
1620                .map(|t| expand_synonyms(t, synonyms))
1621                .collect::<Result<_, _>>()?;
1622            let Some(syn) = synonyms.get(name) else {
1623                return Ok(MonoType::Variant(name.clone(), args));
1624            };
1625            if args.len() != syn.param_vars.len() {
1626                return Err(TypeError::simple(
1627                    None,
1628                    format!(
1629                        "type synonym '{name}' expects {} argument{}, got {}",
1630                        syn.param_vars.len(),
1631                        if syn.param_vars.len() == 1 { "" } else { "s" },
1632                        args.len()
1633                    ),
1634                ));
1635            }
1636            let mut var_map: HashMap<usize, MonoType> = HashMap::new();
1637            for (pv, arg) in syn.param_vars.iter().zip(args.iter()) {
1638                var_map.insert(types::ptr_key(pv), arg.clone());
1639            }
1640            let substituted = types::substitute(&syn.body, &var_map, &HashMap::new());
1641            expand_synonyms(&substituted, synonyms)
1642        }
1643        MonoType::InlineCmd(cs) => Ok(MonoType::InlineCmd(expand_synonyms_cmd_args(cs, synonyms)?)),
1644        MonoType::BlockCmd(cs) => Ok(MonoType::BlockCmd(expand_synonyms_cmd_args(cs, synonyms)?)),
1645        MonoType::MathCmd(cs) => Ok(MonoType::MathCmd(expand_synonyms_cmd_args(cs, synonyms)?)),
1646    }
1647}
1648
1649fn expand_synonyms_row(
1650    row: &Row,
1651    synonyms: &HashMap<String, SynonymDecl>,
1652) -> Result<Row, TypeError> {
1653    match row {
1654        Row::Empty => Ok(Row::Empty),
1655        Row::Var(v) => Ok(Row::Var(v.clone())),
1656        Row::Cons(label, t, rest) => Ok(Row::Cons(
1657            label.clone(),
1658            Box::new(expand_synonyms(t, synonyms)?),
1659            Box::new(expand_synonyms_row(rest, synonyms)?),
1660        )),
1661    }
1662}
1663
1664fn expand_synonyms_cmd_args(
1665    cs: &[CmdArgType],
1666    synonyms: &HashMap<String, SynonymDecl>,
1667) -> Result<Vec<CmdArgType>, TypeError> {
1668    cs.iter()
1669        .map(|c| {
1670            Ok(CmdArgType {
1671                optional: c.optional,
1672                opt_labels: c
1673                    .opt_labels
1674                    .iter()
1675                    .map(|(l, t)| Ok((l.clone(), expand_synonyms(t, synonyms)?)))
1676                    .collect::<Result<_, TypeError>>()?,
1677                ty: expand_synonyms(&c.ty, synonyms)?,
1678            })
1679        })
1680        .collect()
1681}
1682
1683// ============================================================================
1684// The checker.
1685// ============================================================================
1686
1687pub(crate) struct Checker<'s> {
1688    /// The interner every identifier in the tree being checked came from.
1689    /// Needed both to mint derived lookup keys (`"{modpfx}.{ctor}"`) and to
1690    /// resolve a `Symbol` back to text for an error message — see
1691    /// [`Checker::text`].
1692    store: &'s SymbolStore,
1693    ctx: TypeContext,
1694    /// Constructor name -> the (`Rc`-shared) declaration it belongs to.
1695    /// Later declarations shadow earlier ones of the same ctor name, mirroring
1696    /// ordinary name shadowing elsewhere in this port.
1697    ctors: HashMap<String, Rc<VariantDecl>>,
1698    /// The same declarations, keyed by *type* name instead — needed by the
1699    /// exhaustiveness pass (`exhaustive::check_match`) to enumerate a
1700    /// variant's full constructor set given the scrutinee's resolved
1701    /// `MonoType::Variant(name, _)`.
1702    variants: HashMap<String, Rc<VariantDecl>>,
1703    /// The synonym table. A field rather than a local of `new_with_version`
1704    /// so `declare_variant` can expand ctor payloads through it after
1705    /// construction time (per-binding registration).
1706    synonyms: HashMap<String, SynonymDecl>,
1707    /// Non-fatal diagnostics accumulated by the exhaustiveness/redundancy
1708    /// pass (see `typecheck_verbose`); v0.0.6's `exhchecker.ml` warns and
1709    /// continues rather than rejecting the program.
1710    warnings: Vec<MatchWarning>,
1711    /// The target version this session is checking against — set by
1712    /// [`Checker::install_builtin_variants`] (every real construction path's
1713    /// call; `Checker::empty` alone never does, defaulting to `V0_0`).
1714    /// Threaded into `name_to_mono`'s surface-type-name fork and
1715    /// `Ast::LetMathIn`'s scheme rule (`math_command_scheme` vs
1716    /// `math_command_scheme_v01`).
1717    version: RustyfiVersion,
1718    /// The generation of the code currently being inferred, when that is
1719    /// NOT [`Checker::version`] — set (save/restore) by the
1720    /// `Ast::VersionScope` infer arm, `None` outside every such scope.
1721    /// Deliberately a SEPARATE field rather than a temporary overwrite of
1722    /// `version`, since that one must stay pinned to `V0_1` for a merged
1723    /// whole-program session (`v1::module_check::check_program_inner`). See
1724    /// [`Checker::binding_version`], its only consumer.
1725    scoped_version: Option<RustyfiVersion>,
1726    /// The module path of the member body currently being inferred (pushed by
1727    /// the `Ast::ModuleScope` arm; empty at top level). A BARE constructor
1728    /// reference is looked up under `<path>.Ctor` (innermost prefix first)
1729    /// before the bare fallback — so two modules' same-named constructors
1730    /// (`Term.Paren` vs `Type.Paren`) no longer collide. See [`Ast::ModuleScope`].
1731    ctor_scope: Vec<String>,
1732    /// The stage this expression is being read at. Starts at whatever the
1733    /// file declared (`@stage:`, default [`Stage::Stage1`]) and is shifted by
1734    /// `&`/`~`: a quote reads its body one stage LATER, a splice one stage
1735    /// EARLIER. Only `Next`/`Prev` consult it.
1736    stage: Stage,
1737}
1738
1739/// One elaborated top-level-shaped binding, viewed by reference — the
1740/// typecheck-side mirror of `elaborate::Binding` and of the four Let-shaped
1741/// `Ast` spine variants (`ast.rs`). The module checker constructs these
1742/// directly from its own per-`val` walk; the whole-program path never
1743/// constructs one explicitly (its `infer` arms pass the same references
1744/// through).
1745pub(crate) enum BindingView<'a, 's> {
1746    /// `Ast::LetIn` — plain value OR `\`/`+`-sigiled command binding; the
1747    /// sigil dispatch (`command_scheme`) stays inside the checker.
1748    Let {
1749        name: Symbol<'s>,
1750        value: &'a Ast<'s>,
1751    },
1752    /// `Ast::LetMathIn` — a math-command binding (distinct variant by
1753    /// construction).
1754    LetMath {
1755        name: Symbol<'s>,
1756        value: &'a Ast<'s>,
1757    },
1758    /// `Ast::LetRecIn`'s binding group (all names in scope in all bodies).
1759    LetRec(&'a [(Symbol<'s>, Rc<Ast<'s>>)]),
1760    /// `Ast::LetMutableIn` — value restriction, never generalized.
1761    LetMutable { name: Symbol<'s>, init: &'a Ast<'s> },
1762}
1763
1764impl<'s> Checker<'s> {
1765    // See `base_type_env`'s `#[allow(dead_code)]` note above — same shape,
1766    // same reason.
1767    #[allow(dead_code)]
1768    fn new(program: &Program<'s>) -> Result<Checker<'s>, TypeError> {
1769        Self::new_with_version(program, RustyfiVersion::V0_0)
1770    }
1771
1772    /// Bare session: empty tables, fresh `TypeContext`. Registers NOTHING —
1773    /// not even builtins — so `new_with_version` can compose the exact
1774    /// statement order of the original monolithic constructor.
1775    pub(crate) fn empty(store: &'s SymbolStore) -> Checker<'s> {
1776        Checker {
1777            store,
1778            ctx: TypeContext::new(),
1779            ctors: HashMap::new(),
1780            variants: HashMap::new(),
1781            synonyms: HashMap::new(),
1782            warnings: Vec::new(),
1783            version: RustyfiVersion::V0_0,
1784            scoped_version: None,
1785            ctor_scope: Vec::new(),
1786            // A document is stage 1; a library overrides this from its
1787            // `@stage:` header before checking begins.
1788            stage: Stage::default(),
1789        }
1790    }
1791
1792    /// Set the session's target version with NO other side effect — a pure
1793    /// field write, safe to call before anything else.
1794    /// `new_with_version`/`v1::module_check::check_program`'s session-setup
1795    /// sequences both call this FIRST, ahead of their order-critical
1796    /// `declare_synonym` loop, so a V0_1 synonym body that names
1797    /// `math-text`/`math-boxes` resolves correctly even though
1798    /// `install_builtin_variants` (which also sets this field) does not run
1799    /// until afterward. A no-op for every 0.0.6 path: `empty()` already
1800    /// defaults to `V0_0`.
1801    pub(crate) fn set_version(&mut self, version: RustyfiVersion) {
1802        self.version = version;
1803    }
1804
1805    /// A symbol's source text. Every diagnostic this module formats goes
1806    /// through here: `Symbol`'s own `Debug` is index-only by design, and the
1807    /// golden tests diff the resolved strings.
1808    fn text(&self, sym: Symbol<'s>) -> &'s str {
1809        self.store.resolve(sym)
1810    }
1811
1812    /// Register the builtin variant decls for `version`, and record
1813    /// `version` on `self` — redundant with `set_version` for a path that
1814    /// calls both, kept so this method alone suffices for a caller that
1815    /// skips `set_version` (e.g. the bare-builtins test construction).
1816    pub(crate) fn install_builtin_variants(&mut self, version: RustyfiVersion) {
1817        self.version = version;
1818        self.install_additional_builtin_variants(version);
1819    }
1820
1821    /// Register `version`'s builtin variant/ctor set WITHOUT
1822    /// touching `self.version` (unlike [`Checker::install_builtin_
1823    /// variants`], which also sets the whole-session version tag) — called
1824    /// from the `Ast::VersionScope` `infer` arm below, lazily, the first
1825    /// time a version-scoped subtree is reached, so a `V0_0`-only ADT like
1826    /// `page` (`A0Paper`/…/`A4Paper`/`UserDefinedPaper`, gated on
1827    /// `has_page_adt()`, `prim_types.rs`'s `builtin_variants_with_version`)
1828    /// is constructible/matchable inside a spliced dependency's internal
1829    /// `page-break A4Paper …` call even though the whole-program `Checker`
1830    /// was built under `V0_1` (where `page` isn't registered at all).
1831    /// `self.ctors`/`self.variants` are flat, last-writer-wins, program-
1832    /// global tables already (`hide_ctors`'s doc comment); every
1833    /// `builtin_variants_with_version` entry OTHER than `page` is identical
1834    /// between the two versions (only `page` is gated by `has_page_adt()`,
1835    /// `prim_types.rs:2119`), so this is a safe additive merge, not a
1836    /// replace — idempotent across repeated `VersionScope` nodes of the same
1837    /// version.
1838    pub(crate) fn install_additional_builtin_variants(&mut self, version: RustyfiVersion) {
1839        for decl in builtin_variants_with_version(version) {
1840            let decl = Rc::new(decl);
1841            self.variants.insert(decl.name.clone(), decl.clone());
1842            for (cname, _) in &decl.ctors {
1843                self.ctors.insert(cname.clone(), decl.clone());
1844            }
1845        }
1846    }
1847
1848    /// One synonym registration. Does NOT cycle-check — register all, then
1849    /// call `check_cycles`. Fallible:
1850    /// `check_type_expr_v0_1_only` rejects a `?(l:ty)->` domain in the
1851    /// synonym's body under `V0_0` before it is ever lowered.
1852    pub(crate) fn declare_synonym(&mut self, decl: &UserSynonymDecl) -> Result<(), TypeError> {
1853        check_type_expr_v0_1_only(&decl.body, self.version)?;
1854        self.synonyms
1855            .insert(decl.name.clone(), build_synonym_decl(decl, self.version));
1856        Ok(())
1857    }
1858
1859    /// Cycle-check the accumulated synonym table — a thin wrapper over the
1860    /// existing free fn `check_synonym_cycles`.
1861    pub(crate) fn check_cycles(&self) -> Result<(), TypeError> {
1862        check_synonym_cycles(&self.synonyms)
1863    }
1864
1865    /// One variant-decl registration.
1866    pub(crate) fn declare_variant(&mut self, decl: &UserTypeDecl) -> Result<(), TypeError> {
1867        let decl = Rc::new(build_variant_decl(decl, &self.synonyms, self.version)?);
1868        self.variants.insert(decl.name.clone(), decl.clone());
1869        for (cname, _) in &decl.ctors {
1870            self.ctors.insert(cname.clone(), decl.clone());
1871        }
1872        // If the variant's own type name is module-qualified (`M.t`), also
1873        // register each constructor under a qualified key (`M.Ctor`) so a
1874        // within-module bare reference (via `Checker::lookup_ctor`, driven by
1875        // `Ast::ModuleScope`) resolves to THIS module's ctor even when another
1876        // module declares the same bare ctor name. Builtins have undotted type
1877        // names, so this adds nothing for them.
1878        if let Some((modpfx, _)) = decl.name.rsplit_once('.') {
1879            for (cname, _) in &decl.ctors {
1880                self.ctors.insert(format!("{modpfx}.{cname}"), decl.clone());
1881            }
1882        }
1883        Ok(())
1884    }
1885
1886    /// The original whole-program constructor, re-expressed as the exact
1887    /// same statement sequence through the methods above: synonyms →
1888    /// cycle-check → builtins → user variant decls. This order-preservation
1889    /// is load-bearing for session-incrementality.
1890    fn new_with_version(
1891        program: &Program<'s>,
1892        version: RustyfiVersion,
1893    ) -> Result<Checker<'s>, TypeError> {
1894        // Synonyms are registered (and checked for cycles) before any
1895        // variant decl is lowered, since a variant's ctor payload may name a
1896        // synonym (`build_variant_decl` expands through `synonyms`).
1897        let mut c = Checker::empty(program.store);
1898        c.set_version(version);
1899        for usd in &program.synonym_decls {
1900            c.declare_synonym(usd)?;
1901        }
1902        c.check_cycles()?;
1903        c.install_builtin_variants(version);
1904        for utd in &program.type_decls {
1905            c.declare_variant(utd)?;
1906        }
1907        Ok(c)
1908    }
1909
1910    fn fresh(&mut self) -> MonoType {
1911        MonoType::Var(self.ctx.fresh_var())
1912    }
1913
1914    fn unify_ctx(
1915        &mut self,
1916        expected: &MonoType,
1917        found: &MonoType,
1918        span: Option<Span>,
1919        what: &str,
1920    ) -> Result<(), TypeError> {
1921        unify(expected, found).map_err(|e| TypeError::from_unify(span, what, e))
1922    }
1923
1924    /// Turn a `\`/`+`-named `LetIn` binding's ordinarily-inferred value type
1925    /// `tv` into the genuine command type (`MonoType::InlineCmd`/`BlockCmd`)
1926    /// it gets bound under: a user-defined command is typed as
1927    /// `[τ1; ..; τn] inline-cmd` (resp. `block-cmd`), matching v0.0.6's real
1928    /// `HorzCommandType`/`VertCommandType` (`typechecker.ml`'s
1929    /// `UTLetHorzIn`/`UTLetVertIn` rules), not a plain "context-curried"
1930    /// function.
1931    ///
1932    /// Two shapes reach this function, per [`command_sigil`]'s call site:
1933    ///
1934    /// * a genuine `let-inline`/`let-block` definition, whose value is the
1935    ///   `Lambda(ctxvar, Lambda(p1, .., Lambda(pn, body)))` chain
1936    ///   `elaborate::elaborate_let_inline` builds — [`peel_func_chain`]
1937    ///   recovers that `Func` chain; the leading domain must unify with
1938    ///   `context`, the final codomain with `inline-boxes`/`block-boxes`,
1939    ///   and the domains between become the command's `CmdArgType` list.
1940    /// * a qualified-name *alias* of an already-command-typed binding (a
1941    ///   module's own `M.\cmd` re-export, or an `open` re-binding — both
1942    ///   build `LetIn(name, Ast::Var(qualified), body)`): the aliased name
1943    ///   was already run through this function at its own definition site,
1944    ///   so `tv` here already *is* the command type — this branch passes it
1945    ///   through unchanged (re-generalized) instead of peeling a `Func`
1946    ///   chain out of something that isn't one.
1947    fn command_scheme(
1948        &mut self,
1949        name: &str,
1950        sigil: char,
1951        tv: MonoType,
1952        span: Option<Span>,
1953    ) -> Result<PolyType, TypeError> {
1954        debug_assert!(sigil == '\\' || sigil == '+');
1955        let is_inline = sigil == '\\';
1956        let (want_result, kind, other_kind) = if is_inline {
1957            (t_inline_boxes(), "inline", "block")
1958        } else {
1959            (t_block_boxes(), "block", "inline")
1960        };
1961
1962        match &*resolve(&tv) {
1963            MonoType::InlineCmd(_) if is_inline => {
1964                return Ok(generalize(self.ctx.level(), &tv));
1965            }
1966            MonoType::BlockCmd(_) if !is_inline => {
1967                return Ok(generalize(self.ctx.level(), &tv));
1968            }
1969            MonoType::InlineCmd(_) | MonoType::BlockCmd(_) => {
1970                return Err(TypeError::simple(
1971                    span,
1972                    format!(
1973                        "'{name}' is bound to a {other_kind} command, but its \
1974                         name marks it as {article} {kind} command",
1975                        article = if kind == "inline" { "an" } else { "a" },
1976                    ),
1977                ));
1978            }
1979            // A qualified-name alias (`M.\cmd` re-export, or an `open`) of a
1980            // GENUINE `let-math` binding: math commands share the `\` sigil
1981            // with inline commands (there is no separate math-command token),
1982            // so an alias site only ever reaches this generic `Ast::LetIn`
1983            // path (never `Ast::LetMathIn`, which is produced only at a math
1984            // command's OWN definition site — top-level `let-math` via
1985            // `walk_bindings`, or the expression-level `let-math .. in ..`
1986            // form, `elaborate.rs`'s `Expr::LetMathIn` arm — never at an
1987            // alias site; see that variant's doc comment). Pass a already-
1988            // `MathCmd`-typed alias through unchanged, exactly like the
1989            // `InlineCmd`/`BlockCmd` arms above do for their own kind.
1990            MonoType::MathCmd(_) if is_inline => {
1991                return Ok(generalize(self.ctx.level(), &tv));
1992            }
1993            _ => {}
1994        }
1995
1996        // V0_1 harvests each param's closed
1997        // `?(l:τ,…)` label map from the `Row` that `Ast::LambdaOpt` leaves on
1998        // that param's own arrow (`peel_func_chain_rows`), instead of the
1999        // 0.0.6 "`_ option` domain ⇒ optional slot" heuristic below (which
2000        // stays untouched, byte-identical, under V0_0 — it never sees a
2001        // non-`Row::Empty` row at all, since V0_0 code never builds
2002        // `Ast::LambdaOpt`).
2003        let params: Vec<CmdArgType> = if self.version.has_row_polymorphism() {
2004            let (mut slots, result) = peel_func_chain_rows(tv);
2005            if slots.is_empty() {
2006                return Err(TypeError::simple(
2007                    span,
2008                    format!(
2009                        "the binding for '{name}' must be a function taking a \
2010                         context as its first argument (e.g. via `val inline ctx \
2011                         {name} .. = ..`)"
2012                    ),
2013                ));
2014            }
2015            let (ctx_row, ctx_ty) = slots.remove(0);
2016            // A labeled bundle can never legally land on the ctx binder
2017            // (`elaborate_let_inline` always wraps it in a plain
2018            // `Ast::Lambda`, which infers `Row::Empty` — `prim_types::arrow`)
2019            // — guard it defensively rather than silently dropping/mis-
2020            // attributing a label.
2021            if !matches!(&*resolve_row(&ctx_row), Row::Empty) {
2022                return Err(TypeError::simple(
2023                    span,
2024                    format!(
2025                        "the context argument of '{name}' cannot carry a labeled \
2026                         optional bundle"
2027                    ),
2028                ));
2029            }
2030            self.unify_ctx(
2031                &t_context(),
2032                &ctx_ty,
2033                span,
2034                &format!("the context argument of '{name}'"),
2035            )?;
2036            self.unify_ctx(
2037                &want_result,
2038                &result,
2039                span,
2040                &format!("the result of '{name}'"),
2041            )?;
2042            slots
2043                .into_iter()
2044                .map(|(row, dom)| harvest_slot(row, dom))
2045                .collect()
2046        } else {
2047            let (mut doms, result) = peel_func_chain(tv);
2048            if doms.is_empty() {
2049                return Err(TypeError::simple(
2050                    span,
2051                    format!(
2052                        "the binding for '{name}' must be a function taking a \
2053                         context as its first argument (e.g. via `let-inline ctx \
2054                         {name} .. = ..`)"
2055                    ),
2056                ));
2057            }
2058            let ctx_ty = doms.remove(0);
2059            self.unify_ctx(
2060                &t_context(),
2061                &ctx_ty,
2062                span,
2063                &format!("the context argument of '{name}'"),
2064            )?;
2065            self.unify_ctx(
2066                &want_result,
2067                &result,
2068                span,
2069                &format!("the result of '{name}'"),
2070            )?;
2071            // Optional command params, simplified (Sub-area 2): this grammar
2072            // has no def-site `?:param` marker, so a param counts as optional
2073            // exactly when its INFERRED domain resolves to `_ option` — i.e.
2074            // the body actually uses it as an `option` (`match p with Some ..
2075            // | None -> ..`). `CmdArgType.ty` then stores the option's INNER
2076            // type, matching the `[ty?; ..]` signature-lowering shape 1:1
2077            // (`lower_type_atom`'s `TypeAtom::Cmd` arm); `check_cmd_args`
2078            // re-wraps it in `option(..)` per call, since call-site args
2079            // always arrive pre-wrapped as `Some`/`None`
2080            // (`elaborate.rs`'s `app_arg_to_ast`).
2081            doms.into_iter()
2082                .map(|d| match resolve(&d).into_owned() {
2083                    MonoType::Variant(vname, mut vargs)
2084                        if vname == "option" && vargs.len() == 1 =>
2085                    {
2086                        optional(vargs.pop().unwrap())
2087                    }
2088                    _ => mandatory(d),
2089                })
2090                .collect()
2091        };
2092        let cmd_ty = if is_inline {
2093            MonoType::InlineCmd(params)
2094        } else {
2095            MonoType::BlockCmd(params)
2096        };
2097        Ok(generalize(self.ctx.level(), &cmd_ty))
2098    }
2099
2100    /// `Ast::LetMathIn`'s scheme-building rule — the math-command analog of
2101    /// `command_scheme` above, but simpler: a math command has **no**
2102    /// implicit context argument (see `elaborate.rs`'s
2103    /// `elaborate_let_math`), so every domain of `tv`'s function-chain
2104    /// becomes a `CmdArgType` (the same optional-param heuristic as
2105    /// `command_scheme`), and the bare result — not a peeled first argument
2106    /// — must be `math`. A zero-arity binding (`tv` not a `Func` at all,
2107    /// e.g. `let-math \to = rel \`→\``) falls out naturally:
2108    /// `peel_func_chain` returns no domains and `tv` itself as the result.
2109    fn math_command_scheme(
2110        &mut self,
2111        name: &str,
2112        tv: MonoType,
2113        span: Option<Span>,
2114    ) -> Result<PolyType, TypeError> {
2115        let (doms, result) = peel_func_chain(tv);
2116        self.unify_ctx(
2117            &t_math_text(),
2118            &result,
2119            span,
2120            &format!("the result of math command '{name}'"),
2121        )?;
2122        let params: Vec<CmdArgType> = doms
2123            .into_iter()
2124            .map(|d| match resolve(&d).into_owned() {
2125                MonoType::Variant(vname, mut vargs) if vname == "option" && vargs.len() == 1 => {
2126                    optional(vargs.pop().unwrap())
2127                }
2128                _ => mandatory(d),
2129            })
2130            .collect();
2131        Ok(generalize(self.ctx.level(), &MonoType::MathCmd(params)))
2132    }
2133
2134    /// `Ast::LetMathIn`'s V0_1 scheme-building rule — the `val math` analog
2135    /// of `math_command_scheme` above. The lowering
2136    /// (`v1/lower.rs::lower_bind_v1`) ALWAYS synthesizes exactly three
2137    /// trailing lambdas around a `val math` body — `fun ctx -> fun sub ->
2138    /// fun sup -> …` — so `tv`'s function chain always has at least 3
2139    /// domains; the LAST three are peeled off as `(d_ctx, d_sub, d_sup)`
2140    /// (`context`, `option math-text`, `option math-text`), the bare result
2141    /// must be `math-boxes`, and the REMAINING leading domains become the
2142    /// command's ordinary `CmdArgType` params.
2143    ///
2144    /// Like `command_scheme`'s V0_1 branch,
2145    /// a leading user parameter may be a `?(l = x, …)` bundle, so this uses
2146    /// the row-carrying `peel_func_chain_rows` + `harvest_slot` instead of
2147    /// the plain `_ option` heuristic. The synthesized ctx/sub/sup trailing
2148    /// trio can never legally carry a bundle (`lower_value_math` always
2149    /// wraps them in plain `fun`s, inferring `Row::Empty`) — guarded
2150    /// defensively, since an off-by-one here would silently turn `sub`/`sup`
2151    /// into a labeled slot or eat the last user param (the trio is at the
2152    /// TAIL of the domain chain, opposite inline/block where ctx is FIRST).
2153    fn math_command_scheme_v01(
2154        &mut self,
2155        name: &str,
2156        tv: MonoType,
2157        span: Option<Span>,
2158    ) -> Result<PolyType, TypeError> {
2159        let (mut slots, result) = peel_func_chain_rows(tv);
2160        if slots.len() < 3 {
2161            return Err(TypeError::simple(
2162                span,
2163                format!(
2164                    "'val math' command '{name}' must take a context and (via the \
2165                     synthesized `with sub sup`/`%math-attach-scripts` wrapper) two \
2166                     optional scripts as its trailing arguments — see the math-split spec"
2167                ),
2168            ));
2169        }
2170        let (row_sup, d_sup) = slots.pop().unwrap();
2171        let (row_sub, d_sub) = slots.pop().unwrap();
2172        let (row_ctx, d_ctx) = slots.pop().unwrap();
2173        for (which, row) in [
2174            ("context", &row_ctx),
2175            ("'sub'", &row_sub),
2176            ("'sup'", &row_sup),
2177        ] {
2178            if !matches!(&*resolve_row(row), Row::Empty) {
2179                return Err(TypeError::simple(
2180                    span,
2181                    format!(
2182                        "the {which} argument of 'val math' command '{name}' cannot \
2183                         carry a labeled optional bundle"
2184                    ),
2185                ));
2186            }
2187        }
2188        self.unify_ctx(
2189            &t_context(),
2190            &d_ctx,
2191            span,
2192            &format!("the context argument of 'val math' command '{name}'"),
2193        )?;
2194        self.unify_ctx(
2195            &t_option(t_math_text()),
2196            &d_sub,
2197            span,
2198            &format!("the 'sub' argument of 'val math' command '{name}'"),
2199        )?;
2200        self.unify_ctx(
2201            &t_option(t_math_text()),
2202            &d_sup,
2203            span,
2204            &format!("the 'sup' argument of 'val math' command '{name}'"),
2205        )?;
2206        self.unify_ctx(
2207            &t_math_boxes(),
2208            &result,
2209            span,
2210            &format!(
2211                "the result of 0.1 math command '{name}' — a `math-boxes`, \
2212                 usually via `read-math`"
2213            ),
2214        )?;
2215        let params: Vec<CmdArgType> = slots
2216            .into_iter()
2217            .map(|(row, dom)| harvest_slot(row, dom))
2218            .collect();
2219        Ok(generalize(self.ctx.level(), &MonoType::MathCmd(params)))
2220    }
2221
2222    /// Shared by `check_itext`'s `IText::Cmd`, `check_btext`'s `BText::Cmd`,
2223    /// and `check_math_elem`'s `MathElem::Cmd`: check a command application's
2224    /// argument count (exact — every optional slot is either explicitly
2225    /// marked at the call site or `None`-padded by elaboration, so it is
2226    /// never actually *absent* from `args`) and each argument's type against
2227    /// `params`. An `optional` param's `args[i].arg` is always a
2228    /// `Some(..)`/`None` value (`app_arg_to_ast`'s desugaring), so it's
2229    /// checked against `option(param.ty)`, not `param.ty` directly.
2230    ///
2231    /// Each `args[i]` additionally carries
2232    /// a (possibly empty) supplied `?(l = e, …)` bundle (`args[i].opts`) —
2233    /// every label must be declared in that slot's own closed
2234    /// `param.opt_labels` map (upstream's `UnexpectedOptionalLabel`,
2235    /// `typechecker.ml:900-901`); a declared label this call omits simply
2236    /// defaults to `None` at runtime, nothing to check here for it. `opts`
2237    /// is `[]` for every 0.0.6-reachable call, so this loop is a no-op
2238    /// there.
2239    fn check_cmd_args(
2240        &mut self,
2241        env: &TypeEnv<'s>,
2242        name: &str,
2243        span: Span,
2244        params: &[CmdArgType],
2245        args: &[CmdArg<'s>],
2246    ) -> Result<(), TypeError> {
2247        if params.len() != args.len() {
2248            return Err(TypeError::simple(
2249                Some(span),
2250                format!(
2251                    "command '{name}' expects {} argument{}, got {}",
2252                    params.len(),
2253                    if params.len() == 1 { "" } else { "s" },
2254                    args.len()
2255                ),
2256            ));
2257        }
2258        for (i, (param, arg)) in params.iter().zip(args.iter()).enumerate() {
2259            for (label, val) in &arg.opts {
2260                match param.opt_labels.iter().find(|(l, _)| l == label) {
2261                    Some((_, lty)) => {
2262                        let tval = self.infer(env, val)?;
2263                        self.unify_ctx(
2264                            lty,
2265                            &tval,
2266                            ast_span(val).or(Some(span)),
2267                            &format!("optional argument `{label}` of '{name}'"),
2268                        )?;
2269                    }
2270                    None => {
2271                        return Err(TypeError::simple(
2272                            ast_span(val).or(Some(span)),
2273                            format!(
2274                                "command '{name}' has no optional label `{label}` \
2275                                 on argument {}",
2276                                i + 1
2277                            ),
2278                        ));
2279                    }
2280                }
2281            }
2282            let targ = self.infer(env, &arg.arg)?;
2283            let expected = if param.optional {
2284                t_option(param.ty.clone())
2285            } else {
2286                param.ty.clone()
2287            };
2288            self.unify_ctx(
2289                &expected,
2290                &targ,
2291                ast_span(&arg.arg).or(Some(span)),
2292                &format!("argument {} of '{name}'", i + 1),
2293            )?;
2294        }
2295        Ok(())
2296    }
2297
2298    // ---- expressions -------------------------------------------------------
2299
2300    /// Infer the scheme(s) of ONE binding against a static `env`,
2301    /// WITHOUT extending anything. Returns the
2302    /// `(name, PolyType)` pairs in binding order (singleton for
2303    /// Let/LetMath/LetMutable; group order for LetRec). The caller decides
2304    /// what to put in the environment — the whole-program path commits them
2305    /// verbatim (`env.with_all`).
2306    ///
2307    /// Level discipline, sigil dispatch, generalization, and the
2308    /// value-restriction asymmetry all live INSIDE this method, so callers
2309    /// cannot get them wrong.
2310    pub(crate) fn infer_binding(
2311        &mut self,
2312        env: &TypeEnv<'s>,
2313        binding: BindingView<'_, 's>,
2314    ) -> Result<Vec<(Symbol<'s>, PolyType)>, TypeError> {
2315        match binding {
2316            BindingView::Let { name, value } => {
2317                self.ctx.enter_level();
2318                let tv = self.infer(env, value)?;
2319                self.ctx.leave_level();
2320                let scheme = match command_sigil(self.text(name)) {
2321                    // A `\`/`+`-named binding: either a genuine `let-inline`/
2322                    // `let-block` definition (`value` is the
2323                    // `Lambda(ctxvar, Lambda(p1, .., body))` chain
2324                    // `elaborate_let_inline` builds) or a qualified-name
2325                    // alias of one (`value` is a bare `Ast::Var`, from a
2326                    // module's own `M.\cmd` re-export or an `open`) — see
2327                    // `command_scheme`.
2328                    Some(sigil) => {
2329                        self.command_scheme(self.text(name), sigil, tv, ast_span(value))?
2330                    }
2331                    None => generalize(self.ctx.level(), &tv),
2332                };
2333                Ok(vec![(name, scheme)])
2334            }
2335
2336            // `let-math \cmd param* = expr in body` — structurally
2337            // identical to the `Let` command- binding rule above, but for a
2338            // binding that is ALREADY known (by construction, via the
2339            // dedicated Ast variant) to be a math command, so there is no
2340            // sigil to dispatch on and no "which kind of `\`-binding is
2341            // this" ambiguity to resolve.
2342            BindingView::LetMath { name, value } => {
2343                self.ctx.enter_level();
2344                let tv = self.infer(env, value)?;
2345                self.ctx.leave_level();
2346                // V0_0's `let-math` and V0_1's `val math` both lower to the
2347                // SAME `Ast::LetMathIn` — only the SCHEME RULE forks, since
2348                // a `val math` binding's lowering always synthesizes exactly
2349                // three trailing ctx/sub/sup lambdas that
2350                // `math_command_scheme`'s v0.0.6 rule knows nothing about.
2351                //
2352                // It forks on the BINDING's generation, not the session's
2353                // (`binding_version`, not `self.version`): in a merged
2354                // cross-version program the session is always `V0_1` while a
2355                // spliced 0.0.6 package's `let-math` RHS carries its own
2356                // `Ast::VersionScope(V0_0, _)`. On every single-version
2357                // program the two agree by construction (no `VersionScope`
2358                // node exists there at all).
2359                let scheme = if self.binding_version(value).math_is_split() {
2360                    self.math_command_scheme_v01(self.text(name), tv, ast_span(value))?
2361                } else {
2362                    self.math_command_scheme(self.text(name), tv, ast_span(value))?
2363                };
2364                Ok(vec![(name, scheme)])
2365            }
2366
2367            BindingView::LetRec(bindings) => {
2368                self.ctx.enter_level();
2369                // The group's own stage, so a `@stage: 0` library's mutually
2370                // recursive clauses can still see each other (they are read at
2371                // stage 0, and a stage-0 read of a stage-1 binder is refused).
2372                let group_stage = self.binding_stage_rec(bindings);
2373                let mut rec_env = env.clone();
2374                let mut vars = Vec::with_capacity(bindings.len());
2375                for (name, _) in bindings {
2376                    let v = self.fresh();
2377                    vars.push(v.clone());
2378                    rec_env = rec_env.with(*name, PolyType::mono(v), group_stage);
2379                }
2380                for ((name, val), v) in bindings.iter().zip(vars.iter()) {
2381                    let tv = self.infer(&rec_env, val)?;
2382                    self.unify_ctx(
2383                        v,
2384                        &tv,
2385                        ast_span(val),
2386                        &format!("let-rec binding '{}'", self.text(*name)),
2387                    )?;
2388                }
2389                self.ctx.leave_level();
2390                let mut schemes = Vec::with_capacity(bindings.len());
2391                for ((name, _), v) in bindings.iter().zip(vars.iter()) {
2392                    let scheme = generalize(self.ctx.level(), v);
2393                    schemes.push((*name, scheme));
2394                }
2395                Ok(schemes)
2396            }
2397
2398            BindingView::LetMutable { name, init } => {
2399                // NO generalization: `let-mutable`'s binding is the
2400                // classic ML "value restriction" case — a mutable reference
2401                // must stay monomorphic, or `let-mutable r <- [] in ((r <-
2402                // 1 :: !r); (r <- true :: !r); !r)`-style code could smuggle
2403                // an `int` and a `bool` through the very same cell. Binding
2404                // it via `PolyType::mono` (not `generalize`) enforces this
2405                // directly: every use of `name` in `body` shares the exact
2406                // same `Ref` type, not a fresh instantiation.
2407                let tinit = self.infer(env, init)?;
2408                Ok(vec![(name, PolyType::mono(reff(tinit)))])
2409            }
2410        }
2411    }
2412
2413    /// Infer one expression against a static env — a `pub(crate)` wrapper
2414    /// over the private `infer` below, which stays private so the ~40
2415    /// internal `self.infer(` call sites need not change. `v1::module_check`
2416    /// uses this for the document body and any non-binding expression.
2417    pub(crate) fn infer_expr(
2418        &mut self,
2419        env: &TypeEnv<'s>,
2420        ast: &Ast<'s>,
2421    ) -> Result<MonoType, TypeError> {
2422        self.infer(env, ast)
2423    }
2424
2425    /// Drain accumulated non-fatal match warnings (exhaustiveness /
2426    /// redundancy) — for per-binding callers; the whole-program path reads
2427    /// the `warnings` field directly.
2428    pub(crate) fn take_warnings(&mut self) -> Vec<MatchWarning> {
2429        std::mem::take(&mut self.warnings)
2430    }
2431
2432    /// Mutable access to the inference context — sig lowering needs it
2433    /// (`lower_sig_item(item, &mut ctx)`), and its subsumption check mints
2434    /// fresh vars through it.
2435    pub(crate) fn ctx_mut(&mut self) -> &mut TypeContext {
2436        &mut self.ctx
2437    }
2438
2439    /// Expand every synonym reference inside `ty` against this session's
2440    /// synonym table — a `pub(crate)` accessor over the private free fn
2441    /// [`expand_synonyms`], added for `v1/module_check.rs`:
2442    /// a sig `val`'s declared type may mention the
2443    /// module's own `type t = ..` synonym (by its pre-qualified `"M.t"`
2444    /// name, `v1/lower.rs`'s `TypeNameEnv`), and must expand through the
2445    /// SAME table the impl side's `build_variant_decl`/ordinary inference
2446    /// already does, so e.g. `val f : t -> t` over `type t = int` checks
2447    /// against the impl's expanded `int -> int`.
2448    pub(crate) fn expand_synonyms_in(&self, ty: &MonoType) -> Result<MonoType, TypeError> {
2449        expand_synonyms(ty, &self.synonyms)
2450    }
2451
2452    /// Deregister constructors hidden by a signature seal (
2453    /// `v1/module_check.rs`'s ctor-hide trigger — see that module's doc
2454    /// comment). Each entry is removed only if the currently-registered
2455    /// decl's type name matches, guarding against bare-name ctor collisions
2456    /// (`Checker.ctors` is last-writer-wins program-globally, 0.0.6-
2457    /// inherited): if a LATER unsealed variant re-registered the same ctor
2458    /// name after this one, its entry survives the hide untouched.
2459    pub(crate) fn hide_ctors(&mut self, entries: &[(String, String)]) {
2460        for (ctor, tyname) in entries {
2461            if self.ctors.get(ctor).is_some_and(|d| &d.name == tyname) {
2462                self.ctors.remove(ctor);
2463            }
2464            // Also drop the module-qualified key registered by
2465            // `declare_variant` (guarded by the same decl-identity check).
2466            if let Some((modpfx, _)) = tyname.rsplit_once('.') {
2467                let q = format!("{modpfx}.{ctor}");
2468                if self.ctors.get(&q).is_some_and(|d| &d.name == tyname) {
2469                    self.ctors.remove(&q);
2470                }
2471            }
2472        }
2473    }
2474
2475    /// `f ?(l = e, …) arg` — SATySFi 0.1 labeled-optional application
2476    /// inference. Kept OUT of the hot [`Checker::infer`] match (via
2477    /// `#[inline(never)]`) so its labeled-optional locals (`Vec`, `Row`) do
2478    /// not enlarge the deeply-recursed `infer` stack frame — see
2479    /// `infer_lambda_opt`.
2480    #[inline(never)]
2481    fn infer_apply_opt(
2482        &mut self,
2483        env: &TypeEnv<'s>,
2484        func: &Ast<'s>,
2485        opts: &[(String, Ast<'s>)],
2486        arg: &Ast<'s>,
2487    ) -> Result<MonoType, TypeError> {
2488        let tf = self.infer(env, func)?;
2489        let ta = self.infer(env, arg)?;
2490        let tr = self.fresh();
2491        let mut opt_tys = Vec::with_capacity(opts.len());
2492        for (label, e) in opts {
2493            opt_tys.push((label.clone(), self.infer(env, e)?));
2494        }
2495        let mut row = Row::Var(self.ctx.fresh_row_var());
2496        for (label, ty) in opt_tys.into_iter().rev() {
2497            row = Row::Cons(label, Box::new(ty), Box::new(row));
2498        }
2499        self.unify_ctx(
2500            &tf,
2501            &MonoType::Func(Box::new(row), Box::new(ta), Box::new(tr.clone())),
2502            ast_span(func),
2503            "function application",
2504        )?;
2505        Ok(tr)
2506    }
2507
2508    /// `fun ?(l = x, …) p -> body` — SATySFi 0.1 labeled-optional lambda
2509    /// inference. Kept OUT of the hot [`Checker::infer`] match
2510    /// (`#[inline(never)]`): `infer` recurses to the AST depth of the program
2511    /// under check, and Rust sizes a function's stack frame to its LARGEST
2512    /// match arm's locals; inlining this arm's `env.clone()` + `Vec`/`Row`
2513    /// locals into every `infer` frame measurably enlarged the deep recursion
2514    /// (enough to overflow the test harness's small default thread stack on a
2515    /// big merged program). Extracting it restores `infer`'s frame to its
2516    /// pre-optional-arg size.
2517    #[inline(never)]
2518    fn infer_lambda_opt(
2519        &mut self,
2520        env: &TypeEnv<'s>,
2521        opts: &[(String, Symbol<'s>)],
2522        param: Symbol<'s>,
2523        body: &Ast<'s>,
2524    ) -> Result<MonoType, TypeError> {
2525        let mut inner = env.clone();
2526        let mut opt_tys = Vec::with_capacity(opts.len());
2527        for (label, binder) in opts {
2528            let tl = self.fresh();
2529            inner = inner.with(*binder, PolyType::mono(t_option(tl.clone())), self.stage);
2530            opt_tys.push((label.clone(), tl));
2531        }
2532        let tp = self.fresh();
2533        inner = inner.with(param, PolyType::mono(tp.clone()), self.stage);
2534        let tb = self.infer(&inner, body)?;
2535        let mut row = Row::Empty;
2536        for (label, tl) in opt_tys.into_iter().rev() {
2537            row = Row::Cons(label, Box::new(tl), Box::new(row));
2538        }
2539        Ok(MonoType::Func(Box::new(row), Box::new(tp), Box::new(tb)))
2540    }
2541
2542    /// The stage a binding whose right-hand side is `value` is introduced at
2543    /// — upstream's `pre.stage` at the point of `Typeenv.add`.
2544    ///
2545    /// Upstream reads a whole FILE at one stage, so `pre.stage` is simply the
2546    /// ambient stage there. This port flattens every library into one
2547    /// `let`-chain and instead marks each spliced binding's RHS with
2548    /// [`Ast::StageScope`] (`elaborate.rs`'s per-item wrap), so the ambient
2549    /// stage is only right for a binding the current file wrote itself. The
2550    /// peeling through `ModuleScope`/`VersionScope` is because those wrappers
2551    /// are applied INSIDE `push_named_binding`/the `LetRec` arm, i.e. after
2552    /// the stage wrap, for a module member.
2553    ///
2554    /// Only the `ModuleScope` half of that peel actually fires: `elaborate.rs`
2555    /// applies `maybe_v006_scope` to a binding's RHS and `stage_wrap_item`
2556    /// outside it, so a cross-version staged binding is always
2557    /// `StageScope(_, VersionScope(_, ..))`. The `VersionScope` arm is kept
2558    /// anyway, because `elaborate::already_staged` peels the same two and the
2559    /// two must not disagree. Deleting the `ModuleScope` arm breaks
2560    /// `xver_staging.rs`'s
2561    /// `the_file_stage_and_the_version_scope_compose_on_a_module_member`.
2562    pub(crate) fn binding_stage(&self, value: &Ast<'s>) -> Stage {
2563        fn declared<'s>(a: &Ast<'s>) -> Option<Stage> {
2564            match a {
2565                Ast::StageScope(st, _) => Some(*st),
2566                Ast::ModuleScope(_, b) | Ast::VersionScope(_, b) => declared(b),
2567                _ => None,
2568            }
2569        }
2570        declared(value).unwrap_or(self.stage)
2571    }
2572
2573    /// The GENERATION one binding was authored in — the version analogue of
2574    /// [`Checker::binding_stage`], and needed for exactly the same reason: a
2575    /// merged cross-version program has ONE `Checker::version`, hard-coded
2576    /// to `V0_1` (`v1::module_check::check_program_inner`), while each
2577    /// spliced 0.0.6 dependency's bindings carry their own
2578    /// `Ast::VersionScope(V0_0, _)` on the RHS (`elaborate::
2579    /// maybe_v006_scope`). Any scheme rule that FORKS on the version must
2580    /// ask the binding, not the session, or a 0.0.6 package's binding is
2581    /// read under 0.1's rule.
2582    ///
2583    /// Concretely: `let-math`. `math_command_scheme` (0.0.6) and
2584    /// `math_command_scheme_v01` are two different rules for the same
2585    /// `Ast::LetMathIn`, and 0.1's demands three synthesized trailing
2586    /// `ctx`/`sub`/`sup` lambdas a 0.0.6 `let-math \frac = math-frac` does
2587    /// not have — dispatching on `self.version` refused EVERY `let-math` in
2588    /// a crossed 0.0.6 package, including the bundled `math.satyh` that
2589    /// `@require:` reaches transitively (`texlogo`, `latexcmds`, `siunitx`,
2590    /// …).
2591    ///
2592    /// The wrapper peel is `binding_stage`'s, minus its terminating arm:
2593    /// `elaborate::walk_bindings` puts `VersionScope` INSIDE `StageScope`
2594    /// (`already_staged`'s doc comment), so a staged spliced binding is
2595    /// `StageScope(_, VersionScope(_, ..))` and this must look through
2596    /// `StageScope` too.
2597    ///
2598    /// The peel alone is not enough, hence the `scoped_version` fallback:
2599    /// `maybe_v006_scope` wraps a TOP-LEVEL binding's RHS, but an
2600    /// EXPRESSION-level `let-math \c = e in body` (`elaborate.rs`'s
2601    /// `Expr::LetMathIn` arm, e.g. `siunitx`'s `let-math \C = ord \`C\` in
2602    /// ${\math-sup{}{\circ}\C}`) is a node inside another binding's
2603    /// already-wrapped RHS and carries no wrapper of its own — so the
2604    /// ambient generation, recorded by the `Ast::VersionScope` infer arm as
2605    /// it descends, answers for it instead. Outside every scope this is
2606    /// `None` and the session's own version answers.
2607    pub(crate) fn binding_version(&self, value: &Ast<'s>) -> RustyfiVersion {
2608        fn declared<'s>(a: &Ast<'s>) -> Option<RustyfiVersion> {
2609            match a {
2610                Ast::VersionScope(v, _) => Some(*v),
2611                Ast::StageScope(_, b) | Ast::ModuleScope(_, b) => declared(b),
2612                _ => None,
2613            }
2614        }
2615        declared(value)
2616            .or(self.scoped_version)
2617            .unwrap_or(self.version)
2618    }
2619
2620    /// [`Checker::binding_stage`] for a `let-rec` GROUP. Every clause of one
2621    /// group comes from one item of one file, so they share a stage; the
2622    /// elaborator wraps them identically and the first is representative.
2623    pub(crate) fn binding_stage_rec(&self, bindings: &[(Symbol<'s>, Rc<Ast<'s>>)]) -> Stage {
2624        match bindings.first() {
2625            Some((_, v)) => self.binding_stage(v),
2626            None => self.stage,
2627        }
2628    }
2629
2630    /// Look `name` up for an OCCURRENCE, enforcing the staging matrix
2631    /// ([`Stage::can_reference`]) — upstream's `UTContentOf` arm, which is
2632    /// where a stage-0 name used from stage 1 (or the reverse) is refused.
2633    ///
2634    /// `Ok(None)` means unbound, left to each caller because each has its own
2635    /// "should not happen post-elaboration" wording. `what` names the kind of
2636    /// occurrence for the diagnostic (`"variable"`, `"inline command"`, …).
2637    fn staged<'e>(
2638        &self,
2639        env: &'e TypeEnv<'s>,
2640        name: Symbol<'s>,
2641        span: Option<Span>,
2642        what: &str,
2643    ) -> Result<Option<&'e PolyType>, TypeError> {
2644        let Some(entry) = env.entry(name) else {
2645            return Ok(None);
2646        };
2647        if !self.stage.can_reference(entry.stage) {
2648            return Err(TypeError::simple(
2649                span,
2650                format!(
2651                    "invalid occurrence of {what} '{}' as to stage: it is bound at {}, \
2652                     but this is {}",
2653                    self.text(name),
2654                    entry.stage.as_str(),
2655                    self.stage.as_str()
2656                ),
2657            ));
2658        }
2659        Ok(Some(&entry.poly))
2660    }
2661
2662    /// `infer`, reading `ast` at a different stage and restoring afterwards --
2663    /// the type-side twin of the `ctor_scope` push/pop below it.
2664    fn infer_at(
2665        &mut self,
2666        stage: Stage,
2667        env: &TypeEnv<'s>,
2668        ast: &Ast<'s>,
2669    ) -> Result<MonoType, TypeError> {
2670        let saved = std::mem::replace(&mut self.stage, stage);
2671        let result = self.infer(env, ast);
2672        self.stage = saved;
2673        result
2674    }
2675
2676    fn infer(&mut self, env: &TypeEnv<'s>, ast: &Ast<'s>) -> Result<MonoType, TypeError> {
2677        match ast {
2678            // A binding spliced in from a file whose `@stage:` was not the
2679            // default: read it at that stage, so its quotes are legal.
2680            Ast::StageScope(stage, body) => self.infer_at(*stage, env, body),
2681            // `&e` — quote. Legal only at stage 0; its body is read one
2682            // stage later, and the result is that body's type wrapped in
2683            // `code` (upstream `typechecker.ml`'s `UTNext` arm).
2684            Ast::Next(inner) => {
2685                if self.stage != Stage::Stage0 {
2686                    return Err(TypeError::simple(
2687                        None,
2688                        format!(
2689                            "`&` (next-stage quote) is only valid at stage 0, but this is {}",
2690                            self.stage.as_str()
2691                        ),
2692                    ));
2693                }
2694                let ty = self.infer_at(Stage::Stage1, env, inner)?;
2695                Ok(MonoType::Code(Box::new(ty)))
2696            }
2697            // `~e` — splice. Legal only at stage 1; its body is read one stage
2698            // earlier and must produce `code b`, which this expression then
2699            // stands for (upstream's `UTPrev` arm).
2700            Ast::Prev(inner) => {
2701                if self.stage != Stage::Stage1 {
2702                    return Err(TypeError::simple(
2703                        None,
2704                        format!(
2705                            "`~` (previous-stage splice) is only valid at stage 1, but this is {}",
2706                            self.stage.as_str()
2707                        ),
2708                    ));
2709                }
2710                let ty = self.infer_at(Stage::Stage0, env, inner)?;
2711                let beta = MonoType::Var(self.ctx.fresh_var());
2712                unify(&ty, &MonoType::Code(Box::new(beta.clone())))
2713                    .map_err(|e| TypeError::from_unify(None, "a `~` splice", e))?;
2714                Ok(beta)
2715            }
2716            Ast::Unit => Ok(t_unit()),
2717            Ast::Bool(_) => Ok(t_bool()),
2718            Ast::Int(_) => Ok(t_int()),
2719            Ast::Float(_) => Ok(t_float()),
2720            Ast::Length(_) => Ok(t_length()),
2721            Ast::Str(_) => Ok(t_string()),
2722
2723            Ast::Var(name, span) => match self.staged(env, *name, Some(*span), "variable")? {
2724                Some(poly) => Ok(instantiate(poly, self.ctx.level())),
2725                // Should not happen post-elaboration: `elaborate.rs`'s
2726                // `scoped_var` already rejects any unbound name before this
2727                // ever runs. Surfaced as a (spanned) error rather than a
2728                // panic anyway, since "should not happen" isn't "cannot".
2729                None => Err(TypeError::simple(
2730                    Some(*span),
2731                    format!(
2732                        "internal error: unbound variable '{}' reached the typechecker",
2733                        self.text(*name)
2734                    ),
2735                )),
2736            },
2737
2738            Ast::Apply(f, a) => {
2739                let tf = self.infer(env, f)?;
2740                let ta = self.infer(env, a)?;
2741                let tr = self.fresh();
2742                // Version-split the optional-argument row: 0.0.6 functions
2743                // provably carry no labeled optionals (`Row::Empty`, matching
2744                // `arrow()` and every prim). Under 0.1 a fresh open row var
2745                // absorbs the callee's
2746                // declared optional row, letting a *plain* call of an
2747                // opt-taking function typecheck (defaulting every optional to
2748                // `None` at run time) and making higher-order code
2749                // row-polymorphic after generalization.
2750                let opts_row = if self.version.has_row_polymorphism() {
2751                    Row::Var(self.ctx.fresh_row_var())
2752                } else {
2753                    Row::Empty
2754                };
2755                self.unify_ctx(
2756                    &tf,
2757                    &MonoType::Func(Box::new(opts_row), Box::new(ta), Box::new(tr.clone())),
2758                    ast_span(f),
2759                    "function application",
2760                )?;
2761                Ok(tr)
2762            }
2763
2764            Ast::Lambda(param, body) => {
2765                let tp = self.fresh();
2766                let inner = env.with(*param, PolyType::mono(tp.clone()), self.stage);
2767                let tb = self.infer(&inner, body)?;
2768                Ok(arrow(tp, tb))
2769            }
2770
2771            // `f ?(l = e, …) arg` (SATySFi 0.1; upstream typechecker.ml's
2772            // `Apply(labmap, …)`). The callee must unify with a function
2773            // whose optional row carries at least each supplied `l : τ_l`,
2774            // with an open tail (a fresh row var) for any further optionals
2775            // the callee declares that this call omits.
2776            Ast::ApplyOpt { func, opts, arg } => self.infer_apply_opt(env, func, opts, arg),
2777
2778            // `fun ?(l = x, …) p -> body` (SATySFi 0.1; upstream
2779            // `Function(evid_labmap, …)`). Each labeled optional binder `x`
2780            // is bound at `option τ_l` inside the body; the resulting
2781            // function type carries a CLOSED row `?(l : τ_l, …)` — the very
2782            // same fresh `τ_l` shared between the binder's `option τ_l` and
2783            // the row's `Cons(l, τ_l)`.
2784            Ast::LambdaOpt { opts, param, body } => self.infer_lambda_opt(env, opts, *param, body),
2785
2786            Ast::LetIn(name, value, body) => {
2787                let schemes = self.infer_binding(env, BindingView::Let { name: *name, value })?;
2788                let inner = env.with_all(schemes, self.binding_stage(value));
2789                self.infer(&inner, body)
2790            }
2791
2792            // `let-math \cmd param* = expr in body` — see
2793            // `infer_binding`'s `BindingView::LetMath` arm.
2794            Ast::LetMathIn(name, value, body) => {
2795                let schemes =
2796                    self.infer_binding(env, BindingView::LetMath { name: *name, value })?;
2797                let inner = env.with_all(schemes, self.binding_stage(value));
2798                self.infer(&inner, body)
2799            }
2800
2801            Ast::LetRecIn(bindings, body) => {
2802                let schemes = self.infer_binding(env, BindingView::LetRec(bindings))?;
2803                let inner = env.with_all(schemes, self.binding_stage_rec(bindings));
2804                self.infer(&inner, body)
2805            }
2806
2807            Ast::IfThenElse(cond, then_b, else_b) => {
2808                let tc = self.infer(env, cond)?;
2809                self.unify_ctx(&t_bool(), &tc, ast_span(cond), "the condition of 'if'")?;
2810                let tt = self.infer(env, then_b)?;
2811                let te = self.infer(env, else_b)?;
2812                self.unify_ctx(&tt, &te, ast_span(else_b), "the branches of 'if'")?;
2813                Ok(tt)
2814            }
2815
2816            Ast::Match(scrutinee, arms) => {
2817                let tscrut = self.infer(env, scrutinee)?;
2818                let mut result: Option<MonoType> = None;
2819                for arm in arms {
2820                    let arm_env = self.bind_pattern(env.clone(), &arm.pat, &tscrut)?;
2821                    if let Some(guard) = &arm.guard {
2822                        let tg = self.infer(&arm_env, guard)?;
2823                        self.unify_ctx(&t_bool(), &tg, ast_span(guard), "a match guard")?;
2824                    }
2825                    let tbody = self.infer(&arm_env, &arm.body)?;
2826                    match &result {
2827                        None => result = Some(tbody),
2828                        Some(r) => {
2829                            self.unify_ctx(r, &tbody, ast_span(&arm.body), "the arms of 'match'")?
2830                        }
2831                    }
2832                }
2833                // Exhaustiveness/redundancy: non-fatal, so it runs
2834                // only after every arm has
2835                // typechecked, against `tscrut` as resolved as inference will
2836                // ever make it. See `exhaustive::check_match`'s doc comment.
2837                let resolved_scrut = resolve(&tscrut);
2838                let new_warnings = crate::exhaustive::check_match(
2839                    self.store,
2840                    &resolved_scrut,
2841                    ast_span(scrutinee),
2842                    arms,
2843                    &self.variants,
2844                );
2845                self.warnings.extend(new_warnings);
2846                // `Match`'s `arms` is always non-empty (`c::Expr::Match`
2847                // requires a `first` arm plus zero or more `rest`), so
2848                // `result` is always `Some` in practice; the fallback fresh
2849                // variable is defensive only.
2850                Ok(result.unwrap_or_else(|| self.fresh()))
2851            }
2852
2853            Ast::Tuple(items) => {
2854                let tys = items
2855                    .iter()
2856                    .map(|it| self.infer(env, it))
2857                    .collect::<Result<Vec<_>, _>>()?;
2858                Ok(product(tys))
2859            }
2860
2861            Ast::Ctor(name, payload) => self.infer_ctor(env, name, payload.as_deref(), None),
2862
2863            Ast::Record(fields) => {
2864                let mut typed = Vec::with_capacity(fields.len());
2865                for (label, e) in fields {
2866                    typed.push((label.clone(), self.infer(env, e)?));
2867                }
2868                let mut row = Row::Empty;
2869                for (label, ty) in typed.into_iter().rev() {
2870                    row = Row::Cons(label, Box::new(ty), Box::new(row));
2871                }
2872                Ok(MonoType::Record(row))
2873            }
2874
2875            Ast::List(items) => {
2876                let elem = self.fresh();
2877                for it in items {
2878                    let t = self.infer(env, it)?;
2879                    self.unify_ctx(&elem, &t, ast_span(it), "a list element")?;
2880                }
2881                Ok(list(elem))
2882            }
2883
2884            Ast::InlineText(elems) => {
2885                for e in elems.iter() {
2886                    self.check_itext(env, e)?;
2887                }
2888                Ok(t_inline_text())
2889            }
2890
2891            Ast::BlockText(elems) => {
2892                for e in elems.iter() {
2893                    self.check_btext(env, e)?;
2894                }
2895                Ok(t_block_text())
2896            }
2897
2898            Ast::MathText(elems) => {
2899                for e in elems.iter() {
2900                    self.check_math_elem(env, e)?;
2901                }
2902                Ok(MonoType::Base(BaseType::MathText))
2903            }
2904
2905            Ast::LetMutableIn(name, init, body) => {
2906                // NO generalization: `let-mutable`'s binding is the
2907                // classic ML "value restriction" case — see
2908                // `infer_binding`'s `BindingView::LetMutable` arm.
2909                let schemes =
2910                    self.infer_binding(env, BindingView::LetMutable { name: *name, init })?;
2911                let inner = env.with_all(schemes, self.binding_stage(init));
2912                self.infer(&inner, body)
2913            }
2914
2915            Ast::Overwrite(name, span, value) => {
2916                let t_ref = match self.staged(env, *name, Some(*span), "mutable variable")? {
2917                    Some(poly) => instantiate(poly, self.ctx.level()),
2918                    None => {
2919                        return Err(TypeError::simple(
2920                            Some(*span),
2921                            format!(
2922                            "internal error: unbound mutable variable '{}' reached the typechecker",
2923                            self.text(*name)
2924                        ),
2925                        ))
2926                    }
2927                };
2928                let inner = self.fresh();
2929                self.unify_ctx(
2930                    &t_ref,
2931                    &reff(inner.clone()),
2932                    Some(*span),
2933                    &format!("the overwrite target '{}'", self.text(*name)),
2934                )?;
2935                let tvalue = self.infer(env, value)?;
2936                // Prefer the overwrite's own (always-present) span over
2937                // `ast_span(value)`, which is `None` for most value shapes
2938                // (literals carry no span at all — see `ast.rs`'s module
2939                // doc comment) and would otherwise leave this common error
2940                // unlocated.
2941                self.unify_ctx(
2942                    &inner,
2943                    &tvalue,
2944                    ast_span(value).or(Some(*span)),
2945                    &format!("the overwrite value for '{}'", self.text(*name)),
2946                )?;
2947                Ok(t_unit())
2948            }
2949
2950            Ast::WhileDo(cond, body) => {
2951                let tc = self.infer(env, cond)?;
2952                self.unify_ctx(&t_bool(), &tc, ast_span(cond), "the condition of 'while'")?;
2953                let tb = self.infer(env, body)?;
2954                self.unify_ctx(&t_unit(), &tb, ast_span(body), "the body of 'while'")?;
2955                Ok(t_unit())
2956            }
2957
2958            Ast::Sequential(a, b) => {
2959                let ta = self.infer(env, a)?;
2960                // v0.0.6 requires the left-hand side of `before`/`;` to be
2961                // `unit` (`typechecker.ml`'s `UTSequential` case): not just
2962                // "evaluated and discarded" but type-checked as `unit`
2963                // specifically, so e.g. a stray non-unit expression used
2964                // only for effect (but returning, say, an `int`) is rejected
2965                // rather than silently ignored.
2966                self.unify_ctx(
2967                    &t_unit(),
2968                    &ta,
2969                    ast_span(a),
2970                    "the left-hand side of 'before'",
2971                )?;
2972                self.infer(env, b)
2973            }
2974
2975            Ast::AccessField(e, label, span) => {
2976                let te = self.infer(env, e)?;
2977                let field = self.fresh();
2978                let rv = self.ctx.fresh_row_var();
2979                let open_row = MonoType::Record(Row::Cons(
2980                    label.clone(),
2981                    Box::new(field.clone()),
2982                    Box::new(Row::Var(rv)),
2983                ));
2984                self.unify_ctx(
2985                    &open_row,
2986                    &te,
2987                    Some(*span),
2988                    &format!("the field access '#{label}'"),
2989                )?;
2990                Ok(field)
2991            }
2992
2993            Ast::UpdateField(base, label, value) => {
2994                let tbase = self.infer(env, base)?;
2995                let tvalue = self.infer(env, value)?;
2996                let rv = self.ctx.fresh_row_var();
2997                let open_row = MonoType::Record(Row::Cons(
2998                    label.clone(),
2999                    Box::new(tvalue),
3000                    Box::new(Row::Var(rv)),
3001                ));
3002                self.unify_ctx(
3003                    &open_row,
3004                    &tbase,
3005                    ast_span(base),
3006                    &format!("the record update of '{label}'"),
3007                )?;
3008                Ok(tbase)
3009            }
3010
3011            // Swap the active primitive-type env to `version`'s
3012            // for `body` — see `version_scoped_type_env`'s doc comment —
3013            // and make sure `version`'s builtin ADTs (e.g. `page`,
3014            // `V0_0`-only) are registered in the (otherwise
3015            // whole-program-tagged) ctor table — see
3016            // `install_additional_builtin_variants`'s doc comment. Never
3017            // reached on a pure single-version program (no
3018            // `Ast::VersionScope` node is ever produced there).
3019            Ast::VersionScope(version, body) => {
3020                self.install_additional_builtin_variants(*version);
3021                let scoped = version_scoped_type_env(self.store, env, *version);
3022                // Also make the generation available to any version-forking
3023                // rule reached INSIDE the body — an expression-level
3024                // `let-math .. in ..` is the one that needs it, since its
3025                // own `Ast::LetMathIn` carries no wrapper of its own. See
3026                // `Checker::binding_version`.
3027                let saved = self.scoped_version.replace(*version);
3028                let r = self.infer(&scoped, body);
3029                self.scoped_version = saved;
3030                r
3031            }
3032            // A module member's body: resolve its bare constructor references
3033            // against `path`'s constructors first. `path` is the full absolute
3034            // module path (nested modules wrap with `["M","N"]`), so replace
3035            // rather than push.
3036            Ast::ModuleScope(path, body) => {
3037                let saved = std::mem::replace(&mut self.ctor_scope, path.clone());
3038                let r = self.infer(env, body);
3039                self.ctor_scope = saved;
3040                r
3041            }
3042        }
3043    }
3044
3045    /// Look up a constructor honoring the current [`Checker::ctor_scope`]: try
3046    /// the innermost-out module-qualified keys (`M.N.Ctor`, `M.Ctor`) before
3047    /// the bare fallback (`Ctor`). Keeps the returned decl's ctor NAME strings
3048    /// bare — only the table KEY is qualified — so eval/exhaustiveness/error
3049    /// text are untouched.
3050    fn lookup_ctor(&self, name: &str) -> Option<Rc<VariantDecl>> {
3051        for k in (1..=self.ctor_scope.len()).rev() {
3052            let key = format!("{}.{}", self.ctor_scope[..k].join("."), name);
3053            if let Some(d) = self.ctors.get(&key) {
3054                return Some(d.clone());
3055            }
3056        }
3057        self.ctors.get(name).cloned()
3058    }
3059
3060    /// Shared by `Ast::Ctor` and pattern-matching's `Pattern::Ctor`: look up
3061    /// `name`'s declaration, mint fresh type arguments for its (possibly
3062    /// zero) parameters, and check the payload — either an already-inferred
3063    /// expression type to unify against (`Ast::Ctor`'s case, via `infer`
3064    /// directly) or nothing (patterns bind their own payload separately, in
3065    /// `bind_pattern`). `expected_result`, if given, is unified against the
3066    /// application's result type — used by nothing yet in this port's
3067    /// rules but kept general for symmetry; always `None` from `infer`,
3068    /// which just returns the result type instead.
3069    fn infer_ctor(
3070        &mut self,
3071        env: &TypeEnv<'s>,
3072        name: &str,
3073        payload: Option<&Ast<'s>>,
3074        expected_result: Option<&MonoType>,
3075    ) -> Result<MonoType, TypeError> {
3076        let decl = self
3077            .lookup_ctor(name)
3078            .ok_or_else(|| TypeError::simple(None, format!("unknown constructor '{name}'")))?;
3079        let args: Vec<MonoType> = (0..decl.params).map(|_| self.fresh()).collect();
3080        let (payload_ty, result_ty) = decl.instantiate_ctor(name, &args).ok_or_else(|| {
3081            TypeError::simple(
3082                None,
3083                format!("constructor '{name}' applied with the wrong number of type arguments"),
3084            )
3085        })?;
3086        if let Some(expected) = expected_result {
3087            self.unify_ctx(expected, &result_ty, None, &format!("constructor '{name}'"))?;
3088        }
3089        match (payload_ty, payload) {
3090            (Some(expected), Some(actual)) => {
3091                let actual_ty = self.infer(env, actual)?;
3092                self.unify_ctx(
3093                    &expected,
3094                    &actual_ty,
3095                    ast_span(actual),
3096                    &format!("the payload of constructor '{name}'"),
3097                )?;
3098            }
3099            (None, None) => {}
3100            (Some(_), None) => {
3101                return Err(TypeError::simple(
3102                    None,
3103                    format!("constructor '{name}' expects a payload but none was given"),
3104                ))
3105            }
3106            (None, Some(_)) => {
3107                return Err(TypeError::simple(
3108                    None,
3109                    format!("constructor '{name}' takes no payload but one was given"),
3110                ))
3111            }
3112        }
3113        Ok(result_ty)
3114    }
3115
3116    // ---- patterns ------------------------------------------------------
3117
3118    /// Type-check `pat` against `ty`, extending (a clone of) `env` with
3119    /// every name it binds. Mirrors `typechecker.ml`'s `typecheck_pattern`.
3120    fn bind_pattern(
3121        &mut self,
3122        env: TypeEnv<'s>,
3123        pat: &Pattern<'s>,
3124        ty: &MonoType,
3125    ) -> Result<TypeEnv<'s>, TypeError> {
3126        match pat {
3127            Pattern::Wild => Ok(env),
3128            Pattern::Var(name) => Ok(env.with(*name, PolyType::mono(ty.clone()), self.stage)),
3129            Pattern::Unit => {
3130                self.unify_ctx(&t_unit(), ty, None, "a unit pattern")?;
3131                Ok(env)
3132            }
3133            Pattern::Bool(_) => {
3134                self.unify_ctx(&t_bool(), ty, None, "a boolean pattern")?;
3135                Ok(env)
3136            }
3137            Pattern::Int(_) => {
3138                self.unify_ctx(&t_int(), ty, None, "an integer pattern")?;
3139                Ok(env)
3140            }
3141            Pattern::Str(_) => {
3142                self.unify_ctx(&t_string(), ty, None, "a string pattern")?;
3143                Ok(env)
3144            }
3145            Pattern::Tuple(pats) => {
3146                let elem_tys: Vec<MonoType> = pats.iter().map(|_| self.fresh()).collect();
3147                self.unify_ctx(&product(elem_tys.clone()), ty, None, "a tuple pattern")?;
3148                let mut env = env;
3149                for (p, t) in pats.iter().zip(elem_tys.iter()) {
3150                    env = self.bind_pattern(env, p, t)?;
3151                }
3152                Ok(env)
3153            }
3154            Pattern::EmptyList => {
3155                let elem = self.fresh();
3156                self.unify_ctx(&list(elem), ty, None, "an empty-list pattern")?;
3157                Ok(env)
3158            }
3159            Pattern::Cons(head, tail) => {
3160                let elem = self.fresh();
3161                self.unify_ctx(&list(elem.clone()), ty, None, "a cons pattern")?;
3162                let env = self.bind_pattern(env, head, &elem)?;
3163                self.bind_pattern(env, tail, &list(elem))
3164            }
3165            Pattern::Ctor(name, payload) => {
3166                let decl = self.lookup_ctor(name).ok_or_else(|| {
3167                    TypeError::simple(None, format!("unknown constructor '{name}' in a pattern"))
3168                })?;
3169                let args: Vec<MonoType> = (0..decl.params).map(|_| self.fresh()).collect();
3170                let (payload_ty, result_ty) = decl.instantiate_ctor(name, &args).ok_or_else(|| {
3171                    TypeError::simple(
3172                        None,
3173                        format!(
3174                            "constructor '{name}' applied with the wrong number of type arguments in a pattern"
3175                        ),
3176                    )
3177                })?;
3178                self.unify_ctx(
3179                    &result_ty,
3180                    ty,
3181                    None,
3182                    &format!("the constructor pattern '{name}'"),
3183                )?;
3184                match (payload_ty, payload) {
3185                    (Some(expected), Some(p)) => self.bind_pattern(env, p, &expected),
3186                    (None, None) => Ok(env),
3187                    (Some(_), None) => Err(TypeError::simple(
3188                        None,
3189                        format!(
3190                            "constructor pattern '{name}' expects a payload but none was given"
3191                        ),
3192                    )),
3193                    (None, Some(_)) => Err(TypeError::simple(
3194                        None,
3195                        format!("constructor pattern '{name}' takes no payload but one was given"),
3196                    )),
3197                }
3198            }
3199            Pattern::As(inner, name) => {
3200                let env = self.bind_pattern(env, inner, ty)?;
3201                Ok(env.with(*name, PolyType::mono(ty.clone()), self.stage))
3202            }
3203        }
3204    }
3205
3206    // ---- inline / block / math text -------------------------------------
3207
3208    /// Check one inline-text element. A command's own type is a genuine
3209    /// `MonoType::InlineCmd(params)` (`[...] inline-cmd`, mirroring v0.0.6's
3210    /// `HorzCommandType`) — bound either by `Ast::LetIn`'s command-binding
3211    /// rule (`Checker::command_scheme`) or, for the port's built-in
3212    /// commands, directly by `prim_types::primitive_type`'s `\emph` entry.
3213    /// Checking an application here is exact-arity plus one unification per
3214    /// argument against `params`, via `check_cmd_args` — there is no
3215    /// `context -> arg1 -> .. -> inline-boxes` function shape to unify the
3216    /// whole command type against.
3217    fn check_itext(&mut self, env: &TypeEnv<'s>, it: &IText<'s>) -> Result<(), TypeError> {
3218        match it {
3219            IText::Text(_) | IText::CodeText(_) => Ok(()),
3220            IText::Cmd { name, span, args } => {
3221                let tcmd = match self.staged(env, *name, Some(*span), "inline command")? {
3222                    Some(poly) => instantiate(poly, self.ctx.level()),
3223                    None => {
3224                        return Err(TypeError::simple(
3225                            Some(*span),
3226                            format!(
3227                            "internal error: unbound inline command '{}' reached the typechecker",
3228                            self.text(*name)
3229                        ),
3230                        ))
3231                    }
3232                };
3233                match &*resolve(&tcmd) {
3234                    MonoType::InlineCmd(params) => {
3235                        self.check_cmd_args(env, self.text(*name), *span, &params, args)
3236                    }
3237                    other => Err(TypeError::simple(
3238                        Some(*span),
3239                        format!(
3240                            "internal error: inline command '{}' does not have an \
3241                             inline-cmd type (found `{other}`)",
3242                            self.text(*name)
3243                        ),
3244                    )),
3245                }
3246            }
3247            IText::Embed { expr, span } => {
3248                let te = self.infer(env, expr)?;
3249                self.unify_ctx(
3250                    &t_inline_text(),
3251                    &te,
3252                    Some(*span),
3253                    "an inline-text '#…;' embed",
3254                )?;
3255                Ok(())
3256            }
3257            IText::EmbedMath { elems, span: _ } => {
3258                // PERMISSIVE: there is no real type to check a quoted-math
3259                // embed's expressions against. Type each against its own
3260                // fresh variable, purely so unbound-name mistakes inside it
3261                // are still caught, without asserting anything about the
3262                // result.
3263                for me in elems.iter() {
3264                    self.check_math_elem(env, me)?;
3265                }
3266                Ok(())
3267            }
3268        }
3269    }
3270
3271    /// Block-text analogue of `check_itext`'s `IText::Cmd` case — see its
3272    /// doc comment; a `BText::Cmd`'s type is `MonoType::BlockCmd(params)`.
3273    fn check_btext(&mut self, env: &TypeEnv<'s>, bt: &BText<'s>) -> Result<(), TypeError> {
3274        match bt {
3275            BText::Cmd { name, span, args } => {
3276                let tcmd = match self.staged(env, *name, Some(*span), "block command")? {
3277                    Some(poly) => instantiate(poly, self.ctx.level()),
3278                    None => {
3279                        return Err(TypeError::simple(
3280                            Some(*span),
3281                            format!(
3282                            "internal error: unbound block command '{}' reached the typechecker",
3283                            self.text(*name)
3284                        ),
3285                        ))
3286                    }
3287                };
3288                match &*resolve(&tcmd) {
3289                    MonoType::BlockCmd(params) => {
3290                        self.check_cmd_args(env, self.text(*name), *span, &params, args)
3291                    }
3292                    other => Err(TypeError::simple(
3293                        Some(*span),
3294                        format!(
3295                            "internal error: block command '{}' does not have a \
3296                             block-cmd type (found `{other}`)",
3297                            self.text(*name)
3298                        ),
3299                    )),
3300                }
3301            }
3302            BText::Embed { expr, span } => {
3303                let te = self.infer(env, expr)?;
3304                self.unify_ctx(
3305                    &t_block_text(),
3306                    &te,
3307                    Some(*span),
3308                    "a block-text '#…;' embed",
3309                )?;
3310                Ok(())
3311            }
3312        }
3313    }
3314
3315    /// Walk one quoted math element. `Chars`/`Group`/`Sub`/`Sup`/`Primes`
3316    /// carry no program-mode content of their own (nothing to check beyond
3317    /// recursing). `Cmd`/`Embed` are where math meets the ordinary
3318    /// expression language: a `Cmd`'s `name` must resolve to a genuine
3319    /// `MathCmd` type (checked exactly like `check_itext`'s `IText::Cmd`,
3320    /// via `check_cmd_args` — a math command's optional `?:`/`?*`-marked
3321    /// or marker-less-padded arguments are handled by `check_cmd_args` the
3322    /// same generic way), and an `Embed`'s (`#expr`) type must unify with
3323    /// `math` (a math command parameter, or another program-mode value
3324    /// that itself produces math — `Value::Math`/ `Value::MathText` are
3325    /// the two runtime shapes this unifies against, see `value.rs`).
3326    fn check_math_elem(&mut self, env: &TypeEnv<'s>, m: &MathElem<'s>) -> Result<(), TypeError> {
3327        match m {
3328            MathElem::Chars(_) => Ok(()),
3329            MathElem::Group(elems) => {
3330                for e in elems {
3331                    self.check_math_elem(env, e)?;
3332                }
3333                Ok(())
3334            }
3335            MathElem::Sub(base, script) | MathElem::Sup(base, script) => {
3336                self.check_math_elem(env, base)?;
3337                for e in script {
3338                    self.check_math_elem(env, e)?;
3339                }
3340                Ok(())
3341            }
3342            MathElem::Primes(base, _) => self.check_math_elem(env, base),
3343            MathElem::Cmd { name, span, args } => {
3344                let tcmd = match self.staged(env, *name, Some(*span), "math command")? {
3345                    Some(poly) => instantiate(poly, self.ctx.level()),
3346                    None => {
3347                        return Err(TypeError::simple(
3348                            Some(*span),
3349                            format!(
3350                                "internal error: unbound math command '{}' reached the typechecker",
3351                                self.text(*name)
3352                            ),
3353                        ))
3354                    }
3355                };
3356                match &*resolve(&tcmd) {
3357                    MonoType::MathCmd(params) => {
3358                        self.check_cmd_args(env, self.text(*name), *span, &params, args)
3359                    }
3360                    other => Err(TypeError::simple(
3361                        Some(*span),
3362                        format!(
3363                            "internal error: math command '{}' does not have a \
3364                             math-cmd type (found `{other}`)",
3365                            self.text(*name)
3366                        ),
3367                    )),
3368                }
3369            }
3370            MathElem::Embed { expr, span } => {
3371                let te = self.infer(env, expr)?;
3372                self.unify_ctx(&t_math_text(), &te, Some(*span), "a math '#…' embed")?;
3373                Ok(())
3374            }
3375        }
3376    }
3377}
3378
3379/// If `name` (an `Ast::LetIn` binding's name) is command-shaped, the sigil
3380/// that says which kind — `'\\'` for an inline command, `'+'` for a block
3381/// command — else `None` for an ordinary variable binding.
3382///
3383/// Looks only at the *local* segment (after the last `.`): a
3384/// module-qualified command is spelled e.g. `"M.\cmd"`, sigil on the local
3385/// part only (module names can never start with `\`/`+` — `qualify_key`'s
3386/// doc comment). A bare name has no `.` at all, so `rsplit('.').next()`
3387/// degrades to the whole string.
3388///
3389/// **Must also check the second character.** A genuine command sigil is
3390/// always immediately followed by an identifier, but a parenthesized
3391/// operator NAME (`cst.rs`'s `BindName`) can merely *start* with the same
3392/// character, e.g. `let (+++>) = ..` (`itemize.satyh`) or `let (+.) = ..`.
3393/// Requiring an alphabetic second character mirrors the lexer's own split
3394/// and keeps such an operator name an ordinary variable binding rather than
3395/// a false-positive command.
3396fn command_sigil(name: &str) -> Option<char> {
3397    let local = name.rsplit('.').next().unwrap_or(name);
3398    let mut chars = local.chars();
3399    match chars.next() {
3400        Some(c @ ('\\' | '+')) if chars.next().is_some_and(|c2| c2.is_ascii_alphabetic()) => {
3401            Some(c)
3402        }
3403        _ => None,
3404    }
3405}
3406
3407/// Greedily unwrap a (resolved) `Func` chain into its list of domains and
3408/// final codomain: `dom1 -> dom2 -> .. -> domN -> result` becomes
3409/// `(vec![dom1, .., domN], result)`. Only ever follows the *codomain* at
3410/// each step (never recurses into a domain, even one that is itself a
3411/// `Func`) — used by `Checker::command_scheme` to recover a `let-inline`/
3412/// `let-block` binding's `context -> arg1 -> .. -> argN -> result` shape
3413/// from its ordinarily-inferred function type.
3414fn peel_func_chain(ty: MonoType) -> (Vec<MonoType>, MonoType) {
3415    let mut doms = Vec::new();
3416    let mut cur = ty;
3417    loop {
3418        // Owned: this walk moves each arrow's domain/codomain out.
3419        match resolve(&cur).into_owned() {
3420            MonoType::Func(_row, dom, cod) => {
3421                doms.push(*dom);
3422                cur = *cod;
3423            }
3424            other => return (doms, other),
3425        }
3426    }
3427}
3428
3429/// [`peel_func_chain`]'s row-carrying twin:
3430/// same greedy unwrap, but keeps each arrow's own (resolved) optional-
3431/// argument [`Row`] alongside its domain, since a V0_1 command's `LambdaOpt`-
3432/// produced arrows carry each parameter's `?(l:τ,…)` bundle on that
3433/// PARAMETER's own arrow (the arrow whose *domain* is the labeled argument —
3434/// see `Checker::command_scheme`'s V0_1 harvest). Used only by
3435/// `command_scheme`; `check_cmd_args`/`math_command_scheme*` still use the
3436/// row-blind `peel_func_chain` (they never harvest labels).
3437fn peel_func_chain_rows(ty: MonoType) -> (Vec<(Row, MonoType)>, MonoType) {
3438    let mut slots = Vec::new();
3439    let mut cur = ty;
3440    loop {
3441        // Owned, as in `peel_func_chain`.
3442        match resolve(&cur).into_owned() {
3443            MonoType::Func(row, dom, cod) => {
3444                slots.push((*row, *dom));
3445                cur = *cod;
3446            }
3447            other => return (slots, other),
3448        }
3449    }
3450}
3451
3452/// Turn one V0_1 command parameter's [`Row`] (the row `Ast::LambdaOpt`'s
3453/// inference leaves on the `Func` arrow whose *domain* is that parameter —
3454/// see [`peel_func_chain_rows`]) into a closed-label-map [`CmdArgType`]: walk
3455/// the (resolved) row's `Cons` chain into a `Vec<(String, MonoType)>`, sorted
3456/// by label so `unify_cmd_args`'s equal-domain zip is order-insensitive.
3457/// A leftover `Row::Var` (an
3458/// under-constrained/free row — the ordinary case for a slot with no `?(…)`
3459/// bundle at all) defaults to no labels, same as `Row::Empty`. Shared by
3460/// `Checker::command_scheme`'s V0_1 branch and
3461/// `Checker::math_command_scheme_v01` so both harvest
3462/// identically.
3463fn harvest_slot(row: Row, dom: MonoType) -> CmdArgType {
3464    let mut opt_labels: Vec<(String, MonoType)> = Vec::new();
3465    let mut cur = resolve_row(&row).into_owned();
3466    loop {
3467        match cur {
3468            Row::Empty => break,
3469            Row::Var(_) => break,
3470            Row::Cons(label, lty, rest) => {
3471                opt_labels.push((label, *lty));
3472                cur = resolve_row(&rest).into_owned();
3473            }
3474        }
3475    }
3476    opt_labels.sort_by(|a, b| a.0.cmp(&b.0));
3477    labeled(opt_labels, dom)
3478}
3479
3480/// A best-effort span for an `Ast` node: only `Var`/`Overwrite`/
3481/// `AccessField` carry one directly (see `ast.rs`'s module doc comment);
3482/// everything else falls back to `None`; the resulting `TypeError` then just
3483/// prints without a location prefix.
3484pub(crate) fn ast_span<'s>(ast: &Ast<'s>) -> Option<Span> {
3485    match ast {
3486        Ast::Var(_, span) => Some(*span),
3487        Ast::Overwrite(_, span, _) => Some(*span),
3488        Ast::AccessField(_, _, span) => Some(*span),
3489        Ast::VersionScope(_, inner) => ast_span(inner),
3490        Ast::ModuleScope(_, inner) => ast_span(inner),
3491        _ => None,
3492    }
3493}
3494
3495/// Type-check a whole elaborated [`Program`], additionally returning every
3496/// non-fatal [`MatchWarning`] the exhaustiveness/redundancy pass collected
3497/// — v0.0.6's `exhchecker.ml` warns
3498/// on a non-exhaustive or redundant `match` rather than rejecting the
3499/// program, so these never turn a would-have-passed program into a
3500/// `TypeError`.
3501pub fn typecheck_verbose<'s>(program: &Program<'s>) -> Result<Vec<MatchWarning>, TypeError> {
3502    typecheck_verbose_with_version(program, RustyfiVersion::V0_0)
3503}
3504
3505/// Same as [`typecheck_verbose`], for a given target `version` — threads
3506/// through to `Checker::new_with_version`/`base_type_env_with_version` so a
3507/// `V0_1` program's `page-break` resolves against the `length * length`
3508/// tuple type (`prim_types::t_page_or_geometry`) and never sees `page`'s
3509/// `VariantDecl` (gated out of `builtin_variants_with_version(V0_1)`) — a
3510/// `V0_1` program that writes `A4Paper` gets the SAME "unbound constructor"
3511/// error upstream's own 0.1 compiler would give it, which is the faithful
3512/// behavior (the ADT is genuinely gone, not merely discouraged).
3513pub fn typecheck_verbose_with_version<'s>(
3514    program: &Program<'s>,
3515    version: RustyfiVersion,
3516) -> Result<Vec<MatchWarning>, TypeError> {
3517    let mut checker = Checker::new_with_version(program, version)?;
3518    let env = base_type_env_with_version(checker.store, version);
3519    checker.infer(&env, &program.body)?;
3520    Ok(checker.warnings)
3521}
3522
3523/// Type-check a whole elaborated [`Program`]. Validation only: on success the
3524/// caller proceeds to evaluate `program.body`; the evaluator is untouched by
3525/// this phase. A thin wrapper over [`typecheck_verbose`] that discards its
3526/// warnings.
3527pub fn typecheck<'s>(program: &Program<'s>) -> Result<(), TypeError> {
3528    typecheck_with_version(program, RustyfiVersion::V0_0)
3529}
3530
3531/// Same as [`typecheck`], for a given target `version`. See
3532/// `typecheck_verbose_with_version`'s doc comment.
3533pub fn typecheck_with_version<'s>(
3534    program: &Program<'s>,
3535    version: RustyfiVersion,
3536) -> Result<(), TypeError> {
3537    typecheck_verbose_with_version(program, version).map(|_warnings| ())
3538}
3539
3540// ============================================================================
3541// Per-binding ≡ whole-program equivalence, and session-incrementality.
3542// A `#[cfg(test)]` unit module since `BindingView`/`Checker`/`infer_binding`
3543// etc. are `pub(crate)` — an integration test can't reach them.
3544// ============================================================================
3545#[cfg(test)]
3546mod l3_per_binding_tests {
3547    use super::*;
3548    use crate::{elaborate, primitives};
3549
3550    fn elaborate_src<'s>(store: &'s SymbolStore, src: &str) -> Program<'s> {
3551        let file = rustyfi_syntax::parse_file(src).expect("parse failed");
3552        let env = primitives::base_env();
3553        let scope = elaborate::Scope::new(store, env.names());
3554        elaborate::elaborate_program(&file, &scope).expect("elaborate failed")
3555    }
3556
3557    /// Manually drive the checker per binding: walk `program.body`'s Let
3558    /// chain constructing `BindingView`s by hand, `infer_binding` +
3559    /// `with_all` at each step, `infer_expr` on the non-Let tail. This is
3560    /// exactly what `infer`'s own recursion does internally — driven here
3561    /// from outside the engine through the `pub(crate)` per-binding API, the
3562    /// same way `v1/module_check.rs` does.
3563    fn drive_manually<'s>(
3564        program: &Program<'s>,
3565        version: RustyfiVersion,
3566    ) -> Result<Vec<MatchWarning>, TypeError> {
3567        let mut checker = Checker::new_with_version(program, version)?;
3568        let mut env = base_type_env_with_version(program.store, version);
3569        let mut ast: &Ast<'s> = &program.body;
3570        loop {
3571            ast = match ast {
3572                Ast::LetIn(name, value, body) => {
3573                    let schemes =
3574                        checker.infer_binding(&env, BindingView::Let { name: *name, value })?;
3575                    env = env.with_all(schemes, checker.binding_stage(value));
3576                    body
3577                }
3578                Ast::LetMathIn(name, value, body) => {
3579                    let schemes =
3580                        checker.infer_binding(&env, BindingView::LetMath { name: *name, value })?;
3581                    env = env.with_all(schemes, checker.binding_stage(value));
3582                    body
3583                }
3584                Ast::LetRecIn(bindings, body) => {
3585                    let schemes = checker.infer_binding(&env, BindingView::LetRec(bindings))?;
3586                    env = env.with_all(schemes, checker.binding_stage_rec(bindings));
3587                    body
3588                }
3589                Ast::LetMutableIn(name, init, body) => {
3590                    let schemes = checker
3591                        .infer_binding(&env, BindingView::LetMutable { name: *name, init })?;
3592                    env = env.with_all(schemes, checker.binding_stage(init));
3593                    body
3594                }
3595                other => {
3596                    checker.infer_expr(&env, other)?;
3597                    break;
3598                }
3599            };
3600        }
3601        Ok(checker.take_warnings())
3602    }
3603
3604    /// Elaborate `src` once, then compare `typecheck_verbose_with_version`
3605    /// against the manual per-binding drive: identical verdict, identical
3606    /// `TypeError` `Display` on error, identical `MatchWarning` list (incl.
3607    /// order — `MatchWarning` derives `PartialEq`) on success.
3608    fn assert_equivalent(src: &str) {
3609        let version = RustyfiVersion::V0_0;
3610        let store = SymbolStore::new();
3611        let program = elaborate_src(&store, src);
3612        let whole = typecheck_verbose_with_version(&program, version);
3613        let manual = drive_manually(&program, version);
3614        match (whole, manual) {
3615            (Ok(w1), Ok(w2)) => {
3616                assert_eq!(w1, w2, "warnings differ for {src:?}");
3617            }
3618            (Err(e1), Err(e2)) => {
3619                assert_eq!(
3620                    format!("{e1}"),
3621                    format!("{e2}"),
3622                    "error strings differ for {src:?}"
3623                );
3624            }
3625            (Ok(w), Err(e)) => panic!(
3626                "{src:?}: whole-program accepted (warnings={w:?}), manual drive rejected: {e}"
3627            ),
3628            (Err(e), Ok(w)) => panic!(
3629                "{src:?}: whole-program rejected ({e}), manual drive accepted (warnings={w:?})"
3630            ),
3631        }
3632    }
3633
3634    #[test]
3635    fn per_binding_drive_matches_whole_program_across_binding_kinds() {
3636        let cases: &[&str] = &[
3637            // ---- plain `let` ----
3638            "let x = 1 in x + 1",
3639            "let x = 1 in x + true", // failing: type mismatch
3640            // ---- polymorphic `let` ----
3641            "let id = fun x -> x in (id 1, id true)",
3642            // ---- `let-inline` command binding (+ its application) ----
3643            "let-inline ctx \\emph it = read-inline ctx it
3644             in
3645             { \\emph{ ok } }",
3646            "let-inline ctx \\bad = ctx + 1
3647             in
3648             ()", // failing: not context-headed
3649            // ---- `let-block` command binding (+ its application) ----
3650            "let-block ctx +p it = line-break true true ctx (read-inline ctx it)
3651             in
3652             '< +p{ ok } >",
3653            "let-block ctx +duo a b = read-block ctx a
3654             in
3655             '< +duo{x} >", // failing: wrong arity
3656            // ---- `let-math` command binding ----
3657            "let-math \\g m = ${#m#m} in 0",
3658            "let-math \\f = 3 in 0", // failing: value isn't `math`
3659            // ---- `let-rec` group ----
3660            "let-rec is-even n = if n == 0 then true else is-odd (n - 1)
3661             and is-odd n = if n == 0 then false else is-even (n - 1)
3662             in
3663             is-even 4",
3664            "let-rec f n = if n == 0 then 0 else (f true)
3665             in
3666             f 1", // failing: recursive use at a mismatched type
3667            // ---- `let-mutable` (value restriction) ----
3668            "let-mutable x <- 0
3669             in
3670             (x <- 5)",
3671            "let-mutable r <- []
3672             in
3673             ((r <- (1 :: !r)) before (r <- (true :: !r)))", // failing: value restriction
3674            // ---- a `match` to also exercise warning accumulation ----
3675            "match Some 1 with
3676             | Some n -> n
3677             | None -> 0",
3678        ];
3679        for src in cases {
3680            assert_equivalent(src);
3681        }
3682    }
3683
3684    /// Session-incrementality. `declare_variant` after
3685    /// `infer_binding` affects only *later* checking against the session —
3686    /// a ctor referenced before its `declare_variant` fails with the same
3687    /// "unknown constructor" error the whole-program path gives for a
3688    /// genuinely-undeclared one; after `declare_variant`, it typechecks.
3689    #[test]
3690    fn session_incrementality_declare_variant_affects_only_later_bindings() {
3691        let store = SymbolStore::new();
3692        let program = elaborate_src(&store, "type t = | A of int in 0");
3693        assert_eq!(program.type_decls.len(), 1);
3694        let decl = &program.type_decls[0];
3695
3696        let mut checker = Checker::empty(&store);
3697        checker.install_builtin_variants(RustyfiVersion::V0_0);
3698        let env = base_type_env_with_version(&store, RustyfiVersion::V0_0);
3699
3700        // Before `declare_variant`, `A` is unknown — same message shape a
3701        // genuinely-undeclared constructor gets.
3702        let a_payload = Ast::Ctor("A".to_string(), Some(Box::new(Ast::Int(1))));
3703        let before = checker
3704            .infer_binding(
3705                &env,
3706                BindingView::Let {
3707                    name: store.intern("before"),
3708                    value: &a_payload,
3709                },
3710            )
3711            .expect_err("`A` should be unknown before declare_variant");
3712        assert_eq!(format!("{before}"), "unknown constructor 'A'");
3713
3714        let nosuch_payload = Ast::Ctor("NoSuchCtor".to_string(), None);
3715        let genuinely_unknown = checker
3716            .infer_binding(
3717                &env,
3718                BindingView::Let {
3719                    name: store.intern("n"),
3720                    value: &nosuch_payload,
3721                },
3722            )
3723            .expect_err("a genuinely undeclared ctor should also fail");
3724        assert_eq!(
3725            format!("{genuinely_unknown}"),
3726            "unknown constructor 'NoSuchCtor'"
3727        );
3728
3729        // After `declare_variant`, `A` becomes visible and typechecks —
3730        // this later binding sees it; the earlier `before` call above is
3731        // unaffected (it already returned its error).
3732        checker
3733            .declare_variant(decl)
3734            .expect("declare_variant should succeed");
3735        let after = checker.infer_binding(
3736            &env,
3737            BindingView::Let {
3738                name: store.intern("after"),
3739                value: &a_payload,
3740            },
3741        );
3742        assert!(
3743            after.is_ok(),
3744            "A(1) should typecheck after declare_variant: {after:?}"
3745        );
3746    }
3747}
3748
3749// ============================================================================
3750// The shadowing-fix follow-up to the version-scope env swap above:
3751// `version_scoped_type_env`'s `Ast::VersionScope` overwrite must not
3752// re-stomp a `PRIMITIVE_NAMES` entry the user already shadowed BEFORE the
3753// `VersionScope` is reached. A `#[cfg(test)]` unit module (mirroring
3754// `l3_per_binding_tests` above) since `Checker`/`TypeEnv`/`Ast` are all
3755// `pub(crate)` or crate-private-shaped enough that a hand-built synthetic
3756// `Ast` fixture (no parser/elaborator round-trip needed to pin this one
3757// shape) is the most direct way to exercise the exact env-swap path.
3758// ============================================================================
3759#[cfg(test)]
3760mod x2b_shadow_tests {
3761    use super::*;
3762
3763    /// `let page-break = 42 in <VersionScope V0_0> page-break` — infer
3764    /// the whole tree under a `V0_1`-ambient `Checker`. `page-break` is a
3765    /// `PRIMITIVE_NAMES` member (a version-forked one, no less: its `V0_0`
3766    /// scheme takes the `page` ADT, its `V0_1` scheme a `length * length`
3767    /// tuple — see `page_prims.rs`). An overwrite loop that replaced every
3768    /// `PRIMITIVE_NAMES` entry unconditionally on entering the
3769    /// `VersionScope` would re-stomp the user's `page-break = 42` with
3770    /// `V0_0`'s builtin `page-break` (a curried function type), inferring the
3771    /// inner `Var` as a function, not `int`. Instead
3772    /// `version_scoped_type_env` sees `page-break` recorded in
3773    /// `env.shadowed` (set by the `LetIn` arm's `env.with_all`, which goes
3774    /// through `TypeEnv::with`) and skips the overwrite for that one name, so
3775    /// the `Var` resolves through the untouched user binding and the whole
3776    /// expression types as `int`.
3777    #[test]
3778    fn version_scope_does_not_clobber_a_user_shadowed_primitive() {
3779        assert!(
3780            PRIMITIVE_NAMES.contains(&"page-break"),
3781            "fixture assumption: page-break must be a PRIMITIVE_NAMES member"
3782        );
3783
3784        let span = Span::default();
3785        let store = SymbolStore::new();
3786        let page_break = store.intern("page-break");
3787        let ast = Ast::LetIn(
3788            page_break,
3789            Box::new(Ast::Int(42)),
3790            Box::new(Ast::VersionScope(
3791                RustyfiVersion::V0_0,
3792                Box::new(Ast::Var(page_break, span)),
3793            )),
3794        );
3795        let program = Program {
3796            type_decls: Vec::new(),
3797            synonym_decls: Vec::new(),
3798            body: ast,
3799            store: &store,
3800        };
3801
3802        let mut checker = Checker::new_with_version(&program, RustyfiVersion::V0_1)
3803            .expect("checker construction over an empty-decls program should succeed");
3804        let env = base_type_env_with_version(&store, RustyfiVersion::V0_1);
3805        let ty = checker.infer(&env, &program.body).unwrap_or_else(|e| {
3806            panic!(
3807                "inferring the version-scoped `Var` over the user's shadowed \
3808                 `page-break = 42` binding should type-check as `int`, not error: {e}"
3809            )
3810        });
3811        assert!(
3812            matches!(ty, MonoType::Base(BaseType::Int)),
3813            "the VersionScope env swap must respect the user's `page-break` shadow \
3814             (expected MonoType::Base(BaseType::Int), got {ty:?} instead) — a \
3815             MonoType::Func here would mean version_scoped_type_env re-stomped the \
3816             user binding with V0_0's builtin page-break scheme"
3817        );
3818    }
3819
3820    /// Companion positive control (same shape as the test above, MINUS the
3821    /// enclosing user shadow): a version-forked primitive referenced inside
3822    /// a `VersionScope` still resolves to `version`'s own (function-typed)
3823    /// scheme. Contrasts directly with
3824    /// `version_scope_does_not_clobber_a_user_shadowed_primitive`'s `int`
3825    /// result: same `page-break` name, same `VersionScope(V0_0, _)`, the only
3826    /// difference being the absence of a prior `let page-break = …` shadow.
3827    #[test]
3828    fn version_scope_still_resolves_unshadowed_forked_primitive() {
3829        let span = Span::default();
3830        let store = SymbolStore::new();
3831        let ast = Ast::VersionScope(
3832            RustyfiVersion::V0_0,
3833            Box::new(Ast::Var(store.intern("page-break"), span)),
3834        );
3835        let program = Program {
3836            type_decls: Vec::new(),
3837            synonym_decls: Vec::new(),
3838            body: ast,
3839            store: &store,
3840        };
3841        let mut checker = Checker::new_with_version(&program, RustyfiVersion::V0_1)
3842            .expect("checker construction over an empty-decls program should succeed");
3843        let env = base_type_env_with_version(&store, RustyfiVersion::V0_1);
3844        let ty = checker.infer(&env, &program.body).unwrap_or_else(|e| {
3845            panic!(
3846                "an unshadowed page-break reference inside a VersionScope should still \
3847                 type-check (X2a's original capability, unaffected by X2b): {e}"
3848            )
3849        });
3850        assert!(
3851            matches!(ty, MonoType::Func(..)),
3852            "page-break (unshadowed) inside a VersionScope should still resolve to its \
3853             builtin (function-typed) scheme, got {ty:?} instead — the X2b shadow guard \
3854             must not have blocked this NON-shadowed overwrite"
3855        );
3856    }
3857}
3858
3859// ============================================================================
3860// Acceptance test against the real `stdja.satyh` `sig … end` block (
3861// command values are covered by `crates/rustyfi-lang/tests/typecheck.rs`'s
3862// end-to-end fixtures; this module covers the `SigItem`/`constraint` lowering
3863// directly, since `lower_sig_item` is a crate-private entry point no
3864// sig-enforcement pass calls yet).
3865// ============================================================================
3866#[cfg(test)]
3867mod sig_constraint_tests {
3868    use super::*;
3869    use rustyfi_syntax::cst::{SigAnnot, TopBinding};
3870
3871    fn parse_module_sig(src: &str) -> SigAnnot {
3872        let file = rustyfi_syntax::parse_file(src).expect("parse failed");
3873        for b in &file.prelude {
3874            if let TopBinding::Module { sig: Some(sig), .. } = b {
3875                return sig.clone();
3876            }
3877        }
3878        panic!("no `module .. : sig .. end` found in {src:?}");
3879    }
3880
3881    #[test]
3882    fn constraint_suffix_lowers_to_a_kind_record_bound_on_its_tyvar() {
3883        let sig = parse_module_sig(
3884            "module M : sig\n\
3885             val document : 'a -> config ?-> block-text -> document\n\
3886             constraint 'a :: (| title : inline-text; author : inline-text |)\n\
3887             end = struct\n\
3888             let document x c bt = bt\n\
3889             end",
3890        );
3891        let mut ctx = TypeContext::new();
3892        let mut saw_record_kind = false;
3893        for item in &sig.items {
3894            let (name, ty) =
3895                lower_sig_item(item, &mut ctx, RustyfiVersion::V0_0).expect("a value item");
3896            assert_eq!(name, "document");
3897            // Walk the lowered `Func` chain: `'a`'s fresh variable is the
3898            // very first domain (`Func(Var('a), Func(option(config),
3899            // Func(block-text, document)))` — see `lower_type_expr`'s doc
3900            // comment for the `?->` shape).
3901            if let MonoType::Func(_row, dom, _) = &ty {
3902                if let MonoType::Var(v) = &**dom {
3903                    if let Kind::Record(labels) = v.kind() {
3904                        saw_record_kind = true;
3905                        let expected: BTreeSet<String> =
3906                            ["title", "author"].iter().map(|s| s.to_string()).collect();
3907                        assert_eq!(labels, expected);
3908                    }
3909                }
3910            }
3911        }
3912        assert!(
3913            saw_record_kind,
3914            "expected 'a's fresh variable to carry a Kind::Record bound"
3915        );
3916    }
3917
3918    #[test]
3919    fn kind_record_bound_accepts_a_row_with_every_required_label() {
3920        // Direct demonstration that the constraint's lowered `Kind::Record`
3921        // bound rides on *existing* `unify`/`bind_var` machinery for free —
3922        // no sig-enforcement pass exists yet to drive this against a real
3923        // `struct` implementation, but
3924        // the positive-presence check itself already works once something
3925        // does.
3926        let mut ctx = TypeContext::new();
3927        let labels: BTreeSet<String> = ["title", "author"].iter().map(|s| s.to_string()).collect();
3928        let v = ctx.fresh_var_with_kind(Kind::Record(labels));
3929        let constrained = MonoType::Var(v);
3930        let full = MonoType::Record(Row::Cons(
3931            "title".to_string(),
3932            Box::new(t_inline_text()),
3933            Box::new(Row::Cons(
3934                "author".to_string(),
3935                Box::new(t_inline_text()),
3936                Box::new(Row::Empty),
3937            )),
3938        ));
3939        unify(&constrained, &full).expect("row has both required labels");
3940    }
3941
3942    #[test]
3943    fn kind_record_bound_rejects_a_row_missing_a_required_label() {
3944        let mut ctx = TypeContext::new();
3945        let labels: BTreeSet<String> = ["title", "author"].iter().map(|s| s.to_string()).collect();
3946        let v = ctx.fresh_var_with_kind(Kind::Record(labels));
3947        let constrained = MonoType::Var(v);
3948        let missing_author = MonoType::Record(Row::Cons(
3949            "title".to_string(),
3950            Box::new(t_inline_text()),
3951            Box::new(Row::Empty),
3952        ));
3953        let err = unify(&constrained, &missing_author)
3954            .expect_err("row is missing the required 'author' label");
3955        assert!(
3956            format!("{err:?}").contains("author"),
3957            "error should name the missing label: {err:?}"
3958        );
3959    }
3960
3961    #[test]
3962    fn real_stdja_sig_block_lowers_every_item_to_a_monotype() {
3963        // Mirrors the whole `sig … end` block of the real upstream
3964        // `stdja.satyh:24-51` (v0.0.6 checkout) — command values, command
3965        // types, `?->`, and the `constraint` suffix all together. Every item
3966        // parses and lowers without error (an empty `struct end` body is
3967        // enough — sig enforcement against a real implementation is not
3968        // this test's job).
3969        let sig = parse_module_sig(
3970            "module StdJa : sig\n\
3971             val default-config : config\n\
3972             val document : 'a -> config ?-> block-text -> document\n\
3973             constraint 'a :: (|\n\
3974             title : inline-text;\n\
3975             author : inline-text;\n\
3976             show-toc : bool;\n\
3977             show-title : bool;\n\
3978             |)\n\
3979             val font-latin-roman : string * float * float\n\
3980             direct \\ref : [string] inline-cmd\n\
3981             direct \\ref-page : [string] inline-cmd\n\
3982             direct \\figure : [inline-text; block-text] inline-cmd\n\
3983             direct +p : [inline-text] block-cmd\n\
3984             direct +pn : [inline-text] block-cmd\n\
3985             direct +section : [string?; string?; inline-text; block-text] block-cmd\n\
3986             direct +subsection : [string?; string?; inline-text; block-text] block-cmd\n\
3987             direct \\emph : [inline-text] inline-cmd\n\
3988             end = struct\n\
3989             end",
3990        );
3991        let mut ctx = TypeContext::new();
3992        let mut names = Vec::new();
3993        for item in &sig.items {
3994            let (name, _ty) =
3995                lower_sig_item(item, &mut ctx, RustyfiVersion::V0_0).expect("a value item");
3996            names.push(name);
3997        }
3998        assert_eq!(
3999            names,
4000            vec![
4001                "default-config",
4002                "document",
4003                "font-latin-roman",
4004                "\\ref",
4005                "\\ref-page",
4006                "\\figure",
4007                "+p",
4008                "+pn",
4009                "+section",
4010                "+subsection",
4011                "\\emph",
4012            ]
4013        );
4014    }
4015}