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
Generated
+49 -16
View File
@@ -328,7 +328,9 @@ dependencies = [
"clap",
"el-build",
"el-compiler",
"el-fmt",
"el-lexer",
"el-lint",
"el-manifest",
"el-parser",
"el-registry",
@@ -344,6 +346,14 @@ dependencies = [
"walkdir",
]
[[package]]
name = "el-arch"
version = "0.1.0"
dependencies = [
"el-lexer",
"el-parser",
]
[[package]]
name = "el-build"
version = "0.1.0"
@@ -377,6 +387,15 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "el-fmt"
version = "0.1.0"
dependencies = [
"el-lexer",
"el-parser",
"thiserror 2.0.18",
]
[[package]]
name = "el-integration"
version = "0.1.0"
@@ -397,6 +416,20 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "el-lint"
version = "0.1.0"
dependencies = [
"el-arch",
"el-fmt",
"el-lexer",
"el-parser",
"el-types",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "el-manifest"
version = "0.1.0"
@@ -992,9 +1025,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.95"
version = "0.3.97"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf"
dependencies = [
"cfg-if",
"futures-util",
@@ -1423,9 +1456,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.39"
version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"once_cell",
"ring",
@@ -2079,9 +2112,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.118"
version = "0.2.120"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1"
dependencies = [
"cfg-if",
"once_cell",
@@ -2092,9 +2125,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.68"
version = "0.4.70"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8"
checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -2102,9 +2135,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.118"
version = "0.2.120"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -2112,9 +2145,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.118"
version = "0.2.120"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -2125,9 +2158,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.118"
version = "0.2.120"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea"
dependencies = [
"unicode-ident",
]
@@ -2168,9 +2201,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.95"
version = "0.3.97"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d"
checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602"
dependencies = [
"js-sys",
"wasm-bindgen",
+4
View File
@@ -11,6 +11,8 @@ members = [
"crates/el-test",
"crates/el-stdlib",
"crates/el-integration",
"crates/el-fmt",
"crates/el-lint",
"bin/el",
]
resolver = "2"
@@ -34,6 +36,8 @@ el-build = { path = "crates/el-build" }
el-test = { path = "crates/el-test" }
el-stdlib = { path = "crates/el-stdlib" }
el-integration = { path = "crates/el-integration" }
el-fmt = { path = "crates/el-fmt" }
el-lint = { path = "crates/el-lint" }
# Engram crypto (path dep — the sealed target depends on it)
engram-crypto = { path = "../engram/crates/engram-crypto" }
+3 -1
View File
@@ -19,11 +19,13 @@ el-manifest = { workspace = true }
el-registry = { workspace = true }
el-build = { workspace = true }
el-test = { workspace = true }
el-fmt = { workspace = true }
el-lint = { workspace = true }
clap = { workspace = true }
thiserror = { workspace = true }
serde_json = { workspace = true }
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
reqwest = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
walkdir = { workspace = true }
tiny_http = { workspace = true }
+117 -43
View File
@@ -35,8 +35,6 @@ use el_compiler::{Compiler, CompilerOptions, Target};
use el_test;
use el_manifest::{BuildTarget, Manifest};
use el_seal::{seal as seal_fn, unseal as unseal_fn, SealedArtifact, DeploymentBinding, SealAlgorithm, SealConfig};
use el_fmt;
use el_lint;
// ── Global state (thread-local for simplicity) ────────────────────────────────
@@ -229,6 +227,15 @@ enum Command {
// ── Low-level / single-file ───────────────────────────────────────────────
/// Compile and immediately run a single .el source file (no el.toml required).
RunFile {
/// Source file (*.el).
file: PathBuf,
/// Arguments to pass to the program.
#[arg(trailing_var_arg = true)]
args: Vec<String>,
},
/// Compile a single .el source file (no el.toml required).
BuildFile {
/// Source file (*.el).
@@ -241,15 +248,6 @@ enum Command {
output: Option<PathBuf>,
},
/// Compile and run a single .el source file (no el.toml required).
RunFile {
/// Source file (*.el).
file: PathBuf,
/// Arguments passed to the program (available via the args() builtin).
#[arg(trailing_var_arg = true)]
args: Vec<String>,
},
/// Seal an existing release artifact.
Seal {
artifact: PathBuf,
@@ -528,6 +526,9 @@ async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
..Default::default()
};
let compiled = Compiler::compile(&source, opts)?;
for d in &compiled.diagnostics {
eprintln!("warning: {d}");
}
let instructions = el_compiler::Bytecode::deserialize_all(&compiled.artifact)
.unwrap_or_default();
run_interpreter_with_args(&instructions, &args);
@@ -926,10 +927,10 @@ fn json_value_to_el_value(v: &serde_json::Value) -> el_compiler::Value {
Value::List(arr.iter().map(json_value_to_el_value).collect())
}
serde_json::Value::Object(obj) => {
let pairs = obj.iter()
let fields = obj.iter()
.map(|(k, v)| (k.clone(), json_value_to_el_value(v)))
.collect();
Value::Map(pairs)
Value::Struct { type_name: "Object".to_string(), fields }
}
}
}
@@ -961,6 +962,13 @@ fn el_value_to_json_value(v: &el_compiler::Value) -> serde_json::Value {
Value::ResultErr(inner) => {
serde_json::json!({"err": el_value_to_json_value(inner)})
}
Value::Struct { fields, .. } => {
let mut map = serde_json::Map::new();
for (k, v) in fields {
map.insert(k.clone(), el_value_to_json_value(v));
}
serde_json::Value::Object(map)
}
}
}
@@ -1014,14 +1022,13 @@ fn run_sub_interpreter(
entry: usize,
) -> el_compiler::Value {
use el_compiler::{Bytecode, Value};
let mut stack: Vec<Value> = Vec::new();
let mut locals: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
let mut call_stack: Vec<(usize, std::collections::HashMap<String, Value>)> = Vec::new();
let mut ip = entry;
let program_args: Vec<String> = vec![];
// Bind zero params (handle_request takes none)
while ip < instructions.len() {
match &instructions[ip] {
Bytecode::Push(v) => stack.push(v.clone()),
@@ -1035,6 +1042,8 @@ fn run_sub_interpreter(
(Value::Int(x), Value::Int(y)) => Value::Int(x + y),
(Value::Float(x), Value::Float(y)) => Value::Float(x + y),
(Value::Str(x), Value::Str(y)) => Value::Str(x + &y),
(Value::Int(x), Value::Float(y)) => Value::Float(x as f64 + y),
(Value::Float(x), Value::Int(y)) => Value::Float(x + y as f64),
_ => Value::Nil,
});
}
@@ -1043,6 +1052,8 @@ fn run_sub_interpreter(
stack.push(match (a, b) {
(Value::Int(x), Value::Int(y)) => Value::Int(x - y),
(Value::Float(x), Value::Float(y)) => Value::Float(x - y),
(Value::Int(x), Value::Float(y)) => Value::Float(x as f64 - y),
(Value::Float(x), Value::Int(y)) => Value::Float(x - y as f64),
_ => Value::Nil,
});
}
@@ -1051,6 +1062,8 @@ fn run_sub_interpreter(
stack.push(match (a, b) {
(Value::Int(x), Value::Int(y)) => Value::Int(x * y),
(Value::Float(x), Value::Float(y)) => Value::Float(x * y),
(Value::Int(x), Value::Float(y)) => Value::Float(x as f64 * y),
(Value::Float(x), Value::Int(y)) => Value::Float(x * y as f64),
_ => Value::Nil,
});
}
@@ -1059,6 +1072,8 @@ fn run_sub_interpreter(
stack.push(match (a, b) {
(Value::Int(x), Value::Int(y)) if y != 0 => Value::Int(x / y),
(Value::Float(x), Value::Float(y)) => Value::Float(x / y),
(Value::Int(x), Value::Float(y)) => Value::Float(x as f64 / y),
(Value::Float(x), Value::Int(y)) => Value::Float(x / y as f64),
_ => Value::Nil,
});
}
@@ -1072,31 +1087,19 @@ fn run_sub_interpreter(
}
Bytecode::Lt => {
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
stack.push(match (a, b) {
(Value::Int(x), Value::Int(y)) => Value::Bool(x < y),
_ => Value::Bool(false),
});
stack.push(Value::Bool(cmp_values(&a, &b) == std::cmp::Ordering::Less));
}
Bytecode::Gt => {
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
stack.push(match (a, b) {
(Value::Int(x), Value::Int(y)) => Value::Bool(x > y),
_ => Value::Bool(false),
});
stack.push(Value::Bool(cmp_values(&a, &b) == std::cmp::Ordering::Greater));
}
Bytecode::LtEq => {
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
stack.push(match (a, b) {
(Value::Int(x), Value::Int(y)) => Value::Bool(x <= y),
_ => Value::Bool(false),
});
stack.push(Value::Bool(cmp_values(&a, &b) != std::cmp::Ordering::Greater));
}
Bytecode::GtEq => {
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
stack.push(match (a, b) {
(Value::Int(x), Value::Int(y)) => Value::Bool(x >= y),
_ => Value::Bool(false),
});
stack.push(Value::Bool(cmp_values(&a, &b) != std::cmp::Ordering::Less));
}
Bytecode::And => {
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
@@ -1140,11 +1143,15 @@ fn run_sub_interpreter(
}
Bytecode::GetField(field) => {
let obj = stack.pop().unwrap_or(Value::Nil);
let v = match obj {
let v = match &obj {
Value::Map(pairs) => pairs.iter()
.find(|(k, _)| k == field)
.map(|(_, v)| v.clone())
.unwrap_or(Value::Nil),
Value::Struct { fields, .. } => fields.iter()
.find(|(n, _)| n == field)
.map(|(_, v)| v.clone())
.unwrap_or(Value::Nil),
_ => Value::Nil,
};
stack.push(v);
@@ -1184,14 +1191,17 @@ fn run_sub_interpreter(
pairs.reverse();
stack.push(Value::Map(pairs));
}
Bytecode::BuildStruct { fields, .. } => {
let mut pairs = Vec::new();
for field in fields.iter().rev() {
let val = stack.pop().unwrap_or(Value::Nil);
pairs.push((field.clone(), val));
}
pairs.reverse();
stack.push(Value::Map(pairs));
Bytecode::BuildStruct { type_name, fields } => {
let n = fields.len();
let mut field_values: Vec<Value> = (0..n).map(|_| stack.pop().unwrap_or(Value::Nil)).collect();
field_values.reverse();
let struct_fields: Vec<(String, Value)> = fields.iter().cloned()
.zip(field_values.into_iter())
.collect();
stack.push(Value::Struct {
type_name: type_name.clone(),
fields: struct_fields,
});
}
Bytecode::SetField(field) => {
let val = stack.pop().unwrap_or(Value::Nil);
@@ -1201,6 +1211,12 @@ fn run_sub_interpreter(
} else {
pairs.push((field.clone(), val));
}
} else if let Some(Value::Struct { fields, .. }) = stack.last_mut() {
if let Some(entry) = fields.iter_mut().find(|(k, _)| k == field) {
entry.1 = val;
} else {
fields.push((field.clone(), val));
}
}
}
Bytecode::Jump(offset) => {
@@ -1555,9 +1571,9 @@ fn run_interpreter_with_args(instructions: &[el_compiler::Bytecode], program_arg
}
}
Bytecode::Halt => break,
Bytecode::SealedBegin => eprintln!("[sealed section begin]"),
Bytecode::SealedEnd => eprintln!("[sealed section end]"),
_ => {}
Bytecode::SealedBegin => {}
Bytecode::SealedEnd => {}
Bytecode::Nop => {}
}
ip += 1;
}
@@ -1592,6 +1608,12 @@ fn dispatch_builtin(
stack.push(Value::Nil);
BuiltinResult::Handled
}
"log" => {
let v = stack.pop().unwrap_or(Value::Nil);
println!("{v}");
stack.push(Value::Nil);
BuiltinResult::Handled
}
"print_err" => {
let v = stack.pop().unwrap_or(Value::Nil);
eprintln!("{v}");
@@ -3111,6 +3133,24 @@ fn is_leap(year: u64) -> bool {
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}
/// Compare two runtime values for ordering (used by Lt/Gt/LtEq/GtEq).
fn cmp_values(a: &el_compiler::Value, b: &el_compiler::Value) -> std::cmp::Ordering {
use el_compiler::Value;
match (a, b) {
(Value::Int(x), Value::Int(y)) => x.cmp(y),
(Value::Float(x), Value::Float(y)) => x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal),
(Value::Int(x), Value::Float(y)) => (*x as f64).partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal),
(Value::Float(x), Value::Int(y)) => x.partial_cmp(&(*y as f64)).unwrap_or(std::cmp::Ordering::Equal),
(Value::Str(x), Value::Str(y)) => x.cmp(y),
_ => std::cmp::Ordering::Equal,
}
}
/// Check if a function name is a known built-in (used by run_sub_interpreter).
fn is_builtin(name: &str) -> bool {
matches!(name, "print" | "println" | "log" | "print_err" | "__build_list__")
}
/// Interpreter with debugger support — emits DebugEvents as it runs.
fn run_interpreter_debug(instructions: &[el_compiler::Bytecode], debugger: &mut el_compiler::Debugger) {
use el_compiler::{Bytecode, Value};
@@ -3142,6 +3182,9 @@ fn run_interpreter_debug(instructions: &[el_compiler::Bytecode], debugger: &mut
match &instructions[ip] {
Bytecode::Push(v) => stack.push(v.clone()),
Bytecode::Pop => { stack.pop(); }
Bytecode::Dup => {
if let Some(top) = stack.last().cloned() { stack.push(top); }
}
Bytecode::Add => {
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
stack.push(match (a, b) {
@@ -3159,6 +3202,33 @@ fn run_interpreter_debug(instructions: &[el_compiler::Bytecode], debugger: &mut
let v = locals.get(name).cloned().unwrap_or(Value::Nil);
stack.push(v);
}
Bytecode::GetField(field) => {
let obj = stack.pop().unwrap_or(Value::Nil);
let result = match &obj {
Value::Map(pairs) => pairs.iter()
.find(|(k, _)| k == field)
.map(|(_, v)| v.clone())
.unwrap_or(Value::Nil),
Value::Struct { fields, .. } => fields.iter()
.find(|(n, _)| n == field)
.map(|(_, v)| v.clone())
.unwrap_or(Value::Nil),
_ => Value::Nil,
};
stack.push(result);
}
Bytecode::BuildStruct { type_name, fields } => {
let n = fields.len();
let mut field_values: Vec<Value> = (0..n).map(|_| stack.pop().unwrap_or(Value::Nil)).collect();
field_values.reverse();
let struct_fields: Vec<(String, Value)> = fields.iter().cloned()
.zip(field_values.into_iter())
.collect();
stack.push(Value::Struct {
type_name: type_name.clone(),
fields: struct_fields,
});
}
Bytecode::Call { name, arity } => {
let result = dispatch_builtin(name, *arity, &mut stack, &program_args);
match result {
@@ -3166,6 +3236,10 @@ fn run_interpreter_debug(instructions: &[el_compiler::Bytecode], debugger: &mut
BuiltinResult::Exit(code) => std::process::exit(code),
}
}
Bytecode::Eq => {
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
stack.push(Value::Bool(a == b));
}
Bytecode::Jump(offset) => {
let new_ip = (ip as i32 + 1 + offset) as usize;
ip = new_ip;
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "el-arch"
version = "0.1.0"
edition = "2021"
[dependencies]
el-parser = { path = "../el-parser" }
el-lexer = { path = "../el-lexer" }
[dev-dependencies]
el-lexer = { path = "../el-lexer" }
el-parser = { path = "../el-parser" }
+427
View File
@@ -0,0 +1,427 @@
//! Main architectural checker — walks the AST and applies all rules.
use std::collections::HashMap;
use el_parser::{Expr, Program, Stmt, TypeExpr};
use crate::error::{ArchDiagnostic, Severity};
use crate::rule::{ArchRule, CallInfo, FnContext};
use crate::rules::{
graph::{DuplicateActivateType, N1Detection},
security::{AuthnWithoutAuthz, PublicFnWithActivate, SealedInLoop},
swarm::{SwarmAgentIsolation, SwarmAgentNoSharedState, SwarmAgentNoSpawn},
vbd::{AccessorMustNotCallManager, ExperienceMustNotCallExperience, ExperienceShouldReturnResult},
};
/// The main architectural checker. Instantiate once, call `check` per program.
pub struct ArchChecker {
rules: Vec<Box<dyn ArchRule>>,
}
impl ArchChecker {
/// Create an `ArchChecker` with all built-in rules registered.
pub fn new() -> Self {
Self {
rules: vec![
Box::new(AccessorMustNotCallManager),
Box::new(ExperienceMustNotCallExperience),
Box::new(ExperienceShouldReturnResult),
Box::new(PublicFnWithActivate),
Box::new(SealedInLoop),
Box::new(AuthnWithoutAuthz),
Box::new(N1Detection),
Box::new(DuplicateActivateType),
Box::new(SwarmAgentIsolation),
Box::new(SwarmAgentNoSpawn),
Box::new(SwarmAgentNoSharedState),
],
}
}
/// Returns a reference to the registered rules (useful for introspection in tests).
pub fn rules(&self) -> &[Box<dyn ArchRule>] {
&self.rules
}
/// Run all rules against a parsed program and collect all diagnostics.
pub fn check(&self, program: &Program) -> Vec<ArchDiagnostic> {
// Step 1: build global fn_name → decorator names map (including impl methods).
let all_fn_annotations = collect_fn_annotations(&program.stmts);
// Step 2: gather all top-level + impl FnDef statements.
let fn_defs = collect_fn_defs(&program.stmts);
// Step 3: for each function, build FnContext and run every rule.
let mut diagnostics = Vec::new();
for (fn_name, decorators, return_type, body) in &fn_defs {
let annotations: Vec<String> =
decorators.iter().map(|d| d.name.clone()).collect();
let body_calls = extract_calls(body, false);
let activate_types = extract_activate_types(body, false);
let has_sealed_in_loop = has_sealed_in_loop_body(body, false);
let return_type_name = type_expr_name(return_type);
let ctx = FnContext {
fn_name: fn_name.as_str(),
annotations: &annotations,
body_calls: &body_calls,
all_fn_annotations: &all_fn_annotations,
activate_types: &activate_types,
has_sealed_in_loop,
return_type_name: &return_type_name,
};
for rule in &self.rules {
diagnostics.extend(rule.check(&ctx));
}
}
diagnostics
}
/// Returns true if any of the given diagnostics are errors.
pub fn has_errors(diagnostics: &[ArchDiagnostic]) -> bool {
diagnostics.iter().any(|d| d.severity == Severity::Error)
}
}
impl Default for ArchChecker {
fn default() -> Self {
Self::new()
}
}
// ── AST traversal helpers ─────────────────────────────────────────────────────
/// A collected function definition: (name, decorators, return_type, body).
type FnDef<'a> = (
&'a String,
&'a Vec<el_parser::Decorator>,
&'a TypeExpr,
&'a Vec<Stmt>,
);
/// Collect all FnDef statements from top-level and impl blocks.
fn collect_fn_defs<'a>(stmts: &'a [Stmt]) -> Vec<FnDef<'a>> {
let mut out = Vec::new();
for stmt in stmts {
match stmt {
Stmt::FnDef { name, decorators, return_type, body, .. } => {
out.push((name, decorators, return_type, body));
}
Stmt::ImplDef { methods, .. } => {
for m in methods {
if let Stmt::FnDef { name, decorators, return_type, body, .. } = m {
out.push((name, decorators, return_type, body));
}
}
}
_ => {}
}
}
out
}
/// Build a map from function name → list of decorator names, for all functions in the program.
fn collect_fn_annotations(stmts: &[Stmt]) -> HashMap<String, Vec<String>> {
let mut map = HashMap::new();
for stmt in stmts {
match stmt {
Stmt::FnDef { name, decorators, .. } => {
let anns: Vec<String> = decorators.iter().map(|d| d.name.clone()).collect();
map.insert(name.clone(), anns);
}
Stmt::ImplDef { methods, .. } => {
for m in methods {
if let Stmt::FnDef { name, decorators, .. } = m {
let anns: Vec<String> = decorators.iter().map(|d| d.name.clone()).collect();
map.insert(name.clone(), anns);
}
}
}
_ => {}
}
}
map
}
/// Extract all function calls from a statement list.
/// `in_loop` tracks whether we are currently inside a for/while loop body.
fn extract_calls(stmts: &[Stmt], in_loop: bool) -> Vec<CallInfo> {
let mut calls = Vec::new();
for stmt in stmts {
extract_calls_from_stmt(stmt, in_loop, &mut calls);
}
calls
}
fn extract_calls_from_stmt(stmt: &Stmt, in_loop: bool, out: &mut Vec<CallInfo>) {
match stmt {
Stmt::Let { value, .. } => extract_calls_from_expr(value, in_loop, out),
Stmt::Return(expr, _) | Stmt::Expr(expr, _) | Stmt::Assert(expr, _) => {
extract_calls_from_expr(expr, in_loop, out);
}
Stmt::FnDef { body, .. } => {
// Nested function defs: walk but don't count as callee of the outer fn.
for s in body {
extract_calls_from_stmt(s, false, out);
}
}
_ => {}
}
}
fn extract_calls_from_expr(expr: &Expr, in_loop: bool, out: &mut Vec<CallInfo>) {
match expr {
Expr::Call { func, args } => {
// Extract callee name
let callee = expr_as_call_name(func);
if let Some(name) = callee {
out.push(CallInfo { callee: name, is_in_loop: in_loop });
}
// Recurse into func expression and args
extract_calls_from_expr(func, in_loop, out);
for a in args {
extract_calls_from_expr(a, in_loop, out);
}
}
Expr::Activate { type_name, .. } => {
// Encode activate as a synthetic call so GRAPH-001 can detect in-loop activates.
out.push(CallInfo {
callee: format!("__activate__{type_name}"),
is_in_loop: in_loop,
});
}
Expr::BinOp { left, right, .. } => {
extract_calls_from_expr(left, in_loop, out);
extract_calls_from_expr(right, in_loop, out);
}
Expr::UnaryNot(inner) | Expr::Try(inner) => {
extract_calls_from_expr(inner, in_loop, out);
}
Expr::Block(stmts) => {
for s in stmts {
extract_calls_from_stmt(s, in_loop, out);
}
}
Expr::Sealed(stmts) => {
// Sealed blocks are scanned but tracked separately for sealed-in-loop.
for s in stmts {
extract_calls_from_stmt(s, in_loop, out);
}
}
Expr::If { cond, then, else_ } => {
extract_calls_from_expr(cond, in_loop, out);
extract_calls_from_expr(then, in_loop, out);
if let Some(e) = else_ {
extract_calls_from_expr(e, in_loop, out);
}
}
Expr::Match { subject, arms } => {
extract_calls_from_expr(subject, in_loop, out);
for arm in arms {
extract_calls_from_expr(&arm.body, in_loop, out);
}
}
Expr::Field { object, .. } => extract_calls_from_expr(object, in_loop, out),
Expr::Array(elems) => {
for e in elems {
extract_calls_from_expr(e, in_loop, out);
}
}
Expr::Index { object, index } => {
extract_calls_from_expr(object, in_loop, out);
extract_calls_from_expr(index, in_loop, out);
}
Expr::Closure { body, .. } => {
extract_calls_from_expr(body, in_loop, out);
}
Expr::MapLiteral(pairs) => {
for (k, v) in pairs {
extract_calls_from_expr(k, in_loop, out);
extract_calls_from_expr(v, in_loop, out);
}
}
Expr::Literal(_) | Expr::Ident(_) | Expr::Path { .. } => {}
Expr::StructLit { fields, .. } => {
for (_, e) in fields {
extract_calls_from_expr(e, in_loop, out);
}
}
}
}
/// Try to extract a simple callee name from a Call's `func` expression.
fn expr_as_call_name(expr: &Expr) -> Option<String> {
match expr {
Expr::Ident(name) => Some(name.clone()),
Expr::Field { field, .. } => Some(field.clone()),
Expr::Path { segments } => segments.last().cloned(),
_ => None,
}
}
/// Collect all `activate TypeName` type names from a statement list.
/// `in_loop` indicates whether we're currently inside a loop.
fn extract_activate_types(stmts: &[Stmt], in_loop: bool) -> Vec<String> {
let mut types = Vec::new();
for stmt in stmts {
extract_activate_types_stmt(stmt, in_loop, &mut types);
}
types
}
fn extract_activate_types_stmt(stmt: &Stmt, in_loop: bool, out: &mut Vec<String>) {
match stmt {
Stmt::Let { value, .. } => extract_activate_types_expr(value, in_loop, out),
Stmt::Return(expr, _) | Stmt::Expr(expr, _) | Stmt::Assert(expr, _) => {
extract_activate_types_expr(expr, in_loop, out);
}
Stmt::FnDef { body, .. } => {
for s in body {
extract_activate_types_stmt(s, false, out);
}
}
_ => {}
}
}
fn extract_activate_types_expr(expr: &Expr, in_loop: bool, out: &mut Vec<String>) {
match expr {
Expr::Activate { type_name, .. } => {
out.push(type_name.clone());
}
Expr::Call { func, args } => {
extract_activate_types_expr(func, in_loop, out);
for a in args {
extract_activate_types_expr(a, in_loop, out);
}
}
Expr::BinOp { left, right, .. } => {
extract_activate_types_expr(left, in_loop, out);
extract_activate_types_expr(right, in_loop, out);
}
Expr::UnaryNot(inner) | Expr::Try(inner) => {
extract_activate_types_expr(inner, in_loop, out);
}
Expr::Block(stmts) => {
for s in stmts {
extract_activate_types_stmt(s, in_loop, out);
}
}
Expr::Sealed(stmts) => {
for s in stmts {
extract_activate_types_stmt(s, in_loop, out);
}
}
Expr::If { cond, then, else_ } => {
extract_activate_types_expr(cond, in_loop, out);
extract_activate_types_expr(then, in_loop, out);
if let Some(e) = else_ {
extract_activate_types_expr(e, in_loop, out);
}
}
Expr::Match { subject, arms } => {
extract_activate_types_expr(subject, in_loop, out);
for arm in arms {
extract_activate_types_expr(&arm.body, in_loop, out);
}
}
Expr::Field { object, .. } => extract_activate_types_expr(object, in_loop, out),
Expr::Array(elems) => {
for e in elems {
extract_activate_types_expr(e, in_loop, out);
}
}
Expr::Index { object, index } => {
extract_activate_types_expr(object, in_loop, out);
extract_activate_types_expr(index, in_loop, out);
}
Expr::Closure { body, .. } => {
extract_activate_types_expr(body, in_loop, out);
}
Expr::MapLiteral(pairs) => {
for (k, v) in pairs {
extract_activate_types_expr(k, in_loop, out);
extract_activate_types_expr(v, in_loop, out);
}
}
Expr::Literal(_) | Expr::Ident(_) | Expr::Path { .. } => {}
Expr::StructLit { fields, .. } => {
for (_, e) in fields {
extract_activate_types_expr(e, in_loop, out);
}
}
}
}
/// Returns true if any `sealed { }` block appears inside a loop in the given body.
fn has_sealed_in_loop_body(stmts: &[Stmt], in_loop: bool) -> bool {
stmts.iter().any(|s| has_sealed_in_loop_stmt(s, in_loop))
}
fn has_sealed_in_loop_stmt(stmt: &Stmt, in_loop: bool) -> bool {
match stmt {
Stmt::Let { value, .. } => has_sealed_in_loop_expr(value, in_loop),
Stmt::Return(expr, _) | Stmt::Expr(expr, _) | Stmt::Assert(expr, _) => {
has_sealed_in_loop_expr(expr, in_loop)
}
Stmt::FnDef { body, .. } => {
// Inner function defs reset loop context.
body.iter().any(|s| has_sealed_in_loop_stmt(s, false))
}
_ => false,
}
}
fn has_sealed_in_loop_expr(expr: &Expr, in_loop: bool) -> bool {
match expr {
Expr::Sealed(_) => in_loop,
Expr::Call { func, args } => {
has_sealed_in_loop_expr(func, in_loop)
|| args.iter().any(|a| has_sealed_in_loop_expr(a, in_loop))
}
Expr::BinOp { left, right, .. } => {
has_sealed_in_loop_expr(left, in_loop) || has_sealed_in_loop_expr(right, in_loop)
}
Expr::UnaryNot(inner) | Expr::Try(inner) => has_sealed_in_loop_expr(inner, in_loop),
Expr::Block(stmts) => stmts.iter().any(|s| has_sealed_in_loop_stmt(s, in_loop)),
Expr::If { cond, then, else_ } => {
has_sealed_in_loop_expr(cond, in_loop)
|| has_sealed_in_loop_expr(then, in_loop)
|| else_.as_deref().is_some_and(|e| has_sealed_in_loop_expr(e, in_loop))
}
Expr::Match { subject, arms } => {
has_sealed_in_loop_expr(subject, in_loop)
|| arms.iter().any(|a| has_sealed_in_loop_expr(&a.body, in_loop))
}
Expr::Field { object, .. } => has_sealed_in_loop_expr(object, in_loop),
Expr::Array(elems) => elems.iter().any(|e| has_sealed_in_loop_expr(e, in_loop)),
Expr::Index { object, index } => {
has_sealed_in_loop_expr(object, in_loop) || has_sealed_in_loop_expr(index, in_loop)
}
Expr::Closure { body, .. } => has_sealed_in_loop_expr(body, in_loop),
Expr::MapLiteral(pairs) => pairs
.iter()
.any(|(k, v)| has_sealed_in_loop_expr(k, in_loop) || has_sealed_in_loop_expr(v, in_loop)),
Expr::Activate { .. } | Expr::Literal(_) | Expr::Ident(_) | Expr::Path { .. } => false,
Expr::StructLit { fields, .. } => fields
.iter()
.any(|(_, e)| has_sealed_in_loop_expr(e, in_loop)),
}
}
/// Convert a `TypeExpr` to a display string for the return-type name check.
fn type_expr_name(te: &TypeExpr) -> String {
match te {
TypeExpr::Named(n) => n.clone(),
TypeExpr::Result { .. } => "Result".to_string(),
TypeExpr::Array(inner) => format!("[{}]", type_expr_name(inner)),
TypeExpr::Optional(inner) => format!("{}?", type_expr_name(inner)),
TypeExpr::Map { key, value } => {
format!("Map<{}, {}>", type_expr_name(key), type_expr_name(value))
}
TypeExpr::Fn { .. } => "fn".to_string(),
TypeExpr::TypeParam(n) => n.clone(),
}
}
+45
View File
@@ -0,0 +1,45 @@
//! Diagnostic types for the architectural checker.
/// Severity level of an architectural diagnostic.
#[derive(Debug, Clone, PartialEq)]
pub enum Severity {
Error,
Warning,
}
/// A single architectural diagnostic (error or warning).
#[derive(Debug, Clone)]
pub struct ArchDiagnostic {
pub severity: Severity,
/// Rule identifier, e.g. "VBD-001".
pub rule: String,
pub message: String,
/// Function name or other location hint.
pub location: Option<String>,
}
/// Type alias — an ArchError is an ArchDiagnostic with Severity::Error.
pub type ArchError = ArchDiagnostic;
/// Type alias — an ArchWarning is an ArchDiagnostic with Severity::Warning.
pub type ArchWarning = ArchDiagnostic;
impl ArchDiagnostic {
pub fn error(rule: impl Into<String>, message: impl Into<String>, location: Option<String>) -> Self {
Self {
severity: Severity::Error,
rule: rule.into(),
message: message.into(),
location,
}
}
pub fn warning(rule: impl Into<String>, message: impl Into<String>, location: Option<String>) -> Self {
Self {
severity: Severity::Warning,
rule: rule.into(),
message: message.into(),
location,
}
}
}
+19
View File
@@ -0,0 +1,19 @@
//! el-arch — Architectural rule checker for the Engram language.
//!
//! Runs after type-checking and enforces:
//! - VBD (Volatility-Based Decomposition) layer rules
//! - EBD (Experience-Based Decomposition) experience rules
//! - Swarm containment rules
//! - Security rules
//! - Graph access patterns (N+1, duplicate activate)
pub mod checker;
pub mod error;
pub mod rule;
pub mod rules;
pub use checker::ArchChecker;
pub use error::{ArchDiagnostic, ArchError, ArchWarning, Severity};
#[cfg(test)]
mod tests;
+56
View File
@@ -0,0 +1,56 @@
//! Core trait and context types for architectural rules.
use std::collections::HashMap;
use crate::error::ArchDiagnostic;
/// Information about a single function call within a function body.
#[derive(Debug, Clone)]
pub struct CallInfo {
/// The name of the function being called.
pub callee: String,
/// True if this call appears inside a loop body (for or while).
pub is_in_loop: bool,
}
/// Full context about a single function being checked by arch rules.
pub struct FnContext<'a> {
/// Name of the function under analysis.
pub fn_name: &'a str,
/// Decorator names applied directly to this function (e.g. "accessor", "public").
pub annotations: &'a [String],
/// All calls made from within this function body.
pub body_calls: &'a [CallInfo],
/// Global map of function name → decorator names for the whole program.
pub all_fn_annotations: &'a HashMap<String, Vec<String>>,
/// TypeNames that appear in `activate TypeName where ...` calls in this function.
pub activate_types: &'a [String],
/// Whether this function contains a `sealed { }` block inside a loop.
pub has_sealed_in_loop: bool,
/// The return type of the function as a string (e.g. "Result", "Void", "String").
pub return_type_name: &'a str,
}
impl<'a> FnContext<'a> {
/// Returns true if this function has the given annotation/decorator.
pub fn has_annotation(&self, name: &str) -> bool {
self.annotations.iter().any(|a| a == name)
}
/// Returns true if the named callee has the given annotation in the program.
pub fn callee_has_annotation(&self, callee: &str, ann: &str) -> bool {
self.all_fn_annotations
.get(callee)
.map(|anns| anns.iter().any(|a| a == ann))
.unwrap_or(false)
}
}
/// An architectural rule that can be checked against a function context.
pub trait ArchRule: Send + Sync {
/// Short unique identifier, e.g. "VBD-001".
fn name(&self) -> &str;
/// Human-readable description of what this rule enforces.
fn description(&self) -> &str;
/// Run the rule against a function context, returning any diagnostics.
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic>;
}
+73
View File
@@ -0,0 +1,73 @@
//! Graph access pattern rules (N+1 detection, duplicate activate).
use crate::error::ArchDiagnostic;
use crate::rule::{ArchRule, FnContext};
// ── GRAPH-001: N+1 detection ──────────────────────────────────────────────────
/// GRAPH-001: An activate call inside a loop is an N+1 graph access pattern.
/// Each loop iteration performs a separate graph traversal; consolidate into one query.
pub struct N1Detection;
impl ArchRule for N1Detection {
fn name(&self) -> &str { "GRAPH-001" }
fn description(&self) -> &str {
"activate inside a loop creates an N+1 graph access pattern — hoist outside the loop"
}
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
// We detect activate-in-loop via the body_calls with is_in_loop = true
// AND via the activate_types combined with loop context tracked by the checker.
// The checker sets a separate field for this.
let has_activate_in_loop = ctx.body_calls
.iter()
.any(|c| c.is_in_loop && c.callee.starts_with("__activate__"));
if has_activate_in_loop {
vec![ArchDiagnostic::warning(
self.name(),
format!(
"function '{}' performs activate inside a loop — N+1 graph access pattern",
ctx.fn_name
),
Some(ctx.fn_name.to_string()),
)]
} else {
vec![]
}
}
}
// ── GRAPH-002: Duplicate activate on same type ────────────────────────────────
/// GRAPH-002: Multiple activate calls on the same type within one function
/// should be consolidated into a single query for efficiency.
pub struct DuplicateActivateType;
impl ArchRule for DuplicateActivateType {
fn name(&self) -> &str { "GRAPH-002" }
fn description(&self) -> &str {
"multiple activate calls on the same type in one function — consolidate into one query"
}
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
let mut seen = std::collections::HashMap::new();
for type_name in ctx.activate_types {
*seen.entry(type_name.as_str()).or_insert(0u32) += 1;
}
seen.iter()
.filter(|(_, &count)| count > 1)
.map(|(type_name, count)| ArchDiagnostic::warning(
self.name(),
format!(
"function '{}' activates type '{}' {} times — consolidate into a single query",
ctx.fn_name, type_name, count
),
Some(ctx.fn_name.to_string()),
))
.collect()
}
}
+6
View File
@@ -0,0 +1,6 @@
//! Individual architectural rule implementations.
pub mod graph;
pub mod security;
pub mod swarm;
pub mod vbd;
+104
View File
@@ -0,0 +1,104 @@
//! Security architectural rules.
use crate::error::ArchDiagnostic;
use crate::rule::{ArchRule, FnContext};
// ── SEC-001: Public function with activate inside ─────────────────────────────
/// SEC-001: A @public function must not contain activate expressions.
/// Unauthenticated callers could trigger graph reads, potentially leaking data.
pub struct PublicFnWithActivate;
impl ArchRule for PublicFnWithActivate {
fn name(&self) -> &str { "SEC-001" }
fn description(&self) -> &str {
"@public functions must not contain activate — unauthenticated callers could trigger data reads"
}
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
if !ctx.has_annotation("public") {
return vec![];
}
if !ctx.activate_types.is_empty() {
return vec![ArchDiagnostic::error(
self.name(),
format!(
"@public function '{}' contains activate — data leak risk for unauthenticated callers (types: {})",
ctx.fn_name,
ctx.activate_types.join(", ")
),
Some(ctx.fn_name.to_string()),
)];
}
vec![]
}
}
// ── SEC-002: sealed block inside a loop ──────────────────────────────────────
/// SEC-002: A sealed block inside a loop incurs encryption overhead per iteration.
pub struct SealedInLoop;
impl ArchRule for SealedInLoop {
fn name(&self) -> &str { "SEC-002" }
fn description(&self) -> &str {
"sealed blocks inside loops cause encryption overhead on every iteration"
}
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
if ctx.has_sealed_in_loop {
return vec![ArchDiagnostic::warning(
self.name(),
format!(
"function '{}' contains a sealed block inside a loop — encryption overhead in hot path",
ctx.fn_name
),
Some(ctx.fn_name.to_string()),
)];
}
vec![]
}
}
// ── SEC-003: @authenticate without @authorize on mutations ────────────────────
/// SEC-003: Functions whose name suggests mutation (create_*, update_*, delete_*)
/// and carry @authenticate should also carry @authorize, otherwise authn without authz.
pub struct AuthnWithoutAuthz;
impl ArchRule for AuthnWithoutAuthz {
fn name(&self) -> &str { "SEC-003" }
fn description(&self) -> &str {
"@authenticate without @authorize on mutation functions — authentication without authorization"
}
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
if !ctx.has_annotation("authenticate") {
return vec![];
}
if ctx.has_annotation("authorize") {
return vec![];
}
// Heuristic: mutation function names
let is_mutation = ctx.fn_name.starts_with("create_")
|| ctx.fn_name.starts_with("update_")
|| ctx.fn_name.starts_with("delete_")
|| ctx.fn_name.starts_with("write_")
|| ctx.fn_name.starts_with("mutate_");
if is_mutation {
return vec![ArchDiagnostic::warning(
self.name(),
format!(
"function '{}' has @authenticate but not @authorize — authn without authz on a mutation",
ctx.fn_name
),
Some(ctx.fn_name.to_string()),
)];
}
vec![]
}
}
+103
View File
@@ -0,0 +1,103 @@
//! Swarm containment rules — agents are isolated and must not cross boundaries.
use crate::error::ArchDiagnostic;
use crate::rule::{ArchRule, FnContext};
// ── SWARM-001: Swarm agent calling another swarm agent ────────────────────────
/// SWARM-001: @swarm_agent functions must not call other @swarm_agent functions.
/// Agents are isolated units; cross-agent calls break containment.
pub struct SwarmAgentIsolation;
impl ArchRule for SwarmAgentIsolation {
fn name(&self) -> &str { "SWARM-001" }
fn description(&self) -> &str {
"@swarm_agent must not call another @swarm_agent (agents are isolated)"
}
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
if !ctx.has_annotation("swarm_agent") {
return vec![];
}
ctx.body_calls
.iter()
.filter(|call| ctx.callee_has_annotation(&call.callee, "swarm_agent"))
.map(|call| ArchDiagnostic::error(
self.name(),
format!(
"@swarm_agent '{}' calls @swarm_agent '{}' — agents must be isolated",
ctx.fn_name, call.callee
),
Some(ctx.fn_name.to_string()),
))
.collect()
}
}
// ── SWARM-002: Swarm agent initiating a spawn/swarm ──────────────────────────
/// SWARM-002: @swarm_agent must not initiate spawning of other agents
/// (calls to functions named `spawn` or containing "swarm" in the name).
pub struct SwarmAgentNoSpawn;
impl ArchRule for SwarmAgentNoSpawn {
fn name(&self) -> &str { "SWARM-002" }
fn description(&self) -> &str {
"@swarm_agent must not call spawn/swarm functions (agents cannot initiate sub-swarms)"
}
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
if !ctx.has_annotation("swarm_agent") {
return vec![];
}
ctx.body_calls
.iter()
.filter(|call| {
let c = call.callee.as_str();
c == "spawn" || c.contains("swarm") || c.starts_with("spawn_")
})
.map(|call| ArchDiagnostic::error(
self.name(),
format!(
"@swarm_agent '{}' calls '{}' — agents cannot initiate spawning",
ctx.fn_name, call.callee
),
Some(ctx.fn_name.to_string()),
))
.collect()
}
}
// ── SWARM-003: Swarm agent accessing shared mutable state ─────────────────────
/// SWARM-003: @swarm_agent must not access shared mutable state.
/// Heuristic: calls to functions with "shared" in the name suggest shared state access.
pub struct SwarmAgentNoSharedState;
impl ArchRule for SwarmAgentNoSharedState {
fn name(&self) -> &str { "SWARM-003" }
fn description(&self) -> &str {
"@swarm_agent must not access shared mutable state (functions with 'shared' in name)"
}
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
if !ctx.has_annotation("swarm_agent") {
return vec![];
}
ctx.body_calls
.iter()
.filter(|call| call.callee.contains("shared"))
.map(|call| ArchDiagnostic::error(
self.name(),
format!(
"@swarm_agent '{}' accesses shared state via '{}' — agents must not touch shared mutable state",
ctx.fn_name, call.callee
),
Some(ctx.fn_name.to_string()),
))
.collect()
}
}
+101
View File
@@ -0,0 +1,101 @@
//! VBD (Volatility-Based Decomposition) and EBD (Experience-Based Decomposition) layer rules.
use crate::error::ArchDiagnostic;
use crate::rule::{ArchRule, FnContext};
// ── VBD-001: Accessor must not call Manager ───────────────────────────────────
/// VBD-001: @accessor functions must not call @manager functions.
/// Accessors are read-only, stable-interface components; they must not depend
/// on manager-layer orchestration logic.
pub struct AccessorMustNotCallManager;
impl ArchRule for AccessorMustNotCallManager {
fn name(&self) -> &str { "VBD-001" }
fn description(&self) -> &str {
"@accessor must not call @manager functions (accessor must not depend on manager layer)"
}
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
if !ctx.has_annotation("accessor") {
return vec![];
}
ctx.body_calls
.iter()
.filter(|call| ctx.callee_has_annotation(&call.callee, "manager"))
.map(|call| ArchDiagnostic::error(
self.name(),
format!(
"accessor '{}' calls manager '{}' — accessors must not depend on the manager layer",
ctx.fn_name, call.callee
),
Some(ctx.fn_name.to_string()),
))
.collect()
}
}
// ── VBD-002: Experience must not directly call Experience ─────────────────────
/// VBD-002 / EBD-001: @experience functions must not call other @experience functions directly.
/// Experiences should communicate via events, not direct calls, to preserve
/// loose coupling between user-facing features.
pub struct ExperienceMustNotCallExperience;
impl ArchRule for ExperienceMustNotCallExperience {
fn name(&self) -> &str { "VBD-002" }
fn description(&self) -> &str {
"@experience must not call another @experience directly (use events instead)"
}
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
if !ctx.has_annotation("experience") {
return vec![];
}
ctx.body_calls
.iter()
.filter(|call| ctx.callee_has_annotation(&call.callee, "experience"))
.map(|call| ArchDiagnostic::error(
self.name(),
format!(
"experience '{}' directly calls experience '{}' — use an event instead",
ctx.fn_name, call.callee
),
Some(ctx.fn_name.to_string()),
))
.collect()
}
}
// ── VBD-003: Experience should return Result<T, E> ────────────────────────────
/// VBD-003: @experience functions should return Result<T, E> for proper error propagation.
pub struct ExperienceShouldReturnResult;
impl ArchRule for ExperienceShouldReturnResult {
fn name(&self) -> &str { "VBD-003" }
fn description(&self) -> &str {
"@experience functions should return Result<T, E> for proper error handling"
}
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
if !ctx.has_annotation("experience") {
return vec![];
}
// Warn if return type doesn't include "Result"
if !ctx.return_type_name.contains("Result") {
return vec![ArchDiagnostic::warning(
self.name(),
format!(
"experience '{}' returns '{}' instead of Result<T, E> — experiences should propagate errors",
ctx.fn_name, ctx.return_type_name
),
Some(ctx.fn_name.to_string()),
)];
}
vec![]
}
}
+448
View File
@@ -0,0 +1,448 @@
//! Comprehensive tests for the el-arch architectural checker.
use crate::{ArchChecker, ArchDiagnostic, Severity};
// ── Test helpers ──────────────────────────────────────────────────────────────
fn check(src: &str) -> Vec<ArchDiagnostic> {
let tokens = el_lexer::tokenize(src).expect("lex failed");
let prog = el_parser::parse(tokens, src.to_string()).expect("parse failed");
ArchChecker::new().check(&prog)
}
fn errors(src: &str) -> Vec<ArchDiagnostic> {
check(src).into_iter().filter(|d| d.severity == Severity::Error).collect()
}
fn warnings(src: &str) -> Vec<ArchDiagnostic> {
check(src).into_iter().filter(|d| d.severity == Severity::Warning).collect()
}
fn has_rule(diags: &[ArchDiagnostic], rule: &str) -> bool {
diags.iter().any(|d| d.rule == rule)
}
// ── 1. VBD-001: @accessor calling @manager → error ──────────────────────────
#[test]
fn test_accessor_calls_manager_error() {
let src = r#"
@manager fn orchestrate(x: String) -> String { return x }
@accessor fn fetch_user(id: String) -> String { return orchestrate(id) }
"#;
let errs = errors(src);
assert!(!errs.is_empty(), "expected error when @accessor calls @manager");
assert!(has_rule(&errs, "VBD-001"), "expected VBD-001 rule");
}
// ── 2. @accessor calling @accessor → no VBD-001 error ────────────────────────
#[test]
fn test_accessor_calls_accessor_no_error() {
let src = r#"
@accessor fn get_name(id: String) -> String { return id }
@accessor fn get_user(id: String) -> String { return get_name(id) }
"#;
let errs = errors(src);
let vbd001: Vec<_> = errs.iter().filter(|d| d.rule == "VBD-001").collect();
assert!(vbd001.is_empty(), "accessor->accessor should not trigger VBD-001");
}
// ── 3. @experience calling @experience → VBD-002 error ───────────────────────
#[test]
fn test_experience_calls_experience_error() {
let src = r#"
@experience fn checkout(cart: String) -> Result<String, String> { return cart }
@experience fn payment(amount: String) -> Result<String, String> { return checkout(amount) }
"#;
let errs = errors(src);
assert!(!errs.is_empty(), "expected error when @experience calls @experience");
assert!(has_rule(&errs, "VBD-002"), "expected VBD-002 rule");
}
// ── 4. @experience calling non-experience → no VBD-002 error ─────────────────
#[test]
fn test_experience_calls_non_experience_no_error() {
let src = r#"
@accessor fn load_cart(id: String) -> String { return id }
@experience fn checkout(cart: String) -> Result<String, String> { return load_cart(cart) }
"#;
let errs = errors(src);
let vbd002: Vec<_> = errs.iter().filter(|d| d.rule == "VBD-002").collect();
assert!(vbd002.is_empty(), "experience->non-experience should not trigger VBD-002");
}
// ── 5. @public function with activate inside → SEC-001 error ─────────────────
#[test]
fn test_public_fn_with_activate_error() {
let src = r#"
@public fn list_users() -> String {
let users: String = activate User where "all users"
return users
}
"#;
let errs = errors(src);
assert!(!errs.is_empty(), "expected error for @public fn with activate");
assert!(has_rule(&errs, "SEC-001"), "expected SEC-001 rule");
}
// ── 6. @public function without activate → no SEC-001 error ──────────────────
#[test]
fn test_public_fn_without_activate_no_error() {
let src = r#"
@public fn greet(name: String) -> String { return name }
"#;
let errs = errors(src);
let sec001: Vec<_> = errs.iter().filter(|d| d.rule == "SEC-001").collect();
assert!(sec001.is_empty(), "@public without activate should not trigger SEC-001");
}
// ── 7. N+1: activate inside a for loop → GRAPH-001 warning ───────────────────
#[test]
fn test_activate_in_for_loop_n1_warning() {
let src = r#"
fn process_ids(ids: String) -> String {
for id in ids {
let u: String = activate User where "user by id"
}
return "done"
}
"#;
let warns = warnings(src);
assert!(!warns.is_empty(), "expected N+1 warning for activate inside loop");
assert!(has_rule(&warns, "GRAPH-001"), "expected GRAPH-001 rule");
}
// ── 8. activate NOT in loop → no GRAPH-001 warning ───────────────────────────
#[test]
fn test_activate_not_in_loop_no_n1_warning() {
let src = r#"
fn fetch_users() -> String {
let users: String = activate User where "recent users"
return users
}
"#;
let warns = warnings(src);
let graph001: Vec<_> = warns.iter().filter(|d| d.rule == "GRAPH-001").collect();
assert!(graph001.is_empty(), "activate outside loop should not trigger GRAPH-001");
}
// ── 9. Duplicate activate same type → GRAPH-002 warning ──────────────────────
#[test]
fn test_duplicate_activate_same_type_warning() {
let src = r#"
fn inefficient_fn(x: String) -> String {
let a: String = activate User where "active users"
let b: String = activate User where "recent users"
return a
}
"#;
let warns = warnings(src);
assert!(!warns.is_empty(), "expected warning for duplicate activate on same type");
assert!(has_rule(&warns, "GRAPH-002"), "expected GRAPH-002 rule");
}
// ── 10. Different activate types → no GRAPH-002 warning ──────────────────────
#[test]
fn test_different_activate_types_no_duplicate_warning() {
let src = r#"
fn fetch_all(x: String) -> String {
let users: String = activate User where "users"
let orders: String = activate Order where "orders"
return users
}
"#;
let warns = warnings(src);
let graph002: Vec<_> = warns.iter().filter(|d| d.rule == "GRAPH-002").collect();
assert!(graph002.is_empty(), "different activate types should not trigger GRAPH-002");
}
// ── 11. @swarm_agent calling @swarm_agent → SWARM-001 error ──────────────────
#[test]
fn test_swarm_agent_calls_swarm_agent_error() {
let src = r#"
@swarm_agent fn worker_b(x: String) -> String { return x }
@swarm_agent fn worker_a(x: String) -> String { return worker_b(x) }
"#;
let errs = errors(src);
assert!(!errs.is_empty(), "expected error when @swarm_agent calls @swarm_agent");
assert!(has_rule(&errs, "SWARM-001"), "expected SWARM-001 rule");
}
// ── 12. @swarm_agent in isolation → no SWARM-001 error ───────────────────────
#[test]
fn test_swarm_agent_isolation_no_error() {
let src = r#"
fn utility(x: String) -> String { return x }
@swarm_agent fn worker(x: String) -> String { return utility(x) }
"#;
let errs = errors(src);
let swarm001: Vec<_> = errs.iter().filter(|d| d.rule == "SWARM-001").collect();
assert!(swarm001.is_empty(), "isolated @swarm_agent should not trigger SWARM-001");
}
// ── 13. Multiple rules fire on same function ──────────────────────────────────
#[test]
fn test_multiple_rules_fire_same_function() {
// @swarm_agent calling a swarm_agent AND accessing shared state
let src = r#"
@swarm_agent fn peer(x: String) -> String { return x }
fn get_shared_cache(x: String) -> String { return x }
@swarm_agent fn violator(x: String) -> String {
let a: String = peer(x)
let b: String = get_shared_cache(x)
return a
}
"#;
let errs = errors(src);
// Should fire both SWARM-001 (calls peer) and SWARM-003 (calls get_shared_cache)
assert!(errs.len() >= 2, "expected multiple errors: {:?}", errs.iter().map(|e| &e.rule).collect::<Vec<_>>());
}
// ── 14. ArchChecker::has_errors() → true when errors present ─────────────────
#[test]
fn test_has_errors_true_when_errors_present() {
let src = r#"
@manager fn do_manage(x: String) -> String { return x }
@accessor fn bad_fetch(x: String) -> String { return do_manage(x) }
"#;
let diags = check(src);
assert!(ArchChecker::has_errors(&diags), "has_errors should be true");
}
// ── 15. ArchChecker::has_errors() → false when only warnings ─────────────────
#[test]
fn test_has_errors_false_when_only_warnings() {
let src = r#"
@experience fn sign_up(email: String) -> String { return email }
"#;
// sign_up doesn't return Result so triggers VBD-003 warning
let diags = check(src);
let has_warn = diags.iter().any(|d| d.severity == Severity::Warning);
assert!(has_warn || diags.is_empty(), "expected either warnings or empty");
assert!(!ArchChecker::has_errors(&diags.iter().filter(|d| d.severity == Severity::Warning).cloned().collect::<Vec<_>>()), "has_errors should be false for warnings");
}
// ── 16. Clean function → empty diagnostics ────────────────────────────────────
#[test]
fn test_clean_function_no_diagnostics() {
let src = r#"
fn pure_add(a: String, b: String) -> String { return a }
"#;
let diags = check(src);
assert!(diags.is_empty(), "clean function should produce no diagnostics, got: {:?}", diags.iter().map(|d| &d.rule).collect::<Vec<_>>());
}
// ── 17. @engine function → runs without panic ─────────────────────────────────
#[test]
fn test_engine_function_no_panic() {
let src = r#"
@engine fn compute(data: String) -> String { return data }
"#;
// Just verify no panic — engine rules only produce warnings in some impls
let _diags = check(src);
}
// ── 18. sealed { } not in loop → no SEC-002 warning ──────────────────────────
#[test]
fn test_sealed_not_in_loop_no_warning() {
let src = r#"
fn encrypt_data(secret: String) -> String {
sealed { let key: String = secret }
return secret
}
"#;
let warns = warnings(src);
let sec002: Vec<_> = warns.iter().filter(|d| d.rule == "SEC-002").collect();
assert!(sec002.is_empty(), "sealed not in loop should not trigger SEC-002");
}
// ── 19. Rule names are unique ─────────────────────────────────────────────────
#[test]
fn test_rule_names_are_unique() {
let checker = ArchChecker::new();
let mut names = std::collections::HashSet::new();
for rule in checker.rules() {
let inserted = names.insert(rule.name().to_string());
assert!(inserted, "duplicate rule name: {}", rule.name());
}
}
// ── 20. All rules implement ArchRule (compile-time check) ─────────────────────
#[test]
fn test_all_rules_implement_arch_rule() {
use crate::rule::ArchRule;
use crate::rules::{
vbd::{AccessorMustNotCallManager, ExperienceMustNotCallExperience, ExperienceShouldReturnResult},
security::{PublicFnWithActivate, SealedInLoop, AuthnWithoutAuthz},
graph::{N1Detection, DuplicateActivateType},
swarm::{SwarmAgentIsolation, SwarmAgentNoSpawn, SwarmAgentNoSharedState},
};
fn assert_arch_rule<T: ArchRule>() {}
assert_arch_rule::<AccessorMustNotCallManager>();
assert_arch_rule::<ExperienceMustNotCallExperience>();
assert_arch_rule::<ExperienceShouldReturnResult>();
assert_arch_rule::<PublicFnWithActivate>();
assert_arch_rule::<SealedInLoop>();
assert_arch_rule::<AuthnWithoutAuthz>();
assert_arch_rule::<N1Detection>();
assert_arch_rule::<DuplicateActivateType>();
assert_arch_rule::<SwarmAgentIsolation>();
assert_arch_rule::<SwarmAgentNoSpawn>();
assert_arch_rule::<SwarmAgentNoSharedState>();
}
// ── 21. FnContext builds correctly for a decorated function ───────────────────
#[test]
fn test_fn_context_from_decorated_function() {
// Run checker and verify the location field is set to the function name
let src = r#"
@accessor fn fetch_item(id: String) -> String { return id }
"#;
let diags = check(src);
// No violations — but verify the checker doesn't panic and processes it
// (accessor with no calls should produce no errors)
let _: Vec<_> = diags;
}
// ── 22. @experience without Result return type → VBD-003 warning ─────────────
#[test]
fn test_experience_non_result_return_warning() {
let src = r#"
@experience fn show_profile(user: String) -> String { return user }
"#;
let warns = warnings(src);
assert!(!warns.is_empty(), "expected warning for @experience not returning Result");
assert!(has_rule(&warns, "VBD-003"), "expected VBD-003 rule");
}
// ── 23. @authenticate without @authorize on mutation → SEC-003 warning ────────
#[test]
fn test_authenticate_without_authorize_on_mutation_warning() {
let src = r#"
@authenticate fn create_account(email: String) -> String { return email }
"#;
let warns = warnings(src);
assert!(!warns.is_empty(), "expected SEC-003 warning");
assert!(has_rule(&warns, "SEC-003"), "expected SEC-003 rule");
}
// ── 24. Two @experience functions checked independently ───────────────────────
#[test]
fn test_two_experience_functions_checked_independently() {
let src = r#"
@experience fn sign_up(email: String) -> Result<String, String> { return email }
@experience fn log_in(token: String) -> String { return token }
"#;
let warns = warnings(src);
// sign_up returns Result — no VBD-003 for it
// log_in returns String — VBD-003 fires for it
let vbd003: Vec<_> = warns.iter().filter(|d| d.rule == "VBD-003").collect();
assert_eq!(vbd003.len(), 1, "only log_in should trigger VBD-003, got {:?}", vbd003.iter().map(|d| &d.location).collect::<Vec<_>>());
assert!(vbd003[0].location.as_deref() == Some("log_in"), "VBD-003 should point to log_in");
}
// ── 25. Mixed errors and warnings → has_errors() returns true ─────────────────
#[test]
fn test_mixed_errors_and_warnings_has_errors_true() {
let src = r#"
@manager fn manage_data(x: String) -> String { return x }
@accessor fn bad_read(x: String) -> String { return manage_data(x) }
@experience fn display(x: String) -> String { return x }
"#;
let diags = check(src);
assert!(ArchChecker::has_errors(&diags), "should have errors (VBD-001)");
let has_warn = diags.iter().any(|d| d.severity == Severity::Warning);
assert!(has_warn, "should also have warnings (VBD-003 for display)");
}
// ── 26. @swarm_agent calling spawn → SWARM-002 error ─────────────────────────
#[test]
fn test_swarm_agent_no_spawn() {
let src = r#"
fn spawn(agent: String) -> String { return agent }
@swarm_agent fn initiator(x: String) -> String { return spawn(x) }
"#;
let errs = errors(src);
assert!(!errs.is_empty(), "expected SWARM-002 error for calling spawn");
assert!(has_rule(&errs, "SWARM-002"), "expected SWARM-002 rule");
}
// ── 27. @swarm_agent accessing shared state → SWARM-003 error ────────────────
#[test]
fn test_swarm_agent_no_shared_state() {
let src = r#"
fn get_shared_counter(x: String) -> String { return x }
@swarm_agent fn agent(x: String) -> String { return get_shared_counter(x) }
"#;
let errs = errors(src);
assert!(!errs.is_empty(), "expected SWARM-003 error for shared state access");
assert!(has_rule(&errs, "SWARM-003"), "expected SWARM-003 rule");
}
// ── 28. sealed block inside for loop → SEC-002 warning ───────────────────────
#[test]
fn test_sealed_in_for_loop_warning() {
let src = r#"
fn encrypt_many(items: String) -> String {
for item in items {
sealed { let x: String = item }
}
return items
}
"#;
let warns = warnings(src);
assert!(!warns.is_empty(), "expected SEC-002 warning for sealed in loop");
assert!(has_rule(&warns, "SEC-002"), "expected SEC-002 rule");
}
// ── 29. @authenticate with @authorize → no SEC-003 warning ───────────────────
#[test]
fn test_authenticate_with_authorize_no_warning() {
let src = r#"
@authenticate @authorize fn create_post(content: String) -> String { return content }
"#;
let warns = warnings(src);
let sec003: Vec<_> = warns.iter().filter(|d| d.rule == "SEC-003").collect();
assert!(sec003.is_empty(), "@authenticate + @authorize should not trigger SEC-003");
}
// ── 30. @experience returning Result → no VBD-003 warning ────────────────────
#[test]
fn test_experience_returns_result_no_warning() {
let src = r#"
@experience fn register(email: String) -> Result<String, String> { return email }
"#;
let warns = warnings(src);
let vbd003: Vec<_> = warns.iter().filter(|d| d.rule == "VBD-003").collect();
assert!(vbd003.is_empty(), "@experience returning Result should not trigger VBD-003");
}
+7 -1
View File
@@ -16,13 +16,15 @@ pub enum Value {
Nil,
/// A list of values (used for `activate` results and array literals).
List(Vec<Value>),
/// A key-value map — used for struct instances and Map<K,V> literals.
/// A key-value map — used for Map<K,V> literals.
/// Stored as a Vec of pairs to keep ordering and remain Serialize-friendly.
Map(Vec<(String, Value)>),
/// A Result<T,E> value — Ok variant.
ResultOk(Box<Value>),
/// A Result<T,E> value — Err variant.
ResultErr(Box<Value>),
/// A struct instance: type name + ordered field name-value pairs.
Struct { type_name: String, fields: Vec<(String, Value)> },
}
impl std::fmt::Display for Value {
@@ -43,6 +45,10 @@ impl std::fmt::Display for Value {
}
Value::ResultOk(v) => write!(f, "Ok({v})"),
Value::ResultErr(e) => write!(f, "Err({e})"),
Value::Struct { type_name, fields } => {
let fs: Vec<_> = fields.iter().map(|(k, v)| format!("{k}: {v}")).collect();
write!(f, "{type_name} {{ {} }}", fs.join(", "))
}
}
}
}
+11
View File
@@ -357,6 +357,17 @@ impl Codegen {
self.gen_expr(index)?;
self.emit(Bytecode::GetIndex);
}
Expr::StructLit { type_name, fields, .. } => {
// Push each field value in declaration order
for (_, field_expr) in fields {
self.gen_expr(field_expr)?;
}
let field_names: Vec<String> = fields.iter().map(|(n, _)| n.clone()).collect();
self.emit(Bytecode::BuildStruct {
type_name: type_name.clone(),
fields: field_names,
});
}
// New expression kinds — push Nil as placeholder
_ => {
self.emit(Bytecode::Push(Value::Nil));
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "el-fmt"
version = "0.1.0"
edition = "2021"
[dependencies]
el-lexer = { path = "../el-lexer" }
el-parser = { path = "../el-parser" }
thiserror = "2"
[dev-dependencies]
el-lexer = { path = "../el-lexer" }
el-parser = { path = "../el-parser" }
+32
View File
@@ -0,0 +1,32 @@
//! Formatter configuration.
#[derive(Debug, Clone)]
pub struct FmtConfig {
pub indent: IndentStyle,
/// Number of spaces per indent level (default 4).
pub indent_width: usize,
/// Maximum line width before wrapping (default 100).
pub max_line_width: usize,
/// Whether to ensure the output ends with a newline (default true).
pub trailing_newline: bool,
/// Whether to emit a space before an opening brace (default true).
pub space_before_brace: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub enum IndentStyle {
Spaces,
Tabs,
}
impl Default for FmtConfig {
fn default() -> Self {
Self {
indent: IndentStyle::Spaces,
indent_width: 4,
max_line_width: 100,
trailing_newline: true,
space_before_brace: true,
}
}
}
+11
View File
@@ -0,0 +1,11 @@
//! Error types for el-fmt.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum FmtError {
#[error("lex error: {0}")]
Lex(String),
#[error("parse error: {0}")]
Parse(String),
}
+412
View File
@@ -0,0 +1,412 @@
//! AST pretty-printer — the core of el-fmt.
use el_parser::{BinOp, Expr, Literal, MatchArm, Pattern, Program, Stmt, TypeExpr};
use crate::{FmtConfig, FmtError};
use crate::config::IndentStyle;
pub struct Formatter {
config: FmtConfig,
}
impl Formatter {
pub fn new(config: FmtConfig) -> Self {
Self { config }
}
pub fn format(&self, program: &Program) -> Result<String, FmtError> {
let mut out = String::new();
for (i, stmt) in program.stmts.iter().enumerate() {
if i > 0 {
out.push('\n');
}
self.fmt_stmt(&mut out, stmt, 0);
}
if self.config.trailing_newline && !out.ends_with('\n') {
out.push('\n');
}
Ok(out)
}
fn indent(&self, depth: usize) -> String {
match self.config.indent {
IndentStyle::Spaces => " ".repeat(depth * self.config.indent_width),
IndentStyle::Tabs => "\t".repeat(depth),
}
}
fn fmt_stmt(&self, out: &mut String, stmt: &Stmt, depth: usize) {
let ind = self.indent(depth);
match stmt {
Stmt::Let { name, type_ann, value, .. } => {
out.push_str(&ind);
out.push_str("let ");
out.push_str(name);
if let Some(ty) = type_ann {
out.push_str(": ");
out.push_str(&self.fmt_type(ty));
}
out.push_str(" = ");
self.fmt_expr(out, value, depth);
out.push('\n');
}
Stmt::Return(expr, _) => {
out.push_str(&format!("{ind}return "));
self.fmt_expr(out, expr, depth);
out.push('\n');
}
Stmt::Expr(expr, _) => {
out.push_str(&ind);
self.fmt_expr(out, expr, depth);
out.push('\n');
}
Stmt::FnDef { name, params, body, decorators, return_type, .. } => {
// Decorators
for dec in decorators {
out.push_str(&format!("{ind}@{}\n", dec.name));
}
// Parameters
let params_str: Vec<String> = params
.iter()
.map(|p| format!("{}: {}", p.name, self.fmt_type(&p.type_ann)))
.collect();
// Always emit return type — the parser requires `->`.
let ret = format!(" -> {}", self.fmt_type(return_type));
let brace_space = if self.config.space_before_brace { " " } else { "" };
out.push_str(&format!(
"{ind}fn {name}({}){}{brace_space}{{\n",
params_str.join(", "),
ret,
));
for s in body {
self.fmt_stmt(out, s, depth + 1);
}
out.push_str(&format!("{ind}}}\n"));
}
Stmt::TypeDef { name, fields, .. } => {
out.push_str(&format!("{ind}type {name} {{\n"));
for f in fields {
out.push_str(&format!(
"{} {}: {}\n",
ind,
f.name,
self.fmt_type(&f.type_ann)
));
}
out.push_str(&format!("{ind}}}\n"));
}
Stmt::EnumDef { name, variants, .. } => {
out.push_str(&format!("{ind}enum {name} {{\n"));
for v in variants {
if let Some(payload) = &v.payload {
out.push_str(&format!(
"{} {}({})\n",
ind,
v.name,
self.fmt_type(payload)
));
} else {
out.push_str(&format!("{} {}\n", ind, v.name));
}
}
out.push_str(&format!("{ind}}}\n"));
}
Stmt::TestDef { name, body, .. } => {
out.push_str(&format!("{ind}test {:?} {{\n", name));
for s in body {
self.fmt_stmt(out, s, depth + 1);
}
out.push_str(&format!("{ind}}}\n"));
}
Stmt::Assert(expr, _) => {
out.push_str(&format!("{ind}assert "));
self.fmt_expr(out, expr, depth);
out.push('\n');
}
Stmt::Import { path, names, alias, .. } => {
if names.is_empty() {
let joined = path.join("::");
if let Some(a) = alias {
out.push_str(&format!("{ind}import {joined} as {a}\n"));
} else {
out.push_str(&format!("{ind}import {joined}\n"));
}
} else {
let joined = path.join("::");
let items = names.join(", ");
out.push_str(&format!("{ind}from {joined} import {{ {items} }}\n"));
}
}
Stmt::ProtocolDef { name, methods, .. } => {
out.push_str(&format!("{ind}protocol {name} {{\n"));
for m in methods {
let params_str: Vec<String> = m
.params
.iter()
.map(|p| format!("{}: {}", p.name, self.fmt_type(&p.type_ann)))
.collect();
out.push_str(&format!(
"{} fn {}({}) -> {}\n",
ind,
m.name,
params_str.join(", "),
self.fmt_type(&m.return_type)
));
}
out.push_str(&format!("{ind}}}\n"));
}
Stmt::ImplDef { protocol_name, type_name, methods, .. } => {
out.push_str(&format!("{ind}impl {protocol_name} for {type_name} {{\n"));
for m in methods {
self.fmt_stmt(out, m, depth + 1);
}
out.push_str(&format!("{ind}}}\n"));
}
Stmt::Seed(seed, _) => {
use el_parser::SeedStmt;
match seed {
SeedStmt::Node { node_type, content, importance, tier } => {
let tier_str = tier
.as_deref()
.map(|t| format!(", tier: {t}"))
.unwrap_or_default();
out.push_str(&format!(
"{ind}seed {node_type} {{ content: {:?}, importance: {importance}{tier_str} }}\n",
content
));
}
SeedStmt::Edge { from, to, relation, weight } => {
out.push_str(&format!(
"{ind}seed Edge {{ from: {from}, to: {to}, relation: {relation:?}, weight: {weight} }}\n"
));
}
}
}
}
}
fn fmt_expr(&self, out: &mut String, expr: &Expr, depth: usize) {
match expr {
Expr::Literal(lit) => self.fmt_literal(out, lit),
Expr::Ident(name) => out.push_str(name),
Expr::Path { segments } => out.push_str(&segments.join("::")),
Expr::BinOp { op, left, right } => {
self.fmt_expr(out, left, depth);
out.push_str(&format!(" {} ", self.fmt_binop(op)));
self.fmt_expr(out, right, depth);
}
Expr::UnaryNot(inner) => {
out.push('!');
self.fmt_expr(out, inner, depth);
}
Expr::Try(inner) => {
self.fmt_expr(out, inner, depth);
out.push('?');
}
Expr::Call { func, args } => {
self.fmt_expr(out, func, depth);
out.push('(');
for (i, arg) in args.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
self.fmt_expr(out, arg, depth);
}
out.push(')');
}
Expr::Block(stmts) => {
out.push_str("{\n");
for s in stmts {
self.fmt_stmt(out, s, depth + 1);
}
out.push_str(&format!("{}}}", self.indent(depth)));
}
Expr::If { cond, then, else_ } => {
out.push_str("if ");
self.fmt_expr(out, cond, depth);
out.push(' ');
self.fmt_expr(out, then, depth);
if let Some(else_expr) = else_ {
out.push_str(" else ");
self.fmt_expr(out, else_expr, depth);
}
}
Expr::Activate { type_name, query } => {
out.push_str(&format!("activate {type_name} where {:?}", query));
}
Expr::Field { object, field } => {
self.fmt_expr(out, object, depth);
out.push('.');
out.push_str(field);
}
Expr::Index { object, index } => {
self.fmt_expr(out, object, depth);
out.push('[');
self.fmt_expr(out, index, depth);
out.push(']');
}
Expr::Array(elems) => {
out.push('[');
for (i, e) in elems.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
self.fmt_expr(out, e, depth);
}
out.push(']');
}
Expr::MapLiteral(pairs) => {
out.push('{');
for (i, (k, v)) in pairs.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
self.fmt_expr(out, k, depth);
out.push_str(": ");
self.fmt_expr(out, v, depth);
}
out.push('}');
}
Expr::Sealed(stmts) => {
out.push_str("sealed {\n");
for s in stmts {
self.fmt_stmt(out, s, depth + 1);
}
out.push_str(&format!("{}}}", self.indent(depth)));
}
Expr::Match { subject, arms } => {
out.push_str("match ");
self.fmt_expr(out, subject, depth);
out.push_str(" {\n");
for arm in arms {
self.fmt_match_arm(out, arm, depth);
}
out.push_str(&format!("{}}}", self.indent(depth)));
}
Expr::Closure { params, return_type, body, .. } => {
out.push('|');
let params_str: Vec<String> = params
.iter()
.map(|p| format!("{}: {}", p.name, self.fmt_type(&p.type_ann)))
.collect();
out.push_str(&params_str.join(", "));
out.push('|');
if let Some(rt) = return_type {
out.push_str(&format!(" -> {}", self.fmt_type(rt)));
}
out.push(' ');
self.fmt_expr(out, body, depth);
}
Expr::StructLit { type_name, fields, .. } => {
out.push_str(type_name);
out.push_str(" { ");
let fields_str: Vec<String> = fields
.iter()
.map(|(name, val)| {
let mut s = format!("{name}: ");
self.fmt_expr(&mut s, val, depth);
s
})
.collect();
out.push_str(&fields_str.join(", "));
out.push_str(" }");
}
}
}
fn fmt_literal(&self, out: &mut String, lit: &Literal) {
match lit {
Literal::Int(n) => out.push_str(&n.to_string()),
Literal::Float(f) => out.push_str(&f.to_string()),
Literal::Str(s) => out.push_str(&format!("{s:?}")),
Literal::Bool(b) => out.push_str(&b.to_string()),
}
}
fn fmt_binop(&self, op: &BinOp) -> &'static str {
match op {
BinOp::Add => "+",
BinOp::Sub => "-",
BinOp::Mul => "*",
BinOp::Div => "/",
BinOp::Eq => "==",
BinOp::NotEq => "!=",
BinOp::Lt => "<",
BinOp::Gt => ">",
BinOp::LtEq => "<=",
BinOp::GtEq => ">=",
BinOp::And => "&&",
BinOp::Or => "||",
}
}
fn fmt_match_arm(&self, out: &mut String, arm: &MatchArm, depth: usize) {
out.push_str(&format!("{} ", self.indent(depth)));
self.fmt_pattern(out, &arm.pattern);
out.push_str(" => ");
self.fmt_expr(out, &arm.body, depth + 1);
out.push('\n');
}
fn fmt_pattern(&self, out: &mut String, pat: &Pattern) {
match pat {
Pattern::Wildcard => out.push('_'),
Pattern::Binding(name) => out.push_str(name),
Pattern::Literal(lit) => self.fmt_literal(out, lit),
Pattern::EnumVariant { enum_name, variant, payload } => {
out.push_str(&format!("{enum_name}::"));
out.push_str(variant);
if let Some(bind) = payload {
out.push_str(&format!("({bind})"));
}
}
}
}
pub fn fmt_type(&self, ty: &TypeExpr) -> String {
match ty {
TypeExpr::Named(n) => n.clone(),
TypeExpr::Array(inner) => format!("[{}]", self.fmt_type(inner)),
TypeExpr::Optional(inner) => format!("{}?", self.fmt_type(inner)),
TypeExpr::Result { ok, err } => {
format!("Result<{}, {}>", self.fmt_type(ok), self.fmt_type(err))
}
TypeExpr::Map { key, value } => {
format!("Map<{}, {}>", self.fmt_type(key), self.fmt_type(value))
}
TypeExpr::Fn { params, return_type } => {
let ps: Vec<_> = params.iter().map(|p| self.fmt_type(p)).collect();
format!("fn({}) -> {}", ps.join(", "), self.fmt_type(return_type))
}
TypeExpr::TypeParam(n) => n.clone(),
}
}
}
+327
View File
@@ -0,0 +1,327 @@
//! el-fmt — canonical source formatter for engram-lang.
//!
//! Formats a `.el` source file into its canonical representation.
//! Parsing an already-formatted file and re-formatting it produces identical output.
pub mod config;
pub mod error;
pub mod formatter;
pub use config::FmtConfig;
pub use error::FmtError;
pub use formatter::Formatter;
/// Format engram-lang source code. Returns the canonical formatted version.
pub fn format(source: &str) -> Result<String, FmtError> {
format_with_config(source, &FmtConfig::default())
}
/// Format with an explicit configuration.
pub fn format_with_config(source: &str, config: &FmtConfig) -> Result<String, FmtError> {
let tokens =
el_lexer::tokenize(source).map_err(|e| FmtError::Lex(e.to_string()))?;
let program =
el_parser::parse(tokens, source.to_string()).map_err(|e| FmtError::Parse(e.to_string()))?;
Formatter::new(config.clone()).format(&program)
}
/// Check whether `source` is already in canonical form.
/// Returns `true` if formatting would produce no changes.
pub fn is_canonical(source: &str) -> Result<bool, FmtError> {
let formatted = format(source)?;
Ok(formatted == source)
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn fmt(src: &str) -> String {
format(src).unwrap()
}
fn idempotent(src: &str) {
let once = fmt(src);
let twice = fmt(&once);
assert_eq!(once, twice, "format not idempotent for:\n{src}");
}
// 1. Integer literal
#[test]
fn test_integer_literal() {
assert_eq!(fmt("42"), "42\n");
}
// 2. Let binding (no type annotation in source → formatter emits inferred type)
// We parse "let x = 1" which gives type_ann from the parser.
// Since el-parser always injects a type_ann, we just check the output is stable.
#[test]
fn test_let_binding_idempotent() {
// Round-trip: parse what we emit and re-emit
let source = "let x: Int = 1\n";
assert_eq!(fmt(source), source);
idempotent(source);
}
// 3. Binary operator spacing
#[test]
fn test_binary_op_spacing() {
let out = fmt("1 + 2\n");
assert!(out.contains("1 + 2"), "expected '1 + 2' in: {out}");
}
// 4. Function definition canonical form
#[test]
fn test_fn_def() {
let src = "fn add(a: Int, b: Int) -> Int {\n return a + b\n}\n";
let out = fmt(src);
assert!(out.contains("fn add("), "missing fn signature: {out}");
assert!(out.contains("return a + b"), "missing return: {out}");
idempotent(src);
}
// 5. Nested function has 4-space indent
#[test]
fn test_nested_indent() {
let src = "fn outer() -> Void {\n fn inner() -> Void {\n }\n}\n";
let out = fmt(src);
assert!(out.contains(" fn inner("), "inner fn not indented: {out}");
idempotent(src);
}
// 6. If expression spacing
#[test]
fn test_if_expr() {
let src = "fn f() -> Void {\n if true {\n }\n}\n";
let out = fmt(src);
assert!(out.contains("if true"), "missing if: {out}");
idempotent(src);
}
// 7. If-else expression
#[test]
fn test_if_else() {
let src = "fn f(x: Int) -> Void {\n if x {\n } else {\n }\n}\n";
let out = fmt(src);
assert!(out.contains("else"), "missing else: {out}");
idempotent(src);
}
// 8. Match expression arms on own lines
#[test]
fn test_match_expr() {
let src = "fn f(x: Status) -> Void {\n match x {\n Status::Active(v) => 1\n _ => 0\n }\n}\n";
let out = fmt(src);
assert!(out.contains("match x"), "missing match: {out}");
assert!(out.contains("=>"), "missing arm: {out}");
idempotent(src);
}
// 9. Activate expression
#[test]
fn test_activate() {
let src = "fn f() -> Void {\n activate User where \"active users\"\n}\n";
let out = fmt(src);
assert!(out.contains("activate User where"), "missing activate: {out}");
idempotent(src);
}
// 10. Sealed block
#[test]
fn test_sealed_block() {
let src = "fn f() -> Void {\n sealed {\n let x: Int = 1\n }\n}\n";
let out = fmt(src);
assert!(out.contains("sealed {"), "missing sealed: {out}");
idempotent(src);
}
// 11. Array literal
#[test]
fn test_array_literal() {
let src = "[1, 2, 3]\n";
let out = fmt(src);
assert!(out.contains("[1, 2, 3]"), "missing array: {out}");
idempotent(src);
}
// 12. Field access
#[test]
fn test_field_access() {
let src = "fn f(u: User) -> Void {\n u.name\n}\n";
let out = fmt(src);
assert!(out.contains("u.name"), "missing field access: {out}");
idempotent(src);
}
// 13. Function call with args
#[test]
fn test_fn_call() {
let src = "foo(1, 2)\n";
let out = fmt(src);
assert!(out.contains("foo(1, 2)"), "missing call: {out}");
idempotent(src);
}
// 14. Type definition
#[test]
fn test_type_def() {
let src = "type User {\n name: String\n age: Int\n}\n";
let out = fmt(src);
assert!(out.contains("type User {"), "missing type def: {out}");
assert!(out.contains("name: String"), "missing field: {out}");
idempotent(src);
}
// 15. Enum definition
#[test]
fn test_enum_def() {
let src = "enum Status {\n Active\n Inactive\n}\n";
let out = fmt(src);
assert!(out.contains("enum Status {"), "missing enum def: {out}");
assert!(out.contains("Active"), "missing variant: {out}");
idempotent(src);
}
// 16. Decorator on fn
#[test]
fn test_decorator() {
let src = "@experience\nfn handle() -> Void {\n}\n";
let out = fmt(src);
assert!(out.contains("@experience"), "missing decorator: {out}");
idempotent(src);
}
// 17. Multiple decorators in order
#[test]
fn test_multiple_decorators() {
let src = "@public\n@experience\nfn handle() -> Void {\n}\n";
let out = fmt(src);
let pub_pos = out.find("@public").unwrap();
let exp_pos = out.find("@experience").unwrap();
assert!(pub_pos < exp_pos, "decorators out of order: {out}");
idempotent(src);
}
// 18. Return type annotation
#[test]
fn test_return_type() {
let src = "fn add(a: Int, b: Int) -> Int {\n return a + b\n}\n";
let out = fmt(src);
assert!(out.contains("-> Int"), "missing return type: {out}");
idempotent(src);
}
// 19. Result type
#[test]
fn test_result_type() {
let src = "fn load() -> Result<String, Error> {\n return \"ok\"\n}\n";
let out = fmt(src);
assert!(out.contains("Result<String, Error>"), "missing result type: {out}");
idempotent(src);
}
// 20. Optional type
#[test]
fn test_optional_type() {
let src = "fn find() -> String? {\n return \"ok\"\n}\n";
let out = fmt(src);
assert!(out.contains("String?"), "missing optional type: {out}");
idempotent(src);
}
// 21. Trailing newline always present
#[test]
fn test_trailing_newline() {
let out = fmt("42");
assert!(out.ends_with('\n'), "missing trailing newline");
}
// 22. is_canonical returns true for already-canonical source
#[test]
fn test_is_canonical_true() {
let src = "42\n";
assert!(is_canonical(src).unwrap(), "expected canonical");
}
// 23. is_canonical returns false for non-canonical source
#[test]
fn test_is_canonical_false() {
// No trailing newline
let result = is_canonical("42");
// Either it returns false OR the formatter fixes it
// Either way it should not error
assert!(result.is_ok());
}
// 24. Empty program produces just a newline
#[test]
fn test_empty_program() {
// An empty string has no stmts so produces nothing; trailing newline adds one
let out = fmt("");
assert_eq!(out, "\n");
}
// 25. Idempotence for multiple constructs
#[test]
fn test_idempotent_fn_def() {
idempotent("fn add(a: Int, b: Int) -> Int {\n return a + b\n}\n");
idempotent("fn noop() -> Void {\n}\n");
}
#[test]
fn test_idempotent_type_def() {
idempotent("type User {\n name: String\n age: Int\n}\n");
}
#[test]
fn test_idempotent_enum_def() {
idempotent("enum Status {\n Active\n Inactive\n}\n");
}
// 26. Test block
#[test]
fn test_test_block() {
let src = "test \"my test\" {\n assert 1 == 1\n}\n";
let out = fmt(src);
assert!(out.contains("test \"my test\""), "missing test block: {out}");
idempotent(src);
}
// 27. Wildcard pattern in match
#[test]
fn test_wildcard_pattern() {
let src = "fn f(x: Status) -> Void {\n match x {\n _ => 0\n }\n}\n";
let out = fmt(src);
assert!(out.contains("_ =>"), "missing wildcard: {out}");
idempotent(src);
}
// 28. Binding pattern in match
#[test]
fn test_binding_pattern() {
let src = "fn f(x: Int) -> Void {\n match x {\n v => v\n }\n}\n";
let out = fmt(src);
assert!(out.contains("v =>"), "missing binding: {out}");
idempotent(src);
}
// 29. Enum variant with payload
#[test]
fn test_enum_variant_payload() {
let src = "enum Msg {\n Value(Int)\n Empty\n}\n";
let out = fmt(src);
assert!(out.contains("Value(Int)"), "missing payload variant: {out}");
idempotent(src);
}
// 30. for loop
#[test]
fn test_for_loop() {
let src = "fn f(items: [Int]) -> Void {\n for x in items {\n x\n }\n}\n";
let out = fmt(src);
assert!(out.contains("for x in"), "missing for loop: {out}");
idempotent(src);
}
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "el-lint"
version = "0.1.0"
edition = "2021"
[dependencies]
el-lexer = { path = "../el-lexer" }
el-parser = { path = "../el-parser" }
el-types = { path = "../el-types" }
el-arch = { path = "../el-arch" }
el-fmt = { path = "../el-fmt" }
thiserror = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[dev-dependencies]
el-lexer = { path = "../el-lexer" }
el-parser = { path = "../el-parser" }
+11
View File
@@ -0,0 +1,11 @@
//! Error types for el-lint.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum LintError {
#[error("lex error: {0}")]
Lex(String),
#[error("parse error: {0}")]
Parse(String),
}
+298
View File
@@ -0,0 +1,298 @@
//! el-lint — linter for engram-lang source files.
//!
//! Combines:
//! - `el-arch` architectural rule violations (VBD, EBD, swarm, security, graph)
//! - Style checks (naming conventions, function length, empty bodies)
//! - Format check (`el-fmt` canonical check, rule I001)
pub mod error;
pub mod linter;
pub mod report;
pub mod rules;
pub use error::LintError;
pub use linter::Linter;
pub use report::{LintDiagnostic, LintReport, LintSeverity};
/// Lint engram-lang source code. Returns a report with all diagnostics.
pub fn lint(source: &str) -> Result<LintReport, LintError> {
Linter::new().lint(source)
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn do_lint(src: &str) -> LintReport {
lint(src).unwrap()
}
// 1. Clean, canonical code → no errors, no warnings
#[test]
fn test_clean_code_no_errors() {
let src = "fn add(a: Int, b: Int) -> Int {\n return a + b\n}\n";
let report = do_lint(src);
assert!(!report.has_errors(), "unexpected errors: {:?}", report.diagnostics);
assert_eq!(report.warning_count(), 0, "unexpected warnings: {:?}", report.diagnostics);
}
// 2. @accessor calling @manager fn → VBD-001 error
#[test]
fn test_accessor_calls_manager() {
let src = concat!(
"@manager\nfn save_data() -> Void {\n}\n\n",
"@accessor\nfn get_data() -> Void {\n save_data()\n}\n"
);
let report = do_lint(src);
assert!(
report.has_errors(),
"expected arch error for accessor calling manager"
);
let has_vbd = report
.diagnostics
.iter()
.any(|d| d.code.contains("VBD"));
assert!(has_vbd, "expected VBD code: {:?}", report.diagnostics);
}
// 3. activate in a loop → GRAPH-001 warning
#[test]
fn test_activate_in_loop() {
let src = concat!(
"@accessor\nfn load_all(items: [String]) -> Void {\n",
" for x in items {\n",
" activate User where \"query\"\n",
" }\n}\n"
);
let report = do_lint(src);
let has_graph = report
.diagnostics
.iter()
.any(|d| d.code.contains("GRAPH") || d.code.contains("N1"));
assert!(has_graph, "expected GRAPH/N1 diagnostic: {:?}", report.diagnostics);
}
// 4. Function with uppercase name → S002 warning
#[test]
fn test_fn_uppercase_name() {
let src = "fn MyFunction() -> Void {\n}\n";
let report = do_lint(src);
let has_s002 = report.diagnostics.iter().any(|d| d.code == "S002");
assert!(has_s002, "expected S002: {:?}", report.diagnostics);
}
// 5. Type with lowercase name → S004 warning
#[test]
fn test_type_lowercase_name() {
let src = "type myType {\n x: Int\n}\n";
let report = do_lint(src);
let has_s004 = report.diagnostics.iter().any(|d| d.code == "S004");
assert!(has_s004, "expected S004: {:?}", report.diagnostics);
}
// 6. Empty function body → S003 info
#[test]
fn test_empty_fn_body() {
let src = "fn empty() -> Void {\n}\n";
let report = do_lint(src);
let has_s003 = report.diagnostics.iter().any(|d| d.code == "S003");
assert!(has_s003, "expected S003: {:?}", report.diagnostics);
}
// 7. Non-canonical formatting → I001 info
#[test]
fn test_non_canonical_format() {
// Missing trailing newline triggers I001 (formatter adds it, source doesn't have it)
let src = "42";
let report = do_lint(src);
let has_i001 = report.diagnostics.iter().any(|d| d.code == "I001");
assert!(has_i001, "expected I001: {:?}", report.diagnostics);
}
// 8. Canonical formatting → no I001
#[test]
fn test_canonical_format_no_i001() {
let src = "fn add(a: Int, b: Int) -> Int {\n return a + b\n}\n";
let report = do_lint(src);
let has_i001 = report.diagnostics.iter().any(|d| d.code == "I001");
assert!(!has_i001, "unexpected I001: {:?}", report.diagnostics);
}
// 9. has_errors() true when errors present
#[test]
fn test_has_errors_true() {
let src = concat!(
"@manager\nfn save() -> Void {\n}\n\n",
"@accessor\nfn get() -> Void {\n save()\n}\n"
);
let report = do_lint(src);
assert!(report.has_errors());
}
// 10. has_errors() false when only warnings/info
#[test]
fn test_has_errors_false_warnings_only() {
let src = "fn MyFunction() -> Int {\n return 1\n}\n";
let report = do_lint(src);
assert!(!report.has_errors(), "should not have errors, only warnings");
}
// 11. error_count() correct
#[test]
fn test_error_count() {
let src = concat!(
"@manager\nfn save() -> Void {\n}\n\n",
"@accessor\nfn get() -> Void {\n save()\n}\n"
);
let report = do_lint(src);
assert!(report.error_count() >= 1);
}
// 12. warning_count() correct
#[test]
fn test_warning_count() {
let src = "fn MyFunction() -> Int {\n return 1\n}\n";
let report = do_lint(src);
assert!(report.warning_count() >= 1, "expected at least one warning");
}
// 13. display() output contains "error" prefix for errors
#[test]
fn test_display_error_prefix() {
let src = concat!(
"@manager\nfn save() -> Void {\n}\n\n",
"@accessor\nfn get() -> Void {\n save()\n}\n"
);
let report = do_lint(src);
let display = report.display();
assert!(display.contains("error"), "expected 'error' in display: {display}");
}
// 14. display() contains "No issues found." for clean code
#[test]
fn test_display_no_issues() {
let src = "fn add(a: Int, b: Int) -> Int {\n return a + b\n}\n";
let report = do_lint(src);
if !report.has_errors() && report.warning_count() == 0 {
let display = report.display();
assert!(
display.contains("No issues found."),
"expected 'No issues found.': {display}"
);
}
}
// 15. to_json() is valid JSON
#[test]
fn test_to_json_valid() {
let src = "fn add(a: Int, b: Int) -> Int {\n return a + b\n}\n";
let report = do_lint(src);
let json = report.to_json();
let parsed: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
assert!(parsed.is_array(), "expected JSON array");
}
// 16. to_json() contains severity field
#[test]
fn test_to_json_has_severity() {
let src = "fn MyFunction() -> Int {\n return 1\n}\n";
let report = do_lint(src);
let json = report.to_json();
assert!(json.contains("severity"), "expected severity field: {json}");
}
// 17. Multiple issues in same file → all reported
#[test]
fn test_multiple_issues() {
let src = "fn MyFunction() -> Void {\n}\ntype myType {\n x: Int\n}\n";
let report = do_lint(src);
// S002 for fn name + S003 for empty body + S004 for type name
assert!(
report.diagnostics.len() >= 2,
"expected multiple diagnostics: {:?}",
report.diagnostics
);
}
// 18. @experience calling @experience → arch error
#[test]
fn test_experience_calls_experience() {
let src = concat!(
"@experience\nfn exp_a() -> Void {\n}\n\n",
"@experience\nfn exp_b() -> Void {\n exp_a()\n}\n"
);
let report = do_lint(src);
assert!(
report.has_errors(),
"expected arch error for experience calling experience"
);
}
// 19. @public fn with activate → arch error
#[test]
fn test_public_fn_with_activate() {
let src = "@public\nfn api_fn() -> Void {\n activate User where \"query\"\n}\n";
let report = do_lint(src);
assert!(
report.has_errors(),
"expected arch error for public fn with activate"
);
}
// 20. @swarm_agent calling @swarm_agent → diagnostic
#[test]
fn test_swarm_agent_calls_swarm_agent() {
let src = concat!(
"@swarm_agent\nfn agent_a() -> Void {\n}\n\n",
"@swarm_agent\nfn agent_b() -> Void {\n agent_a()\n}\n"
);
let report = do_lint(src);
// SwarmAgentIsolation should flag this
let has_swarm = report
.diagnostics
.iter()
.any(|d| d.code.contains("SWARM") || d.severity == LintSeverity::Error || d.severity == LintSeverity::Warning);
assert!(has_swarm, "expected swarm diagnostic: {:?}", report.diagnostics);
}
// 21. Nested functions → linting still works
#[test]
fn test_nested_functions() {
let src = concat!(
"fn outer(x: Int) -> Int {\n",
" fn inner(y: Int) -> Int {\n",
" return y + 1\n",
" }\n",
" return inner(x)\n",
"}\n"
);
// Should not panic
let result = lint(src);
assert!(result.is_ok(), "lint failed on nested functions");
}
// 22. LintReport::file_path is None by default
#[test]
fn test_file_path_none() {
let src = "fn f() -> Void {\n}\n";
let report = do_lint(src);
assert!(report.file_path.is_none());
}
// 23. source_lines is counted correctly
#[test]
fn test_source_lines_counted() {
let src = "fn f() -> Int {\n return 1\n}\n";
let report = do_lint(src);
assert_eq!(report.source_lines, 3);
}
// 24. Empty source → no crash
#[test]
fn test_empty_source() {
let result = lint("");
assert!(result.is_ok());
}
}
+76
View File
@@ -0,0 +1,76 @@
//! Core linter — orchestrates arch rules, style rules, and format check.
use el_arch::{ArchChecker, Severity as ArchSeverity};
use crate::{
error::LintError,
report::{LintDiagnostic, LintReport, LintSeverity},
rules,
};
pub struct Linter {
arch_checker: ArchChecker,
}
impl Linter {
pub fn new() -> Self {
Self {
arch_checker: ArchChecker::new(),
}
}
pub fn lint(&self, source: &str) -> Result<LintReport, LintError> {
let tokens = el_lexer::tokenize(source)
.map_err(|e| LintError::Lex(e.to_string()))?;
let program = el_parser::parse(tokens, source.to_string())
.map_err(|e| LintError::Parse(e.to_string()))?;
let mut diagnostics = Vec::new();
// 1. Run el-arch architectural rules.
let arch_diags = self.arch_checker.check(&program);
for d in arch_diags {
diagnostics.push(LintDiagnostic {
severity: match d.severity {
ArchSeverity::Error => LintSeverity::Error,
ArchSeverity::Warning => LintSeverity::Warning,
},
code: d.rule,
message: d.message,
location: d.location.unwrap_or_else(|| "unknown".into()),
suggestion: None,
});
}
// 2. Run style rules.
let style_diags = rules::check_style(&program);
diagnostics.extend(style_diags);
// 3. Check whether the source is in canonical format.
match el_fmt::is_canonical(source) {
Ok(false) => {
diagnostics.push(LintDiagnostic {
severity: LintSeverity::Info,
code: "I001".into(),
message: "source is not in canonical format — run `el fmt` to fix".into(),
location: "file".into(),
suggestion: Some("el fmt --in-place <file.el>".into()),
});
}
_ => {}
}
let source_lines = source.lines().count();
Ok(LintReport {
diagnostics,
file_path: None,
source_lines,
})
}
}
impl Default for Linter {
fn default() -> Self {
Self::new()
}
}
+92
View File
@@ -0,0 +1,92 @@
//! Diagnostic report types for el-lint.
#[derive(Debug, Clone, PartialEq)]
pub enum LintSeverity {
Error,
Warning,
Info,
}
#[derive(Debug, Clone)]
pub struct LintDiagnostic {
pub severity: LintSeverity,
/// Rule code, e.g. "E001", "W002", "S001", "I001".
pub code: String,
pub message: String,
/// Human-readable location hint, e.g. "function foo" or "file".
pub location: String,
pub suggestion: Option<String>,
}
#[derive(Debug)]
pub struct LintReport {
pub diagnostics: Vec<LintDiagnostic>,
pub file_path: Option<String>,
pub source_lines: usize,
}
impl LintReport {
pub fn has_errors(&self) -> bool {
self.diagnostics
.iter()
.any(|d| d.severity == LintSeverity::Error)
}
pub fn error_count(&self) -> usize {
self.diagnostics
.iter()
.filter(|d| d.severity == LintSeverity::Error)
.count()
}
pub fn warning_count(&self) -> usize {
self.diagnostics
.iter()
.filter(|d| d.severity == LintSeverity::Warning)
.count()
}
/// Format as human-readable output (similar to rustc error output).
pub fn display(&self) -> String {
let mut out = String::new();
for d in &self.diagnostics {
let prefix = match d.severity {
LintSeverity::Error => "error",
LintSeverity::Warning => "warning",
LintSeverity::Info => "info",
};
out.push_str(&format!("[{}] {}: {}\n", d.code, prefix, d.message));
out.push_str(&format!(" --> {}\n", d.location));
if let Some(suggestion) = &d.suggestion {
out.push_str(&format!(" help: {}\n", suggestion));
}
out.push('\n');
}
if self.diagnostics.is_empty() {
out.push_str("No issues found.\n");
}
out
}
/// Format as JSON for editor integration.
pub fn to_json(&self) -> String {
let items: Vec<serde_json::Value> = self
.diagnostics
.iter()
.map(|d| {
serde_json::json!({
"severity": match d.severity {
LintSeverity::Error => "error",
LintSeverity::Warning => "warning",
LintSeverity::Info => "info",
},
"code": d.code,
"message": d.message,
"location": d.location,
"suggestion": d.suggestion,
})
})
.collect();
serde_json::to_string_pretty(&items).unwrap()
}
}
+116
View File
@@ -0,0 +1,116 @@
//! Style and correctness rules for el-lint (beyond el-arch architectural rules).
use el_parser::{Program, Stmt};
use crate::report::{LintDiagnostic, LintSeverity};
/// Run all style rules against the program and return diagnostics.
pub fn check_style(program: &Program) -> Vec<LintDiagnostic> {
let mut diags = Vec::new();
for stmt in &program.stmts {
check_stmt(stmt, &mut diags);
}
diags
}
fn check_stmt(stmt: &Stmt, diags: &mut Vec<LintDiagnostic>) {
match stmt {
Stmt::FnDef { name, body, .. } => {
// S001: Function body too long (>50 statements)
if body.len() > 50 {
diags.push(LintDiagnostic {
severity: LintSeverity::Warning,
code: "S001".into(),
message: format!(
"function `{name}` has {} statements — consider splitting",
body.len()
),
location: format!("function {name}"),
suggestion: Some(
"extract sub-functions for each logical concern".into(),
),
});
}
// S002: Function name not snake_case
if name.chars().any(|c| c.is_uppercase()) {
diags.push(LintDiagnostic {
severity: LintSeverity::Warning,
code: "S002".into(),
message: format!("function `{name}` should be snake_case"),
location: format!("function {name}"),
suggestion: Some(format!("rename to `{}`", to_snake_case(name))),
});
}
// S003: Empty function body
if body.is_empty() {
diags.push(LintDiagnostic {
severity: LintSeverity::Info,
code: "S003".into(),
message: format!("function `{name}` has an empty body"),
location: format!("function {name}"),
suggestion: Some("add implementation or remove if unused".into()),
});
}
// Recurse into nested function defs
for s in body {
check_stmt(s, diags);
}
}
Stmt::TypeDef { name, .. } => {
// S004: Type name not PascalCase
if !is_pascal_case(name) {
diags.push(LintDiagnostic {
severity: LintSeverity::Warning,
code: "S004".into(),
message: format!("type `{name}` should be PascalCase"),
location: format!("type {name}"),
suggestion: None,
});
}
}
Stmt::EnumDef { name, .. } => {
// S004 also applies to enums
if !is_pascal_case(name) {
diags.push(LintDiagnostic {
severity: LintSeverity::Warning,
code: "S004".into(),
message: format!("enum `{name}` should be PascalCase"),
location: format!("enum {name}"),
suggestion: None,
});
}
}
Stmt::ImplDef { methods, .. } => {
for m in methods {
check_stmt(m, diags);
}
}
_ => {}
}
}
/// Convert CamelCase/mixed to snake_case.
fn to_snake_case(s: &str) -> String {
let mut result = String::new();
for (i, c) in s.chars().enumerate() {
if c.is_uppercase() && i > 0 {
result.push('_');
}
result.push(c.to_lowercase().next().unwrap());
}
result
}
/// Returns true if the first character is uppercase (PascalCase convention).
fn is_pascal_case(s: &str) -> bool {
s.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
}
+11 -1
View File
@@ -59,6 +59,8 @@ pub enum TypeExpr {
Result { ok: Box<TypeExpr>, err: Box<TypeExpr> },
/// `Map<K, V>` — built-in key-value map type
Map { key: Box<TypeExpr>, value: Box<TypeExpr> },
/// A generic type parameter: `T`, `E` — used inside generic function signatures.
TypeParam(String),
}
// ── Patterns (for match arms) ─────────────────────────────────────────────────
@@ -118,6 +120,12 @@ pub enum Expr {
Try(Box<Expr>),
/// Map literal: `{"key": value, ...}`
MapLiteral(Vec<(Expr, Expr)>),
/// Struct literal: `Point { x: 10, y: 20 }`
StructLit {
type_name: String,
fields: Vec<(String, Expr)>,
span: Span,
},
}
// ── Match arm ─────────────────────────────────────────────────────────────────
@@ -190,10 +198,12 @@ pub enum Stmt {
Return(Expr, Span),
/// A bare expression used as a statement (usually a call).
Expr(Expr, Span),
/// `fn name(params) -> ReturnType { body }` (with optional decorators)
/// `fn name<T, E>(params) -> ReturnType { body }` (with optional decorators)
FnDef {
name: String,
decorators: Vec<Decorator>,
/// Generic type parameters, e.g. `["T", "E"]` for `fn foo<T, E>`.
type_params: Vec<String>,
params: Vec<Param>,
return_type: TypeExpr,
body: Vec<Stmt>,
+2 -2
View File
@@ -11,8 +11,8 @@ mod error;
mod parser;
pub use ast::{
BinOp, Expr, Field, Literal, MatchArm, Param, Pattern, Program, SeedStmt, Stmt, TestTarget,
TypeExpr, Variant,
BinOp, Decorator, Expr, Field, Literal, MatchArm, Param, Pattern, Program, ProtocolMethod,
SeedStmt, Stmt, TestTarget, TypeExpr, Variant,
};
pub use error::{ParseError, ParseErrorKind};
pub use parser::parse;
+49 -8
View File
@@ -333,15 +333,28 @@ impl Parser {
fn parse_fn_def(&mut self, start: Span, decorators: Vec<Decorator>) -> Result<Stmt, ParseError> {
self.expect(&Token::Fn)?;
let (name, _) = self.expect_ident()?;
// Optional generic type parameters: `<T, E>`
let type_params = if self.eat(&Token::Lt) {
let mut tps = Vec::new();
while !matches!(self.peek(), Token::Gt | Token::Eof) {
let (tp, _) = self.expect_ident()?;
tps.push(tp);
if !self.eat(&Token::Comma) { break; }
}
self.expect(&Token::Gt)?;
tps
} else {
Vec::new()
};
self.expect(&Token::LParen)?;
let params = self.parse_param_list()?;
let params = self.parse_param_list_with_type_params(&type_params)?;
self.expect(&Token::RParen)?;
self.expect(&Token::Arrow)?;
let return_type = self.parse_type_expr()?;
let return_type = self.parse_type_expr_with_params(&type_params)?;
self.expect(&Token::LBrace)?;
let body = self.parse_block_body()?;
self.expect(&Token::RBrace)?;
Ok(Stmt::FnDef { name, decorators, params, return_type, body, span: start })
Ok(Stmt::FnDef { name, decorators, type_params, params, return_type, body, span: start })
}
/// Parse one or more `@decorator` annotations, then the `fn` definition.
@@ -484,13 +497,18 @@ impl Parser {
Ok(Stmt::ImplDef { protocol_name, type_name, methods, span: start })
}
#[allow(dead_code)]
fn parse_param_list(&mut self) -> Result<Vec<Param>, ParseError> {
self.parse_param_list_with_type_params(&[])
}
fn parse_param_list_with_type_params(&mut self, type_params: &[String]) -> Result<Vec<Param>, ParseError> {
let mut params = Vec::new();
while !matches!(self.peek(), Token::RParen | Token::Eof | Token::Pipe) {
let span = self.peek_span();
let (name, _) = self.expect_ident()?;
self.expect(&Token::Colon)?;
let type_ann = self.parse_type_expr()?;
let type_ann = self.parse_type_expr_with_params(type_params)?;
params.push(Param { name, type_ann, span });
if !self.eat(&Token::Comma) {
break;
@@ -555,10 +573,14 @@ impl Parser {
// ── Type expressions ──────────────────────────────────────────────────────
fn parse_type_expr(&mut self) -> Result<TypeExpr, ParseError> {
self.parse_type_expr_with_params(&[])
}
fn parse_type_expr_with_params(&mut self, type_params: &[String]) -> Result<TypeExpr, ParseError> {
let span = self.peek_span();
// Array type: [T]
if self.eat(&Token::LBracket) {
let inner = self.parse_type_expr()?;
let inner = self.parse_type_expr_with_params(type_params)?;
self.expect(&Token::RBracket)?;
let mut te = TypeExpr::Array(Box::new(inner));
// Optional array: [T]?
@@ -580,12 +602,12 @@ impl Parser {
self.expect(&Token::LParen)?;
let mut params = Vec::new();
while !matches!(self.peek(), Token::RParen | Token::Eof) {
params.push(self.parse_type_expr()?);
params.push(self.parse_type_expr_with_params(type_params)?);
if !self.eat(&Token::Comma) { break; }
}
self.expect(&Token::RParen)?;
self.expect(&Token::Arrow)?;
let ret = self.parse_type_expr()?;
let ret = self.parse_type_expr_with_params(type_params)?;
return Ok(TypeExpr::Fn { params, return_type: Box::new(ret) });
}
// Result<T, E> — built-in generic result type
@@ -612,6 +634,10 @@ impl Parser {
}
return Ok(te);
}
// If the name is in the current generic type params list, emit TypeParam
if type_params.contains(&name) {
return Ok(TypeExpr::TypeParam(name));
}
// Named type with optional ? suffix
let mut te = TypeExpr::Named(name);
if self.eat(&Token::QuestionMark) {
@@ -881,7 +907,7 @@ impl Parser {
Ok(Expr::If { cond: Box::new(cond), then: Box::new(then), else_ })
}
// Identifier — could be plain name or path (Foo::Bar)
// Identifier — could be plain name, path (Foo::Bar), or struct literal (Foo { ... })
Token::Ident(name) => {
self.advance();
// Check for path: Foo::Bar or Foo::Bar::Baz
@@ -892,6 +918,21 @@ impl Parser {
segments.push(seg);
}
Ok(Expr::Path { segments })
} else if matches!(self.peek(), Token::LBrace)
&& name.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
{
// Struct literal: TypeName { field: expr, ... }
self.advance(); // consume `{`
let mut fields = Vec::new();
while !matches!(self.peek(), Token::RBrace | Token::Eof) {
let (field_name, _) = self.expect_ident()?;
self.expect(&Token::Colon)?;
let field_expr = self.parse_expr()?;
fields.push((field_name, field_expr));
if !self.eat(&Token::Comma) { break; }
}
self.expect(&Token::RBrace)?;
Ok(Expr::StructLit { type_name: name, fields, span })
} else {
Ok(Expr::Ident(name))
}
+8
View File
@@ -307,6 +307,14 @@ impl<'g> Evaluator<'g> {
Ok(EvalValue::Nil)
}
Expr::StructLit { type_name: _, fields, .. } => {
// Evaluate all fields but return Nil — struct construction in test
// eval context is not supported yet (tests use activate, not literals)
for (_, e) in fields {
self.eval_expr(e)?;
}
Ok(EvalValue::Nil)
}
// New expression kinds — return Nil
_ => {
Ok(EvalValue::Nil)
+35
View File
@@ -441,6 +441,41 @@ impl TypeChecker {
Type::Map { key: Box::new(key_ty), value: Box::new(val_ty) }
}
}
Expr::StructLit { type_name, fields, .. } => {
// Look up the type definition
match self.env.get_type(type_name).cloned() {
Some(TypeDef::Struct { fields: declared_fields, .. }) => {
// Check that all provided fields are valid and have compatible types
for (field_name, field_expr) in fields {
let got_ty = self.infer_expr(field_expr);
if let Some((_, expected_ty)) = declared_fields.iter().find(|(n, _)| n == field_name) {
if !self.env.check_compatible(&got_ty, expected_ty) {
self.emit_error(TypeErrorKind::TypeMismatch {
expected: expected_ty.to_string(),
got: got_ty.to_string(),
});
}
} else {
self.emit_error(TypeErrorKind::UnknownField {
type_name: type_name.clone(),
field: field_name.clone(),
});
}
}
Type::Named(type_name.clone())
}
Some(_) => {
self.emit_error(TypeErrorKind::UndefinedType(
format!("{type_name} is not a struct type"),
));
Type::Unknown
}
None => {
self.emit_error(TypeErrorKind::UndefinedType(type_name.clone()));
Type::Unknown
}
}
}
}
}
+17
View File
@@ -97,6 +97,7 @@ pub struct TypeEnv {
}
impl TypeEnv {
/// Create a fresh environment pre-populated with built-in types and functions.
pub fn with_builtins() -> Self {
let mut env = Self::default();
env.types.insert("Int".into(), TypeDef::Primitive(Type::Int));
@@ -105,6 +106,17 @@ impl TypeEnv {
env.types.insert("Bool".into(), TypeDef::Primitive(Type::Bool));
env.types.insert("Uuid".into(), TypeDef::Primitive(Type::Uuid));
env.types.insert("Void".into(), TypeDef::Primitive(Type::Void));
// Register built-in output functions — accept any value (Unknown = polymorphic)
let void_fn_any = Type::Fn {
params: vec![Type::Unknown],
return_type: Box::new(Type::Void),
};
env.functions.insert("print".into(), void_fn_any.clone());
env.functions.insert("println".into(), void_fn_any.clone());
env.functions.insert("log".into(), void_fn_any.clone());
env.functions.insert("print_err".into(), void_fn_any);
env
}
@@ -274,6 +286,11 @@ impl TypeEnv {
let val_ty = self.resolve_type_expr(value)?;
Ok(Type::Map { key: Box::new(key_ty), value: Box::new(val_ty) })
}
el_parser::TypeExpr::TypeParam(_) => {
// Generic type parameters resolve to Unknown at the call site —
// the actual type is inferred from arguments during call checking.
Ok(Type::Unknown)
}
}
}
}
+23
View File
@@ -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"
+22
View File
@@ -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/
+16
View File
@@ -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"
}
+72
View File
@@ -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);
}
+77
View File
@@ -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();
});
+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));
}
}