Skip to main content

rustyfi_lang/
elaborate.rs

1//! Surface CST → `Ast` elaboration. Does scope resolution, operator-
2//! precedence/associativity resolution (the CST leaves that flattened, see
3//! `cst.rs`'s module doc comment), pattern lowering, the `let-inline`/
4//! `let-block` context-argument desugaring, mutable/`while`/`before`
5//! desugaring, field access/record-update folding, itemize-tree
6//! reconstruction, quoted-math lowering, and (untyped) module name-mangling.
7//! This function's signature is the seam where the typechecker
8//! (typechecker.ml / unification.ml port) slots in.
9
10// The elaborator emits the BRANDED tree: every lexical identifier is a
11// `Symbol<'s>` interned into the `SymbolStore` carried by [`Scope`]. See
12// `crate::ast`'s module doc comment for what `I` covers (environment keys
13// only — record labels, constructor tags and optional-argument labels stay
14// `String` here exactly as they always were).
15use crate::ast::branded::{Ast, BText, CmdArg, IText, MatchArm, MathElem, Pattern};
16use crate::symbol::{Symbol, SymbolStore};
17use rustyfi_backend::Length;
18use rustyfi_syntax::cst::{self, ast as c};
19use rustyfi_syntax::leaf::{AnyHorzCmdTok, AnyMathCmdTok, AnyVertCmdTok, UnopExclamTok, VarTok};
20use rustyfi_syntax::span::Span;
21use rustyfi_syntax::token::Token;
22use rustyfi_syntax::RustyfiVersion;
23use std::collections::{HashMap, HashSet, VecDeque};
24use std::rc::Rc;
25
26#[derive(Debug, thiserror::Error)]
27#[error("{span}: {msg}")]
28pub struct ElabError {
29    pub span: Span,
30    pub msg: String,
31}
32
33fn err<T>(span: Span, msg: impl Into<String>) -> Result<T, ElabError> {
34    Err(ElabError {
35        span,
36        msg: msg.into(),
37    })
38}
39
40/// Overlay size at which a [`Scope`]'s name set folds down into a fresh
41/// shared base — the same persistent split, and the same reason, as
42/// `typecheck::OVERLAY_CAP`.
43const NAMES_OVERLAY_CAP: usize = 64;
44
45/// The names in scope (primitives plus, progressively, `let`-bound names).
46/// A flat name set — there is no real namespacing, so a module's qualified
47/// names (`"M.x"`) are just ordinary strings that happen to contain a dot
48/// (see the module doc comment on `qualify_key`).
49#[derive(Clone, Debug)]
50pub struct Scope<'s> {
51    /// The names in scope, split into a large SHARED base (`Rc`, cloned by a
52    /// refcount bump) and a small overlay of the most recent bindings, folded
53    /// into a fresh base once it reaches [`NAMES_OVERLAY_CAP`].
54    ///
55    /// `Scope::with` clones the whole scope per binding — the natural way to
56    /// write a lexical walk — but a flat `HashSet<String>` made that
57    /// O(program x scope): measured at 4-11 MILLION `String` allocations per
58    /// corpus document (~4-8k scope clones, each copying 1300-2600 names),
59    /// the dominant cost of elaboration. Do not flatten it back.
60    ///
61    /// `Rc<str>` rather than `String` so even the capped overlay copy is
62    /// refcount bumps. The set is insert-only, which is what makes the
63    /// shared base sound without tombstones (the two maps below DO support
64    /// removal, but stay flat: ~15 entries vs this one's thousands, 1-2% of
65    /// the clone traffic).
66    names_base: Rc<HashSet<Rc<str>>>,
67    names_overlay: HashSet<Rc<str>>,
68    /// Per declared parameter position, `true` where that position is a
69    /// `Param::Optional` (`?:name`) — recorded for a name bound via a plain
70    /// (non-`let-rec`) `let`/`let .. in` or one of the three command-binding
71    /// forms. `let to-math ?:iopt e = ..` records `[true, false]`;
72    /// `stdja.satyh`'s `let document record ?:configopt inner = ..`
73    /// (optional in SECOND position) records `[false, true, false]`.
74    ///
75    /// This drives marker-less optional-argument defaulting: a bare call
76    /// site (`to-math e1`) must still supply `None` for `iopt` and match
77    /// `e1` against `e`; `document record body` must supply `None` for
78    /// `configopt` and match `body` against `inner`, not `configopt`'s
79    /// domain. A scalar LEADING-COUNT encoding can't express that — it
80    /// can't tell "optional at position 1" from "no optional" once position
81    /// 0 is mandatory — hence the full per-position shape. See
82    /// `app_chain_generic`'s use of [`Scope::optional_shape`].
83    ///
84    /// [`Scope::optional_arity`] (a derived LEADING-RUN count) feeds the
85    /// command-argument paths (`cmd_args`, `math_bot`'s `Cmd` arm), which
86    /// stay leading-only. Absent from the map means "no known optionals" —
87    /// the common case.
88    optional_shape: std::collections::HashMap<String, Vec<bool>>,
89    /// A module member's bare (sibling-visible) local name → the ACTUAL Ast
90    /// key its value is bound under (see `push_named_binding`'s doc comment):
91    /// a member of `module M = struct .. end` is bound under a MANGLED key
92    /// (`"$M.atan2"`, never a valid surface identifier, so it can't collide
93    /// with anything), not a bare `"atan2"` `LetIn` — so a SIBLING member's
94    /// bare reference (`Ast::Var("atan2")`, as written) must be redirected to
95    /// that mangled key at construction time, deliberately NOT to the
96    /// qualified `"M.atan2"` key (that distinction is what makes opaque-type
97    /// sealing work). This map is that redirect, consulted by [`scoped_var`]
98    /// and the inline/block/math command-key resolution sites. Entries exist
99    /// only while processing a module's own `struct` body (`running`, local
100    /// to that recursive `walk_bindings` call) and never propagate to the
101    /// caller's outer scope (only EXPORTED qualified keys' `names`/
102    /// `optional_arity` entries are copied back) — so a rename can only
103    /// affect references written inside that same module, never outside it
104    /// or after its `end`.
105    renames: std::collections::HashMap<String, String>,
106    /// The source-language version this scope elaborates under — gates the
107    /// SATySFi 0.1-only labeled-optional nodes (`Expr::FunRows`,
108    /// `AppArg::Bundled`) so a 0.0.6-compiled file that happens to parse them
109    /// (the additive-`cst` accept-surface widening) is rejected with a
110    /// version error rather than silently accepted. `V0_0` by default.
111    version: RustyfiVersion,
112    /// The interner every identifier this scope helps build is minted from.
113    ///
114    /// The scope's own tables above stay **text**-keyed: elaboration is a
115    /// string-manipulation pass (it mangles `"M.x"` / `"$M.atan2"` /
116    /// `"%cmd_arg0"` keys, tests command sigils by first character, and scans
117    /// by prefix in [`Scope::names_with_prefix`]), so keying them by `Symbol`
118    /// would only add a resolve on every probe. Interning happens at the
119    /// boundary instead — [`Scope::resolve`] and [`Scope::sym`] are the two
120    /// points where a text key becomes the `Symbol` an `Ast` node carries.
121    store: &'s SymbolStore,
122}
123
124impl<'s> Scope<'s> {
125    pub fn new(store: &'s SymbolStore, names: impl IntoIterator<Item = String>) -> Scope<'s> {
126        Scope::new_with_version(store, names, RustyfiVersion::V0_0)
127    }
128
129    /// Like [`Scope::new`] but elaborating under an explicit source version —
130    /// the V0_1 compile path (`lib.rs`) uses this so the 0.1 labeled-optional
131    /// nodes are accepted.
132    pub fn new_with_version(
133        store: &'s SymbolStore,
134        names: impl IntoIterator<Item = String>,
135        version: RustyfiVersion,
136    ) -> Scope<'s> {
137        Scope {
138            names_base: Rc::new(names.into_iter().map(Rc::from).collect()),
139            names_overlay: HashSet::new(),
140            optional_shape: std::collections::HashMap::new(),
141            renames: std::collections::HashMap::new(),
142            version,
143            store,
144        }
145    }
146
147    /// Intern `name` as-is. Use this where a key is *already* the final Ast
148    /// key (a mangled module key, a freshly minted `%`-prefixed desugar name);
149    /// use [`Scope::resolve`] where a bare source reference is being looked
150    /// up, since only that path applies the module-member rename redirect.
151    fn sym(&self, name: &str) -> Symbol<'s> {
152        self.store.intern(name)
153    }
154
155    fn with(&self, name: &str) -> Scope<'s> {
156        let mut s = self.clone();
157        s.insert(name);
158        s
159    }
160
161    /// In-place version of [`Scope::with`], for the folds below that thread
162    /// one evolving scope through a sequence of bindings without cloning at
163    /// every step. Rebinding a name plainly (no known arity) clears any
164    /// stale [`Scope::optional_arity`]/[`Scope::rename`] entry, so a local
165    /// parameter/pattern binding can never inherit an outer optional-
166    /// leading function's arity — or an outer module member's qualified
167    /// redirect — just by sharing its name.
168    fn insert(&mut self, name: &str) {
169        self.names_overlay.insert(Rc::from(name));
170        self.promote_names();
171        self.optional_shape.remove(name);
172        self.renames.remove(name);
173    }
174
175    /// Fold the name overlay into a fresh shared base once it reaches
176    /// [`NAMES_OVERLAY_CAP`], bounding what every later `with` has to copy.
177    /// Amortized O(1) per binding, as in `typecheck::TypeEnv::maybe_promote`.
178    fn promote_names(&mut self) {
179        if self.names_overlay.len() < NAMES_OVERLAY_CAP {
180            return;
181        }
182        let mut base = (*self.names_base).clone();
183        base.extend(self.names_overlay.drain());
184        self.names_base = Rc::new(base);
185    }
186
187    /// Like [`Scope::insert`], but also records `name`'s full per-position
188    /// optional-parameter shape (see the struct doc comment) — used only at
189    /// the handful of binding sites that know a def-site `Param` list
190    /// (`walk_bindings`'s `TopBinding::Let`/`LetInline`/`LetBlock`/`LetMath`
191    /// arms, `Expr::LetIn`/`Expr::LetMathIn`).
192    fn insert_with_shape(&mut self, name: &str, shape: Vec<bool>) {
193        self.names_overlay.insert(Rc::from(name));
194        self.promote_names();
195        if shape.iter().any(|&opt| opt) {
196            self.optional_shape.insert(name.to_string(), shape);
197        } else {
198            self.optional_shape.remove(name);
199        }
200        self.renames.remove(name);
201    }
202
203    /// Record that a bare reference to `local` (a module member's own
204    /// sibling-visible name) must actually resolve to the Ast key
205    /// `actual_key` (its qualified binding — see [`Scope`]'s `renames`
206    /// field doc comment and `push_named_binding`). `local` stays `true`
207    /// under [`Scope::contains`] (unaffected by this call) — only WHICH KEY
208    /// [`Scope::resolve`] returns for it changes.
209    fn rename(&mut self, local: &str, actual_key: &str) {
210        self.renames
211            .insert(local.to_string(), actual_key.to_string());
212    }
213
214    /// The Ast key a bare reference to `name` should actually use, interned:
215    /// its [`Scope::rename`] redirect, if one is active, else `name` itself
216    /// unchanged (the overwhelmingly common case — every name outside a
217    /// module's own body).
218    ///
219    /// This is the elaborator's main text → [`Symbol`] boundary: almost every
220    /// identifier an `Ast` node carries is minted right here.
221    fn resolve(&self, name: &str) -> Symbol<'s> {
222        self.store.intern(self.resolve_text(name))
223    }
224
225    /// [`Scope::resolve`] before interning — for the one caller that needs to
226    /// *compare* the redirect target against source text rather than embed it
227    /// in a node (`app_chain_generic`'s unary-`not` special case, which must
228    /// only fire when `not` still resolves to itself).
229    fn resolve_text<'a>(&'a self, name: &'a str) -> &'a str {
230        self.renames.get(name).map(|s| s.as_str()).unwrap_or(name)
231    }
232
233    fn contains(&self, name: &str) -> bool {
234        self.names_overlay.contains(name) || self.names_base.contains(name)
235    }
236
237    /// `name`'s recorded leading-optional-parameter count (the maximal
238    /// prefix of `true`s in its [`Scope::optional_shape`] entry), or `0` if
239    /// none is known — the command-argument paths (`cmd_args`, `math_bot`'s
240    /// `Cmd` arm) only ever auto-omit a *leading* run.
241    fn optional_arity(&self, name: &str) -> usize {
242        self.optional_shape
243            .get(name)
244            .map(|shape| shape.iter().take_while(|&&opt| opt).count())
245            .unwrap_or(0)
246    }
247
248    /// `name`'s recorded full per-position optional-parameter shape (see the
249    /// struct doc comment), or `&[]` if none is known — used by
250    /// `app_chain_generic`'s marker-less-optional-defaulting, which (unlike
251    /// [`Scope::optional_arity`]) must see optionals anywhere in the param
252    /// list, not just a leading run.
253    fn optional_shape(&self, name: &str) -> &[bool] {
254        self.optional_shape
255            .get(name)
256            .map(|v| v.as_slice())
257            .unwrap_or(&[])
258    }
259
260    /// Every currently-known name starting with `prefix` (used by `open`,
261    /// which brings a module's `"M."`-prefixed names into unqualified
262    /// scope). Sorted for deterministic alias-binding order.
263    fn names_with_prefix(&self, prefix: &str) -> Vec<String> {
264        // A `BTreeSet` because the result must be DEDUPLICATED as well as
265        // sorted: a name re-inserted after a promotion can sit in both
266        // layers, and `open` binding an alias twice would not be harmless.
267        self.names_overlay
268            .iter()
269            .chain(self.names_base.iter())
270            .filter(|n| n.starts_with(prefix))
271            .map(|n| n.to_string())
272            .collect::<std::collections::BTreeSet<_>>()
273            .into_iter()
274            .collect()
275    }
276}
277
278/// A `Var` node for a name that must already be in scope (primitive
279/// operators and the internal `%context`/`read-inline`/`read-block` wiring
280/// are all resolved the same way as user variables). Existence is checked
281/// against the BARE `name` (unaffected by any active [`Scope::rename`]
282/// redirect); the constructed node's own key goes through
283/// [`Scope::resolve`], so a module member's sibling reference compiles
284/// directly to that member's mangled key when one is active — see
285/// `push_named_binding`'s doc comment.
286fn scoped_var<'s>(name: &str, span: Span, scope: &Scope<'s>) -> Result<Ast<'s>, ElabError> {
287    if scope.contains(name) {
288        Ok(Ast::Var(scope.resolve(name), span))
289    } else {
290        err(span, format!("unbound variable '{name}'"))
291    }
292}
293
294/// A user type declaration, surfaced (but not yet lowered into
295/// [`crate::types::MonoType`] — that's `typecheck::build_variant_decl`'s job)
296/// from a CST [`cst::TypeDecl`]. Ctor payload types are kept as raw CST
297/// `TypeExpr`s: cheap to clone, and this untyped elaborator has no use for
298/// them beyond passing them through to the typechecker.
299#[derive(Clone, Debug)]
300pub struct UserTypeDecl {
301    pub name: String,
302    /// Type-parameter names, in declaration order (e.g. `["a"]` for `'a`).
303    pub params: Vec<String>,
304    /// `(ctor name, payload type expr)`, in declaration order.
305    pub ctors: Vec<(String, Option<c::TypeExpr>)>,
306}
307
308/// A user type *synonym* declaration (`type point = length * length`),
309/// surfaced in parallel with [`UserTypeDecl`] — see that struct's doc
310/// comment; the body is kept as a raw CST `TypeExpr` for the same reason.
311/// `typecheck::build_synonym_decl` is where it is actually lowered to a
312/// `MonoType` template, and `typecheck::expand_synonyms` is where a
313/// reference to `name` elsewhere is transparently replaced by it.
314#[derive(Clone, Debug)]
315pub struct UserSynonymDecl {
316    pub name: String,
317    /// Type-parameter names, in declaration order. Only the zero-param case
318    /// is reachable through a *use* of the synonym today — see
319    /// `cst::ast::TypeAtom`'s doc comment (no applied-type-constructor
320    /// syntax exists to instantiate one) — but parsing/storing params keeps
321    /// this declaration-side path symmetric with `UserTypeDecl`.
322    pub params: Vec<String>,
323    pub body: c::TypeExpr,
324}
325
326/// One lowered `type` declaration: either shape [`lower_type_decl`] can
327/// produce, for `walk_bindings` to sort into `Program`'s two decl lists.
328enum LoweredTypeDecl {
329    Variant(UserTypeDecl),
330    Synonym(UserSynonymDecl),
331}
332
333/// Lower one `type` binding, including every `and`-clause of a mutual-
334/// recursion chain (`type A = … and B = …`), into consecutive lowered decls —
335/// all clauses share one program-global decl space, so their mutual references
336/// resolve with the same forward-reference tolerance the typechecker already
337/// gives the 0.1 lowering's consecutive `type … and …` output.
338fn lower_type_decl(
339    decl: &cst::TypeDecl,
340    mod_path: &[String],
341    tymap: &HashMap<String, String>,
342) -> Vec<LoweredTypeDecl> {
343    let mut out = Vec::with_capacity(1 + decl.ands.len());
344    out.push(lower_one_type_clause(
345        &decl.tyvars,
346        &decl.name,
347        &decl.body,
348        mod_path,
349        tymap,
350    ));
351    for a in &decl.ands {
352        out.push(lower_one_type_clause(
353            &a.tyvars, &a.name, &a.body, mod_path, tymap,
354        ));
355    }
356    out
357}
358
359/// A module's type declaration is registered under its MODULE-QUALIFIED name
360/// (`M.t`), and any within-module reference to a module-local type name in its
361/// body is rewritten to the same qualified name (`tymap`, built by
362/// `walk_bindings`). This keeps two modules' same-named types (e.g. every
363/// `satysfi-base` module's `type t`) from colliding in the program-global
364/// synonym/variant tables. The `contains('.')` guard leaves an
365/// already-qualified name (the 0.1 lowering emits `"M.t"` directly) alone; a
366/// top-level (`mod_path` empty) declaration stays bare.
367fn lower_one_type_clause(
368    tyvars: &[rustyfi_syntax::leaf::TypeVarTok],
369    name: &VarTok,
370    body: &cst::TypeDeclBody,
371    mod_path: &[String],
372    tymap: &HashMap<String, String>,
373) -> LoweredTypeDecl {
374    let params: Vec<String> = tyvars.iter().map(|v| v.name.clone()).collect();
375    let qname = if name.name.contains('.') {
376        name.name.clone()
377    } else {
378        qualify_key(mod_path, &name.name)
379    };
380    match body {
381        cst::TypeDeclBody::Variant { first, rest, .. } => {
382            let mut ctors = Vec::with_capacity(1 + rest.len());
383            let mut push_ctor = |cname: String, payload: Option<&cst::OfType>| {
384                let ty = payload.map(|o| {
385                    let mut t = o.ty.clone();
386                    qualify_ty(&mut t, tymap);
387                    t
388                });
389                ctors.push((cname, ty));
390            };
391            push_ctor(first.ctor.name.clone(), first.of_ty.as_ref());
392            for bv in rest {
393                push_ctor(bv.def.ctor.name.clone(), bv.def.of_ty.as_ref());
394            }
395            LoweredTypeDecl::Variant(UserTypeDecl {
396                name: qname,
397                params,
398                ctors,
399            })
400        }
401        cst::TypeDeclBody::Synonym(ty) => {
402            let mut b = ty.clone();
403            qualify_ty(&mut b, tymap);
404            LoweredTypeDecl::Synonym(UserSynonymDecl {
405                name: qname,
406                params,
407                body: b,
408            })
409        }
410    }
411}
412
413// ---- within-module type-reference qualification ----------------------------
414// Rewrite a cloned CST `TypeExpr` in place, replacing every module-local BARE
415// type-name reference with its module-qualified name (`tymap`: bare -> `M.t`).
416// A `Mod.t` reference (`TypeAtom::NameMod`) is already absolute and left as-is;
417// a name not in `tymap` (builtins, external names) is untouched.
418
419fn qualify_ty(ty: &mut c::TypeExpr, map: &HashMap<String, String>) {
420    if map.is_empty() {
421        return;
422    }
423    match ty {
424        c::TypeExpr::Fun { opts, dom, cod, .. } => {
425            for o in opts {
426                qualify_prod(&mut o.ty, map);
427            }
428            qualify_prod(dom, map);
429            qualify_ty(cod, map);
430        }
431        c::TypeExpr::Atom(prod) => qualify_prod(prod, map),
432        c::TypeExpr::OptRowFun {
433            opt_dom, dom, cod, ..
434        } => {
435            for e in &mut opt_dom.entries {
436                qualify_ty(&mut e.ty.0, map);
437            }
438            qualify_prod(dom, map);
439            qualify_ty(cod, map);
440        }
441    }
442}
443
444fn qualify_prod(p: &mut c::TypeProd, map: &HashMap<String, String>) {
445    qualify_app(&mut p.first, map);
446    for s in &mut p.rest {
447        qualify_app(&mut s.ty, map);
448    }
449}
450
451fn qualify_app(a: &mut c::TypeApp, map: &HashMap<String, String>) {
452    qualify_atom(&mut a.head, map);
453    for at in &mut a.rest {
454        qualify_atom(at, map);
455    }
456}
457
458fn qualify_atom(at: &mut c::TypeAtom, map: &HashMap<String, String>) {
459    match at {
460        c::TypeAtom::Name(n) => {
461            if let Some(q) = map.get(&n.name) {
462                n.name = q.clone();
463            }
464        }
465        c::TypeAtom::Paren { inner, .. } => qualify_ty(&mut inner.0, map),
466        c::TypeAtom::Record { fields, .. } => {
467            for f in fields {
468                qualify_ty(&mut f.ty.0, map);
469            }
470        }
471        c::TypeAtom::RecordOpen { inner, .. } => {
472            for f in &mut inner.fields {
473                qualify_ty(&mut f.ty.0, map);
474            }
475        }
476        c::TypeAtom::Cmd { args, .. } => {
477            for it in args {
478                for l in &mut it.opt_labels {
479                    qualify_ty(&mut l.ty.0, map);
480                }
481                qualify_ty(&mut it.ty.0, map);
482            }
483        }
484        c::TypeAtom::Var(_) | c::TypeAtom::NameMod(_) => {}
485    }
486}
487
488/// The result of elaborating a whole file: every `type` declaration it
489/// surfaced (in source order — a later declaration may reference an earlier
490/// one, or itself, since variant types are nominal; see
491/// `typecheck::build_variant_decl`), every type *synonym* it surfaced (see
492/// `typecheck::build_synonym_decl`), plus the elaborated document body.
493#[derive(Clone, Debug)]
494pub struct Program<'s> {
495    pub type_decls: Vec<UserTypeDecl>,
496    pub synonym_decls: Vec<UserSynonymDecl>,
497    pub body: Ast<'s>,
498    /// The interner every identifier in `body` was minted from. Carried on
499    /// the program itself (rather than passed alongside it) so that the
500    /// downstream passes — `typecheck`, `v1::module_check`, and the compile
501    /// membrane — keep their existing one-argument signatures and cannot be
502    /// handed a program and a store that don't belong together.
503    pub store: &'s SymbolStore,
504}
505
506/// Elaborate a whole file into a [`Program`] (the elaborated body plus any
507/// surfaced `type` declarations — see [`elaborate`] for the thin wrapper
508/// existing callers that only want the body keep using).
509///
510/// **Library files.** `File.body` is `None` for a bare `prelude EOI` file (a
511/// `.satyh` library with no document expression) — a separate loader crate
512/// is responsible for merging a library's `prelude` into a document file's
513/// before this function ever sees it, so there is no
514/// "top-level bindings must be followed by `in`" check here at all: by the
515/// time `elaborate_program` runs, either `body` is present (an ordinary
516/// document, or an already-merged file) or it is a genuine library file,
517/// which is a (clean) error to hand to `elaborate_program` directly.
518pub fn elaborate_program<'s>(
519    file: &cst::File,
520    prelude_scope: &Scope<'s>,
521) -> Result<Program<'s>, ElabError> {
522    elaborate_program_with_versions(file, prelude_scope, &HashSet::new(), &HashMap::new(), None)
523}
524
525/// [`elaborate_program`] for a loader-merged file, saying which prelude
526/// entries came from a file whose `@stage:` header was not the default. Each
527/// gets its RHS wrapped in [`Ast::StageScope`] so the typechecker reads it at
528/// that stage -- a `@stage: 0` library may quote (`&e`), a document may not.
529pub fn elaborate_program_with_stages<'s>(
530    file: &cst::File,
531    prelude_scope: &Scope<'s>,
532    stages: &HashMap<usize, crate::types::Stage>,
533) -> Result<Program<'s>, ElabError> {
534    elaborate_program_with_versions(file, prelude_scope, &HashSet::new(), stages, None)
535}
536
537/// Like [`elaborate_program`], but marking a subset of `file.prelude`'s
538/// TOP-LEVEL entries (by index) as originating from a spliced `V0_0`
539/// dependency. Every `Binding` from one of those
540/// entries — recursively including bindings inside a nested `module .. =
541/// struct .. end` — has its elaborated RHS wrapped in
542/// [`Ast::VersionScope`]`(V0_0, _)`, so `compile.rs`/`eval.rs`/`typecheck.rs`
543/// resolve that subtree's version-forked primitives against `V0_0` instead
544/// of the merged program's ambient `V0_1`.
545///
546/// `v006_indices` is empty on every single-version path, making `this_v006`
547/// always `false` there, so no `VersionScope` node is built at all.
548///
549/// `wrap_body_version`: when `Some(v)`, the file's own
550/// document tail expression is additionally wrapped in
551/// `Ast::VersionScope(v, _)` — needed beyond the indexed-`prelude`-item
552/// wrapping above because a `V0_0` entry's tail (e.g. a bare `page-break doc` with
553/// no intermediate `let`) may itself reference forked primitives directly,
554/// not just its `prelude` bindings. `None` everywhere else builds no extra
555/// node.
556pub fn elaborate_program_with_versions<'s>(
557    file: &cst::File,
558    prelude_scope: &Scope<'s>,
559    v006_indices: &HashSet<usize>,
560    stages: &HashMap<usize, crate::types::Stage>,
561    wrap_body_version: Option<RustyfiVersion>,
562) -> Result<Program<'s>, ElabError> {
563    let Some(body) = &file.body else {
564        return err(
565            Span::default(),
566            "this file has no document expression - it is a library file",
567        );
568    };
569    let items: Vec<&cst::TopBinding> = file.prelude.iter().collect();
570    let mut type_decls = Vec::new();
571    let mut synonym_decls = Vec::new();
572    let (bindings, _exported, final_scope) = walk_bindings(
573        &items,
574        prelude_scope,
575        &[],
576        &mut type_decls,
577        &mut synonym_decls,
578        &ItemOrigins {
579            v006: v006_indices,
580            stages,
581        },
582        &HashMap::new(),
583    )?;
584    // `final_scope` (mod_path `[]`) already IS `prelude_scope` plus every
585    // top-level name — including each one's `Scope::optional_arity` entry,
586    // which a manual `insert` per `exported` name would have dropped (see
587    // `Scope`'s doc comment) — so the file body sees the same
588    // marker-less-optional-call defaulting a top-level function's own
589    // sibling declarations do.
590    let body_ast = expr(body, &final_scope)?;
591    let body_ast = match wrap_body_version {
592        Some(v) => Ast::VersionScope(v, Box::new(body_ast)),
593        None => body_ast,
594    };
595    Ok(Program {
596        type_decls,
597        synonym_decls,
598        body: nest(prelude_scope.store, bindings, body_ast),
599        store: prelude_scope.store,
600    })
601}
602
603/// Where each TOP-LEVEL entry of a merged prelude came from.
604///
605/// The loader concatenates every library's prelude into one file, which drops
606/// two per-file properties the bindings still need: which generation authored
607/// them (`v006`) and which `@stage:` their file declared
608/// (`stages`). Both are keyed by the entry's index at THIS level, and both are
609/// empty for a single-file compile. `v006` is carried onto a binding by
610/// `maybe_v006_scope`, `stages` by `stage_wrap_item`.
611struct ItemOrigins<'a> {
612    v006: &'a HashSet<usize>,
613    stages: &'a HashMap<usize, crate::types::Stage>,
614}
615
616/// Does `value` already carry a stage of its own? True for a nested
617/// module's members (`walk_bindings` wrapped them) and for a 0.1 `val ~x`
618/// (its own qualifier wins); [`stage_wrap_item`] leaves those alone so the
619/// INNER, more specific stage is what the typechecker reads.
620///
621/// The `ModuleScope`/`VersionScope` peeling mirrors
622/// `typecheck::Checker::binding_stage`, which looks for the same node
623/// through the same two wrappers — they must agree, or a binding could be
624/// wrapped twice with different stages.
625///
626/// Of the two, only the `ModuleScope` arm is load-bearing today: every
627/// `walk_bindings` arm applies [`maybe_v006_scope`] before this runs, and
628/// [`stage_wrap_item`] wraps outside whatever it finds, so a doubly-scoped
629/// binding is always `StageScope(_, VersionScope(_, ..))`, never the
630/// reverse — the `VersionScope` arm can only fire on a nesting no caller
631/// builds. Verified by deleting it: nothing in the suite fails. Kept as the
632/// cheap half of the agreement contract above (one arm-reordering away from
633/// being needed), not because it runs.
634fn already_staged(value: &Ast<'_>) -> bool {
635    match value {
636        Ast::StageScope(..) => true,
637        Ast::ModuleScope(_, b) | Ast::VersionScope(_, b) => already_staged(b),
638        _ => false,
639    }
640}
641
642/// Mark every binding one top-level item contributed as belonging to `stage`.
643///
644/// One item is not one binding: a module member also mints a qualified alias,
645/// a `let-rec` group inside a module mints one alias per clause, `open` and
646/// `direct` mint one per re-exposed name, a destructuring `let` mints one per
647/// pattern variable. Every one of them is code from the SAME file, and so is
648/// at that file's stage — wrapping only the "main" value would leave the
649/// aliases at the default stage 1, which the per-binding staging matrix then
650/// reads as a genuine stage crossing: `list.satyg`'s `@stage: persistent`
651/// `let reverse lst = fold-left …` would be refused for naming its own
652/// `let-rec` sibling.
653fn stage_wrap_item<'s>(bindings: &mut [Binding<'s>], stage: crate::types::Stage) {
654    fn wrap<'s>(slot: &mut Ast<'s>, stage: crate::types::Stage) {
655        if already_staged(slot) {
656            return;
657        }
658        let taken = std::mem::replace(slot, Ast::Unit);
659        *slot = Ast::StageScope(stage, Box::new(taken));
660    }
661    for b in bindings {
662        match b {
663            Binding::Let(_, v) | Binding::LetMutable(_, v) | Binding::LetMath(_, v) => {
664                wrap(v, stage)
665            }
666            Binding::LetRec(clauses) => {
667                for (_, v) in clauses.iter_mut() {
668                    if already_staged(v) {
669                        continue;
670                    }
671                    *v = Rc::new(Ast::StageScope(stage, Box::new((**v).clone())));
672                }
673            }
674        }
675    }
676}
677
678/// The stage ONE binding declared on itself (`cst::TopStage`), if any — the
679/// per-binding half of the question `ItemOrigins::stages` answers per FILE.
680/// SATySFi 0.1 writes it as `val ~x = e` / `val persistent ~x = e`
681/// (`v1/lower.rs` puts it here); 0.0.6 never sets it, and saying so is this
682/// function's other job.
683///
684/// **Version gate.** `cst::TopBinding` is shared, so the `~` qualifier
685/// PARSES under 0.0.6 too — which would let a genuine 0.0.6 file write `let
686/// ~x = e`, a form upstream 0.0.6 doesn't have at all (`EXACT_TILDE` appears
687/// only as a splice-operand prefix, `v0.0.6 parser.mly:797`, and as macro
688/// syntax, `:608`/`:1199`; 0.0.6 declares one stage per FILE via `@stage:`).
689/// Elaboration is the first place that knows which generation authored the
690/// binding, so it refuses the form here.
691///
692/// `authored_v006` is that per-ITEM answer, not the file's: a mixed compile's
693/// scope carries ONE version (`V0_1` for both cross-version roots, see
694/// `lib.rs`) while `ItemOrigins::v006` marks individual prelude slots a
695/// 0.0.6 dependency contributed — so a spliced 0.0.6 item is gated even
696/// inside a 0.1-rooted program, and vice versa.
697fn binding_stage(
698    stage: Option<&cst::TopStage>,
699    version: RustyfiVersion,
700    authored_v006: bool,
701) -> Result<Option<crate::types::Stage>, ElabError> {
702    let Some(s) = stage else { return Ok(None) };
703    if authored_v006 || !version.has_per_binding_stage() {
704        return err(
705            s.tilde.0,
706            "a per-binding stage qualifier (`~`) is SATySFi 0.1 syntax (`val ~x = e`) — \
707             this binding is compiled as 0.0.6, which declares its stage per FILE \
708             with a `@stage:` header",
709        );
710    }
711    Ok(Some(match s.persistent {
712        Some(_) => crate::types::Stage::Persistent0,
713        None => crate::types::Stage::Stage0,
714    }))
715}
716
717/// Wrap `value` in [`Ast::VersionScope`]`(V0_0, _)` iff `this_v006` — the
718/// one-line helper every `walk_bindings` binding-construction arm below
719/// calls right after building its (fully elaborated) RHS. See
720/// [`elaborate_program_with_versions`]'s doc comment.
721fn maybe_v006_scope<'s>(value: Ast<'s>, this_v006: bool) -> Ast<'s> {
722    if this_v006 {
723        Ast::VersionScope(RustyfiVersion::V0_0, Box::new(value))
724    } else {
725        value
726    }
727}
728
729/// Elaborate a whole file into one expression, discarding any `type`
730/// declarations it surfaces (existing callers that only need the untyped
731/// `Ast`; see [`elaborate_program`] for the version the typechecker
732/// uses).
733pub fn elaborate<'s>(file: &cst::File, prelude_scope: &Scope<'s>) -> Result<Ast<'s>, ElabError> {
734    Ok(elaborate_program(file, prelude_scope)?.body)
735}
736
737// ---- module name-mangling & the top-level/struct-decl fold ---------------
738
739/// The (untyped) module name-mangling scheme: a qualified name's runtime/
740/// scope key is simply `mods.join(".") + "." + local`, where `local` is
741/// whatever bare key the unqualified form would have used — a plain
742/// variable's own name (`"x"` → `"M.x"`), or a command's sigil-inclusive
743/// name (`"\cmd"` → `"M.\cmd"`, *not* the surface-syntax `"\M.cmd"` spelling
744/// `Token::HorzCmdWithMod`'s `Display` impl renders — this port's `Scope`
745/// and `Env` are both flat string-keyed maps with no separate namespace for
746/// commands vs. variables, so one uniform "prefix-join" scheme for every
747/// kind of name is simplest, and nothing round-trips through source syntax
748/// again once elaborated). Nested modules mangle recursively by construction
749/// — `mod_path` is the *full* accumulated path (`["M", "N"]`) at the point a
750/// name is bound, never re-qualified after the fact, so `module N = struct
751/// let x = .. end` inside `module M = struct .. end` yields key `"M.N.x"`
752/// directly.
753fn qualify_key(mod_path: &[String], local: &str) -> String {
754    if mod_path.is_empty() {
755        local.to_string()
756    } else {
757        format!("{}.{}", mod_path.join("."), local)
758    }
759}
760
761/// If `item` is a `direct \cmd : ty` / `direct +cmd : ty` signature item
762/// (`cst::SigItem::DirectHorzCmd`/`DirectVertCmd` — math commands share the
763/// `\` sigil with inline ones, see `command_scheme`'s doc comment in
764/// `typecheck.rs`, so there is no separate math case here), its bare command
765/// name (sigil included — `"\cmd"`/`"+cmd"`, the same key format
766/// `push_named_binding` binds locally) and the name token's span, for
767/// the enclosing-scope exposure. `None` for
768/// every other `SigItem` (`val`/`type`), which stay module-qualified only.
769fn direct_cmd_name(item: &cst::SigItem) -> Option<(String, Span)> {
770    match item {
771        cst::SigItem::DirectHorzCmd { name, .. } => Some((name.name.clone(), name.span)),
772        cst::SigItem::DirectVertCmd { name, .. } => Some((name.name.clone(), name.span)),
773        _ => None,
774    }
775}
776
777/// One step of the top-level/struct-decl fold, deferred (see [`nest`]) so
778/// that folding in a `module`'s declarations doesn't require building the
779/// "rest of the program" before the module's own bindings are known.
780enum Binding<'s> {
781    Let(String, Ast<'s>),
782    LetRec(Vec<(String, Rc<Ast<'s>>)>),
783    LetMutable(String, Ast<'s>),
784    /// A `let-math` binding — nests as `Ast::LetMathIn`, not `Ast::LetIn`
785    /// (see that variant's doc comment).
786    LetMath(String, Ast<'s>),
787}
788
789/// Wrap `tail` in every collected `Binding`, innermost (last-pushed) first —
790/// i.e. in the same order `elaborate_prelude`/`elaborate_struct_decls` used
791/// to build `Ast::LetIn`/`Ast::LetRecIn` directly, just deferred into data
792/// first so a `module`'s bindings can be spliced into the flat sequence
793/// before any of it is turned into `Ast`.
794fn nest<'s>(store: &'s SymbolStore, bindings: Vec<Binding<'s>>, tail: Ast<'s>) -> Ast<'s> {
795    // `Binding` carries its key as text (it is minted by string mangling —
796    // `qualify_key`, `$`-prefixing, `open`'s prefix suffixing); interning
797    // happens here, where the key finally becomes an `Ast` node's identifier.
798    let mut ast = tail;
799    for b in bindings.into_iter().rev() {
800        ast = match b {
801            Binding::Let(name, val) => {
802                Ast::LetIn(store.intern(&name), Box::new(val), Box::new(ast))
803            }
804            Binding::LetRec(bs) => Ast::LetRecIn(
805                bs.into_iter().map(|(n, v)| (store.intern(&n), v)).collect(),
806                Box::new(ast),
807            ),
808            Binding::LetMutable(name, val) => {
809                Ast::LetMutableIn(store.intern(&name), Box::new(val), Box::new(ast))
810            }
811            Binding::LetMath(name, val) => {
812                Ast::LetMathIn(store.intern(&name), Box::new(val), Box::new(ast))
813            }
814        };
815    }
816    ast
817}
818
819/// After binding `local` (inside a `module M = struct .. end`, i.e.
820/// `mod_path` non-empty), also bind the qualified alias `M.local` — an
821/// `Ast::Var`-referencing `LetIn`, the same alias-binding technique `open`
822/// uses below — so later qualified references (and any enclosing `open`)
823/// can resolve it. `local` is added to `running` (so *sibling* declarations
824/// still see it unqualified) but never to `exported`: per v0.0.6 semantics,
825/// after `end` only the qualified name is visible to what follows.
826///
827/// Only remaining caller: `TopBinding::LetRec`'s per-name loop, where `local`
828/// is ALREADY bound bare (`rec_bindings`'s mutual-recursion scope needs every
829/// clause visible to every other by its bare name before this runs) — so
830/// there is no single value to re-bind under a mangled key the way
831/// `push_named_binding` does. The bare name stays physically present in the
832/// flat `nest()` chain and can leak past this module's `end` if it collides
833/// with something later. A known, separable gap, narrower than
834/// `push_named_binding`'s coverage — it needs a top-level `let rec .. and
835/// ..` group directly inside a `module .. = struct .. end`, not any of the
836/// far more common plain `val`/`let-inline`/`let-block`/`let-math`/`let
837/// mutable` members.
838fn export_alias<'s>(
839    mod_path: &[String],
840    local: String,
841    shape: Vec<bool>,
842    bindings: &mut Vec<Binding<'s>>,
843    running: &mut Scope<'s>,
844    exported: &mut Vec<String>,
845) {
846    if mod_path.is_empty() {
847        running.insert_with_shape(&local, shape);
848        exported.push(local);
849    } else {
850        // Same anti-leak scheme as `push_named_binding` (see its doc comment
851        // for why the bare key can't be used directly) — e.g. this is what
852        // stops satysfi-base's `Float.round : float -> float` from shadowing
853        // the builtin `round : float -> int`.
854        let qual = qualify_key(mod_path, &local);
855        let mangled = format!("${qual}");
856        bindings.push(Binding::Let(
857            qual.clone(),
858            Ast::Var(running.sym(&mangled), Span::default()),
859        ));
860        running.insert_with_shape(&local, shape.clone());
861        running.rename(&local, &mangled);
862        running.insert_with_shape(&qual, shape);
863        exported.push(qual);
864    }
865}
866
867/// `shape` is `local`'s recorded per-position optional-parameter shape (see
868/// [`Scope`]'s doc comment) — non-empty for `TopBinding::Let` and for
869/// `TopBinding::LetInline`/`LetBlock`/`LetMath` (`param_optional_shape`);
870/// every other binding kind here passes an empty `Vec`.
871///
872/// For `mod_path` non-empty (inside `module M = struct .. end`), `value` is
873/// bound under a MANGLED key (`"$M.local"` — `$` can't appear in a surface
874/// identifier/command name, so it can't collide with anything user-written),
875/// NOT under the bare `"local"`. A bare `LetIn` stays PHYSICALLY PRESENT in
876/// the one flat `nest()` chain the whole program compiles to, which pops no
877/// scopes of its own — so the bare name would stay bound, silently
878/// SHADOWING any unrelated same-named binding (a base primitive, or another
879/// package's member) for the rest of the merged program rather than until
880/// this module's `end`. [`Scope::rename`] redirects a SIBLING member's bare
881/// reference to the mangled key — consulted by [`scoped_var`] and the
882/// inline/block/math command-key resolution sites in
883/// `inline_elems`/`block_elems`/`math_bot`.
884///
885/// The qualified alias (`"M.local"`) is bound separately, and matters beyond
886/// mere lookup: `v1/module_check.rs`'s sealing pass keys its opaque/stamped
887/// type rewrite on this EXACT qualified string (`static_env.seals`) and
888/// applies it ONLY to a binding whose OWN key matches. A mangled key never
889/// matches, so a member's OWN body (and any SIBLING reaching it via the
890/// redirect) keeps its naturally-inferred, TRANSPARENT type; only an
891/// EXPLICIT qualified reference sees the sig's opaque view. Binding the
892/// value directly under the qualified key, or redirecting siblings to it
893/// instead of the mangled one, would break sealing for any member whose
894/// body uses ANOTHER sealed sibling's value at an opaque type
895/// (`v01_sealing.rs`'s `u1_opaque_accept`/`u8_command_decls`/
896/// `u9_ctor_hiding`/`t13_escaped_skolem_message` all pin this).
897///
898/// The redirect and the mangled key both live only in `running`, this
899/// recursive call's OWN local scope — never copied back to the caller
900/// (only the qualified name's existence/shape is, via
901/// `inner_running.optional_shape`) — so they can't affect anything outside
902/// this module or after its `end`.
903fn push_named_binding<'s>(
904    mod_path: &[String],
905    local: String,
906    value: Ast<'s>,
907    shape: Vec<bool>,
908    make_binding: impl FnOnce(String, Ast<'s>) -> Binding<'s>,
909    bindings: &mut Vec<Binding<'s>>,
910    running: &mut Scope<'s>,
911    exported: &mut Vec<String>,
912) {
913    if mod_path.is_empty() {
914        bindings.push(make_binding(local.clone(), value));
915        running.insert_with_shape(&local, shape);
916        exported.push(local);
917    } else {
918        let qual = qualify_key(mod_path, &local);
919        let mangled = format!("${qual}");
920        // Mark the member's own RHS as belonging to `mod_path`, so a bare
921        // constructor reference inside it resolves against this module's
922        // constructors first (see `Ast::ModuleScope`). Transparent to eval /
923        // type inference otherwise.
924        let value = Ast::ModuleScope(mod_path.to_vec(), Box::new(value));
925        bindings.push(make_binding(mangled.clone(), value));
926        bindings.push(Binding::Let(
927            qual.clone(),
928            Ast::Var(running.sym(&mangled), Span::default()),
929        ));
930        running.insert_with_shape(&local, shape.clone());
931        running.rename(&local, &mangled);
932        running.insert_with_shape(&qual, shape);
933        exported.push(qual);
934    }
935}
936
937/// The per-position optional shape of a `Param` list (see [`Scope`]'s doc
938/// comment for what this encodes and why, with its `stdja.satyh` example):
939/// one `bool` per parameter, `true` exactly where it's a `Param::Optional`,
940/// in declared order. Shared by a plain `let`'s params and a command
941/// binding's (`let-inline`/`let-block`/`let-math`/`Expr::LetMathIn` — all
942/// use the same `cst::ast::Param` list now, `cst.rs`'s `Param` doc comment).
943/// Recorded into [`Scope::optional_shape`] so a marker-less call can
944/// auto-omit any optional slot it reaches, not just a leading one (upstream
945/// `typecheck_command_arguments` skips optional slots left unmarked
946/// wherever they fall).
947fn param_optional_shape(params: &[c::Param]) -> Vec<bool> {
948    params
949        .iter()
950        .map(|p| matches!(p, c::Param::Optional { .. }))
951        .collect()
952}
953
954/// The optional-parameter shape a *parameter-less alias* binding inherits from
955/// its right-hand side. A `let x = y` (or `let x = M.y`) with no parameters of
956/// its own is a plain value alias: `x` should carry exactly `y`'s declared
957/// optional shape so a marker-less call `x a b` auto-omits `y`'s optionals the
958/// same way `y a b` would (`app_chain_generic`). The motivating case is
959/// `stdja.satyh`'s top-level `let document = StdJa.document`, re-exporting the
960/// module member `document record ?:configopt inner` (shape `[false, true,
961/// false]`) under the bare name every real document calls — `document rec
962/// '<..>'`, omitting the optional `configopt`; without this the alias records
963/// an empty shape and the block-text mis-binds against `configopt`'s domain.
964/// Returns `&[]`-equivalent for any RHS that is not a bare (module-qualified)
965/// variable reference — a value alias only, never a partial application.
966fn alias_optional_shape<'s>(value: &c::Expr, scope: &Scope<'s>) -> Vec<bool> {
967    let c::Expr::Ops(chain) = value else {
968        return Vec::new();
969    };
970    if !chain.tail.is_empty() || chain.before.is_some() {
971        return Vec::new();
972    }
973    let a = &chain.head;
974    if a.minus.is_some()
975        || a.excl.is_some()
976        || a.stage.is_some()
977        || !a.head_accesses.is_empty()
978        || !a.args.is_empty()
979    {
980        return Vec::new();
981    }
982    head_optional_shape(&a.head, scope).to_vec()
983}
984
985/// Fold one sequence of top-level-shaped bindings — the file's own prelude
986/// (`mod_path` empty) or a `module .. = struct .. end` body's decls
987/// otherwise (`nxtoplevel`/`nxstruct` share every alternative but `Module`/
988/// `Open`, see `cst.rs`'s `StructDecl` doc comment) — into an ordered
989/// [`Binding`] list to [`nest`] around whatever follows, plus the names
990/// visible *outside* this sequence (every name when `mod_path` is empty;
991/// only the qualified aliases otherwise — see [`export_alias`]).
992///
993/// `Module` recurses with an extended `mod_path`; its bindings splice
994/// directly into the flat list (so its `Ast::LetIn`s nest exactly where the
995/// `module .. end` appeared textually), and its exported qualified names —
996/// together with each one's [`Scope::optional_arity`], read off the
997/// recursive call's own final `running` — fold into `running` (visible to
998/// later siblings) and bubble up through `exported` (`module N = ..` nested
999/// in `module M = ..` reaches `"M.N.x"` all the way to the file level), so a
1000/// qualified call to a leading-`?:`-optional function still auto-omits even
1001/// from outside its defining module.
1002///
1003/// The final `running` is returned as a third element so callers can reuse
1004/// it directly as the scope for what follows, rather than rebuild one from
1005/// `exported` strings alone — which would drop every `optional_arity` entry
1006/// (see [`elaborate_program`]).
1007fn walk_bindings<'s>(
1008    items: &[&cst::TopBinding],
1009    scope: &Scope<'s>,
1010    mod_path: &[String],
1011    type_decls: &mut Vec<UserTypeDecl>,
1012    synonym_decls: &mut Vec<UserSynonymDecl>,
1013    origins: &ItemOrigins<'_>,
1014    tymap: &HashMap<String, String>,
1015) -> Result<(Vec<Binding<'s>>, Vec<String>, Scope<'s>), ElabError> {
1016    let mut bindings: Vec<Binding<'s>> = Vec::new();
1017    let mut running = scope.clone();
1018    let mut exported: Vec<String> = Vec::new();
1019    // Module-local type-name qualification map (bare -> `M.t`). A no-op at the
1020    // top level (`mod_path` empty). Pre-scan ALL of this
1021    // level's `type` decls first so mutual/forward references (`type 'a state
1022    // = … and 'a u = ('a state) …`) resolve.
1023    let mut level_tymap = tymap.clone();
1024    if !mod_path.is_empty() {
1025        for top in items {
1026            if let cst::TopBinding::Type(decl) = top {
1027                for n in std::iter::once(&decl.name).chain(decl.ands.iter().map(|a| &a.name)) {
1028                    if !n.name.contains('.') {
1029                        level_tymap.insert(n.name.clone(), qualify_key(mod_path, &n.name));
1030                    }
1031                }
1032            }
1033        }
1034    }
1035    for (item_idx, top) in items.iter().enumerate() {
1036        // Is THIS top-level item (and everything nested inside
1037        // it, e.g. a `module .. = struct .. end`'s own decls — see the
1038        // `Module` arm below) part of a spliced V0_0 dependency? Always
1039        // `false` for `elaborate_program`'s empty `v006_indices` (the
1040        // pure-0.0.6 / pure-0.1 paths), so this is a dead branch there.
1041        let this_v006 = origins.v006.contains(&item_idx);
1042        // The stage the file this item came from declared, if not the default.
1043        // A stage the BINDING declared on itself (0.1's `val ~x`) wins over
1044        // the one its FILE declared (0.0.6's `@stage:`) — they cannot both be
1045        // set, since no file is authored in both generations, so the `or` is
1046        // really a merge of two disjoint sources rather than a precedence
1047        // rule.
1048        let own_stage = match top {
1049            cst::TopBinding::Let(b) => b.stage.as_ref(),
1050            cst::TopBinding::LetRec { stage, .. }
1051            | cst::TopBinding::LetInline { stage, .. }
1052            | cst::TopBinding::LetBlock { stage, .. }
1053            | cst::TopBinding::LetMath { stage, .. }
1054            | cst::TopBinding::LetMutable { stage, .. } => stage.as_ref(),
1055            _ => None,
1056        };
1057        let this_stage = binding_stage(own_stage, scope.version, this_v006)?
1058            .or_else(|| origins.stages.get(&item_idx).copied());
1059        // Every binding this item is about to append belongs to `this_stage`
1060        // — including the aliases the arms below mint alongside the value
1061        // itself. Recorded by index so `stage_wrap_item` can wrap exactly
1062        // that range once the arm is done (see its doc comment).
1063        let bindings_before = bindings.len();
1064        match top {
1065            cst::TopBinding::Let(top_let) => {
1066                // Same curry-with-patterns desugaring as `rec_clause_value`
1067                // (see its doc comment), minus multi-clause `extra`;
1068                // `gr.satyh`'s tuple-destructuring params hit the general
1069                // path there.
1070                let top_let_params = params_to_patbots(&top_let.params);
1071                let value = rec_clause_value(&top_let_params, &top_let.value, &[], &running)?;
1072                let value = maybe_v006_scope(value, this_v006);
1073                // A parameter-less binding may be a plain value alias
1074                // (`let document = StdJa.document`) — inherit the aliased
1075                // name's optional shape so a marker-less call auto-omits its
1076                // optionals (see `alias_optional_shape`).
1077                let mut shape = param_optional_shape(&top_let.params);
1078                if shape.is_empty() && top_let.params.is_empty() {
1079                    shape = alias_optional_shape(&top_let.value, &running);
1080                }
1081                push_named_binding(
1082                    mod_path,
1083                    top_let.name.name.clone(),
1084                    value,
1085                    shape,
1086                    Binding::Let,
1087                    &mut bindings,
1088                    &mut running,
1089                    &mut exported,
1090                );
1091            }
1092            cst::TopBinding::LetPattern { pat, value, .. } => {
1093                // Destructuring `let pat = value` at struct/top level (the
1094                // binding twin of `Expr::LetPatternIn`): evaluate `value` ONCE
1095                // under a hidden internal name, then bind each pattern
1096                // variable to `match hidden with pat -> var` — so every name
1097                // is a normal (module-qualifiable) member. `%`-prefixed names
1098                // are internal-only and never exported.
1099                let value_ast = expr(value, &running)?;
1100                let value_ast = maybe_v006_scope(value_ast, this_v006);
1101                let lowered_pat = pattern(running.store, pat)?;
1102                let mut names = Vec::new();
1103                collect_pattern_names(running.store, &lowered_pat, &mut names);
1104                let hidden = format!("%patbind.{}.{}", mod_path.join("."), item_idx);
1105                let scrut = if mod_path.is_empty() {
1106                    value_ast
1107                } else {
1108                    Ast::ModuleScope(mod_path.to_vec(), Box::new(value_ast))
1109                };
1110                bindings.push(Binding::Let(hidden.clone(), scrut));
1111                running.insert_with_shape(&hidden, Vec::new());
1112                for n in &names {
1113                    let extract = Ast::Match(
1114                        Box::new(Ast::Var(running.sym(&hidden), Span::default())),
1115                        vec![MatchArm {
1116                            pat: lowered_pat.clone(),
1117                            guard: None,
1118                            body: Ast::Var(running.sym(n), Span::default()),
1119                        }],
1120                    );
1121                    push_named_binding(
1122                        mod_path,
1123                        n.to_string(),
1124                        extract,
1125                        Vec::new(),
1126                        Binding::Let,
1127                        &mut bindings,
1128                        &mut running,
1129                        &mut exported,
1130                    );
1131                }
1132            }
1133            cst::TopBinding::LetRec { first, ands, .. } => {
1134                let (recs, rec_scope) = rec_bindings(first, ands, &running, mod_path)?;
1135                running = rec_scope;
1136                // BARE clause names (for `export_alias`) — the `recs` keys are
1137                // now MANGLED inside a module, so derive names from the source.
1138                let names: Vec<String> = std::iter::once(&first.name.name)
1139                    .chain(ands.iter().map(|a| &a.binding.name.name))
1140                    .cloned()
1141                    .collect();
1142                // RHS granularity — wrap EACH recursive clause's
1143                // own body individually (not the `LetRecIn` node as a
1144                // whole), matching `elaborate_program_with_versions`'s doc
1145                // comment.
1146                let recs = if this_v006 {
1147                    recs.into_iter()
1148                        .map(|(n, body)| {
1149                            (
1150                                n,
1151                                Rc::new(Ast::VersionScope(
1152                                    RustyfiVersion::V0_0,
1153                                    Box::new((*body).clone()),
1154                                )),
1155                            )
1156                        })
1157                        .collect()
1158                } else {
1159                    recs
1160                };
1161                // Same per-clause granularity for the stage: the `LetRecIn`
1162                // node itself is not an expression the typechecker reads at a
1163                // stage, its clause BODIES are.
1164                let recs: Vec<(String, Rc<Ast<'s>>)> = match this_stage {
1165                    Some(st) => recs
1166                        .into_iter()
1167                        .map(|(n, body)| {
1168                            (n, Rc::new(Ast::StageScope(st, Box::new((*body).clone()))))
1169                        })
1170                        .collect(),
1171                    None => recs,
1172                };
1173                // Mark each clause body as belonging to `mod_path` (ctor
1174                // scoping — see `Ast::ModuleScope`); a no-op at top level.
1175                let recs: Vec<(String, Rc<Ast<'s>>)> = if mod_path.is_empty() {
1176                    recs
1177                } else {
1178                    recs.into_iter()
1179                        .map(|(n, body)| {
1180                            (
1181                                n,
1182                                Rc::new(Ast::ModuleScope(
1183                                    mod_path.to_vec(),
1184                                    Box::new((*body).clone()),
1185                                )),
1186                            )
1187                        })
1188                        .collect()
1189                };
1190                bindings.push(Binding::LetRec(recs));
1191                for n in names {
1192                    export_alias(
1193                        mod_path,
1194                        n,
1195                        Vec::new(),
1196                        &mut bindings,
1197                        &mut running,
1198                        &mut exported,
1199                    );
1200                }
1201            }
1202            cst::TopBinding::LetInline {
1203                ctx,
1204                cmd,
1205                params,
1206                value,
1207                ..
1208            } => {
1209                let value_ast =
1210                    elaborate_let_inline(ctx.as_ref(), params, value, &running, "read-inline")?;
1211                let value_ast =
1212                    maybe_v006_scope(value_ast, this_v006);
1213                push_named_binding(
1214                    mod_path,
1215                    cmd.name.clone(),
1216                    value_ast,
1217                    param_optional_shape(params),
1218                    Binding::Let,
1219                    &mut bindings,
1220                    &mut running,
1221                    &mut exported,
1222                );
1223            }
1224            cst::TopBinding::LetBlock {
1225                ctx,
1226                cmd,
1227                params,
1228                value,
1229                ..
1230            } => {
1231                let value_ast =
1232                    elaborate_let_inline(ctx.as_ref(), params, value, &running, "read-block")?;
1233                let value_ast =
1234                    maybe_v006_scope(value_ast, this_v006);
1235                push_named_binding(
1236                    mod_path,
1237                    cmd.name.clone(),
1238                    value_ast,
1239                    param_optional_shape(params),
1240                    Binding::Let,
1241                    &mut bindings,
1242                    &mut running,
1243                    &mut exported,
1244                );
1245            }
1246            cst::TopBinding::LetMath {
1247                cmd,
1248                params,
1249                value,
1250                ..
1251            } => {
1252                let value_ast = elaborate_let_math(params, value, &running)?;
1253                let value_ast =
1254                    maybe_v006_scope(value_ast, this_v006);
1255                push_named_binding(
1256                    mod_path,
1257                    cmd.name.clone(),
1258                    value_ast,
1259                    param_optional_shape(params),
1260                    Binding::LetMath,
1261                    &mut bindings,
1262                    &mut running,
1263                    &mut exported,
1264                );
1265            }
1266            // `type` declarations have no runtime effect in this untyped
1267            // elaborator: constructors are bare `Ctor` atoms, never scope-
1268            // checked, and a synonym is never itself a runtime value — so
1269            // neither needs a scope entry. Both are still surfaced
1270            // (unqualified; see `UserTypeDecl`/`UserSynonymDecl`) for the
1271            // typechecker.
1272            cst::TopBinding::Type(decl) => {
1273                for lowered in lower_type_decl(decl, mod_path, &level_tymap) {
1274                    match lowered {
1275                        LoweredTypeDecl::Variant(v) => type_decls.push(v),
1276                        LoweredTypeDecl::Synonym(s) => synonym_decls.push(s),
1277                    }
1278                }
1279            }
1280            cst::TopBinding::LetMutable {
1281                name, value, ..
1282            } => {
1283                let value_ast = expr(value, &running)?;
1284                // The stage wraps the INITIAL value (the only expression a
1285                // `let-mutable` holds); `Binding::LetMutable` then makes the
1286                // ref cell out of it.
1287                let value_ast =
1288                    maybe_v006_scope(value_ast, this_v006);
1289                push_named_binding(
1290                    mod_path,
1291                    name.name.clone(),
1292                    value_ast,
1293                    Vec::new(),
1294                    Binding::LetMutable,
1295                    &mut bindings,
1296                    &mut running,
1297                    &mut exported,
1298                );
1299            }
1300            cst::TopBinding::Module {
1301                name, sig, decls, ..
1302            } => {
1303                // Signature annotations (`sig .. end`) are accepted and
1304                // ignored: this elaborator does no type checking, so
1305                // `val`/`type` items have nothing to check against (full
1306                // reconciliation is deferred). `direct` items ARE handled:
1307                // each exposes its command UNQUALIFIED at the enclosing
1308                // scope, aliasing the module's qualified binding — the same
1309                // `Ast::Var`-alias trick `export_alias`/`Open` use below.
1310                // `typecheck.rs`'s `command_scheme` threads command types
1311                // through an alias site transparently, so the exposed name
1312                // gets its command type for free.
1313                let mut child_path = mod_path.to_vec();
1314                child_path.push(name.name.clone());
1315                let inner_items: Vec<&cst::TopBinding> =
1316                    decls.iter().map(|d| d.0.as_ref()).collect();
1317                // A nested `module .. = struct .. end` has no
1318                // index correspondence to the OUTER `v006_indices` (that set
1319                // indexes THIS level's `items`, not `inner_items`) — if the
1320                // enclosing item is itself v006-marked, every inner item is
1321                // too (the whole subtree came from the same spliced 0.0.6
1322                // file); otherwise none are.
1323                let inner_v006: HashSet<usize> = if this_v006 {
1324                    (0..inner_items.len()).collect()
1325                } else {
1326                    HashSet::new()
1327                };
1328                // Same reasoning for the stage: a nested module's items are
1329                // indexed against `inner_items`, so the enclosing item's stage
1330                // (if any) applies to all of them.
1331                let inner_stages: HashMap<usize, crate::types::Stage> = match this_stage {
1332                    Some(st) => (0..inner_items.len()).map(|i| (i, st)).collect(),
1333                    None => HashMap::new(),
1334                };
1335                let (inner_bindings, inner_exported, inner_running) = walk_bindings(
1336                    &inner_items,
1337                    &running,
1338                    &child_path,
1339                    type_decls,
1340                    synonym_decls,
1341                    &ItemOrigins {
1342                        v006: &inner_v006,
1343                        stages: &inner_stages,
1344                    },
1345                    &level_tymap,
1346                )?;
1347                // A module's own bare (unqualified) member names never leak
1348                // past this `end`: `push_named_binding` (used by every
1349                // ordinary member below) binds each member under a MANGLED
1350                // key and registers a `Scope::rename` redirect for sibling
1351                // lookups (see that function's doc comment). Naive scope-
1352                // popping is NOT an option here, because `nest()` produces
1353                // one flat `LetIn` chain and
1354                // `v1/module_check.rs`'s spine-walking sealing pass only
1355                // recognizes TOP-LEVEL `LetIn`/`LetMathIn`/`LetRecIn`/
1356                // `LetMutableIn` nodes, not ones nested inside a wrapper
1357                // sub-expression.
1358                bindings.extend(inner_bindings);
1359                // The enclosing context's own module prefix (`Outer.` when
1360                // this whole `walk_bindings` is elaborating `module Outer`'s
1361                // body). Each nested member is ALSO exposed under its
1362                // ENCLOSING-relative name so a sibling's `Inner.double`
1363                // reference resolves (a nested module `N` inside `M` binds its
1364                // members as `M.N.x`, but a sibling writes `N.x`).
1365                let self_prefix = if mod_path.is_empty() {
1366                    String::new()
1367                } else {
1368                    format!("{}.", mod_path.join("."))
1369                };
1370                for q in &inner_exported {
1371                    let shape = inner_running.optional_shape(q).to_vec();
1372                    running.insert_with_shape(q, shape.clone());
1373                    if !self_prefix.is_empty() {
1374                        if let Some(rel) = q.strip_prefix(&self_prefix) {
1375                            running.insert_with_shape(rel, shape);
1376                            running.rename(rel, q);
1377                        }
1378                    }
1379                }
1380                if let Some(sig_annot) = sig {
1381                    for item in &sig_annot.items {
1382                        if let Some((local, span)) = direct_cmd_name(item) {
1383                            let qual = qualify_key(&child_path, &local);
1384                            // Cheap positive-obligation check (a `direct`-only
1385                            // slice of the fuller sig-preservation, which
1386                            // stays deferred for `val`/`type` items): the
1387                            // struct must actually define what it declares
1388                            // `direct`, or the alias below would dangle.
1389                            if !inner_exported.contains(&qual) {
1390                                return err(
1391                                    span,
1392                                    format!(
1393                                        "module `{}` signature declares `direct {local} : ..` \
1394                                         but its `struct .. end` body never defines `{local}`",
1395                                        name.name
1396                                    ),
1397                                );
1398                            }
1399                            let shape = running.optional_shape(&qual).to_vec();
1400                            bindings.push(Binding::Let(
1401                                local.clone(),
1402                                Ast::Var(running.sym(&qual), Span::default()),
1403                            ));
1404                            running.insert_with_shape(&local, shape);
1405                            exported.push(local);
1406                        }
1407                    }
1408                }
1409                exported.extend(inner_exported);
1410            }
1411            cst::TopBinding::Open { name, .. } => {
1412                let prefix = format!("{}.", name.name);
1413                for q in running.names_with_prefix(&prefix) {
1414                    let suffix = q[prefix.len()..].to_string();
1415                    let shape = running.optional_shape(&q).to_vec();
1416                    bindings.push(Binding::Let(
1417                        suffix.clone(),
1418                        Ast::Var(running.sym(&q), Span::default()),
1419                    ));
1420                    running.insert_with_shape(&suffix, shape);
1421                    // `open` only re-exposes an *existing* qualified name
1422                    // under its bare suffix locally; it doesn't itself mint
1423                    // a new qualified name, so nothing goes into `exported`
1424                    // here.
1425                }
1426                // Also overlay the opened module's DIRECT type members so a
1427                // later bare reference to one resolves to its qualified name
1428                // (the type analog of the value re-exposure above). Only
1429                // direct members (`M.t`, never `M.N.t`), mirroring the value
1430                // `names_with_prefix` rule; the module is already fully walked
1431                // (`open` names an earlier module), so `type_decls`/
1432                // `synonym_decls` hold its qualified entries.
1433                for q in type_decls
1434                    .iter()
1435                    .map(|d| &d.name)
1436                    .chain(synonym_decls.iter().map(|s| &s.name))
1437                {
1438                    if let Some(suffix) = q.strip_prefix(&prefix) {
1439                        if !suffix.contains('.') {
1440                            level_tymap.insert(suffix.to_string(), q.clone());
1441                        }
1442                    }
1443                }
1444            }
1445        }
1446        if let Some(st) = this_stage {
1447            stage_wrap_item(&mut bindings[bindings_before..], st);
1448        }
1449    }
1450    Ok((bindings, exported, running))
1451}
1452
1453/// Curry a command binding's (`let-inline`/`let-block`/`let-math`) `Param`
1454/// list — already widened to `PatBot` by `params_to_patbots` — around a
1455/// value built from the fully-extended scope, mirroring `rec_clause_value`'s
1456/// two-path shape but per-param rather than clause-tuple (upstream's
1457/// `curry_lambda_abstract` builds one `UTFunction` per `cmdarglst` element,
1458/// `parser.mly:50-63`, unlike `let-rec`'s single tupled match — kept as an
1459/// intentional divergence because that's what upstream itself does for
1460/// command arguments).
1461///
1462/// The all-variable-parameter fast path emits a plain `Lambda` chain; it
1463/// must, since `elaborate_let_inline`'s "lightweight" form builds its
1464/// `read-inline`/`read-block` application *inside* `build_value` (called
1465/// with the innermost, fully-extended scope), so the wrapping must nest
1466/// *inside* every curried parameter. The general path instead lowers each
1467/// parameter to its own `Lambda(%cmd_argN, Match(%cmd_argN, [pat -> rest]))`
1468/// — a refutable pattern (e.g. `Some(x)`) can fail at *application* time,
1469/// like any `match` arm (see `eval.rs`'s `Ast::Match` for the resulting
1470/// runtime error).
1471fn curry_cmd_params<'s>(
1472    patbots: &[c::PatBot],
1473    scope: &Scope<'s>,
1474    build_value: impl FnOnce(&Scope<'s>) -> Result<Ast<'s>, ElabError>,
1475) -> Result<Ast<'s>, ElabError> {
1476    if patbots.iter().all(is_var_patbot) {
1477        let mut inner = scope.clone();
1478        for p in patbots {
1479            inner = inner.with(patbot_var_name(p));
1480        }
1481        let mut value_ast = build_value(&inner)?;
1482        for p in patbots.iter().rev() {
1483            value_ast = Ast::Lambda(scope.sym(patbot_var_name(p)), Rc::new(value_ast));
1484        }
1485        return Ok(value_ast);
1486    }
1487    let pats: Vec<Pattern<'s>> = patbots
1488        .iter()
1489        .map(|p| patbot(scope.store, p))
1490        .collect::<Result<_, _>>()?;
1491    let mut names = Vec::new();
1492    for p in &pats {
1493        collect_pattern_names(scope.store, p, &mut names);
1494    }
1495    let mut inner = scope.clone();
1496    for n in &names {
1497        inner = inner.with(n);
1498    }
1499    let mut value_ast = build_value(&inner)?;
1500    let dummy = Span::default();
1501    for (i, pat) in pats.into_iter().enumerate().rev() {
1502        let fresh = scope.sym(&format!("%cmd_arg{i}"));
1503        value_ast = Ast::Lambda(
1504            fresh,
1505            Rc::new(Ast::Match(
1506                Box::new(Ast::Var(fresh, dummy)),
1507                vec![MatchArm {
1508                    pat,
1509                    guard: None,
1510                    body: value_ast,
1511                }],
1512            )),
1513        );
1514    }
1515    Ok(value_ast)
1516}
1517
1518/// `[ctxvar] let-inline \cmd param* = value` / `[ctxvar] let-block +cmd
1519/// param* = value` (`nxhorzdec`/`nxvertdec` in `parser.mly`, lines 548-577).
1520/// Each `param` is upstream's `arg` (`cst.rs`'s `Param` doc comment — a full
1521/// patbot, a `?:`-marked variable, or a
1522/// `?(l = x, …)` labeled-optional bundle), curried via the bundle-aware
1523/// `curry_cmd_params_v1` (which delegates wholesale to `curry_cmd_params`
1524/// when no bundle is present).
1525///
1526/// Two forms, confirmed against v0.0.6 `parser.mly`:
1527/// * with an explicit leading context variable, the value is elaborated
1528///   as-is (already inline-boxes/block-boxes typed) under
1529///   `Lambda(ctxvar, Lambda(p1, .., value))`;
1530/// * without one (the "lightweight" form), `parser.mly` synthesizes an
1531///   implicit `%context` variable and wraps the (inline-text/block-text
1532///   typed) value in a `read-inline`/`read-block` call *inside* the
1533///   curried parameters but *around* the value itself:
1534///   `curry_lambda_abstract_pattern params (read-inline %context value)`,
1535///   all wrapped in `Lambda(%context, ..)`. We reproduce that exactly,
1536///   using `reader` = `"read-inline"` or `"read-block"`.
1537fn elaborate_let_inline<'s>(
1538    ctx: Option<&VarTok>,
1539    params: &[c::Param],
1540    value: &c::Expr,
1541    scope: &Scope<'s>,
1542    reader: &str,
1543) -> Result<Ast<'s>, ElabError> {
1544    match ctx {
1545        Some(ctxvar) => {
1546            let ctx_scope = scope.with(&ctxvar.name);
1547            let value_ast = curry_cmd_params_v1(params, &ctx_scope, |inner| expr(value, inner))?;
1548            Ok(Ast::Lambda(scope.sym(&ctxvar.name), Rc::new(value_ast)))
1549        }
1550        None => {
1551            const IMPLICIT_CTX: &str = "%context";
1552            let dummy = Span::default();
1553            let ctx_scope = scope.with(IMPLICIT_CTX);
1554            let curried = curry_cmd_params_v1(params, &ctx_scope, |inner| {
1555                let value_ast = expr(value, inner)?;
1556                let read_fn = scoped_var(reader, dummy, inner)?;
1557                let ctx_var = scoped_var(IMPLICIT_CTX, dummy, inner)?;
1558                Ok(Ast::Apply(
1559                    Box::new(Ast::Apply(Box::new(read_fn), Box::new(ctx_var))),
1560                    Box::new(value_ast),
1561                ))
1562            })?;
1563            Ok(Ast::Lambda(scope.sym(IMPLICIT_CTX), Rc::new(curried)))
1564        }
1565    }
1566}
1567
1568/// `let-math \cmd param* = expr` (upstream `nxmathdec`, `parser.mly:586-591`):
1569/// curry `params` (upstream's `arg`, `cst.rs`'s `Param` doc comment) via
1570/// `curry_cmd_params`, with **no** implicit/explicit context variable at all
1571/// (contrast `elaborate_let_inline`, which always threads one) — a math
1572/// command's own type (`math-cmd`) carries no context argument. A zero-param
1573/// binding (e.g. `let-math \to = rel \`→\``) elaborates to `value` directly,
1574/// un-wrapped. Shared by `TopBinding::LetMath` (via `walk_bindings`) and the
1575/// expression-level `Expr::LetMathIn` (`parser.mly:688`, upstream's only
1576/// command binding with a local `in`-bodied form — see that variant's doc
1577/// comment).
1578fn elaborate_let_math<'s>(
1579    params: &[c::Param],
1580    value: &c::Expr,
1581    scope: &Scope<'s>,
1582) -> Result<Ast<'s>, ElabError> {
1583    curry_cmd_params_v1(params, scope, |inner| expr(value, inner))
1584}
1585
1586/// Elaborate one `let-rec` clause group (shared by the local `Expr::LetRecIn`
1587/// and the top-level `TopBinding::LetRec`): every name is in scope in every
1588/// binding's own value (mutual recursion) as well as in the body, and each
1589/// binding's own parameters curry into a `Lambda` around its elaborated
1590/// value. Whether the (possibly zero) curried result is actually a function
1591/// is a *runtime* check (see `eval.rs`'s `Ast::LetRecIn` handling) — nothing
1592/// here forces `params` to be non-empty, since a paramterless binding whose
1593/// `value` is itself e.g. a `fun ...` expression is equally valid.
1594fn rec_bindings<'s>(
1595    first: &c::RecBinding,
1596    ands: &[c::AndBinding],
1597    scope: &Scope<'s>,
1598    mod_path: &[String],
1599) -> Result<(Vec<(String, Rc<Ast<'s>>)>, Scope<'s>), ElabError> {
1600    let all: Vec<&c::RecBinding> = std::iter::once(first)
1601        .chain(ands.iter().map(|a| &a.binding))
1602        .collect();
1603    let mut rec_scope = scope.clone();
1604    for rb in &all {
1605        rec_scope = rec_scope.with(&rb.name.name);
1606    }
1607    // Inside a `module M = struct .. end`, bind each clause under a MANGLED key
1608    // (`$M.name`) and redirect its (self/mutual/sibling) bare references there,
1609    // so the recursive value never leaks into the flat program scope under its
1610    // bare name (see `export_alias`). A top-level (`mod_path` empty) `let-rec`
1611    // keeps bare keys.
1612    let key_of = |name: &str| -> String {
1613        if mod_path.is_empty() {
1614            name.to_string()
1615        } else {
1616            format!("${}", qualify_key(mod_path, name))
1617        }
1618    };
1619    if !mod_path.is_empty() {
1620        for rb in &all {
1621            rec_scope.rename(&rb.name.name, &key_of(&rb.name.name));
1622        }
1623    }
1624    let mut bindings = Vec::with_capacity(all.len());
1625    for rb in all {
1626        let value_ast = rec_clause_value(&rb.params, &rb.value, &rb.extra, &rec_scope)?;
1627        bindings.push((key_of(&rb.name.name), Rc::new(value_ast)));
1628    }
1629    Ok((bindings, rec_scope))
1630}
1631
1632/// Elaborate the (possibly multi-clause) value of one `let-rec` binding
1633/// (`RecBinding`/`RecClause`: `name [|] patbot* = value (| patbot* =
1634/// value)*`). A multi-clause definition desugars into one curried function
1635/// of `n` fresh parameters (`n` = every clause's shared arity — an
1636/// `IllegalArgumentLength`-style error if they disagree) that matches a
1637/// tuple of them against each clause's patterns in turn, first clause first
1638/// (`option.satyg`'s `let-rec map | f (None) = None | f (Some(v)) = Some(f
1639/// v)`: 2 clauses, arity 2). At arity 1 the "tuple" is just the single fresh
1640/// parameter, no `Ast::Tuple` wrapper (matches `list.satyg`'s single-
1641/// parameter clauses, e.g. `let-rec append lst1 lst2 = ..`, mixed with
1642/// genuinely-refutable single clauses elsewhere).
1643///
1644/// The single-clause, all-variable-parameter case (`let-rec f x y = ..`, no
1645/// `|` — the common shape) special-cases to a direct `Lambda` chain:
1646/// behaviorally identical to the general path, it just skips a throwaway
1647/// `Match`/fresh-variable indirection.
1648fn rec_clause_value<'s>(
1649    params0: &[c::PatBot],
1650    value0: &c::Expr,
1651    extra: &[c::RecClause],
1652    scope: &Scope<'s>,
1653) -> Result<Ast<'s>, ElabError> {
1654    let arity = params0.len();
1655    for cl in extra {
1656        if cl.params.len() != arity {
1657            return err(
1658                cl.bar.0,
1659                format!(
1660                    "every clause of a multi-clause 'let-rec' binding must bind the \
1661                     same number of parameters (expected {arity}, got {})",
1662                    cl.params.len()
1663                ),
1664            );
1665        }
1666    }
1667
1668    if extra.is_empty() && params0.iter().all(is_var_patbot) {
1669        let mut inner = scope.clone();
1670        for p in params0 {
1671            inner = inner.with(patbot_var_name(p));
1672        }
1673        let mut value_ast = expr(value0, &inner)?;
1674        for p in params0.iter().rev() {
1675            value_ast = Ast::Lambda(scope.sym(patbot_var_name(p)), Rc::new(value_ast));
1676        }
1677        return Ok(value_ast);
1678    }
1679
1680    let fresh: Vec<Symbol<'s>> = (0..arity)
1681        .map(|i| scope.sym(&format!("%rec_arg{i}")))
1682        .collect();
1683    let mut arms = Vec::with_capacity(1 + extra.len());
1684    arms.push(rec_clause_arm(params0, value0, scope)?);
1685    for cl in extra {
1686        arms.push(rec_clause_arm(&cl.params, &cl.value, scope)?);
1687    }
1688    let dummy = Span::default();
1689    let scrutinee = if arity == 1 {
1690        Ast::Var(fresh[0], dummy)
1691    } else {
1692        Ast::Tuple(fresh.iter().map(|f| Ast::Var(*f, dummy)).collect())
1693    };
1694    let mut body = Ast::Match(Box::new(scrutinee), arms);
1695    for f in fresh.iter().rev() {
1696        body = Ast::Lambda(*f, Rc::new(body));
1697    }
1698    Ok(body)
1699}
1700
1701/// Lower a SATySFi 0.1 `fun ?(l = x, …) p -> body` unit (`Expr::FunRows`) to
1702/// an [`Ast::LambdaOpt`]. Gated on the V0_1 source version (a 0.0.6-parsed
1703/// occurrence — reachable only via the additive-`cst` accept surface — is
1704/// rejected here with a version error). Duplicate labels in one binder list
1705/// are rejected. Each optional binder and the positional param enter scope
1706/// as plain names (labeled optionals have no marker-less padding, so NO
1707/// `optional_arity` entry). A pattern param desugars to a fresh var + `Match`
1708/// exactly as `rec_clause_value` does for a destructuring parameter.
1709fn fun_rows_to_ast<'s>(
1710    kw_span: Span,
1711    opts: &c::CstOptBinders,
1712    param: &c::PatBot,
1713    body: &c::Expr,
1714    scope: &Scope<'s>,
1715) -> Result<Ast<'s>, ElabError> {
1716    if !scope.version.has_row_polymorphism() {
1717        return err(
1718            kw_span,
1719            "labeled optional arguments (`?(l = x)`) are SATySFi 0.1 syntax — \
1720             this file is compiled as 0.0.6",
1721        );
1722    }
1723    let mut inner = scope.clone();
1724    for e in &opts.entries {
1725        inner = inner.with(&e.var.name);
1726    }
1727    let body_ast = if is_var_patbot(param) {
1728        let body_scope = inner.with(patbot_var_name(param));
1729        expr(body, &body_scope)?
1730    } else {
1731        let pat = patbot(scope.store, param)?;
1732        let mut names = Vec::new();
1733        collect_pattern_names(scope.store, &pat, &mut names);
1734        let mut body_scope = inner;
1735        for n in &names {
1736            body_scope = body_scope.with(n);
1737        }
1738        expr(body, &body_scope)?
1739    };
1740    lambda_opt_from(scope.store, opts, param, body_ast)
1741}
1742
1743/// Build an `Ast::LambdaOpt` from a `?(l = x, …)` binder bundle, its
1744/// positional param, and an ALREADY-ELABORATED inner body — the shared core
1745/// factored out of [`fun_rows_to_ast`] (a value-level `fun ?(l = x) p ->
1746/// body` unit) so [`curry_cmd_params_v1`]'s bundle arm (a command
1747/// parameter bundle) can reuse the exact
1748/// same binder logic. Duplicate labels in one binder list are rejected. A
1749/// `PatBot::Var` param becomes the `LambdaOpt`'s param directly; any other
1750/// pattern desugars to a fresh `%opt_arg` var + `Match`, exactly like
1751/// `rec_clause_value`'s destructuring-parameter path.
1752fn lambda_opt_from<'s>(
1753    store: &'s SymbolStore,
1754    opts: &c::CstOptBinders,
1755    param: &c::PatBot,
1756    inner_body_ast: Ast<'s>,
1757) -> Result<Ast<'s>, ElabError> {
1758    // `(label, binder)`: the label is data and stays text, the binder is a
1759    // lexical variable and is interned (see `ast::Ast::LambdaOpt`).
1760    let mut opt_pairs: Vec<(String, Symbol<'s>)> = Vec::with_capacity(opts.entries.len());
1761    let mut seen = HashSet::new();
1762    for e in &opts.entries {
1763        if !seen.insert(e.label.name.clone()) {
1764            return err(
1765                e.label.span,
1766                format!(
1767                    "duplicate optional label `{}` in one `?(…)` binder list",
1768                    e.label.name
1769                ),
1770            );
1771        }
1772        opt_pairs.push((e.label.name.clone(), store.intern(&e.var.name)));
1773    }
1774    if is_var_patbot(param) {
1775        Ok(Ast::LambdaOpt {
1776            opts: opt_pairs,
1777            param: store.intern(patbot_var_name(param)),
1778            body: Rc::new(inner_body_ast),
1779        })
1780    } else {
1781        let fresh = store.intern("%opt_arg");
1782        let pat = patbot(store, param)?;
1783        let matched = Ast::Match(
1784            Box::new(Ast::Var(fresh, Span::default())),
1785            vec![MatchArm {
1786                pat,
1787                guard: None,
1788                body: inner_body_ast,
1789            }],
1790        );
1791        Ok(Ast::LambdaOpt {
1792            opts: opt_pairs,
1793            param: fresh,
1794            body: Rc::new(matched),
1795        })
1796    }
1797}
1798
1799/// The bundle-aware command-parameter currier: same overall shape as [`curry_cmd_params`] — extend the scope with
1800/// every name the parameter list binds, build the innermost value once
1801/// against the fully-extended scope, then curry back outward — but also
1802/// handles a `Param::Bundled { opts, body }` entry (`?(l = x, …) pat`) by
1803/// emitting an `Ast::LambdaOpt` for that slot (via [`lambda_opt_from`])
1804/// instead of a plain `Ast::Lambda`/`Match`.
1805///
1806/// **Delegates wholesale to [`curry_cmd_params`]** when `params` has no
1807/// `Bundled` entry — every 0.0.6 binding and most V0_1 ones.
1808///
1809/// **Version-gated** like `fun_rows_to_ast`: a `Bundled` entry reaching the
1810/// general fold under `!scope.version.has_row_polymorphism()` is rejected
1811/// with the same version error — purely defensive, since only
1812/// `v1/lower.rs::lower_command_params` ever constructs `Param::Bundled`, and
1813/// `lower_value_math` already rejects it for a `val math` binding before
1814/// elaboration.
1815fn curry_cmd_params_v1<'s>(
1816    params: &[c::Param],
1817    scope: &Scope<'s>,
1818    build_value: impl FnOnce(&Scope<'s>) -> Result<Ast<'s>, ElabError>,
1819) -> Result<Ast<'s>, ElabError> {
1820    if !params.iter().any(|p| matches!(p, c::Param::Bundled { .. })) {
1821        let patbots = params_to_patbots(params);
1822        return curry_cmd_params(&patbots, scope, build_value);
1823    }
1824    if !scope.version.has_row_polymorphism() {
1825        let bundle_span = params
1826            .iter()
1827            .find_map(|p| match p {
1828                c::Param::Bundled { opts, .. } => Some(opts.q.0),
1829                _ => None,
1830            })
1831            .expect("just checked a `Param::Bundled` entry exists");
1832        return err(
1833            bundle_span,
1834            "labeled optional arguments (`?(l = x)`) are SATySFi 0.1 syntax — \
1835             this file is compiled as 0.0.6",
1836        );
1837    }
1838    let mut inner = scope.clone();
1839    for p in params {
1840        inner = match p {
1841            c::Param::Bundled { opts, body } => {
1842                for e in &opts.entries {
1843                    inner = inner.with(&e.var.name);
1844                }
1845                extend_with_patbot(inner, body)?
1846            }
1847            _ => extend_with_patbot(inner, &param_to_patbot(p))?,
1848        };
1849    }
1850    let mut value_ast = build_value(&inner)?;
1851    let dummy = Span::default();
1852    for (i, p) in params.iter().enumerate().rev() {
1853        value_ast = match p {
1854            c::Param::Bundled { opts, body } => {
1855                lambda_opt_from(scope.store, opts, body, value_ast)?
1856            }
1857            c::Param::Optional { name, .. } => {
1858                Ast::Lambda(scope.sym(&name.name), Rc::new(value_ast))
1859            }
1860            c::Param::Pat(pat) if is_var_patbot(pat) => {
1861                Ast::Lambda(scope.sym(patbot_var_name(pat)), Rc::new(value_ast))
1862            }
1863            c::Param::Pat(pat) => {
1864                let pp = patbot(scope.store, pat)?;
1865                let fresh = scope.sym(&format!("%cmd_arg{i}"));
1866                Ast::Lambda(
1867                    fresh,
1868                    Rc::new(Ast::Match(
1869                        Box::new(Ast::Var(fresh, dummy)),
1870                        vec![MatchArm {
1871                            pat: pp,
1872                            guard: None,
1873                            body: value_ast,
1874                        }],
1875                    )),
1876                )
1877            }
1878        };
1879    }
1880    Ok(value_ast)
1881}
1882
1883/// Extend `scope` with every name patbot `p` binds: its single var name if
1884/// it's a plain `PatBot::Var`, else every name the full pattern binds
1885/// (`collect_pattern_names`) — the scope half of `curry_cmd_params_v1`'s
1886/// general fold (mirroring `curry_cmd_params`'s own inline scope-extension,
1887/// factored out here since the bundle-aware fold interleaves it with a
1888/// bundle's own binder names).
1889fn extend_with_patbot<'s>(scope: Scope<'s>, p: &c::PatBot) -> Result<Scope<'s>, ElabError> {
1890    if is_var_patbot(p) {
1891        Ok(scope.with(patbot_var_name(p)))
1892    } else {
1893        let store = scope.store;
1894        let pat = patbot(store, p)?;
1895        let mut names = Vec::new();
1896        collect_pattern_names(store, &pat, &mut names);
1897        let mut s = scope;
1898        for n in &names {
1899            s = s.with(n);
1900        }
1901        Ok(s)
1902    }
1903}
1904
1905/// Widen a plain (non-`let-rec`) `let`'s `Param` down to a `PatBot`, so
1906/// `TopLet`/`Expr::LetIn` can share `rec_clause_value`'s pattern-currying
1907/// machinery with `let-rec` unchanged: the def-site optional marker
1908/// (`Param::Optional`, `?:name`) carries no elaboration-time semantics of
1909/// its own in this port (see `cst.rs`'s `Param` doc comment) — it is simply
1910/// a plain variable binder, `PatBot::Var`.
1911fn param_to_patbot(p: &c::Param) -> c::PatBot {
1912    match p {
1913        c::Param::Optional { name, .. } => c::PatBot::Var(name.clone()),
1914        c::Param::Pat(pat) => pat.clone(),
1915        // A `?(l = x, …)` command-parameter bundle
1916        // never reaches this widener: `v1/lower.rs::
1917        // lower_command_params` is its only constructor, for a command
1918        // binding's OWN `Param` list, and `curry_cmd_params_v1` — that
1919        // list's only caller — checks for `Bundled` itself and routes
1920        // around this widener entirely when found (see its doc comment). A
1921        // plain `let`/`let-rec`'s `Param` list (this widener's other caller)
1922        // can't contain one either — `lower_param_units` always right-folds
1923        // a bundled unit into an `Expr::FunRows` chain, returning an EMPTY
1924        // `Param` list when any unit is bundled.
1925        c::Param::Bundled { .. } => {
1926            unreachable!("a `?(l = x)` command-parameter bundle cannot reach `param_to_patbot`")
1927        }
1928    }
1929}
1930
1931fn params_to_patbots(params: &[c::Param]) -> Vec<c::PatBot> {
1932    params.iter().map(param_to_patbot).collect()
1933}
1934
1935fn is_var_patbot(p: &c::PatBot) -> bool {
1936    matches!(p, c::PatBot::Var(_))
1937}
1938
1939/// Panics if `p` isn't `PatBot::Var` — callers must check [`is_var_patbot`] first.
1940fn patbot_var_name(p: &c::PatBot) -> &str {
1941    match p {
1942        c::PatBot::Var(v) => &v.name,
1943        _ => unreachable!("patbot_var_name called on a non-Var PatBot"),
1944    }
1945}
1946
1947/// One `patbot* = value` clause of a multi-clause `let-rec`, lowered to a
1948/// [`MatchArm`] over the clause's parameter patterns (see
1949/// [`rec_clause_value`]'s doc comment for the arity-1-vs-N pattern shape) —
1950/// the body sees every name the patterns bind, exactly like an ordinary
1951/// `match` arm ([`match_arm`], below).
1952fn rec_clause_arm<'s>(
1953    params: &[c::PatBot],
1954    value: &c::Expr,
1955    scope: &Scope<'s>,
1956) -> Result<MatchArm<'s>, ElabError> {
1957    let pats: Vec<Pattern<'s>> = params
1958        .iter()
1959        .map(|p| patbot(scope.store, p))
1960        .collect::<Result<_, _>>()?;
1961    let mut names = Vec::new();
1962    for p in &pats {
1963        collect_pattern_names(scope.store, p, &mut names);
1964    }
1965    let mut inner = scope.clone();
1966    for n in &names {
1967        inner = inner.with(n);
1968    }
1969    let body = expr(value, &inner)?;
1970    let pat = if pats.len() == 1 {
1971        pats.into_iter().next().unwrap()
1972    } else {
1973        Pattern::Tuple(pats)
1974    };
1975    Ok(MatchArm {
1976        pat,
1977        guard: None,
1978        body,
1979    })
1980}
1981
1982fn expr<'s>(e: &c::Expr, scope: &Scope<'s>) -> Result<Ast<'s>, ElabError> {
1983    match e {
1984        c::Expr::LetRecIn {
1985            first, ands, body, ..
1986        } => {
1987            let (bindings, rec_scope) = rec_bindings(first, ands, scope, &[])?;
1988            let body_ast = expr(body, &rec_scope)?;
1989            // `rec_bindings` yields text keys (it mangles module members'
1990            // into `$M.name`); intern them here, as `nest` does for the
1991            // top-level spine.
1992            Ok(Ast::LetRecIn(
1993                bindings
1994                    .into_iter()
1995                    .map(|(n, v)| (scope.sym(&n), v))
1996                    .collect(),
1997                Box::new(body_ast),
1998            ))
1999        }
2000        c::Expr::LetIn {
2001            name,
2002            params,
2003            value,
2004            body,
2005            ..
2006        } => {
2007            // `params` is now a full `param*` (`cst::ast::Expr::LetIn`'s doc
2008            // comment — widened for `hdecoset.satyh`/`vdecoset.satyh`'s `let
2009            // deco _ _ _ _ = [] in ..`, and further widened to `Param` for
2010            // `stdja.satyh`'s `let document record ?:configopt inner = ..`),
2011            // so this reuses the same single-clause pattern-currying path
2012            // `let-rec`/`Fun` already share, non-recursively (`scope`, not a
2013            // scope extended with `name` itself) — `params_to_patbots` first
2014            // widens any `?:name` marker to a plain `PatBot::Var`.
2015            let let_in_params = params_to_patbots(params);
2016            let value_ast = rec_clause_value(&let_in_params, value, &[], scope)?;
2017            // Record `name`'s leading-`?:`-optional-parameter count (see
2018            // `Scope`'s doc comment) so a later marker-less bare call in
2019            // `body` (`app_chain_generic`) auto-omits it, e.g. progsynt.satyh's
2020            // `let to-math ?:iopt e = .. in .. to-math e1 ..`.
2021            let mut body_scope = scope.clone();
2022            body_scope.insert_with_shape(&name.name, param_optional_shape(params));
2023            let body_ast = expr(body, &body_scope)?;
2024            Ok(Ast::LetIn(
2025                scope.sym(&name.name),
2026                Box::new(value_ast),
2027                Box::new(body_ast),
2028            ))
2029        }
2030        // `let pat = value in body` (`nxnonrecdec`'s general-pattern case —
2031        // see `cst::ast::Expr::LetPatternIn`'s doc comment for why this is a
2032        // separate variant from `LetIn` above). Lowered to the same
2033        // single-arm-`match` machinery a `match` expression's own arms use
2034        // (`pattern`/`collect_pattern_names`, below): `value` is elaborated
2035        // under the OUTER scope (a destructuring let's right-hand side never
2036        // sees its own bound names, same as `LetIn`), then matched against
2037        // `pat`, whose bound names are in scope for `body`.
2038        c::Expr::LetPatternIn {
2039            pat, value, body, ..
2040        } => {
2041            let value_ast = expr(value, scope)?;
2042            let lowered_pat = pattern(scope.store, pat)?;
2043            let mut names = Vec::new();
2044            collect_pattern_names(scope.store, &lowered_pat, &mut names);
2045            let mut inner = scope.clone();
2046            for n in &names {
2047                inner = inner.with(n);
2048            }
2049            let body_ast = expr(body, &inner)?;
2050            Ok(Ast::Match(
2051                Box::new(value_ast),
2052                vec![MatchArm {
2053                    pat: lowered_pat,
2054                    guard: None,
2055                    body: body_ast,
2056                }],
2057            ))
2058        }
2059        c::Expr::If {
2060            cond,
2061            then_branch,
2062            else_branch,
2063            ..
2064        } => Ok(Ast::IfThenElse(
2065            Box::new(expr(cond, scope)?),
2066            Box::new(expr(then_branch, scope)?),
2067            Box::new(expr(else_branch, scope)?),
2068        )),
2069        // `fun patbot+ -> body` (`nxlambda`'s `LAMBDA argpats ARROW nxlor`,
2070        // `argpats = list(patbot)` — see `cst::ast::Expr::Fun`'s doc
2071        // comment). Delegates to `rec_clause_value` (below), the SAME
2072        // arity-preserving pattern-currying `let-rec` already needs for its
2073        // own `patbot*` clause parameters (`fun`'s `extra` clause list is
2074        // simply empty — a lambda has no `|`-alternation): the common
2075        // all-plain-variable case still becomes a direct `Lambda` chain,
2076        // with no `Match`/fresh-variable indirection, and only a genuine
2077        // destructuring parameter (e.g. the bundled `list.satyg`'s
2078        // `mapi-adjacent`: `fun (i, acc) x leftopt rightopt -> ..`) pays for
2079        // the general path.
2080        c::Expr::Fun {
2081            kw, params, body, ..
2082        } => {
2083            if params.is_empty() {
2084                return err(kw.0, "'fun' needs at least one parameter");
2085            }
2086            rec_clause_value(params, body, &[], scope)
2087        }
2088        // `fun ?(l = x, …) p -> body` — SATySFi 0.1 labeled-optional lambda
2089        // unit (one bundle, one positional param).
2090        c::Expr::FunRows {
2091            kw,
2092            opts,
2093            param,
2094            body,
2095            ..
2096        } => fun_rows_to_ast(kw.0, opts, param, body, scope),
2097        c::Expr::Match {
2098            scrutinee,
2099            first,
2100            rest,
2101            ..
2102        } => {
2103            let scrut = expr(scrutinee, scope)?;
2104            let mut arms = Vec::with_capacity(1 + rest.len());
2105            arms.push(match_arm(first, scope)?);
2106            for bar in rest {
2107                arms.push(match_arm(&bar.arm, scope)?);
2108            }
2109            Ok(Ast::Match(Box::new(scrut), arms))
2110        }
2111        // `let-mutable name <- init in body` (`nxletsub`'s `LETMUTABLE` case).
2112        c::Expr::LetMutableIn {
2113            name, init, body, ..
2114        } => {
2115            let init_ast = expr(init, scope)?;
2116            let inner = scope.with(&name.name);
2117            let body_ast = expr(body, &inner)?;
2118            Ok(Ast::LetMutableIn(
2119                scope.sym(&name.name),
2120                Box::new(init_ast),
2121                Box::new(body_ast),
2122            ))
2123        }
2124        // `let-math \cmd param* = value in body` (`nxletsub`'s `LETMATH`
2125        // case, `parser.mly:688`) — upstream's only command binding with a
2126        // local `in`-bodied form (`LETHORZ`/`LETVERT` stay top-level-only,
2127        // see `cst.rs`'s `Expr::LetMathIn` doc comment). Structurally
2128        // identical to the top-level `TopBinding::LetMath` arm of
2129        // `walk_bindings`: elaborate the (curried) value under the OUTER
2130        // scope via the shared `elaborate_let_math` helper, then record
2131        // `cmd`'s leading-`?:`-optional-parameter count for `body`, same as
2132        // `Expr::LetIn` just above.
2133        c::Expr::LetMathIn {
2134            cmd,
2135            params,
2136            value,
2137            body,
2138            ..
2139        } => {
2140            let value_ast = elaborate_let_math(params, value, scope)?;
2141            let mut body_scope = scope.clone();
2142            body_scope.insert_with_shape(&cmd.name, param_optional_shape(params));
2143            let body_ast = expr(body, &body_scope)?;
2144            Ok(Ast::LetMathIn(
2145                scope.sym(&cmd.name),
2146                Box::new(value_ast),
2147                Box::new(body_ast),
2148            ))
2149        }
2150        // `open Name in body` (`nxletsub`'s `OPEN` case) — same alias-binding
2151        // technique as the top-level `TopBinding::Open` fold above (see
2152        // `walk_bindings`), just producing the `LetIn` chain directly since
2153        // there is no further sequence of sibling top bindings to thread a
2154        // scope through here.
2155        c::Expr::OpenIn { name, body, .. } => {
2156            open_module(&name.name, name.span, scope, |s| expr(body, s))
2157        }
2158        // `while cond do body` (`nxwhl`).
2159        c::Expr::WhileDo { cond, body, .. } => Ok(Ast::WhileDo(
2160            Box::new(expr(cond, scope)?),
2161            Box::new(expr(body, scope)?),
2162        )),
2163        // `name <- value` (`nxlambda`'s `OVERWRITEEQ` case). Existence is
2164        // checked against the bare `name.name` (unaffected by any active
2165        // `Scope::rename` redirect); the constructed node's own key goes
2166        // through `Scope::resolve`, exactly like `scoped_var` — a mutable
2167        // module member's sibling overwrite (`first-footnote <- Some m`)
2168        // must target the SAME mangled key its `LetMutableIn` is bound
2169        // under, or the typechecker's own `Ast::Overwrite` arm (which looks
2170        // the name up directly in `env`, independent of `Scope`) reports it
2171        // unbound — see `push_named_binding`'s doc comment.
2172        c::Expr::Overwrite { name, value, .. } => {
2173            if !scope.contains(&name.name) {
2174                return err(
2175                    name.span,
2176                    format!("unbound mutable variable '{}'", name.name),
2177                );
2178            }
2179            Ok(Ast::Overwrite(
2180                scope.resolve(&name.name),
2181                name.span,
2182                Box::new(expr(value, scope)?),
2183            ))
2184        }
2185        c::Expr::Ops(chain) => op_chain(chain, scope),
2186    }
2187}
2188
2189// ---- operator-precedence fold --------------------------------------------
2190
2191/// Precedence-climbing associativity.
2192#[derive(Clone, Copy)]
2193enum Assoc {
2194    Left,
2195    Right,
2196}
2197
2198/// The v0.0.6 `nxlor`..`nxrtimes` precedence ladder, transcribed from
2199/// `parser.mly` lines 722-780 (loosest to tightest):
2200///
2201/// | level | tokens                                    | assoc |
2202/// |-------|-------------------------------------------|-------|
2203/// | 1     | `BinopBar` (`\|>`, ...)                    | left  |
2204/// | 2     | `BinopAmp`                                 | left  |
2205/// | 3     | `BinopEq`, `BinopGt`, `BinopLt`             | right |
2206/// | 4     | `BinopHat` (`^`), `Cons` (`::`)             | right |
2207/// | 5     | `BinopPlus`, `BinopMinus`, `ExactMinus`     | left  |
2208/// | 6     | `BinopTimes`, `ExactTimes`, `BinopDivides`, `Mod` | right |
2209///
2210/// Deviation: v0.0.6's plus/minus level is actually a left/right mix
2211/// (`nxlplus`/`nxlminus`/`nxrplus`/`nxrminus`, four mutually referencing
2212/// nonterminals) that differs from plain left-association only in how
2213/// chains nest — `nxlminus`'s right operand is `nxrtimes`, not `nxrminus`,
2214/// so `1 - 2 - 3`'s *tree shape* differs subtly from a naive left fold even
2215/// though both compute `(1 - 2) - 3`. Since `+`/`*` (this level's only
2216/// concrete instances here) are associative and no surface syntax or test
2217/// can observe tree shape, we use plain LEFT association, matching `-`
2218/// exactly and irrelevant for `+`.
2219///
2220/// Level 6 (`nxrtimes`) is genuinely right-recursive in the grammar itself
2221/// (`nxltimes`'s right operand is `nxrtimes`, recursing on itself) — `8 / 4
2222/// / 2` really does parse as `8 / (4 / 2)` in v0.0.6, not `(8 / 4) / 2`. We
2223/// keep this fidelity quirk.
2224///
2225/// **`&&`/`||` are NOT short-circuited here.** v0.0.6's `bytecomp/
2226/// vminstdef.yaml` registers them as ordinary strict primitives
2227/// (`LogicalAnd`/`LogicalOr`, `code: make_bool (binl && binr)`/`(binl ||
2228/// binr)`), applied like any binop through `parser.mly`'s `binary_operator`
2229/// (`nxland`/`nxlor`, lines 722-727) — by the time OCaml's `&&`/`||` runs,
2230/// both VM-stack operands are already popped (fully evaluated), so real
2231/// SATySFi doesn't short-circuit at the source level either. `primitives.rs`
2232/// registers `"&&"`/`"||"` as strict 2-arg primitives to match; no
2233/// `if`-desugaring here.
2234fn op_prec(tok: &Token) -> (u8, Assoc) {
2235    match tok {
2236        Token::BinopBar(_) => (1, Assoc::Left),
2237        Token::BinopAmp(_) => (2, Assoc::Left),
2238        Token::BinopEq(_) | Token::BinopGt(_) | Token::BinopLt(_) => (3, Assoc::Right),
2239        Token::BinopHat(_) | Token::Cons => (4, Assoc::Right),
2240        Token::BinopPlus(_) | Token::BinopMinus(_) | Token::ExactMinus => (5, Assoc::Left),
2241        Token::BinopTimes(_) | Token::ExactTimes | Token::BinopDivides(_) | Token::Mod => {
2242            (6, Assoc::Right)
2243        }
2244        _ => unreachable!("BinOpTok::parse only ever matches the operator tokens listed above"),
2245    }
2246}
2247
2248/// `nxbfr`'s postfix `before` (see `OpChain::before`'s doc comment in
2249/// `cst.rs`): `e1 before e2` → `Ast::Sequential(e1, e2)`, where `e1` is the
2250/// whole precedence-folded operator chain.
2251fn op_chain<'s>(chain: &c::OpChain, scope: &Scope<'s>) -> Result<Ast<'s>, ElabError> {
2252    let head_ast = app_expr(&chain.head, scope)?;
2253    let folded = if chain.tail.is_empty() {
2254        head_ast
2255    } else {
2256        let mut atoms: VecDeque<Ast<'s>> = VecDeque::with_capacity(chain.tail.len() + 1);
2257        atoms.push_back(head_ast);
2258        let mut ops: VecDeque<(String, Span, Token)> = VecDeque::with_capacity(chain.tail.len());
2259        for rhs in &chain.tail {
2260            let text = rhs.op.op_text();
2261            // `|>` is handled entirely by `climb`'s
2262            // special case below — it is
2263            // deliberately NOT a `scope`-bound name (no runtime primitive, no
2264            // `prim_types` entry: `a |> f` lowers straight to `Apply(f, a)`,
2265            // ordinary application the inferencer/evaluator already handle),
2266            // so it must skip the "unbound operator" gate every other
2267            // operator token goes through.
2268            if text != "|>" && !scope.contains(&text) {
2269                return err(rhs.op.span, format!("unbound operator '{text}'"));
2270            }
2271            // `scope.resolve` redirects a module member's own bare
2272            // operator (`val (+++) a b = ..` inside `module M = struct ..
2273            // end`) to its mangled key, exactly like `scoped_var` — `"|>"`
2274            // is never a `Scope::rename` target (it's deliberately never
2275            // scope-bound at all, see this arm's own comment above), so
2276            // resolving it is a no-op.
2277            ops.push_back((
2278                scope.resolve_text(&text).to_string(),
2279                rhs.op.span,
2280                rhs.op.tok.clone(),
2281            ));
2282            atoms.push_back(app_expr(&rhs.rhs, scope)?);
2283        }
2284        climb(&mut atoms, &mut ops, 0, scope)
2285    };
2286    match &chain.before {
2287        Some(bt) => Ok(Ast::Sequential(
2288            Box::new(folded),
2289            Box::new(expr(&bt.body, scope)?),
2290        )),
2291        None => Ok(folded),
2292    }
2293}
2294
2295/// Standard precedence-climbing fold over an already-elaborated flat
2296/// `atom (op atom)*` sequence (`atoms.len() == ops.len() + 1`). Every binop
2297/// elaborates uniformly to `Apply(Apply(Var(op_text), lhs), rhs)` — SATySFi
2298/// binops (including `::`, see the `primitives.rs` note) are just env-bound
2299/// primitives, no special-cased AST node needed — **except `|>`**, which is
2300/// reverse application (`a |> f` ≡ `f a`, upstream `primitives.cppo.ml:552`)
2301/// special-cased directly to `Apply(rhs, lhs)` rather than
2302/// `Apply(Apply(Var("|>"), lhs), rhs)`: no primitive named `"|>"` is ever
2303/// registered (see `op_chain`'s matching skip of the scope-contains gate),
2304/// since applying a user-supplied closure isn't something any current
2305/// primitive body does. `|>` sits at level 1 (loosest, left-associative,
2306/// see `op_prec`), so `a |> f |> g` folds as `(a |> f) |> g` = `g (f a)`,
2307/// matching the bundled `list.satyg`'s pipe-heavy style (`reverse`,
2308/// `map-adjacent`, `map-with-ends`).
2309fn climb<'s>(
2310    atoms: &mut VecDeque<Ast<'s>>,
2311    ops: &mut VecDeque<(String, Span, Token)>,
2312    min_prec: u8,
2313    scope: &Scope<'s>,
2314) -> Ast<'s> {
2315    let mut lhs = atoms
2316        .pop_front()
2317        .expect("one more atom than consumed operators");
2318    while let Some((_, _, tok)) = ops.front() {
2319        let (prec, assoc) = op_prec(tok);
2320        if prec < min_prec {
2321            break;
2322        }
2323        let (text, span, _) = ops.pop_front().unwrap();
2324        let next_min = match assoc {
2325            Assoc::Left => prec + 1,
2326            Assoc::Right => prec,
2327        };
2328        let rhs = climb(atoms, ops, next_min, scope);
2329        lhs = if text == "|>" {
2330            Ast::Apply(Box::new(rhs), Box::new(lhs))
2331        } else {
2332            Ast::Apply(
2333                Box::new(Ast::Apply(
2334                    Box::new(Ast::Var(scope.sym(&text), span)),
2335                    Box::new(lhs),
2336                )),
2337                Box::new(rhs),
2338            )
2339        };
2340    }
2341    lhs
2342}
2343
2344// ---- application chains --------------------------------------------------
2345
2346fn app_expr<'s>(a: &c::AppExpr, scope: &Scope<'s>) -> Result<Ast<'s>, ElabError> {
2347    // `not` binds looser than application (upstream `nxbot: NOT nxbot` in
2348    // parser.mly): `not f x` means `not (f x)`, NOT `(not f) x`. This port's
2349    // lexer deliberately leaves `not` an ordinary identifier (so it can still
2350    // be passed first-class in ARGUMENT position — e.g. `List.map not xs`),
2351    // so we recognise the logical-negation form only in HEAD position with at
2352    // least one argument: re-fold the arguments into a single inner
2353    // application and apply the `not` primitive to it. A bare `not` (no args)
2354    // or a `not` sitting in argument position resolves to the `not`
2355    // primitive as an ordinary value.
2356    if a.minus.is_none() && a.excl.is_none() && a.stage.is_none() && a.head_accesses.is_empty() && !a.args.is_empty() {
2357        if let c::Atomic::Var(v) = &a.head {
2358            if v.name == "not" && scope.contains("not") && scope.resolve_text("not") == "not" {
2359                let not_fn = scoped_var("not", v.span, scope)?;
2360                let mut inner = app_arg_to_ast(&a.args[0], scope)?;
2361                for rest in &a.args[1..] {
2362                    inner = apply_one_arg(inner, rest, scope)?;
2363                }
2364                return Ok(Ast::Apply(Box::new(not_fn), Box::new(inner)));
2365            }
2366        }
2367    }
2368    let ast = if a.excl.is_none() && a.stage.is_none() && a.head_accesses.is_empty() {
2369        if let c::Atomic::Ctor(ctor) = &a.head {
2370            // A constructor head: the first argument (if any) is its payload
2371            // (`Some 1`); any further arguments Apply-fold on top of the
2372            // resulting `Ctor` value, which the evaluator will reject at run
2373            // time (constructors are not functions).
2374            let mut args_iter = a.args.iter();
2375            match args_iter.next() {
2376                Some(first) => {
2377                    let payload = app_arg_to_ast(first, scope)?;
2378                    let mut ast = Ast::Ctor(ctor.name.clone(), Some(Box::new(payload)));
2379                    for rest in args_iter {
2380                        ast = apply_one_arg(ast, rest, scope)?;
2381                    }
2382                    ast
2383                }
2384                None => Ast::Ctor(ctor.name.clone(), None),
2385            }
2386        } else {
2387            app_chain_generic(a, scope)?
2388        }
2389    } else {
2390        // `!Ctor` / `Ctor#field` don't correspond to any valid v0.0.6
2391        // program (`CONSTRUCTOR` isn't part of `nxbot`, so it can never sit
2392        // under a `#label`/`UNOP_EXCLAM` prefix there) — fall back to the
2393        // generic path, which treats the bare constructor as an ordinary
2394        // (payload-less) atomic value.
2395        app_chain_generic(a, scope)?
2396    };
2397    match &a.minus {
2398        // Unary minus desugars exactly as v0.0.6's `nxun` does (parser.mly
2399        // ~line 774): `0 - <the whole application>`.
2400        Some(m) => {
2401            let minus = scoped_var("-", m.0, scope)?;
2402            Ok(Ast::Apply(
2403                Box::new(Ast::Apply(Box::new(minus), Box::new(Ast::Int(0)))),
2404                Box::new(ast),
2405            ))
2406        }
2407        None => Ok(ast),
2408    }
2409}
2410
2411fn app_chain_generic<'s>(a: &c::AppExpr, scope: &Scope<'s>) -> Result<Ast<'s>, ElabError> {
2412    let mut ast =
2413        atomic_head_with_excl(&a.head, &a.head_accesses, a.excl.as_ref(), a.stage.as_ref(), scope)?;
2414    // Marker-less optional-argument defaulting (`Scope`'s doc comment /
2415    // Sub-area 2): if the head is a bare name (no `!`/`#access`) known to
2416    // have `?:`-optional parameters ANYWHERE in its declared `Param` list
2417    // (not just leading — see `Scope::optional_shape`'s doc comment), walk
2418    // `a.args` position by position against that shape:
2419    //
2420    //  - at a declared OPTIONAL position, an explicit `?:e`/`?*` marker is
2421    //    consumed as written; anything else (a bare arg, a `?(l=e)` bundle,
2422    //    or no argument left) is NOT consumed — a plain `None` is
2423    //    synthesized instead, and the same argument is re-examined against
2424    //    the NEXT position. Without this, a positional argument after an
2425    //    omitted optional mis-binds against the omitted slot: `document
2426    //    record body` (no marker) must become `Apply(Apply(Apply(document,
2427    //    record), None), body)`, not unify `body` against `configopt`'s
2428    //    domain directly.
2429    //  - at a declared MANDATORY position, the next argument is consumed
2430    //    positionally, like plain application.
2431    //  - once every position is visited (or the shape is unknown/empty —
2432    //    the common case), remaining arguments apply one at a time
2433    //    (`apply_one_arg`), so a call with MORE arguments than the head's
2434    //    arity (currying further) is unaffected.
2435    //
2436    // Guarded on non-empty `a.args` so a bare function-VALUE reference (no
2437    // application, e.g. `to-math` passed to `List.map`) is left untouched.
2438    let shape: &[bool] = if a.excl.is_none() && a.head_accesses.is_empty() && !a.args.is_empty() {
2439        head_optional_shape(&a.head, scope)
2440    } else {
2441        &[]
2442    };
2443    let mut args_iter = a.args.iter().peekable();
2444    let mut pos = 0usize;
2445    while pos < shape.len() {
2446        if shape[pos] {
2447            match args_iter.peek() {
2448                Some(c::AppArg::Optional { .. }) | Some(c::AppArg::Omission(_)) => {
2449                    let arg = args_iter.next().unwrap();
2450                    ast = Ast::Apply(Box::new(ast), Box::new(app_arg_to_ast(arg, scope)?));
2451                }
2452                Some(_) => {
2453                    ast = Ast::Apply(Box::new(ast), Box::new(Ast::Ctor("None".to_string(), None)));
2454                }
2455                None => break,
2456            }
2457        } else {
2458            match args_iter.next() {
2459                Some(arg) => ast = apply_one_arg(ast, arg, scope)?,
2460                None => break,
2461            }
2462        }
2463        pos += 1;
2464    }
2465    for arg in args_iter {
2466        ast = apply_one_arg(ast, arg, scope)?;
2467    }
2468    Ok(ast)
2469}
2470
2471/// Apply one application-chain argument to the running `func` AST. A SATySFi
2472/// 0.1 `?(l = e, …)`-bundled argument becomes an [`Ast::ApplyOpt`] (carrying
2473/// the labeled optionals plus the paired positional argument); every other
2474/// argument is an ordinary [`Ast::Apply`].
2475fn apply_one_arg<'s>(
2476    func: Ast<'s>,
2477    arg: &c::AppArg,
2478    scope: &Scope<'s>,
2479) -> Result<Ast<'s>, ElabError> {
2480    match arg {
2481        c::AppArg::Bundled {
2482            opts,
2483            excl,
2484            atom,
2485            accesses,
2486        } => {
2487            let opt_args = elaborate_opt_args(opts, scope)?;
2488            let arg_ast = atomic_head_with_excl(atom, accesses, excl.as_ref(), None, scope)?;
2489            Ok(Ast::ApplyOpt {
2490                func: Box::new(func),
2491                opts: opt_args,
2492                arg: Box::new(arg_ast),
2493            })
2494        }
2495        c::AppArg::BundledCtor { opts, ctor } => {
2496            let opt_args = elaborate_opt_args(opts, scope)?;
2497            Ok(Ast::ApplyOpt {
2498                func: Box::new(func),
2499                opts: opt_args,
2500                arg: Box::new(Ast::Ctor(ctor.name.clone(), None)),
2501            })
2502        }
2503        _ => Ok(Ast::Apply(
2504            Box::new(func),
2505            Box::new(app_arg_to_ast(arg, scope)?),
2506        )),
2507    }
2508}
2509
2510/// Elaborate a `?(l = e, …)` optional-argument bundle: version-gate it (a
2511/// 0.0.6-parsed occurrence is rejected here), reject a duplicate label within
2512/// the one bundle, and elaborate each label's value expression.
2513fn elaborate_opt_args<'s>(
2514    opts: &c::CstOptArgs,
2515    scope: &Scope<'s>,
2516) -> Result<Vec<(String, Ast<'s>)>, ElabError> {
2517    if !scope.version.has_row_polymorphism() {
2518        return err(
2519            opts.q.0,
2520            "labeled optional arguments (`?(l = e)`) are SATySFi 0.1 syntax — \
2521             this file is compiled as 0.0.6",
2522        );
2523    }
2524    let mut out: Vec<(String, Ast<'s>)> = Vec::with_capacity(opts.entries.len());
2525    let mut seen = HashSet::new();
2526    for e in &opts.entries {
2527        if !seen.insert(e.label.name.clone()) {
2528            return err(
2529                e.label.span,
2530                format!(
2531                    "duplicate optional label `{}` in one `?(…)` bundle",
2532                    e.label.name
2533                ),
2534            );
2535        }
2536        out.push((e.label.name.clone(), expr(&e.value.0, scope)?));
2537    }
2538    Ok(out)
2539}
2540
2541/// `a.head`'s recorded full per-position optional-parameter shape (`Scope::
2542/// optional_shape`), for a bare unqualified or module-qualified variable
2543/// head only — any other head shape (a parenthesized expression, a
2544/// dereferenced/accessed value, …) can never name a known `let`/`let ..
2545/// in` binding directly, so it conservatively reports `&[]` (unknown).
2546fn head_optional_shape<'s, 'a>(head: &c::Atomic, scope: &'a Scope<'s>) -> &'a [bool] {
2547    match head {
2548        c::Atomic::Var(v) => scope.optional_shape(&v.name),
2549        c::Atomic::VarWithMod(v) => scope.optional_shape(&qualify_key(&v.mods, &v.name)),
2550        _ => &[],
2551    }
2552}
2553
2554/// `!x` / `!x#a#b` (`nxunsub`'s `UNOP_EXCLAM nxbot` — parser.mly:795,
2555/// `let (rng, varnm) = unop in .. UTApply((rng, UTContentOf([], varnm)),
2556/// utast2)`): the deref operator binds to the atomic head *plus its own
2557/// `#access` chain* — `nxbot` itself folds `ACCESS` left-recursively
2558/// (parser.mly:801, `nxbot ACCESS var`), so `nxunsub`'s `utast2` is already
2559/// the fully-accessed atomic — but never to a *following application
2560/// argument*: `nxapp`'s only production combining an application head with
2561/// more arguments is `nxapp nxunsub` (parser.mly:781), so `!x y` parses as
2562/// `nxapp(nxunsub(!x), y)` = `(!x) y`, not `!(x y)`. This CST's `AppExpr`
2563/// mirrors that split directly: `excl`+`head_accesses` sit on the *head*
2564/// only, `args` is the separate, already-folded-in-elaboration application
2565/// tail — so this helper (used for both an `AppExpr`'s own head and a
2566/// command-argument-chain's head, see `cmd_arg_chain`) elaborates to
2567/// `Apply(Var(excl_text), <head+accesses>)` exactly matching v0.0.6's
2568/// `UTApply` shape above (`varnm` there is always unqualified — this CST has
2569/// no qualified-`!` form either, so no module-mangling applies to it).
2570fn atomic_head_with_excl<'s>(
2571    head: &c::Atomic,
2572    accesses: &[c::AccessSeg],
2573    excl: Option<&UnopExclamTok>,
2574    stage: Option<&c::StagePrefix>,
2575    scope: &Scope<'s>,
2576) -> Result<Ast<'s>, ElabError> {
2577    let mut ast = atomic(head, scope)?;
2578    for acc in accesses {
2579        ast = Ast::AccessField(Box::new(ast), acc.label.name.clone(), acc.label.span);
2580    }
2581    if let Some(e) = excl {
2582        let deref_fn = scoped_var(&e.text, e.span, scope)?;
2583        ast = Ast::Apply(Box::new(deref_fn), Box::new(ast));
2584    }
2585    Ok(match stage {
2586        Some(c::StagePrefix::Next(_)) => Ast::Next(Box::new(ast)),
2587        Some(c::StagePrefix::Prev(_)) => Ast::Prev(Box::new(ast)),
2588        None => ast,
2589    })
2590}
2591
2592/// Desugar one application-chain argument. `?: value`/`?*` (`AppArg::Optional`/
2593/// `Omission` — v0.0.6's `UTApplyOptional`/`UTApplyOmission`) desugar
2594/// *untyped*, straight to the same `option` constructors a program could
2595/// spell by hand: a supplied `?:(e)` becomes `Some(e)`, an omitted `?*`
2596/// becomes `None`. This is the one runtime model shared by every
2597/// optional-arg call site this port supports — a plain function's `f
2598/// ?:(e)`/`f ?*` *and* a command's leading `narg`s (`cst.rs`'s
2599/// `CmdTail::Args`, whose elements are ALSO `AppArg`s) both go through this
2600/// same function — so `Some`/`None` is what a `?->`-typed function's
2601/// `option`-wrapped domain (`typecheck.rs`'s `lower_type_expr`) must unify
2602/// against. No type-directed insertion is needed: every optional slot this
2603/// grammar can produce carries an explicit `?:`/`?*` marker at the call
2604/// site, so elaboration alone fully resolves it.
2605fn app_arg_to_ast<'s>(arg: &c::AppArg, scope: &Scope<'s>) -> Result<Ast<'s>, ElabError> {
2606    match arg {
2607        c::AppArg::Optional { value, .. } => {
2608            let inner = atomic(value, scope)?;
2609            Ok(Ast::Ctor("Some".to_string(), Some(Box::new(inner))))
2610        }
2611        c::AppArg::Omission(_) => Ok(Ast::Ctor("None".to_string(), None)),
2612        c::AppArg::Atom {
2613            stage,
2614            excl,
2615            atom,
2616            accesses,
2617        } => atomic_head_with_excl(atom, accesses, excl.as_ref(), stage.as_ref(), scope),
2618        c::AppArg::Ctor(ctor) => Ok(Ast::Ctor(ctor.name.clone(), None)),
2619        // A `?(l = e, …)` bundle is not a plain argument value — the chain
2620        // builder (`apply_one_arg`) routes it to `Ast::ApplyOpt` before it
2621        // ever reaches here. Reaching this arm means a bundle sat where only
2622        // a value can go (e.g. as a constructor payload).
2623        c::AppArg::Bundled { opts, .. } | c::AppArg::BundledCtor { opts, .. } => err(
2624            opts.q.0,
2625            "a `?(l = e)` labeled-optional bundle cannot be used as a plain \
2626             argument value here",
2627        ),
2628    }
2629}
2630
2631/// Shared machinery for `open Name in body` (`Expr::OpenIn`) and `Name.(body)`
2632/// (`Atomic::OpenModule`, `nxbot`'s `OPENMODULE nxlet RPAREN` production —
2633/// `Mod.(e)` ≡ `open Mod in e`): bring every `"Name."`-prefixed name
2634/// currently in scope into unqualified scope, elaborate `body` under that
2635/// extended scope (via the supplied closure — generic because `Expr::OpenIn`'s
2636/// body is a plain `Expr` but `Atomic::OpenModule`'s is a `ParenBody`, so
2637/// `Mod.(e, e, …)` can produce a tuple exactly like `Atomic::Paren`), then
2638/// wrap the result in one `LetIn` alias per matched name (`x = Name.x`) —
2639/// there is no separate "module scope" at the `Ast` level, so the aliasing
2640/// must be visible there too.
2641fn open_module<'s>(
2642    module_name: &str,
2643    name_span: Span,
2644    scope: &Scope<'s>,
2645    body: impl FnOnce(&Scope<'s>) -> Result<Ast<'s>, ElabError>,
2646) -> Result<Ast<'s>, ElabError> {
2647    let prefix = format!("{module_name}.");
2648    let matches = scope.names_with_prefix(&prefix);
2649    let mut inner = scope.clone();
2650    for q in &matches {
2651        let shape = scope.optional_shape(q).to_vec();
2652        inner.insert_with_shape(&q[prefix.len()..], shape);
2653    }
2654    let body_ast = body(&inner)?;
2655    let mut ast = body_ast;
2656    for q in matches.into_iter().rev() {
2657        let suffix = q[prefix.len()..].to_string();
2658        // `q` may itself be a `Scope::rename` alias rather than a real binding
2659        // key: for a NESTED module opened from a sibling (`Score.(…)` inside
2660        // `module FssFontSelection`, where `Score`'s members are bound under
2661        // the fully-qualified `FssFontSelection.Score.<`), the prefix-matched
2662        // name `Score.<` is only a relative alias registered by
2663        // `walk_bindings`. Resolve it to the actual Ast key so the emitted
2664        // `Var` refers to a binding that exists. For a top-level module the
2665        // rename is identity, so this is a no-op there.
2666        let key = scope.resolve(&q);
2667        ast = Ast::LetIn(
2668            scope.sym(&suffix),
2669            Box::new(Ast::Var(key, name_span)),
2670            Box::new(ast),
2671        );
2672    }
2673    Ok(ast)
2674}
2675
2676fn atomic<'s>(a: &c::Atomic, scope: &Scope<'s>) -> Result<Ast<'s>, ElabError> {
2677    match a {
2678        c::Atomic::Length(l) => match Length::from_unit(l.value, &l.unit) {
2679            Some(len) => Ok(Ast::Length(len)),
2680            None => err(l.span, format!("unknown length unit '{}'", l.unit)),
2681        },
2682        c::Atomic::Float(f) => Ok(Ast::Float(f.value)),
2683        c::Atomic::Int(i) => Ok(Ast::Int(i.value)),
2684        c::Atomic::Literal(l) => Ok(Ast::Str(omit_spaces(l.omit_pre, l.omit_post, &l.body))),
2685        c::Atomic::True(_) => Ok(Ast::Bool(true)),
2686        c::Atomic::False(_) => Ok(Ast::Bool(false)),
2687        // A bare nullary constructor reached as a plain atomic argument
2688        // (the ctor-with-payload case is handled at the `AppExpr` head
2689        // level in `app_expr`, above; it never reaches here).
2690        c::Atomic::Ctor(ctor) => Ok(Ast::Ctor(ctor.name.clone(), None)),
2691        c::Atomic::Var(v) => scoped_var(&v.name, v.span, scope),
2692        c::Atomic::VarWithMod(tok) => {
2693            scoped_var(&qualify_key(&tok.mods, &tok.name), tok.span, scope)
2694        }
2695        // `(+++)`/`(-->)` — a bare reference to a (possibly user-defined)
2696        // operator as a first-class value; resolves exactly like `Var`
2697        // above, under the same name `x +++ y`/`x --> y` (an `OpChain`'s
2698        // `op_chain`, below) would look up.
2699        c::Atomic::OpRef(op) => scoped_var(&op.name, op.span, scope),
2700        // `(command \cmd)` — a first-class reference to an inline command's
2701        // own binding. No new binding machinery: the command's own
2702        // `let-inline` binding is the
2703        // referent, so this is just its `Var` under the same sigil'd key
2704        // `InlineElem::Cmd` resolves — reusing `scoped_var` also gives the
2705        // usual "unbound command" diagnostic for free.
2706        c::Atomic::Command { name, .. } => {
2707            let (key, span) = horz_cmd_key(name);
2708            scoped_var(&key, span, scope)
2709        }
2710        c::Atomic::Unit { .. } => Ok(Ast::Unit),
2711        c::Atomic::Paren { inner, .. } => paren_body(inner, scope),
2712        c::Atomic::OpenModule { grp, body } => {
2713            open_module(&grp.open.name, grp.open.span, scope, |s| {
2714                paren_body(body, s)
2715            })
2716        }
2717        c::Atomic::Record { body, .. } => record_body_to_ast(body, scope),
2718        c::Atomic::List { items, .. } => {
2719            let mut out = Vec::with_capacity(items.len());
2720            for it in items {
2721                out.push(expr(&it.value, scope)?);
2722            }
2723            Ok(Ast::List(out))
2724        }
2725        c::Atomic::InlineText { elems, .. } => inline_text_ast(elems, scope),
2726        c::Atomic::BlockText { elems, .. } => {
2727            Ok(Ast::BlockText(Rc::new(block_elems(elems, scope)?)))
2728        }
2729        c::Atomic::MathText { elems, .. } => math_block_ast(elems, scope),
2730    }
2731}
2732
2733/// `( expr )` → itself; `( expr, expr, … )` → `Ast::Tuple`.
2734fn paren_body<'s>(pb: &c::ParenBody, scope: &Scope<'s>) -> Result<Ast<'s>, ElabError> {
2735    let first = expr(&pb.first, scope)?;
2736    if pb.rest.is_empty() {
2737        Ok(first)
2738    } else {
2739        let mut items = Vec::with_capacity(pb.rest.len() + 1);
2740        items.push(first);
2741        for r in &pb.rest {
2742            items.push(expr(&r.value, scope)?);
2743        }
2744        Ok(Ast::Tuple(items))
2745    }
2746}
2747
2748/// `(| l = e; … |)` → `Ast::Record`; `(| base with l = e; … |)` → a left
2749/// fold of `Ast::UpdateField` over `base` (`nxrecordsynt`, parser.mly:
2750/// 833-840 — `rcd |> List.fold_left (fun utast1 (fldnm, utastF) ->
2751/// UTUpdateField(utast1, fldnm, utastF)) utast`, i.e. exactly one
2752/// `UpdateField` per field, left-to-right, threading the accumulator).
2753fn record_body_to_ast<'s>(body: &c::RecordBody, scope: &Scope<'s>) -> Result<Ast<'s>, ElabError> {
2754    match body {
2755        c::RecordBody::Fields(fields) => {
2756            let mut out = Vec::with_capacity(fields.len());
2757            for f in fields {
2758                out.push((f.name.name.clone(), expr(&f.value, scope)?));
2759            }
2760            Ok(Ast::Record(out))
2761        }
2762        c::RecordBody::Update { base, fields, .. } => {
2763            let mut ast = expr(base, scope)?;
2764            for f in fields {
2765                let v = expr(&f.value, scope)?;
2766                ast = Ast::UpdateField(Box::new(ast), f.name.name.clone(), Box::new(v));
2767            }
2768            Ok(ast)
2769        }
2770    }
2771}
2772
2773// ---- patterns -------------------------------------------------------------
2774
2775/// `patas`: a `PatCons`, plus an optional `as name` binding.
2776fn pattern<'s>(store: &'s SymbolStore, p: &c::Pattern) -> Result<Pattern<'s>, ElabError> {
2777    let head = pat_cons(store, &p.head)?;
2778    match &p.as_clause {
2779        Some(ac) => Ok(Pattern::As(Box::new(head), store.intern(&ac.name.name))),
2780        None => Ok(head),
2781    }
2782}
2783
2784/// `pattr`: `patbot (:: patbot)*`, folded RIGHT (`::` is right-associative):
2785/// `a :: b :: c` → `Cons(a, Cons(b, c))`.
2786fn pat_cons<'s>(store: &'s SymbolStore, pc: &c::PatCons) -> Result<Pattern<'s>, ElabError> {
2787    let mut segs: Vec<&c::PatBot> = Vec::with_capacity(pc.tail.len() + 1);
2788    segs.push(&pc.head);
2789    for seg in &pc.tail {
2790        segs.push(&seg.tail);
2791    }
2792    let mut iter = segs.into_iter().rev();
2793    let last = iter.next().expect("PatCons always has a head");
2794    let mut acc = patbot(store, last)?;
2795    for pb in iter {
2796        acc = Pattern::Cons(Box::new(patbot(store, pb)?), Box::new(acc));
2797    }
2798    Ok(acc)
2799}
2800
2801fn patbot<'s>(store: &'s SymbolStore, pb: &c::PatBot) -> Result<Pattern<'s>, ElabError> {
2802    match pb {
2803        c::PatBot::CtorApplied { ctor, arg } => Ok(Pattern::Ctor(
2804            ctor.name.clone(),
2805            Some(Box::new(patbot(store, arg)?)),
2806        )),
2807        c::PatBot::Ctor(ctor) => Ok(Pattern::Ctor(ctor.name.clone(), None)),
2808        c::PatBot::Int(i) => Ok(Pattern::Int(i.value)),
2809        c::PatBot::True(_) => Ok(Pattern::Bool(true)),
2810        c::PatBot::False(_) => Ok(Pattern::Bool(false)),
2811        c::PatBot::Str(l) => Ok(Pattern::Str(l.body.clone())),
2812        c::PatBot::Wild(_) => Ok(Pattern::Wild),
2813        c::PatBot::Var(v) => Ok(Pattern::Var(store.intern(&v.name))),
2814        c::PatBot::Unit { .. } => Ok(Pattern::Unit),
2815        c::PatBot::Paren { inner, .. } => {
2816            let first = pattern(store, &inner.first)?;
2817            if inner.rest.is_empty() {
2818                Ok(first)
2819            } else {
2820                let mut items = Vec::with_capacity(inner.rest.len() + 1);
2821                items.push(first);
2822                for r in &inner.rest {
2823                    items.push(pattern(store, &r.value)?);
2824                }
2825                Ok(Pattern::Tuple(items))
2826            }
2827        }
2828        c::PatBot::List { items, .. } => {
2829            let mut acc = Pattern::EmptyList;
2830            for it in items.iter().rev() {
2831                acc = Pattern::Cons(Box::new(pattern(store, &it.value)?), Box::new(acc));
2832            }
2833            Ok(acc)
2834        }
2835    }
2836}
2837
2838/// Collect every name a (lowered) pattern binds — `Var` occurrences plus any
2839/// `as name` clauses — so the elaborator can extend the scope for a match
2840/// arm's guard and body.
2841fn collect_pattern_names<'s>(store: &'s SymbolStore, p: &Pattern<'s>, out: &mut Vec<&'s str>) {
2842    match p {
2843        Pattern::Var(n) => out.push(store.resolve(*n)),
2844        Pattern::As(inner, n) => {
2845            collect_pattern_names(store, inner, out);
2846            out.push(store.resolve(*n));
2847        }
2848        Pattern::Tuple(ps) => {
2849            for p in ps {
2850                collect_pattern_names(store, p, out);
2851            }
2852        }
2853        Pattern::Cons(head, tail) => {
2854            collect_pattern_names(store, head, out);
2855            collect_pattern_names(store, tail, out);
2856        }
2857        Pattern::Ctor(_, Some(inner)) => collect_pattern_names(store, inner, out),
2858        Pattern::Wild
2859        | Pattern::Unit
2860        | Pattern::Bool(_)
2861        | Pattern::Int(_)
2862        | Pattern::Str(_)
2863        | Pattern::EmptyList
2864        | Pattern::Ctor(_, None) => {}
2865    }
2866}
2867
2868fn match_arm<'s>(arm: &c::MatchArm, scope: &Scope<'s>) -> Result<MatchArm<'s>, ElabError> {
2869    let pat = pattern(scope.store, &arm.pat)?;
2870    let mut names = Vec::new();
2871    collect_pattern_names(scope.store, &pat, &mut names);
2872    let mut inner = scope.clone();
2873    for n in &names {
2874        inner = inner.with(n);
2875    }
2876    let guard = match &arm.guard {
2877        Some(g) => Some(expr(&g.cond, &inner)?),
2878        None => None,
2879    };
2880    let body = expr(&arm.body, &inner)?;
2881    Ok(MatchArm { pat, guard, body })
2882}
2883
2884// ---- inline/block text ----------------------------------------------------
2885
2886/// `AnyHorzCmdTok`/`AnyVertCmdTok`'s scope key + span: a plain command uses
2887/// its own sigil-inclusive name unchanged; a module-qualified one mangles
2888/// via [`qualify_key`] (see its doc comment on the module name-mangling
2889/// scheme).
2890fn horz_cmd_key(name: &AnyHorzCmdTok) -> (String, Span) {
2891    match name {
2892        AnyHorzCmdTok::Plain(t) => (t.name.clone(), t.span),
2893        AnyHorzCmdTok::Mod(t) => (qualify_key(&t.mods, &t.name), t.span),
2894    }
2895}
2896
2897fn vert_cmd_key(name: &AnyVertCmdTok) -> (String, Span) {
2898    match name {
2899        AnyVertCmdTok::Plain(t) => (t.name.clone(), t.span),
2900        AnyVertCmdTok::Mod(t) => (qualify_key(&t.mods, &t.name), t.span),
2901    }
2902}
2903
2904/// [`AnyMathCmdTok`]'s scope key + span — the math-mode analogue of
2905/// [`horz_cmd_key`]/[`vert_cmd_key`].
2906fn math_cmd_key(name: &AnyMathCmdTok) -> (String, Span) {
2907    match name {
2908        AnyMathCmdTok::Plain(t) => (t.name.clone(), t.span),
2909        AnyMathCmdTok::Mod(t) => (qualify_key(&t.mods, &t.name), t.span),
2910    }
2911}
2912
2913/// An inline-text group's content (`{ .. }`): itemize-aware entry point.
2914/// `sxsep`'s two alternatives (parser.mly:1039-1042) are a `nonempty_list`
2915/// of `*`-headed items (→ `UTItemize`, see [`itemize`]) or plain content;
2916/// since `InlineElem`'s `ItemBullet` markers are kept flat rather than
2917/// grouped in-grammar (see `cst.rs`'s doc comment on `InlineElem`), the
2918/// dispatch happens here instead of in the parser.
2919fn inline_text_ast<'s>(elems: &[c::InlineElem], scope: &Scope<'s>) -> Result<Ast<'s>, ElabError> {
2920    // `{| a | b | … |}` horizontal-LIST literal (`sxlist` in parser.mly): a
2921    // leading `|` (`Sep`) immediately after `{` marks the inline-text-list
2922    // form — a value of type `inline-text list` — distinct from a plain
2923    // `{ … }` inline text. The elements arrive flat and `Sep`-delimited (see
2924    // `InlineElem`'s doc comment); regroup them into one inline-text per cell.
2925    if matches!(elems.first(), Some(c::InlineElem::Sep(_))) {
2926        return inline_text_list(elems, scope);
2927    }
2928    if elems
2929        .iter()
2930        .any(|e| matches!(e, c::InlineElem::ItemBullet(_)))
2931    {
2932        itemize(elems, scope)
2933    } else {
2934        Ok(Ast::InlineText(Rc::new(inline_elems(elems, scope)?)))
2935    }
2936}
2937
2938/// Regroup a `{| a | b | … |}` inline-text-list literal's flat, `Sep`-delimited
2939/// elements (see [`inline_text_ast`]) into an [`Ast::List`] of one inline-text
2940/// per cell. The leading/trailing empty groups produced by the framing `{|`
2941/// and `|}` are structural and dropped; an interior empty group is a real
2942/// empty cell (`{| a | | b |}`). Each cell is elaborated recursively, so a
2943/// cell may itself be an itemize (`{* … }`).
2944fn inline_text_list<'s>(elems: &[c::InlineElem], scope: &Scope<'s>) -> Result<Ast<'s>, ElabError> {
2945    let mut groups: Vec<&[c::InlineElem]> = Vec::new();
2946    let mut start = 0usize;
2947    for (i, e) in elems.iter().enumerate() {
2948        if matches!(e, c::InlineElem::Sep(_)) {
2949            groups.push(&elems[start..i]);
2950            start = i + 1;
2951        }
2952    }
2953    groups.push(&elems[start..]);
2954    if groups.first().is_some_and(|g| g.is_empty()) {
2955        groups.remove(0);
2956    }
2957    if groups.last().is_some_and(|g| g.is_empty()) {
2958        groups.pop();
2959    }
2960    let mut items = Vec::with_capacity(groups.len());
2961    for g in groups {
2962        items.push(inline_text_ast(g, scope)?);
2963    }
2964    Ok(Ast::List(items))
2965}
2966
2967/// Coalesce chars/spaces/breaks into text runs; commands become
2968/// `IText::Cmd`; `#var;` embeds become `IText::Embed`; `${..}` embeds become
2969/// `IText::EmbedMath`. Never sees an `ItemBullet` in a well-formed call (the
2970/// itemize splitter in [`itemize`] always calls this on a bullet-free
2971/// slice) — one showing up here is reported as an error rather than
2972/// panicking, since a defensive diagnostic is friendlier than a panic even
2973/// though the shape should be unreachable.
2974fn inline_elems<'s>(
2975    elems: &[c::InlineElem],
2976    scope: &Scope<'s>,
2977) -> Result<Vec<IText<'s>>, ElabError> {
2978    let mut out = Vec::new();
2979    let mut text = String::new();
2980    for el in elems {
2981        match el {
2982            c::InlineElem::Char(ch) => text.push_str(&ch.text),
2983            c::InlineElem::CodeText(t) => {
2984                if !text.is_empty() {
2985                    out.push(IText::Text(std::mem::take(&mut text)));
2986                }
2987                out.push(IText::CodeText(t.text.clone()));
2988            }
2989            c::InlineElem::Space(_) => text.push(' '),
2990            c::InlineElem::Break(_) => text.push('\n'),
2991            c::InlineElem::Cmd { name, tail } => {
2992                if !text.is_empty() {
2993                    out.push(IText::Text(std::mem::take(&mut text)));
2994                }
2995                let (key, span) = horz_cmd_key(name);
2996                if !scope.contains(&key) {
2997                    return err(span, format!("unbound inline command '{key}'"));
2998                }
2999                out.push(IText::Cmd {
3000                    name: scope.resolve(&key),
3001                    span,
3002                    args: cmd_args(tail, scope, scope.optional_shape(&key))?,
3003                });
3004            }
3005            c::InlineElem::Embed { var, .. } => {
3006                if !text.is_empty() {
3007                    out.push(IText::Text(std::mem::take(&mut text)));
3008                }
3009                let key = qualify_key(&var.mods, &var.name);
3010                if !scope.contains(&key) {
3011                    return err(var.span, format!("unbound variable '{key}'"));
3012                }
3013                out.push(IText::Embed {
3014                    expr: Ast::Var(scope.resolve(&key), var.span),
3015                    span: var.span,
3016                });
3017            }
3018            c::InlineElem::EmbedMath { mgrp, elems } => {
3019                if !text.is_empty() {
3020                    out.push(IText::Text(std::mem::take(&mut text)));
3021                }
3022                if let Some(first) = elems.first() {
3023                    if let c::MathBot::Sep(tok) = &first.base {
3024                        return err(tok.0, "a '|'-separated math list cannot be embedded directly in inline text: `${| … |}` here would be a `math list`, but an embedded formula must be a single `math`");
3025                    }
3026                }
3027                let span = mgrp.open.0.unite(mgrp.close.0);
3028                out.push(IText::EmbedMath {
3029                    elems: Rc::new(lower_math_elems(elems, scope)?),
3030                    span,
3031                });
3032            }
3033            c::InlineElem::ItemBullet(tok) => {
3034                return err(
3035                    tok.span,
3036                    "unexpected itemize bullet '*' outside a bullet list",
3037                );
3038            }
3039            c::InlineElem::Sep(tok) => {
3040                return err(tok.0, "'|' separator is not supported here yet");
3041            }
3042        }
3043    }
3044    if !text.is_empty() {
3045        out.push(IText::Text(text));
3046    }
3047    Ok(out)
3048}
3049
3050fn block_elems<'s>(elems: &[c::BlockElem], scope: &Scope<'s>) -> Result<Vec<BText<'s>>, ElabError> {
3051    let mut out = Vec::with_capacity(elems.len());
3052    for el in elems {
3053        match el {
3054            c::BlockElem::Cmd { name, tail } => {
3055                let (key, span) = vert_cmd_key(name);
3056                if !scope.contains(&key) {
3057                    return err(span, format!("unbound block command '{key}'"));
3058                }
3059                out.push(BText::Cmd {
3060                    name: scope.resolve(&key),
3061                    span,
3062                    args: cmd_args(tail, scope, scope.optional_shape(&key))?,
3063                });
3064            }
3065            c::BlockElem::Embed { var, .. } => {
3066                let key = qualify_key(&var.mods, &var.name);
3067                if !scope.contains(&key) {
3068                    return err(var.span, format!("unbound variable '{key}'"));
3069                }
3070                out.push(BText::Embed {
3071                    expr: Ast::Var(scope.resolve(&key), var.span),
3072                    span: var.span,
3073                });
3074            }
3075        }
3076    }
3077    Ok(out)
3078}
3079
3080/// Flatten a command tail back into its argument list. `CmdTail::Args` is a
3081/// flat, non-empty `AppArg` sequence (`cst.rs`'s own dedicated grammar, not
3082/// a reuse of the general application chain), so this is just
3083/// `cmd_arg_to_ast` per element; a supplied/omitted optional (`?:`/`?*`)
3084/// desugars to `Some`/`None` exactly like a plain function's optional
3085/// application (`app_arg_to_ast`'s doc comment). A `?(l = e, …)`-bundled
3086/// element carries its labels on the
3087/// returned [`CmdArg`]'s `opts` instead of desugaring to `Some`/`None` — the
3088/// 0.0.6 leading-padding loop below only ever matches `Optional`/`Omission`,
3089/// never `Bundled`/`BundledCtor`, and a V0_1 command's `leading` is always
3090/// `0`, so the two mechanisms never interact.
3091fn cmd_args<'s>(
3092    tail: &c::CmdTail,
3093    scope: &Scope<'s>,
3094    shape: &[bool],
3095) -> Result<Vec<CmdArg<'s>>, ElabError> {
3096    let args: Vec<&c::AppArg> = match tail {
3097        c::CmdTail::Semi(_) => Vec::new(),
3098        c::CmdTail::Args { first, rest, .. } => {
3099            let mut v: Vec<&c::AppArg> = Vec::with_capacity(1 + rest.len());
3100            v.push(first);
3101            for a in rest {
3102                v.push(a);
3103            }
3104            v
3105        }
3106    };
3107    // Marker-less optional-argument defaulting against the command's declared
3108    // `Param` shape — the command-argument twin of `app_chain_generic`'s
3109    // algorithm (see its comment for the slot-by-slot rule; e.g. `enumitem`'s
3110    // `+item : [cfg?; inline-text; cfg?; block-text]` has an optional after a
3111    // mandatory argument). Unlike that one, a command's arity is fixed by its
3112    // declared type, so a trailing omitted optional IS filled with `None`
3113    // rather than left uncurried.
3114    let mut out = Vec::with_capacity(args.len().max(shape.len()));
3115    let mut args_iter = args.into_iter().peekable();
3116    let mut pos = 0usize;
3117    while pos < shape.len() {
3118        if shape[pos] {
3119            match args_iter.peek() {
3120                Some(c::AppArg::Optional { .. }) | Some(c::AppArg::Omission(_)) => {
3121                    out.push(CmdArg {
3122                        opts: Vec::new(),
3123                        arg: app_arg_to_ast(args_iter.next().unwrap(), scope)?,
3124                    });
3125                }
3126                _ => out.push(CmdArg {
3127                    opts: Vec::new(),
3128                    arg: Ast::Ctor("None".to_string(), None),
3129                }),
3130            }
3131        } else {
3132            match args_iter.next() {
3133                Some(a) => out.push(cmd_arg_to_ast(a, scope)?),
3134                None => break,
3135            }
3136        }
3137        pos += 1;
3138    }
3139    for a in args_iter {
3140        out.push(cmd_arg_to_ast(a, scope)?);
3141    }
3142    Ok(out)
3143}
3144
3145/// One command-application argument, the `?(l = e, …)`-bundle-aware twin of
3146/// `app_arg_to_ast`: a `Bundled`/
3147/// `BundledCtor` arg becomes a [`CmdArg`] whose `opts` carries the labeled
3148/// optionals — elaborated via `elaborate_opt_args`, exactly like a plain
3149/// function application's `f ?(l = e) x` (`apply_one_arg`'s `Ast::ApplyOpt`
3150/// arm); every other `AppArg` shape becomes a `CmdArg` with empty `opts` (the
3151/// unbundled call — the ONLY shape every
3152/// 0.0.6-reachable call ever emits).
3153fn cmd_arg_to_ast<'s>(arg: &c::AppArg, scope: &Scope<'s>) -> Result<CmdArg<'s>, ElabError> {
3154    match arg {
3155        c::AppArg::Bundled {
3156            opts,
3157            excl,
3158            atom,
3159            accesses,
3160        } => Ok(CmdArg {
3161            opts: elaborate_opt_args(opts, scope)?,
3162            arg: atomic_head_with_excl(atom, accesses, excl.as_ref(), None, scope)?,
3163        }),
3164        c::AppArg::BundledCtor { opts, ctor } => Ok(CmdArg {
3165            opts: elaborate_opt_args(opts, scope)?,
3166            arg: Ast::Ctor(ctor.name.clone(), None),
3167        }),
3168        _ => Ok(CmdArg {
3169            opts: Vec::new(),
3170            arg: app_arg_to_ast(arg, scope)?,
3171        }),
3172    }
3173}
3174
3175// ---- itemize ---------------------------------------------------------------
3176
3177/// One node of the itemize tree being built, before it is lowered to the
3178/// `Ctor("Item", ..)` value shape by [`item_node_to_ast`].
3179struct ItemNode<'s> {
3180    text: Ast<'s>,
3181    children: Vec<ItemNode<'s>>,
3182}
3183
3184fn inline_elem_span(el: &c::InlineElem) -> Span {
3185    match el {
3186        c::InlineElem::Char(t) => t.span,
3187        c::InlineElem::CodeText(t) => t.span,
3188        c::InlineElem::Space(t) => t.0,
3189        c::InlineElem::Break(t) => t.0,
3190        c::InlineElem::Embed { var, .. } => var.span,
3191        c::InlineElem::EmbedMath { mgrp, .. } => mgrp.open.0.unite(mgrp.close.0),
3192        c::InlineElem::Cmd { name, .. } => horz_cmd_key(name).1,
3193        c::InlineElem::ItemBullet(t) => t.span,
3194        c::InlineElem::Sep(t) => t.0,
3195    }
3196}
3197
3198/// Consecutive `ItemBullet`-headed runs of an inline-text group elaborate to
3199/// a single itemize `Ctor("Item", (text, list))` tree instead of plain
3200/// `InlineText` — transcribed from `parser.mly`'s `make_list_to_itemize`/
3201/// `insert_last` (lines 331-356) and `typecheck_itemize`/`typecheck_itemize_list`
3202/// (typechecker.ml:1359-1374, which lower each `UTItem(utast1, utitmzlst)`
3203/// node to `NonValueConstructor("Item", PrimitiveTuple([e1; e2]))` — the
3204/// `Item` constructor's `(inline-text * itemize list)` payload shape from
3205/// `primitives.cppo.ml:159`).
3206fn itemize<'s>(elems: &[c::InlineElem], scope: &Scope<'s>) -> Result<Ast<'s>, ElabError> {
3207    // `sxsep`'s itemize alternative is `nonempty_list(sxitem)` (parser.mly:
3208    // 1042) — i.e. the *whole* group must be bullets-and-their-content, no
3209    // leading plain text before the first bullet.
3210    let mut i = 0;
3211    while i < elems.len() && !matches!(elems[i], c::InlineElem::ItemBullet(_)) {
3212        i += 1;
3213    }
3214    if i != 0 {
3215        return err(
3216            inline_elem_span(&elems[0]),
3217            "content before the first itemize bullet '*' is not supported",
3218        );
3219    }
3220    let mut segments: Vec<(usize, Span, &[c::InlineElem])> = Vec::new();
3221    while i < elems.len() {
3222        let (depth, span) = match &elems[i] {
3223            c::InlineElem::ItemBullet(tok) => (tok.depth, tok.span),
3224            _ => unreachable!("loop invariant: elems[i] is always an ItemBullet here"),
3225        };
3226        let start = i + 1;
3227        let mut j = start;
3228        while j < elems.len() && !matches!(elems[j], c::InlineElem::ItemBullet(_)) {
3229            j += 1;
3230        }
3231        segments.push((depth, span, &elems[start..j]));
3232        i = j;
3233    }
3234    // `make_list_to_itemize_sub`'s accumulator starts as a dummy root item
3235    // with empty inline text and no children (parser.mly:332,
3236    // `UTItem((.., UTInputHorz([])), [])`).
3237    let mut root = ItemNode {
3238        text: Ast::InlineText(Rc::new(Vec::new())),
3239        children: Vec::new(),
3240    };
3241    let mut crrntdp = 0usize;
3242    for (depth, span, content) in segments {
3243        if depth > crrntdp + 1 {
3244            return err(span, format!("illegal item depth {depth} after {crrntdp}"));
3245        }
3246        let text_ast = Ast::InlineText(Rc::new(inline_elems(content, scope)?));
3247        insert_last(&mut root, 1, depth, text_ast);
3248        crrntdp = depth;
3249    }
3250    Ok(item_node_to_ast(root))
3251}
3252
3253/// `insert_last` (parser.mly:346-356), simplified: the OCaml version rebuilds
3254/// an immutable list by peeling `hditmz :: tlitmzlst` heads into an
3255/// accumulator until exactly one child remains, then either recurses into it
3256/// (if not yet at the target depth) or appends a new sibling after it —
3257/// which is equivalent (and much simpler to transcribe with a mutable tree)
3258/// to just always operating on `node.children`'s *last* element: recurse
3259/// into it while `i < depth`, otherwise push a new sibling leaf.
3260fn insert_last<'s>(node: &mut ItemNode<'s>, i: usize, depth: usize, new_text: Ast<'s>) {
3261    if node.children.is_empty() {
3262        node.children.push(ItemNode {
3263            text: new_text,
3264            children: Vec::new(),
3265        });
3266        return;
3267    }
3268    if i < depth {
3269        insert_last(node.children.last_mut().unwrap(), i + 1, depth, new_text);
3270    } else {
3271        node.children.push(ItemNode {
3272            text: new_text,
3273            children: Vec::new(),
3274        });
3275    }
3276}
3277
3278fn item_node_to_ast<'s>(node: ItemNode<'s>) -> Ast<'s> {
3279    let children = Ast::List(node.children.into_iter().map(item_node_to_ast).collect());
3280    Ast::Ctor(
3281        "Item".to_string(),
3282        Some(Box::new(Ast::Tuple(vec![node.text, children]))),
3283    )
3284}
3285
3286// ---- quoted math ------------------------------------------------------------
3287
3288fn lower_math_elems<'s>(
3289    elems: &[cst::MathErased],
3290    scope: &Scope<'s>,
3291) -> Result<Vec<MathElem<'s>>, ElabError> {
3292    elems.iter().map(|e| math_elem_cst(e, scope)).collect()
3293}
3294
3295/// `mathblock` (parser.mly:1059-1066): a LEADING `|` puts the math area in
3296/// list mode — `${| m | m |}` is upstream-desugared in-grammar to an
3297/// ordinary list literal of `math` values (make_cons over UTMath; there is
3298/// NO matrix/grid node anywhere in the frontend or math backend) —
3299/// otherwise the area is one plain `math` (today's single-MathText path).
3300/// Edge cases replicated exactly: list mode triggers only on a LEADING `|`
3301/// (`${a|b}` is upstream a parse error); the trailing `|` is mandatory
3302/// (`${|a|b}` rejected); `${|}` = empty list, `${||}` = one empty cell;
3303/// `|` never carries scripts. Split is over the flat erased stream so the
3304/// sibling inline `{| … |}` (sxsep) can reuse it later.
3305fn math_block_ast<'s>(elems: &[cst::MathErased], scope: &Scope<'s>) -> Result<Ast<'s>, ElabError> {
3306    let leading_sep = matches!(elems.first(), Some(e) if matches!(&e.base, c::MathBot::Sep(_)));
3307    if !leading_sep {
3308        return Ok(Ast::MathText(Rc::new(lower_math_elems(elems, scope)?)));
3309    }
3310    for e in elems {
3311        if let c::MathBot::Sep(tok) = &e.base {
3312            if !e.scripts.is_empty() {
3313                return err(
3314                    tok.0,
3315                    "a '|' math-list separator cannot carry a script ('^'/'_'/primes)",
3316                );
3317            }
3318        }
3319    }
3320    if !matches!(elems.last(), Some(e) if matches!(&e.base, c::MathBot::Sep(_))) {
3321        let c::MathBot::Sep(first) = &elems[0].base else {
3322            unreachable!()
3323        };
3324        return err(
3325            first.0,
3326            "a '|'-separated math list must end with a trailing '|' (write `${| a | b |}`)",
3327        );
3328    }
3329    let mut segments: Vec<Ast<'s>> = Vec::new();
3330    let mut seg_start = 1usize;
3331    for (i, e) in elems.iter().enumerate().skip(1) {
3332        if matches!(&e.base, c::MathBot::Sep(_)) {
3333            let seg = &elems[seg_start..i];
3334            segments.push(Ast::MathText(Rc::new(lower_math_elems(seg, scope)?)));
3335            seg_start = i + 1;
3336        }
3337    }
3338    Ok(Ast::List(segments))
3339}
3340
3341fn math_elem_cst<'s>(m: &c::MathElemCst, scope: &Scope<'s>) -> Result<MathElem<'s>, ElabError> {
3342    let base = math_bot(&m.base, scope)?;
3343    fold_math_scripts(base, &m.scripts, scope)
3344}
3345
3346fn math_bot<'s>(b: &c::MathBot, scope: &Scope<'s>) -> Result<MathElem<'s>, ElabError> {
3347    match b {
3348        c::MathBot::Cmd { name, args } => {
3349            let (key, span) = math_cmd_key(name);
3350            if !scope.contains(&key) {
3351                return err(span, format!("unbound math command '{key}'"));
3352            }
3353            // Marker-less optional defaulting — the command mirror of
3354            // app_chain_generic (upstream typecheck_command_arguments skips
3355            // optional slots left unmarked). No non-empty-args guard: a
3356            // MathElem::Cmd is always an application (a bare command VALUE is
3357            // `command \cmd`), so `${\cmd}` with `[t?] math-cmd` pads too.
3358            let leading = scope.optional_arity(&key);
3359            let mut arg_asts = Vec::with_capacity(args.len().max(leading));
3360            let mut args_iter = args.iter().peekable();
3361            let mut supplied = 0;
3362            while supplied < leading {
3363                match args_iter.peek() {
3364                    Some(c::MathArg::Optional { .. }) | Some(c::MathArg::Omission(_)) => {
3365                        arg_asts.push(math_arg_to_ast(args_iter.next().unwrap(), scope)?);
3366                        supplied += 1;
3367                    }
3368                    _ => break,
3369                }
3370            }
3371            for _ in supplied..leading {
3372                arg_asts.push(Ast::Ctor("None".to_string(), None));
3373            }
3374            for a in args_iter {
3375                arg_asts.push(math_arg_to_ast(a, scope)?);
3376            }
3377            // `CmdArg`-shaped for uniformity with `IText::Cmd`/`BText::Cmd`
3378            // (see `MathElem::Cmd`'s doc
3379            // comment) — `opts` is always empty: the math-mode application
3380            // grammar (`c::MathArg`) has no `?(l=e)` bundle form at all.
3381            Ok(MathElem::Cmd {
3382                name: scope.resolve(&key),
3383                span,
3384                args: arg_asts
3385                    .into_iter()
3386                    .map(|arg| CmdArg { opts: Vec::new(), arg })
3387                    .collect(),
3388            })
3389        }
3390        c::MathBot::Chars(tok) => Ok(MathElem::Chars(tok.text.clone())),
3391        c::MathBot::Embed(tok) => {
3392            // Math commands are qualified via `math_cmd_key` the same way
3393            // `horz_cmd_key`/`vert_cmd_key` handle `\Mod.cmd`; `#var`/
3394            // `#Mod.var` embeds already carry a `mods` list (`VarInMathTok`),
3395            // so those are mangled the same as everywhere else.
3396            let key = qualify_key(&tok.mods, &tok.name);
3397            if !scope.contains(&key) {
3398                return err(tok.span, format!("unbound variable '{key}'"));
3399            }
3400            Ok(MathElem::Embed {
3401                expr: Ast::Var(scope.resolve(&key), tok.span),
3402                span: tok.span,
3403            })
3404        }
3405        c::MathBot::Sep(tok) => err(tok.0, "'|' builds a math list and may only be used when the math area starts with '|' (e.g. `${| a | b |}`); it cannot appear mid-formula or inside a `{ … }` math group"),
3406        c::MathBot::Group { elems, .. } => Ok(MathElem::Group(lower_math_elems(elems, scope)?)),
3407    }
3408}
3409
3410fn math_group_arg<'s>(
3411    g: &c::MathGroupArg,
3412    scope: &Scope<'s>,
3413) -> Result<Vec<MathElem<'s>>, ElabError> {
3414    match g {
3415        c::MathGroupArg::Group { elems, .. } => lower_math_elems(elems, scope),
3416        c::MathGroupArg::Bot(b) => Ok(vec![math_bot(b, scope)?]),
3417    }
3418}
3419
3420/// `mathtop`'s seven script-combo alternatives (parser.mly:1078-1116),
3421/// folded left over `MathElemCst`'s flat `scripts` vector (see its doc
3422/// comment in `cst.rs`). Combos with only a subscript, only a superscript,
3423/// or a sub+superscript pair (either written order) transcribe exactly:
3424/// whichever token is spelled `SUBSCRIPT` becomes the inner `Sub` operand
3425/// and `SUPERSCRIPT` the outer `Sup`, regardless of source order
3426/// (parser.mly's rules 3 and 5 both produce `Sup(Sub(base,subgrp),supgrp)`).
3427///
3428/// **Deviation for `PRIMES` combined with an explicit script** (rules 4 and
3429/// 6): v0.0.6 encodes primes-plus-script by *reusing* the
3430/// `UTMSubScript`/`UTMSuperScript` nodes as an internal slot-assignment
3431/// trick, so one rendering routine can lay out the prime mark and the real
3432/// script in one corner-glyph slot (rule 4 puts primes in `Sub` and the
3433/// explicit `^group` in the outer `Sup`; rule 6 swaps them — an explicit
3434/// script and primes trade slots depending on which was explicit). This
3435/// port's `MathElem` has a *distinct* `Primes(base, count)` node with no
3436/// v0.0.6 counterpart, so there's no slot to reuse; primes fold in as their
3437/// own step and any immediately-following explicit script applies on top of
3438/// that, in source order — same information (script, count, shared base)
3439/// without the internal rendering hack, which has no meaning yet anyway
3440/// (typesetting is deferred).
3441fn fold_math_scripts<'s>(
3442    base: MathElem<'s>,
3443    scripts: &[c::MathScript],
3444    scope: &Scope<'s>,
3445) -> Result<MathElem<'s>, ElabError> {
3446    let mut acc = base;
3447    let mut i = 0;
3448    while i < scripts.len() {
3449        match &scripts[i] {
3450            c::MathScript::Sub { group, .. } => {
3451                if let Some(c::MathScript::Super { group: g2, .. }) = scripts.get(i + 1) {
3452                    let subg = math_group_arg(group, scope)?;
3453                    let supg = math_group_arg(g2, scope)?;
3454                    acc = MathElem::Sup(Box::new(MathElem::Sub(Box::new(acc), subg)), supg);
3455                    i += 2;
3456                } else {
3457                    let subg = math_group_arg(group, scope)?;
3458                    acc = MathElem::Sub(Box::new(acc), subg);
3459                    i += 1;
3460                }
3461            }
3462            c::MathScript::Super { group, .. } => {
3463                if let Some(c::MathScript::Sub { group: g2, .. }) = scripts.get(i + 1) {
3464                    let supg = math_group_arg(group, scope)?;
3465                    let subg = math_group_arg(g2, scope)?;
3466                    acc = MathElem::Sup(Box::new(MathElem::Sub(Box::new(acc), subg)), supg);
3467                    i += 2;
3468                } else {
3469                    let supg = math_group_arg(group, scope)?;
3470                    acc = MathElem::Sup(Box::new(acc), supg);
3471                    i += 1;
3472                }
3473            }
3474            c::MathScript::Primes(tok) => {
3475                acc = MathElem::Primes(Box::new(acc), tok.count);
3476                i += 1;
3477            }
3478        }
3479    }
3480    Ok(acc)
3481}
3482
3483/// `matharg` (parser.mly:1138-1146 + narg 1201-1210): `?:`-supplied desugars
3484/// to `Some(<body>)`, `?*` to `None` — the math-command mirror of
3485/// `app_arg_to_ast`'s `AppArg::Optional`/`Omission` arms. A mandatory
3486/// (`Plain`) argument elaborates its body directly with no wrapping.
3487fn math_arg_to_ast<'s>(arg: &c::MathArg, scope: &Scope<'s>) -> Result<Ast<'s>, ElabError> {
3488    match arg {
3489        c::MathArg::Plain(body) => math_arg_body_to_ast(body, scope),
3490        c::MathArg::Optional { body, .. } => Ok(Ast::Ctor(
3491            "Some".to_string(),
3492            Some(Box::new(math_arg_body_to_ast(body, scope)?)),
3493        )),
3494        c::MathArg::Omission(_) => Ok(Ast::Ctor("None".to_string(), None)),
3495    }
3496}
3497
3498/// The six `matharg` body shapes (`cst.rs`'s `MathArgBody` doc comment):
3499/// recurse into math (`Math`), program-mode escapes (`!(..)`/`![..]`/
3500/// `!(|..|)`, elaborated exactly like their `Atomic`/`Expr` counterparts), or
3501/// inline/block text escapes (`!{..}`/`!<..>`, elaborated to
3502/// `InlineText`/`BlockText` Asts).
3503fn math_arg_body_to_ast<'s>(
3504    body: &c::MathArgBody,
3505    scope: &Scope<'s>,
3506) -> Result<Ast<'s>, ElabError> {
3507    match body {
3508        c::MathArgBody::Math { elems, .. } => math_block_ast(elems, scope),
3509        c::MathArgBody::Inline { elems, .. } => inline_text_ast(elems, scope),
3510        c::MathArgBody::Block { elems, .. } => {
3511            Ok(Ast::BlockText(Rc::new(block_elems(elems, scope)?)))
3512        }
3513        c::MathArgBody::ParenEscape { inner, .. } => paren_body(inner, scope),
3514        c::MathArgBody::ListEscape { items, .. } => {
3515            let mut out = Vec::with_capacity(items.len());
3516            for it in items {
3517                out.push(expr(&it.value, scope)?);
3518            }
3519            Ok(Ast::List(out))
3520        }
3521        c::MathArgBody::RecordEscape { body, .. } => record_body_to_ast(body, scope),
3522    }
3523}
3524
3525// ---- string-literal space omission -----------------------------------------
3526
3527/// `omit_spaces`/`omit_pre_spaces`/`omit_post_spaces`/`min_indent_space`/
3528/// `shave_indent` (parser.mly's header section, lines 72-152), transcribed
3529/// faithfully (byte-for-byte algorithm, but over `char`s rather than bytes so
3530/// it stays correct on non-ASCII source text — the original indexes
3531/// `String.length`/`String.sub` byte-wise, which coincides with `char`-wise
3532/// indexing everywhere the original relies on it, since it only ever tests
3533/// for `' '`/`'\n'`, both single-byte in UTF-8).
3534fn omit_spaces(omit_pre: bool, omit_post: bool, raw: &str) -> String {
3535    let s1 = if omit_pre {
3536        omit_pre_spaces(raw)
3537    } else {
3538        raw.to_string()
3539    };
3540    let s2 = if omit_post { omit_post_spaces(&s1) } else { s1 };
3541    let min_indent = min_indent_space(&s2);
3542    let shaved = shave_indent(&s2, min_indent);
3543    let mut chars: Vec<char> = shaved.chars().collect();
3544    if chars.last() == Some(&'\n') {
3545        chars.pop();
3546    }
3547    chars.into_iter().collect()
3548}
3549
3550/// Strip every leading `' '` (not `'\n'` or other whitespace).
3551fn omit_pre_spaces(s: &str) -> String {
3552    s.trim_start_matches(' ').to_string()
3553}
3554
3555/// Strip trailing `' '`s; once a `'\n'` is reached, strip that single
3556/// newline and stop (no further recursion past it).
3557fn omit_post_spaces(s: &str) -> String {
3558    let mut chars: Vec<char> = s.chars().collect();
3559    loop {
3560        match chars.last() {
3561            Some(' ') => {
3562                chars.pop();
3563            }
3564            Some('\n') => {
3565                chars.pop();
3566                break;
3567            }
3568            _ => break,
3569        }
3570    }
3571    chars.into_iter().collect()
3572}
3573
3574/// The minimum leading-space count of every line (including the very first,
3575/// since `min_indent_space_sub`'s initial state is `ReadingSpace`, not
3576/// `Normal` — so unlike every *subsequent* line, the first line's leading
3577/// spaces count even without a preceding `'\n'`). A line consisting only of
3578/// spaces does not update the minimum ("does not take space-only line into
3579/// account").
3580fn min_indent_space(s: &str) -> usize {
3581    let chars: Vec<char> = s.chars().collect();
3582    let mut reading_space = true;
3583    let mut spnum = 0usize;
3584    let mut minspnum = chars.len();
3585    for ch in chars {
3586        if reading_space {
3587            match ch {
3588                ' ' => spnum += 1,
3589                '\n' => spnum = 0,
3590                _ => {
3591                    if spnum < minspnum {
3592                        minspnum = spnum;
3593                    }
3594                    reading_space = false;
3595                }
3596            }
3597        } else if ch == '\n' {
3598            reading_space = true;
3599            spnum = 0;
3600        }
3601    }
3602    minspnum
3603}
3604
3605fn shave_indent(s: &str, minspnum: usize) -> String {
3606    let mut out = String::new();
3607    let mut reading_space = false;
3608    let mut spnum = 0usize;
3609    for ch in s.chars() {
3610        if reading_space {
3611            match ch {
3612                ' ' => {
3613                    if spnum >= minspnum {
3614                        out.push(' ');
3615                    }
3616                    spnum += 1;
3617                }
3618                '\n' => {
3619                    out.push('\n');
3620                    spnum = 0;
3621                }
3622                _ => {
3623                    out.push(ch);
3624                    reading_space = false;
3625                }
3626            }
3627        } else if ch == '\n' {
3628            out.push('\n');
3629            reading_space = true;
3630            spnum = 0;
3631        } else {
3632            out.push(ch);
3633        }
3634    }
3635    out
3636}