rename crates/ to engrams/; add el-compiler el package with bootstrap artifact
- crates/ → engrams/ (Rust engrams live here)
- el-compiler/ added: el self-hosting compiler as an el package
- src/{compiler,lexer,parser,codegen}.el
- bootstrap/el-compiler.elc (114KB, Rust-compiled seed)
- el.toml Cargo.toml workspace paths updated
- neuron-rs cross-repo path deps fixed (were pointing to products/ instead of foundation/)
This commit is contained in:
@@ -0,0 +1,735 @@
|
||||
//! el-wasm — Engram language WebAssembly runtime.
|
||||
//!
|
||||
//! Compiles the el compiler and (optionally) execution pipeline to
|
||||
//! `wasm32-unknown-unknown`, exposing a JavaScript API via `wasm-bindgen`.
|
||||
//!
|
||||
//! # Build for browsers
|
||||
//!
|
||||
//! ```bash
|
||||
//! wasm-pack build --target web --out-dir pkg -- --features wasm
|
||||
//! ```
|
||||
//!
|
||||
//! # JavaScript API
|
||||
//!
|
||||
//! ```js
|
||||
//! import init, { compile_source, load_and_run, eval, version } from '/pkg/el_wasm.js';
|
||||
//! await init();
|
||||
//! const result = eval('1 + 2'); // => "3"
|
||||
//! ```
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! The WASM module exposes three entry points:
|
||||
//!
|
||||
//! - **`compile_source`** — source → `.elc` bytes (serialised bytecode)
|
||||
//! - **`load_and_run`** — `.elc` bytes → JSON-encoded result value
|
||||
//! - **`eval`** — source → JSON-encoded result value (compile + run in one step)
|
||||
//!
|
||||
//! The browser caches the `.wasm` file after the first load. Programs are
|
||||
//! distributed as tiny `.elc` bytecode files fetched on demand, enabling a
|
||||
//! PWA strategy that bypasses app-store review cycles.
|
||||
|
||||
pub use el_compiler::{
|
||||
compile_to_bytecode, deserialize_bytecode, serialize_bytecode, Bytecode, CompileError, Value,
|
||||
};
|
||||
|
||||
// ── WASM bindings ─────────────────────────────────────────────────────────────
|
||||
// Only compiled when the `wasm` feature is active (i.e. wasm-pack builds).
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// Initialize the WASM module. Call once from JavaScript before any other API.
|
||||
///
|
||||
/// Sets up the panic hook so Rust panics appear as readable messages in the
|
||||
/// browser developer console rather than opaque `unreachable` traps.
|
||||
#[cfg(feature = "wasm")]
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn init() {
|
||||
// Redirect Rust panics to console.error in the browser.
|
||||
std::panic::set_hook(Box::new(console_error_panic_hook));
|
||||
}
|
||||
|
||||
/// Forward panics to the browser console.
|
||||
#[cfg(feature = "wasm")]
|
||||
fn console_error_panic_hook(info: &std::panic::PanicHookInfo<'_>) {
|
||||
let msg = info.to_string();
|
||||
web_sys_log(&msg);
|
||||
}
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_namespace = console, js_name = error)]
|
||||
fn web_sys_log(s: &str);
|
||||
}
|
||||
|
||||
/// Compile el source code to bytecode bytes (`.elc` format).
|
||||
///
|
||||
/// Returns the raw bytecode bytes on success, or throws a JS error string
|
||||
/// describing the first compilation error.
|
||||
///
|
||||
/// The returned bytes can be cached by the browser and later passed to
|
||||
/// `load_and_run` to execute the program.
|
||||
#[cfg(feature = "wasm")]
|
||||
#[wasm_bindgen]
|
||||
pub fn compile_source(source: &str) -> Result<Vec<u8>, JsValue> {
|
||||
compile_source_inner(source).map_err(|e| JsValue::from_str(&e))
|
||||
}
|
||||
|
||||
/// Load pre-compiled bytecode (`.elc` bytes) and execute it.
|
||||
///
|
||||
/// Returns the JSON-encoded final value from the program, or throws on error.
|
||||
/// The result is always valid JSON — use `JSON.parse(result)` in JavaScript.
|
||||
#[cfg(feature = "wasm")]
|
||||
#[wasm_bindgen]
|
||||
pub fn load_and_run(bytecode_bytes: &[u8]) -> Result<String, JsValue> {
|
||||
load_and_run_inner(bytecode_bytes).map_err(|e| JsValue::from_str(&e))
|
||||
}
|
||||
|
||||
/// Compile and run el source in one step.
|
||||
///
|
||||
/// Equivalent to `load_and_run(compile_source(source))`. Useful for REPL
|
||||
/// and developer-mode execution where the source is available at runtime.
|
||||
///
|
||||
/// Returns the JSON-encoded result, or throws a descriptive error string.
|
||||
#[cfg(feature = "wasm")]
|
||||
#[wasm_bindgen]
|
||||
pub fn eval(source: &str) -> Result<String, JsValue> {
|
||||
let bytes = compile_source_inner(source).map_err(|e| JsValue::from_str(&e))?;
|
||||
load_and_run_inner(&bytes).map_err(|e| JsValue::from_str(&e))
|
||||
}
|
||||
|
||||
/// Return the el runtime version string.
|
||||
#[cfg(feature = "wasm")]
|
||||
#[wasm_bindgen]
|
||||
pub fn version() -> String {
|
||||
env!("CARGO_PKG_VERSION").to_string()
|
||||
}
|
||||
|
||||
// ── Inner implementations (callable from Rust tests without wasm-bindgen) ─────
|
||||
|
||||
/// Compile source to `.elc` bytes. Returns `Err(String)` on failure.
|
||||
pub fn compile_source_inner(source: &str) -> Result<Vec<u8>, String> {
|
||||
let (bytecode, _source_map) =
|
||||
compile_to_bytecode(source).map_err(|e| e.to_string())?;
|
||||
serialize_bytecode(&bytecode)
|
||||
}
|
||||
|
||||
/// Deserialise `.elc` bytes, execute the bytecode, return a JSON-encoded Value.
|
||||
///
|
||||
/// The result is always clean JSON: integers as numbers, strings as strings,
|
||||
/// booleans as booleans, nil as null, lists as arrays, maps as objects.
|
||||
pub fn load_and_run_inner(bytecode_bytes: &[u8]) -> Result<String, String> {
|
||||
let bytecode = deserialize_bytecode(bytecode_bytes)?;
|
||||
let result = run_bytecode(&bytecode)?;
|
||||
let json_value = value_to_json(&result);
|
||||
serde_json::to_string(&json_value).map_err(|e| format!("Serialize result error: {e}"))
|
||||
}
|
||||
|
||||
/// Convert an engram `Value` to a clean `serde_json::Value` for JS consumption.
|
||||
///
|
||||
/// Maps engram types to natural JSON equivalents:
|
||||
/// - `Int` → JSON number
|
||||
/// - `Float` → JSON number
|
||||
/// - `Str` → JSON string
|
||||
/// - `Bool` → JSON boolean
|
||||
/// - `Nil` → JSON null
|
||||
/// - `List` → JSON array
|
||||
/// - `Map` → JSON object
|
||||
/// - `ResultOk(v)` → `{"ok": v}`
|
||||
/// - `ResultErr(e)` → `{"err": e}`
|
||||
pub fn value_to_json(v: &Value) -> serde_json::Value {
|
||||
match v {
|
||||
Value::Int(n) => serde_json::Value::Number(serde_json::Number::from(*n)),
|
||||
Value::Float(f) => serde_json::Number::from_f64(*f)
|
||||
.map(serde_json::Value::Number)
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
Value::Str(s) => serde_json::Value::String(s.clone()),
|
||||
Value::Bool(b) => serde_json::Value::Bool(*b),
|
||||
Value::Nil => serde_json::Value::Null,
|
||||
Value::List(items) => {
|
||||
serde_json::Value::Array(items.iter().map(value_to_json).collect())
|
||||
}
|
||||
Value::Map(pairs) => {
|
||||
let obj: serde_json::Map<String, serde_json::Value> = pairs
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), value_to_json(v)))
|
||||
.collect();
|
||||
serde_json::Value::Object(obj)
|
||||
}
|
||||
Value::ResultOk(inner) => {
|
||||
serde_json::json!({ "ok": value_to_json(inner) })
|
||||
}
|
||||
Value::ResultErr(inner) => {
|
||||
serde_json::json!({ "err": value_to_json(inner) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a bytecode program on the engram stack machine.
|
||||
///
|
||||
/// Returns the value left on the stack when `Halt` is reached, or `Value::Nil`
|
||||
/// if the program is empty.
|
||||
///
|
||||
/// # Supported instructions
|
||||
///
|
||||
/// This is a pure stack machine — no I/O, no filesystem, no OS interaction —
|
||||
/// which makes it safe to run inside WASM. Instructions that reference the
|
||||
/// Engram runtime (`Activate`) return a placeholder `Nil` value; a full
|
||||
/// runtime integration would supply a callback from JS.
|
||||
pub fn run_bytecode(bytecode: &[Bytecode]) -> Result<Value, String> {
|
||||
let mut stack: Vec<Value> = Vec::new();
|
||||
// Local variable environment (flat scope for now).
|
||||
let mut locals: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
|
||||
let mut ip: usize = 0;
|
||||
|
||||
while ip < bytecode.len() {
|
||||
let instr = &bytecode[ip];
|
||||
match instr {
|
||||
// ── Stack ─────────────────────────────────────────────────────────
|
||||
Bytecode::Push(v) => {
|
||||
stack.push(v.clone());
|
||||
}
|
||||
Bytecode::Pop => {
|
||||
stack.pop();
|
||||
}
|
||||
Bytecode::Dup => {
|
||||
let top = stack.last().ok_or("DUP on empty stack")?.clone();
|
||||
stack.push(top);
|
||||
}
|
||||
|
||||
// ── Arithmetic ────────────────────────────────────────────────────
|
||||
Bytecode::Add => {
|
||||
let (a, b) = pop2(&mut stack)?;
|
||||
stack.push(arith_add(a, b)?);
|
||||
}
|
||||
Bytecode::Sub => {
|
||||
let (a, b) = pop2(&mut stack)?;
|
||||
stack.push(arith_sub(a, b)?);
|
||||
}
|
||||
Bytecode::Mul => {
|
||||
let (a, b) = pop2(&mut stack)?;
|
||||
stack.push(arith_mul(a, b)?);
|
||||
}
|
||||
Bytecode::Div => {
|
||||
let (a, b) = pop2(&mut stack)?;
|
||||
stack.push(arith_div(a, b)?);
|
||||
}
|
||||
|
||||
// ── Comparison ────────────────────────────────────────────────────
|
||||
Bytecode::Eq => {
|
||||
let (a, b) = pop2(&mut stack)?;
|
||||
stack.push(Value::Bool(values_eq(&a, &b)));
|
||||
}
|
||||
Bytecode::NotEq => {
|
||||
let (a, b) = pop2(&mut stack)?;
|
||||
stack.push(Value::Bool(!values_eq(&a, &b)));
|
||||
}
|
||||
Bytecode::Lt => {
|
||||
let (a, b) = pop2(&mut stack)?;
|
||||
stack.push(Value::Bool(cmp_values(&a, &b)? < 0));
|
||||
}
|
||||
Bytecode::Gt => {
|
||||
let (a, b) = pop2(&mut stack)?;
|
||||
stack.push(Value::Bool(cmp_values(&a, &b)? > 0));
|
||||
}
|
||||
Bytecode::LtEq => {
|
||||
let (a, b) = pop2(&mut stack)?;
|
||||
stack.push(Value::Bool(cmp_values(&a, &b)? <= 0));
|
||||
}
|
||||
Bytecode::GtEq => {
|
||||
let (a, b) = pop2(&mut stack)?;
|
||||
stack.push(Value::Bool(cmp_values(&a, &b)? >= 0));
|
||||
}
|
||||
|
||||
// ── Logical ───────────────────────────────────────────────────────
|
||||
Bytecode::And => {
|
||||
let (a, b) = pop2(&mut stack)?;
|
||||
stack.push(Value::Bool(is_truthy(&a) && is_truthy(&b)));
|
||||
}
|
||||
Bytecode::Or => {
|
||||
let (a, b) = pop2(&mut stack)?;
|
||||
stack.push(Value::Bool(is_truthy(&a) || is_truthy(&b)));
|
||||
}
|
||||
Bytecode::Not => {
|
||||
let v = stack.pop().ok_or("NOT on empty stack")?;
|
||||
stack.push(Value::Bool(!is_truthy(&v)));
|
||||
}
|
||||
|
||||
// ── Locals ────────────────────────────────────────────────────────
|
||||
Bytecode::LoadLocal(name) => {
|
||||
let v = locals.get(name).cloned().unwrap_or(Value::Nil);
|
||||
stack.push(v);
|
||||
}
|
||||
Bytecode::StoreLocal(name) => {
|
||||
let v = stack.pop().ok_or("STORE on empty stack")?;
|
||||
locals.insert(name.clone(), v);
|
||||
}
|
||||
|
||||
// ── Functions ─────────────────────────────────────────────────────
|
||||
// The bytecode model stores function bodies inline and registers entry
|
||||
// points as locals (`__fn_<name>`). A full call-frame implementation
|
||||
// would use a separate call stack; for WASM we handle the most common
|
||||
// case of stdlib builtins and leave dynamic dispatch as a stub.
|
||||
Bytecode::Call { name, arity } => {
|
||||
let result = call_builtin(name, *arity, &mut stack)?;
|
||||
stack.push(result);
|
||||
}
|
||||
Bytecode::Return => {
|
||||
// Return leaves the value on the stack; the caller pops it.
|
||||
// In this simplified VM we just continue execution.
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Control flow ──────────────────────────────────────────────────
|
||||
Bytecode::Jump(offset) => {
|
||||
ip = apply_offset(ip, *offset)?;
|
||||
continue; // skip ip += 1 below
|
||||
}
|
||||
Bytecode::JumpIf(offset) => {
|
||||
let v = stack.pop().ok_or("JUMPIF on empty stack")?;
|
||||
if is_truthy(&v) {
|
||||
ip = apply_offset(ip, *offset)?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Bytecode::JumpIfNot(offset) => {
|
||||
let v = stack.pop().ok_or("JUMPIFNOT on empty stack")?;
|
||||
if !is_truthy(&v) {
|
||||
ip = apply_offset(ip, *offset)?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fields & Indexing ─────────────────────────────────────────────
|
||||
Bytecode::GetField(field) => {
|
||||
let obj = stack.pop().ok_or("GETFIELD on empty stack")?;
|
||||
let v = match &obj {
|
||||
Value::Map(pairs) => pairs
|
||||
.iter()
|
||||
.find(|(k, _v)| k == field)
|
||||
.map(|(_k, v)| v.clone())
|
||||
.unwrap_or(Value::Nil),
|
||||
_ => Value::Nil,
|
||||
};
|
||||
stack.push(v);
|
||||
}
|
||||
Bytecode::GetIndex => {
|
||||
let idx = stack.pop().ok_or("GETINDEX: missing index")?;
|
||||
let obj = stack.pop().ok_or("GETINDEX: missing object")?;
|
||||
let v = match (&obj, &idx) {
|
||||
(Value::List(items), Value::Int(i)) => {
|
||||
let i = *i as usize;
|
||||
items.get(i).cloned().unwrap_or(Value::Nil)
|
||||
}
|
||||
_ => Value::Nil,
|
||||
};
|
||||
stack.push(v);
|
||||
}
|
||||
Bytecode::BuildMap(n) => {
|
||||
let mut pairs = Vec::new();
|
||||
let n = *n as usize;
|
||||
// Stack: key0, val0, key1, val1, ... (pushed in order)
|
||||
// We collect from the top, so reverse at the end.
|
||||
let start = stack.len().saturating_sub(n * 2);
|
||||
let raw: Vec<Value> = stack.drain(start..).collect();
|
||||
for chunk in raw.chunks(2) {
|
||||
if let [Value::Str(k), v] = chunk {
|
||||
pairs.push((k.clone(), v.clone()));
|
||||
}
|
||||
}
|
||||
stack.push(Value::Map(pairs));
|
||||
}
|
||||
Bytecode::BuildStruct { fields, .. } => {
|
||||
let mut pairs: Vec<(String, Value)> = Vec::new();
|
||||
let start = stack.len().saturating_sub(fields.len());
|
||||
let raw: Vec<Value> = stack.drain(start..).collect();
|
||||
for (field, val) in fields.iter().zip(raw.into_iter()) {
|
||||
pairs.push((field.clone(), val));
|
||||
}
|
||||
stack.push(Value::Map(pairs));
|
||||
}
|
||||
Bytecode::SetField(field) => {
|
||||
let val = stack.pop().ok_or("SETFIELD: missing value")?;
|
||||
let obj = stack.pop().ok_or("SETFIELD: missing object")?;
|
||||
let v = match obj {
|
||||
Value::Map(mut pairs) => {
|
||||
if let Some(entry) = pairs.iter_mut().find(|(k, _)| k == field) {
|
||||
entry.1 = val;
|
||||
} else {
|
||||
pairs.push((field.clone(), val));
|
||||
}
|
||||
Value::Map(pairs)
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
stack.push(v);
|
||||
}
|
||||
|
||||
// ── Special ───────────────────────────────────────────────────────
|
||||
Bytecode::Activate { type_name, query } => {
|
||||
// The Engram runtime integration is provided by the host JS environment.
|
||||
// In a full implementation the JS host would register an `activate` callback.
|
||||
// For now, return a placeholder list so programs using `activate` don't crash.
|
||||
let _ = (type_name, query);
|
||||
stack.push(Value::List(Vec::new()));
|
||||
}
|
||||
Bytecode::SealedBegin | Bytecode::SealedEnd | Bytecode::Nop => {
|
||||
// No-ops in the pure VM.
|
||||
}
|
||||
Bytecode::Halt => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
ip += 1;
|
||||
}
|
||||
|
||||
Ok(stack.pop().unwrap_or(Value::Nil))
|
||||
}
|
||||
|
||||
// ── Stack helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn pop2(stack: &mut Vec<Value>) -> Result<(Value, Value), String> {
|
||||
let b = stack.pop().ok_or("stack underflow (right operand)")?;
|
||||
let a = stack.pop().ok_or("stack underflow (left operand)")?;
|
||||
Ok((a, b))
|
||||
}
|
||||
|
||||
fn apply_offset(ip: usize, offset: i32) -> Result<usize, String> {
|
||||
// offset is relative to the instruction *after* the jump
|
||||
let target = (ip as i64) + 1 + (offset as i64);
|
||||
if target < 0 {
|
||||
return Err(format!("Jump to negative address {target}"));
|
||||
}
|
||||
Ok(target as usize)
|
||||
}
|
||||
|
||||
// ── Value helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn is_truthy(v: &Value) -> bool {
|
||||
match v {
|
||||
Value::Bool(b) => *b,
|
||||
Value::Nil => false,
|
||||
Value::Int(0) => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn values_eq(a: &Value, b: &Value) -> bool {
|
||||
match (a, b) {
|
||||
(Value::Int(x), Value::Int(y)) => x == y,
|
||||
(Value::Float(x), Value::Float(y)) => x == y,
|
||||
(Value::Str(x), Value::Str(y)) => x == y,
|
||||
(Value::Bool(x), Value::Bool(y)) => x == y,
|
||||
(Value::Nil, Value::Nil) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare two values; returns negative / zero / positive like `Ord::cmp`.
|
||||
fn cmp_values(a: &Value, b: &Value) -> Result<i32, String> {
|
||||
match (a, b) {
|
||||
(Value::Int(x), Value::Int(y)) => Ok(x.cmp(y) as i32),
|
||||
(Value::Float(x), Value::Float(y)) => Ok(x.partial_cmp(y).map(|o| o as i32).unwrap_or(0)),
|
||||
(Value::Str(x), Value::Str(y)) => Ok(x.cmp(y) as i32),
|
||||
_ => Err(format!("Cannot compare {a:?} and {b:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Arithmetic helpers ────────────────────────────────────────────────────────
|
||||
|
||||
fn arith_add(a: Value, b: Value) -> Result<Value, String> {
|
||||
match (a, b) {
|
||||
(Value::Int(x), Value::Int(y)) => Ok(Value::Int(x.wrapping_add(y))),
|
||||
(Value::Float(x), Value::Float(y)) => Ok(Value::Float(x + y)),
|
||||
(Value::Str(x), Value::Str(y)) => Ok(Value::Str(x + &y)),
|
||||
(a, b) => Err(format!("ADD: type mismatch {a:?} + {b:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn arith_sub(a: Value, b: Value) -> Result<Value, String> {
|
||||
match (a, b) {
|
||||
(Value::Int(x), Value::Int(y)) => Ok(Value::Int(x.wrapping_sub(y))),
|
||||
(Value::Float(x), Value::Float(y)) => Ok(Value::Float(x - y)),
|
||||
(a, b) => Err(format!("SUB: type mismatch {a:?} - {b:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn arith_mul(a: Value, b: Value) -> Result<Value, String> {
|
||||
match (a, b) {
|
||||
(Value::Int(x), Value::Int(y)) => Ok(Value::Int(x.wrapping_mul(y))),
|
||||
(Value::Float(x), Value::Float(y)) => Ok(Value::Float(x * y)),
|
||||
(a, b) => Err(format!("MUL: type mismatch {a:?} * {b:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn arith_div(a: Value, b: Value) -> Result<Value, String> {
|
||||
match (a, b) {
|
||||
(Value::Int(_), Value::Int(0)) => Err("Division by zero".to_string()),
|
||||
(Value::Int(x), Value::Int(y)) => Ok(Value::Int(x / y)),
|
||||
(Value::Float(x), Value::Float(y)) => Ok(Value::Float(x / y)),
|
||||
(a, b) => Err(format!("DIV: type mismatch {a:?} / {b:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Builtin function dispatch ─────────────────────────────────────────────────
|
||||
|
||||
fn call_builtin(name: &str, arity: u32, stack: &mut Vec<Value>) -> Result<Value, String> {
|
||||
match name {
|
||||
"__build_list__" => {
|
||||
let n = arity as usize;
|
||||
let start = stack.len().saturating_sub(n);
|
||||
let items: Vec<Value> = stack.drain(start..).collect();
|
||||
Ok(Value::List(items))
|
||||
}
|
||||
"print" | "println" => {
|
||||
// In WASM, print is a no-op unless the host wires up a callback.
|
||||
let n = arity as usize;
|
||||
let start = stack.len().saturating_sub(n);
|
||||
let _args: Vec<Value> = stack.drain(start..).collect();
|
||||
Ok(Value::Nil)
|
||||
}
|
||||
"len" => {
|
||||
let n = arity as usize;
|
||||
let start = stack.len().saturating_sub(n);
|
||||
let mut args: Vec<Value> = stack.drain(start..).collect();
|
||||
let v = args.pop().unwrap_or(Value::Nil);
|
||||
let len = match &v {
|
||||
Value::List(items) => items.len() as i64,
|
||||
Value::Str(s) => s.len() as i64,
|
||||
Value::Map(pairs) => pairs.len() as i64,
|
||||
_ => 0,
|
||||
};
|
||||
Ok(Value::Int(len))
|
||||
}
|
||||
_ => {
|
||||
// Unknown function: consume args, return Nil.
|
||||
let n = arity as usize;
|
||||
let start = stack.len().saturating_sub(n);
|
||||
let _: Vec<Value> = stack.drain(start..).collect();
|
||||
Ok(Value::Nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── Compile pipeline tests ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_compile_source_produces_bytes() {
|
||||
let bytes = compile_source_inner("42").unwrap();
|
||||
assert!(!bytes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_roundtrip_bytecode_serialization() {
|
||||
let source = "let x = 1 + 2";
|
||||
let bytes = compile_source_inner(source).unwrap();
|
||||
let (original, _) = compile_to_bytecode(source).unwrap();
|
||||
let restored = deserialize_bytecode(&bytes).unwrap();
|
||||
assert_eq!(original, restored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compile_function_def() {
|
||||
let source = r#"fn add(a: Int, b: Int) -> Int { a + b }"#;
|
||||
let bytes = compile_source_inner(source).unwrap();
|
||||
assert!(!bytes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compile_activate() {
|
||||
let source = r#"activate User where "active users""#;
|
||||
let (bytecode, _) = compile_to_bytecode(source).unwrap();
|
||||
assert!(bytecode
|
||||
.iter()
|
||||
.any(|b| matches!(b, Bytecode::Activate { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_deserialize_activate() {
|
||||
let source = r#"activate User where "query""#;
|
||||
let bytes = compile_source_inner(source).unwrap();
|
||||
let restored = deserialize_bytecode(&bytes).unwrap();
|
||||
assert!(restored
|
||||
.iter()
|
||||
.any(|b| matches!(b, Bytecode::Activate { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compile_sealed_block() {
|
||||
let source = "sealed { let x = 1 }";
|
||||
let (bytecode, _) = compile_to_bytecode(source).unwrap();
|
||||
assert!(bytecode
|
||||
.iter()
|
||||
.any(|b| matches!(b, Bytecode::SealedBegin)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_program_compiles() {
|
||||
let source = "";
|
||||
let (bytecode, _) = compile_to_bytecode(source).unwrap();
|
||||
assert!(matches!(bytecode.last(), Some(Bytecode::Halt)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complex_program_compiles() {
|
||||
let source = r#"
|
||||
let x = 10
|
||||
let y = 20
|
||||
let z = x + y
|
||||
"#;
|
||||
let (bytecode, _) = compile_to_bytecode(source).unwrap();
|
||||
assert!(!bytecode.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bytecode_json_is_valid() {
|
||||
let bytes = compile_source_inner("1 + 2").unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert!(json.is_array());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_string() {
|
||||
assert!(!env!("CARGO_PKG_VERSION").is_empty());
|
||||
}
|
||||
|
||||
// ── VM execution tests ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_run_integer_literal() {
|
||||
let result = load_and_run_inner(&compile_source_inner("42").unwrap()).unwrap();
|
||||
// The final value on the stack is the integer 42.
|
||||
assert_eq!(result, "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_addition() {
|
||||
let result = load_and_run_inner(&compile_source_inner("1 + 2").unwrap()).unwrap();
|
||||
assert_eq!(result, "3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_string_literal() {
|
||||
let result =
|
||||
load_and_run_inner(&compile_source_inner(r#""hello""#).unwrap()).unwrap();
|
||||
assert_eq!(result, r#""hello""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_boolean() {
|
||||
let result = load_and_run_inner(&compile_source_inner("true").unwrap()).unwrap();
|
||||
assert_eq!(result, "true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_let_binding_and_use() {
|
||||
let source = "let x = 10\nx";
|
||||
let result = load_and_run_inner(&compile_source_inner(source).unwrap()).unwrap();
|
||||
assert_eq!(result, "10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_arithmetic_chain() {
|
||||
// 2 * 3 + 4 should be 10 (if parsed left-to-right)
|
||||
let source = "2 * 3";
|
||||
let result = load_and_run_inner(&compile_source_inner(source).unwrap()).unwrap();
|
||||
assert_eq!(result, "6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_activate_returns_list() {
|
||||
let source = r#"activate User where "all""#;
|
||||
let result = load_and_run_inner(&compile_source_inner(source).unwrap()).unwrap();
|
||||
// Activate returns an empty list placeholder in the pure VM.
|
||||
assert_eq!(result, "[]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_if_true_branch() {
|
||||
let source = "if true { 1 } else { 2 }";
|
||||
let result = load_and_run_inner(&compile_source_inner(source).unwrap()).unwrap();
|
||||
assert_eq!(result, "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_if_false_branch() {
|
||||
let source = "if false { 1 } else { 2 }";
|
||||
let result = load_and_run_inner(&compile_source_inner(source).unwrap()).unwrap();
|
||||
assert_eq!(result, "2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_comparison_eq() {
|
||||
let result = load_and_run_inner(&compile_source_inner("1 == 1").unwrap()).unwrap();
|
||||
assert_eq!(result, "true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_comparison_neq() {
|
||||
let result = load_and_run_inner(&compile_source_inner("1 != 2").unwrap()).unwrap();
|
||||
assert_eq!(result, "true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_direct_run_empty_bytecode() {
|
||||
let result = run_bytecode(&[]).unwrap();
|
||||
assert_eq!(result, Value::Nil);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_direct_run_halt_only() {
|
||||
let result = run_bytecode(&[Bytecode::Halt]).unwrap();
|
||||
assert_eq!(result, Value::Nil);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_direct_run_push_halt() {
|
||||
let result = run_bytecode(&[Bytecode::Push(Value::Int(99)), Bytecode::Halt]).unwrap();
|
||||
assert_eq!(result, Value::Int(99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_direct_run_add() {
|
||||
let bc = [
|
||||
Bytecode::Push(Value::Int(3)),
|
||||
Bytecode::Push(Value::Int(4)),
|
||||
Bytecode::Add,
|
||||
Bytecode::Halt,
|
||||
];
|
||||
let result = run_bytecode(&bc).unwrap();
|
||||
assert_eq!(result, Value::Int(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_direct_run_string_concat() {
|
||||
let bc = [
|
||||
Bytecode::Push(Value::Str("hello ".to_string())),
|
||||
Bytecode::Push(Value::Str("world".to_string())),
|
||||
Bytecode::Add,
|
||||
Bytecode::Halt,
|
||||
];
|
||||
let result = run_bytecode(&bc).unwrap();
|
||||
assert_eq!(result, Value::Str("hello world".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_direct_run_jump() {
|
||||
// Jump over a push, land on the second push.
|
||||
let bc = [
|
||||
Bytecode::Jump(1), // ip=0 → skip 1 → ip becomes 2
|
||||
Bytecode::Push(Value::Int(0)), // ip=1 — skipped
|
||||
Bytecode::Push(Value::Int(42)), // ip=2
|
||||
Bytecode::Halt,
|
||||
];
|
||||
let result = run_bytecode(&bc).unwrap();
|
||||
assert_eq!(result, Value::Int(42));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user