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, ¶m_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}