Merge worktree-agent: add struct literals, generics, print/log builtins
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "el-wasm"
|
||||
description = "Engram language WebAssembly runtime — runs .el programs natively in browsers"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
el-compiler = { workspace = true }
|
||||
wasm-bindgen = { workspace = true, optional = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
getrandom = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
# Enable the wasm-bindgen JS API. Pass `--features wasm` to wasm-pack.
|
||||
wasm = ["dep:wasm-bindgen", "dep:getrandom"]
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
# Build the engram-lang WASM package.
|
||||
#
|
||||
# Requires wasm-pack:
|
||||
# cargo install wasm-pack
|
||||
#
|
||||
# Output: crates/el-wasm/pkg/
|
||||
# el_wasm_bg.wasm — the compiled WebAssembly module
|
||||
# el_wasm.js — ES module JS bindings generated by wasm-bindgen
|
||||
# el_wasm.d.ts — TypeScript type definitions
|
||||
# package.json — npm package metadata
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "Building engram-lang WASM runtime..."
|
||||
wasm-pack build --target web --out-dir pkg -- --features wasm
|
||||
|
||||
echo ""
|
||||
echo "Done. Output in crates/el-wasm/pkg/"
|
||||
echo ""
|
||||
ls -lh pkg/*.wasm pkg/*.js 2>/dev/null || ls pkg/
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "Neuron",
|
||||
"short_name": "Neuron",
|
||||
"description": "Neuron — AI companion powered by engram-lang",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0a0a0f",
|
||||
"theme_color": "#6c7fff",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||
],
|
||||
"categories": ["productivity", "utilities"],
|
||||
"lang": "en-US"
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Engram-lang WASM runtime loader
|
||||
// Include this in any web app to run .el programs natively in the browser.
|
||||
//
|
||||
// Usage:
|
||||
// import { initRuntime, runElc, evalSource } from '/js/runtime.js';
|
||||
//
|
||||
// await initRuntime();
|
||||
// const result = await runElc('/programs/main.elc');
|
||||
|
||||
let wasmModule = null;
|
||||
|
||||
/**
|
||||
* Initialise the engram-lang WASM runtime. Safe to call multiple times —
|
||||
* subsequent calls return the already-loaded module immediately.
|
||||
*
|
||||
* @param {string} wasmUrl - Path to the .wasm file (default: /pkg/el_wasm_bg.wasm)
|
||||
* @returns {Promise<object>} The loaded runtime API
|
||||
*/
|
||||
export async function initRuntime(wasmUrl = '/pkg/el_wasm_bg.wasm') {
|
||||
if (wasmModule) return wasmModule;
|
||||
|
||||
const { default: init, compile_source, load_and_run, eval: elEval, version } =
|
||||
await import('/pkg/el_wasm.js');
|
||||
|
||||
await init(wasmUrl);
|
||||
|
||||
wasmModule = { compile_source, load_and_run, eval: elEval, version };
|
||||
console.log(`engram-lang WASM runtime v${version()} loaded`);
|
||||
return wasmModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a pre-compiled .elc file from the server and execute it.
|
||||
*
|
||||
* The browser caches the .elc file automatically based on Cache-Control
|
||||
* headers set by the server. New bytecode is available immediately on the
|
||||
* next fetch — no app-store review required.
|
||||
*
|
||||
* @param {string} elcUrl - URL of the .elc bytecode file
|
||||
* @returns {Promise<any>} The JSON-deserialised result value
|
||||
*/
|
||||
export async function runElc(elcUrl) {
|
||||
const rt = await initRuntime();
|
||||
const response = await fetch(elcUrl);
|
||||
if (!response.ok) throw new Error(`Failed to fetch ${elcUrl}: ${response.status}`);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
return JSON.parse(rt.load_and_run(bytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile engram-lang source and run it immediately. Useful for REPL and
|
||||
* developer-mode execution where source is available at runtime.
|
||||
*
|
||||
* @param {string} source - Engram-lang source code
|
||||
* @returns {Promise<any>} The JSON-deserialised result value
|
||||
*/
|
||||
export async function evalSource(source) {
|
||||
const rt = await initRuntime();
|
||||
return JSON.parse(rt.eval(source));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile engram-lang source to bytecode bytes without running it.
|
||||
* The returned Uint8Array can be stored or uploaded as a .elc file.
|
||||
*
|
||||
* @param {string} source - Engram-lang source code
|
||||
* @returns {Promise<Uint8Array>} Compiled bytecode bytes
|
||||
*/
|
||||
export async function compileSource(source) {
|
||||
const rt = await initRuntime();
|
||||
return rt.compile_source(source);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Service worker for the Neuron PWA powered by engram-lang.
|
||||
//
|
||||
// Caching strategy:
|
||||
// /pkg/ — WASM runtime files, cache-first (content-addressed, change on version bump)
|
||||
// *.elc — compiled bytecode, stale-while-revalidate (instant load, background update)
|
||||
//
|
||||
// The WASM runtime is fetched once and cached indefinitely.
|
||||
// Engram programs (.elc) are served from cache immediately, then refreshed in
|
||||
// the background so the next load gets the newest version — no user action needed.
|
||||
|
||||
const CACHE_NAME = 'engram-v1';
|
||||
const WASM_CACHE = 'engram-wasm-v1';
|
||||
|
||||
// Files to pre-cache during service worker installation.
|
||||
const PRECACHE = [
|
||||
'/pkg/el_wasm_bg.wasm',
|
||||
'/pkg/el_wasm.js',
|
||||
];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(
|
||||
caches.open(WASM_CACHE).then(cache => cache.addAll(PRECACHE))
|
||||
);
|
||||
// Take control immediately — don't wait for existing tabs to close.
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// WASM runtime files: cache-first.
|
||||
// These are large and rarely change; the version bump forces a new URL.
|
||||
if (url.pathname.startsWith('/pkg/')) {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(cached =>
|
||||
cached || fetch(event.request).then(response => {
|
||||
caches.open(WASM_CACHE).then(cache =>
|
||||
cache.put(event.request, response.clone())
|
||||
);
|
||||
return response;
|
||||
})
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Compiled bytecode (.elc): stale-while-revalidate.
|
||||
// Respond immediately from cache, update in background.
|
||||
if (url.pathname.endsWith('.elc')) {
|
||||
event.respondWith(
|
||||
caches.open(CACHE_NAME).then(async cache => {
|
||||
const cached = await cache.match(event.request);
|
||||
const fetchPromise = fetch(event.request).then(response => {
|
||||
cache.put(event.request, response.clone());
|
||||
return response;
|
||||
});
|
||||
return cached || fetchPromise;
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
// Purge old cache versions to reclaim storage.
|
||||
event.waitUntil(
|
||||
caches.keys().then(keys =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter(k => k !== CACHE_NAME && k !== WASM_CACHE)
|
||||
.map(k => caches.delete(k))
|
||||
)
|
||||
)
|
||||
);
|
||||
// Claim all clients immediately.
|
||||
self.clients.claim();
|
||||
});
|
||||
@@ -0,0 +1,735 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user