Skip to main content

elly_core/
ast.rs

1//! The Elly AST and its s-expression dump.
2//!
3//! `to_sexp` emits a compact parenthesized form used by the parse golden
4//! tests so they read close to `docs/elly-spec.md`. Leaves own their text as
5//! [`Text`] (a cheap-clone inline/`Rc` handle), so the AST is `'static` — no
6//! longer tied to the Muon source lifetime; structure is `alloc`ated.
7//!
8//! **Patterns are first-class AST** (see `docs/done/2026-07-26_elly-patterns-native.md`):
9//! an `&`-abstraction, a `let` binding, and a `__match` clause all bind through
10//! the same [`Pattern`] node, which the evaluator interprets directly (a native
11//! matcher, not a lowering to eliminator builtins). The AST *rephrases* Muon
12//! into Elly's own syntax; it does not *compile* patterns into anything else —
13//! that is a later (VM) pass's job.
14
15use alloc::boxed::Box;
16use alloc::format;
17use alloc::rc::Rc;
18use alloc::string::{String, ToString};
19use alloc::vec::Vec;
20
21use num_bigint::BigInt;
22
23use crate::eval::{Builtin, ModuleData};
24
25/// An owned, cheap-clone text leaf — hipstr's single-threaded `Local` (`Rc`)
26/// backend: **inline** for short strings (≤ 23 bytes on 64-bit), else a shared
27/// `Rc<str>` slice, an **O(1) clone** either way. Replaces the former `&'a str`
28/// source borrow so `Expr`/`Value` are `'static` and no longer tied to the Muon
29/// source (see `docs/todo/elly-impl-expr-repr-v1.md`). A `'static` host tag is
30/// stored for free via [`Text::from_static`](hipstr::string::HipStr::from_static).
31pub type Text = hipstr::LocalHipStr<'static>;
32
33/// An Elly expression in the first subset. Strings and records are deferred
34/// (see the spec), so there is no variant for them yet.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum Expr {
37    /// An arbitrary-precision integer literal, parsed from the token (`0`, `-123`,
38    /// `0xCAFE`); see the *integers* section of the spec.
39    Int(BigInt),
40    /// A runtime list literal from a Muon `<list>` (`[…]`), of *any* arity: `[]`
41    /// (the empty list, which is unit), `[e]` (a genuine 1-list, distinct from
42    /// `e`), or `[e0, e1, …]`. Parens `(…)` never produce this — they are
43    /// grouping / argument spread at parse time (see `parse.rs`).
44    List(Vec<Expr>),
45    /// A bare name: a reference to an enclosing `&`-binding, else a free name.
46    /// Produced by the parser as the *unresolved* form; the resolution pass
47    /// (`resolve.rs`) rewrites every in-scope `Name` into a [`Expr::Local`] and
48    /// rejects the rest. Tooling that wants the raw, pre-resolution tree
49    /// (`parse_open`) keeps seeing `Name`; the evaluator only ever sees `Local`.
50    Name(Text),
51    /// A **resolved** reference: a flat de Bruijn `index` produced by the resolver
52    /// from a [`Expr::Name`]. The environment is a nameless cons list of bindings
53    /// (one cell per bound name, innermost first), and `index` counts cells to walk
54    /// from the top (0 = the most recent binding) — so the evaluator's lookup is a
55    /// pointer chase with no string compare and no per-link clone. The original
56    /// `name` is kept only for rendering, **boxed** so it does not widen `Expr` past
57    /// four words (it is read solely by `to_sexp` / `to_json`): a `Local` then
58    /// prints exactly like the `Name` it replaced (`(name foo)` / `{"name":"foo"}`),
59    /// so the goldens are unchanged.
60    Local { name: Box<Text>, index: u32 },
61    /// A `__`-builtin resolved **at parse** (see `Builtin::from_name`). `__` names
62    /// are un-bindable, so a reference like `__Int.add` never depends on the
63    /// environment; resolving it here turns each call into a direct dispatch rather
64    /// than a failing env walk plus a name match at eval. Rendered exactly like the
65    /// `Name` it replaces (`(name __Int.add)` / `{"name":"__Int.add"}`) so the
66    /// goldens are unchanged.
67    Builtin(Builtin),
68    /// A **module sibling reference**: a name that resolved not to a lexical
69    /// binding but to another item of the enclosing module (see
70    /// `docs/done/2026-08-07_elly-modules-v0.md`). Produced only by the module resolver
71    /// (`resolve::resolve_module_bodies`), never by `parse`; it is a *sink* — it
72    /// carries no module reference and is resolved at eval through the home module
73    /// carried in the environment (`EnvNode::Module`).
74    ///
75    /// `index` is the item's position in the home module's (name-sorted) item
76    /// table, assigned at resolve so eval indexes straight into it with no name
77    /// compare — the module-tier analogue of a `Local`'s de Bruijn index. `name` is
78    /// kept for rendering only (boxed to keep `Expr` at four words). Renders
79    /// `(moditem factorial)` / `{"moditem":"factorial"}`.
80    /// `depth` is how many [`Module`](crate::EnvNode::Module) terminals the walk
81    /// **skips** before the one that owns this item. A program with one module has
82    /// no nesting and every `ModItem` carries `0`; a [`recur`](Expr::Recur) inside
83    /// a module body puts a second terminal on the chain, and a reference to the
84    /// module's own item from inside the `recur` has to step over the `recur`'s.
85    /// It is the barrier-stack distance the resolver measured, not rendered (like
86    /// a `Local`'s index, it is derived addressing).
87    ModItem {
88        name: Box<Text>,
89        index: u32,
90        depth: u32,
91    },
92    /// A **recursive binding group as an expression**: `recur (a = va, b = vb) e`.
93    /// The bindings are *simultaneous* — each sees all of them, itself included —
94    /// which is what separates it from `let`, whose group is sequential and
95    /// desugars to nested [`App`](Expr::App)/[`Abs`](Expr::Abs) with no node of its
96    /// own.
97    ///
98    /// The group is a module — the whole of one, body included: the bindings are
99    /// its (name-sorted) item table, and they reach each other as
100    /// [`ModItem`](Expr::ModItem)s *through the terminal* rather than by
101    /// capturing references to each other, the edge that would cycle. The
102    /// expression the group scopes over is the [`ModuleData`'s own
103    /// body](crate::ModuleData::body), the slot a module source fills with
104    /// `__main`. Evaluating this builds the terminal over the current environment
105    /// (the cons-shaped frame) and runs that body under it; see
106    /// `docs/done/2026-08-13_elly-recur.md` and `docs/done/2026-08-14_elly-run.md`.
107    Recur(Rc<ModuleData>),
108    /// A `.`-prefixed literal (`.foo`, `.0`), stored *without* the dot — the dot
109    /// is the Muon sigil and is re-added when displayed / dumped.
110    Symbol(Text),
111    /// A string literal, **decoded** at parse from a Muon `<str>` (JSON/Muon
112    /// escapes resolved, `\uXXXX` surrogate pairs combined) into owned UTF-8. It
113    /// is self-evaluating: eval clones the handle into `Value::Str`. Rendered as
114    /// the bare quoted, re-escaped literal (`"foo"`) — the quotes already mark it
115    /// apart from `.foo` / `foo`, so no `(str …)` wrapper.
116    Str(Text),
117    /// Application: a **flattened call spine** whose element 0 is the callee and
118    /// `[1..]` the arguments. A source spine `f a b c` and a spread call `f(a, b)`
119    /// both parse into one node with a known call-site arity, so the evaluator
120    /// binds a whole call's arguments in one pass (see `apply_n` in `eval.rs`).
121    /// Always holds at least a callee and one argument (two elements); currying is
122    /// recovered by `apply_n`'s over-application loop, so a flat spine is
123    /// semantically identical to the left-nested binary applications it replaced —
124    /// which is exactly how it is re-rendered in `to_sexp` / `to_json`.
125    App(Box<[Expr]>),
126    /// Abstraction `&<pattern> body`: the one universal binding form, its *code*
127    /// (`Lambda`: the parameter patterns and the body) shared behind `Rc`. A run
128    /// of `&p0 &p1 …` is collected at parse into one multi-parameter `Lambda`
129    /// (arity `head.len()`), so applying it *matches* a whole call's arguments in
130    /// one pass. The evaluator builds a closure by cloning the `Rc<Lambda>` and
131    /// capturing the environment, rather than deep-cloning the subtree. Rendered as
132    /// nested `(abs p0 (abs p1 …))` — the curried reading the collected head stands
133    /// for — to keep the goldens stable.
134    Abs(Rc<Lambda>),
135    /// A map literal from a Muon `<block>` (`{ … }`): its entries as
136    /// (key-expr, value-expr) pairs, in source order. Keys stay expressions
137    /// until eval, so computed keys (`(expr)`) just evaluate like any other
138    /// expression; see the *maps* section of the spec.
139    Map(Vec<(Expr, Expr)>),
140    /// A **home-module leaf**: `__module`, `__new` or `__value`, each reading the
141    /// module the body it sits in was written in (see [`HomeLeaf`]). Like a
142    /// [`ModItem`](Expr::ModItem) it is a *sink* — it carries no module reference
143    /// and finds its module through the environment's terminal at eval — and like
144    /// one it costs a leaf and no capture slot.
145    ///
146    /// `depth` is how many [`Module`](crate::EnvNode::Module) terminals to step
147    /// over before the module: a leaf names the nearest terminal *written as a
148    /// module*, so a [`recur`](Expr::Recur) between the leaf and its module is
149    /// stepped over and a method that loops internally still constructs objects of
150    /// its own type. The resolver measures it (the same barrier-stack distance a
151    /// `ModItem` carries) and rejects a leaf with no module on the stack; it is
152    /// derived addressing, so it is not rendered. See
153    /// `docs/done/2026-08-15_elly-objects-v0.md`.
154    Home { leaf: HomeLeaf, depth: u32 },
155}
156
157/// Which of the three [home-module leaves](Expr::Home) a reference is. They share
158/// one variant because they share the whole of their machinery — one walk to the
159/// terminal, one parse clause each, no capture — and differ only in what they
160/// evaluate to once it is found.
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum HomeLeaf {
163    /// `__module` — the home module itself, a `Value::Module`.
164    Module,
165    /// `__new` — the object constructor over the home module: the object builtin
166    /// with the module already applied, so what is left takes the payload.
167    New,
168    /// `__value` — the payload reader over the home module, the same shape. In
169    /// *pattern* position the name is [`Pattern::Unwrap`] instead.
170    Value,
171}
172
173impl HomeLeaf {
174    /// The reserved name this leaf is written and rendered as.
175    pub fn name(self) -> &'static str {
176        match self {
177            HomeLeaf::Module => "__module",
178            HomeLeaf::New => "__new",
179            HomeLeaf::Value => "__value",
180        }
181    }
182}
183// TODO: annotate expressions with source spans
184// TODO: do not hardcode `Vec`/`Box`
185
186/// The body of a [`recur`](Expr::Recur) group. It is an `Option` on
187/// [`ModuleData`] because a *module* may decline to declare one, but a group's is
188/// required by the grammar ([`ParseError::RecurMissingBody`]) — so every
189/// `Expr::Recur` has one, and the two readers that would otherwise both unwrap
190/// (`to_sexp`/`to_json` here, [`eval`](crate::eval) there) say why once.
191///
192/// [`ParseError::RecurMissingBody`]: crate::ParseError::RecurMissingBody
193pub(crate) fn recur_body(data: &ModuleData) -> &Expr {
194    data.body().expect("a recur group always has a body")
195}
196
197/// The *code* of an abstraction: the parameter patterns and the body. Shared
198/// behind `Rc` between the [`Expr::Abs`] that builds a closure and the
199/// `Value::Closure` that captures an environment over it, so a partial
200/// application is one `Rc::clone` (plus a bumped `applied` counter and an
201/// extended environment) rather than a fresh `Lambda` allocation. The **arity is
202/// `head.len()`** — collected greedily at parse from a run of `&p0 &p1 …` — and
203/// is capped at 255 (see `parse.rs`). `head` is non-empty: every abstraction
204/// binds at least one parameter.
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct Lambda {
207    pub head: Box<[Pattern]>,
208    pub body: Expr,
209    /// How a closure over this lambda gets its environment: see [`Captures`].
210    /// Derived data, not syntax — it is not rendered by `to_sexp` / `to_json`.
211    pub captures: Captures,
212}
213
214impl Lambda {
215    /// The number of parameters this abstraction binds before its body runs.
216    pub fn arity(&self) -> usize {
217        self.head.len()
218    }
219}
220
221/// How a closure over a [`Lambda`] gets the environment its body runs in — decided
222/// per abstraction by the resolver, and the *only* thing that distinguishes the two
223/// closure representations (both are semantically identical; environments are
224/// immutable, so reading a value now or later gives the same answer).
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub enum Captures {
227    /// Not yet resolved — the state the parser leaves behind. The evaluator refuses
228    /// it rather than silently reading the wrong slots (the same posture as an
229    /// unresolved [`Expr::Name`] at eval).
230    Unresolved,
231    /// **Share** the defining environment by reference: no frame, no allocation, and
232    /// the body's indices keep counting into the enclosing activation's chain.
233    ///
234    /// Chosen where a closure cannot outlive the call that creates it, so retaining
235    /// the chain retains nothing observable: the callee of a direct application
236    /// (every `let`), and a literal `&`-abstraction handed straight to a builtin that
237    /// only *applies* it — a `__match` clause, an `__Err.catch` or `__eq` branch, a
238    /// `__Int.for` / `__Map.for` / `__Map.merge` callback. Elly mints these on every
239    /// call, and making each one copy a frame is what a measured 10–28% regression
240    /// was made of (see `docs/done/2026-08-14_elly-perf-closure-capture.md`).
241    Chain,
242    /// **Capture** these free variables into a fresh frame, one [`Capture`] each,
243    /// **sorted by `outer`** (see `Capture` for why). The closure then retains only
244    /// what its body uses, and a reference into an enclosing scope is one index into
245    /// the frame (see `EnvNode::Frame` in `eval.rs`). This is the default — every
246    /// abstraction whose closure may escape. An empty plan is a *closed* lambda: it
247    /// captures nothing and allocates no frame.
248    Frame(Box<[Capture]>),
249}
250
251/// One captured free variable: **where to read it** (`outer` — a de Bruijn index in
252/// the *enclosing* activation, at the point the [`Expr::Abs`] is evaluated) and
253/// **where it goes** (`slot` — its position in the closure's frame).
254///
255/// The two are separate because the resolver fixes `slot` at a free variable's
256/// first use, while `outer` only falls out of resolving it in the enclosing scopes;
257/// re-ordering the frame to match would mean renumbering references already emitted
258/// into the body. Instead the resolver sorts the *plan* by `outer`, so the
259/// evaluator fills the whole frame in **one** walk of the defining environment
260/// (ascending indices, never restarting) and writes each value to its `slot`.
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
262pub struct Capture {
263    pub outer: u32,
264    pub slot: u32,
265}
266
267/// A **pattern**: the left-hand side of any binding (`&<pat> …`, `let (<pat> =
268/// …)`, a `__match` clause). Matched against a value by the evaluator's native
269/// matcher (`match_pattern` in `eval.rs`), which either extends the environment
270/// or refutes. `Bind`/`Discard` are irrefutable; every other shape may refute.
271#[derive(Debug, Clone, PartialEq, Eq)]
272pub enum Pattern {
273    /// `_` — matches anything, binds nothing.
274    Discard,
275    /// `name` — binds the subject to `name`. The matcher conses one cell carrying
276    /// the subject onto the environment; the name itself is not stored at runtime
277    /// (references to it were resolved to a de Bruijn index).
278    Bind(Text),
279    /// `name = <pat>` — binds the subject to `name`, then matches `<pat>` against
280    /// the same subject.
281    At(Text, Box<Pattern>),
282    /// `= <expr>` (and its `.sym` sugar) — matches iff the subject equals the
283    /// value of `<expr>`; binds nothing. Evaluating `<expr>` may raise a real
284    /// error, which propagates (it is not a refutation).
285    Equal(Expr),
286    /// `< <expr>` — matches iff the subject is (strictly) ordered before the value
287    /// of `<expr>`; binds nothing. Ordering is defined only within a comparable
288    /// kind (`Int` for now, later `Str`), so a subject/bound that are not both of
289    /// one such kind refute with `.not_int` (a refutation, not a host raise).
290    Less(Expr),
291    /// `> <expr>` — the mirror of [`Less`](Pattern::Less): matches iff the subject
292    /// is ordered after the value of `<expr>`.
293    Greater(Expr),
294    /// `<pat> as <Proto>` / `(as <Proto>) <pat>` — matches iff the subject's
295    /// prototype is `<Proto>`, then matches `<pat>` against the (narrowed)
296    /// subject. See [`ProtoRef`] for the two forms the prototype takes.
297    Type(ProtoRef, Box<Pattern>),
298    /// `__value <pat>` — matches iff the subject is an **object of the home
299    /// module** and its payload matches `<pat>`, binding what the inner pattern
300    /// binds. It is the pattern side of the [`__value`](HomeLeaf::Value) leaf, and
301    /// the prototype check is implicit for the same reason the leaf's is:
302    /// unwrapping a foreign object has no sound reading.
303    ///
304    /// It holds no reference — the module is implicit, exactly as it is for
305    /// `__new` — so the pattern grammar stays closed. `depth` is the leaf's, and
306    /// the matcher walks the environment to that terminal and compares the
307    /// prototype by pointer before descending.
308    Unwrap { depth: u32, inner: Box<Pattern> },
309    /// `(p1 | p2)` — try the left arm, on refutation try the right. Both arms bind
310    /// the same set of names (checked at parse time). **Committed**: once an arm's
311    /// pattern matches, a later failure does not backtrack into the other arm.
312    Or(Box<Pattern>, Box<Pattern>),
313    /// `[p0, …]` with an optional trailing rest — a fixed-arity list pattern.
314    List(Vec<Pattern>, Rest),
315    /// `{ k0: p0, … }` — a map pattern of (key, value pattern) entries.
316    /// `Rest::None` is **closed** (exactly these keys); `Rest::Anon`/`Named` open
317    /// it (at least these keys; `Named` binds the remainder map).
318    Map(Vec<(MapKey, Pattern)>, Rest),
319}
320
321/// A map-pattern entry's key. A **lookup** key (`.sym`, bare `sym`, `(= expr)`)
322/// names a concrete key to probe. A **capture** key (a binder pattern in key
323/// position, e.g. `(k)` / `(_)`) instead *peels* the smallest remaining entry and
324/// matches its key against the pattern — the map analogue of `[a, ...rest]`.
325#[derive(Debug, Clone, PartialEq, Eq)]
326pub enum MapKey {
327    Lookup(Expr),
328    Capture(Box<Pattern>),
329}
330
331/// A pattern's trailing rest: none, an anonymous `...` (ignore the remainder), or
332/// a named `...rest` (bind it to the remainder). For a list, `None` is an exact
333/// arity; for a map, `None` is a closed key set — either way a rest opens it.
334#[derive(Debug, Clone, PartialEq, Eq)]
335pub enum Rest {
336    None,
337    Anon,
338    /// `...name` — binds the remainder (list tail / leftover map) to `name`; the
339    /// matcher conses one cell carrying the remainder onto the environment.
340    Named(Text),
341}
342
343/// The prototype an `as <Proto>` pattern narrows to. One rule covers both forms —
344/// the subject matches iff its prototype *is* the named module — and they differ
345/// only in how much of the work is left to run time.
346#[derive(Debug, Clone, PartialEq, Eq)]
347pub enum ProtoRef {
348    /// The **compiled fast form**: a reference the resolver recognized as the
349    /// builtin module that is some value kind's prototype, so the check is the
350    /// discriminant test [`TyKind`] always was rather than an eval plus a pointer
351    /// compare. `as Int` takes this path, alias or `__`-spelling alike.
352    Kind(TyKind),
353    /// Any other prototype, named by an **atom** — a module item, a local, a
354    /// frame slot, or `__module`. Resolved like an [`Equal`](Pattern::Equal)
355    /// comparand, evaluated at match time, and required to be a module value.
356    Ref(Expr),
357}
358
359impl ProtoRef {
360    /// Render the reference: a kind by its bare name (`Int`), so the existing
361    /// goldens are unchanged, and anything else as the expression it is.
362    fn write_sexp(&self, out: &mut String) {
363        match self {
364            ProtoRef::Kind(ty) => out.push_str(ty.name()),
365            ProtoRef::Ref(expr) => expr.write_sexp(out),
366        }
367    }
368
369    fn write_json(&self, out: &mut String) {
370        match self {
371            ProtoRef::Kind(ty) => json_str(ty.name(), out),
372            ProtoRef::Ref(expr) => expr.write_json(out),
373        }
374    }
375}
376
377/// The value kinds whose prototype is a **builtin module**, so that `as <Proto>`
378/// against it compiles to a discriminant test (see [`ProtoRef::Kind`]).
379#[derive(Debug, Clone, Copy, PartialEq, Eq)]
380pub enum TyKind {
381    Int,
382    Sym,
383    Str,
384    List,
385    Map,
386    Fun,
387    /// A module value, whose prototype is `__Mod`. It has no `as Mod` history —
388    /// the kind set was closed before modules existed — and is here because
389    /// `__Mod` is a builtin module like the rest, so the fast form covers it.
390    Mod,
391}
392
393impl TyKind {
394    /// The spelling used in the `as <Proto>` surface and the dumps.
395    pub fn name(self) -> &'static str {
396        match self {
397            TyKind::Int => "Int",
398            TyKind::Sym => "Sym",
399            TyKind::Str => "Str",
400            TyKind::List => "List",
401            TyKind::Map => "Map",
402            TyKind::Fun => "Fun",
403            TyKind::Mod => "Mod",
404        }
405    }
406}
407
408impl Expr {
409    /// Serialize to a compact s-expression, e.g. `(app (name f) (name x))`.
410    pub fn to_sexp(&self) -> String {
411        let mut out = String::new();
412        self.write_sexp(&mut out);
413        out
414    }
415
416    // TODO: replace with impl de::Deserialize, impl ser::Serialize
417    /// Serialize to compact, tagged JSON, e.g.
418    /// `{"app":[{"name":"f"},{"name":"x"}]}`. Parallels `muon::Seq::to_json`;
419    /// used by the `elly-wasm` playground to render the AST as a tree.
420    pub fn to_json(&self) -> String {
421        let mut out = String::new();
422        self.write_json(&mut out);
423        out
424    }
425
426    fn write_json(&self, out: &mut String) {
427        match self {
428            Expr::Int(n) => {
429                // f64 (JSON's only number type) represents every integer in
430                // [-2^53, 2^53] exactly. Within that range emit a bare number so
431                // the playground shows it as-is; outside it, fall back to a
432                // decimal *string* tagged `Int` to avoid precision loss.
433                if fits_f64_exactly(n) {
434                    out.push_str(&n.to_string());
435                } else {
436                    out.push_str("{\"Int\":");
437                    json_str(&n.to_string(), out);
438                    out.push('}');
439                }
440            }
441            // A `Name` (unresolved) and a `Local` (resolved) both render by name.
442            Expr::Name(n) => {
443                out.push_str("{\"name\":");
444                json_str(n, out);
445                out.push('}');
446            }
447            Expr::Local { name, .. } => {
448                out.push_str("{\"name\":");
449                json_str(name.as_str(), out);
450                out.push('}');
451            }
452            // A parse-resolved builtin renders as the `Name` it stands in for.
453            Expr::Builtin(op) => {
454                out.push_str("{\"name\":");
455                json_str(op.name(), out);
456                out.push('}');
457            }
458            // A home-module leaf renders as the reserved name it was written as,
459            // for the reason a `Local` and a `Builtin` do: the depth beside it is
460            // derived addressing, not syntax.
461            Expr::Home { leaf, .. } => {
462                out.push_str("{\"name\":");
463                json_str(leaf.name(), out);
464                out.push('}');
465            }
466            Expr::ModItem { name, .. } => {
467                out.push_str("{\"moditem\":");
468                json_str(name.as_str(), out);
469                out.push('}');
470            }
471            Expr::Recur(data) => {
472                out.push_str("{\"recur\":[");
473                for (i, (name, body)) in data.items().iter().enumerate() {
474                    if i > 0 {
475                        out.push(',');
476                    }
477                    out.push('[');
478                    json_str(name.as_str(), out);
479                    out.push(',');
480                    body.write_json(out);
481                    out.push(']');
482                }
483                out.push_str("],\"body\":");
484                recur_body(data).write_json(out);
485                out.push('}');
486            }
487            Expr::Symbol(s) => {
488                let mut dotted = String::from(".");
489                dotted.push_str(s);
490                out.push_str("{\"sym\":");
491                json_str(&dotted, out);
492                out.push('}');
493            }
494            Expr::Str(s) => {
495                out.push_str("{\"str\":");
496                json_str(s, out);
497                out.push('}');
498            }
499            // Render the call spine flat, preserving the arity the AST stores.
500            Expr::App(items) => write_app_json(items, out),
501            // Re-render the collected head as nested single-parameter abstractions.
502            Expr::Abs(code) => write_abs_json(&code.head, &code.body, out),
503            Expr::List(elems) => {
504                out.push_str("{\"list\":[");
505                for (i, e) in elems.iter().enumerate() {
506                    if i > 0 {
507                        out.push(',');
508                    }
509                    e.write_json(out);
510                }
511                out.push_str("]}");
512            }
513            Expr::Map(entries) => {
514                out.push_str("{\"map\":[");
515                for (i, (k, v)) in entries.iter().enumerate() {
516                    if i > 0 {
517                        out.push(',');
518                    }
519                    out.push('[');
520                    k.write_json(out);
521                    out.push(',');
522                    v.write_json(out);
523                    out.push(']');
524                }
525                out.push_str("]}");
526            }
527        }
528    }
529
530    fn write_sexp(&self, out: &mut String) {
531        match self {
532            Expr::Int(n) => {
533                out.push_str("(int ");
534                out.push_str(&n.to_string());
535                out.push(')');
536            }
537            // A `Name` (unresolved) and a `Local` (resolved) both render by name.
538            Expr::Name(n) => {
539                out.push_str("(name ");
540                out.push_str(n);
541                out.push(')');
542            }
543            Expr::Local { name, .. } => {
544                out.push_str("(name ");
545                out.push_str(name.as_str());
546                out.push(')');
547            }
548            // A parse-resolved builtin renders as the `Name` it stands in for.
549            Expr::Builtin(op) => {
550                out.push_str("(name ");
551                out.push_str(op.name());
552                out.push(')');
553            }
554            Expr::Home { leaf, .. } => {
555                out.push_str("(name ");
556                out.push_str(leaf.name());
557                out.push(')');
558            }
559            Expr::ModItem { name, .. } => {
560                out.push_str("(moditem ");
561                out.push_str(name.as_str());
562                out.push(')');
563            }
564            // `(recur (bind a …) (bind b …) <body>)` — the bindings in the table's
565            // sorted order, then the body, so the group reads as one form.
566            Expr::Recur(data) => {
567                out.push_str("(recur");
568                for (name, body) in data.items() {
569                    out.push_str(" (bind ");
570                    out.push_str(name.as_str());
571                    out.push(' ');
572                    body.write_sexp(out);
573                    out.push(')');
574                }
575                out.push(' ');
576                recur_body(data).write_sexp(out);
577                out.push(')');
578            }
579            Expr::Symbol(s) => {
580                out.push_str("(sym .");
581                out.push_str(s);
582                out.push(')');
583            }
584            // A string leaf is the bare quoted, re-escaped literal — the quotes
585            // already set it apart from `(sym .foo)` / `(name foo)`, so no wrapper.
586            Expr::Str(s) => json_str(s, out),
587            // Render the call spine flat, preserving the arity the AST stores.
588            Expr::App(items) => write_app_sexp(items, out),
589            // Re-render the collected head as nested single-parameter abstractions.
590            Expr::Abs(code) => write_abs_sexp(&code.head, &code.body, out),
591            Expr::List(elems) => {
592                out.push_str("(list");
593                for e in elems {
594                    out.push(' ');
595                    e.write_sexp(out);
596                }
597                out.push(')');
598            }
599            Expr::Map(entries) => {
600                out.push_str("(map");
601                for (k, v) in entries {
602                    out.push_str(" (entry ");
603                    k.write_sexp(out);
604                    out.push(' ');
605                    v.write_sexp(out);
606                    out.push(')');
607                }
608                out.push(')');
609            }
610        }
611    }
612}
613
614/// Render a flat call spine `[callee, a0, a1, …]` (≥ 2 elements) as the flat
615/// s-expression `(app callee a0 a1)` — the arity is shown as the AST stores it,
616/// not re-curried into left-nested binary `(app (app callee a0) a1)` forms.
617fn write_app_sexp(items: &[Expr], out: &mut String) {
618    out.push_str("(app");
619    for it in items {
620        out.push(' ');
621        it.write_sexp(out);
622    }
623    out.push(')');
624}
625
626/// Render a collected head `[p0, p1, …]` (non-empty) over `body` as the nested
627/// `(abs p0 (abs p1 body))` it stands for.
628fn write_abs_sexp(head: &[Pattern], body: &Expr, out: &mut String) {
629    out.push_str("(abs ");
630    head[0].write_sexp(out);
631    out.push(' ');
632    if head.len() == 1 {
633        body.write_sexp(out);
634    } else {
635        write_abs_sexp(&head[1..], body, out);
636    }
637    out.push(')');
638}
639
640/// The tagged-JSON twin of [`write_app_sexp`]: `{"app":[callee, a0, a1, …]}`, the
641/// whole spine flat so the AST view shows the call's arity as stored.
642fn write_app_json(items: &[Expr], out: &mut String) {
643    out.push_str("{\"app\":[");
644    for (i, it) in items.iter().enumerate() {
645        if i > 0 {
646            out.push(',');
647        }
648        it.write_json(out);
649    }
650    out.push_str("]}");
651}
652
653/// The tagged-JSON twin of [`write_abs_sexp`]: `{"abs":{"param":…,"body":…}}` nested.
654fn write_abs_json(head: &[Pattern], body: &Expr, out: &mut String) {
655    out.push_str("{\"abs\":{\"param\":");
656    head[0].write_json(out);
657    out.push_str(",\"body\":");
658    if head.len() == 1 {
659        body.write_json(out);
660    } else {
661        write_abs_json(&head[1..], body, out);
662    }
663    out.push_str("}}");
664}
665
666impl Pattern {
667    /// Serialize a pattern to the same compact s-expression family as `Expr`.
668    /// A bare `Bind`/`Discard` renders as just its name (`a`, `_`) so the common
669    /// `&x` reads `(abs x …)`; structured patterns are tagged forms
670    /// (`(list a ...rest)`, `(at n <p>)`, `(as Int <p>)`, `(eq <expr>)`, …).
671    fn write_sexp(&self, out: &mut String) {
672        match self {
673            Pattern::Discard => out.push('_'),
674            Pattern::Bind(name) => out.push_str(name),
675            Pattern::At(name, inner) => {
676                out.push_str("(at ");
677                out.push_str(name);
678                out.push(' ');
679                inner.write_sexp(out);
680                out.push(')');
681            }
682            Pattern::Equal(expr) => {
683                out.push_str("(eq ");
684                expr.write_sexp(out);
685                out.push(')');
686            }
687            Pattern::Less(expr) => {
688                out.push_str("(lt ");
689                expr.write_sexp(out);
690                out.push(')');
691            }
692            Pattern::Greater(expr) => {
693                out.push_str("(gt ");
694                expr.write_sexp(out);
695                out.push(')');
696            }
697            Pattern::Type(proto, inner) => {
698                out.push_str("(as ");
699                proto.write_sexp(out);
700                out.push(' ');
701                inner.write_sexp(out);
702                out.push(')');
703            }
704            Pattern::Unwrap { inner, .. } => {
705                out.push_str("(unwrap ");
706                inner.write_sexp(out);
707                out.push(')');
708            }
709            Pattern::Or(left, right) => {
710                out.push_str("(or ");
711                left.write_sexp(out);
712                out.push(' ');
713                right.write_sexp(out);
714                out.push(')');
715            }
716            Pattern::List(elems, rest) => {
717                out.push_str("(list");
718                for e in elems {
719                    out.push(' ');
720                    e.write_sexp(out);
721                }
722                rest.write_sexp(out);
723                out.push(')');
724            }
725            Pattern::Map(entries, rest) => {
726                out.push_str("(map");
727                for (key, vpat) in entries {
728                    out.push_str(" (entry ");
729                    key.write_sexp(out);
730                    out.push(' ');
731                    vpat.write_sexp(out);
732                    out.push(')');
733                }
734                rest.write_sexp(out);
735                out.push(')');
736            }
737        }
738    }
739
740    fn write_json(&self, out: &mut String) {
741        match self {
742            Pattern::Discard => out.push_str("{\"discard\":true}"),
743            Pattern::Bind(name) => {
744                out.push_str("{\"bind\":");
745                json_str(name, out);
746                out.push('}');
747            }
748            Pattern::At(name, inner) => {
749                out.push_str("{\"at\":[");
750                json_str(name, out);
751                out.push(',');
752                inner.write_json(out);
753                out.push_str("]}");
754            }
755            Pattern::Equal(expr) => {
756                out.push_str("{\"eq\":");
757                expr.write_json(out);
758                out.push('}');
759            }
760            Pattern::Less(expr) => {
761                out.push_str("{\"lt\":");
762                expr.write_json(out);
763                out.push('}');
764            }
765            Pattern::Greater(expr) => {
766                out.push_str("{\"gt\":");
767                expr.write_json(out);
768                out.push('}');
769            }
770            Pattern::Type(proto, inner) => {
771                out.push_str("{\"as\":[");
772                proto.write_json(out);
773                out.push(',');
774                inner.write_json(out);
775                out.push_str("]}");
776            }
777            Pattern::Unwrap { inner, .. } => {
778                out.push_str("{\"unwrap\":");
779                inner.write_json(out);
780                out.push('}');
781            }
782            Pattern::Or(left, right) => {
783                out.push_str("{\"or\":[");
784                left.write_json(out);
785                out.push(',');
786                right.write_json(out);
787                out.push_str("]}");
788            }
789            Pattern::List(elems, rest) => {
790                out.push_str("{\"listpat\":{\"elems\":[");
791                for (i, e) in elems.iter().enumerate() {
792                    if i > 0 {
793                        out.push(',');
794                    }
795                    e.write_json(out);
796                }
797                out.push_str("],\"rest\":");
798                rest.write_json(out);
799                out.push_str("}}");
800            }
801            Pattern::Map(entries, rest) => {
802                out.push_str("{\"mappat\":{\"entries\":[");
803                for (i, (key, vpat)) in entries.iter().enumerate() {
804                    if i > 0 {
805                        out.push(',');
806                    }
807                    out.push('[');
808                    key.write_json(out);
809                    out.push(',');
810                    vpat.write_json(out);
811                    out.push(']');
812                }
813                out.push_str("],\"rest\":");
814                rest.write_json(out);
815                out.push_str("}}");
816            }
817        }
818    }
819}
820
821impl Rest {
822    /// Append a trailing rest to an s-expression list/map form: nothing for
823    /// `None`, ` ...` for `Anon`, ` ...name` for `Named`.
824    fn write_sexp(&self, out: &mut String) {
825        match self {
826            Rest::None => {}
827            Rest::Anon => out.push_str(" ..."),
828            Rest::Named(name) => {
829                out.push_str(" ...");
830                out.push_str(name);
831            }
832        }
833    }
834
835    fn write_json(&self, out: &mut String) {
836        match self {
837            Rest::None => out.push_str("null"),
838            Rest::Anon => out.push_str("\"...\""),
839            Rest::Named(name) => {
840                out.push_str("{\"named\":");
841                json_str(name, out);
842                out.push('}');
843            }
844        }
845    }
846}
847
848impl MapKey {
849    fn write_sexp(&self, out: &mut String) {
850        match self {
851            // A lookup key is a plain expression (`.a`, a computed `(name k)`).
852            MapKey::Lookup(expr) => expr.write_sexp(out),
853            // A capture key wraps a pattern that matches the peeled key.
854            MapKey::Capture(kpat) => {
855                out.push_str("(capture ");
856                kpat.write_sexp(out);
857                out.push(')');
858            }
859        }
860    }
861
862    fn write_json(&self, out: &mut String) {
863        match self {
864            MapKey::Lookup(expr) => {
865                out.push_str("{\"lookup\":");
866                expr.write_json(out);
867                out.push('}');
868            }
869            MapKey::Capture(kpat) => {
870                out.push_str("{\"capture\":");
871                kpat.write_json(out);
872                out.push('}');
873            }
874        }
875    }
876}
877
878/// Whether `n` is exactly representable as an `f64`: every integer in
879/// `[-2^53, 2^53]` is (2^53 is the largest magnitude with no representation gap).
880fn fits_f64_exactly(n: &BigInt) -> bool {
881    const LIMIT: i64 = 1 << 53; // 2^53 == 9_007_199_254_740_992
882    *n >= BigInt::from(-LIMIT) && *n <= BigInt::from(LIMIT)
883}
884
885/// Write `s` as a JSON string literal (quoted and escaped). Shared with `eval.rs`
886/// so a `Value::Str`'s display re-escapes identically to an `Expr::Str`'s dumps.
887pub(crate) fn json_str(s: &str, out: &mut String) {
888    out.push('"');
889    for ch in s.chars() {
890        match ch {
891            '"' => out.push_str("\\\""),
892            '\\' => out.push_str("\\\\"),
893            '\n' => out.push_str("\\n"),
894            '\r' => out.push_str("\\r"),
895            '\t' => out.push_str("\\t"),
896            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
897            c => out.push(c),
898        }
899    }
900    out.push('"');
901}