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