Skip to main content

nomiscript/
ast.rs

1use core::fmt;
2
3use num_rational::Ratio;
4
5pub type Fraction = Ratio<i64>;
6
7/// Kind tag for one server-entity wasm struct. Each variant maps to a
8/// concrete `$<kind>` GC struct registered in
9/// `CompileContext::new_skeleton`, plus a set of typed field accessors
10/// in the native registry. Adding an entity adds one variant here +
11/// one row in the `nomi_entity!` macro invocation; the compiler and
12/// host-side allocators pick up the rest by expansion.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum EntityKind {
15    Account,
16    Commodity,
17    Transaction,
18    Split,
19    Tag,
20    Price,
21    SshKey,
22    /// Hierarchical report node — recursive entity carrying the
23    /// per-account/per-period balance rows that the report family
24    /// of natives produces. `$report_node.children` is a
25    /// `pair<report_node>` chain, so `(dolist (n (node-children r))
26    /// ...)` walks the tree without leaving the typed lattice.
27    ReportNode,
28    /// Error condition raised by `(error 'code "msg")` and caught by
29    /// `(handler-case)`. Maps to the `$nomi_condition` struct registered
30    /// for exception support; the clause variable `e` binds at this kind so
31    /// `(error-code e)` / `(error-message e)` type-check.
32    Condition,
33}
34
35impl EntityKind {
36    /// Name of the wasm struct type the compiler registers in
37    /// `CompileContext::new_skeleton` for this entity kind. Keep in
38    /// lockstep with the `register_struct_type` calls there.
39    #[must_use]
40    pub fn type_name(self) -> &'static str {
41        match self {
42            Self::Account => "account",
43            Self::Commodity => "commodity_entity",
44            Self::Transaction => "transaction",
45            Self::Split => "split",
46            Self::Tag => "tag_entity",
47            Self::Price => "price",
48            Self::SshKey => "ssh_key",
49            Self::ReportNode => "report_node",
50            Self::Condition => "nomi_condition",
51        }
52    }
53}
54
55impl fmt::Display for EntityKind {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        write!(f, "{}", self.type_name())
58    }
59}
60
61/// Element type of a homogeneous `$pair` chain. Tracked at compile time so
62/// CAR/CDR emit the right downcast and CONS refuses heterogeneous mixing
63/// with a structured type error. Kept as a `Copy` enum so `WasmType` stays
64/// `Copy` — and so adding a new element type stays a one-line variant
65/// addition. Nesting (pairs-of-pairs) waits for a follow-up sub-slice
66/// once flat lists are stable across the consumers.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68pub enum PairElement {
69    I32,
70    /// Truth-value cell: shares `I32`'s i31-boxed car representation (CAR/CDR
71    /// emit the same i31 downcast), but distinct so a bool extracted from a
72    /// list serializes as `Nil` / `Bool`, not `Number` — the list-element
73    /// analogue of [`WasmType::Bool`] vs `I32`.
74    Bool,
75    Ratio,
76    Commodity,
77    StringRef,
78    Entity(EntityKind),
79    /// Heterogeneous escape hatch (ADR-0025). Cars stay as raw
80    /// `anyref`; CAR returns `WasmType::AnyRef` and the script must
81    /// downcast at the use site (or consume via type-test natives like
82    /// `ok?` / `err-code`). CONS widens to this variant when the two
83    /// arms disagree on element type, and host fns that produce
84    /// fundamentally heterogeneous lists (e.g. =catch-each= result
85    /// cells) construct directly into `PairRef(AnyRef)`.
86    AnyRef,
87}
88
89impl fmt::Display for PairElement {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        match self {
92            Self::I32 => write!(f, "i32"),
93            Self::Bool => write!(f, "bool"),
94            Self::Ratio => write!(f, "ratio"),
95            Self::Commodity => write!(f, "commodity"),
96            Self::StringRef => write!(f, "string"),
97            Self::Entity(kind) => write!(f, "{kind}"),
98            Self::AnyRef => write!(f, "any"),
99        }
100    }
101}
102
103impl PairElement {
104    /// The matching `WasmType` for a pair's car when extracted.
105    #[must_use]
106    pub fn as_wasm_type(self) -> WasmType {
107        match self {
108            Self::I32 => WasmType::I32,
109            Self::Bool => WasmType::Bool,
110            Self::Ratio => WasmType::Ratio,
111            Self::Commodity => WasmType::Commodity,
112            Self::StringRef => WasmType::StringRef,
113            Self::Entity(kind) => WasmType::EntityRef(kind),
114            Self::AnyRef => WasmType::AnyRef,
115        }
116    }
117
118    /// Inverse of `as_wasm_type`: returns `None` if the given type can't
119    /// ride a `$pair`'s anyref car. `WasmType::AnyRef` round-trips to
120    /// `PairElement::AnyRef`; nested pairs and closures still need their
121    /// own follow-up lattice extension.
122    #[must_use]
123    pub fn from_wasm_type(ty: WasmType) -> Option<Self> {
124        match ty {
125            WasmType::I32 => Some(Self::I32),
126            // `Bool` keeps its own slot — it shares `I32`'s i31-boxed car
127            // representation (the CAR/CDR downcast is identical) but stays
128            // distinct so a bool extracted from a list serializes as Nil/Bool,
129            // not Number.
130            WasmType::Bool => Some(Self::Bool),
131            WasmType::Ratio => Some(Self::Ratio),
132            WasmType::Commodity => Some(Self::Commodity),
133            WasmType::StringRef => Some(Self::StringRef),
134            WasmType::EntityRef(kind) => Some(Self::Entity(kind)),
135            WasmType::AnyRef => Some(Self::AnyRef),
136            WasmType::PairRef(_) | WasmType::Closure(_) => None,
137        }
138    }
139
140    /// Widen `self` against `other` to the most-specific common
141    /// element. Identical types stay; everything else widens to
142    /// `AnyRef` so heterogeneous CONS no longer errors. Symmetric.
143    #[must_use]
144    pub fn widen(self, other: Self) -> Self {
145        if self == other { self } else { Self::AnyRef }
146    }
147}
148
149/// Identifier for a closure signature interned in the compile
150/// context's closure registry. Distinct closures sharing the same
151/// `(arg-types) -> ret-type` signature share an id (and therefore a
152/// `$closure_<id>` wasm GC type); their env-struct types vary
153/// independently per scope. The id is allocated in registration order
154/// — small, dense, `Copy` — so it can ride inside `WasmType` without
155/// growing the enum.
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
157pub struct ClosureSigId(pub u32);
158
159impl fmt::Display for ClosureSigId {
160    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161        write!(f, "{}", self.0)
162    }
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
166pub enum WasmType {
167    I32,
168    /// Boolean: a comparison / predicate / `and` / `or` result, or a runtime
169    /// `#t` / `#f`. Wasm representation is identical to `I32` (0 = false,
170    /// nonzero = true), so it composes with `if` / `br_if` and boxes via
171    /// `ref.i31` exactly like `I32`. It is a DISTINCT compile-time type only
172    /// so the debug serializer can surface it as `Nil` / `Bool` rather than
173    /// `Number`: an `I32` is a raw count (tag-count, split-count, …) that
174    /// serializes as a number, whereas a `Bool` is a truth value. Refused by
175    /// arithmetic like every non-numeric type.
176    Bool,
177    Ratio,
178    StringRef,
179    /// Commodity-bearing numeric: rational amount + originating commodity
180    /// uuid. Distinct from `Ratio` so the compiler can refuse mixing money
181    /// and pure-rational arithmetic at compile time. Wasm representation:
182    /// `(i64 numer, i64 denom, i64 commodity_hi, i64 commodity_lo)`.
183    Commodity,
184    /// Homogeneous list cell: `$pair` WasmGC struct with an `anyref` car
185    /// and a nullable `$pair` cdr. The `PairElement` records the car's
186    /// type at compile time so CAR/CDR can emit the correct downcast and
187    /// CONS refuses heterogeneous mixing. Retires the i32-only `$cons` —
188    /// every runtime list shape in the compiler routes through this type.
189    PairRef(PairElement),
190    /// Raw `anyref` car payload extracted from a `PairRef(AnyRef)` or
191    /// produced by a host fn that returns a heterogeneous value
192    /// (ADR-0025). Refused by arithmetic / numeric comparison / closure
193    /// call / typed pair construction; the script must downcast via
194    /// type-test natives (e.g. `ok?`, `err-code`) before consuming.
195    AnyRef,
196    /// Typed server-entity reference: a `(ref null $<kind>)` GC struct
197    /// whose field layout matches the `nomi_entity!` declaration for the
198    /// kind. Refused by arithmetic / list-CONS / numeric comparison; the
199    /// only legal operations are the entity-specific accessor natives
200    /// (`account-id`, `commodity-name`, etc.) registered alongside the
201    /// struct type.
202    EntityRef(EntityKind),
203    /// First-class closure value: `(ref null $closure_<sig>)` carrying a
204    /// typed funcref + nullable env ref. The `ClosureSigId` indexes the
205    /// per-context registry that owns the wasm types and per-call-site
206    /// env-struct layouts. Eligible for FUNCALL / APPLY via `call_ref`;
207    /// refused by arithmetic / numeric comparison / pair construction.
208    Closure(ClosureSigId),
209}
210
211impl fmt::Display for WasmType {
212    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213        match self {
214            Self::I32 => write!(f, "i32"),
215            Self::Bool => write!(f, "bool"),
216            Self::Ratio => write!(f, "ratio"),
217            Self::StringRef => write!(f, "string"),
218            Self::Commodity => write!(f, "commodity"),
219            Self::PairRef(elem) => write!(f, "pair<{elem}>"),
220            Self::EntityRef(kind) => write!(f, "entity<{kind}>"),
221            Self::Closure(sig) => write!(f, "closure<{sig}>"),
222            Self::AnyRef => write!(f, "any"),
223        }
224    }
225}
226
227#[derive(Debug, Clone, PartialEq)]
228pub struct LambdaParams {
229    pub required: Vec<String>,
230    pub optional: Vec<(String, Option<Expr>)>,
231    pub rest: Option<String>,
232    pub key: Vec<(String, Option<Expr>)>,
233    pub aux: Vec<(String, Option<Expr>)>,
234}
235
236impl LambdaParams {
237    pub fn simple(params: Vec<String>) -> Self {
238        Self {
239            required: params,
240            optional: Vec::new(),
241            rest: None,
242            key: Vec::new(),
243            aux: Vec::new(),
244        }
245    }
246}
247
248#[derive(Debug, Clone, PartialEq)]
249pub enum Expr {
250    Nil,
251    Bool(bool),
252    Number(Fraction),
253    String(String),
254    Symbol(String),
255    Keyword(String),
256    Bytes(Vec<u8>),
257    Cons(Box<Expr>, Box<Expr>),
258    List(Vec<Expr>),
259    Quote(Box<Expr>),
260    Quasiquote(Box<Expr>),
261    Unquote(Box<Expr>),
262    UnquoteSplicing(Box<Expr>),
263    Lambda(LambdaParams, Box<Expr>),
264    RuntimeValue(crate::runtime::Value),
265    WasmRuntime(WasmType),
266    /// Value stored in WASM local variable (index, type)
267    WasmLocal(u32, WasmType),
268}
269
270impl Expr {
271    #[must_use]
272    pub fn cons(car: Expr, cdr: Expr) -> Self {
273        Expr::Cons(Box::new(car), Box::new(cdr))
274    }
275
276    /// Returns the `WasmType` if this expression is a runtime value (`WasmRuntime` or `WasmLocal`).
277    #[must_use]
278    pub fn wasm_type(&self) -> Option<WasmType> {
279        match self {
280            Self::WasmRuntime(ty) | Self::WasmLocal(_, ty) => Some(*ty),
281            _ => None,
282        }
283    }
284
285    /// True if this is a runtime value (`WasmRuntime` or `WasmLocal`).
286    #[must_use]
287    pub fn is_wasm_runtime(&self) -> bool {
288        matches!(self, Self::WasmRuntime(_) | Self::WasmLocal(_, _))
289    }
290}
291
292#[derive(Debug, Clone, PartialEq)]
293pub struct Annotation {
294    pub name: String,
295    pub value: Expr,
296}
297
298impl Expr {
299    #[must_use]
300    pub fn is_atom(&self) -> bool {
301        !matches!(self, Expr::List(_) | Expr::Cons(_, _))
302    }
303
304    #[must_use]
305    pub fn as_symbol(&self) -> Option<&str> {
306        match self {
307            Expr::Symbol(s) => Some(s),
308            _ => None,
309        }
310    }
311
312    #[must_use]
313    pub fn as_list(&self) -> Option<&[Expr]> {
314        match self {
315            Expr::List(l) => Some(l),
316            _ => None,
317        }
318    }
319
320    /// Splits a `Symbol` into `(namespace, name)`. A qualified symbol is
321    /// stored canonically as `NS:NAME` (ADR-0029); an unqualified one has no
322    /// `:` and yields `(None, name)`. Non-symbols yield `None`. The split is
323    /// on the single canonical `:` — `NS::NAME` was already folded to `NS:NAME`
324    /// by the reader, so at most one `:` is ever present here.
325    #[must_use]
326    pub fn symbol_parts(&self) -> Option<(Option<&str>, &str)> {
327        let name = self.as_symbol()?;
328        Some(match name.split_once(':') {
329            Some((ns, base)) => (Some(ns), base),
330            None => (None, name),
331        })
332    }
333}
334
335/// Builds the canonical symbol-table key for a (possibly namespaced) name.
336/// `NS:NAME` when a namespace is present, bare `NAME` otherwise — the single
337/// source of truth for how a qualified name maps to its flat table key.
338#[must_use]
339pub fn canonical_symbol(namespace: Option<&str>, name: &str) -> String {
340    match namespace {
341        Some(ns) => format!("{ns}:{name}"),
342        None => name.to_string(),
343    }
344}
345
346#[derive(Debug, Clone, Default)]
347pub struct Program {
348    pub exprs: Vec<Expr>,
349    pub annotations: Vec<Annotation>,
350}
351
352impl Program {
353    #[must_use]
354    pub fn new(exprs: Vec<Expr>) -> Self {
355        Self {
356            exprs,
357            annotations: Vec::new(),
358        }
359    }
360
361    #[must_use]
362    pub fn with_annotations(exprs: Vec<Expr>, annotations: Vec<Annotation>) -> Self {
363        Self { exprs, annotations }
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    #[test]
372    fn test_expr_is_atom() {
373        assert!(Expr::Nil.is_atom());
374        assert!(Expr::Bool(true).is_atom());
375        assert!(Expr::Number(Fraction::from_integer(42)).is_atom());
376        assert!(Expr::String("hello".into()).is_atom());
377        assert!(Expr::Symbol("foo".into()).is_atom());
378        assert!(Expr::Keyword("bar".into()).is_atom());
379        assert!(!Expr::List(vec![]).is_atom());
380        assert!(!Expr::cons(Expr::Nil, Expr::Nil).is_atom());
381        assert!(Expr::Lambda(LambdaParams::simple(vec![]), Box::new(Expr::Nil)).is_atom());
382    }
383
384    #[test]
385    fn test_expr_as_symbol() {
386        assert_eq!(Expr::Symbol("foo".into()).as_symbol(), Some("foo"));
387        assert_eq!(Expr::Number(Fraction::from_integer(1)).as_symbol(), None);
388    }
389
390    #[test]
391    fn test_expr_as_list() {
392        let list = Expr::List(vec![Expr::Symbol("a".into())]);
393        assert!(list.as_list().is_some());
394        assert_eq!(list.as_list().unwrap().len(), 1);
395        assert!(Expr::Symbol("a".into()).as_list().is_none());
396    }
397}