elly_core/value.rs
1//! Runtime [`Value`] and payload types: [`ObjectData`] and [`HostFn`].
2//! Includes value equality, hashing, and human-readable rendering. Split from
3//! `eval.rs`; the evaluator drives these via `apply1`/`apply_n` and owns the
4//! environment types ([`EnvNode`], [`ModuleData`], [`Ctx`], [`Raised`]).
5
6use alloc::boxed::Box;
7use alloc::rc::Rc;
8use alloc::string::{String, ToString};
9use alloc::vec::Vec;
10use core::hash::{Hash, Hasher};
11
12use num_bigint::BigInt;
13use rpds::{HashTrieMap, Vector};
14
15use crate::ast::{json_str, Lambda, Text};
16
17use super::{Builtin, Ctx, Env, EnvNode, EnvRef, ModuleData, Raised};
18
19/// An object: a payload and the prototype that defines its type.
20/// A [`Value::Object`] is an `Rc` pointer to `ObjectData`.
21///
22/// `proto` is the [`Module`](EnvNode::Module) terminal that is the instance; methods
23/// dispatched on the object run in this environment. This is also the handle
24/// used by [`Value::Iota`]'s `home`.
25///
26/// `data` is a single-slot payload. Records with named fields are not yet implemented;
27/// maps are the current idiom. Only the module's own code can access this via
28/// [`__value`](crate::HomeLeaf::Value).
29#[derive(Debug)]
30pub struct ObjectData {
31 pub(crate) proto: EnvRef,
32 pub(crate) data: Value,
33}
34
35impl ObjectData {
36 /// The object's prototype, as the module value it is.
37 pub fn proto(&self) -> Value {
38 Value::Module(self.proto.clone())
39 }
40
41 /// The object's payload. Public to Rust because a debugger and an embedding
42 /// host sit outside the language's module scope; no *builtin* hands it out,
43 /// so Elly code cannot reach it except through its own module's `__value`.
44 pub fn data(&self) -> &Value {
45 &self.data
46 }
47}
48
49/// A runtime value.
50///
51/// On 64-bit targets `Value` is aligned to 16 bytes: it is already 32 bytes
52/// wide, so this costs it nothing, and its 16-byte halves never straddle a
53/// cache line. The `EnvNode` cons cell grows from 40 to 48 bytes (its `Rc`
54/// allocation from 56 to 64, which the system allocator rounds 56 up to
55/// anyway), for 3–7% on the environment-heavy benches. On wasm32 `Value` is 24
56/// bytes, and the alignment would pad it to 32 and cost 5–16%, so it is left off.
57#[derive(Debug, Clone)]
58#[cfg_attr(target_pointer_width = "64", repr(align(16)))]
59pub enum Value {
60 /// Machine-word integer: the canonical representation for any integer fitting
61 /// in `i64`. Stored inline to avoid allocation on common arithmetic paths.
62 /// The invariant "an integer is [`Value::BigInt`] iff it does not fit `i64`"
63 /// ensures well-defined equality and hashing.
64 I64(i64),
65 /// Arbitrary-precision integer for magnitudes outside `i64`. Stored behind
66 /// an `Rc` to keep `Value` width small and make cloning a refcount bump.
67 /// Always `|n| > i64::MAX` per the canonical invariant.
68 BigInt(Rc<BigInt>),
69 /// A symbol literal (stored without the leading dot) as owned [`Text`].
70 /// Rendered with a leading dot.
71 Symbol(Text),
72 /// An owned, immutable UTF-8 string ([`Text`]). Equality and order follow
73 /// Rust's `&str` (lexicographic byte order). Rendered quoted and re-escaped.
74 Str(Text),
75 /// The unit value `()`. A nullary variant that does not widen `Value`.
76 /// Distinct from the empty list.
77 Unit,
78 /// A runtime list backed by an immutable `rpds::Vector` (O(log n) access,
79 /// cheap structural-sharing clone/append). The empty list is a `List` of
80 /// arity zero, not `Unit`.
81 List(Vector<Value>),
82 /// A closure capturing its environment over shared `code`. `id` is a
83 /// per-runtime unique identity minted at creation; closures compare by
84 /// identity rather than structure. `code` is the `Lambda` (patterns and body);
85 /// `applied` tracks bound parameters, so remaining ones are `code.head[applied..]`.
86 /// Partial application extends `env` and increments `applied` without
87 /// re-allocating the `Lambda`.
88 Closure {
89 id: u64,
90 code: Rc<Lambda>,
91 applied: u8,
92 env: Env,
93 },
94 /// An immutable, persistent, unordered map from keys to values, backed by
95 /// `rpds::HashTrieMap`. Keys are identified by structural value equality.
96 /// See `docs/elly-spec.md` § maps.
97 Map(HashTrieMap<Value, Value>),
98 /// A partially-applied builtin: a native `__…` operation and its
99 /// gathered arguments. Invoked when `args.len()` reaches the op's arity.
100 Builtin { op: Builtin, args: Vec<Value> },
101 /// A partially-applied host function: a callback provided by the embedder
102 /// ([`HostFn`]) and its gathered arguments. This is the primary FFI boundary
103 /// in the calling direction.
104 ///
105 /// It is a `Value` rather than an `Expr` so it retains meaning regardless of
106 /// the context it is handed. Identity is the `Rc` pointer plus applied
107 /// arguments.
108 ///
109 /// Applied arguments use `Box<[Value]>` instead of `Vec` to keep `Value`
110 /// width at four words, as pinned by `tests/sizes.rs`. Both are one
111 /// allocation per curried step.
112 HostFn {
113 code: Rc<HostFn>,
114 args: Box<[Value]>,
115 },
116 /// A module instance: the module's [`Module`](EnvNode::Module) terminal.
117 /// The value is the terminal itself, ensuring the module's code and its
118 /// instantiation environment are linked. Applying a symbol materializes
119 /// the item. Identity is the `Rc` pointer.
120 Module(EnvRef),
121 /// An iota: an atomic identity declared with `const`. Equal only to itself.
122 /// Identity is the pair (instance, position), where `home` is the instance's
123 /// [`Module`](EnvNode::Module) terminal and `index` is the position in
124 /// [`ModuleData::iota_names`]. Derived upon reference; two references to the
125 /// same iota of one instance are equal.
126 Iota { home: EnvRef, index: u32 },
127 /// An object: a payload plus the prototype module defining its type
128 /// (see [`ObjectData`]). Built by the [`__new`](crate::HomeLeaf::New) leaf
129 /// of the owning module. Applying a symbol dispatches the name by materializing
130 /// it from the prototype and applying it to the object. Compared by payload
131 /// contents and prototype identity.
132 Object(Rc<ObjectData>),
133}
134
135/// Structural value equality used by `__eq`. Data compares structurally
136/// (integers mathematically, symbols/strings by text, lists elementwise, maps by
137/// content); callables compare by identity (builtins by operation and arguments,
138/// closures by unique id). Different variants are never equal. Total order is not
139/// defined; `<` / `>` only compare `Int` / `Str`.
140impl PartialEq for Value {
141 fn eq(&self, other: &Self) -> bool {
142 match (self, other) {
143 (Value::I64(a), Value::I64(b)) => a == b,
144 (Value::BigInt(a), Value::BigInt(b)) => a == b,
145 // An `I64` and a `BigInt` never compare equal: by the canonical
146 // invariant they partition the integers (a `BigInt` never fits `i64`),
147 // so they cannot denote the same number.
148 (Value::Symbol(a), Value::Symbol(b)) => a == b,
149 (Value::Str(a), Value::Str(b)) => a == b,
150 // Unit is a singleton — one value, so any two are equal.
151 (Value::Unit, Value::Unit) => true,
152 (Value::List(a), Value::List(b)) => a == b,
153 // `HashTrieMap`'s own `PartialEq` is content-based (order-independent).
154 (Value::Map(a), Value::Map(b)) => a == b,
155 // Same operation and equal applied arguments.
156 (Value::Builtin { op: o1, args: a1 }, Value::Builtin { op: o2, args: a2 }) => {
157 o1 == o2 && a1 == a2
158 }
159 // The same reading for a host callback, with the allocation standing
160 // in for the operation: a `HostFn` has no structure to compare, so
161 // two of one name are two functions.
162 (
163 Value::HostFn {
164 code: c1, args: a1, ..
165 },
166 Value::HostFn {
167 code: c2, args: a2, ..
168 },
169 ) => Rc::ptr_eq(c1, c2) && a1 == a2,
170 (Value::Closure { id: i1, .. }, Value::Closure { id: i2, .. }) => i1 == i2,
171 // A module value is its instance's identity: the terminal's `Rc`
172 // pointer, not the item contents (mirrors the closure "compare by
173 // identity" precedent).
174 (Value::Module(a), Value::Module(b)) => EnvRef::ptr_eq(a, b),
175 // An iota is the instance it was declared by plus its position, so
176 // two iotas of one instance differ and one iota of two instantiations
177 // does too.
178 (
179 Value::Iota {
180 home: a, index: i, ..
181 },
182 Value::Iota {
183 home: b, index: j, ..
184 },
185 ) => i == j && EnvRef::ptr_eq(a, b),
186 // An object is its prototype by *identity* and its payload by
187 // *contents* — the nominal/structural mix objects are for. The payload
188 // half is what makes two accesses of one data item compare equal, and
189 // what lets an object be a map key.
190 (Value::Object(a), Value::Object(b)) => {
191 EnvRef::ptr_eq(&a.proto, &b.proto) && a.data == b.data
192 }
193 _ => false,
194 }
195 }
196}
197
198impl Eq for Value {}
199
200/// A `Hash` consistent with structural `PartialEq`. A per-variant discriminant
201/// prevents collisions between different types (e.g. symbol `.0` and integer `0`).
202/// `Map` keys hash order-independently by summing the hash of entries.
203impl Hash for Value {
204 fn hash<H: Hasher>(&self, state: &mut H) {
205 core::mem::discriminant(self).hash(state);
206 match self {
207 Value::I64(n) => n.hash(state),
208 Value::BigInt(n) => n.hash(state),
209 Value::Symbol(s) => s.hash(state),
210 Value::Str(s) => s.hash(state),
211 // Unit carries no payload; the discriminant above is its whole hash.
212 Value::Unit => {}
213 Value::List(vs) => {
214 for v in vs.iter() {
215 v.hash(state);
216 }
217 }
218 Value::Map(m) => {
219 let mut acc: u64 = 0;
220 for (k, v) in m.iter() {
221 let mut h = FnvHasher::new();
222 k.hash(&mut h);
223 v.hash(&mut h);
224 acc = acc.wrapping_add(h.finish());
225 }
226 acc.hash(state);
227 }
228 Value::Builtin { op, args } => {
229 op.hash(state);
230 for a in args {
231 a.hash(state);
232 }
233 }
234 Value::HostFn { code, args } => {
235 (Rc::as_ptr(code) as usize).hash(state);
236 for a in args {
237 a.hash(state);
238 }
239 }
240 Value::Closure { id, .. } => id.hash(state),
241 // Hash by the same identity used for equality: the terminal's pointer.
242 Value::Module(home) => (EnvRef::as_ptr(home) as usize).hash(state),
243 Value::Iota { home, index } => {
244 (EnvRef::as_ptr(home) as usize).hash(state);
245 index.hash(state);
246 }
247 // Mirrors the equality above: the prototype's pointer, the payload's
248 // contents.
249 Value::Object(obj) => {
250 (EnvRef::as_ptr(&obj.proto) as usize).hash(state);
251 obj.data.hash(state);
252 }
253 }
254 }
255}
256
257/// A deterministic no_std FNV-1a `Hasher` used for order-independent map
258/// hashing (see `impl Hash for Value`). Not the hasher used by `HashTrieMap`.
259struct FnvHasher(u64);
260
261impl FnvHasher {
262 fn new() -> Self {
263 FnvHasher(0xcbf2_9ce4_8422_2325)
264 }
265}
266
267impl Hasher for FnvHasher {
268 fn finish(&self) -> u64 {
269 self.0
270 }
271 fn write(&mut self, bytes: &[u8]) {
272 for &b in bytes {
273 self.0 ^= u64::from(b);
274 self.0 = self.0.wrapping_mul(0x0000_0100_0000_01b3);
275 }
276 }
277}
278
279/// A host function: a native callback provided by the embedder, behind the `Rc`
280/// a [`Value::HostFn`] carries.
281///
282/// It includes a name, arity, and code. The evaluator gathers arguments according
283/// to `arity` before calling the function, making it strict and curried.
284///
285/// `Ctx` is passed to allow the callback to call Elly functions ([`apply_with`](crate::apply_with))
286/// using the same ID source, preventing identity collisions for minted closures.
287///
288/// Arity must be at least 1; zero-argument constants are supplied as values.
289pub struct HostFn {
290 pub(crate) name: Text,
291 pub(crate) arity: usize,
292 pub(crate) f: Box<HostCall>,
293}
294
295/// The contract a host callback signs: a whole call's arguments — exactly
296/// [`arity`](HostFn::arity) of them, since the evaluator gathers before it fires
297/// — plus the [`Ctx`], answering a value or a [`Raised`].
298pub type HostCall = dyn Fn(&[Value], &Ctx) -> Result<Value, Raised>;
299
300impl HostFn {
301 /// A host function taking `arity` arguments.
302 ///
303 /// Name is for diagnostics; identity is the allocation. Panics on `arity == 0`.
304 pub fn new(
305 name: impl Into<Text>,
306 arity: usize,
307 f: impl Fn(&[Value], &Ctx) -> Result<Value, Raised> + 'static,
308 ) -> HostFn {
309 assert!(arity > 0, "a host function takes at least one argument");
310 HostFn {
311 name: name.into(),
312 arity,
313 f: Box::new(f),
314 }
315 }
316
317 /// The name the function renders under.
318 pub fn name(&self) -> &Text {
319 &self.name
320 }
321
322 /// How many arguments it is called with.
323 pub fn arity(&self) -> usize {
324 self.arity
325 }
326}
327
328/// The callback is not `Debug`, so the derive cannot apply; the shape a
329/// `Value::HostFn` shows is its name and arity, which is what identifies it in a
330/// dump anyway.
331impl core::fmt::Debug for HostFn {
332 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
333 f.debug_struct("HostFn")
334 .field("name", &self.name)
335 .field("arity", &self.arity)
336 .finish_non_exhaustive()
337 }
338}
339
340impl Value {
341 /// Builds an integer value from a `BigInt`, demoting to [`Value::I64`] if it
342 /// fits. This maintains the canonical invariant that a `BigInt` is used only
343 /// when the value does not fit `i64`, ensuring consistent equality and hashing.
344 pub fn from_bigint(n: BigInt) -> Value {
345 match i64::try_from(&n) {
346 Ok(i) => Value::I64(i),
347 Err(_) => Value::BigInt(Rc::new(n)),
348 }
349 }
350
351 /// [`from_bigint`](Value::from_bigint) for a `BigInt` reference. Demotion
352 /// to `i64` is decided before cloning to avoid unnecessary allocations
353 /// for literals fitting in `i64`.
354 pub fn from_bigint_ref(n: &BigInt) -> Value {
355 match i64::try_from(n) {
356 Ok(i) => Value::I64(i),
357 Err(_) => Value::BigInt(Rc::new(n.clone())),
358 }
359 }
360
361 /// Creates a `Value::HostFn` from a callback. Unapplied, so the first
362 /// argument begins the currying process. Panics on `arity == 0`.
363 pub fn host_fn(
364 name: impl Into<Text>,
365 arity: usize,
366 f: impl Fn(&[Value], &Ctx) -> Result<Value, Raised> + 'static,
367 ) -> Value {
368 Value::HostFn {
369 code: Rc::new(HostFn::new(name, arity, f)),
370 args: Box::new([]),
371 }
372 }
373
374 /// Creates a list value from its elements. Hides the backing `rpds::Vector`
375 /// to avoid version/feature mismatches between the core and the host.
376 pub fn list(elems: impl IntoIterator<Item = Value>) -> Value {
377 Value::List(elems.into_iter().collect())
378 }
379
380 /// Creates a map value from its entries; later entries overwrite equal keys.
381 /// Hides `rpds` implementation details.
382 pub fn map(entries: impl IntoIterator<Item = (Value, Value)>) -> Value {
383 let mut m = HashTrieMap::new();
384 for (k, v) in entries {
385 m.insert_mut(k, v);
386 }
387 Value::Map(m)
388 }
389
390 /// Returns the elements of a list value, or `None` otherwise. Hides
391 /// `rpds` implementation details.
392 pub fn list_items(&self) -> Option<impl ExactSizeIterator<Item = &Value>> {
393 match self {
394 Value::List(elems) => Some(elems.iter()),
395 _ => None,
396 }
397 }
398
399 /// Returns the number of arguments a function-shaped value needs before it
400 /// runs. `None` for non-functions, including applicable values like lists
401 /// or modules (which use projection, not calls).
402 ///
403 /// Runners use this to detect under-application (see `docs/done/2026-08-14_elly-run.md`).
404 pub fn remaining_arity(&self) -> Option<usize> {
405 match self {
406 Value::Closure { code, applied, .. } => Some(code.head.len() - *applied as usize),
407 Value::Builtin { op, args } => Some(op.arity() - args.len()),
408 Value::HostFn { code, args } => Some(code.arity - args.len()),
409 _ => None,
410 }
411 }
412
413 /// Returns the [`ModuleData`] of a [`Value::Module`], or `None`. A module
414 /// value holds the [`Module`](EnvNode::Module) terminal; this provides access
415 /// to the item table without matching the environment shape.
416 pub fn module_data(&self) -> Option<&Rc<ModuleData>> {
417 match self {
418 Value::Module(home) => match &**home {
419 EnvNode::Module { data, .. } => Some(data),
420 _ => unreachable!("a module value always holds a module terminal"),
421 },
422 _ => None,
423 }
424 }
425
426 /// Returns the [`ObjectData`] of a [`Value::Object`], or `None`. Provides
427 /// access to the payload and prototype.
428 pub fn object_data(&self) -> Option<&Rc<ObjectData>> {
429 match self {
430 Value::Object(obj) => Some(obj),
431 _ => None,
432 }
433 }
434
435 /// Returns a human-readable rendering for golden tests.
436 pub fn to_display(&self) -> String {
437 let mut out = String::new();
438 self.write_display(&mut out);
439 out
440 }
441
442 fn write_display(&self, out: &mut String) {
443 match self {
444 Value::I64(n) => out.push_str(&n.to_string()),
445 Value::BigInt(n) => out.push_str(&n.to_string()),
446 Value::Symbol(s) => {
447 out.push('.');
448 out.push_str(s);
449 }
450 // A string renders as a quoted, re-escaped literal (`"foo"`),
451 // distinguishing it from symbols and names.
452 Value::Str(s) => json_str(s, out),
453 // Unit renders `()`, distinct from the empty list `[]`.
454 Value::Unit => out.push_str("()"),
455 // Lists render with brackets (`[]`, `[.a]`, `[.a, .b]`).
456 Value::List(vs) => {
457 out.push('[');
458 for (i, v) in vs.iter().enumerate() {
459 if i > 0 {
460 out.push_str(", ");
461 }
462 v.write_display(out);
463 }
464 out.push(']');
465 }
466 // Maps render sorted by rendered key text (ties broken by value)
467 // for stable dumps: `{}` or `{ .a: 1, .b: 2 }`.
468 Value::Map(m) => {
469 if m.is_empty() {
470 out.push_str("{}");
471 } else {
472 let mut entries: Vec<(String, String)> = m
473 .iter()
474 .map(|(k, v)| {
475 let mut ks = String::new();
476 k.write_display(&mut ks);
477 let mut vs = String::new();
478 v.write_display(&mut vs);
479 (ks, vs)
480 })
481 .collect();
482 entries.sort();
483 out.push('{');
484 for (i, (ks, vs)) in entries.iter().enumerate() {
485 out.push_str(if i > 0 { ", " } else { " " });
486 out.push_str(ks);
487 out.push_str(": ");
488 out.push_str(vs);
489 }
490 out.push_str(" }");
491 }
492 }
493 Value::Closure { .. } => out.push_str("<closure>"),
494 Value::Builtin { op, .. } => {
495 out.push_str("<builtin ");
496 out.push_str(op.name());
497 out.push('>');
498 }
499 // Host functions render by name and arity.
500 Value::HostFn { code, .. } => {
501 out.push_str("<hostfn ");
502 out.push_str(code.name.as_str());
503 out.push('>');
504 }
505 Value::Module(_) => out.push_str("<module>"),
506 // Iotas render as `<const .name>`. The module instance is not
507 // named to avoid dependence on host-defined resolver paths.
508 Value::Iota { home, index } => {
509 out.push_str("<const .");
510 if let EnvNode::Module { data, .. } = &**home {
511 if let Some(name) = data.iota(*index) {
512 out.push_str(name);
513 }
514 }
515 out.push('>');
516 }
517 // Objects render as `<object payload>`. The prototype is not
518 // named for the same reason as modules. The payload is shown to support
519 // debugging by hosts.
520 Value::Object(obj) => {
521 out.push_str("<object ");
522 obj.data.write_display(out);
523 out.push('>');
524 }
525 }
526 }
527}