//! 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, 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 { 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 { 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, 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 { 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 = 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 { let mut stack: Vec = Vec::new(); // Local variable environment (flat scope for now). let mut locals: std::collections::HashMap = 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_`). 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 = 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 = 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) -> 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 { // 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 { 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 { 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 { 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 { 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 { 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) -> Result { match name { "__build_list__" => { let n = arity as usize; let start = stack.len().saturating_sub(n); let items: Vec = 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 = stack.drain(start..).collect(); Ok(Value::Nil) } "len" => { let n = arity as usize; let start = stack.len().saturating_sub(n); let mut args: Vec = 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 = 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)); } }