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}