Archived
736 lines
28 KiB
Rust
736 lines
28 KiB
Rust
//! el-wasm — Engram language WebAssembly runtime.
|
|
//!
|
|
//! Compiles the engram-lang 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 engram-lang 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 engram-lang 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 engram-lang 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));
|
|
}
|
|
}
|