Merge worktree-agent: add struct literals, generics, print/log builtins
This commit is contained in:
Generated
+49
-16
@@ -328,7 +328,9 @@ dependencies = [
|
|||||||
"clap",
|
"clap",
|
||||||
"el-build",
|
"el-build",
|
||||||
"el-compiler",
|
"el-compiler",
|
||||||
|
"el-fmt",
|
||||||
"el-lexer",
|
"el-lexer",
|
||||||
|
"el-lint",
|
||||||
"el-manifest",
|
"el-manifest",
|
||||||
"el-parser",
|
"el-parser",
|
||||||
"el-registry",
|
"el-registry",
|
||||||
@@ -344,6 +346,14 @@ dependencies = [
|
|||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "el-arch"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"el-lexer",
|
||||||
|
"el-parser",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "el-build"
|
name = "el-build"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -377,6 +387,15 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "el-fmt"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"el-lexer",
|
||||||
|
"el-parser",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "el-integration"
|
name = "el-integration"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -397,6 +416,20 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"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]]
|
[[package]]
|
||||||
name = "el-manifest"
|
name = "el-manifest"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -992,9 +1025,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.95"
|
version = "0.3.97"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
|
checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
@@ -1423,9 +1456,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.39"
|
version = "0.23.40"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"
|
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ring",
|
"ring",
|
||||||
@@ -2079,9 +2112,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.118"
|
version = "0.2.120"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
|
checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -2092,9 +2125,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-futures"
|
name = "wasm-bindgen-futures"
|
||||||
version = "0.4.68"
|
version = "0.4.70"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8"
|
checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
@@ -2102,9 +2135,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.118"
|
version = "0.2.120"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
|
checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"wasm-bindgen-macro-support",
|
"wasm-bindgen-macro-support",
|
||||||
@@ -2112,9 +2145,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro-support"
|
name = "wasm-bindgen-macro-support"
|
||||||
version = "0.2.118"
|
version = "0.2.120"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
|
checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -2125,9 +2158,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-shared"
|
name = "wasm-bindgen-shared"
|
||||||
version = "0.2.118"
|
version = "0.2.120"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
|
checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
@@ -2168,9 +2201,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.95"
|
version = "0.3.97"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d"
|
checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ members = [
|
|||||||
"crates/el-test",
|
"crates/el-test",
|
||||||
"crates/el-stdlib",
|
"crates/el-stdlib",
|
||||||
"crates/el-integration",
|
"crates/el-integration",
|
||||||
|
"crates/el-fmt",
|
||||||
|
"crates/el-lint",
|
||||||
"bin/el",
|
"bin/el",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
@@ -34,6 +36,8 @@ el-build = { path = "crates/el-build" }
|
|||||||
el-test = { path = "crates/el-test" }
|
el-test = { path = "crates/el-test" }
|
||||||
el-stdlib = { path = "crates/el-stdlib" }
|
el-stdlib = { path = "crates/el-stdlib" }
|
||||||
el-integration = { path = "crates/el-integration" }
|
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 dep — the sealed target depends on it)
|
||||||
engram-crypto = { path = "../engram/crates/engram-crypto" }
|
engram-crypto = { path = "../engram/crates/engram-crypto" }
|
||||||
|
|||||||
+3
-1
@@ -19,11 +19,13 @@ el-manifest = { workspace = true }
|
|||||||
el-registry = { workspace = true }
|
el-registry = { workspace = true }
|
||||||
el-build = { workspace = true }
|
el-build = { workspace = true }
|
||||||
el-test = { workspace = true }
|
el-test = { workspace = true }
|
||||||
|
el-fmt = { workspace = true }
|
||||||
|
el-lint = { workspace = true }
|
||||||
clap = { workspace = true }
|
clap = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
|
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
walkdir = { workspace = true }
|
walkdir = { workspace = true }
|
||||||
tiny_http = { workspace = true }
|
tiny_http = { workspace = true }
|
||||||
|
|||||||
+117
-43
@@ -35,8 +35,6 @@ use el_compiler::{Compiler, CompilerOptions, Target};
|
|||||||
use el_test;
|
use el_test;
|
||||||
use el_manifest::{BuildTarget, Manifest};
|
use el_manifest::{BuildTarget, Manifest};
|
||||||
use el_seal::{seal as seal_fn, unseal as unseal_fn, SealedArtifact, DeploymentBinding, SealAlgorithm, SealConfig};
|
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) ────────────────────────────────
|
// ── Global state (thread-local for simplicity) ────────────────────────────────
|
||||||
|
|
||||||
@@ -229,6 +227,15 @@ enum Command {
|
|||||||
|
|
||||||
// ── Low-level / single-file ───────────────────────────────────────────────
|
// ── Low-level / single-file ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Compile and immediately run a single .el source file (no el.toml required).
|
||||||
|
RunFile {
|
||||||
|
/// Source file (*.el).
|
||||||
|
file: PathBuf,
|
||||||
|
/// Arguments to pass to the program.
|
||||||
|
#[arg(trailing_var_arg = true)]
|
||||||
|
args: Vec<String>,
|
||||||
|
},
|
||||||
|
|
||||||
/// Compile a single .el source file (no el.toml required).
|
/// Compile a single .el source file (no el.toml required).
|
||||||
BuildFile {
|
BuildFile {
|
||||||
/// Source file (*.el).
|
/// Source file (*.el).
|
||||||
@@ -241,15 +248,6 @@ enum Command {
|
|||||||
output: Option<PathBuf>,
|
output: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Compile and run a single .el source file (no el.toml required).
|
|
||||||
RunFile {
|
|
||||||
/// Source file (*.el).
|
|
||||||
file: PathBuf,
|
|
||||||
/// Arguments passed to the program (available via the args() builtin).
|
|
||||||
#[arg(trailing_var_arg = true)]
|
|
||||||
args: Vec<String>,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Seal an existing release artifact.
|
/// Seal an existing release artifact.
|
||||||
Seal {
|
Seal {
|
||||||
artifact: PathBuf,
|
artifact: PathBuf,
|
||||||
@@ -528,6 +526,9 @@ async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let compiled = Compiler::compile(&source, opts)?;
|
let compiled = Compiler::compile(&source, opts)?;
|
||||||
|
for d in &compiled.diagnostics {
|
||||||
|
eprintln!("warning: {d}");
|
||||||
|
}
|
||||||
let instructions = el_compiler::Bytecode::deserialize_all(&compiled.artifact)
|
let instructions = el_compiler::Bytecode::deserialize_all(&compiled.artifact)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
run_interpreter_with_args(&instructions, &args);
|
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())
|
Value::List(arr.iter().map(json_value_to_el_value).collect())
|
||||||
}
|
}
|
||||||
serde_json::Value::Object(obj) => {
|
serde_json::Value::Object(obj) => {
|
||||||
let pairs = obj.iter()
|
let fields = obj.iter()
|
||||||
.map(|(k, v)| (k.clone(), json_value_to_el_value(v)))
|
.map(|(k, v)| (k.clone(), json_value_to_el_value(v)))
|
||||||
.collect();
|
.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) => {
|
Value::ResultErr(inner) => {
|
||||||
serde_json::json!({"err": el_value_to_json_value(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,
|
entry: usize,
|
||||||
) -> el_compiler::Value {
|
) -> el_compiler::Value {
|
||||||
use el_compiler::{Bytecode, Value};
|
use el_compiler::{Bytecode, Value};
|
||||||
|
|
||||||
let mut stack: Vec<Value> = Vec::new();
|
let mut stack: Vec<Value> = Vec::new();
|
||||||
let mut locals: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
|
let mut locals: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
|
||||||
let mut call_stack: Vec<(usize, std::collections::HashMap<String, Value>)> = Vec::new();
|
let mut call_stack: Vec<(usize, std::collections::HashMap<String, Value>)> = Vec::new();
|
||||||
let mut ip = entry;
|
let mut ip = entry;
|
||||||
let program_args: Vec<String> = vec![];
|
let program_args: Vec<String> = vec![];
|
||||||
|
|
||||||
// Bind zero params (handle_request takes none)
|
|
||||||
|
|
||||||
while ip < instructions.len() {
|
while ip < instructions.len() {
|
||||||
match &instructions[ip] {
|
match &instructions[ip] {
|
||||||
Bytecode::Push(v) => stack.push(v.clone()),
|
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::Int(x), Value::Int(y)) => Value::Int(x + y),
|
||||||
(Value::Float(x), Value::Float(y)) => Value::Float(x + y),
|
(Value::Float(x), Value::Float(y)) => Value::Float(x + y),
|
||||||
(Value::Str(x), Value::Str(y)) => Value::Str(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,
|
_ => Value::Nil,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1043,6 +1052,8 @@ fn run_sub_interpreter(
|
|||||||
stack.push(match (a, b) {
|
stack.push(match (a, b) {
|
||||||
(Value::Int(x), Value::Int(y)) => Value::Int(x - y),
|
(Value::Int(x), Value::Int(y)) => Value::Int(x - y),
|
||||||
(Value::Float(x), Value::Float(y)) => Value::Float(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,
|
_ => Value::Nil,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1051,6 +1062,8 @@ fn run_sub_interpreter(
|
|||||||
stack.push(match (a, b) {
|
stack.push(match (a, b) {
|
||||||
(Value::Int(x), Value::Int(y)) => Value::Int(x * y),
|
(Value::Int(x), Value::Int(y)) => Value::Int(x * y),
|
||||||
(Value::Float(x), Value::Float(y)) => Value::Float(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,
|
_ => Value::Nil,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1059,6 +1072,8 @@ fn run_sub_interpreter(
|
|||||||
stack.push(match (a, b) {
|
stack.push(match (a, b) {
|
||||||
(Value::Int(x), Value::Int(y)) if y != 0 => Value::Int(x / y),
|
(Value::Int(x), Value::Int(y)) if y != 0 => Value::Int(x / y),
|
||||||
(Value::Float(x), Value::Float(y)) => Value::Float(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,
|
_ => Value::Nil,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1072,31 +1087,19 @@ fn run_sub_interpreter(
|
|||||||
}
|
}
|
||||||
Bytecode::Lt => {
|
Bytecode::Lt => {
|
||||||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||||||
stack.push(match (a, b) {
|
stack.push(Value::Bool(cmp_values(&a, &b) == std::cmp::Ordering::Less));
|
||||||
(Value::Int(x), Value::Int(y)) => Value::Bool(x < y),
|
|
||||||
_ => Value::Bool(false),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Bytecode::Gt => {
|
Bytecode::Gt => {
|
||||||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||||||
stack.push(match (a, b) {
|
stack.push(Value::Bool(cmp_values(&a, &b) == std::cmp::Ordering::Greater));
|
||||||
(Value::Int(x), Value::Int(y)) => Value::Bool(x > y),
|
|
||||||
_ => Value::Bool(false),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Bytecode::LtEq => {
|
Bytecode::LtEq => {
|
||||||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||||||
stack.push(match (a, b) {
|
stack.push(Value::Bool(cmp_values(&a, &b) != std::cmp::Ordering::Greater));
|
||||||
(Value::Int(x), Value::Int(y)) => Value::Bool(x <= y),
|
|
||||||
_ => Value::Bool(false),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Bytecode::GtEq => {
|
Bytecode::GtEq => {
|
||||||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||||||
stack.push(match (a, b) {
|
stack.push(Value::Bool(cmp_values(&a, &b) != std::cmp::Ordering::Less));
|
||||||
(Value::Int(x), Value::Int(y)) => Value::Bool(x >= y),
|
|
||||||
_ => Value::Bool(false),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Bytecode::And => {
|
Bytecode::And => {
|
||||||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
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) => {
|
Bytecode::GetField(field) => {
|
||||||
let obj = stack.pop().unwrap_or(Value::Nil);
|
let obj = stack.pop().unwrap_or(Value::Nil);
|
||||||
let v = match obj {
|
let v = match &obj {
|
||||||
Value::Map(pairs) => pairs.iter()
|
Value::Map(pairs) => pairs.iter()
|
||||||
.find(|(k, _)| k == field)
|
.find(|(k, _)| k == field)
|
||||||
.map(|(_, v)| v.clone())
|
.map(|(_, v)| v.clone())
|
||||||
.unwrap_or(Value::Nil),
|
.unwrap_or(Value::Nil),
|
||||||
|
Value::Struct { fields, .. } => fields.iter()
|
||||||
|
.find(|(n, _)| n == field)
|
||||||
|
.map(|(_, v)| v.clone())
|
||||||
|
.unwrap_or(Value::Nil),
|
||||||
_ => Value::Nil,
|
_ => Value::Nil,
|
||||||
};
|
};
|
||||||
stack.push(v);
|
stack.push(v);
|
||||||
@@ -1184,14 +1191,17 @@ fn run_sub_interpreter(
|
|||||||
pairs.reverse();
|
pairs.reverse();
|
||||||
stack.push(Value::Map(pairs));
|
stack.push(Value::Map(pairs));
|
||||||
}
|
}
|
||||||
Bytecode::BuildStruct { fields, .. } => {
|
Bytecode::BuildStruct { type_name, fields } => {
|
||||||
let mut pairs = Vec::new();
|
let n = fields.len();
|
||||||
for field in fields.iter().rev() {
|
let mut field_values: Vec<Value> = (0..n).map(|_| stack.pop().unwrap_or(Value::Nil)).collect();
|
||||||
let val = stack.pop().unwrap_or(Value::Nil);
|
field_values.reverse();
|
||||||
pairs.push((field.clone(), val));
|
let struct_fields: Vec<(String, Value)> = fields.iter().cloned()
|
||||||
}
|
.zip(field_values.into_iter())
|
||||||
pairs.reverse();
|
.collect();
|
||||||
stack.push(Value::Map(pairs));
|
stack.push(Value::Struct {
|
||||||
|
type_name: type_name.clone(),
|
||||||
|
fields: struct_fields,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Bytecode::SetField(field) => {
|
Bytecode::SetField(field) => {
|
||||||
let val = stack.pop().unwrap_or(Value::Nil);
|
let val = stack.pop().unwrap_or(Value::Nil);
|
||||||
@@ -1201,6 +1211,12 @@ fn run_sub_interpreter(
|
|||||||
} else {
|
} else {
|
||||||
pairs.push((field.clone(), val));
|
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) => {
|
Bytecode::Jump(offset) => {
|
||||||
@@ -1555,9 +1571,9 @@ fn run_interpreter_with_args(instructions: &[el_compiler::Bytecode], program_arg
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Bytecode::Halt => break,
|
Bytecode::Halt => break,
|
||||||
Bytecode::SealedBegin => eprintln!("[sealed section begin]"),
|
Bytecode::SealedBegin => {}
|
||||||
Bytecode::SealedEnd => eprintln!("[sealed section end]"),
|
Bytecode::SealedEnd => {}
|
||||||
_ => {}
|
Bytecode::Nop => {}
|
||||||
}
|
}
|
||||||
ip += 1;
|
ip += 1;
|
||||||
}
|
}
|
||||||
@@ -1592,6 +1608,12 @@ fn dispatch_builtin(
|
|||||||
stack.push(Value::Nil);
|
stack.push(Value::Nil);
|
||||||
BuiltinResult::Handled
|
BuiltinResult::Handled
|
||||||
}
|
}
|
||||||
|
"log" => {
|
||||||
|
let v = stack.pop().unwrap_or(Value::Nil);
|
||||||
|
println!("{v}");
|
||||||
|
stack.push(Value::Nil);
|
||||||
|
BuiltinResult::Handled
|
||||||
|
}
|
||||||
"print_err" => {
|
"print_err" => {
|
||||||
let v = stack.pop().unwrap_or(Value::Nil);
|
let v = stack.pop().unwrap_or(Value::Nil);
|
||||||
eprintln!("{v}");
|
eprintln!("{v}");
|
||||||
@@ -3111,6 +3133,24 @@ fn is_leap(year: u64) -> bool {
|
|||||||
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
|
(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.
|
/// Interpreter with debugger support — emits DebugEvents as it runs.
|
||||||
fn run_interpreter_debug(instructions: &[el_compiler::Bytecode], debugger: &mut el_compiler::Debugger) {
|
fn run_interpreter_debug(instructions: &[el_compiler::Bytecode], debugger: &mut el_compiler::Debugger) {
|
||||||
use el_compiler::{Bytecode, Value};
|
use el_compiler::{Bytecode, Value};
|
||||||
@@ -3142,6 +3182,9 @@ fn run_interpreter_debug(instructions: &[el_compiler::Bytecode], debugger: &mut
|
|||||||
match &instructions[ip] {
|
match &instructions[ip] {
|
||||||
Bytecode::Push(v) => stack.push(v.clone()),
|
Bytecode::Push(v) => stack.push(v.clone()),
|
||||||
Bytecode::Pop => { stack.pop(); }
|
Bytecode::Pop => { stack.pop(); }
|
||||||
|
Bytecode::Dup => {
|
||||||
|
if let Some(top) = stack.last().cloned() { stack.push(top); }
|
||||||
|
}
|
||||||
Bytecode::Add => {
|
Bytecode::Add => {
|
||||||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||||||
stack.push(match (a, b) {
|
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);
|
let v = locals.get(name).cloned().unwrap_or(Value::Nil);
|
||||||
stack.push(v);
|
stack.push(v);
|
||||||
}
|
}
|
||||||
|
Bytecode::GetField(field) => {
|
||||||
|
let obj = stack.pop().unwrap_or(Value::Nil);
|
||||||
|
let result = match &obj {
|
||||||
|
Value::Map(pairs) => pairs.iter()
|
||||||
|
.find(|(k, _)| k == field)
|
||||||
|
.map(|(_, v)| v.clone())
|
||||||
|
.unwrap_or(Value::Nil),
|
||||||
|
Value::Struct { fields, .. } => fields.iter()
|
||||||
|
.find(|(n, _)| n == field)
|
||||||
|
.map(|(_, v)| v.clone())
|
||||||
|
.unwrap_or(Value::Nil),
|
||||||
|
_ => Value::Nil,
|
||||||
|
};
|
||||||
|
stack.push(result);
|
||||||
|
}
|
||||||
|
Bytecode::BuildStruct { type_name, fields } => {
|
||||||
|
let n = fields.len();
|
||||||
|
let mut field_values: Vec<Value> = (0..n).map(|_| stack.pop().unwrap_or(Value::Nil)).collect();
|
||||||
|
field_values.reverse();
|
||||||
|
let struct_fields: Vec<(String, Value)> = fields.iter().cloned()
|
||||||
|
.zip(field_values.into_iter())
|
||||||
|
.collect();
|
||||||
|
stack.push(Value::Struct {
|
||||||
|
type_name: type_name.clone(),
|
||||||
|
fields: struct_fields,
|
||||||
|
});
|
||||||
|
}
|
||||||
Bytecode::Call { name, arity } => {
|
Bytecode::Call { name, arity } => {
|
||||||
let result = dispatch_builtin(name, *arity, &mut stack, &program_args);
|
let result = dispatch_builtin(name, *arity, &mut stack, &program_args);
|
||||||
match result {
|
match result {
|
||||||
@@ -3166,6 +3236,10 @@ fn run_interpreter_debug(instructions: &[el_compiler::Bytecode], debugger: &mut
|
|||||||
BuiltinResult::Exit(code) => std::process::exit(code),
|
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) => {
|
Bytecode::Jump(offset) => {
|
||||||
let new_ip = (ip as i32 + 1 + offset) as usize;
|
let new_ip = (ip as i32 + 1 + offset) as usize;
|
||||||
ip = new_ip;
|
ip = new_ip;
|
||||||
|
|||||||
@@ -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" }
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
//! Main architectural checker — walks the AST and applies all rules.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use el_parser::{Expr, Program, Stmt, TypeExpr};
|
||||||
|
|
||||||
|
use crate::error::{ArchDiagnostic, Severity};
|
||||||
|
use crate::rule::{ArchRule, CallInfo, FnContext};
|
||||||
|
use crate::rules::{
|
||||||
|
graph::{DuplicateActivateType, N1Detection},
|
||||||
|
security::{AuthnWithoutAuthz, PublicFnWithActivate, SealedInLoop},
|
||||||
|
swarm::{SwarmAgentIsolation, SwarmAgentNoSharedState, SwarmAgentNoSpawn},
|
||||||
|
vbd::{AccessorMustNotCallManager, ExperienceMustNotCallExperience, ExperienceShouldReturnResult},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The main architectural checker. Instantiate once, call `check` per program.
|
||||||
|
pub struct ArchChecker {
|
||||||
|
rules: Vec<Box<dyn ArchRule>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArchChecker {
|
||||||
|
/// Create an `ArchChecker` with all built-in rules registered.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
rules: vec![
|
||||||
|
Box::new(AccessorMustNotCallManager),
|
||||||
|
Box::new(ExperienceMustNotCallExperience),
|
||||||
|
Box::new(ExperienceShouldReturnResult),
|
||||||
|
Box::new(PublicFnWithActivate),
|
||||||
|
Box::new(SealedInLoop),
|
||||||
|
Box::new(AuthnWithoutAuthz),
|
||||||
|
Box::new(N1Detection),
|
||||||
|
Box::new(DuplicateActivateType),
|
||||||
|
Box::new(SwarmAgentIsolation),
|
||||||
|
Box::new(SwarmAgentNoSpawn),
|
||||||
|
Box::new(SwarmAgentNoSharedState),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a reference to the registered rules (useful for introspection in tests).
|
||||||
|
pub fn rules(&self) -> &[Box<dyn ArchRule>] {
|
||||||
|
&self.rules
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run all rules against a parsed program and collect all diagnostics.
|
||||||
|
pub fn check(&self, program: &Program) -> Vec<ArchDiagnostic> {
|
||||||
|
// Step 1: build global fn_name → decorator names map (including impl methods).
|
||||||
|
let all_fn_annotations = collect_fn_annotations(&program.stmts);
|
||||||
|
|
||||||
|
// Step 2: gather all top-level + impl FnDef statements.
|
||||||
|
let fn_defs = collect_fn_defs(&program.stmts);
|
||||||
|
|
||||||
|
// Step 3: for each function, build FnContext and run every rule.
|
||||||
|
let mut diagnostics = Vec::new();
|
||||||
|
for (fn_name, decorators, return_type, body) in &fn_defs {
|
||||||
|
let annotations: Vec<String> =
|
||||||
|
decorators.iter().map(|d| d.name.clone()).collect();
|
||||||
|
let body_calls = extract_calls(body, false);
|
||||||
|
let activate_types = extract_activate_types(body, false);
|
||||||
|
let has_sealed_in_loop = has_sealed_in_loop_body(body, false);
|
||||||
|
let return_type_name = type_expr_name(return_type);
|
||||||
|
|
||||||
|
let ctx = FnContext {
|
||||||
|
fn_name: fn_name.as_str(),
|
||||||
|
annotations: &annotations,
|
||||||
|
body_calls: &body_calls,
|
||||||
|
all_fn_annotations: &all_fn_annotations,
|
||||||
|
activate_types: &activate_types,
|
||||||
|
has_sealed_in_loop,
|
||||||
|
return_type_name: &return_type_name,
|
||||||
|
};
|
||||||
|
|
||||||
|
for rule in &self.rules {
|
||||||
|
diagnostics.extend(rule.check(&ctx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if any of the given diagnostics are errors.
|
||||||
|
pub fn has_errors(diagnostics: &[ArchDiagnostic]) -> bool {
|
||||||
|
diagnostics.iter().any(|d| d.severity == Severity::Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ArchChecker {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AST traversal helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// A collected function definition: (name, decorators, return_type, body).
|
||||||
|
type FnDef<'a> = (
|
||||||
|
&'a String,
|
||||||
|
&'a Vec<el_parser::Decorator>,
|
||||||
|
&'a TypeExpr,
|
||||||
|
&'a Vec<Stmt>,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Collect all FnDef statements from top-level and impl blocks.
|
||||||
|
fn collect_fn_defs<'a>(stmts: &'a [Stmt]) -> Vec<FnDef<'a>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for stmt in stmts {
|
||||||
|
match stmt {
|
||||||
|
Stmt::FnDef { name, decorators, return_type, body, .. } => {
|
||||||
|
out.push((name, decorators, return_type, body));
|
||||||
|
}
|
||||||
|
Stmt::ImplDef { methods, .. } => {
|
||||||
|
for m in methods {
|
||||||
|
if let Stmt::FnDef { name, decorators, return_type, body, .. } = m {
|
||||||
|
out.push((name, decorators, return_type, body));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a map from function name → list of decorator names, for all functions in the program.
|
||||||
|
fn collect_fn_annotations(stmts: &[Stmt]) -> HashMap<String, Vec<String>> {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
for stmt in stmts {
|
||||||
|
match stmt {
|
||||||
|
Stmt::FnDef { name, decorators, .. } => {
|
||||||
|
let anns: Vec<String> = decorators.iter().map(|d| d.name.clone()).collect();
|
||||||
|
map.insert(name.clone(), anns);
|
||||||
|
}
|
||||||
|
Stmt::ImplDef { methods, .. } => {
|
||||||
|
for m in methods {
|
||||||
|
if let Stmt::FnDef { name, decorators, .. } = m {
|
||||||
|
let anns: Vec<String> = decorators.iter().map(|d| d.name.clone()).collect();
|
||||||
|
map.insert(name.clone(), anns);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract all function calls from a statement list.
|
||||||
|
/// `in_loop` tracks whether we are currently inside a for/while loop body.
|
||||||
|
fn extract_calls(stmts: &[Stmt], in_loop: bool) -> Vec<CallInfo> {
|
||||||
|
let mut calls = Vec::new();
|
||||||
|
for stmt in stmts {
|
||||||
|
extract_calls_from_stmt(stmt, in_loop, &mut calls);
|
||||||
|
}
|
||||||
|
calls
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_calls_from_stmt(stmt: &Stmt, in_loop: bool, out: &mut Vec<CallInfo>) {
|
||||||
|
match stmt {
|
||||||
|
Stmt::Let { value, .. } => extract_calls_from_expr(value, in_loop, out),
|
||||||
|
Stmt::Return(expr, _) | Stmt::Expr(expr, _) | Stmt::Assert(expr, _) => {
|
||||||
|
extract_calls_from_expr(expr, in_loop, out);
|
||||||
|
}
|
||||||
|
Stmt::FnDef { body, .. } => {
|
||||||
|
// Nested function defs: walk but don't count as callee of the outer fn.
|
||||||
|
for s in body {
|
||||||
|
extract_calls_from_stmt(s, false, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_calls_from_expr(expr: &Expr, in_loop: bool, out: &mut Vec<CallInfo>) {
|
||||||
|
match expr {
|
||||||
|
Expr::Call { func, args } => {
|
||||||
|
// Extract callee name
|
||||||
|
let callee = expr_as_call_name(func);
|
||||||
|
if let Some(name) = callee {
|
||||||
|
out.push(CallInfo { callee: name, is_in_loop: in_loop });
|
||||||
|
}
|
||||||
|
// Recurse into func expression and args
|
||||||
|
extract_calls_from_expr(func, in_loop, out);
|
||||||
|
for a in args {
|
||||||
|
extract_calls_from_expr(a, in_loop, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Activate { type_name, .. } => {
|
||||||
|
// Encode activate as a synthetic call so GRAPH-001 can detect in-loop activates.
|
||||||
|
out.push(CallInfo {
|
||||||
|
callee: format!("__activate__{type_name}"),
|
||||||
|
is_in_loop: in_loop,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Expr::BinOp { left, right, .. } => {
|
||||||
|
extract_calls_from_expr(left, in_loop, out);
|
||||||
|
extract_calls_from_expr(right, in_loop, out);
|
||||||
|
}
|
||||||
|
Expr::UnaryNot(inner) | Expr::Try(inner) => {
|
||||||
|
extract_calls_from_expr(inner, in_loop, out);
|
||||||
|
}
|
||||||
|
Expr::Block(stmts) => {
|
||||||
|
for s in stmts {
|
||||||
|
extract_calls_from_stmt(s, in_loop, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Sealed(stmts) => {
|
||||||
|
// Sealed blocks are scanned but tracked separately for sealed-in-loop.
|
||||||
|
for s in stmts {
|
||||||
|
extract_calls_from_stmt(s, in_loop, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::If { cond, then, else_ } => {
|
||||||
|
extract_calls_from_expr(cond, in_loop, out);
|
||||||
|
extract_calls_from_expr(then, in_loop, out);
|
||||||
|
if let Some(e) = else_ {
|
||||||
|
extract_calls_from_expr(e, in_loop, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Match { subject, arms } => {
|
||||||
|
extract_calls_from_expr(subject, in_loop, out);
|
||||||
|
for arm in arms {
|
||||||
|
extract_calls_from_expr(&arm.body, in_loop, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Field { object, .. } => extract_calls_from_expr(object, in_loop, out),
|
||||||
|
Expr::Array(elems) => {
|
||||||
|
for e in elems {
|
||||||
|
extract_calls_from_expr(e, in_loop, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Index { object, index } => {
|
||||||
|
extract_calls_from_expr(object, in_loop, out);
|
||||||
|
extract_calls_from_expr(index, in_loop, out);
|
||||||
|
}
|
||||||
|
Expr::Closure { body, .. } => {
|
||||||
|
extract_calls_from_expr(body, in_loop, out);
|
||||||
|
}
|
||||||
|
Expr::MapLiteral(pairs) => {
|
||||||
|
for (k, v) in pairs {
|
||||||
|
extract_calls_from_expr(k, in_loop, out);
|
||||||
|
extract_calls_from_expr(v, in_loop, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Literal(_) | Expr::Ident(_) | Expr::Path { .. } => {}
|
||||||
|
Expr::StructLit { fields, .. } => {
|
||||||
|
for (_, e) in fields {
|
||||||
|
extract_calls_from_expr(e, in_loop, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to extract a simple callee name from a Call's `func` expression.
|
||||||
|
fn expr_as_call_name(expr: &Expr) -> Option<String> {
|
||||||
|
match expr {
|
||||||
|
Expr::Ident(name) => Some(name.clone()),
|
||||||
|
Expr::Field { field, .. } => Some(field.clone()),
|
||||||
|
Expr::Path { segments } => segments.last().cloned(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect all `activate TypeName` type names from a statement list.
|
||||||
|
/// `in_loop` indicates whether we're currently inside a loop.
|
||||||
|
fn extract_activate_types(stmts: &[Stmt], in_loop: bool) -> Vec<String> {
|
||||||
|
let mut types = Vec::new();
|
||||||
|
for stmt in stmts {
|
||||||
|
extract_activate_types_stmt(stmt, in_loop, &mut types);
|
||||||
|
}
|
||||||
|
types
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_activate_types_stmt(stmt: &Stmt, in_loop: bool, out: &mut Vec<String>) {
|
||||||
|
match stmt {
|
||||||
|
Stmt::Let { value, .. } => extract_activate_types_expr(value, in_loop, out),
|
||||||
|
Stmt::Return(expr, _) | Stmt::Expr(expr, _) | Stmt::Assert(expr, _) => {
|
||||||
|
extract_activate_types_expr(expr, in_loop, out);
|
||||||
|
}
|
||||||
|
Stmt::FnDef { body, .. } => {
|
||||||
|
for s in body {
|
||||||
|
extract_activate_types_stmt(s, false, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_activate_types_expr(expr: &Expr, in_loop: bool, out: &mut Vec<String>) {
|
||||||
|
match expr {
|
||||||
|
Expr::Activate { type_name, .. } => {
|
||||||
|
out.push(type_name.clone());
|
||||||
|
}
|
||||||
|
Expr::Call { func, args } => {
|
||||||
|
extract_activate_types_expr(func, in_loop, out);
|
||||||
|
for a in args {
|
||||||
|
extract_activate_types_expr(a, in_loop, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::BinOp { left, right, .. } => {
|
||||||
|
extract_activate_types_expr(left, in_loop, out);
|
||||||
|
extract_activate_types_expr(right, in_loop, out);
|
||||||
|
}
|
||||||
|
Expr::UnaryNot(inner) | Expr::Try(inner) => {
|
||||||
|
extract_activate_types_expr(inner, in_loop, out);
|
||||||
|
}
|
||||||
|
Expr::Block(stmts) => {
|
||||||
|
for s in stmts {
|
||||||
|
extract_activate_types_stmt(s, in_loop, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Sealed(stmts) => {
|
||||||
|
for s in stmts {
|
||||||
|
extract_activate_types_stmt(s, in_loop, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::If { cond, then, else_ } => {
|
||||||
|
extract_activate_types_expr(cond, in_loop, out);
|
||||||
|
extract_activate_types_expr(then, in_loop, out);
|
||||||
|
if let Some(e) = else_ {
|
||||||
|
extract_activate_types_expr(e, in_loop, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Match { subject, arms } => {
|
||||||
|
extract_activate_types_expr(subject, in_loop, out);
|
||||||
|
for arm in arms {
|
||||||
|
extract_activate_types_expr(&arm.body, in_loop, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Field { object, .. } => extract_activate_types_expr(object, in_loop, out),
|
||||||
|
Expr::Array(elems) => {
|
||||||
|
for e in elems {
|
||||||
|
extract_activate_types_expr(e, in_loop, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Index { object, index } => {
|
||||||
|
extract_activate_types_expr(object, in_loop, out);
|
||||||
|
extract_activate_types_expr(index, in_loop, out);
|
||||||
|
}
|
||||||
|
Expr::Closure { body, .. } => {
|
||||||
|
extract_activate_types_expr(body, in_loop, out);
|
||||||
|
}
|
||||||
|
Expr::MapLiteral(pairs) => {
|
||||||
|
for (k, v) in pairs {
|
||||||
|
extract_activate_types_expr(k, in_loop, out);
|
||||||
|
extract_activate_types_expr(v, in_loop, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Literal(_) | Expr::Ident(_) | Expr::Path { .. } => {}
|
||||||
|
Expr::StructLit { fields, .. } => {
|
||||||
|
for (_, e) in fields {
|
||||||
|
extract_activate_types_expr(e, in_loop, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if any `sealed { }` block appears inside a loop in the given body.
|
||||||
|
fn has_sealed_in_loop_body(stmts: &[Stmt], in_loop: bool) -> bool {
|
||||||
|
stmts.iter().any(|s| has_sealed_in_loop_stmt(s, in_loop))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_sealed_in_loop_stmt(stmt: &Stmt, in_loop: bool) -> bool {
|
||||||
|
match stmt {
|
||||||
|
Stmt::Let { value, .. } => has_sealed_in_loop_expr(value, in_loop),
|
||||||
|
Stmt::Return(expr, _) | Stmt::Expr(expr, _) | Stmt::Assert(expr, _) => {
|
||||||
|
has_sealed_in_loop_expr(expr, in_loop)
|
||||||
|
}
|
||||||
|
Stmt::FnDef { body, .. } => {
|
||||||
|
// Inner function defs reset loop context.
|
||||||
|
body.iter().any(|s| has_sealed_in_loop_stmt(s, false))
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_sealed_in_loop_expr(expr: &Expr, in_loop: bool) -> bool {
|
||||||
|
match expr {
|
||||||
|
Expr::Sealed(_) => in_loop,
|
||||||
|
Expr::Call { func, args } => {
|
||||||
|
has_sealed_in_loop_expr(func, in_loop)
|
||||||
|
|| args.iter().any(|a| has_sealed_in_loop_expr(a, in_loop))
|
||||||
|
}
|
||||||
|
Expr::BinOp { left, right, .. } => {
|
||||||
|
has_sealed_in_loop_expr(left, in_loop) || has_sealed_in_loop_expr(right, in_loop)
|
||||||
|
}
|
||||||
|
Expr::UnaryNot(inner) | Expr::Try(inner) => has_sealed_in_loop_expr(inner, in_loop),
|
||||||
|
Expr::Block(stmts) => stmts.iter().any(|s| has_sealed_in_loop_stmt(s, in_loop)),
|
||||||
|
Expr::If { cond, then, else_ } => {
|
||||||
|
has_sealed_in_loop_expr(cond, in_loop)
|
||||||
|
|| has_sealed_in_loop_expr(then, in_loop)
|
||||||
|
|| else_.as_deref().is_some_and(|e| has_sealed_in_loop_expr(e, in_loop))
|
||||||
|
}
|
||||||
|
Expr::Match { subject, arms } => {
|
||||||
|
has_sealed_in_loop_expr(subject, in_loop)
|
||||||
|
|| arms.iter().any(|a| has_sealed_in_loop_expr(&a.body, in_loop))
|
||||||
|
}
|
||||||
|
Expr::Field { object, .. } => has_sealed_in_loop_expr(object, in_loop),
|
||||||
|
Expr::Array(elems) => elems.iter().any(|e| has_sealed_in_loop_expr(e, in_loop)),
|
||||||
|
Expr::Index { object, index } => {
|
||||||
|
has_sealed_in_loop_expr(object, in_loop) || has_sealed_in_loop_expr(index, in_loop)
|
||||||
|
}
|
||||||
|
Expr::Closure { body, .. } => has_sealed_in_loop_expr(body, in_loop),
|
||||||
|
Expr::MapLiteral(pairs) => pairs
|
||||||
|
.iter()
|
||||||
|
.any(|(k, v)| has_sealed_in_loop_expr(k, in_loop) || has_sealed_in_loop_expr(v, in_loop)),
|
||||||
|
Expr::Activate { .. } | Expr::Literal(_) | Expr::Ident(_) | Expr::Path { .. } => false,
|
||||||
|
Expr::StructLit { fields, .. } => fields
|
||||||
|
.iter()
|
||||||
|
.any(|(_, e)| has_sealed_in_loop_expr(e, in_loop)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a `TypeExpr` to a display string for the return-type name check.
|
||||||
|
fn type_expr_name(te: &TypeExpr) -> String {
|
||||||
|
match te {
|
||||||
|
TypeExpr::Named(n) => n.clone(),
|
||||||
|
TypeExpr::Result { .. } => "Result".to_string(),
|
||||||
|
TypeExpr::Array(inner) => format!("[{}]", type_expr_name(inner)),
|
||||||
|
TypeExpr::Optional(inner) => format!("{}?", type_expr_name(inner)),
|
||||||
|
TypeExpr::Map { key, value } => {
|
||||||
|
format!("Map<{}, {}>", type_expr_name(key), type_expr_name(value))
|
||||||
|
}
|
||||||
|
TypeExpr::Fn { .. } => "fn".to_string(),
|
||||||
|
TypeExpr::TypeParam(n) => n.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
//! Diagnostic types for the architectural checker.
|
||||||
|
|
||||||
|
/// Severity level of an architectural diagnostic.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum Severity {
|
||||||
|
Error,
|
||||||
|
Warning,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single architectural diagnostic (error or warning).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ArchDiagnostic {
|
||||||
|
pub severity: Severity,
|
||||||
|
/// Rule identifier, e.g. "VBD-001".
|
||||||
|
pub rule: String,
|
||||||
|
pub message: String,
|
||||||
|
/// Function name or other location hint.
|
||||||
|
pub location: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type alias — an ArchError is an ArchDiagnostic with Severity::Error.
|
||||||
|
pub type ArchError = ArchDiagnostic;
|
||||||
|
|
||||||
|
/// Type alias — an ArchWarning is an ArchDiagnostic with Severity::Warning.
|
||||||
|
pub type ArchWarning = ArchDiagnostic;
|
||||||
|
|
||||||
|
impl ArchDiagnostic {
|
||||||
|
pub fn error(rule: impl Into<String>, message: impl Into<String>, location: Option<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
severity: Severity::Error,
|
||||||
|
rule: rule.into(),
|
||||||
|
message: message.into(),
|
||||||
|
location,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn warning(rule: impl Into<String>, message: impl Into<String>, location: Option<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
severity: Severity::Warning,
|
||||||
|
rule: rule.into(),
|
||||||
|
message: message.into(),
|
||||||
|
location,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
//! Core trait and context types for architectural rules.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use crate::error::ArchDiagnostic;
|
||||||
|
|
||||||
|
/// Information about a single function call within a function body.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CallInfo {
|
||||||
|
/// The name of the function being called.
|
||||||
|
pub callee: String,
|
||||||
|
/// True if this call appears inside a loop body (for or while).
|
||||||
|
pub is_in_loop: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full context about a single function being checked by arch rules.
|
||||||
|
pub struct FnContext<'a> {
|
||||||
|
/// Name of the function under analysis.
|
||||||
|
pub fn_name: &'a str,
|
||||||
|
/// Decorator names applied directly to this function (e.g. "accessor", "public").
|
||||||
|
pub annotations: &'a [String],
|
||||||
|
/// All calls made from within this function body.
|
||||||
|
pub body_calls: &'a [CallInfo],
|
||||||
|
/// Global map of function name → decorator names for the whole program.
|
||||||
|
pub all_fn_annotations: &'a HashMap<String, Vec<String>>,
|
||||||
|
/// TypeNames that appear in `activate TypeName where ...` calls in this function.
|
||||||
|
pub activate_types: &'a [String],
|
||||||
|
/// Whether this function contains a `sealed { }` block inside a loop.
|
||||||
|
pub has_sealed_in_loop: bool,
|
||||||
|
/// The return type of the function as a string (e.g. "Result", "Void", "String").
|
||||||
|
pub return_type_name: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> FnContext<'a> {
|
||||||
|
/// Returns true if this function has the given annotation/decorator.
|
||||||
|
pub fn has_annotation(&self, name: &str) -> bool {
|
||||||
|
self.annotations.iter().any(|a| a == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the named callee has the given annotation in the program.
|
||||||
|
pub fn callee_has_annotation(&self, callee: &str, ann: &str) -> bool {
|
||||||
|
self.all_fn_annotations
|
||||||
|
.get(callee)
|
||||||
|
.map(|anns| anns.iter().any(|a| a == ann))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An architectural rule that can be checked against a function context.
|
||||||
|
pub trait ArchRule: Send + Sync {
|
||||||
|
/// Short unique identifier, e.g. "VBD-001".
|
||||||
|
fn name(&self) -> &str;
|
||||||
|
/// Human-readable description of what this rule enforces.
|
||||||
|
fn description(&self) -> &str;
|
||||||
|
/// Run the rule against a function context, returning any diagnostics.
|
||||||
|
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
//! Graph access pattern rules (N+1 detection, duplicate activate).
|
||||||
|
|
||||||
|
use crate::error::ArchDiagnostic;
|
||||||
|
use crate::rule::{ArchRule, FnContext};
|
||||||
|
|
||||||
|
// ── GRAPH-001: N+1 detection ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// GRAPH-001: An activate call inside a loop is an N+1 graph access pattern.
|
||||||
|
/// Each loop iteration performs a separate graph traversal; consolidate into one query.
|
||||||
|
pub struct N1Detection;
|
||||||
|
|
||||||
|
impl ArchRule for N1Detection {
|
||||||
|
fn name(&self) -> &str { "GRAPH-001" }
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"activate inside a loop creates an N+1 graph access pattern — hoist outside the loop"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
|
||||||
|
// We detect activate-in-loop via the body_calls with is_in_loop = true
|
||||||
|
// AND via the activate_types combined with loop context tracked by the checker.
|
||||||
|
// The checker sets a separate field for this.
|
||||||
|
let has_activate_in_loop = ctx.body_calls
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.is_in_loop && c.callee.starts_with("__activate__"));
|
||||||
|
|
||||||
|
if has_activate_in_loop {
|
||||||
|
vec![ArchDiagnostic::warning(
|
||||||
|
self.name(),
|
||||||
|
format!(
|
||||||
|
"function '{}' performs activate inside a loop — N+1 graph access pattern",
|
||||||
|
ctx.fn_name
|
||||||
|
),
|
||||||
|
Some(ctx.fn_name.to_string()),
|
||||||
|
)]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GRAPH-002: Duplicate activate on same type ────────────────────────────────
|
||||||
|
|
||||||
|
/// GRAPH-002: Multiple activate calls on the same type within one function
|
||||||
|
/// should be consolidated into a single query for efficiency.
|
||||||
|
pub struct DuplicateActivateType;
|
||||||
|
|
||||||
|
impl ArchRule for DuplicateActivateType {
|
||||||
|
fn name(&self) -> &str { "GRAPH-002" }
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"multiple activate calls on the same type in one function — consolidate into one query"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
|
||||||
|
let mut seen = std::collections::HashMap::new();
|
||||||
|
for type_name in ctx.activate_types {
|
||||||
|
*seen.entry(type_name.as_str()).or_insert(0u32) += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.iter()
|
||||||
|
.filter(|(_, &count)| count > 1)
|
||||||
|
.map(|(type_name, count)| ArchDiagnostic::warning(
|
||||||
|
self.name(),
|
||||||
|
format!(
|
||||||
|
"function '{}' activates type '{}' {} times — consolidate into a single query",
|
||||||
|
ctx.fn_name, type_name, count
|
||||||
|
),
|
||||||
|
Some(ctx.fn_name.to_string()),
|
||||||
|
))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
//! Individual architectural rule implementations.
|
||||||
|
|
||||||
|
pub mod graph;
|
||||||
|
pub mod security;
|
||||||
|
pub mod swarm;
|
||||||
|
pub mod vbd;
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
//! Security architectural rules.
|
||||||
|
|
||||||
|
use crate::error::ArchDiagnostic;
|
||||||
|
use crate::rule::{ArchRule, FnContext};
|
||||||
|
|
||||||
|
// ── SEC-001: Public function with activate inside ─────────────────────────────
|
||||||
|
|
||||||
|
/// SEC-001: A @public function must not contain activate expressions.
|
||||||
|
/// Unauthenticated callers could trigger graph reads, potentially leaking data.
|
||||||
|
pub struct PublicFnWithActivate;
|
||||||
|
|
||||||
|
impl ArchRule for PublicFnWithActivate {
|
||||||
|
fn name(&self) -> &str { "SEC-001" }
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"@public functions must not contain activate — unauthenticated callers could trigger data reads"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
|
||||||
|
if !ctx.has_annotation("public") {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
if !ctx.activate_types.is_empty() {
|
||||||
|
return vec![ArchDiagnostic::error(
|
||||||
|
self.name(),
|
||||||
|
format!(
|
||||||
|
"@public function '{}' contains activate — data leak risk for unauthenticated callers (types: {})",
|
||||||
|
ctx.fn_name,
|
||||||
|
ctx.activate_types.join(", ")
|
||||||
|
),
|
||||||
|
Some(ctx.fn_name.to_string()),
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SEC-002: sealed block inside a loop ──────────────────────────────────────
|
||||||
|
|
||||||
|
/// SEC-002: A sealed block inside a loop incurs encryption overhead per iteration.
|
||||||
|
pub struct SealedInLoop;
|
||||||
|
|
||||||
|
impl ArchRule for SealedInLoop {
|
||||||
|
fn name(&self) -> &str { "SEC-002" }
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"sealed blocks inside loops cause encryption overhead on every iteration"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
|
||||||
|
if ctx.has_sealed_in_loop {
|
||||||
|
return vec![ArchDiagnostic::warning(
|
||||||
|
self.name(),
|
||||||
|
format!(
|
||||||
|
"function '{}' contains a sealed block inside a loop — encryption overhead in hot path",
|
||||||
|
ctx.fn_name
|
||||||
|
),
|
||||||
|
Some(ctx.fn_name.to_string()),
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SEC-003: @authenticate without @authorize on mutations ────────────────────
|
||||||
|
|
||||||
|
/// SEC-003: Functions whose name suggests mutation (create_*, update_*, delete_*)
|
||||||
|
/// and carry @authenticate should also carry @authorize, otherwise authn without authz.
|
||||||
|
pub struct AuthnWithoutAuthz;
|
||||||
|
|
||||||
|
impl ArchRule for AuthnWithoutAuthz {
|
||||||
|
fn name(&self) -> &str { "SEC-003" }
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"@authenticate without @authorize on mutation functions — authentication without authorization"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
|
||||||
|
if !ctx.has_annotation("authenticate") {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
if ctx.has_annotation("authorize") {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
// Heuristic: mutation function names
|
||||||
|
let is_mutation = ctx.fn_name.starts_with("create_")
|
||||||
|
|| ctx.fn_name.starts_with("update_")
|
||||||
|
|| ctx.fn_name.starts_with("delete_")
|
||||||
|
|| ctx.fn_name.starts_with("write_")
|
||||||
|
|| ctx.fn_name.starts_with("mutate_");
|
||||||
|
|
||||||
|
if is_mutation {
|
||||||
|
return vec![ArchDiagnostic::warning(
|
||||||
|
self.name(),
|
||||||
|
format!(
|
||||||
|
"function '{}' has @authenticate but not @authorize — authn without authz on a mutation",
|
||||||
|
ctx.fn_name
|
||||||
|
),
|
||||||
|
Some(ctx.fn_name.to_string()),
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
//! Swarm containment rules — agents are isolated and must not cross boundaries.
|
||||||
|
|
||||||
|
use crate::error::ArchDiagnostic;
|
||||||
|
use crate::rule::{ArchRule, FnContext};
|
||||||
|
|
||||||
|
// ── SWARM-001: Swarm agent calling another swarm agent ────────────────────────
|
||||||
|
|
||||||
|
/// SWARM-001: @swarm_agent functions must not call other @swarm_agent functions.
|
||||||
|
/// Agents are isolated units; cross-agent calls break containment.
|
||||||
|
pub struct SwarmAgentIsolation;
|
||||||
|
|
||||||
|
impl ArchRule for SwarmAgentIsolation {
|
||||||
|
fn name(&self) -> &str { "SWARM-001" }
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"@swarm_agent must not call another @swarm_agent (agents are isolated)"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
|
||||||
|
if !ctx.has_annotation("swarm_agent") {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
ctx.body_calls
|
||||||
|
.iter()
|
||||||
|
.filter(|call| ctx.callee_has_annotation(&call.callee, "swarm_agent"))
|
||||||
|
.map(|call| ArchDiagnostic::error(
|
||||||
|
self.name(),
|
||||||
|
format!(
|
||||||
|
"@swarm_agent '{}' calls @swarm_agent '{}' — agents must be isolated",
|
||||||
|
ctx.fn_name, call.callee
|
||||||
|
),
|
||||||
|
Some(ctx.fn_name.to_string()),
|
||||||
|
))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SWARM-002: Swarm agent initiating a spawn/swarm ──────────────────────────
|
||||||
|
|
||||||
|
/// SWARM-002: @swarm_agent must not initiate spawning of other agents
|
||||||
|
/// (calls to functions named `spawn` or containing "swarm" in the name).
|
||||||
|
pub struct SwarmAgentNoSpawn;
|
||||||
|
|
||||||
|
impl ArchRule for SwarmAgentNoSpawn {
|
||||||
|
fn name(&self) -> &str { "SWARM-002" }
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"@swarm_agent must not call spawn/swarm functions (agents cannot initiate sub-swarms)"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
|
||||||
|
if !ctx.has_annotation("swarm_agent") {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
ctx.body_calls
|
||||||
|
.iter()
|
||||||
|
.filter(|call| {
|
||||||
|
let c = call.callee.as_str();
|
||||||
|
c == "spawn" || c.contains("swarm") || c.starts_with("spawn_")
|
||||||
|
})
|
||||||
|
.map(|call| ArchDiagnostic::error(
|
||||||
|
self.name(),
|
||||||
|
format!(
|
||||||
|
"@swarm_agent '{}' calls '{}' — agents cannot initiate spawning",
|
||||||
|
ctx.fn_name, call.callee
|
||||||
|
),
|
||||||
|
Some(ctx.fn_name.to_string()),
|
||||||
|
))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SWARM-003: Swarm agent accessing shared mutable state ─────────────────────
|
||||||
|
|
||||||
|
/// SWARM-003: @swarm_agent must not access shared mutable state.
|
||||||
|
/// Heuristic: calls to functions with "shared" in the name suggest shared state access.
|
||||||
|
pub struct SwarmAgentNoSharedState;
|
||||||
|
|
||||||
|
impl ArchRule for SwarmAgentNoSharedState {
|
||||||
|
fn name(&self) -> &str { "SWARM-003" }
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"@swarm_agent must not access shared mutable state (functions with 'shared' in name)"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
|
||||||
|
if !ctx.has_annotation("swarm_agent") {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
ctx.body_calls
|
||||||
|
.iter()
|
||||||
|
.filter(|call| call.callee.contains("shared"))
|
||||||
|
.map(|call| ArchDiagnostic::error(
|
||||||
|
self.name(),
|
||||||
|
format!(
|
||||||
|
"@swarm_agent '{}' accesses shared state via '{}' — agents must not touch shared mutable state",
|
||||||
|
ctx.fn_name, call.callee
|
||||||
|
),
|
||||||
|
Some(ctx.fn_name.to_string()),
|
||||||
|
))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
//! VBD (Volatility-Based Decomposition) and EBD (Experience-Based Decomposition) layer rules.
|
||||||
|
|
||||||
|
use crate::error::ArchDiagnostic;
|
||||||
|
use crate::rule::{ArchRule, FnContext};
|
||||||
|
|
||||||
|
// ── VBD-001: Accessor must not call Manager ───────────────────────────────────
|
||||||
|
|
||||||
|
/// VBD-001: @accessor functions must not call @manager functions.
|
||||||
|
/// Accessors are read-only, stable-interface components; they must not depend
|
||||||
|
/// on manager-layer orchestration logic.
|
||||||
|
pub struct AccessorMustNotCallManager;
|
||||||
|
|
||||||
|
impl ArchRule for AccessorMustNotCallManager {
|
||||||
|
fn name(&self) -> &str { "VBD-001" }
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"@accessor must not call @manager functions (accessor must not depend on manager layer)"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
|
||||||
|
if !ctx.has_annotation("accessor") {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
ctx.body_calls
|
||||||
|
.iter()
|
||||||
|
.filter(|call| ctx.callee_has_annotation(&call.callee, "manager"))
|
||||||
|
.map(|call| ArchDiagnostic::error(
|
||||||
|
self.name(),
|
||||||
|
format!(
|
||||||
|
"accessor '{}' calls manager '{}' — accessors must not depend on the manager layer",
|
||||||
|
ctx.fn_name, call.callee
|
||||||
|
),
|
||||||
|
Some(ctx.fn_name.to_string()),
|
||||||
|
))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── VBD-002: Experience must not directly call Experience ─────────────────────
|
||||||
|
|
||||||
|
/// VBD-002 / EBD-001: @experience functions must not call other @experience functions directly.
|
||||||
|
/// Experiences should communicate via events, not direct calls, to preserve
|
||||||
|
/// loose coupling between user-facing features.
|
||||||
|
pub struct ExperienceMustNotCallExperience;
|
||||||
|
|
||||||
|
impl ArchRule for ExperienceMustNotCallExperience {
|
||||||
|
fn name(&self) -> &str { "VBD-002" }
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"@experience must not call another @experience directly (use events instead)"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
|
||||||
|
if !ctx.has_annotation("experience") {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
ctx.body_calls
|
||||||
|
.iter()
|
||||||
|
.filter(|call| ctx.callee_has_annotation(&call.callee, "experience"))
|
||||||
|
.map(|call| ArchDiagnostic::error(
|
||||||
|
self.name(),
|
||||||
|
format!(
|
||||||
|
"experience '{}' directly calls experience '{}' — use an event instead",
|
||||||
|
ctx.fn_name, call.callee
|
||||||
|
),
|
||||||
|
Some(ctx.fn_name.to_string()),
|
||||||
|
))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── VBD-003: Experience should return Result<T, E> ────────────────────────────
|
||||||
|
|
||||||
|
/// VBD-003: @experience functions should return Result<T, E> for proper error propagation.
|
||||||
|
pub struct ExperienceShouldReturnResult;
|
||||||
|
|
||||||
|
impl ArchRule for ExperienceShouldReturnResult {
|
||||||
|
fn name(&self) -> &str { "VBD-003" }
|
||||||
|
|
||||||
|
fn description(&self) -> &str {
|
||||||
|
"@experience functions should return Result<T, E> for proper error handling"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
|
||||||
|
if !ctx.has_annotation("experience") {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
// Warn if return type doesn't include "Result"
|
||||||
|
if !ctx.return_type_name.contains("Result") {
|
||||||
|
return vec![ArchDiagnostic::warning(
|
||||||
|
self.name(),
|
||||||
|
format!(
|
||||||
|
"experience '{}' returns '{}' instead of Result<T, E> — experiences should propagate errors",
|
||||||
|
ctx.fn_name, ctx.return_type_name
|
||||||
|
),
|
||||||
|
Some(ctx.fn_name.to_string()),
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
//! Comprehensive tests for the el-arch architectural checker.
|
||||||
|
|
||||||
|
use crate::{ArchChecker, ArchDiagnostic, Severity};
|
||||||
|
|
||||||
|
// ── Test helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn check(src: &str) -> Vec<ArchDiagnostic> {
|
||||||
|
let tokens = el_lexer::tokenize(src).expect("lex failed");
|
||||||
|
let prog = el_parser::parse(tokens, src.to_string()).expect("parse failed");
|
||||||
|
ArchChecker::new().check(&prog)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn errors(src: &str) -> Vec<ArchDiagnostic> {
|
||||||
|
check(src).into_iter().filter(|d| d.severity == Severity::Error).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warnings(src: &str) -> Vec<ArchDiagnostic> {
|
||||||
|
check(src).into_iter().filter(|d| d.severity == Severity::Warning).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_rule(diags: &[ArchDiagnostic], rule: &str) -> bool {
|
||||||
|
diags.iter().any(|d| d.rule == rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. VBD-001: @accessor calling @manager → error ──────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_accessor_calls_manager_error() {
|
||||||
|
let src = r#"
|
||||||
|
@manager fn orchestrate(x: String) -> String { return x }
|
||||||
|
@accessor fn fetch_user(id: String) -> String { return orchestrate(id) }
|
||||||
|
"#;
|
||||||
|
let errs = errors(src);
|
||||||
|
assert!(!errs.is_empty(), "expected error when @accessor calls @manager");
|
||||||
|
assert!(has_rule(&errs, "VBD-001"), "expected VBD-001 rule");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. @accessor calling @accessor → no VBD-001 error ────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_accessor_calls_accessor_no_error() {
|
||||||
|
let src = r#"
|
||||||
|
@accessor fn get_name(id: String) -> String { return id }
|
||||||
|
@accessor fn get_user(id: String) -> String { return get_name(id) }
|
||||||
|
"#;
|
||||||
|
let errs = errors(src);
|
||||||
|
let vbd001: Vec<_> = errs.iter().filter(|d| d.rule == "VBD-001").collect();
|
||||||
|
assert!(vbd001.is_empty(), "accessor->accessor should not trigger VBD-001");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. @experience calling @experience → VBD-002 error ───────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_experience_calls_experience_error() {
|
||||||
|
let src = r#"
|
||||||
|
@experience fn checkout(cart: String) -> Result<String, String> { return cart }
|
||||||
|
@experience fn payment(amount: String) -> Result<String, String> { return checkout(amount) }
|
||||||
|
"#;
|
||||||
|
let errs = errors(src);
|
||||||
|
assert!(!errs.is_empty(), "expected error when @experience calls @experience");
|
||||||
|
assert!(has_rule(&errs, "VBD-002"), "expected VBD-002 rule");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. @experience calling non-experience → no VBD-002 error ─────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_experience_calls_non_experience_no_error() {
|
||||||
|
let src = r#"
|
||||||
|
@accessor fn load_cart(id: String) -> String { return id }
|
||||||
|
@experience fn checkout(cart: String) -> Result<String, String> { return load_cart(cart) }
|
||||||
|
"#;
|
||||||
|
let errs = errors(src);
|
||||||
|
let vbd002: Vec<_> = errs.iter().filter(|d| d.rule == "VBD-002").collect();
|
||||||
|
assert!(vbd002.is_empty(), "experience->non-experience should not trigger VBD-002");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. @public function with activate inside → SEC-001 error ─────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_public_fn_with_activate_error() {
|
||||||
|
let src = r#"
|
||||||
|
@public fn list_users() -> String {
|
||||||
|
let users: String = activate User where "all users"
|
||||||
|
return users
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let errs = errors(src);
|
||||||
|
assert!(!errs.is_empty(), "expected error for @public fn with activate");
|
||||||
|
assert!(has_rule(&errs, "SEC-001"), "expected SEC-001 rule");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. @public function without activate → no SEC-001 error ──────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_public_fn_without_activate_no_error() {
|
||||||
|
let src = r#"
|
||||||
|
@public fn greet(name: String) -> String { return name }
|
||||||
|
"#;
|
||||||
|
let errs = errors(src);
|
||||||
|
let sec001: Vec<_> = errs.iter().filter(|d| d.rule == "SEC-001").collect();
|
||||||
|
assert!(sec001.is_empty(), "@public without activate should not trigger SEC-001");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 7. N+1: activate inside a for loop → GRAPH-001 warning ───────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_activate_in_for_loop_n1_warning() {
|
||||||
|
let src = r#"
|
||||||
|
fn process_ids(ids: String) -> String {
|
||||||
|
for id in ids {
|
||||||
|
let u: String = activate User where "user by id"
|
||||||
|
}
|
||||||
|
return "done"
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let warns = warnings(src);
|
||||||
|
assert!(!warns.is_empty(), "expected N+1 warning for activate inside loop");
|
||||||
|
assert!(has_rule(&warns, "GRAPH-001"), "expected GRAPH-001 rule");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 8. activate NOT in loop → no GRAPH-001 warning ───────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_activate_not_in_loop_no_n1_warning() {
|
||||||
|
let src = r#"
|
||||||
|
fn fetch_users() -> String {
|
||||||
|
let users: String = activate User where "recent users"
|
||||||
|
return users
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let warns = warnings(src);
|
||||||
|
let graph001: Vec<_> = warns.iter().filter(|d| d.rule == "GRAPH-001").collect();
|
||||||
|
assert!(graph001.is_empty(), "activate outside loop should not trigger GRAPH-001");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 9. Duplicate activate same type → GRAPH-002 warning ──────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_duplicate_activate_same_type_warning() {
|
||||||
|
let src = r#"
|
||||||
|
fn inefficient_fn(x: String) -> String {
|
||||||
|
let a: String = activate User where "active users"
|
||||||
|
let b: String = activate User where "recent users"
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let warns = warnings(src);
|
||||||
|
assert!(!warns.is_empty(), "expected warning for duplicate activate on same type");
|
||||||
|
assert!(has_rule(&warns, "GRAPH-002"), "expected GRAPH-002 rule");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 10. Different activate types → no GRAPH-002 warning ──────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_different_activate_types_no_duplicate_warning() {
|
||||||
|
let src = r#"
|
||||||
|
fn fetch_all(x: String) -> String {
|
||||||
|
let users: String = activate User where "users"
|
||||||
|
let orders: String = activate Order where "orders"
|
||||||
|
return users
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let warns = warnings(src);
|
||||||
|
let graph002: Vec<_> = warns.iter().filter(|d| d.rule == "GRAPH-002").collect();
|
||||||
|
assert!(graph002.is_empty(), "different activate types should not trigger GRAPH-002");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 11. @swarm_agent calling @swarm_agent → SWARM-001 error ──────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_swarm_agent_calls_swarm_agent_error() {
|
||||||
|
let src = r#"
|
||||||
|
@swarm_agent fn worker_b(x: String) -> String { return x }
|
||||||
|
@swarm_agent fn worker_a(x: String) -> String { return worker_b(x) }
|
||||||
|
"#;
|
||||||
|
let errs = errors(src);
|
||||||
|
assert!(!errs.is_empty(), "expected error when @swarm_agent calls @swarm_agent");
|
||||||
|
assert!(has_rule(&errs, "SWARM-001"), "expected SWARM-001 rule");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 12. @swarm_agent in isolation → no SWARM-001 error ───────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_swarm_agent_isolation_no_error() {
|
||||||
|
let src = r#"
|
||||||
|
fn utility(x: String) -> String { return x }
|
||||||
|
@swarm_agent fn worker(x: String) -> String { return utility(x) }
|
||||||
|
"#;
|
||||||
|
let errs = errors(src);
|
||||||
|
let swarm001: Vec<_> = errs.iter().filter(|d| d.rule == "SWARM-001").collect();
|
||||||
|
assert!(swarm001.is_empty(), "isolated @swarm_agent should not trigger SWARM-001");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 13. Multiple rules fire on same function ──────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_rules_fire_same_function() {
|
||||||
|
// @swarm_agent calling a swarm_agent AND accessing shared state
|
||||||
|
let src = r#"
|
||||||
|
@swarm_agent fn peer(x: String) -> String { return x }
|
||||||
|
fn get_shared_cache(x: String) -> String { return x }
|
||||||
|
@swarm_agent fn violator(x: String) -> String {
|
||||||
|
let a: String = peer(x)
|
||||||
|
let b: String = get_shared_cache(x)
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let errs = errors(src);
|
||||||
|
// Should fire both SWARM-001 (calls peer) and SWARM-003 (calls get_shared_cache)
|
||||||
|
assert!(errs.len() >= 2, "expected multiple errors: {:?}", errs.iter().map(|e| &e.rule).collect::<Vec<_>>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 14. ArchChecker::has_errors() → true when errors present ─────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_has_errors_true_when_errors_present() {
|
||||||
|
let src = r#"
|
||||||
|
@manager fn do_manage(x: String) -> String { return x }
|
||||||
|
@accessor fn bad_fetch(x: String) -> String { return do_manage(x) }
|
||||||
|
"#;
|
||||||
|
let diags = check(src);
|
||||||
|
assert!(ArchChecker::has_errors(&diags), "has_errors should be true");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 15. ArchChecker::has_errors() → false when only warnings ─────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_has_errors_false_when_only_warnings() {
|
||||||
|
let src = r#"
|
||||||
|
@experience fn sign_up(email: String) -> String { return email }
|
||||||
|
"#;
|
||||||
|
// sign_up doesn't return Result so triggers VBD-003 warning
|
||||||
|
let diags = check(src);
|
||||||
|
let has_warn = diags.iter().any(|d| d.severity == Severity::Warning);
|
||||||
|
assert!(has_warn || diags.is_empty(), "expected either warnings or empty");
|
||||||
|
assert!(!ArchChecker::has_errors(&diags.iter().filter(|d| d.severity == Severity::Warning).cloned().collect::<Vec<_>>()), "has_errors should be false for warnings");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 16. Clean function → empty diagnostics ────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_clean_function_no_diagnostics() {
|
||||||
|
let src = r#"
|
||||||
|
fn pure_add(a: String, b: String) -> String { return a }
|
||||||
|
"#;
|
||||||
|
let diags = check(src);
|
||||||
|
assert!(diags.is_empty(), "clean function should produce no diagnostics, got: {:?}", diags.iter().map(|d| &d.rule).collect::<Vec<_>>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 17. @engine function → runs without panic ─────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_engine_function_no_panic() {
|
||||||
|
let src = r#"
|
||||||
|
@engine fn compute(data: String) -> String { return data }
|
||||||
|
"#;
|
||||||
|
// Just verify no panic — engine rules only produce warnings in some impls
|
||||||
|
let _diags = check(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 18. sealed { } not in loop → no SEC-002 warning ──────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sealed_not_in_loop_no_warning() {
|
||||||
|
let src = r#"
|
||||||
|
fn encrypt_data(secret: String) -> String {
|
||||||
|
sealed { let key: String = secret }
|
||||||
|
return secret
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let warns = warnings(src);
|
||||||
|
let sec002: Vec<_> = warns.iter().filter(|d| d.rule == "SEC-002").collect();
|
||||||
|
assert!(sec002.is_empty(), "sealed not in loop should not trigger SEC-002");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 19. Rule names are unique ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rule_names_are_unique() {
|
||||||
|
let checker = ArchChecker::new();
|
||||||
|
let mut names = std::collections::HashSet::new();
|
||||||
|
for rule in checker.rules() {
|
||||||
|
let inserted = names.insert(rule.name().to_string());
|
||||||
|
assert!(inserted, "duplicate rule name: {}", rule.name());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 20. All rules implement ArchRule (compile-time check) ─────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_all_rules_implement_arch_rule() {
|
||||||
|
use crate::rule::ArchRule;
|
||||||
|
use crate::rules::{
|
||||||
|
vbd::{AccessorMustNotCallManager, ExperienceMustNotCallExperience, ExperienceShouldReturnResult},
|
||||||
|
security::{PublicFnWithActivate, SealedInLoop, AuthnWithoutAuthz},
|
||||||
|
graph::{N1Detection, DuplicateActivateType},
|
||||||
|
swarm::{SwarmAgentIsolation, SwarmAgentNoSpawn, SwarmAgentNoSharedState},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn assert_arch_rule<T: ArchRule>() {}
|
||||||
|
assert_arch_rule::<AccessorMustNotCallManager>();
|
||||||
|
assert_arch_rule::<ExperienceMustNotCallExperience>();
|
||||||
|
assert_arch_rule::<ExperienceShouldReturnResult>();
|
||||||
|
assert_arch_rule::<PublicFnWithActivate>();
|
||||||
|
assert_arch_rule::<SealedInLoop>();
|
||||||
|
assert_arch_rule::<AuthnWithoutAuthz>();
|
||||||
|
assert_arch_rule::<N1Detection>();
|
||||||
|
assert_arch_rule::<DuplicateActivateType>();
|
||||||
|
assert_arch_rule::<SwarmAgentIsolation>();
|
||||||
|
assert_arch_rule::<SwarmAgentNoSpawn>();
|
||||||
|
assert_arch_rule::<SwarmAgentNoSharedState>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 21. FnContext builds correctly for a decorated function ───────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fn_context_from_decorated_function() {
|
||||||
|
// Run checker and verify the location field is set to the function name
|
||||||
|
let src = r#"
|
||||||
|
@accessor fn fetch_item(id: String) -> String { return id }
|
||||||
|
"#;
|
||||||
|
let diags = check(src);
|
||||||
|
// No violations — but verify the checker doesn't panic and processes it
|
||||||
|
// (accessor with no calls should produce no errors)
|
||||||
|
let _: Vec<_> = diags;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 22. @experience without Result return type → VBD-003 warning ─────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_experience_non_result_return_warning() {
|
||||||
|
let src = r#"
|
||||||
|
@experience fn show_profile(user: String) -> String { return user }
|
||||||
|
"#;
|
||||||
|
let warns = warnings(src);
|
||||||
|
assert!(!warns.is_empty(), "expected warning for @experience not returning Result");
|
||||||
|
assert!(has_rule(&warns, "VBD-003"), "expected VBD-003 rule");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 23. @authenticate without @authorize on mutation → SEC-003 warning ────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_authenticate_without_authorize_on_mutation_warning() {
|
||||||
|
let src = r#"
|
||||||
|
@authenticate fn create_account(email: String) -> String { return email }
|
||||||
|
"#;
|
||||||
|
let warns = warnings(src);
|
||||||
|
assert!(!warns.is_empty(), "expected SEC-003 warning");
|
||||||
|
assert!(has_rule(&warns, "SEC-003"), "expected SEC-003 rule");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 24. Two @experience functions checked independently ───────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_two_experience_functions_checked_independently() {
|
||||||
|
let src = r#"
|
||||||
|
@experience fn sign_up(email: String) -> Result<String, String> { return email }
|
||||||
|
@experience fn log_in(token: String) -> String { return token }
|
||||||
|
"#;
|
||||||
|
let warns = warnings(src);
|
||||||
|
// sign_up returns Result — no VBD-003 for it
|
||||||
|
// log_in returns String — VBD-003 fires for it
|
||||||
|
let vbd003: Vec<_> = warns.iter().filter(|d| d.rule == "VBD-003").collect();
|
||||||
|
assert_eq!(vbd003.len(), 1, "only log_in should trigger VBD-003, got {:?}", vbd003.iter().map(|d| &d.location).collect::<Vec<_>>());
|
||||||
|
assert!(vbd003[0].location.as_deref() == Some("log_in"), "VBD-003 should point to log_in");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 25. Mixed errors and warnings → has_errors() returns true ─────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mixed_errors_and_warnings_has_errors_true() {
|
||||||
|
let src = r#"
|
||||||
|
@manager fn manage_data(x: String) -> String { return x }
|
||||||
|
@accessor fn bad_read(x: String) -> String { return manage_data(x) }
|
||||||
|
@experience fn display(x: String) -> String { return x }
|
||||||
|
"#;
|
||||||
|
let diags = check(src);
|
||||||
|
assert!(ArchChecker::has_errors(&diags), "should have errors (VBD-001)");
|
||||||
|
let has_warn = diags.iter().any(|d| d.severity == Severity::Warning);
|
||||||
|
assert!(has_warn, "should also have warnings (VBD-003 for display)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 26. @swarm_agent calling spawn → SWARM-002 error ─────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_swarm_agent_no_spawn() {
|
||||||
|
let src = r#"
|
||||||
|
fn spawn(agent: String) -> String { return agent }
|
||||||
|
@swarm_agent fn initiator(x: String) -> String { return spawn(x) }
|
||||||
|
"#;
|
||||||
|
let errs = errors(src);
|
||||||
|
assert!(!errs.is_empty(), "expected SWARM-002 error for calling spawn");
|
||||||
|
assert!(has_rule(&errs, "SWARM-002"), "expected SWARM-002 rule");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 27. @swarm_agent accessing shared state → SWARM-003 error ────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_swarm_agent_no_shared_state() {
|
||||||
|
let src = r#"
|
||||||
|
fn get_shared_counter(x: String) -> String { return x }
|
||||||
|
@swarm_agent fn agent(x: String) -> String { return get_shared_counter(x) }
|
||||||
|
"#;
|
||||||
|
let errs = errors(src);
|
||||||
|
assert!(!errs.is_empty(), "expected SWARM-003 error for shared state access");
|
||||||
|
assert!(has_rule(&errs, "SWARM-003"), "expected SWARM-003 rule");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 28. sealed block inside for loop → SEC-002 warning ───────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sealed_in_for_loop_warning() {
|
||||||
|
let src = r#"
|
||||||
|
fn encrypt_many(items: String) -> String {
|
||||||
|
for item in items {
|
||||||
|
sealed { let x: String = item }
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let warns = warnings(src);
|
||||||
|
assert!(!warns.is_empty(), "expected SEC-002 warning for sealed in loop");
|
||||||
|
assert!(has_rule(&warns, "SEC-002"), "expected SEC-002 rule");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 29. @authenticate with @authorize → no SEC-003 warning ───────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_authenticate_with_authorize_no_warning() {
|
||||||
|
let src = r#"
|
||||||
|
@authenticate @authorize fn create_post(content: String) -> String { return content }
|
||||||
|
"#;
|
||||||
|
let warns = warnings(src);
|
||||||
|
let sec003: Vec<_> = warns.iter().filter(|d| d.rule == "SEC-003").collect();
|
||||||
|
assert!(sec003.is_empty(), "@authenticate + @authorize should not trigger SEC-003");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 30. @experience returning Result → no VBD-003 warning ────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_experience_returns_result_no_warning() {
|
||||||
|
let src = r#"
|
||||||
|
@experience fn register(email: String) -> Result<String, String> { return email }
|
||||||
|
"#;
|
||||||
|
let warns = warnings(src);
|
||||||
|
let vbd003: Vec<_> = warns.iter().filter(|d| d.rule == "VBD-003").collect();
|
||||||
|
assert!(vbd003.is_empty(), "@experience returning Result should not trigger VBD-003");
|
||||||
|
}
|
||||||
@@ -16,13 +16,15 @@ pub enum Value {
|
|||||||
Nil,
|
Nil,
|
||||||
/// A list of values (used for `activate` results and array literals).
|
/// A list of values (used for `activate` results and array literals).
|
||||||
List(Vec<Value>),
|
List(Vec<Value>),
|
||||||
/// A key-value map — used for struct instances and Map<K,V> literals.
|
/// A key-value map — used for Map<K,V> literals.
|
||||||
/// Stored as a Vec of pairs to keep ordering and remain Serialize-friendly.
|
/// Stored as a Vec of pairs to keep ordering and remain Serialize-friendly.
|
||||||
Map(Vec<(String, Value)>),
|
Map(Vec<(String, Value)>),
|
||||||
/// A Result<T,E> value — Ok variant.
|
/// A Result<T,E> value — Ok variant.
|
||||||
ResultOk(Box<Value>),
|
ResultOk(Box<Value>),
|
||||||
/// A Result<T,E> value — Err variant.
|
/// A Result<T,E> value — Err variant.
|
||||||
ResultErr(Box<Value>),
|
ResultErr(Box<Value>),
|
||||||
|
/// A struct instance: type name + ordered field name-value pairs.
|
||||||
|
Struct { type_name: String, fields: Vec<(String, Value)> },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for Value {
|
impl std::fmt::Display for Value {
|
||||||
@@ -43,6 +45,10 @@ impl std::fmt::Display for Value {
|
|||||||
}
|
}
|
||||||
Value::ResultOk(v) => write!(f, "Ok({v})"),
|
Value::ResultOk(v) => write!(f, "Ok({v})"),
|
||||||
Value::ResultErr(e) => write!(f, "Err({e})"),
|
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(", "))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -357,6 +357,17 @@ impl Codegen {
|
|||||||
self.gen_expr(index)?;
|
self.gen_expr(index)?;
|
||||||
self.emit(Bytecode::GetIndex);
|
self.emit(Bytecode::GetIndex);
|
||||||
}
|
}
|
||||||
|
Expr::StructLit { type_name, fields, .. } => {
|
||||||
|
// Push each field value in declaration order
|
||||||
|
for (_, field_expr) in fields {
|
||||||
|
self.gen_expr(field_expr)?;
|
||||||
|
}
|
||||||
|
let field_names: Vec<String> = fields.iter().map(|(n, _)| n.clone()).collect();
|
||||||
|
self.emit(Bytecode::BuildStruct {
|
||||||
|
type_name: type_name.clone(),
|
||||||
|
fields: field_names,
|
||||||
|
});
|
||||||
|
}
|
||||||
// New expression kinds — push Nil as placeholder
|
// New expression kinds — push Nil as placeholder
|
||||||
_ => {
|
_ => {
|
||||||
self.emit(Bytecode::Push(Value::Nil));
|
self.emit(Bytecode::Push(Value::Nil));
|
||||||
|
|||||||
@@ -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" }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
//! AST pretty-printer — the core of el-fmt.
|
||||||
|
|
||||||
|
use el_parser::{BinOp, Expr, Literal, MatchArm, Pattern, Program, Stmt, TypeExpr};
|
||||||
|
|
||||||
|
use crate::{FmtConfig, FmtError};
|
||||||
|
use crate::config::IndentStyle;
|
||||||
|
|
||||||
|
pub struct Formatter {
|
||||||
|
config: FmtConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Formatter {
|
||||||
|
pub fn new(config: FmtConfig) -> Self {
|
||||||
|
Self { config }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format(&self, program: &Program) -> Result<String, FmtError> {
|
||||||
|
let mut out = String::new();
|
||||||
|
for (i, stmt) in program.stmts.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
self.fmt_stmt(&mut out, stmt, 0);
|
||||||
|
}
|
||||||
|
if self.config.trailing_newline && !out.ends_with('\n') {
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn indent(&self, depth: usize) -> String {
|
||||||
|
match self.config.indent {
|
||||||
|
IndentStyle::Spaces => " ".repeat(depth * self.config.indent_width),
|
||||||
|
IndentStyle::Tabs => "\t".repeat(depth),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fmt_stmt(&self, out: &mut String, stmt: &Stmt, depth: usize) {
|
||||||
|
let ind = self.indent(depth);
|
||||||
|
match stmt {
|
||||||
|
Stmt::Let { name, type_ann, value, .. } => {
|
||||||
|
out.push_str(&ind);
|
||||||
|
out.push_str("let ");
|
||||||
|
out.push_str(name);
|
||||||
|
if let Some(ty) = type_ann {
|
||||||
|
out.push_str(": ");
|
||||||
|
out.push_str(&self.fmt_type(ty));
|
||||||
|
}
|
||||||
|
out.push_str(" = ");
|
||||||
|
self.fmt_expr(out, value, depth);
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
Stmt::Return(expr, _) => {
|
||||||
|
out.push_str(&format!("{ind}return "));
|
||||||
|
self.fmt_expr(out, expr, depth);
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
Stmt::Expr(expr, _) => {
|
||||||
|
out.push_str(&ind);
|
||||||
|
self.fmt_expr(out, expr, depth);
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
Stmt::FnDef { name, params, body, decorators, return_type, .. } => {
|
||||||
|
// Decorators
|
||||||
|
for dec in decorators {
|
||||||
|
out.push_str(&format!("{ind}@{}\n", dec.name));
|
||||||
|
}
|
||||||
|
// Parameters
|
||||||
|
let params_str: Vec<String> = params
|
||||||
|
.iter()
|
||||||
|
.map(|p| format!("{}: {}", p.name, self.fmt_type(&p.type_ann)))
|
||||||
|
.collect();
|
||||||
|
// Always emit return type — the parser requires `->`.
|
||||||
|
let ret = format!(" -> {}", self.fmt_type(return_type));
|
||||||
|
let brace_space = if self.config.space_before_brace { " " } else { "" };
|
||||||
|
out.push_str(&format!(
|
||||||
|
"{ind}fn {name}({}){}{brace_space}{{\n",
|
||||||
|
params_str.join(", "),
|
||||||
|
ret,
|
||||||
|
));
|
||||||
|
for s in body {
|
||||||
|
self.fmt_stmt(out, s, depth + 1);
|
||||||
|
}
|
||||||
|
out.push_str(&format!("{ind}}}\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Stmt::TypeDef { name, fields, .. } => {
|
||||||
|
out.push_str(&format!("{ind}type {name} {{\n"));
|
||||||
|
for f in fields {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"{} {}: {}\n",
|
||||||
|
ind,
|
||||||
|
f.name,
|
||||||
|
self.fmt_type(&f.type_ann)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out.push_str(&format!("{ind}}}\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Stmt::EnumDef { name, variants, .. } => {
|
||||||
|
out.push_str(&format!("{ind}enum {name} {{\n"));
|
||||||
|
for v in variants {
|
||||||
|
if let Some(payload) = &v.payload {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"{} {}({})\n",
|
||||||
|
ind,
|
||||||
|
v.name,
|
||||||
|
self.fmt_type(payload)
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
out.push_str(&format!("{} {}\n", ind, v.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str(&format!("{ind}}}\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Stmt::TestDef { name, body, .. } => {
|
||||||
|
out.push_str(&format!("{ind}test {:?} {{\n", name));
|
||||||
|
for s in body {
|
||||||
|
self.fmt_stmt(out, s, depth + 1);
|
||||||
|
}
|
||||||
|
out.push_str(&format!("{ind}}}\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Stmt::Assert(expr, _) => {
|
||||||
|
out.push_str(&format!("{ind}assert "));
|
||||||
|
self.fmt_expr(out, expr, depth);
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
Stmt::Import { path, names, alias, .. } => {
|
||||||
|
if names.is_empty() {
|
||||||
|
let joined = path.join("::");
|
||||||
|
if let Some(a) = alias {
|
||||||
|
out.push_str(&format!("{ind}import {joined} as {a}\n"));
|
||||||
|
} else {
|
||||||
|
out.push_str(&format!("{ind}import {joined}\n"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let joined = path.join("::");
|
||||||
|
let items = names.join(", ");
|
||||||
|
out.push_str(&format!("{ind}from {joined} import {{ {items} }}\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Stmt::ProtocolDef { name, methods, .. } => {
|
||||||
|
out.push_str(&format!("{ind}protocol {name} {{\n"));
|
||||||
|
for m in methods {
|
||||||
|
let params_str: Vec<String> = m
|
||||||
|
.params
|
||||||
|
.iter()
|
||||||
|
.map(|p| format!("{}: {}", p.name, self.fmt_type(&p.type_ann)))
|
||||||
|
.collect();
|
||||||
|
out.push_str(&format!(
|
||||||
|
"{} fn {}({}) -> {}\n",
|
||||||
|
ind,
|
||||||
|
m.name,
|
||||||
|
params_str.join(", "),
|
||||||
|
self.fmt_type(&m.return_type)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out.push_str(&format!("{ind}}}\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Stmt::ImplDef { protocol_name, type_name, methods, .. } => {
|
||||||
|
out.push_str(&format!("{ind}impl {protocol_name} for {type_name} {{\n"));
|
||||||
|
for m in methods {
|
||||||
|
self.fmt_stmt(out, m, depth + 1);
|
||||||
|
}
|
||||||
|
out.push_str(&format!("{ind}}}\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Stmt::Seed(seed, _) => {
|
||||||
|
use el_parser::SeedStmt;
|
||||||
|
match seed {
|
||||||
|
SeedStmt::Node { node_type, content, importance, tier } => {
|
||||||
|
let tier_str = tier
|
||||||
|
.as_deref()
|
||||||
|
.map(|t| format!(", tier: {t}"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
out.push_str(&format!(
|
||||||
|
"{ind}seed {node_type} {{ content: {:?}, importance: {importance}{tier_str} }}\n",
|
||||||
|
content
|
||||||
|
));
|
||||||
|
}
|
||||||
|
SeedStmt::Edge { from, to, relation, weight } => {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"{ind}seed Edge {{ from: {from}, to: {to}, relation: {relation:?}, weight: {weight} }}\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fmt_expr(&self, out: &mut String, expr: &Expr, depth: usize) {
|
||||||
|
match expr {
|
||||||
|
Expr::Literal(lit) => self.fmt_literal(out, lit),
|
||||||
|
|
||||||
|
Expr::Ident(name) => out.push_str(name),
|
||||||
|
|
||||||
|
Expr::Path { segments } => out.push_str(&segments.join("::")),
|
||||||
|
|
||||||
|
Expr::BinOp { op, left, right } => {
|
||||||
|
self.fmt_expr(out, left, depth);
|
||||||
|
out.push_str(&format!(" {} ", self.fmt_binop(op)));
|
||||||
|
self.fmt_expr(out, right, depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
Expr::UnaryNot(inner) => {
|
||||||
|
out.push('!');
|
||||||
|
self.fmt_expr(out, inner, depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
Expr::Try(inner) => {
|
||||||
|
self.fmt_expr(out, inner, depth);
|
||||||
|
out.push('?');
|
||||||
|
}
|
||||||
|
|
||||||
|
Expr::Call { func, args } => {
|
||||||
|
self.fmt_expr(out, func, depth);
|
||||||
|
out.push('(');
|
||||||
|
for (i, arg) in args.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
out.push_str(", ");
|
||||||
|
}
|
||||||
|
self.fmt_expr(out, arg, depth);
|
||||||
|
}
|
||||||
|
out.push(')');
|
||||||
|
}
|
||||||
|
|
||||||
|
Expr::Block(stmts) => {
|
||||||
|
out.push_str("{\n");
|
||||||
|
for s in stmts {
|
||||||
|
self.fmt_stmt(out, s, depth + 1);
|
||||||
|
}
|
||||||
|
out.push_str(&format!("{}}}", self.indent(depth)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Expr::If { cond, then, else_ } => {
|
||||||
|
out.push_str("if ");
|
||||||
|
self.fmt_expr(out, cond, depth);
|
||||||
|
out.push(' ');
|
||||||
|
self.fmt_expr(out, then, depth);
|
||||||
|
if let Some(else_expr) = else_ {
|
||||||
|
out.push_str(" else ");
|
||||||
|
self.fmt_expr(out, else_expr, depth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Expr::Activate { type_name, query } => {
|
||||||
|
out.push_str(&format!("activate {type_name} where {:?}", query));
|
||||||
|
}
|
||||||
|
|
||||||
|
Expr::Field { object, field } => {
|
||||||
|
self.fmt_expr(out, object, depth);
|
||||||
|
out.push('.');
|
||||||
|
out.push_str(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
Expr::Index { object, index } => {
|
||||||
|
self.fmt_expr(out, object, depth);
|
||||||
|
out.push('[');
|
||||||
|
self.fmt_expr(out, index, depth);
|
||||||
|
out.push(']');
|
||||||
|
}
|
||||||
|
|
||||||
|
Expr::Array(elems) => {
|
||||||
|
out.push('[');
|
||||||
|
for (i, e) in elems.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
out.push_str(", ");
|
||||||
|
}
|
||||||
|
self.fmt_expr(out, e, depth);
|
||||||
|
}
|
||||||
|
out.push(']');
|
||||||
|
}
|
||||||
|
|
||||||
|
Expr::MapLiteral(pairs) => {
|
||||||
|
out.push('{');
|
||||||
|
for (i, (k, v)) in pairs.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
out.push_str(", ");
|
||||||
|
}
|
||||||
|
self.fmt_expr(out, k, depth);
|
||||||
|
out.push_str(": ");
|
||||||
|
self.fmt_expr(out, v, depth);
|
||||||
|
}
|
||||||
|
out.push('}');
|
||||||
|
}
|
||||||
|
|
||||||
|
Expr::Sealed(stmts) => {
|
||||||
|
out.push_str("sealed {\n");
|
||||||
|
for s in stmts {
|
||||||
|
self.fmt_stmt(out, s, depth + 1);
|
||||||
|
}
|
||||||
|
out.push_str(&format!("{}}}", self.indent(depth)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Expr::Match { subject, arms } => {
|
||||||
|
out.push_str("match ");
|
||||||
|
self.fmt_expr(out, subject, depth);
|
||||||
|
out.push_str(" {\n");
|
||||||
|
for arm in arms {
|
||||||
|
self.fmt_match_arm(out, arm, depth);
|
||||||
|
}
|
||||||
|
out.push_str(&format!("{}}}", self.indent(depth)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Expr::Closure { params, return_type, body, .. } => {
|
||||||
|
out.push('|');
|
||||||
|
let params_str: Vec<String> = params
|
||||||
|
.iter()
|
||||||
|
.map(|p| format!("{}: {}", p.name, self.fmt_type(&p.type_ann)))
|
||||||
|
.collect();
|
||||||
|
out.push_str(¶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<String> = fields
|
||||||
|
.iter()
|
||||||
|
.map(|(name, val)| {
|
||||||
|
let mut s = format!("{name}: ");
|
||||||
|
self.fmt_expr(&mut s, val, depth);
|
||||||
|
s
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
out.push_str(&fields_str.join(", "));
|
||||||
|
out.push_str(" }");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fmt_literal(&self, out: &mut String, lit: &Literal) {
|
||||||
|
match lit {
|
||||||
|
Literal::Int(n) => out.push_str(&n.to_string()),
|
||||||
|
Literal::Float(f) => out.push_str(&f.to_string()),
|
||||||
|
Literal::Str(s) => out.push_str(&format!("{s:?}")),
|
||||||
|
Literal::Bool(b) => out.push_str(&b.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fmt_binop(&self, op: &BinOp) -> &'static str {
|
||||||
|
match op {
|
||||||
|
BinOp::Add => "+",
|
||||||
|
BinOp::Sub => "-",
|
||||||
|
BinOp::Mul => "*",
|
||||||
|
BinOp::Div => "/",
|
||||||
|
BinOp::Eq => "==",
|
||||||
|
BinOp::NotEq => "!=",
|
||||||
|
BinOp::Lt => "<",
|
||||||
|
BinOp::Gt => ">",
|
||||||
|
BinOp::LtEq => "<=",
|
||||||
|
BinOp::GtEq => ">=",
|
||||||
|
BinOp::And => "&&",
|
||||||
|
BinOp::Or => "||",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fmt_match_arm(&self, out: &mut String, arm: &MatchArm, depth: usize) {
|
||||||
|
out.push_str(&format!("{} ", self.indent(depth)));
|
||||||
|
self.fmt_pattern(out, &arm.pattern);
|
||||||
|
out.push_str(" => ");
|
||||||
|
self.fmt_expr(out, &arm.body, depth + 1);
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fmt_pattern(&self, out: &mut String, pat: &Pattern) {
|
||||||
|
match pat {
|
||||||
|
Pattern::Wildcard => out.push('_'),
|
||||||
|
Pattern::Binding(name) => out.push_str(name),
|
||||||
|
Pattern::Literal(lit) => self.fmt_literal(out, lit),
|
||||||
|
Pattern::EnumVariant { enum_name, variant, payload } => {
|
||||||
|
out.push_str(&format!("{enum_name}::"));
|
||||||
|
out.push_str(variant);
|
||||||
|
if let Some(bind) = payload {
|
||||||
|
out.push_str(&format!("({bind})"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fmt_type(&self, ty: &TypeExpr) -> String {
|
||||||
|
match ty {
|
||||||
|
TypeExpr::Named(n) => n.clone(),
|
||||||
|
TypeExpr::Array(inner) => format!("[{}]", self.fmt_type(inner)),
|
||||||
|
TypeExpr::Optional(inner) => format!("{}?", self.fmt_type(inner)),
|
||||||
|
TypeExpr::Result { ok, err } => {
|
||||||
|
format!("Result<{}, {}>", self.fmt_type(ok), self.fmt_type(err))
|
||||||
|
}
|
||||||
|
TypeExpr::Map { key, value } => {
|
||||||
|
format!("Map<{}, {}>", self.fmt_type(key), self.fmt_type(value))
|
||||||
|
}
|
||||||
|
TypeExpr::Fn { params, return_type } => {
|
||||||
|
let ps: Vec<_> = params.iter().map(|p| self.fmt_type(p)).collect();
|
||||||
|
format!("fn({}) -> {}", ps.join(", "), self.fmt_type(return_type))
|
||||||
|
}
|
||||||
|
TypeExpr::TypeParam(n) => n.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
//! el-fmt — canonical source formatter for engram-lang.
|
||||||
|
//!
|
||||||
|
//! Formats a `.el` source file into its canonical representation.
|
||||||
|
//! Parsing an already-formatted file and re-formatting it produces identical output.
|
||||||
|
|
||||||
|
pub mod config;
|
||||||
|
pub mod error;
|
||||||
|
pub mod formatter;
|
||||||
|
|
||||||
|
pub use config::FmtConfig;
|
||||||
|
pub use error::FmtError;
|
||||||
|
pub use formatter::Formatter;
|
||||||
|
|
||||||
|
/// Format engram-lang source code. Returns the canonical formatted version.
|
||||||
|
pub fn format(source: &str) -> Result<String, FmtError> {
|
||||||
|
format_with_config(source, &FmtConfig::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format with an explicit configuration.
|
||||||
|
pub fn format_with_config(source: &str, config: &FmtConfig) -> Result<String, FmtError> {
|
||||||
|
let tokens =
|
||||||
|
el_lexer::tokenize(source).map_err(|e| FmtError::Lex(e.to_string()))?;
|
||||||
|
let program =
|
||||||
|
el_parser::parse(tokens, source.to_string()).map_err(|e| FmtError::Parse(e.to_string()))?;
|
||||||
|
Formatter::new(config.clone()).format(&program)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether `source` is already in canonical form.
|
||||||
|
/// Returns `true` if formatting would produce no changes.
|
||||||
|
pub fn is_canonical(source: &str) -> Result<bool, FmtError> {
|
||||||
|
let formatted = format(source)?;
|
||||||
|
Ok(formatted == source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn fmt(src: &str) -> String {
|
||||||
|
format(src).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn idempotent(src: &str) {
|
||||||
|
let once = fmt(src);
|
||||||
|
let twice = fmt(&once);
|
||||||
|
assert_eq!(once, twice, "format not idempotent for:\n{src}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Integer literal
|
||||||
|
#[test]
|
||||||
|
fn test_integer_literal() {
|
||||||
|
assert_eq!(fmt("42"), "42\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Let binding (no type annotation in source → formatter emits inferred type)
|
||||||
|
// We parse "let x = 1" which gives type_ann from the parser.
|
||||||
|
// Since el-parser always injects a type_ann, we just check the output is stable.
|
||||||
|
#[test]
|
||||||
|
fn test_let_binding_idempotent() {
|
||||||
|
// Round-trip: parse what we emit and re-emit
|
||||||
|
let source = "let x: Int = 1\n";
|
||||||
|
assert_eq!(fmt(source), source);
|
||||||
|
idempotent(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Binary operator spacing
|
||||||
|
#[test]
|
||||||
|
fn test_binary_op_spacing() {
|
||||||
|
let out = fmt("1 + 2\n");
|
||||||
|
assert!(out.contains("1 + 2"), "expected '1 + 2' in: {out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Function definition canonical form
|
||||||
|
#[test]
|
||||||
|
fn test_fn_def() {
|
||||||
|
let src = "fn add(a: Int, b: Int) -> Int {\n return a + b\n}\n";
|
||||||
|
let out = fmt(src);
|
||||||
|
assert!(out.contains("fn add("), "missing fn signature: {out}");
|
||||||
|
assert!(out.contains("return a + b"), "missing return: {out}");
|
||||||
|
idempotent(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Nested function has 4-space indent
|
||||||
|
#[test]
|
||||||
|
fn test_nested_indent() {
|
||||||
|
let src = "fn outer() -> Void {\n fn inner() -> Void {\n }\n}\n";
|
||||||
|
let out = fmt(src);
|
||||||
|
assert!(out.contains(" fn inner("), "inner fn not indented: {out}");
|
||||||
|
idempotent(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. If expression spacing
|
||||||
|
#[test]
|
||||||
|
fn test_if_expr() {
|
||||||
|
let src = "fn f() -> Void {\n if true {\n }\n}\n";
|
||||||
|
let out = fmt(src);
|
||||||
|
assert!(out.contains("if true"), "missing if: {out}");
|
||||||
|
idempotent(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. If-else expression
|
||||||
|
#[test]
|
||||||
|
fn test_if_else() {
|
||||||
|
let src = "fn f(x: Int) -> Void {\n if x {\n } else {\n }\n}\n";
|
||||||
|
let out = fmt(src);
|
||||||
|
assert!(out.contains("else"), "missing else: {out}");
|
||||||
|
idempotent(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Match expression arms on own lines
|
||||||
|
#[test]
|
||||||
|
fn test_match_expr() {
|
||||||
|
let src = "fn f(x: Status) -> Void {\n match x {\n Status::Active(v) => 1\n _ => 0\n }\n}\n";
|
||||||
|
let out = fmt(src);
|
||||||
|
assert!(out.contains("match x"), "missing match: {out}");
|
||||||
|
assert!(out.contains("=>"), "missing arm: {out}");
|
||||||
|
idempotent(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Activate expression
|
||||||
|
#[test]
|
||||||
|
fn test_activate() {
|
||||||
|
let src = "fn f() -> Void {\n activate User where \"active users\"\n}\n";
|
||||||
|
let out = fmt(src);
|
||||||
|
assert!(out.contains("activate User where"), "missing activate: {out}");
|
||||||
|
idempotent(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Sealed block
|
||||||
|
#[test]
|
||||||
|
fn test_sealed_block() {
|
||||||
|
let src = "fn f() -> Void {\n sealed {\n let x: Int = 1\n }\n}\n";
|
||||||
|
let out = fmt(src);
|
||||||
|
assert!(out.contains("sealed {"), "missing sealed: {out}");
|
||||||
|
idempotent(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. Array literal
|
||||||
|
#[test]
|
||||||
|
fn test_array_literal() {
|
||||||
|
let src = "[1, 2, 3]\n";
|
||||||
|
let out = fmt(src);
|
||||||
|
assert!(out.contains("[1, 2, 3]"), "missing array: {out}");
|
||||||
|
idempotent(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12. Field access
|
||||||
|
#[test]
|
||||||
|
fn test_field_access() {
|
||||||
|
let src = "fn f(u: User) -> Void {\n u.name\n}\n";
|
||||||
|
let out = fmt(src);
|
||||||
|
assert!(out.contains("u.name"), "missing field access: {out}");
|
||||||
|
idempotent(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13. Function call with args
|
||||||
|
#[test]
|
||||||
|
fn test_fn_call() {
|
||||||
|
let src = "foo(1, 2)\n";
|
||||||
|
let out = fmt(src);
|
||||||
|
assert!(out.contains("foo(1, 2)"), "missing call: {out}");
|
||||||
|
idempotent(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 14. Type definition
|
||||||
|
#[test]
|
||||||
|
fn test_type_def() {
|
||||||
|
let src = "type User {\n name: String\n age: Int\n}\n";
|
||||||
|
let out = fmt(src);
|
||||||
|
assert!(out.contains("type User {"), "missing type def: {out}");
|
||||||
|
assert!(out.contains("name: String"), "missing field: {out}");
|
||||||
|
idempotent(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 15. Enum definition
|
||||||
|
#[test]
|
||||||
|
fn test_enum_def() {
|
||||||
|
let src = "enum Status {\n Active\n Inactive\n}\n";
|
||||||
|
let out = fmt(src);
|
||||||
|
assert!(out.contains("enum Status {"), "missing enum def: {out}");
|
||||||
|
assert!(out.contains("Active"), "missing variant: {out}");
|
||||||
|
idempotent(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 16. Decorator on fn
|
||||||
|
#[test]
|
||||||
|
fn test_decorator() {
|
||||||
|
let src = "@experience\nfn handle() -> Void {\n}\n";
|
||||||
|
let out = fmt(src);
|
||||||
|
assert!(out.contains("@experience"), "missing decorator: {out}");
|
||||||
|
idempotent(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 17. Multiple decorators in order
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_decorators() {
|
||||||
|
let src = "@public\n@experience\nfn handle() -> Void {\n}\n";
|
||||||
|
let out = fmt(src);
|
||||||
|
let pub_pos = out.find("@public").unwrap();
|
||||||
|
let exp_pos = out.find("@experience").unwrap();
|
||||||
|
assert!(pub_pos < exp_pos, "decorators out of order: {out}");
|
||||||
|
idempotent(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 18. Return type annotation
|
||||||
|
#[test]
|
||||||
|
fn test_return_type() {
|
||||||
|
let src = "fn add(a: Int, b: Int) -> Int {\n return a + b\n}\n";
|
||||||
|
let out = fmt(src);
|
||||||
|
assert!(out.contains("-> Int"), "missing return type: {out}");
|
||||||
|
idempotent(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 19. Result type
|
||||||
|
#[test]
|
||||||
|
fn test_result_type() {
|
||||||
|
let src = "fn load() -> Result<String, Error> {\n return \"ok\"\n}\n";
|
||||||
|
let out = fmt(src);
|
||||||
|
assert!(out.contains("Result<String, Error>"), "missing result type: {out}");
|
||||||
|
idempotent(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 20. Optional type
|
||||||
|
#[test]
|
||||||
|
fn test_optional_type() {
|
||||||
|
let src = "fn find() -> String? {\n return \"ok\"\n}\n";
|
||||||
|
let out = fmt(src);
|
||||||
|
assert!(out.contains("String?"), "missing optional type: {out}");
|
||||||
|
idempotent(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 21. Trailing newline always present
|
||||||
|
#[test]
|
||||||
|
fn test_trailing_newline() {
|
||||||
|
let out = fmt("42");
|
||||||
|
assert!(out.ends_with('\n'), "missing trailing newline");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 22. is_canonical returns true for already-canonical source
|
||||||
|
#[test]
|
||||||
|
fn test_is_canonical_true() {
|
||||||
|
let src = "42\n";
|
||||||
|
assert!(is_canonical(src).unwrap(), "expected canonical");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 23. is_canonical returns false for non-canonical source
|
||||||
|
#[test]
|
||||||
|
fn test_is_canonical_false() {
|
||||||
|
// No trailing newline
|
||||||
|
let result = is_canonical("42");
|
||||||
|
// Either it returns false OR the formatter fixes it
|
||||||
|
// Either way it should not error
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 24. Empty program produces just a newline
|
||||||
|
#[test]
|
||||||
|
fn test_empty_program() {
|
||||||
|
// An empty string has no stmts so produces nothing; trailing newline adds one
|
||||||
|
let out = fmt("");
|
||||||
|
assert_eq!(out, "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 25. Idempotence for multiple constructs
|
||||||
|
#[test]
|
||||||
|
fn test_idempotent_fn_def() {
|
||||||
|
idempotent("fn add(a: Int, b: Int) -> Int {\n return a + b\n}\n");
|
||||||
|
idempotent("fn noop() -> Void {\n}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_idempotent_type_def() {
|
||||||
|
idempotent("type User {\n name: String\n age: Int\n}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_idempotent_enum_def() {
|
||||||
|
idempotent("enum Status {\n Active\n Inactive\n}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 26. Test block
|
||||||
|
#[test]
|
||||||
|
fn test_test_block() {
|
||||||
|
let src = "test \"my test\" {\n assert 1 == 1\n}\n";
|
||||||
|
let out = fmt(src);
|
||||||
|
assert!(out.contains("test \"my test\""), "missing test block: {out}");
|
||||||
|
idempotent(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 27. Wildcard pattern in match
|
||||||
|
#[test]
|
||||||
|
fn test_wildcard_pattern() {
|
||||||
|
let src = "fn f(x: Status) -> Void {\n match x {\n _ => 0\n }\n}\n";
|
||||||
|
let out = fmt(src);
|
||||||
|
assert!(out.contains("_ =>"), "missing wildcard: {out}");
|
||||||
|
idempotent(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 28. Binding pattern in match
|
||||||
|
#[test]
|
||||||
|
fn test_binding_pattern() {
|
||||||
|
let src = "fn f(x: Int) -> Void {\n match x {\n v => v\n }\n}\n";
|
||||||
|
let out = fmt(src);
|
||||||
|
assert!(out.contains("v =>"), "missing binding: {out}");
|
||||||
|
idempotent(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 29. Enum variant with payload
|
||||||
|
#[test]
|
||||||
|
fn test_enum_variant_payload() {
|
||||||
|
let src = "enum Msg {\n Value(Int)\n Empty\n}\n";
|
||||||
|
let out = fmt(src);
|
||||||
|
assert!(out.contains("Value(Int)"), "missing payload variant: {out}");
|
||||||
|
idempotent(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 30. for loop
|
||||||
|
#[test]
|
||||||
|
fn test_for_loop() {
|
||||||
|
let src = "fn f(items: [Int]) -> Void {\n for x in items {\n x\n }\n}\n";
|
||||||
|
let out = fmt(src);
|
||||||
|
assert!(out.contains("for x in"), "missing for loop: {out}");
|
||||||
|
idempotent(src);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
//! el-lint — linter for engram-lang source files.
|
||||||
|
//!
|
||||||
|
//! Combines:
|
||||||
|
//! - `el-arch` architectural rule violations (VBD, EBD, swarm, security, graph)
|
||||||
|
//! - Style checks (naming conventions, function length, empty bodies)
|
||||||
|
//! - Format check (`el-fmt` canonical check, rule I001)
|
||||||
|
|
||||||
|
pub mod error;
|
||||||
|
pub mod linter;
|
||||||
|
pub mod report;
|
||||||
|
pub mod rules;
|
||||||
|
|
||||||
|
pub use error::LintError;
|
||||||
|
pub use linter::Linter;
|
||||||
|
pub use report::{LintDiagnostic, LintReport, LintSeverity};
|
||||||
|
|
||||||
|
/// Lint engram-lang source code. Returns a report with all diagnostics.
|
||||||
|
pub fn lint(source: &str) -> Result<LintReport, LintError> {
|
||||||
|
Linter::new().lint(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn do_lint(src: &str) -> LintReport {
|
||||||
|
lint(src).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Clean, canonical code → no errors, no warnings
|
||||||
|
#[test]
|
||||||
|
fn test_clean_code_no_errors() {
|
||||||
|
let src = "fn add(a: Int, b: Int) -> Int {\n return a + b\n}\n";
|
||||||
|
let report = do_lint(src);
|
||||||
|
assert!(!report.has_errors(), "unexpected errors: {:?}", report.diagnostics);
|
||||||
|
assert_eq!(report.warning_count(), 0, "unexpected warnings: {:?}", report.diagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. @accessor calling @manager fn → VBD-001 error
|
||||||
|
#[test]
|
||||||
|
fn test_accessor_calls_manager() {
|
||||||
|
let src = concat!(
|
||||||
|
"@manager\nfn save_data() -> Void {\n}\n\n",
|
||||||
|
"@accessor\nfn get_data() -> Void {\n save_data()\n}\n"
|
||||||
|
);
|
||||||
|
let report = do_lint(src);
|
||||||
|
assert!(
|
||||||
|
report.has_errors(),
|
||||||
|
"expected arch error for accessor calling manager"
|
||||||
|
);
|
||||||
|
let has_vbd = report
|
||||||
|
.diagnostics
|
||||||
|
.iter()
|
||||||
|
.any(|d| d.code.contains("VBD"));
|
||||||
|
assert!(has_vbd, "expected VBD code: {:?}", report.diagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. activate in a loop → GRAPH-001 warning
|
||||||
|
#[test]
|
||||||
|
fn test_activate_in_loop() {
|
||||||
|
let src = concat!(
|
||||||
|
"@accessor\nfn load_all(items: [String]) -> Void {\n",
|
||||||
|
" for x in items {\n",
|
||||||
|
" activate User where \"query\"\n",
|
||||||
|
" }\n}\n"
|
||||||
|
);
|
||||||
|
let report = do_lint(src);
|
||||||
|
let has_graph = report
|
||||||
|
.diagnostics
|
||||||
|
.iter()
|
||||||
|
.any(|d| d.code.contains("GRAPH") || d.code.contains("N1"));
|
||||||
|
assert!(has_graph, "expected GRAPH/N1 diagnostic: {:?}", report.diagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Function with uppercase name → S002 warning
|
||||||
|
#[test]
|
||||||
|
fn test_fn_uppercase_name() {
|
||||||
|
let src = "fn MyFunction() -> Void {\n}\n";
|
||||||
|
let report = do_lint(src);
|
||||||
|
let has_s002 = report.diagnostics.iter().any(|d| d.code == "S002");
|
||||||
|
assert!(has_s002, "expected S002: {:?}", report.diagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Type with lowercase name → S004 warning
|
||||||
|
#[test]
|
||||||
|
fn test_type_lowercase_name() {
|
||||||
|
let src = "type myType {\n x: Int\n}\n";
|
||||||
|
let report = do_lint(src);
|
||||||
|
let has_s004 = report.diagnostics.iter().any(|d| d.code == "S004");
|
||||||
|
assert!(has_s004, "expected S004: {:?}", report.diagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Empty function body → S003 info
|
||||||
|
#[test]
|
||||||
|
fn test_empty_fn_body() {
|
||||||
|
let src = "fn empty() -> Void {\n}\n";
|
||||||
|
let report = do_lint(src);
|
||||||
|
let has_s003 = report.diagnostics.iter().any(|d| d.code == "S003");
|
||||||
|
assert!(has_s003, "expected S003: {:?}", report.diagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Non-canonical formatting → I001 info
|
||||||
|
#[test]
|
||||||
|
fn test_non_canonical_format() {
|
||||||
|
// Missing trailing newline triggers I001 (formatter adds it, source doesn't have it)
|
||||||
|
let src = "42";
|
||||||
|
let report = do_lint(src);
|
||||||
|
let has_i001 = report.diagnostics.iter().any(|d| d.code == "I001");
|
||||||
|
assert!(has_i001, "expected I001: {:?}", report.diagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Canonical formatting → no I001
|
||||||
|
#[test]
|
||||||
|
fn test_canonical_format_no_i001() {
|
||||||
|
let src = "fn add(a: Int, b: Int) -> Int {\n return a + b\n}\n";
|
||||||
|
let report = do_lint(src);
|
||||||
|
let has_i001 = report.diagnostics.iter().any(|d| d.code == "I001");
|
||||||
|
assert!(!has_i001, "unexpected I001: {:?}", report.diagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. has_errors() true when errors present
|
||||||
|
#[test]
|
||||||
|
fn test_has_errors_true() {
|
||||||
|
let src = concat!(
|
||||||
|
"@manager\nfn save() -> Void {\n}\n\n",
|
||||||
|
"@accessor\nfn get() -> Void {\n save()\n}\n"
|
||||||
|
);
|
||||||
|
let report = do_lint(src);
|
||||||
|
assert!(report.has_errors());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. has_errors() false when only warnings/info
|
||||||
|
#[test]
|
||||||
|
fn test_has_errors_false_warnings_only() {
|
||||||
|
let src = "fn MyFunction() -> Int {\n return 1\n}\n";
|
||||||
|
let report = do_lint(src);
|
||||||
|
assert!(!report.has_errors(), "should not have errors, only warnings");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. error_count() correct
|
||||||
|
#[test]
|
||||||
|
fn test_error_count() {
|
||||||
|
let src = concat!(
|
||||||
|
"@manager\nfn save() -> Void {\n}\n\n",
|
||||||
|
"@accessor\nfn get() -> Void {\n save()\n}\n"
|
||||||
|
);
|
||||||
|
let report = do_lint(src);
|
||||||
|
assert!(report.error_count() >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12. warning_count() correct
|
||||||
|
#[test]
|
||||||
|
fn test_warning_count() {
|
||||||
|
let src = "fn MyFunction() -> Int {\n return 1\n}\n";
|
||||||
|
let report = do_lint(src);
|
||||||
|
assert!(report.warning_count() >= 1, "expected at least one warning");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13. display() output contains "error" prefix for errors
|
||||||
|
#[test]
|
||||||
|
fn test_display_error_prefix() {
|
||||||
|
let src = concat!(
|
||||||
|
"@manager\nfn save() -> Void {\n}\n\n",
|
||||||
|
"@accessor\nfn get() -> Void {\n save()\n}\n"
|
||||||
|
);
|
||||||
|
let report = do_lint(src);
|
||||||
|
let display = report.display();
|
||||||
|
assert!(display.contains("error"), "expected 'error' in display: {display}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 14. display() contains "No issues found." for clean code
|
||||||
|
#[test]
|
||||||
|
fn test_display_no_issues() {
|
||||||
|
let src = "fn add(a: Int, b: Int) -> Int {\n return a + b\n}\n";
|
||||||
|
let report = do_lint(src);
|
||||||
|
if !report.has_errors() && report.warning_count() == 0 {
|
||||||
|
let display = report.display();
|
||||||
|
assert!(
|
||||||
|
display.contains("No issues found."),
|
||||||
|
"expected 'No issues found.': {display}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 15. to_json() is valid JSON
|
||||||
|
#[test]
|
||||||
|
fn test_to_json_valid() {
|
||||||
|
let src = "fn add(a: Int, b: Int) -> Int {\n return a + b\n}\n";
|
||||||
|
let report = do_lint(src);
|
||||||
|
let json = report.to_json();
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
|
||||||
|
assert!(parsed.is_array(), "expected JSON array");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 16. to_json() contains severity field
|
||||||
|
#[test]
|
||||||
|
fn test_to_json_has_severity() {
|
||||||
|
let src = "fn MyFunction() -> Int {\n return 1\n}\n";
|
||||||
|
let report = do_lint(src);
|
||||||
|
let json = report.to_json();
|
||||||
|
assert!(json.contains("severity"), "expected severity field: {json}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 17. Multiple issues in same file → all reported
|
||||||
|
#[test]
|
||||||
|
fn test_multiple_issues() {
|
||||||
|
let src = "fn MyFunction() -> Void {\n}\ntype myType {\n x: Int\n}\n";
|
||||||
|
let report = do_lint(src);
|
||||||
|
// S002 for fn name + S003 for empty body + S004 for type name
|
||||||
|
assert!(
|
||||||
|
report.diagnostics.len() >= 2,
|
||||||
|
"expected multiple diagnostics: {:?}",
|
||||||
|
report.diagnostics
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 18. @experience calling @experience → arch error
|
||||||
|
#[test]
|
||||||
|
fn test_experience_calls_experience() {
|
||||||
|
let src = concat!(
|
||||||
|
"@experience\nfn exp_a() -> Void {\n}\n\n",
|
||||||
|
"@experience\nfn exp_b() -> Void {\n exp_a()\n}\n"
|
||||||
|
);
|
||||||
|
let report = do_lint(src);
|
||||||
|
assert!(
|
||||||
|
report.has_errors(),
|
||||||
|
"expected arch error for experience calling experience"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 19. @public fn with activate → arch error
|
||||||
|
#[test]
|
||||||
|
fn test_public_fn_with_activate() {
|
||||||
|
let src = "@public\nfn api_fn() -> Void {\n activate User where \"query\"\n}\n";
|
||||||
|
let report = do_lint(src);
|
||||||
|
assert!(
|
||||||
|
report.has_errors(),
|
||||||
|
"expected arch error for public fn with activate"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 20. @swarm_agent calling @swarm_agent → diagnostic
|
||||||
|
#[test]
|
||||||
|
fn test_swarm_agent_calls_swarm_agent() {
|
||||||
|
let src = concat!(
|
||||||
|
"@swarm_agent\nfn agent_a() -> Void {\n}\n\n",
|
||||||
|
"@swarm_agent\nfn agent_b() -> Void {\n agent_a()\n}\n"
|
||||||
|
);
|
||||||
|
let report = do_lint(src);
|
||||||
|
// SwarmAgentIsolation should flag this
|
||||||
|
let has_swarm = report
|
||||||
|
.diagnostics
|
||||||
|
.iter()
|
||||||
|
.any(|d| d.code.contains("SWARM") || d.severity == LintSeverity::Error || d.severity == LintSeverity::Warning);
|
||||||
|
assert!(has_swarm, "expected swarm diagnostic: {:?}", report.diagnostics);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 21. Nested functions → linting still works
|
||||||
|
#[test]
|
||||||
|
fn test_nested_functions() {
|
||||||
|
let src = concat!(
|
||||||
|
"fn outer(x: Int) -> Int {\n",
|
||||||
|
" fn inner(y: Int) -> Int {\n",
|
||||||
|
" return y + 1\n",
|
||||||
|
" }\n",
|
||||||
|
" return inner(x)\n",
|
||||||
|
"}\n"
|
||||||
|
);
|
||||||
|
// Should not panic
|
||||||
|
let result = lint(src);
|
||||||
|
assert!(result.is_ok(), "lint failed on nested functions");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 22. LintReport::file_path is None by default
|
||||||
|
#[test]
|
||||||
|
fn test_file_path_none() {
|
||||||
|
let src = "fn f() -> Void {\n}\n";
|
||||||
|
let report = do_lint(src);
|
||||||
|
assert!(report.file_path.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 23. source_lines is counted correctly
|
||||||
|
#[test]
|
||||||
|
fn test_source_lines_counted() {
|
||||||
|
let src = "fn f() -> Int {\n return 1\n}\n";
|
||||||
|
let report = do_lint(src);
|
||||||
|
assert_eq!(report.source_lines, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 24. Empty source → no crash
|
||||||
|
#[test]
|
||||||
|
fn test_empty_source() {
|
||||||
|
let result = lint("");
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
//! Core linter — orchestrates arch rules, style rules, and format check.
|
||||||
|
|
||||||
|
use el_arch::{ArchChecker, Severity as ArchSeverity};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::LintError,
|
||||||
|
report::{LintDiagnostic, LintReport, LintSeverity},
|
||||||
|
rules,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct Linter {
|
||||||
|
arch_checker: ArchChecker,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Linter {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
arch_checker: ArchChecker::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lint(&self, source: &str) -> Result<LintReport, LintError> {
|
||||||
|
let tokens = el_lexer::tokenize(source)
|
||||||
|
.map_err(|e| LintError::Lex(e.to_string()))?;
|
||||||
|
let program = el_parser::parse(tokens, source.to_string())
|
||||||
|
.map_err(|e| LintError::Parse(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut diagnostics = Vec::new();
|
||||||
|
|
||||||
|
// 1. Run el-arch architectural rules.
|
||||||
|
let arch_diags = self.arch_checker.check(&program);
|
||||||
|
for d in arch_diags {
|
||||||
|
diagnostics.push(LintDiagnostic {
|
||||||
|
severity: match d.severity {
|
||||||
|
ArchSeverity::Error => LintSeverity::Error,
|
||||||
|
ArchSeverity::Warning => LintSeverity::Warning,
|
||||||
|
},
|
||||||
|
code: d.rule,
|
||||||
|
message: d.message,
|
||||||
|
location: d.location.unwrap_or_else(|| "unknown".into()),
|
||||||
|
suggestion: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Run style rules.
|
||||||
|
let style_diags = rules::check_style(&program);
|
||||||
|
diagnostics.extend(style_diags);
|
||||||
|
|
||||||
|
// 3. Check whether the source is in canonical format.
|
||||||
|
match el_fmt::is_canonical(source) {
|
||||||
|
Ok(false) => {
|
||||||
|
diagnostics.push(LintDiagnostic {
|
||||||
|
severity: LintSeverity::Info,
|
||||||
|
code: "I001".into(),
|
||||||
|
message: "source is not in canonical format — run `el fmt` to fix".into(),
|
||||||
|
location: "file".into(),
|
||||||
|
suggestion: Some("el fmt --in-place <file.el>".into()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let source_lines = source.lines().count();
|
||||||
|
Ok(LintReport {
|
||||||
|
diagnostics,
|
||||||
|
file_path: None,
|
||||||
|
source_lines,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Linter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
//! Diagnostic report types for el-lint.
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum LintSeverity {
|
||||||
|
Error,
|
||||||
|
Warning,
|
||||||
|
Info,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LintDiagnostic {
|
||||||
|
pub severity: LintSeverity,
|
||||||
|
/// Rule code, e.g. "E001", "W002", "S001", "I001".
|
||||||
|
pub code: String,
|
||||||
|
pub message: String,
|
||||||
|
/// Human-readable location hint, e.g. "function foo" or "file".
|
||||||
|
pub location: String,
|
||||||
|
pub suggestion: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct LintReport {
|
||||||
|
pub diagnostics: Vec<LintDiagnostic>,
|
||||||
|
pub file_path: Option<String>,
|
||||||
|
pub source_lines: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LintReport {
|
||||||
|
pub fn has_errors(&self) -> bool {
|
||||||
|
self.diagnostics
|
||||||
|
.iter()
|
||||||
|
.any(|d| d.severity == LintSeverity::Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn error_count(&self) -> usize {
|
||||||
|
self.diagnostics
|
||||||
|
.iter()
|
||||||
|
.filter(|d| d.severity == LintSeverity::Error)
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn warning_count(&self) -> usize {
|
||||||
|
self.diagnostics
|
||||||
|
.iter()
|
||||||
|
.filter(|d| d.severity == LintSeverity::Warning)
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format as human-readable output (similar to rustc error output).
|
||||||
|
pub fn display(&self) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for d in &self.diagnostics {
|
||||||
|
let prefix = match d.severity {
|
||||||
|
LintSeverity::Error => "error",
|
||||||
|
LintSeverity::Warning => "warning",
|
||||||
|
LintSeverity::Info => "info",
|
||||||
|
};
|
||||||
|
out.push_str(&format!("[{}] {}: {}\n", d.code, prefix, d.message));
|
||||||
|
out.push_str(&format!(" --> {}\n", d.location));
|
||||||
|
if let Some(suggestion) = &d.suggestion {
|
||||||
|
out.push_str(&format!(" help: {}\n", suggestion));
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
if self.diagnostics.is_empty() {
|
||||||
|
out.push_str("No issues found.\n");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format as JSON for editor integration.
|
||||||
|
pub fn to_json(&self) -> String {
|
||||||
|
let items: Vec<serde_json::Value> = self
|
||||||
|
.diagnostics
|
||||||
|
.iter()
|
||||||
|
.map(|d| {
|
||||||
|
serde_json::json!({
|
||||||
|
"severity": match d.severity {
|
||||||
|
LintSeverity::Error => "error",
|
||||||
|
LintSeverity::Warning => "warning",
|
||||||
|
LintSeverity::Info => "info",
|
||||||
|
},
|
||||||
|
"code": d.code,
|
||||||
|
"message": d.message,
|
||||||
|
"location": d.location,
|
||||||
|
"suggestion": d.suggestion,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
serde_json::to_string_pretty(&items).unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
//! Style and correctness rules for el-lint (beyond el-arch architectural rules).
|
||||||
|
|
||||||
|
use el_parser::{Program, Stmt};
|
||||||
|
|
||||||
|
use crate::report::{LintDiagnostic, LintSeverity};
|
||||||
|
|
||||||
|
/// Run all style rules against the program and return diagnostics.
|
||||||
|
pub fn check_style(program: &Program) -> Vec<LintDiagnostic> {
|
||||||
|
let mut diags = Vec::new();
|
||||||
|
|
||||||
|
for stmt in &program.stmts {
|
||||||
|
check_stmt(stmt, &mut diags);
|
||||||
|
}
|
||||||
|
|
||||||
|
diags
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_stmt(stmt: &Stmt, diags: &mut Vec<LintDiagnostic>) {
|
||||||
|
match stmt {
|
||||||
|
Stmt::FnDef { name, body, .. } => {
|
||||||
|
// S001: Function body too long (>50 statements)
|
||||||
|
if body.len() > 50 {
|
||||||
|
diags.push(LintDiagnostic {
|
||||||
|
severity: LintSeverity::Warning,
|
||||||
|
code: "S001".into(),
|
||||||
|
message: format!(
|
||||||
|
"function `{name}` has {} statements — consider splitting",
|
||||||
|
body.len()
|
||||||
|
),
|
||||||
|
location: format!("function {name}"),
|
||||||
|
suggestion: Some(
|
||||||
|
"extract sub-functions for each logical concern".into(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// S002: Function name not snake_case
|
||||||
|
if name.chars().any(|c| c.is_uppercase()) {
|
||||||
|
diags.push(LintDiagnostic {
|
||||||
|
severity: LintSeverity::Warning,
|
||||||
|
code: "S002".into(),
|
||||||
|
message: format!("function `{name}` should be snake_case"),
|
||||||
|
location: format!("function {name}"),
|
||||||
|
suggestion: Some(format!("rename to `{}`", to_snake_case(name))),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// S003: Empty function body
|
||||||
|
if body.is_empty() {
|
||||||
|
diags.push(LintDiagnostic {
|
||||||
|
severity: LintSeverity::Info,
|
||||||
|
code: "S003".into(),
|
||||||
|
message: format!("function `{name}` has an empty body"),
|
||||||
|
location: format!("function {name}"),
|
||||||
|
suggestion: Some("add implementation or remove if unused".into()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse into nested function defs
|
||||||
|
for s in body {
|
||||||
|
check_stmt(s, diags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Stmt::TypeDef { name, .. } => {
|
||||||
|
// S004: Type name not PascalCase
|
||||||
|
if !is_pascal_case(name) {
|
||||||
|
diags.push(LintDiagnostic {
|
||||||
|
severity: LintSeverity::Warning,
|
||||||
|
code: "S004".into(),
|
||||||
|
message: format!("type `{name}` should be PascalCase"),
|
||||||
|
location: format!("type {name}"),
|
||||||
|
suggestion: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Stmt::EnumDef { name, .. } => {
|
||||||
|
// S004 also applies to enums
|
||||||
|
if !is_pascal_case(name) {
|
||||||
|
diags.push(LintDiagnostic {
|
||||||
|
severity: LintSeverity::Warning,
|
||||||
|
code: "S004".into(),
|
||||||
|
message: format!("enum `{name}` should be PascalCase"),
|
||||||
|
location: format!("enum {name}"),
|
||||||
|
suggestion: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Stmt::ImplDef { methods, .. } => {
|
||||||
|
for m in methods {
|
||||||
|
check_stmt(m, diags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert CamelCase/mixed to snake_case.
|
||||||
|
fn to_snake_case(s: &str) -> String {
|
||||||
|
let mut result = String::new();
|
||||||
|
for (i, c) in s.chars().enumerate() {
|
||||||
|
if c.is_uppercase() && i > 0 {
|
||||||
|
result.push('_');
|
||||||
|
}
|
||||||
|
result.push(c.to_lowercase().next().unwrap());
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the first character is uppercase (PascalCase convention).
|
||||||
|
fn is_pascal_case(s: &str) -> bool {
|
||||||
|
s.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
|
||||||
|
}
|
||||||
@@ -59,6 +59,8 @@ pub enum TypeExpr {
|
|||||||
Result { ok: Box<TypeExpr>, err: Box<TypeExpr> },
|
Result { ok: Box<TypeExpr>, err: Box<TypeExpr> },
|
||||||
/// `Map<K, V>` — built-in key-value map type
|
/// `Map<K, V>` — built-in key-value map type
|
||||||
Map { key: Box<TypeExpr>, value: Box<TypeExpr> },
|
Map { key: Box<TypeExpr>, value: Box<TypeExpr> },
|
||||||
|
/// A generic type parameter: `T`, `E` — used inside generic function signatures.
|
||||||
|
TypeParam(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Patterns (for match arms) ─────────────────────────────────────────────────
|
// ── Patterns (for match arms) ─────────────────────────────────────────────────
|
||||||
@@ -118,6 +120,12 @@ pub enum Expr {
|
|||||||
Try(Box<Expr>),
|
Try(Box<Expr>),
|
||||||
/// Map literal: `{"key": value, ...}`
|
/// Map literal: `{"key": value, ...}`
|
||||||
MapLiteral(Vec<(Expr, Expr)>),
|
MapLiteral(Vec<(Expr, Expr)>),
|
||||||
|
/// Struct literal: `Point { x: 10, y: 20 }`
|
||||||
|
StructLit {
|
||||||
|
type_name: String,
|
||||||
|
fields: Vec<(String, Expr)>,
|
||||||
|
span: Span,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Match arm ─────────────────────────────────────────────────────────────────
|
// ── Match arm ─────────────────────────────────────────────────────────────────
|
||||||
@@ -190,10 +198,12 @@ pub enum Stmt {
|
|||||||
Return(Expr, Span),
|
Return(Expr, Span),
|
||||||
/// A bare expression used as a statement (usually a call).
|
/// A bare expression used as a statement (usually a call).
|
||||||
Expr(Expr, Span),
|
Expr(Expr, Span),
|
||||||
/// `fn name(params) -> ReturnType { body }` (with optional decorators)
|
/// `fn name<T, E>(params) -> ReturnType { body }` (with optional decorators)
|
||||||
FnDef {
|
FnDef {
|
||||||
name: String,
|
name: String,
|
||||||
decorators: Vec<Decorator>,
|
decorators: Vec<Decorator>,
|
||||||
|
/// Generic type parameters, e.g. `["T", "E"]` for `fn foo<T, E>`.
|
||||||
|
type_params: Vec<String>,
|
||||||
params: Vec<Param>,
|
params: Vec<Param>,
|
||||||
return_type: TypeExpr,
|
return_type: TypeExpr,
|
||||||
body: Vec<Stmt>,
|
body: Vec<Stmt>,
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ mod error;
|
|||||||
mod parser;
|
mod parser;
|
||||||
|
|
||||||
pub use ast::{
|
pub use ast::{
|
||||||
BinOp, Expr, Field, Literal, MatchArm, Param, Pattern, Program, SeedStmt, Stmt, TestTarget,
|
BinOp, Decorator, Expr, Field, Literal, MatchArm, Param, Pattern, Program, ProtocolMethod,
|
||||||
TypeExpr, Variant,
|
SeedStmt, Stmt, TestTarget, TypeExpr, Variant,
|
||||||
};
|
};
|
||||||
pub use error::{ParseError, ParseErrorKind};
|
pub use error::{ParseError, ParseErrorKind};
|
||||||
pub use parser::parse;
|
pub use parser::parse;
|
||||||
|
|||||||
@@ -333,15 +333,28 @@ impl Parser {
|
|||||||
fn parse_fn_def(&mut self, start: Span, decorators: Vec<Decorator>) -> Result<Stmt, ParseError> {
|
fn parse_fn_def(&mut self, start: Span, decorators: Vec<Decorator>) -> Result<Stmt, ParseError> {
|
||||||
self.expect(&Token::Fn)?;
|
self.expect(&Token::Fn)?;
|
||||||
let (name, _) = self.expect_ident()?;
|
let (name, _) = self.expect_ident()?;
|
||||||
|
// Optional generic type parameters: `<T, E>`
|
||||||
|
let type_params = if self.eat(&Token::Lt) {
|
||||||
|
let mut tps = Vec::new();
|
||||||
|
while !matches!(self.peek(), Token::Gt | Token::Eof) {
|
||||||
|
let (tp, _) = self.expect_ident()?;
|
||||||
|
tps.push(tp);
|
||||||
|
if !self.eat(&Token::Comma) { break; }
|
||||||
|
}
|
||||||
|
self.expect(&Token::Gt)?;
|
||||||
|
tps
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
self.expect(&Token::LParen)?;
|
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::RParen)?;
|
||||||
self.expect(&Token::Arrow)?;
|
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)?;
|
self.expect(&Token::LBrace)?;
|
||||||
let body = self.parse_block_body()?;
|
let body = self.parse_block_body()?;
|
||||||
self.expect(&Token::RBrace)?;
|
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.
|
/// 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 })
|
Ok(Stmt::ImplDef { protocol_name, type_name, methods, span: start })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
fn parse_param_list(&mut self) -> Result<Vec<Param>, ParseError> {
|
fn parse_param_list(&mut self) -> Result<Vec<Param>, ParseError> {
|
||||||
|
self.parse_param_list_with_type_params(&[])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_param_list_with_type_params(&mut self, type_params: &[String]) -> Result<Vec<Param>, ParseError> {
|
||||||
let mut params = Vec::new();
|
let mut params = Vec::new();
|
||||||
while !matches!(self.peek(), Token::RParen | Token::Eof | Token::Pipe) {
|
while !matches!(self.peek(), Token::RParen | Token::Eof | Token::Pipe) {
|
||||||
let span = self.peek_span();
|
let span = self.peek_span();
|
||||||
let (name, _) = self.expect_ident()?;
|
let (name, _) = self.expect_ident()?;
|
||||||
self.expect(&Token::Colon)?;
|
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 });
|
params.push(Param { name, type_ann, span });
|
||||||
if !self.eat(&Token::Comma) {
|
if !self.eat(&Token::Comma) {
|
||||||
break;
|
break;
|
||||||
@@ -555,10 +573,14 @@ impl Parser {
|
|||||||
// ── Type expressions ──────────────────────────────────────────────────────
|
// ── Type expressions ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn parse_type_expr(&mut self) -> Result<TypeExpr, ParseError> {
|
fn parse_type_expr(&mut self) -> Result<TypeExpr, ParseError> {
|
||||||
|
self.parse_type_expr_with_params(&[])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_type_expr_with_params(&mut self, type_params: &[String]) -> Result<TypeExpr, ParseError> {
|
||||||
let span = self.peek_span();
|
let span = self.peek_span();
|
||||||
// Array type: [T]
|
// Array type: [T]
|
||||||
if self.eat(&Token::LBracket) {
|
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)?;
|
self.expect(&Token::RBracket)?;
|
||||||
let mut te = TypeExpr::Array(Box::new(inner));
|
let mut te = TypeExpr::Array(Box::new(inner));
|
||||||
// Optional array: [T]?
|
// Optional array: [T]?
|
||||||
@@ -580,12 +602,12 @@ impl Parser {
|
|||||||
self.expect(&Token::LParen)?;
|
self.expect(&Token::LParen)?;
|
||||||
let mut params = Vec::new();
|
let mut params = Vec::new();
|
||||||
while !matches!(self.peek(), Token::RParen | Token::Eof) {
|
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; }
|
if !self.eat(&Token::Comma) { break; }
|
||||||
}
|
}
|
||||||
self.expect(&Token::RParen)?;
|
self.expect(&Token::RParen)?;
|
||||||
self.expect(&Token::Arrow)?;
|
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) });
|
return Ok(TypeExpr::Fn { params, return_type: Box::new(ret) });
|
||||||
}
|
}
|
||||||
// Result<T, E> — built-in generic result type
|
// Result<T, E> — built-in generic result type
|
||||||
@@ -612,6 +634,10 @@ impl Parser {
|
|||||||
}
|
}
|
||||||
return Ok(te);
|
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
|
// Named type with optional ? suffix
|
||||||
let mut te = TypeExpr::Named(name);
|
let mut te = TypeExpr::Named(name);
|
||||||
if self.eat(&Token::QuestionMark) {
|
if self.eat(&Token::QuestionMark) {
|
||||||
@@ -881,7 +907,7 @@ impl Parser {
|
|||||||
Ok(Expr::If { cond: Box::new(cond), then: Box::new(then), else_ })
|
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) => {
|
Token::Ident(name) => {
|
||||||
self.advance();
|
self.advance();
|
||||||
// Check for path: Foo::Bar or Foo::Bar::Baz
|
// Check for path: Foo::Bar or Foo::Bar::Baz
|
||||||
@@ -892,6 +918,21 @@ impl Parser {
|
|||||||
segments.push(seg);
|
segments.push(seg);
|
||||||
}
|
}
|
||||||
Ok(Expr::Path { segments })
|
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 {
|
} else {
|
||||||
Ok(Expr::Ident(name))
|
Ok(Expr::Ident(name))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -307,6 +307,14 @@ impl<'g> Evaluator<'g> {
|
|||||||
Ok(EvalValue::Nil)
|
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
|
// New expression kinds — return Nil
|
||||||
_ => {
|
_ => {
|
||||||
Ok(EvalValue::Nil)
|
Ok(EvalValue::Nil)
|
||||||
|
|||||||
@@ -441,6 +441,41 @@ impl TypeChecker {
|
|||||||
Type::Map { key: Box::new(key_ty), value: Box::new(val_ty) }
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ pub struct TypeEnv {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TypeEnv {
|
impl TypeEnv {
|
||||||
|
/// Create a fresh environment pre-populated with built-in types and functions.
|
||||||
pub fn with_builtins() -> Self {
|
pub fn with_builtins() -> Self {
|
||||||
let mut env = Self::default();
|
let mut env = Self::default();
|
||||||
env.types.insert("Int".into(), TypeDef::Primitive(Type::Int));
|
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("Bool".into(), TypeDef::Primitive(Type::Bool));
|
||||||
env.types.insert("Uuid".into(), TypeDef::Primitive(Type::Uuid));
|
env.types.insert("Uuid".into(), TypeDef::Primitive(Type::Uuid));
|
||||||
env.types.insert("Void".into(), TypeDef::Primitive(Type::Void));
|
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
|
env
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,6 +286,11 @@ impl TypeEnv {
|
|||||||
let val_ty = self.resolve_type_expr(value)?;
|
let val_ty = self.resolve_type_expr(value)?;
|
||||||
Ok(Type::Map { key: Box::new(key_ty), value: Box::new(val_ty) })
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "el-wasm"
|
||||||
|
description = "Engram language WebAssembly runtime — runs .el programs natively in browsers"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
el-compiler = { workspace = true }
|
||||||
|
wasm-bindgen = { workspace = true, optional = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
getrandom = { workspace = true, optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# Enable the wasm-bindgen JS API. Pass `--features wasm` to wasm-pack.
|
||||||
|
wasm = ["dep:wasm-bindgen", "dep:getrandom"]
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
wasm-bindgen-test = "0.3"
|
||||||
Executable
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build the engram-lang WASM package.
|
||||||
|
#
|
||||||
|
# Requires wasm-pack:
|
||||||
|
# cargo install wasm-pack
|
||||||
|
#
|
||||||
|
# Output: crates/el-wasm/pkg/
|
||||||
|
# el_wasm_bg.wasm — the compiled WebAssembly module
|
||||||
|
# el_wasm.js — ES module JS bindings generated by wasm-bindgen
|
||||||
|
# el_wasm.d.ts — TypeScript type definitions
|
||||||
|
# package.json — npm package metadata
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "Building engram-lang WASM runtime..."
|
||||||
|
wasm-pack build --target web --out-dir pkg -- --features wasm
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done. Output in crates/el-wasm/pkg/"
|
||||||
|
echo ""
|
||||||
|
ls -lh pkg/*.wasm pkg/*.js 2>/dev/null || ls pkg/
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "Neuron",
|
||||||
|
"short_name": "Neuron",
|
||||||
|
"description": "Neuron — AI companion powered by engram-lang",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0a0a0f",
|
||||||
|
"theme_color": "#6c7fff",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||||
|
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||||
|
],
|
||||||
|
"categories": ["productivity", "utilities"],
|
||||||
|
"lang": "en-US"
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Engram-lang WASM runtime loader
|
||||||
|
// Include this in any web app to run .el programs natively in the browser.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// import { initRuntime, runElc, evalSource } from '/js/runtime.js';
|
||||||
|
//
|
||||||
|
// await initRuntime();
|
||||||
|
// const result = await runElc('/programs/main.elc');
|
||||||
|
|
||||||
|
let wasmModule = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise the engram-lang WASM runtime. Safe to call multiple times —
|
||||||
|
* subsequent calls return the already-loaded module immediately.
|
||||||
|
*
|
||||||
|
* @param {string} wasmUrl - Path to the .wasm file (default: /pkg/el_wasm_bg.wasm)
|
||||||
|
* @returns {Promise<object>} The loaded runtime API
|
||||||
|
*/
|
||||||
|
export async function initRuntime(wasmUrl = '/pkg/el_wasm_bg.wasm') {
|
||||||
|
if (wasmModule) return wasmModule;
|
||||||
|
|
||||||
|
const { default: init, compile_source, load_and_run, eval: elEval, version } =
|
||||||
|
await import('/pkg/el_wasm.js');
|
||||||
|
|
||||||
|
await init(wasmUrl);
|
||||||
|
|
||||||
|
wasmModule = { compile_source, load_and_run, eval: elEval, version };
|
||||||
|
console.log(`engram-lang WASM runtime v${version()} loaded`);
|
||||||
|
return wasmModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a pre-compiled .elc file from the server and execute it.
|
||||||
|
*
|
||||||
|
* The browser caches the .elc file automatically based on Cache-Control
|
||||||
|
* headers set by the server. New bytecode is available immediately on the
|
||||||
|
* next fetch — no app-store review required.
|
||||||
|
*
|
||||||
|
* @param {string} elcUrl - URL of the .elc bytecode file
|
||||||
|
* @returns {Promise<any>} The JSON-deserialised result value
|
||||||
|
*/
|
||||||
|
export async function runElc(elcUrl) {
|
||||||
|
const rt = await initRuntime();
|
||||||
|
const response = await fetch(elcUrl);
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch ${elcUrl}: ${response.status}`);
|
||||||
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||||
|
return JSON.parse(rt.load_and_run(bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile engram-lang source and run it immediately. Useful for REPL and
|
||||||
|
* developer-mode execution where source is available at runtime.
|
||||||
|
*
|
||||||
|
* @param {string} source - Engram-lang source code
|
||||||
|
* @returns {Promise<any>} The JSON-deserialised result value
|
||||||
|
*/
|
||||||
|
export async function evalSource(source) {
|
||||||
|
const rt = await initRuntime();
|
||||||
|
return JSON.parse(rt.eval(source));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile engram-lang source to bytecode bytes without running it.
|
||||||
|
* The returned Uint8Array can be stored or uploaded as a .elc file.
|
||||||
|
*
|
||||||
|
* @param {string} source - Engram-lang source code
|
||||||
|
* @returns {Promise<Uint8Array>} Compiled bytecode bytes
|
||||||
|
*/
|
||||||
|
export async function compileSource(source) {
|
||||||
|
const rt = await initRuntime();
|
||||||
|
return rt.compile_source(source);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Service worker for the Neuron PWA powered by engram-lang.
|
||||||
|
//
|
||||||
|
// Caching strategy:
|
||||||
|
// /pkg/ — WASM runtime files, cache-first (content-addressed, change on version bump)
|
||||||
|
// *.elc — compiled bytecode, stale-while-revalidate (instant load, background update)
|
||||||
|
//
|
||||||
|
// The WASM runtime is fetched once and cached indefinitely.
|
||||||
|
// Engram programs (.elc) are served from cache immediately, then refreshed in
|
||||||
|
// the background so the next load gets the newest version — no user action needed.
|
||||||
|
|
||||||
|
const CACHE_NAME = 'engram-v1';
|
||||||
|
const WASM_CACHE = 'engram-wasm-v1';
|
||||||
|
|
||||||
|
// Files to pre-cache during service worker installation.
|
||||||
|
const PRECACHE = [
|
||||||
|
'/pkg/el_wasm_bg.wasm',
|
||||||
|
'/pkg/el_wasm.js',
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', event => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(WASM_CACHE).then(cache => cache.addAll(PRECACHE))
|
||||||
|
);
|
||||||
|
// Take control immediately — don't wait for existing tabs to close.
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', event => {
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
|
||||||
|
// WASM runtime files: cache-first.
|
||||||
|
// These are large and rarely change; the version bump forces a new URL.
|
||||||
|
if (url.pathname.startsWith('/pkg/')) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request).then(cached =>
|
||||||
|
cached || fetch(event.request).then(response => {
|
||||||
|
caches.open(WASM_CACHE).then(cache =>
|
||||||
|
cache.put(event.request, response.clone())
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compiled bytecode (.elc): stale-while-revalidate.
|
||||||
|
// Respond immediately from cache, update in background.
|
||||||
|
if (url.pathname.endsWith('.elc')) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.open(CACHE_NAME).then(async cache => {
|
||||||
|
const cached = await cache.match(event.request);
|
||||||
|
const fetchPromise = fetch(event.request).then(response => {
|
||||||
|
cache.put(event.request, response.clone());
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
return cached || fetchPromise;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', event => {
|
||||||
|
// Purge old cache versions to reclaim storage.
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then(keys =>
|
||||||
|
Promise.all(
|
||||||
|
keys
|
||||||
|
.filter(k => k !== CACHE_NAME && k !== WASM_CACHE)
|
||||||
|
.map(k => caches.delete(k))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// Claim all clients immediately.
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
@@ -0,0 +1,735 @@
|
|||||||
|
//! el-wasm — Engram language WebAssembly runtime.
|
||||||
|
//!
|
||||||
|
//! Compiles the engram-lang compiler and (optionally) execution pipeline to
|
||||||
|
//! `wasm32-unknown-unknown`, exposing a JavaScript API via `wasm-bindgen`.
|
||||||
|
//!
|
||||||
|
//! # Build for browsers
|
||||||
|
//!
|
||||||
|
//! ```bash
|
||||||
|
//! wasm-pack build --target web --out-dir pkg -- --features wasm
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! # JavaScript API
|
||||||
|
//!
|
||||||
|
//! ```js
|
||||||
|
//! import init, { compile_source, load_and_run, eval, version } from '/pkg/el_wasm.js';
|
||||||
|
//! await init();
|
||||||
|
//! const result = eval('1 + 2'); // => "3"
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! # Architecture
|
||||||
|
//!
|
||||||
|
//! The WASM module exposes three entry points:
|
||||||
|
//!
|
||||||
|
//! - **`compile_source`** — source → `.elc` bytes (serialised bytecode)
|
||||||
|
//! - **`load_and_run`** — `.elc` bytes → JSON-encoded result value
|
||||||
|
//! - **`eval`** — source → JSON-encoded result value (compile + run in one step)
|
||||||
|
//!
|
||||||
|
//! The browser caches the `.wasm` file after the first load. Programs are
|
||||||
|
//! distributed as tiny `.elc` bytecode files fetched on demand, enabling a
|
||||||
|
//! PWA strategy that bypasses app-store review cycles.
|
||||||
|
|
||||||
|
pub use el_compiler::{
|
||||||
|
compile_to_bytecode, deserialize_bytecode, serialize_bytecode, Bytecode, CompileError, Value,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── WASM bindings ─────────────────────────────────────────────────────────────
|
||||||
|
// Only compiled when the `wasm` feature is active (i.e. wasm-pack builds).
|
||||||
|
|
||||||
|
#[cfg(feature = "wasm")]
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
|
/// Initialize the WASM module. Call once from JavaScript before any other API.
|
||||||
|
///
|
||||||
|
/// Sets up the panic hook so Rust panics appear as readable messages in the
|
||||||
|
/// browser developer console rather than opaque `unreachable` traps.
|
||||||
|
#[cfg(feature = "wasm")]
|
||||||
|
#[wasm_bindgen(start)]
|
||||||
|
pub fn init() {
|
||||||
|
// Redirect Rust panics to console.error in the browser.
|
||||||
|
std::panic::set_hook(Box::new(console_error_panic_hook));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forward panics to the browser console.
|
||||||
|
#[cfg(feature = "wasm")]
|
||||||
|
fn console_error_panic_hook(info: &std::panic::PanicHookInfo<'_>) {
|
||||||
|
let msg = info.to_string();
|
||||||
|
web_sys_log(&msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "wasm")]
|
||||||
|
#[wasm_bindgen]
|
||||||
|
extern "C" {
|
||||||
|
#[wasm_bindgen(js_namespace = console, js_name = error)]
|
||||||
|
fn web_sys_log(s: &str);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compile engram-lang source code to bytecode bytes (`.elc` format).
|
||||||
|
///
|
||||||
|
/// Returns the raw bytecode bytes on success, or throws a JS error string
|
||||||
|
/// describing the first compilation error.
|
||||||
|
///
|
||||||
|
/// The returned bytes can be cached by the browser and later passed to
|
||||||
|
/// `load_and_run` to execute the program.
|
||||||
|
#[cfg(feature = "wasm")]
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn compile_source(source: &str) -> Result<Vec<u8>, JsValue> {
|
||||||
|
compile_source_inner(source).map_err(|e| JsValue::from_str(&e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load pre-compiled bytecode (`.elc` bytes) and execute it.
|
||||||
|
///
|
||||||
|
/// Returns the JSON-encoded final value from the program, or throws on error.
|
||||||
|
/// The result is always valid JSON — use `JSON.parse(result)` in JavaScript.
|
||||||
|
#[cfg(feature = "wasm")]
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn load_and_run(bytecode_bytes: &[u8]) -> Result<String, JsValue> {
|
||||||
|
load_and_run_inner(bytecode_bytes).map_err(|e| JsValue::from_str(&e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compile and run engram-lang source in one step.
|
||||||
|
///
|
||||||
|
/// Equivalent to `load_and_run(compile_source(source))`. Useful for REPL
|
||||||
|
/// and developer-mode execution where the source is available at runtime.
|
||||||
|
///
|
||||||
|
/// Returns the JSON-encoded result, or throws a descriptive error string.
|
||||||
|
#[cfg(feature = "wasm")]
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn eval(source: &str) -> Result<String, JsValue> {
|
||||||
|
let bytes = compile_source_inner(source).map_err(|e| JsValue::from_str(&e))?;
|
||||||
|
load_and_run_inner(&bytes).map_err(|e| JsValue::from_str(&e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the engram-lang runtime version string.
|
||||||
|
#[cfg(feature = "wasm")]
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn version() -> String {
|
||||||
|
env!("CARGO_PKG_VERSION").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inner implementations (callable from Rust tests without wasm-bindgen) ─────
|
||||||
|
|
||||||
|
/// Compile source to `.elc` bytes. Returns `Err(String)` on failure.
|
||||||
|
pub fn compile_source_inner(source: &str) -> Result<Vec<u8>, String> {
|
||||||
|
let (bytecode, _source_map) =
|
||||||
|
compile_to_bytecode(source).map_err(|e| e.to_string())?;
|
||||||
|
serialize_bytecode(&bytecode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserialise `.elc` bytes, execute the bytecode, return a JSON-encoded Value.
|
||||||
|
///
|
||||||
|
/// The result is always clean JSON: integers as numbers, strings as strings,
|
||||||
|
/// booleans as booleans, nil as null, lists as arrays, maps as objects.
|
||||||
|
pub fn load_and_run_inner(bytecode_bytes: &[u8]) -> Result<String, String> {
|
||||||
|
let bytecode = deserialize_bytecode(bytecode_bytes)?;
|
||||||
|
let result = run_bytecode(&bytecode)?;
|
||||||
|
let json_value = value_to_json(&result);
|
||||||
|
serde_json::to_string(&json_value).map_err(|e| format!("Serialize result error: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert an engram `Value` to a clean `serde_json::Value` for JS consumption.
|
||||||
|
///
|
||||||
|
/// Maps engram types to natural JSON equivalents:
|
||||||
|
/// - `Int` → JSON number
|
||||||
|
/// - `Float` → JSON number
|
||||||
|
/// - `Str` → JSON string
|
||||||
|
/// - `Bool` → JSON boolean
|
||||||
|
/// - `Nil` → JSON null
|
||||||
|
/// - `List` → JSON array
|
||||||
|
/// - `Map` → JSON object
|
||||||
|
/// - `ResultOk(v)` → `{"ok": v}`
|
||||||
|
/// - `ResultErr(e)` → `{"err": e}`
|
||||||
|
pub fn value_to_json(v: &Value) -> serde_json::Value {
|
||||||
|
match v {
|
||||||
|
Value::Int(n) => serde_json::Value::Number(serde_json::Number::from(*n)),
|
||||||
|
Value::Float(f) => serde_json::Number::from_f64(*f)
|
||||||
|
.map(serde_json::Value::Number)
|
||||||
|
.unwrap_or(serde_json::Value::Null),
|
||||||
|
Value::Str(s) => serde_json::Value::String(s.clone()),
|
||||||
|
Value::Bool(b) => serde_json::Value::Bool(*b),
|
||||||
|
Value::Nil => serde_json::Value::Null,
|
||||||
|
Value::List(items) => {
|
||||||
|
serde_json::Value::Array(items.iter().map(value_to_json).collect())
|
||||||
|
}
|
||||||
|
Value::Map(pairs) => {
|
||||||
|
let obj: serde_json::Map<String, serde_json::Value> = pairs
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), value_to_json(v)))
|
||||||
|
.collect();
|
||||||
|
serde_json::Value::Object(obj)
|
||||||
|
}
|
||||||
|
Value::ResultOk(inner) => {
|
||||||
|
serde_json::json!({ "ok": value_to_json(inner) })
|
||||||
|
}
|
||||||
|
Value::ResultErr(inner) => {
|
||||||
|
serde_json::json!({ "err": value_to_json(inner) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a bytecode program on the engram stack machine.
|
||||||
|
///
|
||||||
|
/// Returns the value left on the stack when `Halt` is reached, or `Value::Nil`
|
||||||
|
/// if the program is empty.
|
||||||
|
///
|
||||||
|
/// # Supported instructions
|
||||||
|
///
|
||||||
|
/// This is a pure stack machine — no I/O, no filesystem, no OS interaction —
|
||||||
|
/// which makes it safe to run inside WASM. Instructions that reference the
|
||||||
|
/// Engram runtime (`Activate`) return a placeholder `Nil` value; a full
|
||||||
|
/// runtime integration would supply a callback from JS.
|
||||||
|
pub fn run_bytecode(bytecode: &[Bytecode]) -> Result<Value, String> {
|
||||||
|
let mut stack: Vec<Value> = Vec::new();
|
||||||
|
// Local variable environment (flat scope for now).
|
||||||
|
let mut locals: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
|
||||||
|
let mut ip: usize = 0;
|
||||||
|
|
||||||
|
while ip < bytecode.len() {
|
||||||
|
let instr = &bytecode[ip];
|
||||||
|
match instr {
|
||||||
|
// ── Stack ─────────────────────────────────────────────────────────
|
||||||
|
Bytecode::Push(v) => {
|
||||||
|
stack.push(v.clone());
|
||||||
|
}
|
||||||
|
Bytecode::Pop => {
|
||||||
|
stack.pop();
|
||||||
|
}
|
||||||
|
Bytecode::Dup => {
|
||||||
|
let top = stack.last().ok_or("DUP on empty stack")?.clone();
|
||||||
|
stack.push(top);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Arithmetic ────────────────────────────────────────────────────
|
||||||
|
Bytecode::Add => {
|
||||||
|
let (a, b) = pop2(&mut stack)?;
|
||||||
|
stack.push(arith_add(a, b)?);
|
||||||
|
}
|
||||||
|
Bytecode::Sub => {
|
||||||
|
let (a, b) = pop2(&mut stack)?;
|
||||||
|
stack.push(arith_sub(a, b)?);
|
||||||
|
}
|
||||||
|
Bytecode::Mul => {
|
||||||
|
let (a, b) = pop2(&mut stack)?;
|
||||||
|
stack.push(arith_mul(a, b)?);
|
||||||
|
}
|
||||||
|
Bytecode::Div => {
|
||||||
|
let (a, b) = pop2(&mut stack)?;
|
||||||
|
stack.push(arith_div(a, b)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Comparison ────────────────────────────────────────────────────
|
||||||
|
Bytecode::Eq => {
|
||||||
|
let (a, b) = pop2(&mut stack)?;
|
||||||
|
stack.push(Value::Bool(values_eq(&a, &b)));
|
||||||
|
}
|
||||||
|
Bytecode::NotEq => {
|
||||||
|
let (a, b) = pop2(&mut stack)?;
|
||||||
|
stack.push(Value::Bool(!values_eq(&a, &b)));
|
||||||
|
}
|
||||||
|
Bytecode::Lt => {
|
||||||
|
let (a, b) = pop2(&mut stack)?;
|
||||||
|
stack.push(Value::Bool(cmp_values(&a, &b)? < 0));
|
||||||
|
}
|
||||||
|
Bytecode::Gt => {
|
||||||
|
let (a, b) = pop2(&mut stack)?;
|
||||||
|
stack.push(Value::Bool(cmp_values(&a, &b)? > 0));
|
||||||
|
}
|
||||||
|
Bytecode::LtEq => {
|
||||||
|
let (a, b) = pop2(&mut stack)?;
|
||||||
|
stack.push(Value::Bool(cmp_values(&a, &b)? <= 0));
|
||||||
|
}
|
||||||
|
Bytecode::GtEq => {
|
||||||
|
let (a, b) = pop2(&mut stack)?;
|
||||||
|
stack.push(Value::Bool(cmp_values(&a, &b)? >= 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Logical ───────────────────────────────────────────────────────
|
||||||
|
Bytecode::And => {
|
||||||
|
let (a, b) = pop2(&mut stack)?;
|
||||||
|
stack.push(Value::Bool(is_truthy(&a) && is_truthy(&b)));
|
||||||
|
}
|
||||||
|
Bytecode::Or => {
|
||||||
|
let (a, b) = pop2(&mut stack)?;
|
||||||
|
stack.push(Value::Bool(is_truthy(&a) || is_truthy(&b)));
|
||||||
|
}
|
||||||
|
Bytecode::Not => {
|
||||||
|
let v = stack.pop().ok_or("NOT on empty stack")?;
|
||||||
|
stack.push(Value::Bool(!is_truthy(&v)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Locals ────────────────────────────────────────────────────────
|
||||||
|
Bytecode::LoadLocal(name) => {
|
||||||
|
let v = locals.get(name).cloned().unwrap_or(Value::Nil);
|
||||||
|
stack.push(v);
|
||||||
|
}
|
||||||
|
Bytecode::StoreLocal(name) => {
|
||||||
|
let v = stack.pop().ok_or("STORE on empty stack")?;
|
||||||
|
locals.insert(name.clone(), v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Functions ─────────────────────────────────────────────────────
|
||||||
|
// The bytecode model stores function bodies inline and registers entry
|
||||||
|
// points as locals (`__fn_<name>`). A full call-frame implementation
|
||||||
|
// would use a separate call stack; for WASM we handle the most common
|
||||||
|
// case of stdlib builtins and leave dynamic dispatch as a stub.
|
||||||
|
Bytecode::Call { name, arity } => {
|
||||||
|
let result = call_builtin(name, *arity, &mut stack)?;
|
||||||
|
stack.push(result);
|
||||||
|
}
|
||||||
|
Bytecode::Return => {
|
||||||
|
// Return leaves the value on the stack; the caller pops it.
|
||||||
|
// In this simplified VM we just continue execution.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Control flow ──────────────────────────────────────────────────
|
||||||
|
Bytecode::Jump(offset) => {
|
||||||
|
ip = apply_offset(ip, *offset)?;
|
||||||
|
continue; // skip ip += 1 below
|
||||||
|
}
|
||||||
|
Bytecode::JumpIf(offset) => {
|
||||||
|
let v = stack.pop().ok_or("JUMPIF on empty stack")?;
|
||||||
|
if is_truthy(&v) {
|
||||||
|
ip = apply_offset(ip, *offset)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Bytecode::JumpIfNot(offset) => {
|
||||||
|
let v = stack.pop().ok_or("JUMPIFNOT on empty stack")?;
|
||||||
|
if !is_truthy(&v) {
|
||||||
|
ip = apply_offset(ip, *offset)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fields & Indexing ─────────────────────────────────────────────
|
||||||
|
Bytecode::GetField(field) => {
|
||||||
|
let obj = stack.pop().ok_or("GETFIELD on empty stack")?;
|
||||||
|
let v = match &obj {
|
||||||
|
Value::Map(pairs) => pairs
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _v)| k == field)
|
||||||
|
.map(|(_k, v)| v.clone())
|
||||||
|
.unwrap_or(Value::Nil),
|
||||||
|
_ => Value::Nil,
|
||||||
|
};
|
||||||
|
stack.push(v);
|
||||||
|
}
|
||||||
|
Bytecode::GetIndex => {
|
||||||
|
let idx = stack.pop().ok_or("GETINDEX: missing index")?;
|
||||||
|
let obj = stack.pop().ok_or("GETINDEX: missing object")?;
|
||||||
|
let v = match (&obj, &idx) {
|
||||||
|
(Value::List(items), Value::Int(i)) => {
|
||||||
|
let i = *i as usize;
|
||||||
|
items.get(i).cloned().unwrap_or(Value::Nil)
|
||||||
|
}
|
||||||
|
_ => Value::Nil,
|
||||||
|
};
|
||||||
|
stack.push(v);
|
||||||
|
}
|
||||||
|
Bytecode::BuildMap(n) => {
|
||||||
|
let mut pairs = Vec::new();
|
||||||
|
let n = *n as usize;
|
||||||
|
// Stack: key0, val0, key1, val1, ... (pushed in order)
|
||||||
|
// We collect from the top, so reverse at the end.
|
||||||
|
let start = stack.len().saturating_sub(n * 2);
|
||||||
|
let raw: Vec<Value> = stack.drain(start..).collect();
|
||||||
|
for chunk in raw.chunks(2) {
|
||||||
|
if let [Value::Str(k), v] = chunk {
|
||||||
|
pairs.push((k.clone(), v.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stack.push(Value::Map(pairs));
|
||||||
|
}
|
||||||
|
Bytecode::BuildStruct { fields, .. } => {
|
||||||
|
let mut pairs: Vec<(String, Value)> = Vec::new();
|
||||||
|
let start = stack.len().saturating_sub(fields.len());
|
||||||
|
let raw: Vec<Value> = stack.drain(start..).collect();
|
||||||
|
for (field, val) in fields.iter().zip(raw.into_iter()) {
|
||||||
|
pairs.push((field.clone(), val));
|
||||||
|
}
|
||||||
|
stack.push(Value::Map(pairs));
|
||||||
|
}
|
||||||
|
Bytecode::SetField(field) => {
|
||||||
|
let val = stack.pop().ok_or("SETFIELD: missing value")?;
|
||||||
|
let obj = stack.pop().ok_or("SETFIELD: missing object")?;
|
||||||
|
let v = match obj {
|
||||||
|
Value::Map(mut pairs) => {
|
||||||
|
if let Some(entry) = pairs.iter_mut().find(|(k, _)| k == field) {
|
||||||
|
entry.1 = val;
|
||||||
|
} else {
|
||||||
|
pairs.push((field.clone(), val));
|
||||||
|
}
|
||||||
|
Value::Map(pairs)
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
};
|
||||||
|
stack.push(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Special ───────────────────────────────────────────────────────
|
||||||
|
Bytecode::Activate { type_name, query } => {
|
||||||
|
// The Engram runtime integration is provided by the host JS environment.
|
||||||
|
// In a full implementation the JS host would register an `activate` callback.
|
||||||
|
// For now, return a placeholder list so programs using `activate` don't crash.
|
||||||
|
let _ = (type_name, query);
|
||||||
|
stack.push(Value::List(Vec::new()));
|
||||||
|
}
|
||||||
|
Bytecode::SealedBegin | Bytecode::SealedEnd | Bytecode::Nop => {
|
||||||
|
// No-ops in the pure VM.
|
||||||
|
}
|
||||||
|
Bytecode::Halt => {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ip += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(stack.pop().unwrap_or(Value::Nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stack helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn pop2(stack: &mut Vec<Value>) -> Result<(Value, Value), String> {
|
||||||
|
let b = stack.pop().ok_or("stack underflow (right operand)")?;
|
||||||
|
let a = stack.pop().ok_or("stack underflow (left operand)")?;
|
||||||
|
Ok((a, b))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_offset(ip: usize, offset: i32) -> Result<usize, String> {
|
||||||
|
// offset is relative to the instruction *after* the jump
|
||||||
|
let target = (ip as i64) + 1 + (offset as i64);
|
||||||
|
if target < 0 {
|
||||||
|
return Err(format!("Jump to negative address {target}"));
|
||||||
|
}
|
||||||
|
Ok(target as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Value helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn is_truthy(v: &Value) -> bool {
|
||||||
|
match v {
|
||||||
|
Value::Bool(b) => *b,
|
||||||
|
Value::Nil => false,
|
||||||
|
Value::Int(0) => false,
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn values_eq(a: &Value, b: &Value) -> bool {
|
||||||
|
match (a, b) {
|
||||||
|
(Value::Int(x), Value::Int(y)) => x == y,
|
||||||
|
(Value::Float(x), Value::Float(y)) => x == y,
|
||||||
|
(Value::Str(x), Value::Str(y)) => x == y,
|
||||||
|
(Value::Bool(x), Value::Bool(y)) => x == y,
|
||||||
|
(Value::Nil, Value::Nil) => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare two values; returns negative / zero / positive like `Ord::cmp`.
|
||||||
|
fn cmp_values(a: &Value, b: &Value) -> Result<i32, String> {
|
||||||
|
match (a, b) {
|
||||||
|
(Value::Int(x), Value::Int(y)) => Ok(x.cmp(y) as i32),
|
||||||
|
(Value::Float(x), Value::Float(y)) => Ok(x.partial_cmp(y).map(|o| o as i32).unwrap_or(0)),
|
||||||
|
(Value::Str(x), Value::Str(y)) => Ok(x.cmp(y) as i32),
|
||||||
|
_ => Err(format!("Cannot compare {a:?} and {b:?}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Arithmetic helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn arith_add(a: Value, b: Value) -> Result<Value, String> {
|
||||||
|
match (a, b) {
|
||||||
|
(Value::Int(x), Value::Int(y)) => Ok(Value::Int(x.wrapping_add(y))),
|
||||||
|
(Value::Float(x), Value::Float(y)) => Ok(Value::Float(x + y)),
|
||||||
|
(Value::Str(x), Value::Str(y)) => Ok(Value::Str(x + &y)),
|
||||||
|
(a, b) => Err(format!("ADD: type mismatch {a:?} + {b:?}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arith_sub(a: Value, b: Value) -> Result<Value, String> {
|
||||||
|
match (a, b) {
|
||||||
|
(Value::Int(x), Value::Int(y)) => Ok(Value::Int(x.wrapping_sub(y))),
|
||||||
|
(Value::Float(x), Value::Float(y)) => Ok(Value::Float(x - y)),
|
||||||
|
(a, b) => Err(format!("SUB: type mismatch {a:?} - {b:?}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arith_mul(a: Value, b: Value) -> Result<Value, String> {
|
||||||
|
match (a, b) {
|
||||||
|
(Value::Int(x), Value::Int(y)) => Ok(Value::Int(x.wrapping_mul(y))),
|
||||||
|
(Value::Float(x), Value::Float(y)) => Ok(Value::Float(x * y)),
|
||||||
|
(a, b) => Err(format!("MUL: type mismatch {a:?} * {b:?}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arith_div(a: Value, b: Value) -> Result<Value, String> {
|
||||||
|
match (a, b) {
|
||||||
|
(Value::Int(_), Value::Int(0)) => Err("Division by zero".to_string()),
|
||||||
|
(Value::Int(x), Value::Int(y)) => Ok(Value::Int(x / y)),
|
||||||
|
(Value::Float(x), Value::Float(y)) => Ok(Value::Float(x / y)),
|
||||||
|
(a, b) => Err(format!("DIV: type mismatch {a:?} / {b:?}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Builtin function dispatch ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn call_builtin(name: &str, arity: u32, stack: &mut Vec<Value>) -> Result<Value, String> {
|
||||||
|
match name {
|
||||||
|
"__build_list__" => {
|
||||||
|
let n = arity as usize;
|
||||||
|
let start = stack.len().saturating_sub(n);
|
||||||
|
let items: Vec<Value> = stack.drain(start..).collect();
|
||||||
|
Ok(Value::List(items))
|
||||||
|
}
|
||||||
|
"print" | "println" => {
|
||||||
|
// In WASM, print is a no-op unless the host wires up a callback.
|
||||||
|
let n = arity as usize;
|
||||||
|
let start = stack.len().saturating_sub(n);
|
||||||
|
let _args: Vec<Value> = stack.drain(start..).collect();
|
||||||
|
Ok(Value::Nil)
|
||||||
|
}
|
||||||
|
"len" => {
|
||||||
|
let n = arity as usize;
|
||||||
|
let start = stack.len().saturating_sub(n);
|
||||||
|
let mut args: Vec<Value> = stack.drain(start..).collect();
|
||||||
|
let v = args.pop().unwrap_or(Value::Nil);
|
||||||
|
let len = match &v {
|
||||||
|
Value::List(items) => items.len() as i64,
|
||||||
|
Value::Str(s) => s.len() as i64,
|
||||||
|
Value::Map(pairs) => pairs.len() as i64,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
Ok(Value::Int(len))
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Unknown function: consume args, return Nil.
|
||||||
|
let n = arity as usize;
|
||||||
|
let start = stack.len().saturating_sub(n);
|
||||||
|
let _: Vec<Value> = stack.drain(start..).collect();
|
||||||
|
Ok(Value::Nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ── Compile pipeline tests ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compile_source_produces_bytes() {
|
||||||
|
let bytes = compile_source_inner("42").unwrap();
|
||||||
|
assert!(!bytes.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_roundtrip_bytecode_serialization() {
|
||||||
|
let source = "let x = 1 + 2";
|
||||||
|
let bytes = compile_source_inner(source).unwrap();
|
||||||
|
let (original, _) = compile_to_bytecode(source).unwrap();
|
||||||
|
let restored = deserialize_bytecode(&bytes).unwrap();
|
||||||
|
assert_eq!(original, restored);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compile_function_def() {
|
||||||
|
let source = r#"fn add(a: Int, b: Int) -> Int { a + b }"#;
|
||||||
|
let bytes = compile_source_inner(source).unwrap();
|
||||||
|
assert!(!bytes.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compile_activate() {
|
||||||
|
let source = r#"activate User where "active users""#;
|
||||||
|
let (bytecode, _) = compile_to_bytecode(source).unwrap();
|
||||||
|
assert!(bytecode
|
||||||
|
.iter()
|
||||||
|
.any(|b| matches!(b, Bytecode::Activate { .. })));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serialize_deserialize_activate() {
|
||||||
|
let source = r#"activate User where "query""#;
|
||||||
|
let bytes = compile_source_inner(source).unwrap();
|
||||||
|
let restored = deserialize_bytecode(&bytes).unwrap();
|
||||||
|
assert!(restored
|
||||||
|
.iter()
|
||||||
|
.any(|b| matches!(b, Bytecode::Activate { .. })));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compile_sealed_block() {
|
||||||
|
let source = "sealed { let x = 1 }";
|
||||||
|
let (bytecode, _) = compile_to_bytecode(source).unwrap();
|
||||||
|
assert!(bytecode
|
||||||
|
.iter()
|
||||||
|
.any(|b| matches!(b, Bytecode::SealedBegin)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_program_compiles() {
|
||||||
|
let source = "";
|
||||||
|
let (bytecode, _) = compile_to_bytecode(source).unwrap();
|
||||||
|
assert!(matches!(bytecode.last(), Some(Bytecode::Halt)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_complex_program_compiles() {
|
||||||
|
let source = r#"
|
||||||
|
let x = 10
|
||||||
|
let y = 20
|
||||||
|
let z = x + y
|
||||||
|
"#;
|
||||||
|
let (bytecode, _) = compile_to_bytecode(source).unwrap();
|
||||||
|
assert!(!bytecode.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bytecode_json_is_valid() {
|
||||||
|
let bytes = compile_source_inner("1 + 2").unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
||||||
|
assert!(json.is_array());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_version_string() {
|
||||||
|
assert!(!env!("CARGO_PKG_VERSION").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── VM execution tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_run_integer_literal() {
|
||||||
|
let result = load_and_run_inner(&compile_source_inner("42").unwrap()).unwrap();
|
||||||
|
// The final value on the stack is the integer 42.
|
||||||
|
assert_eq!(result, "42");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_run_addition() {
|
||||||
|
let result = load_and_run_inner(&compile_source_inner("1 + 2").unwrap()).unwrap();
|
||||||
|
assert_eq!(result, "3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_run_string_literal() {
|
||||||
|
let result =
|
||||||
|
load_and_run_inner(&compile_source_inner(r#""hello""#).unwrap()).unwrap();
|
||||||
|
assert_eq!(result, r#""hello""#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_run_boolean() {
|
||||||
|
let result = load_and_run_inner(&compile_source_inner("true").unwrap()).unwrap();
|
||||||
|
assert_eq!(result, "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_run_let_binding_and_use() {
|
||||||
|
let source = "let x = 10\nx";
|
||||||
|
let result = load_and_run_inner(&compile_source_inner(source).unwrap()).unwrap();
|
||||||
|
assert_eq!(result, "10");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_run_arithmetic_chain() {
|
||||||
|
// 2 * 3 + 4 should be 10 (if parsed left-to-right)
|
||||||
|
let source = "2 * 3";
|
||||||
|
let result = load_and_run_inner(&compile_source_inner(source).unwrap()).unwrap();
|
||||||
|
assert_eq!(result, "6");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_run_activate_returns_list() {
|
||||||
|
let source = r#"activate User where "all""#;
|
||||||
|
let result = load_and_run_inner(&compile_source_inner(source).unwrap()).unwrap();
|
||||||
|
// Activate returns an empty list placeholder in the pure VM.
|
||||||
|
assert_eq!(result, "[]");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_run_if_true_branch() {
|
||||||
|
let source = "if true { 1 } else { 2 }";
|
||||||
|
let result = load_and_run_inner(&compile_source_inner(source).unwrap()).unwrap();
|
||||||
|
assert_eq!(result, "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_run_if_false_branch() {
|
||||||
|
let source = "if false { 1 } else { 2 }";
|
||||||
|
let result = load_and_run_inner(&compile_source_inner(source).unwrap()).unwrap();
|
||||||
|
assert_eq!(result, "2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_run_comparison_eq() {
|
||||||
|
let result = load_and_run_inner(&compile_source_inner("1 == 1").unwrap()).unwrap();
|
||||||
|
assert_eq!(result, "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_run_comparison_neq() {
|
||||||
|
let result = load_and_run_inner(&compile_source_inner("1 != 2").unwrap()).unwrap();
|
||||||
|
assert_eq!(result, "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_direct_run_empty_bytecode() {
|
||||||
|
let result = run_bytecode(&[]).unwrap();
|
||||||
|
assert_eq!(result, Value::Nil);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_direct_run_halt_only() {
|
||||||
|
let result = run_bytecode(&[Bytecode::Halt]).unwrap();
|
||||||
|
assert_eq!(result, Value::Nil);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_direct_run_push_halt() {
|
||||||
|
let result = run_bytecode(&[Bytecode::Push(Value::Int(99)), Bytecode::Halt]).unwrap();
|
||||||
|
assert_eq!(result, Value::Int(99));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_direct_run_add() {
|
||||||
|
let bc = [
|
||||||
|
Bytecode::Push(Value::Int(3)),
|
||||||
|
Bytecode::Push(Value::Int(4)),
|
||||||
|
Bytecode::Add,
|
||||||
|
Bytecode::Halt,
|
||||||
|
];
|
||||||
|
let result = run_bytecode(&bc).unwrap();
|
||||||
|
assert_eq!(result, Value::Int(7));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_direct_run_string_concat() {
|
||||||
|
let bc = [
|
||||||
|
Bytecode::Push(Value::Str("hello ".to_string())),
|
||||||
|
Bytecode::Push(Value::Str("world".to_string())),
|
||||||
|
Bytecode::Add,
|
||||||
|
Bytecode::Halt,
|
||||||
|
];
|
||||||
|
let result = run_bytecode(&bc).unwrap();
|
||||||
|
assert_eq!(result, Value::Str("hello world".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_direct_run_jump() {
|
||||||
|
// Jump over a push, land on the second push.
|
||||||
|
let bc = [
|
||||||
|
Bytecode::Jump(1), // ip=0 → skip 1 → ip becomes 2
|
||||||
|
Bytecode::Push(Value::Int(0)), // ip=1 — skipped
|
||||||
|
Bytecode::Push(Value::Int(42)), // ip=2
|
||||||
|
Bytecode::Halt,
|
||||||
|
];
|
||||||
|
let result = run_bytecode(&bc).unwrap();
|
||||||
|
assert_eq!(result, Value::Int(42));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user