diff --git a/Cargo.lock b/Cargo.lock index 5dfb876..aec7aa0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index cc7f4d3..237dd03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/bin/el/Cargo.toml b/bin/el/Cargo.toml index 7cf8370..d91792f 100644 --- a/bin/el/Cargo.toml +++ b/bin/el/Cargo.toml @@ -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 } diff --git a/bin/el/src/main.rs b/bin/el/src/main.rs index 444091f..dd8fe7f 100644 --- a/bin/el/src/main.rs +++ b/bin/el/src/main.rs @@ -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, + }, + /// Compile a single .el source file (no el.toml required). BuildFile { /// Source file (*.el). @@ -241,15 +248,6 @@ enum Command { output: Option, }, - /// 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, - }, - /// Seal an existing release artifact. Seal { artifact: PathBuf, @@ -528,6 +526,9 @@ async fn run(cli: Cli) -> Result<(), Box> { ..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 = Vec::new(); let mut locals: std::collections::HashMap = std::collections::HashMap::new(); let mut call_stack: Vec<(usize, std::collections::HashMap)> = Vec::new(); let mut ip = entry; let program_args: Vec = 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 = (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 = (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; diff --git a/crates/el-arch/Cargo.toml b/crates/el-arch/Cargo.toml new file mode 100644 index 0000000..49f0396 --- /dev/null +++ b/crates/el-arch/Cargo.toml @@ -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" } diff --git a/crates/el-arch/src/checker.rs b/crates/el-arch/src/checker.rs new file mode 100644 index 0000000..01fc318 --- /dev/null +++ b/crates/el-arch/src/checker.rs @@ -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>, +} + +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] { + &self.rules + } + + /// Run all rules against a parsed program and collect all diagnostics. + pub fn check(&self, program: &Program) -> Vec { + // 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 = + 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, + &'a TypeExpr, + &'a Vec, +); + +/// Collect all FnDef statements from top-level and impl blocks. +fn collect_fn_defs<'a>(stmts: &'a [Stmt]) -> Vec> { + 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> { + let mut map = HashMap::new(); + for stmt in stmts { + match stmt { + Stmt::FnDef { name, decorators, .. } => { + let anns: Vec = 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 = 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 { + 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) { + 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) { + 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 { + 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 { + 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) { + 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) { + 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(), + } +} diff --git a/crates/el-arch/src/error.rs b/crates/el-arch/src/error.rs new file mode 100644 index 0000000..cfce687 --- /dev/null +++ b/crates/el-arch/src/error.rs @@ -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, +} + +/// 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, message: impl Into, location: Option) -> Self { + Self { + severity: Severity::Error, + rule: rule.into(), + message: message.into(), + location, + } + } + + pub fn warning(rule: impl Into, message: impl Into, location: Option) -> Self { + Self { + severity: Severity::Warning, + rule: rule.into(), + message: message.into(), + location, + } + } +} diff --git a/crates/el-arch/src/lib.rs b/crates/el-arch/src/lib.rs new file mode 100644 index 0000000..1830c85 --- /dev/null +++ b/crates/el-arch/src/lib.rs @@ -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; diff --git a/crates/el-arch/src/rule.rs b/crates/el-arch/src/rule.rs new file mode 100644 index 0000000..fae54a6 --- /dev/null +++ b/crates/el-arch/src/rule.rs @@ -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>, + /// 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; +} diff --git a/crates/el-arch/src/rules/graph.rs b/crates/el-arch/src/rules/graph.rs new file mode 100644 index 0000000..21eb75a --- /dev/null +++ b/crates/el-arch/src/rules/graph.rs @@ -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 { + // 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 { + 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() + } +} diff --git a/crates/el-arch/src/rules/mod.rs b/crates/el-arch/src/rules/mod.rs new file mode 100644 index 0000000..0a5c036 --- /dev/null +++ b/crates/el-arch/src/rules/mod.rs @@ -0,0 +1,6 @@ +//! Individual architectural rule implementations. + +pub mod graph; +pub mod security; +pub mod swarm; +pub mod vbd; diff --git a/crates/el-arch/src/rules/security.rs b/crates/el-arch/src/rules/security.rs new file mode 100644 index 0000000..e587631 --- /dev/null +++ b/crates/el-arch/src/rules/security.rs @@ -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 { + 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 { + 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 { + 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![] + } +} diff --git a/crates/el-arch/src/rules/swarm.rs b/crates/el-arch/src/rules/swarm.rs new file mode 100644 index 0000000..cc8b4c1 --- /dev/null +++ b/crates/el-arch/src/rules/swarm.rs @@ -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 { + 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 { + 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 { + 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() + } +} diff --git a/crates/el-arch/src/rules/vbd.rs b/crates/el-arch/src/rules/vbd.rs new file mode 100644 index 0000000..234fcda --- /dev/null +++ b/crates/el-arch/src/rules/vbd.rs @@ -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 { + 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 { + 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 ──────────────────────────── + +/// VBD-003: @experience functions should return Result 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 for proper error handling" + } + + fn check(&self, ctx: &FnContext<'_>) -> Vec { + 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 — experiences should propagate errors", + ctx.fn_name, ctx.return_type_name + ), + Some(ctx.fn_name.to_string()), + )]; + } + vec![] + } +} diff --git a/crates/el-arch/src/tests.rs b/crates/el-arch/src/tests.rs new file mode 100644 index 0000000..51c8127 --- /dev/null +++ b/crates/el-arch/src/tests.rs @@ -0,0 +1,448 @@ +//! Comprehensive tests for the el-arch architectural checker. + +use crate::{ArchChecker, ArchDiagnostic, Severity}; + +// ── Test helpers ────────────────────────────────────────────────────────────── + +fn check(src: &str) -> Vec { + 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 { + check(src).into_iter().filter(|d| d.severity == Severity::Error).collect() +} + +fn warnings(src: &str) -> Vec { + 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 { return cart } +@experience fn payment(amount: String) -> Result { 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 { 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::>()); +} + +// ── 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::>()), "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::>()); +} + +// ── 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() {} + assert_arch_rule::(); + assert_arch_rule::(); + assert_arch_rule::(); + assert_arch_rule::(); + assert_arch_rule::(); + assert_arch_rule::(); + assert_arch_rule::(); + assert_arch_rule::(); + assert_arch_rule::(); + assert_arch_rule::(); + assert_arch_rule::(); +} + +// ── 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 { 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::>()); + 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 { 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"); +} diff --git a/crates/el-compiler/src/bytecode.rs b/crates/el-compiler/src/bytecode.rs index 718b20c..a2ff467 100644 --- a/crates/el-compiler/src/bytecode.rs +++ b/crates/el-compiler/src/bytecode.rs @@ -16,13 +16,15 @@ pub enum Value { Nil, /// A list of values (used for `activate` results and array literals). List(Vec), - /// A key-value map — used for struct instances and Map literals. + /// A key-value map — used for Map literals. /// Stored as a Vec of pairs to keep ordering and remain Serialize-friendly. Map(Vec<(String, Value)>), /// A Result value — Ok variant. ResultOk(Box), /// A Result value — Err variant. ResultErr(Box), + /// 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(", ")) + } } } } diff --git a/crates/el-compiler/src/codegen.rs b/crates/el-compiler/src/codegen.rs index c3985f8..093c08e 100644 --- a/crates/el-compiler/src/codegen.rs +++ b/crates/el-compiler/src/codegen.rs @@ -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 = 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)); diff --git a/crates/el-fmt/Cargo.toml b/crates/el-fmt/Cargo.toml new file mode 100644 index 0000000..cfb4513 --- /dev/null +++ b/crates/el-fmt/Cargo.toml @@ -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" } diff --git a/crates/el-fmt/src/config.rs b/crates/el-fmt/src/config.rs new file mode 100644 index 0000000..430dcc3 --- /dev/null +++ b/crates/el-fmt/src/config.rs @@ -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, + } + } +} diff --git a/crates/el-fmt/src/error.rs b/crates/el-fmt/src/error.rs new file mode 100644 index 0000000..0c8d464 --- /dev/null +++ b/crates/el-fmt/src/error.rs @@ -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), +} diff --git a/crates/el-fmt/src/formatter.rs b/crates/el-fmt/src/formatter.rs new file mode 100644 index 0000000..79b893a --- /dev/null +++ b/crates/el-fmt/src/formatter.rs @@ -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 { + 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 = 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 = 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 = params + .iter() + .map(|p| format!("{}: {}", p.name, self.fmt_type(&p.type_ann))) + .collect(); + out.push_str(¶ms_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 = 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(), + } + } +} diff --git a/crates/el-fmt/src/lib.rs b/crates/el-fmt/src/lib.rs new file mode 100644 index 0000000..d7ce81b --- /dev/null +++ b/crates/el-fmt/src/lib.rs @@ -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 { + format_with_config(source, &FmtConfig::default()) +} + +/// Format with an explicit configuration. +pub fn format_with_config(source: &str, config: &FmtConfig) -> Result { + 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 { + 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 {\n return \"ok\"\n}\n"; + let out = fmt(src); + assert!(out.contains("Result"), "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); + } +} diff --git a/crates/el-lint/Cargo.toml b/crates/el-lint/Cargo.toml new file mode 100644 index 0000000..e334dd9 --- /dev/null +++ b/crates/el-lint/Cargo.toml @@ -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" } diff --git a/crates/el-lint/src/error.rs b/crates/el-lint/src/error.rs new file mode 100644 index 0000000..785a2a1 --- /dev/null +++ b/crates/el-lint/src/error.rs @@ -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), +} diff --git a/crates/el-lint/src/lib.rs b/crates/el-lint/src/lib.rs new file mode 100644 index 0000000..d256ddd --- /dev/null +++ b/crates/el-lint/src/lib.rs @@ -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 { + 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()); + } +} diff --git a/crates/el-lint/src/linter.rs b/crates/el-lint/src/linter.rs new file mode 100644 index 0000000..269d3ba --- /dev/null +++ b/crates/el-lint/src/linter.rs @@ -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 { + 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 ".into()), + }); + } + _ => {} + } + + let source_lines = source.lines().count(); + Ok(LintReport { + diagnostics, + file_path: None, + source_lines, + }) + } +} + +impl Default for Linter { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/el-lint/src/report.rs b/crates/el-lint/src/report.rs new file mode 100644 index 0000000..317c94b --- /dev/null +++ b/crates/el-lint/src/report.rs @@ -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, +} + +#[derive(Debug)] +pub struct LintReport { + pub diagnostics: Vec, + pub file_path: Option, + 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 = 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() + } +} diff --git a/crates/el-lint/src/rules.rs b/crates/el-lint/src/rules.rs new file mode 100644 index 0000000..e98a491 --- /dev/null +++ b/crates/el-lint/src/rules.rs @@ -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 { + let mut diags = Vec::new(); + + for stmt in &program.stmts { + check_stmt(stmt, &mut diags); + } + + diags +} + +fn check_stmt(stmt: &Stmt, diags: &mut Vec) { + 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) +} diff --git a/crates/el-parser/src/ast.rs b/crates/el-parser/src/ast.rs index 092578b..9402621 100644 --- a/crates/el-parser/src/ast.rs +++ b/crates/el-parser/src/ast.rs @@ -59,6 +59,8 @@ pub enum TypeExpr { Result { ok: Box, err: Box }, /// `Map` — built-in key-value map type Map { key: Box, value: Box }, + /// 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), /// 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(params) -> ReturnType { body }` (with optional decorators) FnDef { name: String, decorators: Vec, + /// Generic type parameters, e.g. `["T", "E"]` for `fn foo`. + type_params: Vec, params: Vec, return_type: TypeExpr, body: Vec, diff --git a/crates/el-parser/src/lib.rs b/crates/el-parser/src/lib.rs index d489e19..8e3789d 100644 --- a/crates/el-parser/src/lib.rs +++ b/crates/el-parser/src/lib.rs @@ -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; diff --git a/crates/el-parser/src/parser.rs b/crates/el-parser/src/parser.rs index 5b06e88..cd7917d 100644 --- a/crates/el-parser/src/parser.rs +++ b/crates/el-parser/src/parser.rs @@ -333,15 +333,28 @@ impl Parser { fn parse_fn_def(&mut self, start: Span, decorators: Vec) -> Result { self.expect(&Token::Fn)?; let (name, _) = self.expect_ident()?; + // Optional generic type parameters: `` + 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, ParseError> { + self.parse_param_list_with_type_params(&[]) + } + + fn parse_param_list_with_type_params(&mut self, type_params: &[String]) -> Result, 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 { + self.parse_type_expr_with_params(&[]) + } + + fn parse_type_expr_with_params(&mut self, type_params: &[String]) -> Result { 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 — 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)) } diff --git a/crates/el-test/src/eval.rs b/crates/el-test/src/eval.rs index 6c112bd..bd46360 100644 --- a/crates/el-test/src/eval.rs +++ b/crates/el-test/src/eval.rs @@ -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) diff --git a/crates/el-types/src/checker.rs b/crates/el-types/src/checker.rs index 87fbfd4..4e12c7d 100644 --- a/crates/el-types/src/checker.rs +++ b/crates/el-types/src/checker.rs @@ -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 + } + } + } } } diff --git a/crates/el-types/src/types.rs b/crates/el-types/src/types.rs index 9c1393b..9aeea85 100644 --- a/crates/el-types/src/types.rs +++ b/crates/el-types/src/types.rs @@ -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) + } } } } diff --git a/crates/el-wasm/Cargo.toml b/crates/el-wasm/Cargo.toml new file mode 100644 index 0000000..423fb38 --- /dev/null +++ b/crates/el-wasm/Cargo.toml @@ -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" diff --git a/crates/el-wasm/build.sh b/crates/el-wasm/build.sh new file mode 100755 index 0000000..cc9b7b1 --- /dev/null +++ b/crates/el-wasm/build.sh @@ -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/ diff --git a/crates/el-wasm/js/pwa-manifest.json b/crates/el-wasm/js/pwa-manifest.json new file mode 100644 index 0000000..a9e26d3 --- /dev/null +++ b/crates/el-wasm/js/pwa-manifest.json @@ -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" +} diff --git a/crates/el-wasm/js/runtime.js b/crates/el-wasm/js/runtime.js new file mode 100644 index 0000000..3431320 --- /dev/null +++ b/crates/el-wasm/js/runtime.js @@ -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} 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} 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} 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} Compiled bytecode bytes + */ +export async function compileSource(source) { + const rt = await initRuntime(); + return rt.compile_source(source); +} diff --git a/crates/el-wasm/js/service-worker.js b/crates/el-wasm/js/service-worker.js new file mode 100644 index 0000000..fa9d576 --- /dev/null +++ b/crates/el-wasm/js/service-worker.js @@ -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(); +}); diff --git a/crates/el-wasm/src/lib.rs b/crates/el-wasm/src/lib.rs new file mode 100644 index 0000000..4b21b86 --- /dev/null +++ b/crates/el-wasm/src/lib.rs @@ -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, JsValue> { + compile_source_inner(source).map_err(|e| JsValue::from_str(&e)) +} + +/// Load pre-compiled bytecode (`.elc` bytes) and execute it. +/// +/// Returns the JSON-encoded final value from the program, or throws on error. +/// The result is always valid JSON — use `JSON.parse(result)` in JavaScript. +#[cfg(feature = "wasm")] +#[wasm_bindgen] +pub fn load_and_run(bytecode_bytes: &[u8]) -> Result { + load_and_run_inner(bytecode_bytes).map_err(|e| JsValue::from_str(&e)) +} + +/// Compile and run 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 { + 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, String> { + let (bytecode, _source_map) = + compile_to_bytecode(source).map_err(|e| e.to_string())?; + serialize_bytecode(&bytecode) +} + +/// Deserialise `.elc` bytes, execute the bytecode, return a JSON-encoded Value. +/// +/// The result is always clean JSON: integers as numbers, strings as strings, +/// booleans as booleans, nil as null, lists as arrays, maps as objects. +pub fn load_and_run_inner(bytecode_bytes: &[u8]) -> Result { + let bytecode = deserialize_bytecode(bytecode_bytes)?; + let result = run_bytecode(&bytecode)?; + let json_value = value_to_json(&result); + serde_json::to_string(&json_value).map_err(|e| format!("Serialize result error: {e}")) +} + +/// Convert an engram `Value` to a clean `serde_json::Value` for JS consumption. +/// +/// Maps engram types to natural JSON equivalents: +/// - `Int` → JSON number +/// - `Float` → JSON number +/// - `Str` → JSON string +/// - `Bool` → JSON boolean +/// - `Nil` → JSON null +/// - `List` → JSON array +/// - `Map` → JSON object +/// - `ResultOk(v)` → `{"ok": v}` +/// - `ResultErr(e)` → `{"err": e}` +pub fn value_to_json(v: &Value) -> serde_json::Value { + match v { + Value::Int(n) => serde_json::Value::Number(serde_json::Number::from(*n)), + Value::Float(f) => serde_json::Number::from_f64(*f) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), + Value::Str(s) => serde_json::Value::String(s.clone()), + Value::Bool(b) => serde_json::Value::Bool(*b), + Value::Nil => serde_json::Value::Null, + Value::List(items) => { + serde_json::Value::Array(items.iter().map(value_to_json).collect()) + } + Value::Map(pairs) => { + let obj: serde_json::Map = pairs + .iter() + .map(|(k, v)| (k.clone(), value_to_json(v))) + .collect(); + serde_json::Value::Object(obj) + } + Value::ResultOk(inner) => { + serde_json::json!({ "ok": value_to_json(inner) }) + } + Value::ResultErr(inner) => { + serde_json::json!({ "err": value_to_json(inner) }) + } + } +} + +/// Execute a bytecode program on the engram stack machine. +/// +/// Returns the value left on the stack when `Halt` is reached, or `Value::Nil` +/// if the program is empty. +/// +/// # Supported instructions +/// +/// This is a pure stack machine — no I/O, no filesystem, no OS interaction — +/// which makes it safe to run inside WASM. Instructions that reference the +/// Engram runtime (`Activate`) return a placeholder `Nil` value; a full +/// runtime integration would supply a callback from JS. +pub fn run_bytecode(bytecode: &[Bytecode]) -> Result { + let mut stack: Vec = Vec::new(); + // Local variable environment (flat scope for now). + let mut locals: std::collections::HashMap = std::collections::HashMap::new(); + let mut ip: usize = 0; + + while ip < bytecode.len() { + let instr = &bytecode[ip]; + match instr { + // ── Stack ───────────────────────────────────────────────────────── + Bytecode::Push(v) => { + stack.push(v.clone()); + } + Bytecode::Pop => { + stack.pop(); + } + Bytecode::Dup => { + let top = stack.last().ok_or("DUP on empty stack")?.clone(); + stack.push(top); + } + + // ── Arithmetic ──────────────────────────────────────────────────── + Bytecode::Add => { + let (a, b) = pop2(&mut stack)?; + stack.push(arith_add(a, b)?); + } + Bytecode::Sub => { + let (a, b) = pop2(&mut stack)?; + stack.push(arith_sub(a, b)?); + } + Bytecode::Mul => { + let (a, b) = pop2(&mut stack)?; + stack.push(arith_mul(a, b)?); + } + Bytecode::Div => { + let (a, b) = pop2(&mut stack)?; + stack.push(arith_div(a, b)?); + } + + // ── Comparison ──────────────────────────────────────────────────── + Bytecode::Eq => { + let (a, b) = pop2(&mut stack)?; + stack.push(Value::Bool(values_eq(&a, &b))); + } + Bytecode::NotEq => { + let (a, b) = pop2(&mut stack)?; + stack.push(Value::Bool(!values_eq(&a, &b))); + } + Bytecode::Lt => { + let (a, b) = pop2(&mut stack)?; + stack.push(Value::Bool(cmp_values(&a, &b)? < 0)); + } + Bytecode::Gt => { + let (a, b) = pop2(&mut stack)?; + stack.push(Value::Bool(cmp_values(&a, &b)? > 0)); + } + Bytecode::LtEq => { + let (a, b) = pop2(&mut stack)?; + stack.push(Value::Bool(cmp_values(&a, &b)? <= 0)); + } + Bytecode::GtEq => { + let (a, b) = pop2(&mut stack)?; + stack.push(Value::Bool(cmp_values(&a, &b)? >= 0)); + } + + // ── Logical ─────────────────────────────────────────────────────── + Bytecode::And => { + let (a, b) = pop2(&mut stack)?; + stack.push(Value::Bool(is_truthy(&a) && is_truthy(&b))); + } + Bytecode::Or => { + let (a, b) = pop2(&mut stack)?; + stack.push(Value::Bool(is_truthy(&a) || is_truthy(&b))); + } + Bytecode::Not => { + let v = stack.pop().ok_or("NOT on empty stack")?; + stack.push(Value::Bool(!is_truthy(&v))); + } + + // ── Locals ──────────────────────────────────────────────────────── + Bytecode::LoadLocal(name) => { + let v = locals.get(name).cloned().unwrap_or(Value::Nil); + stack.push(v); + } + Bytecode::StoreLocal(name) => { + let v = stack.pop().ok_or("STORE on empty stack")?; + locals.insert(name.clone(), v); + } + + // ── Functions ───────────────────────────────────────────────────── + // The bytecode model stores function bodies inline and registers entry + // points as locals (`__fn_`). A full call-frame implementation + // would use a separate call stack; for WASM we handle the most common + // case of stdlib builtins and leave dynamic dispatch as a stub. + Bytecode::Call { name, arity } => { + let result = call_builtin(name, *arity, &mut stack)?; + stack.push(result); + } + Bytecode::Return => { + // Return leaves the value on the stack; the caller pops it. + // In this simplified VM we just continue execution. + break; + } + + // ── Control flow ────────────────────────────────────────────────── + Bytecode::Jump(offset) => { + ip = apply_offset(ip, *offset)?; + continue; // skip ip += 1 below + } + Bytecode::JumpIf(offset) => { + let v = stack.pop().ok_or("JUMPIF on empty stack")?; + if is_truthy(&v) { + ip = apply_offset(ip, *offset)?; + continue; + } + } + Bytecode::JumpIfNot(offset) => { + let v = stack.pop().ok_or("JUMPIFNOT on empty stack")?; + if !is_truthy(&v) { + ip = apply_offset(ip, *offset)?; + continue; + } + } + + // ── Fields & Indexing ───────────────────────────────────────────── + Bytecode::GetField(field) => { + let obj = stack.pop().ok_or("GETFIELD on empty stack")?; + let v = match &obj { + Value::Map(pairs) => pairs + .iter() + .find(|(k, _v)| k == field) + .map(|(_k, v)| v.clone()) + .unwrap_or(Value::Nil), + _ => Value::Nil, + }; + stack.push(v); + } + Bytecode::GetIndex => { + let idx = stack.pop().ok_or("GETINDEX: missing index")?; + let obj = stack.pop().ok_or("GETINDEX: missing object")?; + let v = match (&obj, &idx) { + (Value::List(items), Value::Int(i)) => { + let i = *i as usize; + items.get(i).cloned().unwrap_or(Value::Nil) + } + _ => Value::Nil, + }; + stack.push(v); + } + Bytecode::BuildMap(n) => { + let mut pairs = Vec::new(); + let n = *n as usize; + // Stack: key0, val0, key1, val1, ... (pushed in order) + // We collect from the top, so reverse at the end. + let start = stack.len().saturating_sub(n * 2); + let raw: Vec = stack.drain(start..).collect(); + for chunk in raw.chunks(2) { + if let [Value::Str(k), v] = chunk { + pairs.push((k.clone(), v.clone())); + } + } + stack.push(Value::Map(pairs)); + } + Bytecode::BuildStruct { fields, .. } => { + let mut pairs: Vec<(String, Value)> = Vec::new(); + let start = stack.len().saturating_sub(fields.len()); + let raw: Vec = stack.drain(start..).collect(); + for (field, val) in fields.iter().zip(raw.into_iter()) { + pairs.push((field.clone(), val)); + } + stack.push(Value::Map(pairs)); + } + Bytecode::SetField(field) => { + let val = stack.pop().ok_or("SETFIELD: missing value")?; + let obj = stack.pop().ok_or("SETFIELD: missing object")?; + let v = match obj { + Value::Map(mut pairs) => { + if let Some(entry) = pairs.iter_mut().find(|(k, _)| k == field) { + entry.1 = val; + } else { + pairs.push((field.clone(), val)); + } + Value::Map(pairs) + } + other => other, + }; + stack.push(v); + } + + // ── Special ─────────────────────────────────────────────────────── + Bytecode::Activate { type_name, query } => { + // The Engram runtime integration is provided by the host JS environment. + // In a full implementation the JS host would register an `activate` callback. + // For now, return a placeholder list so programs using `activate` don't crash. + let _ = (type_name, query); + stack.push(Value::List(Vec::new())); + } + Bytecode::SealedBegin | Bytecode::SealedEnd | Bytecode::Nop => { + // No-ops in the pure VM. + } + Bytecode::Halt => { + break; + } + } + ip += 1; + } + + Ok(stack.pop().unwrap_or(Value::Nil)) +} + +// ── Stack helpers ───────────────────────────────────────────────────────────── + +fn pop2(stack: &mut Vec) -> Result<(Value, Value), String> { + let b = stack.pop().ok_or("stack underflow (right operand)")?; + let a = stack.pop().ok_or("stack underflow (left operand)")?; + Ok((a, b)) +} + +fn apply_offset(ip: usize, offset: i32) -> Result { + // offset is relative to the instruction *after* the jump + let target = (ip as i64) + 1 + (offset as i64); + if target < 0 { + return Err(format!("Jump to negative address {target}")); + } + Ok(target as usize) +} + +// ── Value helpers ───────────────────────────────────────────────────────────── + +fn is_truthy(v: &Value) -> bool { + match v { + Value::Bool(b) => *b, + Value::Nil => false, + Value::Int(0) => false, + _ => true, + } +} + +fn values_eq(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Int(x), Value::Int(y)) => x == y, + (Value::Float(x), Value::Float(y)) => x == y, + (Value::Str(x), Value::Str(y)) => x == y, + (Value::Bool(x), Value::Bool(y)) => x == y, + (Value::Nil, Value::Nil) => true, + _ => false, + } +} + +/// Compare two values; returns negative / zero / positive like `Ord::cmp`. +fn cmp_values(a: &Value, b: &Value) -> Result { + match (a, b) { + (Value::Int(x), Value::Int(y)) => Ok(x.cmp(y) as i32), + (Value::Float(x), Value::Float(y)) => Ok(x.partial_cmp(y).map(|o| o as i32).unwrap_or(0)), + (Value::Str(x), Value::Str(y)) => Ok(x.cmp(y) as i32), + _ => Err(format!("Cannot compare {a:?} and {b:?}")), + } +} + +// ── Arithmetic helpers ──────────────────────────────────────────────────────── + +fn arith_add(a: Value, b: Value) -> Result { + match (a, b) { + (Value::Int(x), Value::Int(y)) => Ok(Value::Int(x.wrapping_add(y))), + (Value::Float(x), Value::Float(y)) => Ok(Value::Float(x + y)), + (Value::Str(x), Value::Str(y)) => Ok(Value::Str(x + &y)), + (a, b) => Err(format!("ADD: type mismatch {a:?} + {b:?}")), + } +} + +fn arith_sub(a: Value, b: Value) -> Result { + match (a, b) { + (Value::Int(x), Value::Int(y)) => Ok(Value::Int(x.wrapping_sub(y))), + (Value::Float(x), Value::Float(y)) => Ok(Value::Float(x - y)), + (a, b) => Err(format!("SUB: type mismatch {a:?} - {b:?}")), + } +} + +fn arith_mul(a: Value, b: Value) -> Result { + match (a, b) { + (Value::Int(x), Value::Int(y)) => Ok(Value::Int(x.wrapping_mul(y))), + (Value::Float(x), Value::Float(y)) => Ok(Value::Float(x * y)), + (a, b) => Err(format!("MUL: type mismatch {a:?} * {b:?}")), + } +} + +fn arith_div(a: Value, b: Value) -> Result { + match (a, b) { + (Value::Int(_), Value::Int(0)) => Err("Division by zero".to_string()), + (Value::Int(x), Value::Int(y)) => Ok(Value::Int(x / y)), + (Value::Float(x), Value::Float(y)) => Ok(Value::Float(x / y)), + (a, b) => Err(format!("DIV: type mismatch {a:?} / {b:?}")), + } +} + +// ── Builtin function dispatch ───────────────────────────────────────────────── + +fn call_builtin(name: &str, arity: u32, stack: &mut Vec) -> Result { + match name { + "__build_list__" => { + let n = arity as usize; + let start = stack.len().saturating_sub(n); + let items: Vec = stack.drain(start..).collect(); + Ok(Value::List(items)) + } + "print" | "println" => { + // In WASM, print is a no-op unless the host wires up a callback. + let n = arity as usize; + let start = stack.len().saturating_sub(n); + let _args: Vec = stack.drain(start..).collect(); + Ok(Value::Nil) + } + "len" => { + let n = arity as usize; + let start = stack.len().saturating_sub(n); + let mut args: Vec = stack.drain(start..).collect(); + let v = args.pop().unwrap_or(Value::Nil); + let len = match &v { + Value::List(items) => items.len() as i64, + Value::Str(s) => s.len() as i64, + Value::Map(pairs) => pairs.len() as i64, + _ => 0, + }; + Ok(Value::Int(len)) + } + _ => { + // Unknown function: consume args, return Nil. + let n = arity as usize; + let start = stack.len().saturating_sub(n); + let _: Vec = stack.drain(start..).collect(); + Ok(Value::Nil) + } + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── Compile pipeline tests ──────────────────────────────────────────────── + + #[test] + fn test_compile_source_produces_bytes() { + let bytes = compile_source_inner("42").unwrap(); + assert!(!bytes.is_empty()); + } + + #[test] + fn test_roundtrip_bytecode_serialization() { + let source = "let x = 1 + 2"; + let bytes = compile_source_inner(source).unwrap(); + let (original, _) = compile_to_bytecode(source).unwrap(); + let restored = deserialize_bytecode(&bytes).unwrap(); + assert_eq!(original, restored); + } + + #[test] + fn test_compile_function_def() { + let source = r#"fn add(a: Int, b: Int) -> Int { a + b }"#; + let bytes = compile_source_inner(source).unwrap(); + assert!(!bytes.is_empty()); + } + + #[test] + fn test_compile_activate() { + let source = r#"activate User where "active users""#; + let (bytecode, _) = compile_to_bytecode(source).unwrap(); + assert!(bytecode + .iter() + .any(|b| matches!(b, Bytecode::Activate { .. }))); + } + + #[test] + fn test_serialize_deserialize_activate() { + let source = r#"activate User where "query""#; + let bytes = compile_source_inner(source).unwrap(); + let restored = deserialize_bytecode(&bytes).unwrap(); + assert!(restored + .iter() + .any(|b| matches!(b, Bytecode::Activate { .. }))); + } + + #[test] + fn test_compile_sealed_block() { + let source = "sealed { let x = 1 }"; + let (bytecode, _) = compile_to_bytecode(source).unwrap(); + assert!(bytecode + .iter() + .any(|b| matches!(b, Bytecode::SealedBegin))); + } + + #[test] + fn test_empty_program_compiles() { + let source = ""; + let (bytecode, _) = compile_to_bytecode(source).unwrap(); + assert!(matches!(bytecode.last(), Some(Bytecode::Halt))); + } + + #[test] + fn test_complex_program_compiles() { + let source = r#" + let x = 10 + let y = 20 + let z = x + y + "#; + let (bytecode, _) = compile_to_bytecode(source).unwrap(); + assert!(!bytecode.is_empty()); + } + + #[test] + fn test_bytecode_json_is_valid() { + let bytes = compile_source_inner("1 + 2").unwrap(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert!(json.is_array()); + } + + #[test] + fn test_version_string() { + assert!(!env!("CARGO_PKG_VERSION").is_empty()); + } + + // ── VM execution tests ──────────────────────────────────────────────────── + + #[test] + fn test_run_integer_literal() { + let result = load_and_run_inner(&compile_source_inner("42").unwrap()).unwrap(); + // The final value on the stack is the integer 42. + assert_eq!(result, "42"); + } + + #[test] + fn test_run_addition() { + let result = load_and_run_inner(&compile_source_inner("1 + 2").unwrap()).unwrap(); + assert_eq!(result, "3"); + } + + #[test] + fn test_run_string_literal() { + let result = + load_and_run_inner(&compile_source_inner(r#""hello""#).unwrap()).unwrap(); + assert_eq!(result, r#""hello""#); + } + + #[test] + fn test_run_boolean() { + let result = load_and_run_inner(&compile_source_inner("true").unwrap()).unwrap(); + assert_eq!(result, "true"); + } + + #[test] + fn test_run_let_binding_and_use() { + let source = "let x = 10\nx"; + let result = load_and_run_inner(&compile_source_inner(source).unwrap()).unwrap(); + assert_eq!(result, "10"); + } + + #[test] + fn test_run_arithmetic_chain() { + // 2 * 3 + 4 should be 10 (if parsed left-to-right) + let source = "2 * 3"; + let result = load_and_run_inner(&compile_source_inner(source).unwrap()).unwrap(); + assert_eq!(result, "6"); + } + + #[test] + fn test_run_activate_returns_list() { + let source = r#"activate User where "all""#; + let result = load_and_run_inner(&compile_source_inner(source).unwrap()).unwrap(); + // Activate returns an empty list placeholder in the pure VM. + assert_eq!(result, "[]"); + } + + #[test] + fn test_run_if_true_branch() { + let source = "if true { 1 } else { 2 }"; + let result = load_and_run_inner(&compile_source_inner(source).unwrap()).unwrap(); + assert_eq!(result, "1"); + } + + #[test] + fn test_run_if_false_branch() { + let source = "if false { 1 } else { 2 }"; + let result = load_and_run_inner(&compile_source_inner(source).unwrap()).unwrap(); + assert_eq!(result, "2"); + } + + #[test] + fn test_run_comparison_eq() { + let result = load_and_run_inner(&compile_source_inner("1 == 1").unwrap()).unwrap(); + assert_eq!(result, "true"); + } + + #[test] + fn test_run_comparison_neq() { + let result = load_and_run_inner(&compile_source_inner("1 != 2").unwrap()).unwrap(); + assert_eq!(result, "true"); + } + + #[test] + fn test_direct_run_empty_bytecode() { + let result = run_bytecode(&[]).unwrap(); + assert_eq!(result, Value::Nil); + } + + #[test] + fn test_direct_run_halt_only() { + let result = run_bytecode(&[Bytecode::Halt]).unwrap(); + assert_eq!(result, Value::Nil); + } + + #[test] + fn test_direct_run_push_halt() { + let result = run_bytecode(&[Bytecode::Push(Value::Int(99)), Bytecode::Halt]).unwrap(); + assert_eq!(result, Value::Int(99)); + } + + #[test] + fn test_direct_run_add() { + let bc = [ + Bytecode::Push(Value::Int(3)), + Bytecode::Push(Value::Int(4)), + Bytecode::Add, + Bytecode::Halt, + ]; + let result = run_bytecode(&bc).unwrap(); + assert_eq!(result, Value::Int(7)); + } + + #[test] + fn test_direct_run_string_concat() { + let bc = [ + Bytecode::Push(Value::Str("hello ".to_string())), + Bytecode::Push(Value::Str("world".to_string())), + Bytecode::Add, + Bytecode::Halt, + ]; + let result = run_bytecode(&bc).unwrap(); + assert_eq!(result, Value::Str("hello world".to_string())); + } + + #[test] + fn test_direct_run_jump() { + // Jump over a push, land on the second push. + let bc = [ + Bytecode::Jump(1), // ip=0 → skip 1 → ip becomes 2 + Bytecode::Push(Value::Int(0)), // ip=1 — skipped + Bytecode::Push(Value::Int(42)), // ip=2 + Bytecode::Halt, + ]; + let result = run_bytecode(&bc).unwrap(); + assert_eq!(result, Value::Int(42)); + } +}