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). Pre-order, duplicates included.
1528///
1529/// **Derived, not hand-written** ([`crate::visit`]). This walk was the live
1530/// instance of this port's recurring bug: its `InlineCmd`/`BlockCmd`/
1531/// `MathCmd` arm recursed into each slot's `ty` and not into that slot's
1532/// `opt_labels`, so a cycle routed through a `?(l : τ)` optional-label map
1533/// was invisible here while `expand_synonyms_cmd_args` expanded straight
1534/// into it — a stack overflow instead of a `cyclic type synonym` error
1535/// (`tests/synonym_cycle_opt_labels.rs`).
1536///
1537/// Semantics are unchanged otherwise: the traversal is structural (it does
1538/// **not** `resolve` through a bound type variable), pre-order, and
1539/// unconditional, exactly as the hand-written pair was.
1540fn synonym_refs(ty: &MonoType, synonyms: &HashMap<String, SynonymDecl>, out: &mut Vec<String>) {
1541    ty.visit(|t: &MonoType| {
1542        if let MonoType::Variant(name, _) = t {
1543            if synonyms.contains_key(name) {
1544                out.push(name.clone());
1545            }
1546        }
1547    });
1548}
1549
1550/// Reject a cyclic synonym (`type a = b` / `type b = a`, or a directly
1551/// self-referential `type a = a * a`) with a clear error instead of letting
1552/// [`expand_synonyms`] recurse forever. Run unconditionally over every
1553/// registered synonym at [`Checker::new`] time, so a cyclic pair is caught
1554/// even if nothing in the program actually references it.
1555fn check_synonym_cycles(synonyms: &HashMap<String, SynonymDecl>) -> Result<(), TypeError> {
1556    for start in synonyms.keys() {
1557        let mut stack = vec![start.clone()];
1558        check_synonym_cycles_from(start, synonyms, &mut stack)?;
1559    }
1560    Ok(())
1561}
1562
1563fn check_synonym_cycles_from(
1564    name: &str,
1565    synonyms: &HashMap<String, SynonymDecl>,
1566    stack: &mut Vec<String>,
1567) -> Result<(), TypeError> {
1568    let mut refs = Vec::new();
1569    synonym_refs(&synonyms[name].body, synonyms, &mut refs);
1570    for r in refs {
1571        if stack.contains(&r) {
1572            let mut cycle = stack.clone();
1573            cycle.push(r);
1574            return Err(TypeError::simple(
1575                None,
1576                format!("cyclic type synonym: {}", cycle.join(" -> ")),
1577            ));
1578        }
1579        stack.push(r.clone());
1580        check_synonym_cycles_from(&r, synonyms, stack)?;
1581        stack.pop();
1582    }
1583    Ok(())
1584}
1585
1586/// Recursively and transparently expand every synonym reference inside
1587/// `ty`, so `unify` never sees a synonym's name — only real base/product/
1588/// function/variant types. `synonyms` was already validated acyclic by
1589/// [`check_synonym_cycles`] (at `Checker::new` time), so the recursion here
1590/// is guaranteed to terminate; arity (a reference's argument count against
1591/// the synonym's own parameter count) is checked here, the one place that
1592/// actually has concrete arguments to check it against.
1593fn expand_synonyms(
1594    ty: &MonoType,
1595    synonyms: &HashMap<String, SynonymDecl>,
1596) -> Result<MonoType, TypeError> {
1597    match ty {
1598        MonoType::Var(_) | MonoType::Base(_) => Ok(ty.clone()),
1599        MonoType::Func(row, dom, cod) => Ok(MonoType::Func(
1600            Box::new(expand_synonyms_row(row, synonyms)?),
1601            Box::new(expand_synonyms(dom, synonyms)?),
1602            Box::new(expand_synonyms(cod, synonyms)?),
1603        )),
1604        MonoType::Product(ts) => Ok(MonoType::Product(
1605            ts.iter()
1606                .map(|t| expand_synonyms(t, synonyms))
1607                .collect::<Result<_, _>>()?,
1608        )),
1609        MonoType::List(t) => Ok(MonoType::List(Box::new(expand_synonyms(t, synonyms)?))),
1610        MonoType::Ref(t) => Ok(MonoType::Ref(Box::new(expand_synonyms(t, synonyms)?))),
1611        MonoType::Code(t) => Ok(MonoType::Code(Box::new(expand_synonyms(t, synonyms)?))),
1612        MonoType::Record(row) => Ok(MonoType::Record(expand_synonyms_row(row, synonyms)?)),
1613        MonoType::Variant(name, args) => {
1614            let args: Vec<MonoType> = args
1615                .iter()
1616                .map(|t| expand_synonyms(t, synonyms))
1617                .collect::<Result<_, _>>()?;
1618            let Some(syn) = synonyms.get(name) else {
1619                return Ok(MonoType::Variant(name.clone(), args));
1620            };
1621            if args.len() != syn.param_vars.len() {
1622                return Err(TypeError::simple(
1623                    None,
1624                    format!(
1625                        "type synonym '{name}' expects {} argument{}, got {}",
1626                        syn.param_vars.len(),
1627                        if syn.param_vars.len() == 1 { "" } else { "s" },
1628                        args.len()
1629                    ),
1630                ));
1631            }
1632            let mut var_map: HashMap<usize, MonoType> = HashMap::new();
1633            for (pv, arg) in syn.param_vars.iter().zip(args.iter()) {
1634                var_map.insert(types::ptr_key(pv), arg.clone());
1635            }
1636            let substituted = types::substitute(&syn.body, &var_map, &HashMap::new());
1637            expand_synonyms(&substituted, synonyms)
1638        }
1639        MonoType::InlineCmd(cs) => Ok(MonoType::InlineCmd(expand_synonyms_cmd_args(cs, synonyms)?)),
1640        MonoType::BlockCmd(cs) => Ok(MonoType::BlockCmd(expand_synonyms_cmd_args(cs, synonyms)?)),
1641        MonoType::MathCmd(cs) => Ok(MonoType::MathCmd(expand_synonyms_cmd_args(cs, synonyms)?)),
1642    }
1643}
1644
1645fn expand_synonyms_row(
1646    row: &Row,
1647    synonyms: &HashMap<String, SynonymDecl>,
1648) -> Result<Row, TypeError> {
1649    match row {
1650        Row::Empty => Ok(Row::Empty),
1651        Row::Var(v) => Ok(Row::Var(v.clone())),
1652        Row::Cons(label, t, rest) => Ok(Row::Cons(
1653            label.clone(),
1654            Box::new(expand_synonyms(t, synonyms)?),
1655            Box::new(expand_synonyms_row(rest, synonyms)?),
1656        )),
1657    }
1658}
1659
1660fn expand_synonyms_cmd_args(
1661    cs: &[CmdArgType],
1662    synonyms: &HashMap<String, SynonymDecl>,
1663) -> Result<Vec<CmdArgType>, TypeError> {
1664    cs.iter()
1665        .map(|c| {
1666            Ok(CmdArgType {
1667                optional: c.optional,
1668                opt_labels: c
1669                    .opt_labels
1670                    .iter()
1671                    .map(|(l, t)| Ok((l.clone(), expand_synonyms(t, synonyms)?)))
1672                    .collect::<Result<_, TypeError>>()?,
1673                ty: expand_synonyms(&c.ty, synonyms)?,
1674            })
1675        })
1676        .collect()
1677}
1678
1679// ============================================================================
1680// The checker.
1681// ============================================================================
1682
1683pub(crate) struct Checker<'s> {
1684    /// The interner every identifier in the tree being checked came from.
1685    /// Needed both to mint derived lookup keys (`"{modpfx}.{ctor}"`) and to
1686    /// resolve a `Symbol` back to text for an error message — see
1687    /// [`Checker::text`].
1688    store: &'s SymbolStore,
1689    ctx: TypeContext,
1690    /// Constructor name -> the (`Rc`-shared) declaration it belongs to.
1691    /// Later declarations shadow earlier ones of the same ctor name, mirroring
1692    /// ordinary name shadowing elsewhere in this port.
1693    ctors: HashMap<String, Rc<VariantDecl>>,
1694    /// The same declarations, keyed by *type* name instead — needed by the
1695    /// exhaustiveness pass (`exhaustive::check_match`) to enumerate a
1696    /// variant's full constructor set given the scrutinee's resolved
1697    /// `MonoType::Variant(name, _)`.
1698    variants: HashMap<String, Rc<VariantDecl>>,
1699    /// The synonym table. A field rather than a local of `new_with_version`
1700    /// so `declare_variant` can expand ctor payloads through it after
1701    /// construction time (per-binding registration).
1702    synonyms: HashMap<String, SynonymDecl>,
1703    /// Non-fatal diagnostics accumulated by the exhaustiveness/redundancy
1704    /// pass (see `typecheck_verbose`); v0.0.6's `exhchecker.ml` warns and
1705    /// continues rather than rejecting the program.
1706    warnings: Vec<MatchWarning>,
1707    /// The target version this session is checking against — set by
1708    /// [`Checker::install_builtin_variants`] (every real construction path's
1709    /// call; `Checker::empty` alone never does, defaulting to `V0_0`).
1710    /// Threaded into `name_to_mono`'s surface-type-name fork and
1711    /// `Ast::LetMathIn`'s scheme rule (`math_command_scheme` vs
1712    /// `math_command_scheme_v01`).
1713    version: RustyfiVersion,
1714    /// The generation of the code currently being inferred, when that is
1715    /// NOT [`Checker::version`] — set (save/restore) by the
1716    /// `Ast::VersionScope` infer arm, `None` outside every such scope.
1717    /// Deliberately a SEPARATE field rather than a temporary overwrite of
1718    /// `version`, since that one must stay pinned to `V0_1` for a merged
1719    /// whole-program session (`v1::module_check::check_program_inner`). See
1720    /// [`Checker::binding_version`], its only consumer.
1721    scoped_version: Option<RustyfiVersion>,
1722    /// The module path of the member body currently being inferred (pushed by
1723    /// the `Ast::ModuleScope` arm; empty at top level). A BARE constructor
1724    /// reference is looked up under `<path>.Ctor` (innermost prefix first)
1725    /// before the bare fallback — so two modules' same-named constructors
1726    /// (`Term.Paren` vs `Type.Paren`) no longer collide. See [`Ast::ModuleScope`].
1727    ctor_scope: Vec<String>,
1728    /// The stage this expression is being read at. Starts at whatever the
1729    /// file declared (`@stage:`, default [`Stage::Stage1`]) and is shifted by
1730    /// `&`/`~`: a quote reads its body one stage LATER, a splice one stage
1731    /// EARLIER. Only `Next`/`Prev` consult it.
1732    stage: Stage,
1733}
1734
1735/// One elaborated top-level-shaped binding, viewed by reference — the
1736/// typecheck-side mirror of `elaborate::Binding` and of the four Let-shaped
1737/// `Ast` spine variants (`ast.rs`). The module checker constructs these
1738/// directly from its own per-`val` walk; the whole-program path never
1739/// constructs one explicitly (its `infer` arms pass the same references
1740/// through).
1741pub(crate) enum BindingView<'a, 's> {
1742    /// `Ast::LetIn` — plain value OR `\`/`+`-sigiled command binding; the
1743    /// sigil dispatch (`command_scheme`) stays inside the checker.
1744    Let {
1745        name: Symbol<'s>,
1746        value: &'a Ast<'s>,
1747    },
1748    /// `Ast::LetMathIn` — a math-command binding (distinct variant by
1749    /// construction).
1750    LetMath {
1751        name: Symbol<'s>,
1752        value: &'a Ast<'s>,
1753    },
1754    /// `Ast::LetRecIn`'s binding group (all names in scope in all bodies).
1755    LetRec(&'a [(Symbol<'s>, Rc<Ast<'s>>)]),
1756    /// `Ast::LetMutableIn` — value restriction, never generalized.
1757    LetMutable { name: Symbol<'s>, init: &'a Ast<'s> },
1758}
1759
1760impl<'s> Checker<'s> {
1761    // See `base_type_env`'s `#[allow(dead_code)]` note above — same shape,
1762    // same reason.
1763    #[allow(dead_code)]
1764    fn new(program: &Program<'s>) -> Result<Checker<'s>, TypeError> {
1765        Self::new_with_version(program, RustyfiVersion::V0_0)
1766    }
1767
1768    /// Bare session: empty tables, fresh `TypeContext`. Registers NOTHING —
1769    /// not even builtins — so `new_with_version` can compose the exact
1770    /// statement order of the original monolithic constructor.
1771    pub(crate) fn empty(store: &'s SymbolStore) -> Checker<'s> {
1772        Checker {
1773            store,
1774            ctx: TypeContext::new(),
1775            ctors: HashMap::new(),
1776            variants: HashMap::new(),
1777            synonyms: HashMap::new(),
1778            warnings: Vec::new(),
1779            version: RustyfiVersion::V0_0,
1780            scoped_version: None,
1781            ctor_scope: Vec::new(),
1782            // A document is stage 1; a library overrides this from its
1783            // `@stage:` header before checking begins.
1784            stage: Stage::default(),
1785        }
1786    }
1787
1788    /// Set the session's target version with NO other side effect — a pure
1789    /// field write, safe to call before anything else.
1790    /// `new_with_version`/`v1::module_check::check_program`'s session-setup
1791    /// sequences both call this FIRST, ahead of their order-critical
1792    /// `declare_synonym` loop, so a V0_1 synonym body that names
1793    /// `math-text`/`math-boxes` resolves correctly even though
1794    /// `install_builtin_variants` (which also sets this field) does not run
1795    /// until afterward. A no-op for every 0.0.6 path: `empty()` already
1796    /// defaults to `V0_0`.
1797    pub(crate) fn set_version(&mut self, version: RustyfiVersion) {
1798        self.version = version;
1799    }
1800
1801    /// A symbol's source text. Every diagnostic this module formats goes
1802    /// through here: `Symbol`'s own `Debug` is index-only by design, and the
1803    /// golden tests diff the resolved strings.
1804    fn text(&self, sym: Symbol<'s>) -> &'s str {
1805        self.store.resolve(sym)
1806    }
1807
1808    /// Register the builtin variant decls for `version`, and record
1809    /// `version` on `self` — redundant with `set_version` for a path that
1810    /// calls both, kept so this method alone suffices for a caller that
1811    /// skips `set_version` (e.g. the bare-builtins test construction).
1812    pub(crate) fn install_builtin_variants(&mut self, version: RustyfiVersion) {
1813        self.version = version;
1814        self.install_additional_builtin_variants(version);
1815    }
1816
1817    /// Register `version`'s builtin variant/ctor set WITHOUT
1818    /// touching `self.version` (unlike [`Checker::install_builtin_
1819    /// variants`], which also sets the whole-session version tag) — called
1820    /// from the `Ast::VersionScope` `infer` arm below, lazily, the first
1821    /// time a version-scoped subtree is reached, so a `V0_0`-only ADT like
1822    /// `page` (`A0Paper`/…/`A4Paper`/`UserDefinedPaper`, gated on
1823    /// `has_page_adt()`, `prim_types.rs`'s `builtin_variants_with_version`)
1824    /// is constructible/matchable inside a spliced dependency's internal
1825    /// `page-break A4Paper …` call even though the whole-program `Checker`
1826    /// was built under `V0_1` (where `page` isn't registered at all).
1827    /// `self.ctors`/`self.variants` are flat, last-writer-wins, program-
1828    /// global tables already (`hide_ctors`'s doc comment); every
1829    /// `builtin_variants_with_version` entry OTHER than `page` is identical
1830    /// between the two versions (only `page` is gated by `has_page_adt()`,
1831    /// `prim_types.rs:2119`), so this is a safe additive merge, not a
1832    /// replace — idempotent across repeated `VersionScope` nodes of the same
1833    /// version.
1834    pub(crate) fn install_additional_builtin_variants(&mut self, version: RustyfiVersion) {
1835        for decl in builtin_variants_with_version(version) {
1836            let decl = Rc::new(decl);
1837            self.variants.insert(decl.name.clone(), decl.clone());
1838            for (cname, _) in &decl.ctors {
1839                self.ctors.insert(cname.clone(), decl.clone());
1840            }
1841        }
1842    }
1843
1844    /// One synonym registration. Does NOT cycle-check — register all, then
1845    /// call `check_cycles`. Fallible:
1846    /// `check_type_expr_v0_1_only` rejects a `?(l:ty)->` domain in the
1847    /// synonym's body under `V0_0` before it is ever lowered.
1848    pub(crate) fn declare_synonym(&mut self, decl: &UserSynonymDecl) -> Result<(), TypeError> {
1849        check_type_expr_v0_1_only(&decl.body, self.version)?;
1850        self.synonyms
1851            .insert(decl.name.clone(), build_synonym_decl(decl, self.version));
1852        Ok(())
1853    }
1854
1855    /// Cycle-check the accumulated synonym table — a thin wrapper over the
1856    /// existing free fn `check_synonym_cycles`.
1857    pub(crate) fn check_cycles(&self) -> Result<(), TypeError> {
1858        check_synonym_cycles(&self.synonyms)
1859    }
1860
1861    /// One variant-decl registration.
1862    pub(crate) fn declare_variant(&mut self, decl: &UserTypeDecl) -> Result<(), TypeError> {
1863        let decl = Rc::new(build_variant_decl(decl, &self.synonyms, self.version)?);
1864        self.variants.insert(decl.name.clone(), decl.clone());
1865        for (cname, _) in &decl.ctors {
1866            self.ctors.insert(cname.clone(), decl.clone());
1867        }
1868        // If the variant's own type name is module-qualified (`M.t`), also
1869        // register each constructor under a qualified key (`M.Ctor`) so a
1870        // within-module bare reference (via `Checker::lookup_ctor`, driven by
1871        // `Ast::ModuleScope`) resolves to THIS module's ctor even when another
1872        // module declares the same bare ctor name. Builtins have undotted type
1873        // names, so this adds nothing for them.
1874        if let Some((modpfx, _)) = decl.name.rsplit_once('.') {
1875            for (cname, _) in &decl.ctors {
1876                self.ctors.insert(format!("{modpfx}.{cname}"), decl.clone());
1877            }
1878        }
1879        Ok(())
1880    }
1881
1882    /// The original whole-program constructor, re-expressed as the exact
1883    /// same statement sequence through the methods above: synonyms →
1884    /// cycle-check → builtins → user variant decls. This order-preservation
1885    /// is load-bearing for session-incrementality.
1886    fn new_with_version(
1887        program: &Program<'s>,
1888        version: RustyfiVersion,
1889    ) -> Result<Checker<'s>, TypeError> {
1890        // Synonyms are registered (and checked for cycles) before any
1891        // variant decl is lowered, since a variant's ctor payload may name a
1892        // synonym (`build_variant_decl` expands through `synonyms`).
1893        let mut c = Checker::empty(program.store);
1894        c.set_version(version);
1895        for usd in &program.synonym_decls {
1896            c.declare_synonym(usd)?;
1897        }
1898        c.check_cycles()?;
1899        c.install_builtin_variants(version);
1900        for utd in &program.type_decls {
1901            c.declare_variant(utd)?;
1902        }
1903        Ok(c)
1904    }
1905
1906    fn fresh(&mut self) -> MonoType {
1907        MonoType::Var(self.ctx.fresh_var())
1908    }
1909
1910    fn unify_ctx(
1911        &mut self,
1912        expected: &MonoType,
1913        found: &MonoType,
1914        span: Option<Span>,
1915        what: &str,
1916    ) -> Result<(), TypeError> {
1917        unify(expected, found).map_err(|e| TypeError::from_unify(span, what, e))
1918    }
1919
1920    /// Turn a `\`/`+`-named `LetIn` binding's ordinarily-inferred value type
1921    /// `tv` into the genuine command type (`MonoType::InlineCmd`/`BlockCmd`)
1922    /// it gets bound under: a user-defined command is typed as
1923    /// `[τ1; ..; τn] inline-cmd` (resp. `block-cmd`), matching v0.0.6's real
1924    /// `HorzCommandType`/`VertCommandType` (`typechecker.ml`'s
1925    /// `UTLetHorzIn`/`UTLetVertIn` rules), not a plain "context-curried"
1926    /// function.
1927    ///
1928    /// Two shapes reach this function, per [`command_sigil`]'s call site:
1929    ///
1930    /// * a genuine `let-inline`/`let-block` definition, whose value is the
1931    ///   `Lambda(ctxvar, Lambda(p1, .., Lambda(pn, body)))` chain
1932    ///   `elaborate::elaborate_let_inline` builds — [`peel_func_chain`]
1933    ///   recovers that `Func` chain; the leading domain must unify with
1934    ///   `context`, the final codomain with `inline-boxes`/`block-boxes`,
1935    ///   and the domains between become the command's `CmdArgType` list.
1936    /// * a qualified-name *alias* of an already-command-typed binding (a
1937    ///   module's own `M.\cmd` re-export, or an `open` re-binding — both
1938    ///   build `LetIn(name, Ast::Var(qualified), body)`): the aliased name
1939    ///   was already run through this function at its own definition site,
1940    ///   so `tv` here already *is* the command type — this branch passes it
1941    ///   through unchanged (re-generalized) instead of peeling a `Func`
1942    ///   chain out of something that isn't one.
1943    fn command_scheme(
1944        &mut self,
1945        name: &str,
1946        sigil: char,
1947        tv: MonoType,
1948        span: Option<Span>,
1949    ) -> Result<PolyType, TypeError> {
1950        debug_assert!(sigil == '\\' || sigil == '+');
1951        let is_inline = sigil == '\\';
1952        let (want_result, kind, other_kind) = if is_inline {
1953            (t_inline_boxes(), "inline", "block")
1954        } else {
1955            (t_block_boxes(), "block", "inline")
1956        };
1957
1958        match &*resolve(&tv) {
1959            MonoType::InlineCmd(_) if is_inline => {
1960                return Ok(generalize(self.ctx.level(), &tv));
1961            }
1962            MonoType::BlockCmd(_) if !is_inline => {
1963                return Ok(generalize(self.ctx.level(), &tv));
1964            }
1965            MonoType::InlineCmd(_) | MonoType::BlockCmd(_) => {
1966                return Err(TypeError::simple(
1967                    span,
1968                    format!(
1969                        "'{name}' is bound to a {other_kind} command, but its \
1970                         name marks it as {article} {kind} command",
1971                        article = if kind == "inline" { "an" } else { "a" },
1972                    ),
1973                ));
1974            }
1975            // A qualified-name alias (`M.\cmd` re-export, or an `open`) of a
1976            // GENUINE `let-math` binding: math commands share the `\` sigil
1977            // with inline commands (there is no separate math-command token),
1978            // so an alias site only ever reaches this generic `Ast::LetIn`
1979            // path (never `Ast::LetMathIn`, which is produced only at a math
1980            // command's OWN definition site — top-level `let-math` via
1981            // `walk_bindings`, or the expression-level `let-math .. in ..`
1982            // form, `elaborate.rs`'s `Expr::LetMathIn` arm — never at an
1983            // alias site; see that variant's doc comment). Pass a already-
1984            // `MathCmd`-typed alias through unchanged, exactly like the
1985            // `InlineCmd`/`BlockCmd` arms above do for their own kind.
1986            MonoType::MathCmd(_) if is_inline => {
1987                return Ok(generalize(self.ctx.level(), &tv));
1988            }
1989            _ => {}
1990        }
1991
1992        // V0_1 harvests each param's closed
1993        // `?(l:τ,…)` label map from the `Row` that `Ast::LambdaOpt` leaves on
1994        // that param's own arrow (`peel_func_chain_rows`), instead of the
1995        // 0.0.6 "`_ option` domain ⇒ optional slot" heuristic below (which
1996        // stays untouched, byte-identical, under V0_0 — it never sees a
1997        // non-`Row::Empty` row at all, since V0_0 code never builds
1998        // `Ast::LambdaOpt`).
1999        let params: Vec<CmdArgType> = if self.version.has_row_polymorphism() {
2000            let (mut slots, result) = peel_func_chain_rows(tv);
2001            if slots.is_empty() {
2002                return Err(TypeError::simple(
2003                    span,
2004                    format!(
2005                        "the binding for '{name}' must be a function taking a \
2006                         context as its first argument (e.g. via `val inline ctx \
2007                         {name} .. = ..`)"
2008                    ),
2009                ));
2010            }
2011            let (ctx_row, ctx_ty) = slots.remove(0);
2012            // A labeled bundle can never legally land on the ctx binder
2013            // (`elaborate_let_inline` always wraps it in a plain
2014            // `Ast::Lambda`, which infers `Row::Empty` — `prim_types::arrow`)
2015            // — guard it defensively rather than silently dropping/mis-
2016            // attributing a label.
2017            if !matches!(&*resolve_row(&ctx_row), Row::Empty) {
2018                return Err(TypeError::simple(
2019                    span,
2020                    format!(
2021                        "the context argument of '{name}' cannot carry a labeled \
2022                         optional bundle"
2023                    ),
2024                ));
2025            }
2026            self.unify_ctx(
2027                &t_context(),
2028                &ctx_ty,
2029                span,
2030                &format!("the context argument of '{name}'"),
2031            )?;
2032            self.unify_ctx(
2033                &want_result,
2034                &result,
2035                span,
2036                &format!("the result of '{name}'"),
2037            )?;
2038            slots
2039                .into_iter()
2040                .map(|(row, dom)| harvest_slot(row, dom))
2041                .collect()
2042        } else {
2043            let (mut doms, result) = peel_func_chain(tv);
2044            if doms.is_empty() {
2045                return Err(TypeError::simple(
2046                    span,
2047                    format!(
2048                        "the binding for '{name}' must be a function taking a \
2049                         context as its first argument (e.g. via `let-inline ctx \
2050                         {name} .. = ..`)"
2051                    ),
2052                ));
2053            }
2054            let ctx_ty = doms.remove(0);
2055            self.unify_ctx(
2056                &t_context(),
2057                &ctx_ty,
2058                span,
2059                &format!("the context argument of '{name}'"),
2060            )?;
2061            self.unify_ctx(
2062                &want_result,
2063                &result,
2064                span,
2065                &format!("the result of '{name}'"),
2066            )?;
2067            // Optional command params, simplified (Sub-area 2): this grammar
2068            // has no def-site `?:param` marker, so a param counts as optional
2069            // exactly when its INFERRED domain resolves to `_ option` — i.e.
2070            // the body actually uses it as an `option` (`match p with Some ..
2071            // | None -> ..`). `CmdArgType.ty` then stores the option's INNER
2072            // type, matching the `[ty?; ..]` signature-lowering shape 1:1
2073            // (`lower_type_atom`'s `TypeAtom::Cmd` arm); `check_cmd_args`
2074            // re-wraps it in `option(..)` per call, since call-site args
2075            // always arrive pre-wrapped as `Some`/`None`
2076            // (`elaborate.rs`'s `app_arg_to_ast`).
2077            doms.into_iter()
2078                .map(|d| match resolve(&d).into_owned() {
2079                    MonoType::Variant(vname, mut vargs)
2080                        if vname == "option" && vargs.len() == 1 =>
2081                    {
2082                        optional(vargs.pop().unwrap())
2083                    }
2084                    _ => mandatory(d),
2085                })
2086                .collect()
2087        };
2088        let cmd_ty = if is_inline {
2089            MonoType::InlineCmd(params)
2090        } else {
2091            MonoType::BlockCmd(params)
2092        };
2093        Ok(generalize(self.ctx.level(), &cmd_ty))
2094    }
2095
2096    /// `Ast::LetMathIn`'s scheme-building rule — the math-command analog of
2097    /// `command_scheme` above, but simpler: a math command has **no**
2098    /// implicit context argument (see `elaborate.rs`'s
2099    /// `elaborate_let_math`), so every domain of `tv`'s function-chain
2100    /// becomes a `CmdArgType` (the same optional-param heuristic as
2101    /// `command_scheme`), and the bare result — not a peeled first argument
2102    /// — must be `math`. A zero-arity binding (`tv` not a `Func` at all,
2103    /// e.g. `let-math \to = rel \`→\``) falls out naturally:
2104    /// `peel_func_chain` returns no domains and `tv` itself as the result.
2105    fn math_command_scheme(
2106        &mut self,
2107        name: &str,
2108        tv: MonoType,
2109        span: Option<Span>,
2110    ) -> Result<PolyType, TypeError> {
2111        let (doms, result) = peel_func_chain(tv);
2112        self.unify_ctx(
2113            &t_math_text(),
2114            &result,
2115            span,
2116            &format!("the result of math command '{name}'"),
2117        )?;
2118        let params: Vec<CmdArgType> = doms
2119            .into_iter()
2120            .map(|d| match resolve(&d).into_owned() {
2121                MonoType::Variant(vname, mut vargs) if vname == "option" && vargs.len() == 1 => {
2122                    optional(vargs.pop().unwrap())
2123                }
2124                _ => mandatory(d),
2125            })
2126            .collect();
2127        Ok(generalize(self.ctx.level(), &MonoType::MathCmd(params)))
2128    }
2129
2130    /// `Ast::LetMathIn`'s V0_1 scheme-building rule — the `val math` analog
2131    /// of `math_command_scheme` above. The lowering
2132    /// (`v1/lower.rs::lower_bind_v1`) ALWAYS synthesizes exactly three
2133    /// trailing lambdas around a `val math` body — `fun ctx -> fun sub ->
2134    /// fun sup -> …` — so `tv`'s function chain always has at least 3
2135    /// domains; the LAST three are peeled off as `(d_ctx, d_sub, d_sup)`
2136    /// (`context`, `option math-text`, `option math-text`), the bare result
2137    /// must be `math-boxes`, and the REMAINING leading domains become the
2138    /// command's ordinary `CmdArgType` params.
2139    ///
2140    /// Like `command_scheme`'s V0_1 branch,
2141    /// a leading user parameter may be a `?(l = x, …)` bundle, so this uses
2142    /// the row-carrying `peel_func_chain_rows` + `harvest_slot` instead of
2143    /// the plain `_ option` heuristic. The synthesized ctx/sub/sup trailing
2144    /// trio can never legally carry a bundle (`lower_value_math` always
2145    /// wraps them in plain `fun`s, inferring `Row::Empty`) — guarded
2146    /// defensively, since an off-by-one here would silently turn `sub`/`sup`
2147    /// into a labeled slot or eat the last user param (the trio is at the
2148    /// TAIL of the domain chain, opposite inline/block where ctx is FIRST).
2149    fn math_command_scheme_v01(
2150        &mut self,
2151        name: &str,
2152        tv: MonoType,
2153        span: Option<Span>,
2154    ) -> Result<PolyType, TypeError> {
2155        let (mut slots, result) = peel_func_chain_rows(tv);
2156        if slots.len() < 3 {
2157            return Err(TypeError::simple(
2158                span,
2159                format!(
2160                    "'val math' command '{name}' must take a context and (via the \
2161                     synthesized `with sub sup`/`%math-attach-scripts` wrapper) two \
2162                     optional scripts as its trailing arguments — see the math-split spec"
2163                ),
2164            ));
2165        }
2166        let (row_sup, d_sup) = slots.pop().unwrap();
2167        let (row_sub, d_sub) = slots.pop().unwrap();
2168        let (row_ctx, d_ctx) = slots.pop().unwrap();
2169        for (which, row) in [
2170            ("context", &row_ctx),
2171            ("'sub'", &row_sub),
2172            ("'sup'", &row_sup),
2173        ] {
2174            if !matches!(&*resolve_row(row), Row::Empty) {
2175                return Err(TypeError::simple(
2176                    span,
2177                    format!(
2178                        "the {which} argument of 'val math' command '{name}' cannot \
2179                         carry a labeled optional bundle"
2180                    ),
2181                ));
2182            }
2183        }
2184        self.unify_ctx(
2185            &t_context(),
2186            &d_ctx,
2187            span,
2188            &format!("the context argument of 'val math' command '{name}'"),
2189        )?;
2190        self.unify_ctx(
2191            &t_option(t_math_text()),
2192            &d_sub,
2193            span,
2194            &format!("the 'sub' argument of 'val math' command '{name}'"),
2195        )?;
2196        self.unify_ctx(
2197            &t_option(t_math_text()),
2198            &d_sup,
2199            span,
2200            &format!("the 'sup' argument of 'val math' command '{name}'"),
2201        )?;
2202        self.unify_ctx(
2203            &t_math_boxes(),
2204            &result,
2205            span,
2206            &format!(
2207                "the result of 0.1 math command '{name}' — a `math-boxes`, \
2208                 usually via `read-math`"
2209            ),
2210        )?;
2211        let params: Vec<CmdArgType> = slots
2212            .into_iter()
2213            .map(|(row, dom)| harvest_slot(row, dom))
2214            .collect();
2215        Ok(generalize(self.ctx.level(), &MonoType::MathCmd(params)))
2216    }
2217
2218    /// Shared by `check_itext`'s `IText::Cmd`, `check_btext`'s `BText::Cmd`,
2219    /// and `check_math_elem`'s `MathElem::Cmd`: check a command application's
2220    /// argument count (exact — every optional slot is either explicitly
2221    /// marked at the call site or `None`-padded by elaboration, so it is
2222    /// never actually *absent* from `args`) and each argument's type against
2223    /// `params`. An `optional` param's `args[i].arg` is always a
2224    /// `Some(..)`/`None` value (`app_arg_to_ast`'s desugaring), so it's
2225    /// checked against `option(param.ty)`, not `param.ty` directly.
2226    ///
2227    /// Each `args[i]` additionally carries
2228    /// a (possibly empty) supplied `?(l = e, …)` bundle (`args[i].opts`) —
2229    /// every label must be declared in that slot's own closed
2230    /// `param.opt_labels` map (upstream's `UnexpectedOptionalLabel`,
2231    /// `typechecker.ml:900-901`); a declared label this call omits simply
2232    /// defaults to `None` at runtime, nothing to check here for it. `opts`
2233    /// is `[]` for every 0.0.6-reachable call, so this loop is a no-op
2234    /// there.
2235    fn check_cmd_args(
2236        &mut self,
2237        env: &TypeEnv<'s>,
2238        name: &str,
2239        span: Span,
2240        params: &[CmdArgType],
2241        args: &[CmdArg<'s>],
2242    ) -> Result<(), TypeError> {
2243        if params.len() != args.len() {
2244            return Err(TypeError::simple(
2245                Some(span),
2246                format!(
2247                    "command '{name}' expects {} argument{}, got {}",
2248                    params.len(),
2249                    if params.len() == 1 { "" } else { "s" },
2250                    args.len()
2251                ),
2252            ));
2253        }
2254        for (i, (param, arg)) in params.iter().zip(args.iter()).enumerate() {
2255            for (label, val) in &arg.opts {
2256                match param.opt_labels.iter().find(|(l, _)| l == label) {
2257                    Some((_, lty)) => {
2258                        let tval = self.infer(env, val)?;
2259                        self.unify_ctx(
2260                            lty,
2261                            &tval,
2262                            ast_span(val).or(Some(span)),
2263                            &format!("optional argument `{label}` of '{name}'"),
2264                        )?;
2265                    }
2266                    None => {
2267                        return Err(TypeError::simple(
2268                            ast_span(val).or(Some(span)),
2269                            format!(
2270                                "command '{name}' has no optional label `{label}` \
2271                                 on argument {}",
2272                                i + 1
2273                            ),
2274                        ));
2275                    }
2276                }
2277            }
2278            let targ = self.infer(env, &arg.arg)?;
2279            let expected = if param.optional {
2280                t_option(param.ty.clone())
2281            } else {
2282                param.ty.clone()
2283            };
2284            self.unify_ctx(
2285                &expected,
2286                &targ,
2287                ast_span(&arg.arg).or(Some(span)),
2288                &format!("argument {} of '{name}'", i + 1),
2289            )?;
2290        }
2291        Ok(())
2292    }
2293
2294    // ---- expressions -------------------------------------------------------
2295
2296    /// Infer the scheme(s) of ONE binding against a static `env`,
2297    /// WITHOUT extending anything. Returns the
2298    /// `(name, PolyType)` pairs in binding order (singleton for
2299    /// Let/LetMath/LetMutable; group order for LetRec). The caller decides
2300    /// what to put in the environment — the whole-program path commits them
2301    /// verbatim (`env.with_all`).
2302    ///
2303    /// Level discipline, sigil dispatch, generalization, and the
2304    /// value-restriction asymmetry all live INSIDE this method, so callers
2305    /// cannot get them wrong.
2306    pub(crate) fn infer_binding(
2307        &mut self,
2308        env: &TypeEnv<'s>,
2309        binding: BindingView<'_, 's>,
2310    ) -> Result<Vec<(Symbol<'s>, PolyType)>, TypeError> {
2311        match binding {
2312            BindingView::Let { name, value } => {
2313                self.ctx.enter_level();
2314                let tv = self.infer(env, value)?;
2315                self.ctx.leave_level();
2316                let scheme = match command_sigil(self.text(name)) {
2317                    // A `\`/`+`-named binding: either a genuine `let-inline`/
2318                    // `let-block` definition (`value` is the
2319                    // `Lambda(ctxvar, Lambda(p1, .., body))` chain
2320                    // `elaborate_let_inline` builds) or a qualified-name
2321                    // alias of one (`value` is a bare `Ast::Var`, from a
2322                    // module's own `M.\cmd` re-export or an `open`) — see
2323                    // `command_scheme`.
2324                    Some(sigil) => {
2325                        self.command_scheme(self.text(name), sigil, tv, ast_span(value))?
2326                    }
2327                    None => generalize(self.ctx.level(), &tv),
2328                };
2329                Ok(vec![(name, scheme)])
2330            }
2331
2332            // `let-math \cmd param* = expr in body` — structurally
2333            // identical to the `Let` command- binding rule above, but for a
2334            // binding that is ALREADY known (by construction, via the
2335            // dedicated Ast variant) to be a math command, so there is no
2336            // sigil to dispatch on and no "which kind of `\`-binding is
2337            // this" ambiguity to resolve.
2338            BindingView::LetMath { name, value } => {
2339                self.ctx.enter_level();
2340                let tv = self.infer(env, value)?;
2341                self.ctx.leave_level();
2342                // V0_0's `let-math` and V0_1's `val math` both lower to the
2343                // SAME `Ast::LetMathIn` — only the SCHEME RULE forks, since
2344                // a `val math` binding's lowering always synthesizes exactly
2345                // three trailing ctx/sub/sup lambdas that
2346                // `math_command_scheme`'s v0.0.6 rule knows nothing about.
2347                //
2348                // It forks on the BINDING's generation, not the session's
2349                // (`binding_version`, not `self.version`): in a merged
2350                // cross-version program the session is always `V0_1` while a
2351                // spliced 0.0.6 package's `let-math` RHS carries its own
2352                // `Ast::VersionScope(V0_0, _)`. On every single-version
2353                // program the two agree by construction (no `VersionScope`
2354                // node exists there at all).
2355                let scheme = if self.binding_version(value).math_is_split() {
2356                    self.math_command_scheme_v01(self.text(name), tv, ast_span(value))?
2357                } else {
2358                    self.math_command_scheme(self.text(name), tv, ast_span(value))?
2359                };
2360                Ok(vec![(name, scheme)])
2361            }
2362
2363            BindingView::LetRec(bindings) => {
2364                self.ctx.enter_level();
2365                // The group's own stage, so a `@stage: 0` library's mutually
2366                // recursive clauses can still see each other (they are read at
2367                // stage 0, and a stage-0 read of a stage-1 binder is refused).
2368                let group_stage = self.binding_stage_rec(bindings);
2369                let mut rec_env = env.clone();
2370                let mut vars = Vec::with_capacity(bindings.len());
2371                for (name, _) in bindings {
2372                    let v = self.fresh();
2373                    vars.push(v.clone());
2374                    rec_env = rec_env.with(*name, PolyType::mono(v), group_stage);
2375                }
2376                for ((name, val), v) in bindings.iter().zip(vars.iter()) {
2377                    let tv = self.infer(&rec_env, val)?;
2378                    self.unify_ctx(
2379                        v,
2380                        &tv,
2381                        ast_span(val),
2382                        &format!("let-rec binding '{}'", self.text(*name)),
2383                    )?;
2384                }
2385                self.ctx.leave_level();
2386                let mut schemes = Vec::with_capacity(bindings.len());
2387                for ((name, _), v) in bindings.iter().zip(vars.iter()) {
2388                    let scheme = generalize(self.ctx.level(), v);
2389                    schemes.push((*name, scheme));
2390                }
2391                Ok(schemes)
2392            }
2393
2394            BindingView::LetMutable { name, init } => {
2395                // NO generalization: `let-mutable`'s binding is the
2396                // classic ML "value restriction" case — a mutable reference
2397                // must stay monomorphic, or `let-mutable r <- [] in ((r <-
2398                // 1 :: !r); (r <- true :: !r); !r)`-style code could smuggle
2399                // an `int` and a `bool` through the very same cell. Binding
2400                // it via `PolyType::mono` (not `generalize`) enforces this
2401                // directly: every use of `name` in `body` shares the exact
2402                // same `Ref` type, not a fresh instantiation.
2403                let tinit = self.infer(env, init)?;
2404                Ok(vec![(name, PolyType::mono(reff(tinit)))])
2405            }
2406        }
2407    }
2408
2409    /// Infer one expression against a static env — a `pub(crate)` wrapper
2410    /// over the private `infer` below, which stays private so the ~40
2411    /// internal `self.infer(` call sites need not change. `v1::module_check`
2412    /// uses this for the document body and any non-binding expression.
2413    pub(crate) fn infer_expr(
2414        &mut self,
2415        env: &TypeEnv<'s>,
2416        ast: &Ast<'s>,
2417    ) -> Result<MonoType, TypeError> {
2418        self.infer(env, ast)
2419    }
2420
2421    /// Drain accumulated non-fatal match warnings (exhaustiveness /
2422    /// redundancy) — for per-binding callers; the whole-program path reads
2423    /// the `warnings` field directly.
2424    pub(crate) fn take_warnings(&mut self) -> Vec<MatchWarning> {
2425        std::mem::take(&mut self.warnings)
2426    }
2427
2428    /// Mutable access to the inference context — sig lowering needs it
2429    /// (`lower_sig_item(item, &mut ctx)`), and its subsumption check mints
2430    /// fresh vars through it.
2431    pub(crate) fn ctx_mut(&mut self) -> &mut TypeContext {
2432        &mut self.ctx
2433    }
2434
2435    /// Expand every synonym reference inside `ty` against this session's
2436    /// synonym table — a `pub(crate)` accessor over the private free fn
2437    /// [`expand_synonyms`], added for `v1/module_check.rs`:
2438    /// a sig `val`'s declared type may mention the
2439    /// module's own `type t = ..` synonym (by its pre-qualified `"M.t"`
2440    /// name, `v1/lower.rs`'s `TypeNameEnv`), and must expand through the
2441    /// SAME table the impl side's `build_variant_decl`/ordinary inference
2442    /// already does, so e.g. `val f : t -> t` over `type t = int` checks
2443    /// against the impl's expanded `int -> int`.
2444    pub(crate) fn expand_synonyms_in(&self, ty: &MonoType) -> Result<MonoType, TypeError> {
2445        expand_synonyms(ty, &self.synonyms)
2446    }
2447
2448    /// Deregister constructors hidden by a signature seal (
2449    /// `v1/module_check.rs`'s ctor-hide trigger — see that module's doc
2450    /// comment). Each entry is removed only if the currently-registered
2451    /// decl's type name matches, guarding against bare-name ctor collisions
2452    /// (`Checker.ctors` is last-writer-wins program-globally, 0.0.6-
2453    /// inherited): if a LATER unsealed variant re-registered the same ctor
2454    /// name after this one, its entry survives the hide untouched.
2455    pub(crate) fn hide_ctors(&mut self, entries: &[(String, String)]) {
2456        for (ctor, tyname) in entries {
2457            if self.ctors.get(ctor).is_some_and(|d| &d.name == tyname) {
2458                self.ctors.remove(ctor);
2459            }
2460            // Also drop the module-qualified key registered by
2461            // `declare_variant` (guarded by the same decl-identity check).
2462            if let Some((modpfx, _)) = tyname.rsplit_once('.') {
2463                let q = format!("{modpfx}.{ctor}");
2464                if self.ctors.get(&q).is_some_and(|d| &d.name == tyname) {
2465                    self.ctors.remove(&q);
2466                }
2467            }
2468        }
2469    }
2470
2471    /// `f ?(l = e, …) arg` — SATySFi 0.1 labeled-optional application
2472    /// inference. Kept OUT of the hot [`Checker::infer`] match (via
2473    /// `#[inline(never)]`) so its labeled-optional locals (`Vec`, `Row`) do
2474    /// not enlarge the deeply-recursed `infer` stack frame — see
2475    /// `infer_lambda_opt`.
2476    #[inline(never)]
2477    fn infer_apply_opt(
2478        &mut self,
2479        env: &TypeEnv<'s>,
2480        func: &Ast<'s>,
2481        opts: &[(String, Ast<'s>)],
2482        arg: &Ast<'s>,
2483    ) -> Result<MonoType, TypeError> {
2484        let tf = self.infer(env, func)?;
2485        let ta = self.infer(env, arg)?;
2486        let tr = self.fresh();
2487        let mut opt_tys = Vec::with_capacity(opts.len());
2488        for (label, e) in opts {
2489            opt_tys.push((label.clone(), self.infer(env, e)?));
2490        }
2491        let mut row = Row::Var(self.ctx.fresh_row_var());
2492        for (label, ty) in opt_tys.into_iter().rev() {
2493            row = Row::Cons(label, Box::new(ty), Box::new(row));
2494        }
2495        self.unify_ctx(
2496            &tf,
2497            &MonoType::Func(Box::new(row), Box::new(ta), Box::new(tr.clone())),
2498            ast_span(func),
2499            "function application",
2500        )?;
2501        Ok(tr)
2502    }
2503
2504    /// `fun ?(l = x, …) p -> body` — SATySFi 0.1 labeled-optional lambda
2505    /// inference. Kept OUT of the hot [`Checker::infer`] match
2506    /// (`#[inline(never)]`): `infer` recurses to the AST depth of the program
2507    /// under check, and Rust sizes a function's stack frame to its LARGEST
2508    /// match arm's locals; inlining this arm's `env.clone()` + `Vec`/`Row`
2509    /// locals into every `infer` frame measurably enlarged the deep recursion
2510    /// (enough to overflow the test harness's small default thread stack on a
2511    /// big merged program). Extracting it restores `infer`'s frame to its
2512    /// pre-optional-arg size.
2513    #[inline(never)]
2514    fn infer_lambda_opt(
2515        &mut self,
2516        env: &TypeEnv<'s>,
2517        opts: &[(String, Symbol<'s>)],
2518        param: Symbol<'s>,
2519        body: &Ast<'s>,
2520    ) -> Result<MonoType, TypeError> {
2521        let mut inner = env.clone();
2522        let mut opt_tys = Vec::with_capacity(opts.len());
2523        for (label, binder) in opts {
2524            let tl = self.fresh();
2525            inner = inner.with(*binder, PolyType::mono(t_option(tl.clone())), self.stage);
2526            opt_tys.push((label.clone(), tl));
2527        }
2528        let tp = self.fresh();
2529        inner = inner.with(param, PolyType::mono(tp.clone()), self.stage);
2530        let tb = self.infer(&inner, body)?;
2531        let mut row = Row::Empty;
2532        for (label, tl) in opt_tys.into_iter().rev() {
2533            row = Row::Cons(label, Box::new(tl), Box::new(row));
2534        }
2535        Ok(MonoType::Func(Box::new(row), Box::new(tp), Box::new(tb)))
2536    }
2537
2538    /// The stage a binding whose right-hand side is `value` is introduced at
2539    /// — upstream's `pre.stage` at the point of `Typeenv.add`.
2540    ///
2541    /// Upstream reads a whole FILE at one stage, so `pre.stage` is simply the
2542    /// ambient stage there. This port flattens every library into one
2543    /// `let`-chain and instead marks each spliced binding's RHS with
2544    /// [`Ast::StageScope`] (`elaborate.rs`'s per-item wrap), so the ambient
2545    /// stage is only right for a binding the current file wrote itself. The
2546    /// peeling through `ModuleScope`/`VersionScope` is because those wrappers
2547    /// are applied INSIDE `push_named_binding`/the `LetRec` arm, i.e. after
2548    /// the stage wrap, for a module member.
2549    ///
2550    /// Only the `ModuleScope` half of that peel actually fires: `elaborate.rs`
2551    /// applies `maybe_v006_scope` to a binding's RHS and `stage_wrap_item`
2552    /// outside it, so a cross-version staged binding is always
2553    /// `StageScope(_, VersionScope(_, ..))`. The `VersionScope` arm is kept
2554    /// anyway, because `elaborate::already_staged` peels the same two and the
2555    /// two must not disagree. Deleting the `ModuleScope` arm breaks
2556    /// `xver_staging.rs`'s
2557    /// `the_file_stage_and_the_version_scope_compose_on_a_module_member`.
2558    pub(crate) fn binding_stage(&self, value: &Ast<'s>) -> Stage {
2559        fn declared<'s>(a: &Ast<'s>) -> Option<Stage> {
2560            match a {
2561                Ast::StageScope(st, _) => Some(*st),
2562                Ast::ModuleScope(_, b) | Ast::VersionScope(_, b) => declared(b),
2563                _ => None,
2564            }
2565        }
2566        declared(value).unwrap_or(self.stage)
2567    }
2568
2569    /// The GENERATION one binding was authored in — the version analogue of
2570    /// [`Checker::binding_stage`], and needed for exactly the same reason: a
2571    /// merged cross-version program has ONE `Checker::version`, hard-coded
2572    /// to `V0_1` (`v1::module_check::check_program_inner`), while each
2573    /// spliced 0.0.6 dependency's bindings carry their own
2574    /// `Ast::VersionScope(V0_0, _)` on the RHS (`elaborate::
2575    /// maybe_v006_scope`). Any scheme rule that FORKS on the version must
2576    /// ask the binding, not the session, or a 0.0.6 package's binding is
2577    /// read under 0.1's rule.
2578    ///
2579    /// Concretely: `let-math`. `math_command_scheme` (0.0.6) and
2580    /// `math_command_scheme_v01` are two different rules for the same
2581    /// `Ast::LetMathIn`, and 0.1's demands three synthesized trailing
2582    /// `ctx`/`sub`/`sup` lambdas a 0.0.6 `let-math \frac = math-frac` does
2583    /// not have — dispatching on `self.version` refused EVERY `let-math` in
2584    /// a crossed 0.0.6 package, including the bundled `math.satyh` that
2585    /// `@require:` reaches transitively (`texlogo`, `latexcmds`, `siunitx`,
2586    /// …).
2587    ///
2588    /// The wrapper peel is `binding_stage`'s, minus its terminating arm:
2589    /// `elaborate::walk_bindings` puts `VersionScope` INSIDE `StageScope`
2590    /// (`already_staged`'s doc comment), so a staged spliced binding is
2591    /// `StageScope(_, VersionScope(_, ..))` and this must look through
2592    /// `StageScope` too.
2593    ///
2594    /// The peel alone is not enough, hence the `scoped_version` fallback:
2595    /// `maybe_v006_scope` wraps a TOP-LEVEL binding's RHS, but an
2596    /// EXPRESSION-level `let-math \c = e in body` (`elaborate.rs`'s
2597    /// `Expr::LetMathIn` arm, e.g. `siunitx`'s `let-math \C = ord \`C\` in
2598    /// ${\math-sup{}{\circ}\C}`) is a node inside another binding's
2599    /// already-wrapped RHS and carries no wrapper of its own — so the
2600    /// ambient generation, recorded by the `Ast::VersionScope` infer arm as
2601    /// it descends, answers for it instead. Outside every scope this is
2602    /// `None` and the session's own version answers.
2603    pub(crate) fn binding_version(&self, value: &Ast<'s>) -> RustyfiVersion {
2604        fn declared<'s>(a: &Ast<'s>) -> Option<RustyfiVersion> {
2605            match a {
2606                Ast::VersionScope(v, _) => Some(*v),
2607                Ast::StageScope(_, b) | Ast::ModuleScope(_, b) => declared(b),
2608                _ => None,
2609            }
2610        }
2611        declared(value)
2612            .or(self.scoped_version)
2613            .unwrap_or(self.version)
2614    }
2615
2616    /// [`Checker::binding_stage`] for a `let-rec` GROUP. Every clause of one
2617    /// group comes from one item of one file, so they share a stage; the
2618    /// elaborator wraps them identically and the first is representative.
2619    pub(crate) fn binding_stage_rec(&self, bindings: &[(Symbol<'s>, Rc<Ast<'s>>)]) -> Stage {
2620        match bindings.first() {
2621            Some((_, v)) => self.binding_stage(v),
2622            None => self.stage,
2623        }
2624    }
2625
2626    /// Look `name` up for an OCCURRENCE, enforcing the staging matrix
2627    /// ([`Stage::can_reference`]) — upstream's `UTContentOf` arm, which is
2628    /// where a stage-0 name used from stage 1 (or the reverse) is refused.
2629    ///
2630    /// `Ok(None)` means unbound, left to each caller because each has its own
2631    /// "should not happen post-elaboration" wording. `what` names the kind of
2632    /// occurrence for the diagnostic (`"variable"`, `"inline command"`, …).
2633    fn staged<'e>(
2634        &self,
2635        env: &'e TypeEnv<'s>,
2636        name: Symbol<'s>,
2637        span: Option<Span>,
2638        what: &str,
2639    ) -> Result<Option<&'e PolyType>, TypeError> {
2640        let Some(entry) = env.entry(name) else {
2641            return Ok(None);
2642        };
2643        if !self.stage.can_reference(entry.stage) {
2644            return Err(TypeError::simple(
2645                span,
2646                format!(
2647                    "invalid occurrence of {what} '{}' as to stage: it is bound at {}, \
2648                     but this is {}",
2649                    self.text(name),
2650                    entry.stage.as_str(),
2651                    self.stage.as_str()
2652                ),
2653            ));
2654        }
2655        Ok(Some(&entry.poly))
2656    }
2657
2658    /// `infer`, reading `ast` at a different stage and restoring afterwards --
2659    /// the type-side twin of the `ctor_scope` push/pop below it.
2660    fn infer_at(
2661        &mut self,
2662        stage: Stage,
2663        env: &TypeEnv<'s>,
2664        ast: &Ast<'s>,
2665    ) -> Result<MonoType, TypeError> {
2666        let saved = std::mem::replace(&mut self.stage, stage);
2667        let result = self.infer(env, ast);
2668        self.stage = saved;
2669        result
2670    }
2671
2672    fn infer(&mut self, env: &TypeEnv<'s>, ast: &Ast<'s>) -> Result<MonoType, TypeError> {
2673        match ast {
2674            // A binding spliced in from a file whose `@stage:` was not the
2675            // default: read it at that stage, so its quotes are legal.
2676            Ast::StageScope(stage, body) => self.infer_at(*stage, env, body),
2677            // `&e` — quote. Legal only at stage 0; its body is read one
2678            // stage later, and the result is that body's type wrapped in
2679            // `code` (upstream `typechecker.ml`'s `UTNext` arm).
2680            Ast::Next(inner) => {
2681                if self.stage != Stage::Stage0 {
2682                    return Err(TypeError::simple(
2683                        None,
2684                        format!(
2685                            "`&` (next-stage quote) is only valid at stage 0, but this is {}",
2686                            self.stage.as_str()
2687                        ),
2688                    ));
2689                }
2690                let ty = self.infer_at(Stage::Stage1, env, inner)?;
2691                Ok(MonoType::Code(Box::new(ty)))
2692            }
2693            // `~e` — splice. Legal only at stage 1; its body is read one stage
2694            // earlier and must produce `code b`, which this expression then
2695            // stands for (upstream's `UTPrev` arm).
2696            Ast::Prev(inner) => {
2697                if self.stage != Stage::Stage1 {
2698                    return Err(TypeError::simple(
2699                        None,
2700                        format!(
2701                            "`~` (previous-stage splice) is only valid at stage 1, but this is {}",
2702                            self.stage.as_str()
2703                        ),
2704                    ));
2705                }
2706                let ty = self.infer_at(Stage::Stage0, env, inner)?;
2707                let beta = MonoType::Var(self.ctx.fresh_var());
2708                unify(&ty, &MonoType::Code(Box::new(beta.clone())))
2709                    .map_err(|e| TypeError::from_unify(None, "a `~` splice", e))?;
2710                Ok(beta)
2711            }
2712            Ast::Unit => Ok(t_unit()),
2713            Ast::Bool(_) => Ok(t_bool()),
2714            Ast::Int(_) => Ok(t_int()),
2715            Ast::Float(_) => Ok(t_float()),
2716            Ast::Length(_) => Ok(t_length()),
2717            Ast::Str(_) => Ok(t_string()),
2718
2719            Ast::Var(name, span) => match self.staged(env, *name, Some(*span), "variable")? {
2720                Some(poly) => Ok(instantiate(poly, self.ctx.level())),
2721                // Should not happen post-elaboration: `elaborate.rs`'s
2722                // `scoped_var` already rejects any unbound name before this
2723                // ever runs. Surfaced as a (spanned) error rather than a
2724                // panic anyway, since "should not happen" isn't "cannot".
2725                None => Err(TypeError::simple(
2726                    Some(*span),
2727                    format!(
2728                        "internal error: unbound variable '{}' reached the typechecker",
2729                        self.text(*name)
2730                    ),
2731                )),
2732            },
2733
2734            Ast::Apply(f, a) => {
2735                let tf = self.infer(env, f)?;
2736                let ta = self.infer(env, a)?;
2737                let tr = self.fresh();
2738                // Version-split the optional-argument row: 0.0.6 functions
2739                // provably carry no labeled optionals (`Row::Empty`, matching
2740                // `arrow()` and every prim). Under 0.1 a fresh open row var
2741                // absorbs the callee's
2742                // declared optional row, letting a *plain* call of an
2743                // opt-taking function typecheck (defaulting every optional to
2744                // `None` at run time) and making higher-order code
2745                // row-polymorphic after generalization.
2746                let opts_row = if self.version.has_row_polymorphism() {
2747                    Row::Var(self.ctx.fresh_row_var())
2748                } else {
2749                    Row::Empty
2750                };
2751                self.unify_ctx(
2752                    &tf,
2753                    &MonoType::Func(Box::new(opts_row), Box::new(ta), Box::new(tr.clone())),
2754                    ast_span(f),
2755                    "function application",
2756                )?;
2757                Ok(tr)
2758            }
2759
2760            Ast::Lambda(param, body) => {
2761                let tp = self.fresh();
2762                let inner = env.with(*param, PolyType::mono(tp.clone()), self.stage);
2763                let tb = self.infer(&inner, body)?;
2764                Ok(arrow(tp, tb))
2765            }
2766
2767            // `f ?(l = e, …) arg` (SATySFi 0.1; upstream typechecker.ml's
2768            // `Apply(labmap, …)`). The callee must unify with a function
2769            // whose optional row carries at least each supplied `l : τ_l`,
2770            // with an open tail (a fresh row var) for any further optionals
2771            // the callee declares that this call omits.
2772            Ast::ApplyOpt { func, opts, arg } => self.infer_apply_opt(env, func, opts, arg),
2773
2774            // `fun ?(l = x, …) p -> body` (SATySFi 0.1; upstream
2775            // `Function(evid_labmap, …)`). Each labeled optional binder `x`
2776            // is bound at `option τ_l` inside the body; the resulting
2777            // function type carries a CLOSED row `?(l : τ_l, …)` — the very
2778            // same fresh `τ_l` shared between the binder's `option τ_l` and
2779            // the row's `Cons(l, τ_l)`.
2780            Ast::LambdaOpt { opts, param, body } => self.infer_lambda_opt(env, opts, *param, body),
2781
2782            Ast::LetIn(name, value, body) => {
2783                let schemes = self.infer_binding(env, BindingView::Let { name: *name, value })?;
2784                let inner = env.with_all(schemes, self.binding_stage(value));
2785                self.infer(&inner, body)
2786            }
2787
2788            // `let-math \cmd param* = expr in body` — see
2789            // `infer_binding`'s `BindingView::LetMath` arm.
2790            Ast::LetMathIn(name, value, body) => {
2791                let schemes =
2792                    self.infer_binding(env, BindingView::LetMath { name: *name, value })?;
2793                let inner = env.with_all(schemes, self.binding_stage(value));
2794                self.infer(&inner, body)
2795            }
2796
2797            Ast::LetRecIn(bindings, body) => {
2798                let schemes = self.infer_binding(env, BindingView::LetRec(bindings))?;
2799                let inner = env.with_all(schemes, self.binding_stage_rec(bindings));
2800                self.infer(&inner, body)
2801            }
2802
2803            Ast::IfThenElse(cond, then_b, else_b) => {
2804                let tc = self.infer(env, cond)?;
2805                self.unify_ctx(&t_bool(), &tc, ast_span(cond), "the condition of 'if'")?;
2806                let tt = self.infer(env, then_b)?;
2807                let te = self.infer(env, else_b)?;
2808                self.unify_ctx(&tt, &te, ast_span(else_b), "the branches of 'if'")?;
2809                Ok(tt)
2810            }
2811
2812            Ast::Match(scrutinee, arms) => {
2813                let tscrut = self.infer(env, scrutinee)?;
2814                let mut result: Option<MonoType> = None;
2815                for arm in arms {
2816                    let arm_env = self.bind_pattern(env.clone(), &arm.pat, &tscrut)?;
2817                    if let Some(guard) = &arm.guard {
2818                        let tg = self.infer(&arm_env, guard)?;
2819                        self.unify_ctx(&t_bool(), &tg, ast_span(guard), "a match guard")?;
2820                    }
2821                    let tbody = self.infer(&arm_env, &arm.body)?;
2822                    match &result {
2823                        None => result = Some(tbody),
2824                        Some(r) => {
2825                            self.unify_ctx(r, &tbody, ast_span(&arm.body), "the arms of 'match'")?
2826                        }
2827                    }
2828                }
2829                // Exhaustiveness/redundancy: non-fatal, so it runs
2830                // only after every arm has
2831                // typechecked, against `tscrut` as resolved as inference will
2832                // ever make it. See `exhaustive::check_match`'s doc comment.
2833                let resolved_scrut = resolve(&tscrut);
2834                let new_warnings = crate::exhaustive::check_match(
2835                    self.store,
2836                    &resolved_scrut,
2837                    ast_span(scrutinee),
2838                    arms,
2839                    &self.variants,
2840                );
2841                self.warnings.extend(new_warnings);
2842                // `Match`'s `arms` is always non-empty (`c::Expr::Match`
2843                // requires a `first` arm plus zero or more `rest`), so
2844                // `result` is always `Some` in practice; the fallback fresh
2845                // variable is defensive only.
2846                Ok(result.unwrap_or_else(|| self.fresh()))
2847            }
2848
2849            Ast::Tuple(items) => {
2850                let tys = items
2851                    .iter()
2852                    .map(|it| self.infer(env, it))
2853                    .collect::<Result<Vec<_>, _>>()?;
2854                Ok(product(tys))
2855            }
2856
2857            Ast::Ctor(name, payload) => self.infer_ctor(env, name, payload.as_deref(), None),
2858
2859            Ast::Record(fields) => {
2860                let mut typed = Vec::with_capacity(fields.len());
2861                for (label, e) in fields {
2862                    typed.push((label.clone(), self.infer(env, e)?));
2863                }
2864                let mut row = Row::Empty;
2865                for (label, ty) in typed.into_iter().rev() {
2866                    row = Row::Cons(label, Box::new(ty), Box::new(row));
2867                }
2868                Ok(MonoType::Record(row))
2869            }
2870
2871            Ast::List(items) => {
2872                let elem = self.fresh();
2873                for it in items {
2874                    let t = self.infer(env, it)?;
2875                    self.unify_ctx(&elem, &t, ast_span(it), "a list element")?;
2876                }
2877                Ok(list(elem))
2878            }
2879
2880            Ast::InlineText(elems) => {
2881                for e in elems.iter() {
2882                    self.check_itext(env, e)?;
2883                }
2884                Ok(t_inline_text())
2885            }
2886
2887            Ast::BlockText(elems) => {
2888                for e in elems.iter() {
2889                    self.check_btext(env, e)?;
2890                }
2891                Ok(t_block_text())
2892            }
2893
2894            Ast::MathText(elems) => {
2895                for e in elems.iter() {
2896                    self.check_math_elem(env, e)?;
2897                }
2898                Ok(MonoType::Base(BaseType::MathText))
2899            }
2900
2901            Ast::LetMutableIn(name, init, body) => {
2902                // NO generalization: `let-mutable`'s binding is the
2903                // classic ML "value restriction" case — see
2904                // `infer_binding`'s `BindingView::LetMutable` arm.
2905                let schemes =
2906                    self.infer_binding(env, BindingView::LetMutable { name: *name, init })?;
2907                let inner = env.with_all(schemes, self.binding_stage(init));
2908                self.infer(&inner, body)
2909            }
2910
2911            Ast::Overwrite(name, span, value) => {
2912                let t_ref = match self.staged(env, *name, Some(*span), "mutable variable")? {
2913                    Some(poly) => instantiate(poly, self.ctx.level()),
2914                    None => {
2915                        return Err(TypeError::simple(
2916                            Some(*span),
2917                            format!(
2918                            "internal error: unbound mutable variable '{}' reached the typechecker",
2919                            self.text(*name)
2920                        ),
2921                        ))
2922                    }
2923                };
2924                let inner = self.fresh();
2925                self.unify_ctx(
2926                    &t_ref,
2927                    &reff(inner.clone()),
2928                    Some(*span),
2929                    &format!("the overwrite target '{}'", self.text(*name)),
2930                )?;
2931                let tvalue = self.infer(env, value)?;
2932                // Prefer the overwrite's own (always-present) span over
2933                // `ast_span(value)`, which is `None` for most value shapes
2934                // (literals carry no span at all — see `ast.rs`'s module
2935                // doc comment) and would otherwise leave this common error
2936                // unlocated.
2937                self.unify_ctx(
2938                    &inner,
2939                    &tvalue,
2940                    ast_span(value).or(Some(*span)),
2941                    &format!("the overwrite value for '{}'", self.text(*name)),
2942                )?;
2943                Ok(t_unit())
2944            }
2945
2946            Ast::WhileDo(cond, body) => {
2947                let tc = self.infer(env, cond)?;
2948                self.unify_ctx(&t_bool(), &tc, ast_span(cond), "the condition of 'while'")?;
2949                let tb = self.infer(env, body)?;
2950                self.unify_ctx(&t_unit(), &tb, ast_span(body), "the body of 'while'")?;
2951                Ok(t_unit())
2952            }
2953
2954            Ast::Sequential(a, b) => {
2955                let ta = self.infer(env, a)?;
2956                // v0.0.6 requires the left-hand side of `before`/`;` to be
2957                // `unit` (`typechecker.ml`'s `UTSequential` case): not just
2958                // "evaluated and discarded" but type-checked as `unit`
2959                // specifically, so e.g. a stray non-unit expression used
2960                // only for effect (but returning, say, an `int`) is rejected
2961                // rather than silently ignored.
2962                self.unify_ctx(
2963                    &t_unit(),
2964                    &ta,
2965                    ast_span(a),
2966                    "the left-hand side of 'before'",
2967                )?;
2968                self.infer(env, b)
2969            }
2970
2971            Ast::AccessField(e, label, span) => {
2972                let te = self.infer(env, e)?;
2973                let field = self.fresh();
2974                let rv = self.ctx.fresh_row_var();
2975                let open_row = MonoType::Record(Row::Cons(
2976                    label.clone(),
2977                    Box::new(field.clone()),
2978                    Box::new(Row::Var(rv)),
2979                ));
2980                self.unify_ctx(
2981                    &open_row,
2982                    &te,
2983                    Some(*span),
2984                    &format!("the field access '#{label}'"),
2985                )?;
2986                Ok(field)
2987            }
2988
2989            Ast::UpdateField(base, label, value) => {
2990                let tbase = self.infer(env, base)?;
2991                let tvalue = self.infer(env, value)?;
2992                let rv = self.ctx.fresh_row_var();
2993                let open_row = MonoType::Record(Row::Cons(
2994                    label.clone(),
2995                    Box::new(tvalue),
2996                    Box::new(Row::Var(rv)),
2997                ));
2998                self.unify_ctx(
2999                    &open_row,
3000                    &tbase,
3001                    ast_span(base),
3002                    &format!("the record update of '{label}'"),
3003                )?;
3004                Ok(tbase)
3005            }
3006
3007            // Swap the active primitive-type env to `version`'s
3008            // for `body` — see `version_scoped_type_env`'s doc comment —
3009            // and make sure `version`'s builtin ADTs (e.g. `page`,
3010            // `V0_0`-only) are registered in the (otherwise
3011            // whole-program-tagged) ctor table — see
3012            // `install_additional_builtin_variants`'s doc comment. Never
3013            // reached on a pure single-version program (no
3014            // `Ast::VersionScope` node is ever produced there).
3015            Ast::VersionScope(version, body) => {
3016                self.install_additional_builtin_variants(*version);
3017                let scoped = version_scoped_type_env(self.store, env, *version);
3018                // Also make the generation available to any version-forking
3019                // rule reached INSIDE the body — an expression-level
3020                // `let-math .. in ..` is the one that needs it, since its
3021                // own `Ast::LetMathIn` carries no wrapper of its own. See
3022                // `Checker::binding_version`.
3023                let saved = self.scoped_version.replace(*version);
3024                let r = self.infer(&scoped, body);
3025                self.scoped_version = saved;
3026                r
3027            }
3028            // A module member's body: resolve its bare constructor references
3029            // against `path`'s constructors first. `path` is the full absolute
3030            // module path (nested modules wrap with `["M","N"]`), so replace
3031            // rather than push.
3032            Ast::ModuleScope(path, body) => {
3033                let saved = std::mem::replace(&mut self.ctor_scope, path.clone());
3034                let r = self.infer(env, body);
3035                self.ctor_scope = saved;
3036                r
3037            }
3038        }
3039    }
3040
3041    /// Look up a constructor honoring the current [`Checker::ctor_scope`]: try
3042    /// the innermost-out module-qualified keys (`M.N.Ctor`, `M.Ctor`) before
3043    /// the bare fallback (`Ctor`). Keeps the returned decl's ctor NAME strings
3044    /// bare — only the table KEY is qualified — so eval/exhaustiveness/error
3045    /// text are untouched.
3046    fn lookup_ctor(&self, name: &str) -> Option<Rc<VariantDecl>> {
3047        for k in (1..=self.ctor_scope.len()).rev() {
3048            let key = format!("{}.{}", self.ctor_scope[..k].join("."), name);
3049            if let Some(d) = self.ctors.get(&key) {
3050                return Some(d.clone());
3051            }
3052        }
3053        self.ctors.get(name).cloned()
3054    }
3055
3056    /// Shared by `Ast::Ctor` and pattern-matching's `Pattern::Ctor`: look up
3057    /// `name`'s declaration, mint fresh type arguments for its (possibly
3058    /// zero) parameters, and check the payload — either an already-inferred
3059    /// expression type to unify against (`Ast::Ctor`'s case, via `infer`
3060    /// directly) or nothing (patterns bind their own payload separately, in
3061    /// `bind_pattern`). `expected_result`, if given, is unified against the
3062    /// application's result type — used by nothing yet in this port's
3063    /// rules but kept general for symmetry; always `None` from `infer`,
3064    /// which just returns the result type instead.
3065    fn infer_ctor(
3066        &mut self,
3067        env: &TypeEnv<'s>,
3068        name: &str,
3069        payload: Option<&Ast<'s>>,
3070        expected_result: Option<&MonoType>,
3071    ) -> Result<MonoType, TypeError> {
3072        let decl = self
3073            .lookup_ctor(name)
3074            .ok_or_else(|| TypeError::simple(None, format!("unknown constructor '{name}'")))?;
3075        let args: Vec<MonoType> = (0..decl.params).map(|_| self.fresh()).collect();
3076        let (payload_ty, result_ty) = decl.instantiate_ctor(name, &args).ok_or_else(|| {
3077            TypeError::simple(
3078                None,
3079                format!("constructor '{name}' applied with the wrong number of type arguments"),
3080            )
3081        })?;
3082        if let Some(expected) = expected_result {
3083            self.unify_ctx(expected, &result_ty, None, &format!("constructor '{name}'"))?;
3084        }
3085        match (payload_ty, payload) {
3086            (Some(expected), Some(actual)) => {
3087                let actual_ty = self.infer(env, actual)?;
3088                self.unify_ctx(
3089                    &expected,
3090                    &actual_ty,
3091                    ast_span(actual),
3092                    &format!("the payload of constructor '{name}'"),
3093                )?;
3094            }
3095            (None, None) => {}
3096            (Some(_), None) => {
3097                return Err(TypeError::simple(
3098                    None,
3099                    format!("constructor '{name}' expects a payload but none was given"),
3100                ))
3101            }
3102            (None, Some(_)) => {
3103                return Err(TypeError::simple(
3104                    None,
3105                    format!("constructor '{name}' takes no payload but one was given"),
3106                ))
3107            }
3108        }
3109        Ok(result_ty)
3110    }
3111
3112    // ---- patterns ------------------------------------------------------
3113
3114    /// Type-check `pat` against `ty`, extending (a clone of) `env` with
3115    /// every name it binds. Mirrors `typechecker.ml`'s `typecheck_pattern`.
3116    fn bind_pattern(
3117        &mut self,
3118        env: TypeEnv<'s>,
3119        pat: &Pattern<'s>,
3120        ty: &MonoType,
3121    ) -> Result<TypeEnv<'s>, TypeError> {
3122        match pat {
3123            Pattern::Wild => Ok(env),
3124            Pattern::Var(name) => Ok(env.with(*name, PolyType::mono(ty.clone()), self.stage)),
3125            Pattern::Unit => {
3126                self.unify_ctx(&t_unit(), ty, None, "a unit pattern")?;
3127                Ok(env)
3128            }
3129            Pattern::Bool(_) => {
3130                self.unify_ctx(&t_bool(), ty, None, "a boolean pattern")?;
3131                Ok(env)
3132            }
3133            Pattern::Int(_) => {
3134                self.unify_ctx(&t_int(), ty, None, "an integer pattern")?;
3135                Ok(env)
3136            }
3137            Pattern::Str(_) => {
3138                self.unify_ctx(&t_string(), ty, None, "a string pattern")?;
3139                Ok(env)
3140            }
3141            Pattern::Tuple(pats) => {
3142                let elem_tys: Vec<MonoType> = pats.iter().map(|_| self.fresh()).collect();
3143                self.unify_ctx(&product(elem_tys.clone()), ty, None, "a tuple pattern")?;
3144                let mut env = env;
3145                for (p, t) in pats.iter().zip(elem_tys.iter()) {
3146                    env = self.bind_pattern(env, p, t)?;
3147                }
3148                Ok(env)
3149            }
3150            Pattern::EmptyList => {
3151                let elem = self.fresh();
3152                self.unify_ctx(&list(elem), ty, None, "an empty-list pattern")?;
3153                Ok(env)
3154            }
3155            Pattern::Cons(head, tail) => {
3156                let elem = self.fresh();
3157                self.unify_ctx(&list(elem.clone()), ty, None, "a cons pattern")?;
3158                let env = self.bind_pattern(env, head, &elem)?;
3159                self.bind_pattern(env, tail, &list(elem))
3160            }
3161            Pattern::Ctor(name, payload) => {
3162                let decl = self.lookup_ctor(name).ok_or_else(|| {
3163                    TypeError::simple(None, format!("unknown constructor '{name}' in a pattern"))
3164                })?;
3165                let args: Vec<MonoType> = (0..decl.params).map(|_| self.fresh()).collect();
3166                let (payload_ty, result_ty) = decl.instantiate_ctor(name, &args).ok_or_else(|| {
3167                    TypeError::simple(
3168                        None,
3169                        format!(
3170                            "constructor '{name}' applied with the wrong number of type arguments in a pattern"
3171                        ),
3172                    )
3173                })?;
3174                self.unify_ctx(
3175                    &result_ty,
3176                    ty,
3177                    None,
3178                    &format!("the constructor pattern '{name}'"),
3179                )?;
3180                match (payload_ty, payload) {
3181                    (Some(expected), Some(p)) => self.bind_pattern(env, p, &expected),
3182                    (None, None) => Ok(env),
3183                    (Some(_), None) => Err(TypeError::simple(
3184                        None,
3185                        format!(
3186                            "constructor pattern '{name}' expects a payload but none was given"
3187                        ),
3188                    )),
3189                    (None, Some(_)) => Err(TypeError::simple(
3190                        None,
3191                        format!("constructor pattern '{name}' takes no payload but one was given"),
3192                    )),
3193                }
3194            }
3195            Pattern::As(inner, name) => {
3196                let env = self.bind_pattern(env, inner, ty)?;
3197                Ok(env.with(*name, PolyType::mono(ty.clone()), self.stage))
3198            }
3199        }
3200    }
3201
3202    // ---- inline / block / math text -------------------------------------
3203
3204    /// Check one inline-text element. A command's own type is a genuine
3205    /// `MonoType::InlineCmd(params)` (`[...] inline-cmd`, mirroring v0.0.6's
3206    /// `HorzCommandType`) — bound either by `Ast::LetIn`'s command-binding
3207    /// rule (`Checker::command_scheme`) or, for the port's built-in
3208    /// commands, directly by `prim_types::primitive_type`'s `\emph` entry.
3209    /// Checking an application here is exact-arity plus one unification per
3210    /// argument against `params`, via `check_cmd_args` — there is no
3211    /// `context -> arg1 -> .. -> inline-boxes` function shape to unify the
3212    /// whole command type against.
3213    fn check_itext(&mut self, env: &TypeEnv<'s>, it: &IText<'s>) -> Result<(), TypeError> {
3214        match it {
3215            IText::Text(_) | IText::CodeText(_) => Ok(()),
3216            IText::Cmd { name, span, args } => {
3217                let tcmd = match self.staged(env, *name, Some(*span), "inline command")? {
3218                    Some(poly) => instantiate(poly, self.ctx.level()),
3219                    None => {
3220                        return Err(TypeError::simple(
3221                            Some(*span),
3222                            format!(
3223                            "internal error: unbound inline command '{}' reached the typechecker",
3224                            self.text(*name)
3225                        ),
3226                        ))
3227                    }
3228                };
3229                match &*resolve(&tcmd) {
3230                    MonoType::InlineCmd(params) => {
3231                        self.check_cmd_args(env, self.text(*name), *span, &params, args)
3232                    }
3233                    other => Err(TypeError::simple(
3234                        Some(*span),
3235                        format!(
3236                            "internal error: inline command '{}' does not have an \
3237                             inline-cmd type (found `{other}`)",
3238                            self.text(*name)
3239                        ),
3240                    )),
3241                }
3242            }
3243            IText::Embed { expr, span } => {
3244                let te = self.infer(env, expr)?;
3245                self.unify_ctx(
3246                    &t_inline_text(),
3247                    &te,
3248                    Some(*span),
3249                    "an inline-text '#…;' embed",
3250                )?;
3251                Ok(())
3252            }
3253            IText::EmbedMath { elems, span: _ } => {
3254                // PERMISSIVE: there is no real type to check a quoted-math
3255                // embed's expressions against. Type each against its own
3256                // fresh variable, purely so unbound-name mistakes inside it
3257                // are still caught, without asserting anything about the
3258                // result.
3259                for me in elems.iter() {
3260                    self.check_math_elem(env, me)?;
3261                }
3262                Ok(())
3263            }
3264        }
3265    }
3266
3267    /// Block-text analogue of `check_itext`'s `IText::Cmd` case — see its
3268    /// doc comment; a `BText::Cmd`'s type is `MonoType::BlockCmd(params)`.
3269    fn check_btext(&mut self, env: &TypeEnv<'s>, bt: &BText<'s>) -> Result<(), TypeError> {
3270        match bt {
3271            BText::Cmd { name, span, args } => {
3272                let tcmd = match self.staged(env, *name, Some(*span), "block command")? {
3273                    Some(poly) => instantiate(poly, self.ctx.level()),
3274                    None => {
3275                        return Err(TypeError::simple(
3276                            Some(*span),
3277                            format!(
3278                            "internal error: unbound block command '{}' reached the typechecker",
3279                            self.text(*name)
3280                        ),
3281                        ))
3282                    }
3283                };
3284                match &*resolve(&tcmd) {
3285                    MonoType::BlockCmd(params) => {
3286                        self.check_cmd_args(env, self.text(*name), *span, &params, args)
3287                    }
3288                    other => Err(TypeError::simple(
3289                        Some(*span),
3290                        format!(
3291                            "internal error: block command '{}' does not have a \
3292                             block-cmd type (found `{other}`)",
3293                            self.text(*name)
3294                        ),
3295                    )),
3296                }
3297            }
3298            BText::Embed { expr, span } => {
3299                let te = self.infer(env, expr)?;
3300                self.unify_ctx(
3301                    &t_block_text(),
3302                    &te,
3303                    Some(*span),
3304                    "a block-text '#…;' embed",
3305                )?;
3306                Ok(())
3307            }
3308        }
3309    }
3310
3311    /// Walk one quoted math element. `Chars`/`Group`/`Sub`/`Sup`/`Primes`
3312    /// carry no program-mode content of their own (nothing to check beyond
3313    /// recursing). `Cmd`/`Embed` are where math meets the ordinary
3314    /// expression language: a `Cmd`'s `name` must resolve to a genuine
3315    /// `MathCmd` type (checked exactly like `check_itext`'s `IText::Cmd`,
3316    /// via `check_cmd_args` — a math command's optional `?:`/`?*`-marked
3317    /// or marker-less-padded arguments are handled by `check_cmd_args` the
3318    /// same generic way), and an `Embed`'s (`#expr`) type must unify with
3319    /// `math` (a math command parameter, or another program-mode value
3320    /// that itself produces math — `Value::Math`/ `Value::MathText` are
3321    /// the two runtime shapes this unifies against, see `value.rs`).
3322    fn check_math_elem(&mut self, env: &TypeEnv<'s>, m: &MathElem<'s>) -> Result<(), TypeError> {
3323        match m {
3324            MathElem::Chars(_) => Ok(()),
3325            MathElem::Group(elems) => {
3326                for e in elems {
3327                    self.check_math_elem(env, e)?;
3328                }
3329                Ok(())
3330            }
3331            MathElem::Sub(base, script) | MathElem::Sup(base, script) => {
3332                self.check_math_elem(env, base)?;
3333                for e in script {
3334                    self.check_math_elem(env, e)?;
3335                }
3336                Ok(())
3337            }
3338            MathElem::Primes(base, _) => self.check_math_elem(env, base),
3339            MathElem::Cmd { name, span, args } => {
3340                let tcmd = match self.staged(env, *name, Some(*span), "math command")? {
3341                    Some(poly) => instantiate(poly, self.ctx.level()),
3342                    None => {
3343                        return Err(TypeError::simple(
3344                            Some(*span),
3345                            format!(
3346                                "internal error: unbound math command '{}' reached the typechecker",
3347                                self.text(*name)
3348                            ),
3349                        ))
3350                    }
3351                };
3352                match &*resolve(&tcmd) {
3353                    MonoType::MathCmd(params) => {
3354                        self.check_cmd_args(env, self.text(*name), *span, &params, args)
3355                    }
3356                    other => Err(TypeError::simple(
3357                        Some(*span),
3358                        format!(
3359                            "internal error: math command '{}' does not have a \
3360                             math-cmd type (found `{other}`)",
3361                            self.text(*name)
3362                        ),
3363                    )),
3364                }
3365            }
3366            MathElem::Embed { expr, span } => {
3367                let te = self.infer(env, expr)?;
3368                self.unify_ctx(&t_math_text(), &te, Some(*span), "a math '#…' embed")?;
3369                Ok(())
3370            }
3371        }
3372    }
3373}
3374
3375/// If `name` (an `Ast::LetIn` binding's name) is command-shaped, the sigil
3376/// that says which kind — `'\\'` for an inline command, `'+'` for a block
3377/// command — else `None` for an ordinary variable binding.
3378///
3379/// Looks only at the *local* segment (after the last `.`): a
3380/// module-qualified command is spelled e.g. `"M.\cmd"`, sigil on the local
3381/// part only (module names can never start with `\`/`+` — `qualify_key`'s
3382/// doc comment). A bare name has no `.` at all, so `rsplit('.').next()`
3383/// degrades to the whole string.
3384///
3385/// **Must also check the second character.** A genuine command sigil is
3386/// always immediately followed by an identifier, but a parenthesized
3387/// operator NAME (`cst.rs`'s `BindName`) can merely *start* with the same
3388/// character, e.g. `let (+++>) = ..` (`itemize.satyh`) or `let (+.) = ..`.
3389/// Requiring an alphabetic second character mirrors the lexer's own split
3390/// and keeps such an operator name an ordinary variable binding rather than
3391/// a false-positive command.
3392fn command_sigil(name: &str) -> Option<char> {
3393    let local = name.rsplit('.').next().unwrap_or(name);
3394    let mut chars = local.chars();
3395    match chars.next() {
3396        Some(c @ ('\\' | '+')) if chars.next().is_some_and(|c2| c2.is_ascii_alphabetic()) => {
3397            Some(c)
3398        }
3399        _ => None,
3400    }
3401}
3402
3403/// Greedily unwrap a (resolved) `Func` chain into its list of domains and
3404/// final codomain: `dom1 -> dom2 -> .. -> domN -> result` becomes
3405/// `(vec![dom1, .., domN], result)`. Only ever follows the *codomain* at
3406/// each step (never recurses into a domain, even one that is itself a
3407/// `Func`) — used by `Checker::command_scheme` to recover a `let-inline`/
3408/// `let-block` binding's `context -> arg1 -> .. -> argN -> result` shape
3409/// from its ordinarily-inferred function type.
3410fn peel_func_chain(ty: MonoType) -> (Vec<MonoType>, MonoType) {
3411    let mut doms = Vec::new();
3412    let mut cur = ty;
3413    loop {
3414        // Owned: this walk moves each arrow's domain/codomain out.
3415        match resolve(&cur).into_owned() {
3416            MonoType::Func(_row, dom, cod) => {
3417                doms.push(*dom);
3418                cur = *cod;
3419            }
3420            other => return (doms, other),
3421        }
3422    }
3423}
3424
3425/// [`peel_func_chain`]'s row-carrying twin:
3426/// same greedy unwrap, but keeps each arrow's own (resolved) optional-
3427/// argument [`Row`] alongside its domain, since a V0_1 command's `LambdaOpt`-
3428/// produced arrows carry each parameter's `?(l:τ,…)` bundle on that
3429/// PARAMETER's own arrow (the arrow whose *domain* is the labeled argument —
3430/// see `Checker::command_scheme`'s V0_1 harvest). Used only by
3431/// `command_scheme`; `check_cmd_args`/`math_command_scheme*` still use the
3432/// row-blind `peel_func_chain` (they never harvest labels).
3433fn peel_func_chain_rows(ty: MonoType) -> (Vec<(Row, MonoType)>, MonoType) {
3434    let mut slots = Vec::new();
3435    let mut cur = ty;
3436    loop {
3437        // Owned, as in `peel_func_chain`.
3438        match resolve(&cur).into_owned() {
3439            MonoType::Func(row, dom, cod) => {
3440                slots.push((*row, *dom));
3441                cur = *cod;
3442            }
3443            other => return (slots, other),
3444        }
3445    }
3446}
3447
3448/// Turn one V0_1 command parameter's [`Row`] (the row `Ast::LambdaOpt`'s
3449/// inference leaves on the `Func` arrow whose *domain* is that parameter —
3450/// see [`peel_func_chain_rows`]) into a closed-label-map [`CmdArgType`]: walk
3451/// the (resolved) row's `Cons` chain into a `Vec<(String, MonoType)>`, sorted
3452/// by label so `unify_cmd_args`'s equal-domain zip is order-insensitive.
3453/// A leftover `Row::Var` (an
3454/// under-constrained/free row — the ordinary case for a slot with no `?(…)`
3455/// bundle at all) defaults to no labels, same as `Row::Empty`. Shared by
3456/// `Checker::command_scheme`'s V0_1 branch and
3457/// `Checker::math_command_scheme_v01` so both harvest
3458/// identically.
3459fn harvest_slot(row: Row, dom: MonoType) -> CmdArgType {
3460    let mut opt_labels: Vec<(String, MonoType)> = Vec::new();
3461    let mut cur = resolve_row(&row).into_owned();
3462    loop {
3463        match cur {
3464            Row::Empty => break,
3465            Row::Var(_) => break,
3466            Row::Cons(label, lty, rest) => {
3467                opt_labels.push((label, *lty));
3468                cur = resolve_row(&rest).into_owned();
3469            }
3470        }
3471    }
3472    opt_labels.sort_by(|a, b| a.0.cmp(&b.0));
3473    labeled(opt_labels, dom)
3474}
3475
3476/// A best-effort span for an `Ast` node: only `Var`/`Overwrite`/
3477/// `AccessField` carry one directly (see `ast.rs`'s module doc comment);
3478/// everything else falls back to `None`; the resulting `TypeError` then just
3479/// prints without a location prefix.
3480pub(crate) fn ast_span<'s>(ast: &Ast<'s>) -> Option<Span> {
3481    match ast {
3482        Ast::Var(_, span) => Some(*span),
3483        Ast::Overwrite(_, span, _) => Some(*span),
3484        Ast::AccessField(_, _, span) => Some(*span),
3485        Ast::VersionScope(_, inner) => ast_span(inner),
3486        Ast::ModuleScope(_, inner) => ast_span(inner),
3487        _ => None,
3488    }
3489}
3490
3491/// Type-check a whole elaborated [`Program`], additionally returning every
3492/// non-fatal [`MatchWarning`] the exhaustiveness/redundancy pass collected
3493/// — v0.0.6's `exhchecker.ml` warns
3494/// on a non-exhaustive or redundant `match` rather than rejecting the
3495/// program, so these never turn a would-have-passed program into a
3496/// `TypeError`.
3497pub fn typecheck_verbose<'s>(program: &Program<'s>) -> Result<Vec<MatchWarning>, TypeError> {
3498    typecheck_verbose_with_version(program, RustyfiVersion::V0_0)
3499}
3500
3501/// Same as [`typecheck_verbose`], for a given target `version` — threads
3502/// through to `Checker::new_with_version`/`base_type_env_with_version` so a
3503/// `V0_1` program's `page-break` resolves against the `length * length`
3504/// tuple type (`prim_types::t_page_or_geometry`) and never sees `page`'s
3505/// `VariantDecl` (gated out of `builtin_variants_with_version(V0_1)`) — a
3506/// `V0_1` program that writes `A4Paper` gets the SAME "unbound constructor"
3507/// error upstream's own 0.1 compiler would give it, which is the faithful
3508/// behavior (the ADT is genuinely gone, not merely discouraged).
3509pub fn typecheck_verbose_with_version<'s>(
3510    program: &Program<'s>,
3511    version: RustyfiVersion,
3512) -> Result<Vec<MatchWarning>, TypeError> {
3513    let mut checker = Checker::new_with_version(program, version)?;
3514    let env = base_type_env_with_version(checker.store, version);
3515    checker.infer(&env, &program.body)?;
3516    Ok(checker.warnings)
3517}
3518
3519/// Type-check a whole elaborated [`Program`]. Validation only: on success the
3520/// caller proceeds to evaluate `program.body`; the evaluator is untouched by
3521/// this phase. A thin wrapper over [`typecheck_verbose`] that discards its
3522/// warnings.
3523pub fn typecheck<'s>(program: &Program<'s>) -> Result<(), TypeError> {
3524    typecheck_with_version(program, RustyfiVersion::V0_0)
3525}
3526
3527/// Same as [`typecheck`], for a given target `version`. See
3528/// `typecheck_verbose_with_version`'s doc comment.
3529pub fn typecheck_with_version<'s>(
3530    program: &Program<'s>,
3531    version: RustyfiVersion,
3532) -> Result<(), TypeError> {
3533    typecheck_verbose_with_version(program, version).map(|_warnings| ())
3534}
3535
3536// ============================================================================
3537// Per-binding ≡ whole-program equivalence, and session-incrementality.
3538// A `#[cfg(test)]` unit module since `BindingView`/`Checker`/`infer_binding`
3539// etc. are `pub(crate)` — an integration test can't reach them.
3540// ============================================================================
3541#[cfg(test)]
3542mod l3_per_binding_tests {
3543    use super::*;
3544    use crate::{elaborate, primitives};
3545
3546    fn elaborate_src<'s>(store: &'s SymbolStore, src: &str) -> Program<'s> {
3547        let file = rustyfi_syntax::parse_file(src).expect("parse failed");
3548        let env = primitives::base_env();
3549        let scope = elaborate::Scope::new(store, env.names());
3550        elaborate::elaborate_program(&file, &scope).expect("elaborate failed")
3551    }
3552
3553    /// Manually drive the checker per binding: walk `program.body`'s Let
3554    /// chain constructing `BindingView`s by hand, `infer_binding` +
3555    /// `with_all` at each step, `infer_expr` on the non-Let tail. This is
3556    /// exactly what `infer`'s own recursion does internally — driven here
3557    /// from outside the engine through the `pub(crate)` per-binding API, the
3558    /// same way `v1/module_check.rs` does.
3559    fn drive_manually<'s>(
3560        program: &Program<'s>,
3561        version: RustyfiVersion,
3562    ) -> Result<Vec<MatchWarning>, TypeError> {
3563        let mut checker = Checker::new_with_version(program, version)?;
3564        let mut env = base_type_env_with_version(program.store, version);
3565        let mut ast: &Ast<'s> = &program.body;
3566        loop {
3567            ast = match ast {
3568                Ast::LetIn(name, value, body) => {
3569                    let schemes =
3570                        checker.infer_binding(&env, BindingView::Let { name: *name, value })?;
3571                    env = env.with_all(schemes, checker.binding_stage(value));
3572                    body
3573                }
3574                Ast::LetMathIn(name, value, body) => {
3575                    let schemes =
3576                        checker.infer_binding(&env, BindingView::LetMath { name: *name, value })?;
3577                    env = env.with_all(schemes, checker.binding_stage(value));
3578                    body
3579                }
3580                Ast::LetRecIn(bindings, body) => {
3581                    let schemes = checker.infer_binding(&env, BindingView::LetRec(bindings))?;
3582                    env = env.with_all(schemes, checker.binding_stage_rec(bindings));
3583                    body
3584                }
3585                Ast::LetMutableIn(name, init, body) => {
3586                    let schemes = checker
3587                        .infer_binding(&env, BindingView::LetMutable { name: *name, init })?;
3588                    env = env.with_all(schemes, checker.binding_stage(init));
3589                    body
3590                }
3591                other => {
3592                    checker.infer_expr(&env, other)?;
3593                    break;
3594                }
3595            };
3596        }
3597        Ok(checker.take_warnings())
3598    }
3599
3600    /// Elaborate `src` once, then compare `typecheck_verbose_with_version`
3601    /// against the manual per-binding drive: identical verdict, identical
3602    /// `TypeError` `Display` on error, identical `MatchWarning` list (incl.
3603    /// order — `MatchWarning` derives `PartialEq`) on success.
3604    fn assert_equivalent(src: &str) {
3605        let version = RustyfiVersion::V0_0;
3606        let store = SymbolStore::new();
3607        let program = elaborate_src(&store, src);
3608        let whole = typecheck_verbose_with_version(&program, version);
3609        let manual = drive_manually(&program, version);
3610        match (whole, manual) {
3611            (Ok(w1), Ok(w2)) => {
3612                assert_eq!(w1, w2, "warnings differ for {src:?}");
3613            }
3614            (Err(e1), Err(e2)) => {
3615                assert_eq!(
3616                    format!("{e1}"),
3617                    format!("{e2}"),
3618                    "error strings differ for {src:?}"
3619                );
3620            }
3621            (Ok(w), Err(e)) => panic!(
3622                "{src:?}: whole-program accepted (warnings={w:?}), manual drive rejected: {e}"
3623            ),
3624            (Err(e), Ok(w)) => panic!(
3625                "{src:?}: whole-program rejected ({e}), manual drive accepted (warnings={w:?})"
3626            ),
3627        }
3628    }
3629
3630    #[test]
3631    fn per_binding_drive_matches_whole_program_across_binding_kinds() {
3632        let cases: &[&str] = &[
3633            // ---- plain `let` ----
3634            "let x = 1 in x + 1",
3635            "let x = 1 in x + true", // failing: type mismatch
3636            // ---- polymorphic `let` ----
3637            "let id = fun x -> x in (id 1, id true)",
3638            // ---- `let-inline` command binding (+ its application) ----
3639            "let-inline ctx \\emph it = read-inline ctx it
3640             in
3641             { \\emph{ ok } }",
3642            "let-inline ctx \\bad = ctx + 1
3643             in
3644             ()", // failing: not context-headed
3645            // ---- `let-block` command binding (+ its application) ----
3646            "let-block ctx +p it = line-break true true ctx (read-inline ctx it)
3647             in
3648             '< +p{ ok } >",
3649            "let-block ctx +duo a b = read-block ctx a
3650             in
3651             '< +duo{x} >", // failing: wrong arity
3652            // ---- `let-math` command binding ----
3653            "let-math \\g m = ${#m#m} in 0",
3654            "let-math \\f = 3 in 0", // failing: value isn't `math`
3655            // ---- `let-rec` group ----
3656            "let-rec is-even n = if n == 0 then true else is-odd (n - 1)
3657             and is-odd n = if n == 0 then false else is-even (n - 1)
3658             in
3659             is-even 4",
3660            "let-rec f n = if n == 0 then 0 else (f true)
3661             in
3662             f 1", // failing: recursive use at a mismatched type
3663            // ---- `let-mutable` (value restriction) ----
3664            "let-mutable x <- 0
3665             in
3666             (x <- 5)",
3667            "let-mutable r <- []
3668             in
3669             ((r <- (1 :: !r)) before (r <- (true :: !r)))", // failing: value restriction
3670            // ---- a `match` to also exercise warning accumulation ----
3671            "match Some 1 with
3672             | Some n -> n
3673             | None -> 0",
3674        ];
3675        for src in cases {
3676            assert_equivalent(src);
3677        }
3678    }
3679
3680    /// Session-incrementality. `declare_variant` after
3681    /// `infer_binding` affects only *later* checking against the session —
3682    /// a ctor referenced before its `declare_variant` fails with the same
3683    /// "unknown constructor" error the whole-program path gives for a
3684    /// genuinely-undeclared one; after `declare_variant`, it typechecks.
3685    #[test]
3686    fn session_incrementality_declare_variant_affects_only_later_bindings() {
3687        let store = SymbolStore::new();
3688        let program = elaborate_src(&store, "type t = | A of int in 0");
3689        assert_eq!(program.type_decls.len(), 1);
3690        let decl = &program.type_decls[0];
3691
3692        let mut checker = Checker::empty(&store);
3693        checker.install_builtin_variants(RustyfiVersion::V0_0);
3694        let env = base_type_env_with_version(&store, RustyfiVersion::V0_0);
3695
3696        // Before `declare_variant`, `A` is unknown — same message shape a
3697        // genuinely-undeclared constructor gets.
3698        let a_payload = Ast::Ctor("A".to_string(), Some(Box::new(Ast::Int(1))));
3699        let before = checker
3700            .infer_binding(
3701                &env,
3702                BindingView::Let {
3703                    name: store.intern("before"),
3704                    value: &a_payload,
3705                },
3706            )
3707            .expect_err("`A` should be unknown before declare_variant");
3708        assert_eq!(format!("{before}"), "unknown constructor 'A'");
3709
3710        let nosuch_payload = Ast::Ctor("NoSuchCtor".to_string(), None);
3711        let genuinely_unknown = checker
3712            .infer_binding(
3713                &env,
3714                BindingView::Let {
3715                    name: store.intern("n"),
3716                    value: &nosuch_payload,
3717                },
3718            )
3719            .expect_err("a genuinely undeclared ctor should also fail");
3720        assert_eq!(
3721            format!("{genuinely_unknown}"),
3722            "unknown constructor 'NoSuchCtor'"
3723        );
3724
3725        // After `declare_variant`, `A` becomes visible and typechecks —
3726        // this later binding sees it; the earlier `before` call above is
3727        // unaffected (it already returned its error).
3728        checker
3729            .declare_variant(decl)
3730            .expect("declare_variant should succeed");
3731        let after = checker.infer_binding(
3732            &env,
3733            BindingView::Let {
3734                name: store.intern("after"),
3735                value: &a_payload,
3736            },
3737        );
3738        assert!(
3739            after.is_ok(),
3740            "A(1) should typecheck after declare_variant: {after:?}"
3741        );
3742    }
3743}
3744
3745// ============================================================================
3746// The shadowing-fix follow-up to the version-scope env swap above:
3747// `version_scoped_type_env`'s `Ast::VersionScope` overwrite must not
3748// re-stomp a `PRIMITIVE_NAMES` entry the user already shadowed BEFORE the
3749// `VersionScope` is reached. A `#[cfg(test)]` unit module (mirroring
3750// `l3_per_binding_tests` above) since `Checker`/`TypeEnv`/`Ast` are all
3751// `pub(crate)` or crate-private-shaped enough that a hand-built synthetic
3752// `Ast` fixture (no parser/elaborator round-trip needed to pin this one
3753// shape) is the most direct way to exercise the exact env-swap path.
3754// ============================================================================
3755#[cfg(test)]
3756mod x2b_shadow_tests {
3757    use super::*;
3758
3759    /// `let page-break = 42 in <VersionScope V0_0> page-break` — infer
3760    /// the whole tree under a `V0_1`-ambient `Checker`. `page-break` is a
3761    /// `PRIMITIVE_NAMES` member (a version-forked one, no less: its `V0_0`
3762    /// scheme takes the `page` ADT, its `V0_1` scheme a `length * length`
3763    /// tuple — see `page_prims.rs`). An overwrite loop that replaced every
3764    /// `PRIMITIVE_NAMES` entry unconditionally on entering the
3765    /// `VersionScope` would re-stomp the user's `page-break = 42` with
3766    /// `V0_0`'s builtin `page-break` (a curried function type), inferring the
3767    /// inner `Var` as a function, not `int`. Instead
3768    /// `version_scoped_type_env` sees `page-break` recorded in
3769    /// `env.shadowed` (set by the `LetIn` arm's `env.with_all`, which goes
3770    /// through `TypeEnv::with`) and skips the overwrite for that one name, so
3771    /// the `Var` resolves through the untouched user binding and the whole
3772    /// expression types as `int`.
3773    #[test]
3774    fn version_scope_does_not_clobber_a_user_shadowed_primitive() {
3775        assert!(
3776            PRIMITIVE_NAMES.contains(&"page-break"),
3777            "fixture assumption: page-break must be a PRIMITIVE_NAMES member"
3778        );
3779
3780        let span = Span::default();
3781        let store = SymbolStore::new();
3782        let page_break = store.intern("page-break");
3783        let ast = Ast::LetIn(
3784            page_break,
3785            Box::new(Ast::Int(42)),
3786            Box::new(Ast::VersionScope(
3787                RustyfiVersion::V0_0,
3788                Box::new(Ast::Var(page_break, span)),
3789            )),
3790        );
3791        let program = Program {
3792            type_decls: Vec::new(),
3793            synonym_decls: Vec::new(),
3794            body: ast,
3795            store: &store,
3796        };
3797
3798        let mut checker = Checker::new_with_version(&program, RustyfiVersion::V0_1)
3799            .expect("checker construction over an empty-decls program should succeed");
3800        let env = base_type_env_with_version(&store, RustyfiVersion::V0_1);
3801        let ty = checker.infer(&env, &program.body).unwrap_or_else(|e| {
3802            panic!(
3803                "inferring the version-scoped `Var` over the user's shadowed \
3804                 `page-break = 42` binding should type-check as `int`, not error: {e}"
3805            )
3806        });
3807        assert!(
3808            matches!(ty, MonoType::Base(BaseType::Int)),
3809            "the VersionScope env swap must respect the user's `page-break` shadow \
3810             (expected MonoType::Base(BaseType::Int), got {ty:?} instead) — a \
3811             MonoType::Func here would mean version_scoped_type_env re-stomped the \
3812             user binding with V0_0's builtin page-break scheme"
3813        );
3814    }
3815
3816    /// Companion positive control (same shape as the test above, MINUS the
3817    /// enclosing user shadow): a version-forked primitive referenced inside
3818    /// a `VersionScope` still resolves to `version`'s own (function-typed)
3819    /// scheme. Contrasts directly with
3820    /// `version_scope_does_not_clobber_a_user_shadowed_primitive`'s `int`
3821    /// result: same `page-break` name, same `VersionScope(V0_0, _)`, the only
3822    /// difference being the absence of a prior `let page-break = …` shadow.
3823    #[test]
3824    fn version_scope_still_resolves_unshadowed_forked_primitive() {
3825        let span = Span::default();
3826        let store = SymbolStore::new();
3827        let ast = Ast::VersionScope(
3828            RustyfiVersion::V0_0,
3829            Box::new(Ast::Var(store.intern("page-break"), span)),
3830        );
3831        let program = Program {
3832            type_decls: Vec::new(),
3833            synonym_decls: Vec::new(),
3834            body: ast,
3835            store: &store,
3836        };
3837        let mut checker = Checker::new_with_version(&program, RustyfiVersion::V0_1)
3838            .expect("checker construction over an empty-decls program should succeed");
3839        let env = base_type_env_with_version(&store, RustyfiVersion::V0_1);
3840        let ty = checker.infer(&env, &program.body).unwrap_or_else(|e| {
3841            panic!(
3842                "an unshadowed page-break reference inside a VersionScope should still \
3843                 type-check (X2a's original capability, unaffected by X2b): {e}"
3844            )
3845        });
3846        assert!(
3847            matches!(ty, MonoType::Func(..)),
3848            "page-break (unshadowed) inside a VersionScope should still resolve to its \
3849             builtin (function-typed) scheme, got {ty:?} instead — the X2b shadow guard \
3850             must not have blocked this NON-shadowed overwrite"
3851        );
3852    }
3853}
3854
3855// ============================================================================
3856// Acceptance test against the real `stdja.satyh` `sig … end` block (
3857// command values are covered by `crates/rustyfi-lang/tests/typecheck.rs`'s
3858// end-to-end fixtures; this module covers the `SigItem`/`constraint` lowering
3859// directly, since `lower_sig_item` is a crate-private entry point no
3860// sig-enforcement pass calls yet).
3861// ============================================================================
3862#[cfg(test)]
3863mod sig_constraint_tests {
3864    use super::*;
3865    use rustyfi_syntax::cst::{SigAnnot, TopBinding};
3866
3867    fn parse_module_sig(src: &str) -> SigAnnot {
3868        let file = rustyfi_syntax::parse_file(src).expect("parse failed");
3869        for b in &file.prelude {
3870            if let TopBinding::Module { sig: Some(sig), .. } = b {
3871                return sig.clone();
3872            }
3873        }
3874        panic!("no `module .. : sig .. end` found in {src:?}");
3875    }
3876
3877    #[test]
3878    fn constraint_suffix_lowers_to_a_kind_record_bound_on_its_tyvar() {
3879        let sig = parse_module_sig(
3880            "module M : sig\n\
3881             val document : 'a -> config ?-> block-text -> document\n\
3882             constraint 'a :: (| title : inline-text; author : inline-text |)\n\
3883             end = struct\n\
3884             let document x c bt = bt\n\
3885             end",
3886        );
3887        let mut ctx = TypeContext::new();
3888        let mut saw_record_kind = false;
3889        for item in &sig.items {
3890            let (name, ty) =
3891                lower_sig_item(item, &mut ctx, RustyfiVersion::V0_0).expect("a value item");
3892            assert_eq!(name, "document");
3893            // Walk the lowered `Func` chain: `'a`'s fresh variable is the
3894            // very first domain (`Func(Var('a), Func(option(config),
3895            // Func(block-text, document)))` — see `lower_type_expr`'s doc
3896            // comment for the `?->` shape).
3897            if let MonoType::Func(_row, dom, _) = &ty {
3898                if let MonoType::Var(v) = &**dom {
3899                    if let Kind::Record(labels) = v.kind() {
3900                        saw_record_kind = true;
3901                        let expected: BTreeSet<String> =
3902                            ["title", "author"].iter().map(|s| s.to_string()).collect();
3903                        assert_eq!(labels, expected);
3904                    }
3905                }
3906            }
3907        }
3908        assert!(
3909            saw_record_kind,
3910            "expected 'a's fresh variable to carry a Kind::Record bound"
3911        );
3912    }
3913
3914    #[test]
3915    fn kind_record_bound_accepts_a_row_with_every_required_label() {
3916        // Direct demonstration that the constraint's lowered `Kind::Record`
3917        // bound rides on *existing* `unify`/`bind_var` machinery for free —
3918        // no sig-enforcement pass exists yet to drive this against a real
3919        // `struct` implementation, but
3920        // the positive-presence check itself already works once something
3921        // does.
3922        let mut ctx = TypeContext::new();
3923        let labels: BTreeSet<String> = ["title", "author"].iter().map(|s| s.to_string()).collect();
3924        let v = ctx.fresh_var_with_kind(Kind::Record(labels));
3925        let constrained = MonoType::Var(v);
3926        let full = MonoType::Record(Row::Cons(
3927            "title".to_string(),
3928            Box::new(t_inline_text()),
3929            Box::new(Row::Cons(
3930                "author".to_string(),
3931                Box::new(t_inline_text()),
3932                Box::new(Row::Empty),
3933            )),
3934        ));
3935        unify(&constrained, &full).expect("row has both required labels");
3936    }
3937
3938    #[test]
3939    fn kind_record_bound_rejects_a_row_missing_a_required_label() {
3940        let mut ctx = TypeContext::new();
3941        let labels: BTreeSet<String> = ["title", "author"].iter().map(|s| s.to_string()).collect();
3942        let v = ctx.fresh_var_with_kind(Kind::Record(labels));
3943        let constrained = MonoType::Var(v);
3944        let missing_author = MonoType::Record(Row::Cons(
3945            "title".to_string(),
3946            Box::new(t_inline_text()),
3947            Box::new(Row::Empty),
3948        ));
3949        let err = unify(&constrained, &missing_author)
3950            .expect_err("row is missing the required 'author' label");
3951        assert!(
3952            format!("{err:?}").contains("author"),
3953            "error should name the missing label: {err:?}"
3954        );
3955    }
3956
3957    #[test]
3958    fn real_stdja_sig_block_lowers_every_item_to_a_monotype() {
3959        // Mirrors the whole `sig … end` block of the real upstream
3960        // `stdja.satyh:24-51` (v0.0.6 checkout) — command values, command
3961        // types, `?->`, and the `constraint` suffix all together. Every item
3962        // parses and lowers without error (an empty `struct end` body is
3963        // enough — sig enforcement against a real implementation is not
3964        // this test's job).
3965        let sig = parse_module_sig(
3966            "module StdJa : sig\n\
3967             val default-config : config\n\
3968             val document : 'a -> config ?-> block-text -> document\n\
3969             constraint 'a :: (|\n\
3970             title : inline-text;\n\
3971             author : inline-text;\n\
3972             show-toc : bool;\n\
3973             show-title : bool;\n\
3974             |)\n\
3975             val font-latin-roman : string * float * float\n\
3976             direct \\ref : [string] inline-cmd\n\
3977             direct \\ref-page : [string] inline-cmd\n\
3978             direct \\figure : [inline-text; block-text] inline-cmd\n\
3979             direct +p : [inline-text] block-cmd\n\
3980             direct +pn : [inline-text] block-cmd\n\
3981             direct +section : [string?; string?; inline-text; block-text] block-cmd\n\
3982             direct +subsection : [string?; string?; inline-text; block-text] block-cmd\n\
3983             direct \\emph : [inline-text] inline-cmd\n\
3984             end = struct\n\
3985             end",
3986        );
3987        let mut ctx = TypeContext::new();
3988        let mut names = Vec::new();
3989        for item in &sig.items {
3990            let (name, _ty) =
3991                lower_sig_item(item, &mut ctx, RustyfiVersion::V0_0).expect("a value item");
3992            names.push(name);
3993        }
3994        assert_eq!(
3995            names,
3996            vec![
3997                "default-config",
3998                "document",
3999                "font-latin-roman",
4000                "\\ref",
4001                "\\ref-page",
4002                "\\figure",
4003                "+p",
4004                "+pn",
4005                "+section",
4006                "+subsection",
4007                "\\emph",
4008            ]
4009        );
4010    }
4011}