Merge worktree-agent: add struct literals, generics, print/log builtins

This commit is contained in:
Will Anderson
2026-04-28 11:51:02 -05:00
40 changed files with 4058 additions and 72 deletions
+735
View File
@@ -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));
}
}