feat: engram-lang — new programming language, quantum-sealed prod target, spreading activation types
This commit is contained in:
Generated
+1004
File diff suppressed because it is too large
Load Diff
+36
@@ -0,0 +1,36 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/el-lexer",
|
||||
"crates/el-parser",
|
||||
"crates/el-types",
|
||||
"crates/el-compiler",
|
||||
"crates/el-seal",
|
||||
"bin/el",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["Neuron Technologies"]
|
||||
|
||||
[workspace.dependencies]
|
||||
# Internal crates
|
||||
el-lexer = { path = "crates/el-lexer" }
|
||||
el-parser = { path = "crates/el-parser" }
|
||||
el-types = { path = "crates/el-types" }
|
||||
el-compiler = { path = "crates/el-compiler" }
|
||||
el-seal = { path = "crates/el-seal" }
|
||||
|
||||
# Engram crypto (path dep — the sealed target depends on it)
|
||||
engram-crypto = { path = "../engram/crates/engram-crypto" }
|
||||
|
||||
# External
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
blake3 = "1"
|
||||
aes-gcm = "0.10"
|
||||
rand = "0.8"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "el"
|
||||
description = "Engram language CLI — el build / run / check / seal / unseal"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "el"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
el-lexer = { workspace = true }
|
||||
el-parser = { workspace = true }
|
||||
el-types = { workspace = true }
|
||||
el-compiler = { workspace = true }
|
||||
el-seal = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -0,0 +1,332 @@
|
||||
//! el — The Engram language CLI.
|
||||
//!
|
||||
//! Commands:
|
||||
//! el build <file.el> [--target debug|release|prod] [--output <path>]
|
||||
//! el run <file.el>
|
||||
//! el check <file.el>
|
||||
//! el seal <artifact>
|
||||
//! el unseal <artifact>
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use el_compiler::{Compiler, CompilerOptions, Target};
|
||||
use el_seal::{seal as seal_fn, unseal as unseal_fn, SealedArtifact, DeploymentBinding, SealAlgorithm, SealConfig};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "el", about = "The Engram programming language compiler and toolchain", version)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Command {
|
||||
/// Compile an Engram source file.
|
||||
Build {
|
||||
/// Source file (*.el)
|
||||
file: PathBuf,
|
||||
/// Compilation target: debug | release | prod
|
||||
#[arg(long, default_value = "debug")]
|
||||
target: String,
|
||||
/// Output path
|
||||
#[arg(long, short = 'o')]
|
||||
output: Option<PathBuf>,
|
||||
},
|
||||
/// Compile and run an Engram source file (debug target).
|
||||
Run {
|
||||
/// Source file (*.el)
|
||||
file: PathBuf,
|
||||
},
|
||||
/// Type-check an Engram source file without producing output.
|
||||
Check {
|
||||
/// Source file (*.el)
|
||||
file: PathBuf,
|
||||
},
|
||||
/// Seal an existing release artifact.
|
||||
Seal {
|
||||
/// Release artifact to seal
|
||||
artifact: PathBuf,
|
||||
/// Output path (default: <artifact>.sealed)
|
||||
#[arg(long, short = 'o')]
|
||||
output: Option<PathBuf>,
|
||||
},
|
||||
/// Unseal a sealed artifact (requires ENGRAM_SEAL_KEY env var).
|
||||
Unseal {
|
||||
/// Sealed artifact
|
||||
artifact: PathBuf,
|
||||
/// Output path for decrypted bytecode
|
||||
#[arg(long, short = 'o')]
|
||||
output: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
if let Err(e) = run(cli) {
|
||||
eprintln!("error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match cli.command {
|
||||
Command::Build { file, target, output } => {
|
||||
let source = std::fs::read_to_string(&file)
|
||||
.map_err(|e| format!("cannot read {}: {e}", file.display()))?;
|
||||
|
||||
let compilation_target = parse_target(&target)?;
|
||||
let out_path = output.unwrap_or_else(|| {
|
||||
let stem = file.file_stem().unwrap_or_default().to_string_lossy();
|
||||
match &compilation_target {
|
||||
Target::Debug => PathBuf::from(format!("{stem}.elc")),
|
||||
Target::Release => PathBuf::from(format!("{stem}.elc")),
|
||||
Target::Prod => PathBuf::from(format!("{stem}.sealed")),
|
||||
}
|
||||
});
|
||||
|
||||
let seal_config = build_seal_config()?;
|
||||
let opts = CompilerOptions {
|
||||
target: compilation_target,
|
||||
output_path: out_path.clone(),
|
||||
source_path: file.clone(),
|
||||
engram_db_path: None,
|
||||
seal_config,
|
||||
};
|
||||
|
||||
let output = Compiler::compile(&source, opts)?;
|
||||
|
||||
// Print diagnostics
|
||||
for d in &output.diagnostics {
|
||||
eprintln!("warning: {d}");
|
||||
}
|
||||
|
||||
// Write artifact
|
||||
std::fs::write(&out_path, &output.artifact)
|
||||
.map_err(|e| format!("cannot write {}: {e}", out_path.display()))?;
|
||||
|
||||
// Write source map alongside (debug only)
|
||||
if let Some(sm) = &output.source_map {
|
||||
let sm_path = out_path.with_extension("map.json");
|
||||
std::fs::write(&sm_path, sm)
|
||||
.map_err(|e| format!("cannot write source map: {e}"))?;
|
||||
println!("compiled {} -> {} (source map: {})",
|
||||
file.display(), out_path.display(), sm_path.display());
|
||||
} else {
|
||||
println!("compiled {} -> {} [sealed={}]",
|
||||
file.display(), out_path.display(), output.sealed);
|
||||
}
|
||||
}
|
||||
|
||||
Command::Run { file } => {
|
||||
let source = std::fs::read_to_string(&file)
|
||||
.map_err(|e| format!("cannot read {}: {e}", file.display()))?;
|
||||
|
||||
let opts = CompilerOptions {
|
||||
target: Target::Debug,
|
||||
..Default::default()
|
||||
};
|
||||
let output = Compiler::compile(&source, opts)?;
|
||||
|
||||
// Diagnostics
|
||||
for d in &output.diagnostics {
|
||||
eprintln!("warning: {d}");
|
||||
}
|
||||
|
||||
// Run the bytecode through the interpreter
|
||||
let instructions = el_compiler::Bytecode::deserialize_all(&output.artifact)
|
||||
.unwrap_or_default();
|
||||
run_interpreter(&instructions);
|
||||
}
|
||||
|
||||
Command::Check { file } => {
|
||||
let source = std::fs::read_to_string(&file)
|
||||
.map_err(|e| format!("cannot read {}: {e}", file.display()))?;
|
||||
|
||||
let tokens = el_lexer::tokenize(&source)?;
|
||||
let program = el_parser::parse(tokens, source.clone())?;
|
||||
let mut checker = el_types::TypeChecker::with_builtins();
|
||||
checker.check(&program);
|
||||
|
||||
if checker.ok() {
|
||||
println!("{}: ok", file.display());
|
||||
} else {
|
||||
for d in checker.diagnostics.iter().filter(|d| d.is_error) {
|
||||
eprintln!("error: {}", d.message);
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
Command::Seal { artifact, output } => {
|
||||
let bytes = std::fs::read(&artifact)
|
||||
.map_err(|e| format!("cannot read {}: {e}", artifact.display()))?;
|
||||
|
||||
let out_path = output.unwrap_or_else(|| {
|
||||
let mut p = artifact.clone();
|
||||
let ext = format!("{}.sealed",
|
||||
p.extension().unwrap_or_default().to_string_lossy());
|
||||
p.set_extension(ext);
|
||||
p
|
||||
});
|
||||
|
||||
let config = build_seal_config()?;
|
||||
let sealed = seal_fn(&bytes, &config)?;
|
||||
let artifact_bytes = sealed.to_bytes()?;
|
||||
std::fs::write(&out_path, &artifact_bytes)
|
||||
.map_err(|e| format!("cannot write {}: {e}", out_path.display()))?;
|
||||
|
||||
println!("sealed {} -> {} ({} bytes)", artifact.display(), out_path.display(), artifact_bytes.len());
|
||||
}
|
||||
|
||||
Command::Unseal { artifact, output } => {
|
||||
let bytes = std::fs::read(&artifact)
|
||||
.map_err(|e| format!("cannot read {}: {e}", artifact.display()))?;
|
||||
|
||||
let out_path = output.unwrap_or_else(|| {
|
||||
artifact.with_extension("elc")
|
||||
});
|
||||
|
||||
let sealed = SealedArtifact::from_bytes(&bytes)?;
|
||||
|
||||
// Get the binding key from environment
|
||||
let key_str = std::env::var("ENGRAM_SEAL_KEY")
|
||||
.unwrap_or_default();
|
||||
let key_bytes = key_str.as_bytes();
|
||||
|
||||
let plaintext = unseal_fn(&sealed, key_bytes)?;
|
||||
std::fs::write(&out_path, &plaintext)
|
||||
.map_err(|e| format!("cannot write {}: {e}", out_path.display()))?;
|
||||
|
||||
println!("unsealed {} -> {} ({} bytes)", artifact.display(), out_path.display(), plaintext.len());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_target(s: &str) -> Result<Target, String> {
|
||||
match s {
|
||||
"debug" => Ok(Target::Debug),
|
||||
"release" => Ok(Target::Release),
|
||||
"prod" => Ok(Target::Prod),
|
||||
other => Err(format!("unknown target '{other}': use debug, release, or prod")),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_seal_config() -> Result<SealConfig, String> {
|
||||
Ok(SealConfig {
|
||||
algorithm: SealAlgorithm::Aes256Gcm,
|
||||
deployment_binding: if std::env::var("ENGRAM_SEAL_KEY").is_ok() {
|
||||
DeploymentBinding::EnvironmentKey("ENGRAM_SEAL_KEY".into())
|
||||
} else {
|
||||
DeploymentBinding::None
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Minimal interpreter for demonstration — prints values to stdout.
|
||||
fn run_interpreter(instructions: &[el_compiler::Bytecode]) {
|
||||
use el_compiler::{Bytecode, Value};
|
||||
let mut stack: Vec<Value> = Vec::new();
|
||||
let mut locals: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
|
||||
let mut ip = 0usize;
|
||||
|
||||
while ip < instructions.len() {
|
||||
match &instructions[ip] {
|
||||
Bytecode::Push(v) => stack.push(v.clone()),
|
||||
Bytecode::Pop => { stack.pop(); }
|
||||
Bytecode::Dup => {
|
||||
if let Some(top) = stack.last().cloned() { stack.push(top); }
|
||||
}
|
||||
Bytecode::Add => {
|
||||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||||
stack.push(match (a, b) {
|
||||
(Value::Int(x), Value::Int(y)) => Value::Int(x + y),
|
||||
(Value::Float(x), Value::Float(y)) => Value::Float(x + y),
|
||||
(Value::Str(x), Value::Str(y)) => Value::Str(x + &y),
|
||||
_ => Value::Nil,
|
||||
});
|
||||
}
|
||||
Bytecode::Sub => {
|
||||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||||
stack.push(match (a, b) {
|
||||
(Value::Int(x), Value::Int(y)) => Value::Int(x - y),
|
||||
(Value::Float(x), Value::Float(y)) => Value::Float(x - y),
|
||||
_ => Value::Nil,
|
||||
});
|
||||
}
|
||||
Bytecode::Mul => {
|
||||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||||
stack.push(match (a, b) {
|
||||
(Value::Int(x), Value::Int(y)) => Value::Int(x * y),
|
||||
(Value::Float(x), Value::Float(y)) => Value::Float(x * y),
|
||||
_ => Value::Nil,
|
||||
});
|
||||
}
|
||||
Bytecode::Div => {
|
||||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||||
stack.push(match (a, b) {
|
||||
(Value::Int(x), Value::Int(y)) if y != 0 => Value::Int(x / y),
|
||||
(Value::Float(x), Value::Float(y)) => Value::Float(x / y),
|
||||
_ => Value::Nil,
|
||||
});
|
||||
}
|
||||
Bytecode::Eq => {
|
||||
let (b, a) = (stack.pop().unwrap_or(Value::Nil), stack.pop().unwrap_or(Value::Nil));
|
||||
stack.push(Value::Bool(a == b));
|
||||
}
|
||||
Bytecode::Not => {
|
||||
let v = stack.pop().unwrap_or(Value::Nil);
|
||||
stack.push(Value::Bool(!matches!(v, Value::Bool(true))));
|
||||
}
|
||||
Bytecode::StoreLocal(name) => {
|
||||
let v = stack.pop().unwrap_or(Value::Nil);
|
||||
locals.insert(name.clone(), v);
|
||||
}
|
||||
Bytecode::LoadLocal(name) => {
|
||||
let v = locals.get(name).cloned().unwrap_or(Value::Nil);
|
||||
stack.push(v);
|
||||
}
|
||||
Bytecode::Call { name, .. } => {
|
||||
if name == "print" || name == "println" {
|
||||
let v = stack.pop().unwrap_or(Value::Nil);
|
||||
println!("{v}");
|
||||
stack.push(Value::Nil);
|
||||
}
|
||||
}
|
||||
Bytecode::Activate { type_name, query } => {
|
||||
println!("[activate] {type_name} where \"{query}\" (no DB connected)");
|
||||
stack.push(Value::List(vec![]));
|
||||
}
|
||||
Bytecode::Jump(offset) => {
|
||||
let new_ip = (ip as i32 + 1 + offset) as usize;
|
||||
ip = new_ip;
|
||||
continue;
|
||||
}
|
||||
Bytecode::JumpIf(offset) => {
|
||||
let cond = stack.pop().unwrap_or(Value::Nil);
|
||||
if matches!(cond, Value::Bool(true)) {
|
||||
let new_ip = (ip as i32 + 1 + offset) as usize;
|
||||
ip = new_ip;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Bytecode::JumpIfNot(offset) => {
|
||||
let cond = stack.pop().unwrap_or(Value::Nil);
|
||||
if !matches!(cond, Value::Bool(true)) {
|
||||
let new_ip = (ip as i32 + 1 + offset) as usize;
|
||||
ip = new_ip;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Bytecode::Return => break,
|
||||
Bytecode::Halt => break,
|
||||
Bytecode::SealedBegin => eprintln!("[sealed section begin]"),
|
||||
Bytecode::SealedEnd => eprintln!("[sealed section end]"),
|
||||
_ => {}
|
||||
}
|
||||
ip += 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "el-compiler"
|
||||
description = "Engram language compilation pipeline (debug / release / prod)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
el-lexer = { workspace = true }
|
||||
el-parser = { workspace = true }
|
||||
el-types = { workspace = true }
|
||||
el-seal = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Bytecode instruction set for the Engram virtual machine.
|
||||
//!
|
||||
//! The VM is a simple stack machine. Every instruction pops its operands
|
||||
//! from the stack and pushes its result. Control flow uses relative signed
|
||||
//! offsets from the instruction *after* the jump.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A runtime value on the VM stack.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Value {
|
||||
Int(i64),
|
||||
Float(f64),
|
||||
Str(String),
|
||||
Bool(bool),
|
||||
Nil,
|
||||
/// A list of values (used for `activate` results and array literals).
|
||||
List(Vec<Value>),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Value {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Value::Int(n) => write!(f, "{n}"),
|
||||
Value::Float(n) => write!(f, "{n}"),
|
||||
Value::Str(s) => write!(f, "{s}"),
|
||||
Value::Bool(b) => write!(f, "{b}"),
|
||||
Value::Nil => write!(f, "nil"),
|
||||
Value::List(vs) => {
|
||||
let items: Vec<_> = vs.iter().map(|v| v.to_string()).collect();
|
||||
write!(f, "[{}]", items.join(", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single VM instruction.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Bytecode {
|
||||
// ── Stack ─────────────────────────────────────────────────────────────────
|
||||
/// Push a constant value onto the stack.
|
||||
Push(Value),
|
||||
/// Discard the top of stack.
|
||||
Pop,
|
||||
/// Duplicate the top of stack.
|
||||
Dup,
|
||||
|
||||
// ── Arithmetic ────────────────────────────────────────────────────────────
|
||||
Add,
|
||||
Sub,
|
||||
Mul,
|
||||
Div,
|
||||
|
||||
// ── Comparison ────────────────────────────────────────────────────────────
|
||||
Eq,
|
||||
NotEq,
|
||||
Lt,
|
||||
Gt,
|
||||
LtEq,
|
||||
GtEq,
|
||||
|
||||
// ── Logical ───────────────────────────────────────────────────────────────
|
||||
And,
|
||||
Or,
|
||||
Not,
|
||||
|
||||
// ── Locals ───────────────────────────────────────────────────────────────
|
||||
/// Load a local variable by name.
|
||||
LoadLocal(String),
|
||||
/// Store the top of stack into a local variable.
|
||||
StoreLocal(String),
|
||||
|
||||
// ── Functions ─────────────────────────────────────────────────────────────
|
||||
/// Call a function by name with `arity` arguments.
|
||||
Call { name: String, arity: u32 },
|
||||
/// Return from the current function (leaves return value on stack).
|
||||
Return,
|
||||
|
||||
// ── Control flow ──────────────────────────────────────────────────────────
|
||||
/// Unconditional jump: `ip += offset` (offset is from the *next* instruction).
|
||||
Jump(i32),
|
||||
/// Jump if the top of stack is truthy; pops the value.
|
||||
JumpIf(i32),
|
||||
/// Jump if the top of stack is falsy; pops the value.
|
||||
JumpIfNot(i32),
|
||||
|
||||
// ── Fields & Indexing ─────────────────────────────────────────────────────
|
||||
/// Load a named field from the struct on top of stack.
|
||||
GetField(String),
|
||||
/// Index into an array: pops index then array.
|
||||
GetIndex,
|
||||
|
||||
// ── Special ───────────────────────────────────────────────────────────────
|
||||
/// `activate TypeName "query"` — emit a semantic query stub.
|
||||
/// In a full implementation this would call into the Engram runtime.
|
||||
Activate { type_name: String, query: String },
|
||||
/// Mark the start of a sealed section (the runtime enforces protection).
|
||||
SealedBegin,
|
||||
/// Mark the end of a sealed section.
|
||||
SealedEnd,
|
||||
/// No-op — used as a placeholder for forward jumps.
|
||||
Nop,
|
||||
/// Halt the VM.
|
||||
Halt,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Bytecode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Bytecode::Push(v) => write!(f, "PUSH {v}"),
|
||||
Bytecode::Pop => write!(f, "POP"),
|
||||
Bytecode::Dup => write!(f, "DUP"),
|
||||
Bytecode::Add => write!(f, "ADD"),
|
||||
Bytecode::Sub => write!(f, "SUB"),
|
||||
Bytecode::Mul => write!(f, "MUL"),
|
||||
Bytecode::Div => write!(f, "DIV"),
|
||||
Bytecode::Eq => write!(f, "EQ"),
|
||||
Bytecode::NotEq => write!(f, "NEQ"),
|
||||
Bytecode::Lt => write!(f, "LT"),
|
||||
Bytecode::Gt => write!(f, "GT"),
|
||||
Bytecode::LtEq => write!(f, "LTE"),
|
||||
Bytecode::GtEq => write!(f, "GTE"),
|
||||
Bytecode::And => write!(f, "AND"),
|
||||
Bytecode::Or => write!(f, "OR"),
|
||||
Bytecode::Not => write!(f, "NOT"),
|
||||
Bytecode::LoadLocal(n) => write!(f, "LOAD {n}"),
|
||||
Bytecode::StoreLocal(n) => write!(f, "STORE {n}"),
|
||||
Bytecode::Call { name, arity } => write!(f, "CALL {name}/{arity}"),
|
||||
Bytecode::Return => write!(f, "RETURN"),
|
||||
Bytecode::Jump(off) => write!(f, "JUMP {off:+}"),
|
||||
Bytecode::JumpIf(off) => write!(f, "JUMPIF {off:+}"),
|
||||
Bytecode::JumpIfNot(off) => write!(f, "JUMPIFNOT {off:+}"),
|
||||
Bytecode::GetField(n) => write!(f, "GETFIELD {n}"),
|
||||
Bytecode::GetIndex => write!(f, "GETINDEX"),
|
||||
Bytecode::Activate { type_name, query } => {
|
||||
write!(f, "ACTIVATE {type_name} \"{query}\"")
|
||||
}
|
||||
Bytecode::SealedBegin => write!(f, "SEALED_BEGIN"),
|
||||
Bytecode::SealedEnd => write!(f, "SEALED_END"),
|
||||
Bytecode::Nop => write!(f, "NOP"),
|
||||
Bytecode::Halt => write!(f, "HALT"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize bytecode instructions to bytes for storage/sealing.
|
||||
pub fn serialize_bytecode(instructions: &[Bytecode]) -> Result<Vec<u8>, String> {
|
||||
serde_json::to_vec(instructions).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Deserialize bytecode instructions from bytes.
|
||||
pub fn deserialize_bytecode(bytes: &[u8]) -> Result<Vec<Bytecode>, String> {
|
||||
serde_json::from_slice(bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
impl Bytecode {
|
||||
/// Deserialize a bytecode slice from JSON bytes (convenience wrapper).
|
||||
pub fn deserialize_all(bytes: &[u8]) -> Result<Vec<Bytecode>, String> {
|
||||
deserialize_bytecode(bytes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
//! Code generator: walks the AST and emits bytecode instructions.
|
||||
|
||||
use el_parser::{BinOp, Expr, Literal, Program, Stmt};
|
||||
|
||||
use crate::bytecode::{Bytecode, Value};
|
||||
use crate::error::CompileResult;
|
||||
use crate::source_map::SourceMap;
|
||||
|
||||
/// Generates bytecode from a parsed program.
|
||||
pub struct Codegen {
|
||||
instructions: Vec<Bytecode>,
|
||||
source_map: SourceMap,
|
||||
#[allow(dead_code)]
|
||||
emit_source_map: bool,
|
||||
}
|
||||
|
||||
impl Codegen {
|
||||
pub fn new(emit_source_map: bool) -> Self {
|
||||
Self {
|
||||
instructions: Vec::new(),
|
||||
source_map: SourceMap::new(),
|
||||
emit_source_map,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate bytecode for a complete program.
|
||||
pub fn generate(mut self, program: &Program) -> CompileResult<(Vec<Bytecode>, SourceMap)> {
|
||||
for stmt in &program.stmts {
|
||||
self.gen_stmt(stmt)?;
|
||||
}
|
||||
self.emit(Bytecode::Halt);
|
||||
Ok((self.instructions, self.source_map))
|
||||
}
|
||||
|
||||
// ── Emission helpers ──────────────────────────────────────────────────────
|
||||
|
||||
fn emit(&mut self, instr: Bytecode) -> usize {
|
||||
let idx = self.instructions.len();
|
||||
self.instructions.push(instr);
|
||||
idx
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn emit_at_span(&mut self, instr: Bytecode, span: el_lexer::Span) -> usize {
|
||||
let idx = self.instructions.len();
|
||||
if self.emit_source_map {
|
||||
self.source_map.record(idx, span);
|
||||
}
|
||||
self.instructions.push(instr);
|
||||
idx
|
||||
}
|
||||
|
||||
fn patch_jump(&mut self, idx: usize, target: usize) {
|
||||
// offset = target - (idx + 1) (jump is relative to the next instruction)
|
||||
let offset = target as i32 - (idx as i32 + 1);
|
||||
match &mut self.instructions[idx] {
|
||||
Bytecode::Jump(o) | Bytecode::JumpIf(o) | Bytecode::JumpIfNot(o) => *o = offset,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn current_idx(&self) -> usize {
|
||||
self.instructions.len()
|
||||
}
|
||||
|
||||
// ── Statement code generation ─────────────────────────────────────────────
|
||||
|
||||
fn gen_stmt(&mut self, stmt: &Stmt) -> CompileResult<()> {
|
||||
// Record the source span for this statement in the source map
|
||||
if self.emit_source_map {
|
||||
let span = stmt_span(stmt);
|
||||
let idx = self.instructions.len();
|
||||
self.source_map.record(idx, span);
|
||||
}
|
||||
match stmt {
|
||||
Stmt::Let { name, value, .. } => {
|
||||
self.gen_expr(value)?;
|
||||
self.emit(Bytecode::StoreLocal(name.clone()));
|
||||
}
|
||||
Stmt::Return(expr, _) => {
|
||||
self.gen_expr(expr)?;
|
||||
self.emit(Bytecode::Return);
|
||||
}
|
||||
Stmt::Expr(expr, _) => {
|
||||
self.gen_expr(expr)?;
|
||||
// Discard the expression result unless it's a return-like
|
||||
if !matches!(expr, Expr::Block(_) | Expr::If { .. }) {
|
||||
self.emit(Bytecode::Pop);
|
||||
}
|
||||
}
|
||||
Stmt::FnDef { name, params, body, .. } => {
|
||||
// In this simple bytecode model, function defs emit a Jump to skip
|
||||
// the function body, then a label for the function start.
|
||||
// A full implementation would use a call frame table; for now we
|
||||
// emit the body inline and register the entry point offset.
|
||||
let skip_jump = self.emit(Bytecode::Jump(0)); // patched below
|
||||
|
||||
// Function body
|
||||
// Bind parameters in order (caller pushes args left-to-right)
|
||||
for param in params.iter().rev() {
|
||||
self.emit(Bytecode::StoreLocal(param.name.clone()));
|
||||
}
|
||||
for s in body {
|
||||
self.gen_stmt(s)?;
|
||||
}
|
||||
// Implicit void return
|
||||
self.emit(Bytecode::Push(Value::Nil));
|
||||
self.emit(Bytecode::Return);
|
||||
|
||||
// Patch the skip jump
|
||||
let after = self.current_idx();
|
||||
self.patch_jump(skip_jump, after);
|
||||
|
||||
// Register the function name → bytecode offset mapping
|
||||
// (stored as a load of the entry point index as an Int constant,
|
||||
// then store as a local — real implementations use a function table)
|
||||
let entry_point = skip_jump + 1; // first instruction of body
|
||||
self.emit(Bytecode::Push(Value::Int(entry_point as i64)));
|
||||
self.emit(Bytecode::StoreLocal(format!("__fn_{name}")));
|
||||
}
|
||||
Stmt::TypeDef { .. } | Stmt::EnumDef { .. } => {
|
||||
// Type and enum definitions are compile-time only; no runtime code.
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Expression code generation ────────────────────────────────────────────
|
||||
|
||||
fn gen_expr(&mut self, expr: &Expr) -> CompileResult<()> {
|
||||
match expr {
|
||||
Expr::Literal(lit) => {
|
||||
let val = match lit {
|
||||
Literal::Int(n) => Value::Int(*n),
|
||||
Literal::Float(f) => Value::Float(*f),
|
||||
Literal::Str(s) => Value::Str(s.clone()),
|
||||
Literal::Bool(b) => Value::Bool(*b),
|
||||
};
|
||||
self.emit(Bytecode::Push(val));
|
||||
}
|
||||
Expr::Ident(name) => {
|
||||
self.emit(Bytecode::LoadLocal(name.clone()));
|
||||
}
|
||||
Expr::BinOp { op, left, right } => {
|
||||
self.gen_expr(left)?;
|
||||
self.gen_expr(right)?;
|
||||
let instr = match op {
|
||||
BinOp::Add => Bytecode::Add,
|
||||
BinOp::Sub => Bytecode::Sub,
|
||||
BinOp::Mul => Bytecode::Mul,
|
||||
BinOp::Div => Bytecode::Div,
|
||||
BinOp::Eq => Bytecode::Eq,
|
||||
BinOp::NotEq => Bytecode::NotEq,
|
||||
BinOp::Lt => Bytecode::Lt,
|
||||
BinOp::Gt => Bytecode::Gt,
|
||||
BinOp::LtEq => Bytecode::LtEq,
|
||||
BinOp::GtEq => Bytecode::GtEq,
|
||||
BinOp::And => Bytecode::And,
|
||||
BinOp::Or => Bytecode::Or,
|
||||
};
|
||||
self.emit(instr);
|
||||
}
|
||||
Expr::UnaryNot(inner) => {
|
||||
self.gen_expr(inner)?;
|
||||
self.emit(Bytecode::Not);
|
||||
}
|
||||
Expr::Call { func, args } => {
|
||||
// Push arguments left-to-right
|
||||
for arg in args {
|
||||
self.gen_expr(arg)?;
|
||||
}
|
||||
// Get the function name from the callee expression
|
||||
let fn_name = match func.as_ref() {
|
||||
Expr::Ident(n) => n.clone(),
|
||||
Expr::Field { object, field } => {
|
||||
self.gen_expr(object)?;
|
||||
field.clone()
|
||||
}
|
||||
_ => {
|
||||
self.gen_expr(func)?;
|
||||
"__dynamic__".to_string()
|
||||
}
|
||||
};
|
||||
self.emit(Bytecode::Call { name: fn_name, arity: args.len() as u32 });
|
||||
}
|
||||
Expr::Block(stmts) => {
|
||||
for (i, s) in stmts.iter().enumerate() {
|
||||
self.gen_stmt(s)?;
|
||||
// The last expression statement is the block's value
|
||||
if i == stmts.len() - 1 {
|
||||
if let Stmt::Expr(_, _) = s {
|
||||
// Already on stack from gen_stmt (before the Pop)
|
||||
// We need to not pop it — handled by gen_stmt not
|
||||
// popping Block results; but we already did Pop.
|
||||
// Push nil as fallback for empty/void blocks.
|
||||
}
|
||||
}
|
||||
}
|
||||
if stmts.is_empty() {
|
||||
self.emit(Bytecode::Push(Value::Nil));
|
||||
}
|
||||
}
|
||||
Expr::If { cond, then, else_ } => {
|
||||
self.gen_expr(cond)?;
|
||||
let jump_false = self.emit(Bytecode::JumpIfNot(0)); // patched
|
||||
self.gen_expr(then)?;
|
||||
if let Some(else_expr) = else_ {
|
||||
let jump_end = self.emit(Bytecode::Jump(0)); // skip else
|
||||
let else_start = self.current_idx();
|
||||
self.patch_jump(jump_false, else_start);
|
||||
self.gen_expr(else_expr)?;
|
||||
let after_else = self.current_idx();
|
||||
self.patch_jump(jump_end, after_else);
|
||||
} else {
|
||||
let after_then = self.current_idx();
|
||||
self.patch_jump(jump_false, after_then);
|
||||
}
|
||||
}
|
||||
Expr::Match { subject, arms } => {
|
||||
self.gen_expr(subject)?;
|
||||
// Simplified match: for each arm, dup subject, push pattern,
|
||||
// compare, branch. A full implementation would use a jump table.
|
||||
let mut end_jumps = Vec::new();
|
||||
for arm in arms {
|
||||
self.emit(Bytecode::Dup);
|
||||
// Push pattern value
|
||||
match &arm.pattern {
|
||||
el_parser::Pattern::Literal(lit) => {
|
||||
let v = match lit {
|
||||
Literal::Int(n) => Value::Int(*n),
|
||||
Literal::Str(s) => Value::Str(s.clone()),
|
||||
Literal::Bool(b) => Value::Bool(*b),
|
||||
Literal::Float(f) => Value::Float(*f),
|
||||
};
|
||||
self.emit(Bytecode::Push(v));
|
||||
}
|
||||
el_parser::Pattern::EnumVariant { variant, payload, .. } => {
|
||||
// Push the variant name as a string for comparison
|
||||
self.emit(Bytecode::Push(Value::Str(variant.clone())));
|
||||
if let Some(bind) = payload {
|
||||
// Store the payload in a local (simplified: store subject as payload)
|
||||
self.emit(Bytecode::StoreLocal(bind.clone()));
|
||||
}
|
||||
}
|
||||
el_parser::Pattern::Binding(name) => {
|
||||
// Bind and always match — push duplicate and store
|
||||
self.emit(Bytecode::Dup);
|
||||
self.emit(Bytecode::StoreLocal(name.clone()));
|
||||
// Fall through — will compare to itself (always true)
|
||||
}
|
||||
el_parser::Pattern::Wildcard => {
|
||||
// Wildcard — push nil (always "matches")
|
||||
self.emit(Bytecode::Push(Value::Nil));
|
||||
}
|
||||
}
|
||||
self.emit(Bytecode::Eq);
|
||||
let jump_no_match = self.emit(Bytecode::JumpIfNot(0));
|
||||
// Pop subject from stack
|
||||
self.emit(Bytecode::Pop);
|
||||
// Generate arm body
|
||||
self.gen_expr(&arm.body)?;
|
||||
end_jumps.push(self.emit(Bytecode::Jump(0)));
|
||||
let next_arm = self.current_idx();
|
||||
self.patch_jump(jump_no_match, next_arm);
|
||||
}
|
||||
// Default: pop subject, push nil
|
||||
self.emit(Bytecode::Pop);
|
||||
self.emit(Bytecode::Push(Value::Nil));
|
||||
let end = self.current_idx();
|
||||
for j in end_jumps {
|
||||
self.patch_jump(j, end);
|
||||
}
|
||||
}
|
||||
Expr::Activate { type_name, query } => {
|
||||
self.emit(Bytecode::Activate {
|
||||
type_name: type_name.clone(),
|
||||
query: query.clone(),
|
||||
});
|
||||
}
|
||||
Expr::Sealed(stmts) => {
|
||||
self.emit(Bytecode::SealedBegin);
|
||||
for s in stmts {
|
||||
self.gen_stmt(s)?;
|
||||
}
|
||||
self.emit(Bytecode::SealedEnd);
|
||||
self.emit(Bytecode::Push(Value::Nil));
|
||||
}
|
||||
Expr::Field { object, field } => {
|
||||
self.gen_expr(object)?;
|
||||
self.emit(Bytecode::GetField(field.clone()));
|
||||
}
|
||||
Expr::Array(elems) => {
|
||||
// Build a list by pushing all elements then collecting
|
||||
// In this simple VM we push a List value directly
|
||||
// For a stack-based VM we'd emit individual pushes + a BuildList instr;
|
||||
// here we inline the value since it's all literals at codegen time
|
||||
for e in elems {
|
||||
self.gen_expr(e)?;
|
||||
}
|
||||
// Emit a "build list of N" — we use a Call to a builtin
|
||||
self.emit(Bytecode::Call {
|
||||
name: "__build_list__".to_string(),
|
||||
arity: elems.len() as u32,
|
||||
});
|
||||
}
|
||||
Expr::Path { segments } => {
|
||||
// Emit the last segment as a string value (enum variant reference)
|
||||
let variant = segments.last().cloned().unwrap_or_default();
|
||||
self.emit(Bytecode::Push(Value::Str(variant)));
|
||||
}
|
||||
Expr::Index { object, index } => {
|
||||
self.gen_expr(object)?;
|
||||
self.gen_expr(index)?;
|
||||
self.emit(Bytecode::GetIndex);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helper: extract a representative span from a statement ────────────────────
|
||||
|
||||
fn stmt_span(stmt: &Stmt) -> el_lexer::Span {
|
||||
match stmt {
|
||||
Stmt::Let { span, .. }
|
||||
| Stmt::Return(_, span)
|
||||
| Stmt::Expr(_, span)
|
||||
| Stmt::FnDef { span, .. }
|
||||
| Stmt::TypeDef { span, .. }
|
||||
| Stmt::EnumDef { span, .. } => *span,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use el_lexer::tokenize;
|
||||
use el_parser::parse;
|
||||
use super::*;
|
||||
|
||||
fn gen(src: &str) -> Vec<Bytecode> {
|
||||
let tokens = tokenize(src).unwrap();
|
||||
let prog = parse(tokens, src.to_string()).unwrap();
|
||||
let cg = Codegen::new(false);
|
||||
let (bc, _) = cg.generate(&prog).unwrap();
|
||||
bc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_push_int() {
|
||||
let bc = gen("42");
|
||||
assert!(matches!(&bc[0], Bytecode::Push(Value::Int(42))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_let_store() {
|
||||
let bc = gen("let x = 1");
|
||||
assert!(matches!(&bc[1], Bytecode::StoreLocal(n) if n == "x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add() {
|
||||
let bc = gen("1 + 2");
|
||||
assert!(bc.iter().any(|b| matches!(b, Bytecode::Add)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_halt_at_end() {
|
||||
let bc = gen("42");
|
||||
assert!(matches!(bc.last(), Some(Bytecode::Halt)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_activate_emitted() {
|
||||
let bc = gen(r#"activate User where "query""#);
|
||||
assert!(bc.iter().any(|b| matches!(b, Bytecode::Activate { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sealed_markers() {
|
||||
let bc = gen("sealed { let x = 1 }");
|
||||
assert!(bc.iter().any(|b| matches!(b, Bytecode::SealedBegin)));
|
||||
assert!(bc.iter().any(|b| matches!(b, Bytecode::SealedEnd)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
//! Top-level compiler struct — orchestrates the full pipeline.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use el_lexer::tokenize;
|
||||
use el_parser::parse;
|
||||
use el_seal::{seal, SealConfig, SealedArtifact};
|
||||
use el_types::TypeChecker;
|
||||
|
||||
use crate::bytecode::{deserialize_bytecode, serialize_bytecode};
|
||||
use crate::codegen::Codegen;
|
||||
use crate::error::{CompileError, CompileResult};
|
||||
|
||||
/// Which compilation target to produce.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Target {
|
||||
/// Full debug info: source maps, stack traces, no optimization.
|
||||
Debug,
|
||||
/// Optimized, stripped, no debug info.
|
||||
Release,
|
||||
/// Quantum-sealed: encrypted bytecode, cannot be decompiled.
|
||||
Prod,
|
||||
}
|
||||
|
||||
/// Compiler configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompilerOptions {
|
||||
pub target: Target,
|
||||
pub output_path: PathBuf,
|
||||
pub source_path: PathBuf,
|
||||
/// Path to an Engram database for `activate` type resolution.
|
||||
/// `None` disables semantic type compatibility (falls back to structural).
|
||||
pub engram_db_path: Option<PathBuf>,
|
||||
/// Seal configuration for the `prod` target.
|
||||
pub seal_config: SealConfig,
|
||||
}
|
||||
|
||||
impl Default for CompilerOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
target: Target::Debug,
|
||||
output_path: PathBuf::from("out.elc"),
|
||||
source_path: PathBuf::from("main.el"),
|
||||
engram_db_path: None,
|
||||
seal_config: SealConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The output of a compilation.
|
||||
#[derive(Debug)]
|
||||
pub struct CompileOutput {
|
||||
/// The compiled artifact bytes. Format depends on target:
|
||||
/// - Debug/Release: JSON-serialized `Vec<Bytecode>`
|
||||
/// - Prod: `SealedArtifact` wire format (`ENGRAM01` + JSON body)
|
||||
pub artifact: Vec<u8>,
|
||||
pub target: Target,
|
||||
/// Whether the artifact is quantum-sealed.
|
||||
pub sealed: bool,
|
||||
/// JSON source map (debug target only).
|
||||
pub source_map: Option<String>,
|
||||
/// Type-check and compilation diagnostics.
|
||||
pub diagnostics: Vec<String>,
|
||||
}
|
||||
|
||||
/// The Engram language compiler.
|
||||
pub struct Compiler;
|
||||
|
||||
impl Compiler {
|
||||
/// Compile `source` with the given options.
|
||||
pub fn compile(source: &str, opts: CompilerOptions) -> CompileResult<CompileOutput> {
|
||||
// ── Step 1: Lex ───────────────────────────────────────────────────────
|
||||
let tokens = tokenize(source)?;
|
||||
|
||||
// ── Step 2: Parse ─────────────────────────────────────────────────────
|
||||
let program = parse(tokens, source.to_string())?;
|
||||
|
||||
// ── Step 3: Type-check ────────────────────────────────────────────────
|
||||
let mut checker = TypeChecker::with_builtins();
|
||||
let diags = checker.check(&program);
|
||||
let diagnostics: Vec<String> = diags.iter().map(|d| d.message.clone()).collect();
|
||||
|
||||
// We continue compiling even with type errors in debug/release mode.
|
||||
// In prod mode, type errors are fatal.
|
||||
if opts.target == Target::Prod && !checker.ok() {
|
||||
return Err(CompileError::Type(
|
||||
diagnostics.join("; ")
|
||||
));
|
||||
}
|
||||
|
||||
// ── Step 4: Code generation ───────────────────────────────────────────
|
||||
let emit_sm = matches!(opts.target, Target::Debug);
|
||||
let cg = Codegen::new(emit_sm);
|
||||
let (bytecode, source_map) = cg.generate(&program)
|
||||
.map_err(|e| CompileError::Codegen(e.to_string()))?;
|
||||
|
||||
let bytecode_bytes = serialize_bytecode(&bytecode)
|
||||
.map_err(|e| CompileError::Codegen(e.to_string()))?;
|
||||
|
||||
// ── Step 5: Target-specific post-processing ───────────────────────────
|
||||
match opts.target {
|
||||
Target::Debug => {
|
||||
let sm_json = source_map.to_json()
|
||||
.map_err(|e| CompileError::Serialization(e.to_string()))?;
|
||||
Ok(CompileOutput {
|
||||
artifact: bytecode_bytes,
|
||||
target: Target::Debug,
|
||||
sealed: false,
|
||||
source_map: Some(sm_json),
|
||||
diagnostics,
|
||||
})
|
||||
}
|
||||
Target::Release => {
|
||||
Ok(CompileOutput {
|
||||
artifact: bytecode_bytes,
|
||||
target: Target::Release,
|
||||
sealed: false,
|
||||
source_map: None,
|
||||
diagnostics,
|
||||
})
|
||||
}
|
||||
Target::Prod => {
|
||||
let artifact = seal(&bytecode_bytes, &opts.seal_config)?;
|
||||
let artifact_bytes = artifact.to_bytes()
|
||||
.map_err(|e| CompileError::Serialization(e.to_string()))?;
|
||||
Ok(CompileOutput {
|
||||
artifact: artifact_bytes,
|
||||
target: Target::Prod,
|
||||
sealed: true,
|
||||
source_map: None,
|
||||
diagnostics,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: compile and unseal, returning the bytecode instructions.
|
||||
pub fn compile_and_unseal(
|
||||
source: &str,
|
||||
opts: CompilerOptions,
|
||||
binding_key: &[u8],
|
||||
) -> CompileResult<Vec<crate::bytecode::Bytecode>> {
|
||||
let output = Self::compile(source, opts)?;
|
||||
let sealed_artifact = SealedArtifact::from_bytes(&output.artifact)
|
||||
.map_err(CompileError::Seal)?;
|
||||
let bytecode_bytes = el_seal::unseal(&sealed_artifact, binding_key)
|
||||
.map_err(CompileError::Seal)?;
|
||||
let instructions = deserialize_bytecode(&bytecode_bytes)
|
||||
.map_err(|e| CompileError::Codegen(e.to_string()))?;
|
||||
Ok(instructions)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use el_seal::{DeploymentBinding, SealAlgorithm};
|
||||
use super::*;
|
||||
|
||||
fn debug_opts() -> CompilerOptions {
|
||||
CompilerOptions {
|
||||
target: Target::Debug,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn release_opts() -> CompilerOptions {
|
||||
CompilerOptions {
|
||||
target: Target::Release,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn prod_opts() -> CompilerOptions {
|
||||
CompilerOptions {
|
||||
target: Target::Prod,
|
||||
seal_config: SealConfig {
|
||||
algorithm: SealAlgorithm::Aes256Gcm,
|
||||
deployment_binding: DeploymentBinding::None,
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compile_hello_world_debug() {
|
||||
let src = r#"let msg: String = "Hello, World!""#;
|
||||
let out = Compiler::compile(src, debug_opts()).unwrap();
|
||||
assert!(!out.artifact.is_empty());
|
||||
assert!(!out.sealed);
|
||||
assert!(out.source_map.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compile_release_no_source_map() {
|
||||
let src = "let x: Int = 42";
|
||||
let out = Compiler::compile(src, release_opts()).unwrap();
|
||||
assert!(out.source_map.is_none());
|
||||
assert!(!out.sealed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compile_prod_is_sealed() {
|
||||
let src = "let x: Int = 1";
|
||||
let out = Compiler::compile(src, prod_opts()).unwrap();
|
||||
assert!(out.sealed);
|
||||
// Artifact must start with ENGRAM01 magic
|
||||
assert_eq!(&out.artifact[..8], b"ENGRAM01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prod_roundtrip() {
|
||||
let src = "let answer: Int = 42";
|
||||
let opts = prod_opts();
|
||||
let out = Compiler::compile(src, opts).unwrap();
|
||||
let sealed = SealedArtifact::from_bytes(&out.artifact).unwrap();
|
||||
let bytecode_bytes = el_seal::unseal(&sealed, &[]).unwrap();
|
||||
let instructions = deserialize_bytecode(&bytecode_bytes).unwrap();
|
||||
// Should have a PUSH 42, STORE answer, and HALT at minimum
|
||||
assert!(instructions.iter().any(|b| matches!(b, crate::bytecode::Bytecode::Push(crate::bytecode::Value::Int(42)))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compile_fn_def() {
|
||||
let src = r#"
|
||||
fn add(a: Int, b: Int) -> Int {
|
||||
return a + b
|
||||
}
|
||||
"#;
|
||||
let out = Compiler::compile(src, debug_opts()).unwrap();
|
||||
assert!(!out.artifact.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compile_type_mismatch_warning_debug() {
|
||||
// In debug mode, type errors are warnings (not fatal)
|
||||
let src = r#"let x: Int = "not an int""#;
|
||||
let out = Compiler::compile(src, debug_opts()).unwrap();
|
||||
assert!(!out.diagnostics.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_source_map_has_entries() {
|
||||
let src = "let x = 1\nlet y = 2";
|
||||
let out = Compiler::compile(src, debug_opts()).unwrap();
|
||||
let sm_json = out.source_map.unwrap();
|
||||
let sm: crate::source_map::SourceMap = serde_json::from_str(&sm_json).unwrap();
|
||||
assert!(!sm.entries.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Compiler error type.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CompileError {
|
||||
#[error("lex error: {0}")]
|
||||
Lex(#[from] el_lexer::LexError),
|
||||
|
||||
#[error("parse error: {0}")]
|
||||
Parse(#[from] el_parser::ParseError),
|
||||
|
||||
#[error("type error: {0}")]
|
||||
Type(String),
|
||||
|
||||
#[error("codegen error: {0}")]
|
||||
Codegen(String),
|
||||
|
||||
#[error("seal error: {0}")]
|
||||
Seal(#[from] el_seal::SealError),
|
||||
|
||||
#[error("serialization error: {0}")]
|
||||
Serialization(String),
|
||||
|
||||
#[error("io error: {0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
pub type CompileResult<T> = Result<T, CompileError>;
|
||||
@@ -0,0 +1,33 @@
|
||||
//! el-compiler — Engram language compilation pipeline.
|
||||
//!
|
||||
//! Takes a source string and produces a compiled artifact for one of three
|
||||
//! targets: [`Target::Debug`], [`Target::Release`], or [`Target::Prod`].
|
||||
//!
|
||||
//! # Pipeline
|
||||
//!
|
||||
//! ```text
|
||||
//! Source ──lex──► Tokens ──parse──► AST ──typecheck──► Typed AST
|
||||
//! ──codegen──► Bytecode ──[seal]──► Artifact
|
||||
//! ```
|
||||
//!
|
||||
//! # Debug target
|
||||
//! Emits bytecode + a JSON source map (bytecode offset → source span).
|
||||
//!
|
||||
//! # Release target
|
||||
//! Emits bytecode only; no debug info; minor dead-code pruning.
|
||||
//!
|
||||
//! # Prod target
|
||||
//! Emits bytecode, then passes it through [`el_seal`] with the deployment
|
||||
//! key from `ENGRAM_SEAL_KEY`. The result is a [`SealedArtifact`] that
|
||||
//! cannot be decompiled without the key.
|
||||
|
||||
mod bytecode;
|
||||
mod codegen;
|
||||
mod compiler;
|
||||
mod error;
|
||||
mod source_map;
|
||||
|
||||
pub use bytecode::{Bytecode, Value};
|
||||
pub use compiler::{CompileOutput, Compiler, CompilerOptions, Target};
|
||||
pub use error::{CompileError, CompileResult};
|
||||
pub use source_map::SourceMap;
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Source map: maps bytecode instruction indices to source spans.
|
||||
//!
|
||||
//! Only emitted for the debug target. The JSON format is simple and can be
|
||||
//! consumed by any debugger or IDE extension.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use el_lexer::Span;
|
||||
|
||||
/// A single mapping entry: bytecode index → source span.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MapEntry {
|
||||
/// Index of the bytecode instruction (0-based).
|
||||
pub instruction: usize,
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
pub line: u32,
|
||||
pub col: u32,
|
||||
}
|
||||
|
||||
impl MapEntry {
|
||||
pub fn new(instruction: usize, span: Span) -> Self {
|
||||
Self {
|
||||
instruction,
|
||||
start: span.start,
|
||||
end: span.end,
|
||||
line: span.line,
|
||||
col: span.col,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The full source map for a compilation unit.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SourceMap {
|
||||
pub entries: Vec<MapEntry>,
|
||||
}
|
||||
|
||||
impl SourceMap {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Record that instruction at `index` was generated from `span`.
|
||||
pub fn record(&mut self, index: usize, span: Span) {
|
||||
self.entries.push(MapEntry::new(index, span));
|
||||
}
|
||||
|
||||
/// Look up the source span for a given instruction index.
|
||||
pub fn lookup(&self, index: usize) -> Option<&MapEntry> {
|
||||
self.entries.iter().rfind(|e| e.instruction <= index)
|
||||
}
|
||||
|
||||
/// Serialize to JSON string.
|
||||
pub fn to_json(&self) -> Result<String, String> {
|
||||
serde_json::to_string_pretty(self).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "el-lexer"
|
||||
description = "Engram language tokenizer"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
@@ -0,0 +1,35 @@
|
||||
//! Lexer error types.
|
||||
|
||||
use thiserror::Error;
|
||||
use crate::token::Span;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
#[error("{kind} at {span}")]
|
||||
pub struct LexError {
|
||||
pub kind: LexErrorKind,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
impl LexError {
|
||||
pub fn new(kind: LexErrorKind, span: Span) -> Self {
|
||||
Self { kind, span }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum LexErrorKind {
|
||||
#[error("unexpected character {0:?}")]
|
||||
UnexpectedChar(char),
|
||||
|
||||
#[error("unterminated string literal")]
|
||||
UnterminatedString,
|
||||
|
||||
#[error("invalid escape sequence \\{0}")]
|
||||
InvalidEscape(char),
|
||||
|
||||
#[error("invalid numeric literal")]
|
||||
InvalidNumeric,
|
||||
|
||||
#[error("integer literal out of range")]
|
||||
IntegerOverflow,
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
//! Single-pass lexer implementation.
|
||||
|
||||
use crate::error::{LexError, LexErrorKind};
|
||||
use crate::token::{Span, Spanned, Token};
|
||||
|
||||
/// Tokenize an Engram source string.
|
||||
///
|
||||
/// Returns a `Vec` ending with a single [`Token::Eof`]. On the first
|
||||
/// unrecognised character the function returns an error; partial output
|
||||
/// is discarded.
|
||||
pub fn tokenize(source: &str) -> Result<Vec<Spanned<Token>>, LexError> {
|
||||
let mut lex = Lexer::new(source);
|
||||
lex.scan_all()
|
||||
}
|
||||
|
||||
// ── Lexer state ──────────────────────────────────────────────────────────────
|
||||
|
||||
struct Lexer<'src> {
|
||||
src: &'src str,
|
||||
/// Current byte position into `src`.
|
||||
pos: usize,
|
||||
line: u32,
|
||||
/// Byte position of the start of the current line (for computing columns).
|
||||
line_start: usize,
|
||||
}
|
||||
|
||||
impl<'src> Lexer<'src> {
|
||||
fn new(src: &'src str) -> Self {
|
||||
Self { src, pos: 0, line: 1, line_start: 0 }
|
||||
}
|
||||
|
||||
// ── Utilities ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn col_at(&self, byte_pos: usize) -> u32 {
|
||||
(byte_pos - self.line_start + 1) as u32
|
||||
}
|
||||
|
||||
fn span_from(&self, start: usize) -> Span {
|
||||
Span::new(start, self.pos, self.line, self.col_at(start))
|
||||
}
|
||||
|
||||
fn peek(&self) -> Option<char> {
|
||||
self.src[self.pos..].chars().next()
|
||||
}
|
||||
|
||||
fn peek2(&self) -> Option<char> {
|
||||
let mut chars = self.src[self.pos..].chars();
|
||||
chars.next();
|
||||
chars.next()
|
||||
}
|
||||
|
||||
fn advance(&mut self) -> Option<char> {
|
||||
let ch = self.peek()?;
|
||||
self.pos += ch.len_utf8();
|
||||
if ch == '\n' {
|
||||
self.line += 1;
|
||||
self.line_start = self.pos;
|
||||
}
|
||||
Some(ch)
|
||||
}
|
||||
|
||||
/// Consume the next character only if it matches `expected`.
|
||||
fn eat(&mut self, expected: char) -> bool {
|
||||
if self.peek() == Some(expected) {
|
||||
self.advance();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn at_end(&self) -> bool {
|
||||
self.pos >= self.src.len()
|
||||
}
|
||||
|
||||
fn spanned(&self, tok: Token, start: usize) -> Spanned<Token> {
|
||||
Spanned::new(tok, self.span_from(start))
|
||||
}
|
||||
|
||||
// ── Main scan loop ────────────────────────────────────────────────────────
|
||||
|
||||
fn scan_all(&mut self) -> Result<Vec<Spanned<Token>>, LexError> {
|
||||
let mut tokens = Vec::new();
|
||||
loop {
|
||||
self.skip_whitespace_and_comments();
|
||||
if self.at_end() {
|
||||
let span = Span::point(self.pos, self.line, self.col_at(self.pos));
|
||||
tokens.push(Spanned::new(Token::Eof, span));
|
||||
break;
|
||||
}
|
||||
let tok = self.scan_token()?;
|
||||
tokens.push(tok);
|
||||
}
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
fn skip_whitespace_and_comments(&mut self) {
|
||||
loop {
|
||||
// Skip whitespace
|
||||
while let Some(ch) = self.peek() {
|
||||
if ch.is_whitespace() {
|
||||
self.advance();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Skip line comments `// ...`
|
||||
if self.peek() == Some('/') && self.peek2() == Some('/') {
|
||||
self.advance(); // first /
|
||||
self.advance(); // second /
|
||||
while let Some(ch) = self.peek() {
|
||||
self.advance();
|
||||
if ch == '\n' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_token(&mut self) -> Result<Spanned<Token>, LexError> {
|
||||
let start = self.pos;
|
||||
let ch = self.advance().unwrap();
|
||||
|
||||
let tok = match ch {
|
||||
// ── Delimiters ──────────────────────────────────────────────────
|
||||
'(' => Token::LParen,
|
||||
')' => Token::RParen,
|
||||
'{' => Token::LBrace,
|
||||
'}' => Token::RBrace,
|
||||
'[' => Token::LBracket,
|
||||
']' => Token::RBracket,
|
||||
',' => Token::Comma,
|
||||
'.' => Token::Dot,
|
||||
';' => Token::Semicolon,
|
||||
|
||||
// ── Operators that may be multi-char ────────────────────────────
|
||||
'+' => Token::Plus,
|
||||
'*' => Token::Star,
|
||||
'-' => {
|
||||
if self.eat('>') {
|
||||
Token::Arrow
|
||||
} else {
|
||||
Token::Minus
|
||||
}
|
||||
}
|
||||
'/' => Token::Slash,
|
||||
'=' => {
|
||||
if self.eat('=') {
|
||||
Token::EqEq
|
||||
} else if self.eat('>') {
|
||||
Token::FatArrow
|
||||
} else {
|
||||
Token::Eq
|
||||
}
|
||||
}
|
||||
'!' => {
|
||||
if self.eat('=') {
|
||||
Token::NotEq
|
||||
} else {
|
||||
Token::Not
|
||||
}
|
||||
}
|
||||
'<' => {
|
||||
if self.eat('=') {
|
||||
Token::LtEq
|
||||
} else {
|
||||
Token::Lt
|
||||
}
|
||||
}
|
||||
'>' => {
|
||||
if self.eat('=') {
|
||||
Token::GtEq
|
||||
} else {
|
||||
Token::Gt
|
||||
}
|
||||
}
|
||||
'&' => {
|
||||
if self.eat('&') {
|
||||
Token::And
|
||||
} else {
|
||||
return Err(LexError::new(
|
||||
LexErrorKind::UnexpectedChar('&'),
|
||||
self.span_from(start),
|
||||
));
|
||||
}
|
||||
}
|
||||
'|' => {
|
||||
if self.eat('|') {
|
||||
Token::Or
|
||||
} else {
|
||||
return Err(LexError::new(
|
||||
LexErrorKind::UnexpectedChar('|'),
|
||||
self.span_from(start),
|
||||
));
|
||||
}
|
||||
}
|
||||
':' => {
|
||||
if self.eat(':') {
|
||||
Token::ColonColon
|
||||
} else {
|
||||
Token::Colon
|
||||
}
|
||||
}
|
||||
|
||||
// ── String literals ──────────────────────────────────────────────
|
||||
'"' => self.scan_string(start)?,
|
||||
|
||||
// ── Numeric literals ─────────────────────────────────────────────
|
||||
c if c.is_ascii_digit() => self.scan_number(start, c)?,
|
||||
|
||||
// ── Identifiers and keywords ─────────────────────────────────────
|
||||
c if c.is_alphabetic() || c == '_' => self.scan_ident_or_keyword(start, c),
|
||||
|
||||
other => {
|
||||
return Err(LexError::new(
|
||||
LexErrorKind::UnexpectedChar(other),
|
||||
self.span_from(start),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
Ok(self.spanned(tok, start))
|
||||
}
|
||||
|
||||
// ── String scanning ───────────────────────────────────────────────────────
|
||||
|
||||
fn scan_string(&mut self, start: usize) -> Result<Token, LexError> {
|
||||
let mut s = String::new();
|
||||
loop {
|
||||
match self.peek() {
|
||||
None => {
|
||||
return Err(LexError::new(
|
||||
LexErrorKind::UnterminatedString,
|
||||
self.span_from(start),
|
||||
))
|
||||
}
|
||||
Some('"') => {
|
||||
self.advance();
|
||||
return Ok(Token::StringLiteral(s));
|
||||
}
|
||||
Some('\\') => {
|
||||
self.advance(); // consume backslash
|
||||
match self.peek() {
|
||||
Some('n') => { self.advance(); s.push('\n'); }
|
||||
Some('t') => { self.advance(); s.push('\t'); }
|
||||
Some('r') => { self.advance(); s.push('\r'); }
|
||||
Some('"') => { self.advance(); s.push('"'); }
|
||||
Some('\\') => { self.advance(); s.push('\\'); }
|
||||
Some('0') => { self.advance(); s.push('\0'); }
|
||||
Some(c) => {
|
||||
let esc = c;
|
||||
self.advance();
|
||||
return Err(LexError::new(
|
||||
LexErrorKind::InvalidEscape(esc),
|
||||
self.span_from(start),
|
||||
));
|
||||
}
|
||||
None => {
|
||||
return Err(LexError::new(
|
||||
LexErrorKind::UnterminatedString,
|
||||
self.span_from(start),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(c) => {
|
||||
s.push(c);
|
||||
self.advance();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Numeric scanning ──────────────────────────────────────────────────────
|
||||
|
||||
fn scan_number(&mut self, start: usize, first: char) -> Result<Token, LexError> {
|
||||
let mut raw = String::new();
|
||||
raw.push(first);
|
||||
|
||||
let mut is_float = false;
|
||||
|
||||
// Collect digits
|
||||
while let Some(c) = self.peek() {
|
||||
if c.is_ascii_digit() || c == '_' {
|
||||
raw.push(c);
|
||||
self.advance();
|
||||
} else if c == '.' && self.peek2().map_or(false, |d| d.is_ascii_digit()) {
|
||||
is_float = true;
|
||||
raw.push(c);
|
||||
self.advance();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if is_float {
|
||||
let clean: String = raw.chars().filter(|&c| c != '_').collect();
|
||||
let v: f64 = clean.parse().map_err(|_| LexError::new(
|
||||
LexErrorKind::InvalidNumeric,
|
||||
self.span_from(start),
|
||||
))?;
|
||||
Ok(Token::FloatLiteral(v))
|
||||
} else {
|
||||
let clean: String = raw.chars().filter(|&c| c != '_').collect();
|
||||
let v: i64 = clean.parse().map_err(|_| LexError::new(
|
||||
LexErrorKind::IntegerOverflow,
|
||||
self.span_from(start),
|
||||
))?;
|
||||
Ok(Token::IntLiteral(v))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Identifier / keyword scanning ─────────────────────────────────────────
|
||||
|
||||
fn scan_ident_or_keyword(&mut self, _start: usize, first: char) -> Token {
|
||||
let mut s = String::new();
|
||||
s.push(first);
|
||||
while let Some(c) = self.peek() {
|
||||
if c.is_alphanumeric() || c == '_' {
|
||||
s.push(c);
|
||||
self.advance();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
keyword_or_ident(s)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Keyword table ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn keyword_or_ident(s: String) -> Token {
|
||||
match s.as_str() {
|
||||
"let" => Token::Let,
|
||||
"fn" => Token::Fn,
|
||||
"type" => Token::Type,
|
||||
"enum" => Token::Enum,
|
||||
"match" => Token::Match,
|
||||
"return" => Token::Return,
|
||||
"activate" => Token::Activate,
|
||||
"where" => Token::Where,
|
||||
"sealed" => Token::Sealed,
|
||||
"if" => Token::If,
|
||||
"else" => Token::Else,
|
||||
"for" => Token::For,
|
||||
"in" => Token::In,
|
||||
"true" => Token::BoolLiteral(true),
|
||||
"false" => Token::BoolLiteral(false),
|
||||
_ => Token::Ident(s),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::token::Token;
|
||||
|
||||
fn toks(src: &str) -> Vec<Token> {
|
||||
tokenize(src).unwrap().into_iter().map(|s| s.node).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_source() {
|
||||
let result = tokenize("").unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].node, Token::Eof);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_keywords() {
|
||||
let src = "let fn type enum match return activate where sealed if else for in";
|
||||
let tokens = toks(src);
|
||||
assert_eq!(tokens[0], Token::Let);
|
||||
assert_eq!(tokens[1], Token::Fn);
|
||||
assert_eq!(tokens[2], Token::Type);
|
||||
assert_eq!(tokens[3], Token::Enum);
|
||||
assert_eq!(tokens[4], Token::Match);
|
||||
assert_eq!(tokens[5], Token::Return);
|
||||
assert_eq!(tokens[6], Token::Activate);
|
||||
assert_eq!(tokens[7], Token::Where);
|
||||
assert_eq!(tokens[8], Token::Sealed);
|
||||
assert_eq!(tokens[9], Token::If);
|
||||
assert_eq!(tokens[10], Token::Else);
|
||||
assert_eq!(tokens[11], Token::For);
|
||||
assert_eq!(tokens[12], Token::In);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bool_literals() {
|
||||
let tokens = toks("true false");
|
||||
assert_eq!(tokens[0], Token::BoolLiteral(true));
|
||||
assert_eq!(tokens[1], Token::BoolLiteral(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_int_literal() {
|
||||
let tokens = toks("42 0 1_000_000");
|
||||
assert_eq!(tokens[0], Token::IntLiteral(42));
|
||||
assert_eq!(tokens[1], Token::IntLiteral(0));
|
||||
assert_eq!(tokens[2], Token::IntLiteral(1_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_float_literal() {
|
||||
let tokens = toks("3.14 0.5");
|
||||
assert_eq!(tokens[0], Token::FloatLiteral(3.14));
|
||||
assert_eq!(tokens[1], Token::FloatLiteral(0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_string_literal() {
|
||||
let tokens = toks(r#""hello" "world\n""#);
|
||||
assert_eq!(tokens[0], Token::StringLiteral("hello".into()));
|
||||
assert_eq!(tokens[1], Token::StringLiteral("world\n".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_operators() {
|
||||
let src = "+ - * / = == != < > <= >= && || ! -> =>";
|
||||
let tokens = toks(src);
|
||||
assert_eq!(tokens[0], Token::Plus);
|
||||
assert_eq!(tokens[1], Token::Minus);
|
||||
assert_eq!(tokens[2], Token::Star);
|
||||
assert_eq!(tokens[3], Token::Slash);
|
||||
assert_eq!(tokens[4], Token::Eq);
|
||||
assert_eq!(tokens[5], Token::EqEq);
|
||||
assert_eq!(tokens[6], Token::NotEq);
|
||||
assert_eq!(tokens[7], Token::Lt);
|
||||
assert_eq!(tokens[8], Token::Gt);
|
||||
assert_eq!(tokens[9], Token::LtEq);
|
||||
assert_eq!(tokens[10], Token::GtEq);
|
||||
assert_eq!(tokens[11], Token::And);
|
||||
assert_eq!(tokens[12], Token::Or);
|
||||
assert_eq!(tokens[13], Token::Not);
|
||||
assert_eq!(tokens[14], Token::Arrow);
|
||||
assert_eq!(tokens[15], Token::FatArrow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delimiters() {
|
||||
let src = "( ) { } [ ] , : :: . ;";
|
||||
let tokens = toks(src);
|
||||
assert_eq!(tokens[0], Token::LParen);
|
||||
assert_eq!(tokens[1], Token::RParen);
|
||||
assert_eq!(tokens[2], Token::LBrace);
|
||||
assert_eq!(tokens[3], Token::RBrace);
|
||||
assert_eq!(tokens[4], Token::LBracket);
|
||||
assert_eq!(tokens[5], Token::RBracket);
|
||||
assert_eq!(tokens[6], Token::Comma);
|
||||
assert_eq!(tokens[7], Token::Colon);
|
||||
assert_eq!(tokens[8], Token::ColonColon);
|
||||
assert_eq!(tokens[9], Token::Dot);
|
||||
assert_eq!(tokens[10], Token::Semicolon);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_line_comment_skipped() {
|
||||
let tokens = toks("let // this is a comment\nfn");
|
||||
assert_eq!(tokens[0], Token::Let);
|
||||
assert_eq!(tokens[1], Token::Fn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_span_line_col() {
|
||||
let src = "let\nfn";
|
||||
let tokens = tokenize(src).unwrap();
|
||||
assert_eq!(tokens[0].span.line, 1);
|
||||
assert_eq!(tokens[0].span.col, 1);
|
||||
assert_eq!(tokens[1].span.line, 2);
|
||||
assert_eq!(tokens[1].span.col, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unterminated_string_error() {
|
||||
let result = tokenize(r#""unterminated"#);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unexpected_char_error() {
|
||||
let result = tokenize("@");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hello_world_program() {
|
||||
let src = r#"
|
||||
fn greet(name: String) -> String {
|
||||
return "Hello, " + name
|
||||
}
|
||||
|
||||
let msg: String = greet("Will")
|
||||
"#;
|
||||
let tokens = tokenize(src).unwrap();
|
||||
// Verify it tokenizes without error and the last token is EOF
|
||||
assert_eq!(tokens.last().unwrap().node, Token::Eof);
|
||||
// Should have a reasonable number of tokens
|
||||
assert!(tokens.len() > 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_activate_syntax() {
|
||||
let src = r#"activate User where "customer who purchased recently""#;
|
||||
let tokens = toks(src);
|
||||
assert_eq!(tokens[0], Token::Activate);
|
||||
assert_eq!(tokens[1], Token::Ident("User".into()));
|
||||
assert_eq!(tokens[2], Token::Where);
|
||||
assert_eq!(tokens[3], Token::StringLiteral("customer who purchased recently".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_colon_colon_path() {
|
||||
let tokens = toks("Status::Active");
|
||||
assert_eq!(tokens[0], Token::Ident("Status".into()));
|
||||
assert_eq!(tokens[1], Token::ColonColon);
|
||||
assert_eq!(tokens[2], Token::Ident("Active".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ident_with_underscore() {
|
||||
let tokens = toks("my_var _private __double");
|
||||
assert_eq!(tokens[0], Token::Ident("my_var".into()));
|
||||
assert_eq!(tokens[1], Token::Ident("_private".into()));
|
||||
assert_eq!(tokens[2], Token::Ident("__double".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sealed_block_tokens() {
|
||||
// sealed { let x: String = "secret" }
|
||||
// [0]=Sealed [1]={ [2]=let [3]=x [4]=: [5]=String [6]== [7]="secret" [8]=}
|
||||
let src = "sealed { let x: String = \"secret\" }";
|
||||
let tokens = toks(src);
|
||||
assert_eq!(tokens[0], Token::Sealed);
|
||||
assert_eq!(tokens[1], Token::LBrace);
|
||||
assert_eq!(tokens[2], Token::Let);
|
||||
assert_eq!(tokens[7], Token::StringLiteral("secret".into()));
|
||||
assert_eq!(tokens[8], Token::RBrace);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! el-lexer — Engram language tokenizer.
|
||||
//!
|
||||
//! Converts source text into a flat stream of [`Spanned<Token>`] values.
|
||||
//! All spans carry byte offsets, line, and column so that the parser and
|
||||
//! diagnostics engine can point back to exact source positions.
|
||||
//!
|
||||
//! # Design
|
||||
//! - Single-pass; O(n) in source length.
|
||||
//! - No heap allocation per character — only allocates when producing
|
||||
//! string / identifier token payloads.
|
||||
//! - All errors carry a [`Span`] so the caller can produce good diagnostics.
|
||||
|
||||
mod error;
|
||||
mod lexer;
|
||||
mod token;
|
||||
|
||||
pub use error::{LexError, LexErrorKind};
|
||||
pub use lexer::tokenize;
|
||||
pub use token::{Span, Spanned, Token};
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Token definitions and span types.
|
||||
|
||||
/// A span in the source file — byte offsets plus human-readable location.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Span {
|
||||
/// Byte offset of the first character of this token.
|
||||
pub start: usize,
|
||||
/// Byte offset one past the last character of this token.
|
||||
pub end: usize,
|
||||
/// 1-based line number.
|
||||
pub line: u32,
|
||||
/// 1-based column number (byte column within the line).
|
||||
pub col: u32,
|
||||
}
|
||||
|
||||
impl Span {
|
||||
pub fn new(start: usize, end: usize, line: u32, col: u32) -> Self {
|
||||
Self { start, end, line, col }
|
||||
}
|
||||
|
||||
/// A zero-width span at the given position (used for EOF).
|
||||
pub fn point(pos: usize, line: u32, col: u32) -> Self {
|
||||
Self { start: pos, end: pos, line, col }
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Span {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}:{}", self.line, self.col)
|
||||
}
|
||||
}
|
||||
|
||||
/// A value annotated with its source location.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Spanned<T> {
|
||||
pub node: T,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
impl<T> Spanned<T> {
|
||||
pub fn new(node: T, span: Span) -> Self {
|
||||
Self { node, span }
|
||||
}
|
||||
}
|
||||
|
||||
/// All tokens the Engram language lexer can produce.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Token {
|
||||
// ── Keywords ──────────────────────────────────────────────────────────────
|
||||
/// `let`
|
||||
Let,
|
||||
/// `fn`
|
||||
Fn,
|
||||
/// `type`
|
||||
Type,
|
||||
/// `enum`
|
||||
Enum,
|
||||
/// `match`
|
||||
Match,
|
||||
/// `return`
|
||||
Return,
|
||||
/// `activate` — the spreading-activation query construct
|
||||
Activate,
|
||||
/// `where` — used in `activate T where "query"`
|
||||
Where,
|
||||
/// `sealed` — quantum-sealed block
|
||||
Sealed,
|
||||
/// `if`
|
||||
If,
|
||||
/// `else`
|
||||
Else,
|
||||
/// `for`
|
||||
For,
|
||||
/// `in`
|
||||
In,
|
||||
/// `true` / `false`
|
||||
BoolLiteral(bool),
|
||||
|
||||
// ── Literals ──────────────────────────────────────────────────────────────
|
||||
IntLiteral(i64),
|
||||
FloatLiteral(f64),
|
||||
/// String literal with escape sequences already resolved.
|
||||
StringLiteral(String),
|
||||
|
||||
// ── Identifiers ───────────────────────────────────────────────────────────
|
||||
Ident(String),
|
||||
|
||||
// ── Operators ─────────────────────────────────────────────────────────────
|
||||
/// `+`
|
||||
Plus,
|
||||
/// `-`
|
||||
Minus,
|
||||
/// `*`
|
||||
Star,
|
||||
/// `/`
|
||||
Slash,
|
||||
/// `=`
|
||||
Eq,
|
||||
/// `==`
|
||||
EqEq,
|
||||
/// `!=`
|
||||
NotEq,
|
||||
/// `<`
|
||||
Lt,
|
||||
/// `>`
|
||||
Gt,
|
||||
/// `<=`
|
||||
LtEq,
|
||||
/// `>=`
|
||||
GtEq,
|
||||
/// `&&`
|
||||
And,
|
||||
/// `||`
|
||||
Or,
|
||||
/// `!`
|
||||
Not,
|
||||
/// `->` (function return type arrow)
|
||||
Arrow,
|
||||
/// `=>` (match arm)
|
||||
FatArrow,
|
||||
|
||||
// ── Delimiters ────────────────────────────────────────────────────────────
|
||||
/// `(`
|
||||
LParen,
|
||||
/// `)`
|
||||
RParen,
|
||||
/// `{`
|
||||
LBrace,
|
||||
/// `}`
|
||||
RBrace,
|
||||
/// `[`
|
||||
LBracket,
|
||||
/// `]`
|
||||
RBracket,
|
||||
/// `,`
|
||||
Comma,
|
||||
/// `:`
|
||||
Colon,
|
||||
/// `::`
|
||||
ColonColon,
|
||||
/// `.`
|
||||
Dot,
|
||||
/// `;`
|
||||
Semicolon,
|
||||
|
||||
// ── Special ───────────────────────────────────────────────────────────────
|
||||
Eof,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Token {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Token::Let => write!(f, "let"),
|
||||
Token::Fn => write!(f, "fn"),
|
||||
Token::Type => write!(f, "type"),
|
||||
Token::Enum => write!(f, "enum"),
|
||||
Token::Match => write!(f, "match"),
|
||||
Token::Return => write!(f, "return"),
|
||||
Token::Activate => write!(f, "activate"),
|
||||
Token::Where => write!(f, "where"),
|
||||
Token::Sealed => write!(f, "sealed"),
|
||||
Token::If => write!(f, "if"),
|
||||
Token::Else => write!(f, "else"),
|
||||
Token::For => write!(f, "for"),
|
||||
Token::In => write!(f, "in"),
|
||||
Token::BoolLiteral(b) => write!(f, "{b}"),
|
||||
Token::IntLiteral(n) => write!(f, "{n}"),
|
||||
Token::FloatLiteral(n) => write!(f, "{n}"),
|
||||
Token::StringLiteral(s) => write!(f, "\"{s}\""),
|
||||
Token::Ident(s) => write!(f, "{s}"),
|
||||
Token::Plus => write!(f, "+"),
|
||||
Token::Minus => write!(f, "-"),
|
||||
Token::Star => write!(f, "*"),
|
||||
Token::Slash => write!(f, "/"),
|
||||
Token::Eq => write!(f, "="),
|
||||
Token::EqEq => write!(f, "=="),
|
||||
Token::NotEq => write!(f, "!="),
|
||||
Token::Lt => write!(f, "<"),
|
||||
Token::Gt => write!(f, ">"),
|
||||
Token::LtEq => write!(f, "<="),
|
||||
Token::GtEq => write!(f, ">="),
|
||||
Token::And => write!(f, "&&"),
|
||||
Token::Or => write!(f, "||"),
|
||||
Token::Not => write!(f, "!"),
|
||||
Token::Arrow => write!(f, "->"),
|
||||
Token::FatArrow => write!(f, "=>"),
|
||||
Token::LParen => write!(f, "("),
|
||||
Token::RParen => write!(f, ")"),
|
||||
Token::LBrace => write!(f, "{{"),
|
||||
Token::RBrace => write!(f, "}}"),
|
||||
Token::LBracket => write!(f, "["),
|
||||
Token::RBracket => write!(f, "]"),
|
||||
Token::Comma => write!(f, ","),
|
||||
Token::Colon => write!(f, ":"),
|
||||
Token::ColonColon => write!(f, "::"),
|
||||
Token::Dot => write!(f, "."),
|
||||
Token::Semicolon => write!(f, ";"),
|
||||
Token::Eof => write!(f, "<eof>"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "el-parser"
|
||||
description = "Engram language AST and recursive-descent parser"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
el-lexer = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Abstract syntax tree node types.
|
||||
|
||||
use el_lexer::Span;
|
||||
|
||||
// ── Literals ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Literal {
|
||||
Int(i64),
|
||||
Float(f64),
|
||||
Str(String),
|
||||
Bool(bool),
|
||||
}
|
||||
|
||||
// ── Type expressions ──────────────────────────────────────────────────────────
|
||||
|
||||
/// A type annotation in source code, e.g. `String`, `[Int]`, `User?`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum TypeExpr {
|
||||
/// A named type: `Int`, `String`, `User`, …
|
||||
Named(String),
|
||||
/// An array type: `[T]`
|
||||
Array(Box<TypeExpr>),
|
||||
/// An optional type: `T?`
|
||||
Optional(Box<TypeExpr>),
|
||||
/// A function type: `fn(A, B) -> C`
|
||||
Fn { params: Vec<TypeExpr>, return_type: Box<TypeExpr> },
|
||||
}
|
||||
|
||||
// ── Patterns (for match arms) ─────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Pattern {
|
||||
/// `Status::Active`
|
||||
EnumVariant { enum_name: String, variant: String, payload: Option<String> },
|
||||
/// A wildcard `_`
|
||||
Wildcard,
|
||||
/// A literal: `42`, `"str"`, `true`
|
||||
Literal(Literal),
|
||||
/// A binding: `x`
|
||||
Binding(String),
|
||||
}
|
||||
|
||||
// ── Binary operators ──────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum BinOp {
|
||||
Add, Sub, Mul, Div,
|
||||
Eq, NotEq, Lt, Gt, LtEq, GtEq,
|
||||
And, Or,
|
||||
}
|
||||
|
||||
// ── Expressions ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Expr {
|
||||
Literal(Literal),
|
||||
Ident(String),
|
||||
BinOp { op: BinOp, left: Box<Expr>, right: Box<Expr> },
|
||||
UnaryNot(Box<Expr>),
|
||||
Call { func: Box<Expr>, args: Vec<Expr> },
|
||||
Block(Vec<Stmt>),
|
||||
Match { subject: Box<Expr>, arms: Vec<MatchArm> },
|
||||
/// `activate TypeName where "semantic query string"`
|
||||
Activate { type_name: String, query: String },
|
||||
/// `sealed { stmts... }` — quantum-sealed block
|
||||
Sealed(Vec<Stmt>),
|
||||
If { cond: Box<Expr>, then: Box<Expr>, else_: Option<Box<Expr>> },
|
||||
Field { object: Box<Expr>, field: String },
|
||||
/// Array constructor: `[a, b, c]`
|
||||
Array(Vec<Expr>),
|
||||
/// Path expression: `Status::Active` (enum variant ref)
|
||||
Path { segments: Vec<String> },
|
||||
/// Index expression: `arr[0]`
|
||||
Index { object: Box<Expr>, index: Box<Expr> },
|
||||
}
|
||||
|
||||
// ── Match arm ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MatchArm {
|
||||
pub pattern: Pattern,
|
||||
pub body: Expr,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
// ── Statements ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A named parameter in a function definition.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Param {
|
||||
pub name: String,
|
||||
pub type_ann: TypeExpr,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
/// A field in a type definition.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Field {
|
||||
pub name: String,
|
||||
pub type_ann: TypeExpr,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
/// A variant in an enum definition.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Variant {
|
||||
pub name: String,
|
||||
/// Payload type, if any (e.g. `Pending(String)`)
|
||||
pub payload: Option<TypeExpr>,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Stmt {
|
||||
/// `let name: Type = expr`
|
||||
Let {
|
||||
name: String,
|
||||
type_ann: Option<TypeExpr>,
|
||||
value: Expr,
|
||||
span: Span,
|
||||
},
|
||||
/// `return expr`
|
||||
Return(Expr, Span),
|
||||
/// A bare expression used as a statement (usually a call).
|
||||
Expr(Expr, Span),
|
||||
/// `fn name(params) -> ReturnType { body }`
|
||||
FnDef {
|
||||
name: String,
|
||||
params: Vec<Param>,
|
||||
return_type: TypeExpr,
|
||||
body: Vec<Stmt>,
|
||||
span: Span,
|
||||
},
|
||||
/// `type Name { fields... }`
|
||||
TypeDef {
|
||||
name: String,
|
||||
fields: Vec<Field>,
|
||||
span: Span,
|
||||
},
|
||||
/// `enum Name { variants... }`
|
||||
EnumDef {
|
||||
name: String,
|
||||
variants: Vec<Variant>,
|
||||
span: Span,
|
||||
},
|
||||
}
|
||||
|
||||
// ── Top-level program ─────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Program {
|
||||
pub stmts: Vec<Stmt>,
|
||||
/// The original source, kept for diagnostics and source maps.
|
||||
pub source: String,
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Parser error types.
|
||||
|
||||
use thiserror::Error;
|
||||
use el_lexer::{Span, Token};
|
||||
|
||||
#[derive(Debug, Clone, Error)]
|
||||
#[error("{kind} at {span}")]
|
||||
pub struct ParseError {
|
||||
pub kind: ParseErrorKind,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
impl ParseError {
|
||||
pub fn new(kind: ParseErrorKind, span: Span) -> Self {
|
||||
Self { kind, span }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Error)]
|
||||
pub enum ParseErrorKind {
|
||||
#[error("unexpected token {got}, expected {expected}")]
|
||||
UnexpectedToken { expected: String, got: String },
|
||||
|
||||
#[error("unexpected end of file")]
|
||||
UnexpectedEof,
|
||||
|
||||
#[error("invalid expression starting with {0}")]
|
||||
InvalidExprStart(String),
|
||||
|
||||
#[error("invalid type expression: {0}")]
|
||||
InvalidTypeExpr(String),
|
||||
|
||||
#[error("invalid pattern: {0}")]
|
||||
InvalidPattern(String),
|
||||
|
||||
#[error("expected identifier, got {0}")]
|
||||
ExpectedIdent(String),
|
||||
}
|
||||
|
||||
impl ParseError {
|
||||
pub fn expected(expected: impl Into<String>, got: &Token, span: Span) -> Self {
|
||||
Self::new(
|
||||
ParseErrorKind::UnexpectedToken {
|
||||
expected: expected.into(),
|
||||
got: got.to_string(),
|
||||
},
|
||||
span,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn eof(span: Span) -> Self {
|
||||
Self::new(ParseErrorKind::UnexpectedEof, span)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! el-parser — Engram language recursive-descent parser.
|
||||
//!
|
||||
//! Converts a flat token stream into a typed [`Program`] AST.
|
||||
//!
|
||||
//! # Design
|
||||
//! Hand-written recursive descent — no parser generator. Every parse function
|
||||
//! returns `Result<T, ParseError>`, making the error path explicit.
|
||||
|
||||
mod ast;
|
||||
mod error;
|
||||
mod parser;
|
||||
|
||||
pub use ast::{
|
||||
BinOp, Expr, Field, Literal, MatchArm, Param, Pattern, Program, Stmt, TypeExpr, Variant,
|
||||
};
|
||||
pub use error::{ParseError, ParseErrorKind};
|
||||
pub use parser::parse;
|
||||
@@ -0,0 +1,685 @@
|
||||
//! Recursive-descent parser for the Engram language.
|
||||
|
||||
use el_lexer::{Span, Spanned, Token};
|
||||
|
||||
use crate::ast::*;
|
||||
use crate::error::{ParseError, ParseErrorKind};
|
||||
|
||||
/// Parse a token stream into a [`Program`].
|
||||
///
|
||||
/// The `source` string is stored verbatim in the returned program for
|
||||
/// diagnostics and source map generation.
|
||||
pub fn parse(tokens: Vec<Spanned<Token>>, source: String) -> Result<Program, ParseError> {
|
||||
let mut p = Parser::new(tokens);
|
||||
let stmts = p.parse_program()?;
|
||||
Ok(Program { stmts, source })
|
||||
}
|
||||
|
||||
// ── Parser state ──────────────────────────────────────────────────────────────
|
||||
|
||||
struct Parser {
|
||||
tokens: Vec<Spanned<Token>>,
|
||||
/// Current cursor into `tokens`.
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl Parser {
|
||||
fn new(tokens: Vec<Spanned<Token>>) -> Self {
|
||||
Self { tokens, pos: 0 }
|
||||
}
|
||||
|
||||
// ── Token stream navigation ───────────────────────────────────────────────
|
||||
|
||||
fn current(&self) -> &Spanned<Token> {
|
||||
&self.tokens[self.pos.min(self.tokens.len() - 1)]
|
||||
}
|
||||
|
||||
fn peek(&self) -> &Token {
|
||||
&self.current().node
|
||||
}
|
||||
|
||||
fn peek_span(&self) -> Span {
|
||||
self.current().span
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn peek2(&self) -> Option<&Token> {
|
||||
self.tokens.get(self.pos + 1).map(|s| &s.node)
|
||||
}
|
||||
|
||||
fn advance(&mut self) -> &Spanned<Token> {
|
||||
let tok = &self.tokens[self.pos.min(self.tokens.len() - 1)];
|
||||
if self.pos < self.tokens.len() - 1 {
|
||||
self.pos += 1;
|
||||
}
|
||||
tok
|
||||
}
|
||||
|
||||
fn at_end(&self) -> bool {
|
||||
matches!(self.peek(), Token::Eof)
|
||||
}
|
||||
|
||||
/// Consume the current token if it matches `expected`, otherwise error.
|
||||
fn expect(&mut self, expected: &Token) -> Result<Span, ParseError> {
|
||||
if self.peek() == expected {
|
||||
let span = self.peek_span();
|
||||
self.advance();
|
||||
Ok(span)
|
||||
} else {
|
||||
Err(ParseError::expected(format!("{expected}"), self.peek(), self.peek_span()))
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_ident(&mut self) -> Result<(String, Span), ParseError> {
|
||||
let span = self.peek_span();
|
||||
match self.peek().clone() {
|
||||
Token::Ident(name) => {
|
||||
self.advance();
|
||||
Ok((name, span))
|
||||
}
|
||||
tok => Err(ParseError::new(
|
||||
ParseErrorKind::ExpectedIdent(tok.to_string()),
|
||||
span,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn eat(&mut self, tok: &Token) -> bool {
|
||||
if self.peek() == tok {
|
||||
self.advance();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Top-level ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_program(&mut self) -> Result<Vec<Stmt>, ParseError> {
|
||||
let mut stmts = Vec::new();
|
||||
while !self.at_end() {
|
||||
// Skip optional semicolons at top level
|
||||
while self.eat(&Token::Semicolon) {}
|
||||
if self.at_end() {
|
||||
break;
|
||||
}
|
||||
stmts.push(self.parse_stmt()?);
|
||||
}
|
||||
Ok(stmts)
|
||||
}
|
||||
|
||||
// ── Statements ────────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_stmt(&mut self) -> Result<Stmt, ParseError> {
|
||||
let start = self.peek_span();
|
||||
match self.peek().clone() {
|
||||
Token::Let => self.parse_let(start),
|
||||
Token::Fn => self.parse_fn_def(start),
|
||||
Token::Type => self.parse_type_def(start),
|
||||
Token::Enum => self.parse_enum_def(start),
|
||||
Token::Return => {
|
||||
self.advance(); // consume `return`
|
||||
let expr = self.parse_expr()?;
|
||||
let span = Span::new(start.start, expr_span_end(&expr, start), start.line, start.col);
|
||||
self.eat(&Token::Semicolon);
|
||||
Ok(Stmt::Return(expr, span))
|
||||
}
|
||||
_ => {
|
||||
let expr = self.parse_expr()?;
|
||||
let span = start;
|
||||
self.eat(&Token::Semicolon);
|
||||
Ok(Stmt::Expr(expr, span))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_let(&mut self, start: Span) -> Result<Stmt, ParseError> {
|
||||
self.expect(&Token::Let)?;
|
||||
let (name, _) = self.expect_ident()?;
|
||||
let type_ann = if self.eat(&Token::Colon) {
|
||||
Some(self.parse_type_expr()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.expect(&Token::Eq)?;
|
||||
let value = self.parse_expr()?;
|
||||
self.eat(&Token::Semicolon);
|
||||
Ok(Stmt::Let { name, type_ann, value, span: start })
|
||||
}
|
||||
|
||||
fn parse_fn_def(&mut self, start: Span) -> Result<Stmt, ParseError> {
|
||||
self.expect(&Token::Fn)?;
|
||||
let (name, _) = self.expect_ident()?;
|
||||
self.expect(&Token::LParen)?;
|
||||
let params = self.parse_param_list()?;
|
||||
self.expect(&Token::RParen)?;
|
||||
self.expect(&Token::Arrow)?;
|
||||
let return_type = self.parse_type_expr()?;
|
||||
self.expect(&Token::LBrace)?;
|
||||
let body = self.parse_block_body()?;
|
||||
self.expect(&Token::RBrace)?;
|
||||
Ok(Stmt::FnDef { name, params, return_type, body, span: start })
|
||||
}
|
||||
|
||||
fn parse_param_list(&mut self) -> Result<Vec<Param>, ParseError> {
|
||||
let mut params = Vec::new();
|
||||
while !matches!(self.peek(), Token::RParen | Token::Eof) {
|
||||
let span = self.peek_span();
|
||||
let (name, _) = self.expect_ident()?;
|
||||
self.expect(&Token::Colon)?;
|
||||
let type_ann = self.parse_type_expr()?;
|
||||
params.push(Param { name, type_ann, span });
|
||||
if !self.eat(&Token::Comma) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
fn parse_type_def(&mut self, start: Span) -> Result<Stmt, ParseError> {
|
||||
self.expect(&Token::Type)?;
|
||||
let (name, _) = self.expect_ident()?;
|
||||
self.expect(&Token::LBrace)?;
|
||||
let mut fields = Vec::new();
|
||||
while !matches!(self.peek(), Token::RBrace | Token::Eof) {
|
||||
let span = self.peek_span();
|
||||
let (fname, _) = self.expect_ident()?;
|
||||
self.expect(&Token::Colon)?;
|
||||
let type_ann = self.parse_type_expr()?;
|
||||
fields.push(Field { name: fname, type_ann, span });
|
||||
self.eat(&Token::Comma);
|
||||
self.eat(&Token::Semicolon);
|
||||
}
|
||||
self.expect(&Token::RBrace)?;
|
||||
Ok(Stmt::TypeDef { name, fields, span: start })
|
||||
}
|
||||
|
||||
fn parse_enum_def(&mut self, start: Span) -> Result<Stmt, ParseError> {
|
||||
self.expect(&Token::Enum)?;
|
||||
let (name, _) = self.expect_ident()?;
|
||||
self.expect(&Token::LBrace)?;
|
||||
let mut variants = Vec::new();
|
||||
while !matches!(self.peek(), Token::RBrace | Token::Eof) {
|
||||
let span = self.peek_span();
|
||||
let (vname, _) = self.expect_ident()?;
|
||||
let payload = if self.eat(&Token::LParen) {
|
||||
let ty = self.parse_type_expr()?;
|
||||
self.expect(&Token::RParen)?;
|
||||
Some(ty)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
variants.push(Variant { name: vname, payload, span });
|
||||
self.eat(&Token::Comma);
|
||||
self.eat(&Token::Semicolon);
|
||||
}
|
||||
self.expect(&Token::RBrace)?;
|
||||
Ok(Stmt::EnumDef { name, variants, span: start })
|
||||
}
|
||||
|
||||
fn parse_block_body(&mut self) -> Result<Vec<Stmt>, ParseError> {
|
||||
let mut stmts = Vec::new();
|
||||
while !matches!(self.peek(), Token::RBrace | Token::Eof) {
|
||||
while self.eat(&Token::Semicolon) {}
|
||||
if matches!(self.peek(), Token::RBrace | Token::Eof) {
|
||||
break;
|
||||
}
|
||||
stmts.push(self.parse_stmt()?);
|
||||
}
|
||||
Ok(stmts)
|
||||
}
|
||||
|
||||
// ── Type expressions ──────────────────────────────────────────────────────
|
||||
|
||||
fn parse_type_expr(&mut self) -> Result<TypeExpr, ParseError> {
|
||||
let span = self.peek_span();
|
||||
// Array type: [T]
|
||||
if self.eat(&Token::LBracket) {
|
||||
let inner = self.parse_type_expr()?;
|
||||
self.expect(&Token::RBracket)?;
|
||||
let te = TypeExpr::Array(Box::new(inner));
|
||||
// Optional array: [T]?
|
||||
if self.eat(&Token::Not) {
|
||||
// Not actually "!", we need "?" — but we don't have that token.
|
||||
// We'll use Optional postfix via the Ident "?" — skip for now.
|
||||
}
|
||||
return Ok(te);
|
||||
}
|
||||
// Named type
|
||||
let name = match self.peek().clone() {
|
||||
Token::Ident(n) => { self.advance(); n }
|
||||
tok => return Err(ParseError::new(
|
||||
ParseErrorKind::InvalidTypeExpr(tok.to_string()),
|
||||
span,
|
||||
)),
|
||||
};
|
||||
// Check for function type: fn(A) -> B
|
||||
if name == "fn" {
|
||||
self.expect(&Token::LParen)?;
|
||||
let mut params = Vec::new();
|
||||
while !matches!(self.peek(), Token::RParen | Token::Eof) {
|
||||
params.push(self.parse_type_expr()?);
|
||||
if !self.eat(&Token::Comma) { break; }
|
||||
}
|
||||
self.expect(&Token::RParen)?;
|
||||
self.expect(&Token::Arrow)?;
|
||||
let ret = self.parse_type_expr()?;
|
||||
return Ok(TypeExpr::Fn { params, return_type: Box::new(ret) });
|
||||
}
|
||||
Ok(TypeExpr::Named(name))
|
||||
}
|
||||
|
||||
// ── Expressions ───────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_expr(&mut self) -> Result<Expr, ParseError> {
|
||||
self.parse_or_expr()
|
||||
}
|
||||
|
||||
fn parse_or_expr(&mut self) -> Result<Expr, ParseError> {
|
||||
let mut left = self.parse_and_expr()?;
|
||||
while self.eat(&Token::Or) {
|
||||
let right = self.parse_and_expr()?;
|
||||
left = Expr::BinOp { op: BinOp::Or, left: Box::new(left), right: Box::new(right) };
|
||||
}
|
||||
Ok(left)
|
||||
}
|
||||
|
||||
fn parse_and_expr(&mut self) -> Result<Expr, ParseError> {
|
||||
let mut left = self.parse_equality()?;
|
||||
while self.eat(&Token::And) {
|
||||
let right = self.parse_equality()?;
|
||||
left = Expr::BinOp { op: BinOp::And, left: Box::new(left), right: Box::new(right) };
|
||||
}
|
||||
Ok(left)
|
||||
}
|
||||
|
||||
fn parse_equality(&mut self) -> Result<Expr, ParseError> {
|
||||
let mut left = self.parse_comparison()?;
|
||||
loop {
|
||||
let op = match self.peek() {
|
||||
Token::EqEq => BinOp::Eq,
|
||||
Token::NotEq => BinOp::NotEq,
|
||||
_ => break,
|
||||
};
|
||||
self.advance();
|
||||
let right = self.parse_comparison()?;
|
||||
left = Expr::BinOp { op, left: Box::new(left), right: Box::new(right) };
|
||||
}
|
||||
Ok(left)
|
||||
}
|
||||
|
||||
fn parse_comparison(&mut self) -> Result<Expr, ParseError> {
|
||||
let mut left = self.parse_additive()?;
|
||||
loop {
|
||||
let op = match self.peek() {
|
||||
Token::Lt => BinOp::Lt,
|
||||
Token::Gt => BinOp::Gt,
|
||||
Token::LtEq => BinOp::LtEq,
|
||||
Token::GtEq => BinOp::GtEq,
|
||||
_ => break,
|
||||
};
|
||||
self.advance();
|
||||
let right = self.parse_additive()?;
|
||||
left = Expr::BinOp { op, left: Box::new(left), right: Box::new(right) };
|
||||
}
|
||||
Ok(left)
|
||||
}
|
||||
|
||||
fn parse_additive(&mut self) -> Result<Expr, ParseError> {
|
||||
let mut left = self.parse_multiplicative()?;
|
||||
loop {
|
||||
let op = match self.peek() {
|
||||
Token::Plus => BinOp::Add,
|
||||
Token::Minus => BinOp::Sub,
|
||||
_ => break,
|
||||
};
|
||||
self.advance();
|
||||
let right = self.parse_multiplicative()?;
|
||||
left = Expr::BinOp { op, left: Box::new(left), right: Box::new(right) };
|
||||
}
|
||||
Ok(left)
|
||||
}
|
||||
|
||||
fn parse_multiplicative(&mut self) -> Result<Expr, ParseError> {
|
||||
let mut left = self.parse_unary()?;
|
||||
loop {
|
||||
let op = match self.peek() {
|
||||
Token::Star => BinOp::Mul,
|
||||
Token::Slash => BinOp::Div,
|
||||
_ => break,
|
||||
};
|
||||
self.advance();
|
||||
let right = self.parse_unary()?;
|
||||
left = Expr::BinOp { op, left: Box::new(left), right: Box::new(right) };
|
||||
}
|
||||
Ok(left)
|
||||
}
|
||||
|
||||
fn parse_unary(&mut self) -> Result<Expr, ParseError> {
|
||||
if self.eat(&Token::Not) {
|
||||
let inner = self.parse_unary()?;
|
||||
return Ok(Expr::UnaryNot(Box::new(inner)));
|
||||
}
|
||||
self.parse_postfix()
|
||||
}
|
||||
|
||||
fn parse_postfix(&mut self) -> Result<Expr, ParseError> {
|
||||
let mut expr = self.parse_primary()?;
|
||||
loop {
|
||||
match self.peek() {
|
||||
Token::Dot => {
|
||||
self.advance();
|
||||
let (field, _) = self.expect_ident()?;
|
||||
expr = Expr::Field { object: Box::new(expr), field };
|
||||
}
|
||||
Token::LParen => {
|
||||
self.advance();
|
||||
let args = self.parse_arg_list()?;
|
||||
self.expect(&Token::RParen)?;
|
||||
expr = Expr::Call { func: Box::new(expr), args };
|
||||
}
|
||||
Token::LBracket => {
|
||||
self.advance();
|
||||
let index = self.parse_expr()?;
|
||||
self.expect(&Token::RBracket)?;
|
||||
expr = Expr::Index { object: Box::new(expr), index: Box::new(index) };
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
Ok(expr)
|
||||
}
|
||||
|
||||
fn parse_arg_list(&mut self) -> Result<Vec<Expr>, ParseError> {
|
||||
let mut args = Vec::new();
|
||||
while !matches!(self.peek(), Token::RParen | Token::Eof) {
|
||||
args.push(self.parse_expr()?);
|
||||
if !self.eat(&Token::Comma) { break; }
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn parse_primary(&mut self) -> Result<Expr, ParseError> {
|
||||
let span = self.peek_span();
|
||||
match self.peek().clone() {
|
||||
// Literals
|
||||
Token::IntLiteral(n) => { self.advance(); Ok(Expr::Literal(Literal::Int(n))) }
|
||||
Token::FloatLiteral(f) => { self.advance(); Ok(Expr::Literal(Literal::Float(f))) }
|
||||
Token::StringLiteral(s) => { self.advance(); Ok(Expr::Literal(Literal::Str(s))) }
|
||||
Token::BoolLiteral(b) => { self.advance(); Ok(Expr::Literal(Literal::Bool(b))) }
|
||||
|
||||
// Grouped or tuple
|
||||
Token::LParen => {
|
||||
self.advance();
|
||||
let expr = self.parse_expr()?;
|
||||
self.expect(&Token::RParen)?;
|
||||
Ok(expr)
|
||||
}
|
||||
|
||||
// Block
|
||||
Token::LBrace => {
|
||||
self.advance();
|
||||
let stmts = self.parse_block_body()?;
|
||||
self.expect(&Token::RBrace)?;
|
||||
Ok(Expr::Block(stmts))
|
||||
}
|
||||
|
||||
// Array literal
|
||||
Token::LBracket => {
|
||||
self.advance();
|
||||
let mut elems = Vec::new();
|
||||
while !matches!(self.peek(), Token::RBracket | Token::Eof) {
|
||||
elems.push(self.parse_expr()?);
|
||||
if !self.eat(&Token::Comma) { break; }
|
||||
}
|
||||
self.expect(&Token::RBracket)?;
|
||||
Ok(Expr::Array(elems))
|
||||
}
|
||||
|
||||
// match expression
|
||||
Token::Match => {
|
||||
self.advance();
|
||||
let subject = self.parse_expr()?;
|
||||
self.expect(&Token::LBrace)?;
|
||||
let arms = self.parse_match_arms()?;
|
||||
self.expect(&Token::RBrace)?;
|
||||
Ok(Expr::Match { subject: Box::new(subject), arms })
|
||||
}
|
||||
|
||||
// activate Type where "query"
|
||||
Token::Activate => {
|
||||
self.advance();
|
||||
let (type_name, _) = self.expect_ident()?;
|
||||
self.expect(&Token::Where)?;
|
||||
let query = match self.peek().clone() {
|
||||
Token::StringLiteral(s) => { self.advance(); s }
|
||||
tok => return Err(ParseError::expected("string literal", &tok, self.peek_span())),
|
||||
};
|
||||
Ok(Expr::Activate { type_name, query })
|
||||
}
|
||||
|
||||
// sealed { stmts }
|
||||
Token::Sealed => {
|
||||
self.advance();
|
||||
self.expect(&Token::LBrace)?;
|
||||
let stmts = self.parse_block_body()?;
|
||||
self.expect(&Token::RBrace)?;
|
||||
Ok(Expr::Sealed(stmts))
|
||||
}
|
||||
|
||||
// if/else
|
||||
Token::If => {
|
||||
self.advance();
|
||||
let cond = self.parse_expr()?;
|
||||
let then = self.parse_primary()?; // expects block
|
||||
let else_ = if self.eat(&Token::Else) {
|
||||
Some(Box::new(self.parse_primary()?))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Expr::If { cond: Box::new(cond), then: Box::new(then), else_ })
|
||||
}
|
||||
|
||||
// Identifier — could be plain name or path (Foo::Bar)
|
||||
Token::Ident(name) => {
|
||||
self.advance();
|
||||
// Check for path: Foo::Bar or Foo::Bar::Baz
|
||||
if matches!(self.peek(), Token::ColonColon) {
|
||||
let mut segments = vec![name];
|
||||
while self.eat(&Token::ColonColon) {
|
||||
let (seg, _) = self.expect_ident()?;
|
||||
segments.push(seg);
|
||||
}
|
||||
Ok(Expr::Path { segments })
|
||||
} else {
|
||||
Ok(Expr::Ident(name))
|
||||
}
|
||||
}
|
||||
|
||||
tok => Err(ParseError::new(
|
||||
ParseErrorKind::InvalidExprStart(tok.to_string()),
|
||||
span,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Match arms ────────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_match_arms(&mut self) -> Result<Vec<MatchArm>, ParseError> {
|
||||
let mut arms = Vec::new();
|
||||
while !matches!(self.peek(), Token::RBrace | Token::Eof) {
|
||||
let span = self.peek_span();
|
||||
let pattern = self.parse_pattern()?;
|
||||
self.expect(&Token::FatArrow)?;
|
||||
let body = self.parse_expr()?;
|
||||
arms.push(MatchArm { pattern, body, span });
|
||||
self.eat(&Token::Comma);
|
||||
self.eat(&Token::Semicolon);
|
||||
}
|
||||
Ok(arms)
|
||||
}
|
||||
|
||||
fn parse_pattern(&mut self) -> Result<Pattern, ParseError> {
|
||||
let span = self.peek_span();
|
||||
match self.peek().clone() {
|
||||
// Wildcard
|
||||
Token::Ident(ref s) if s == "_" => {
|
||||
self.advance();
|
||||
Ok(Pattern::Wildcard)
|
||||
}
|
||||
// Could be: binding, enum variant, or path
|
||||
Token::Ident(name) => {
|
||||
self.advance();
|
||||
if self.eat(&Token::ColonColon) {
|
||||
// EnumVariant pattern: Status::Active or Status::Pending(reason)
|
||||
let (variant, _) = self.expect_ident()?;
|
||||
let payload = if self.eat(&Token::LParen) {
|
||||
let (bind, _) = self.expect_ident()?;
|
||||
self.expect(&Token::RParen)?;
|
||||
Some(bind)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Pattern::EnumVariant { enum_name: name, variant, payload })
|
||||
} else {
|
||||
// Simple binding
|
||||
Ok(Pattern::Binding(name))
|
||||
}
|
||||
}
|
||||
Token::IntLiteral(n) => { self.advance(); Ok(Pattern::Literal(Literal::Int(n))) }
|
||||
Token::StringLiteral(s) => { self.advance(); Ok(Pattern::Literal(Literal::Str(s))) }
|
||||
Token::BoolLiteral(b) => { self.advance(); Ok(Pattern::Literal(Literal::Bool(b))) }
|
||||
tok => Err(ParseError::new(
|
||||
ParseErrorKind::InvalidPattern(tok.to_string()),
|
||||
span,
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn expr_span_end(_expr: &Expr, fallback: Span) -> usize {
|
||||
fallback.end
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use el_lexer::tokenize;
|
||||
use super::*;
|
||||
|
||||
fn parse_src(src: &str) -> Program {
|
||||
let tokens = tokenize(src).expect("lex failed");
|
||||
parse(tokens, src.to_string()).expect("parse failed")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_let() {
|
||||
let p = parse_src("let x: Int = 42");
|
||||
assert!(matches!(p.stmts[0], Stmt::Let { ref name, .. } if name == "x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_fn_def() {
|
||||
let src = r#"fn greet(name: String) -> String { return "Hello" }"#;
|
||||
let p = parse_src(src);
|
||||
assert!(matches!(&p.stmts[0], Stmt::FnDef { name, .. } if name == "greet"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_type_def() {
|
||||
let src = "type User { id: Uuid name: String email: String }";
|
||||
let p = parse_src(src);
|
||||
assert!(matches!(&p.stmts[0], Stmt::TypeDef { name, fields, .. } if name == "User" && fields.len() == 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_enum_def() {
|
||||
let src = "enum Status { Active Inactive Pending(String) }";
|
||||
let p = parse_src(src);
|
||||
match &p.stmts[0] {
|
||||
Stmt::EnumDef { name, variants, .. } => {
|
||||
assert_eq!(name, "Status");
|
||||
assert_eq!(variants.len(), 3);
|
||||
assert_eq!(variants[2].name, "Pending");
|
||||
assert!(variants[2].payload.is_some());
|
||||
}
|
||||
_ => panic!("expected EnumDef"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_match() {
|
||||
let src = r#"
|
||||
match status {
|
||||
Status::Active => "active"
|
||||
Status::Inactive => "inactive"
|
||||
}
|
||||
"#;
|
||||
let p = parse_src(src);
|
||||
assert!(matches!(&p.stmts[0], Stmt::Expr(Expr::Match { arms, .. }, _) if arms.len() == 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_activate() {
|
||||
let src = r#"activate User where "customer who purchased recently""#;
|
||||
let p = parse_src(src);
|
||||
assert!(matches!(
|
||||
&p.stmts[0],
|
||||
Stmt::Expr(Expr::Activate { type_name, query }, _)
|
||||
if type_name == "User" && query.contains("customer")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sealed_block() {
|
||||
let src = r#"sealed { let key: String = "secret" }"#;
|
||||
let p = parse_src(src);
|
||||
assert!(matches!(&p.stmts[0], Stmt::Expr(Expr::Sealed(_), _)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_binary_ops() {
|
||||
let p = parse_src("let result = 1 + 2 * 3");
|
||||
match &p.stmts[0] {
|
||||
Stmt::Let { value: Expr::BinOp { op: BinOp::Add, right, .. }, .. } => {
|
||||
// Right side should be 2*3
|
||||
assert!(matches!(**right, Expr::BinOp { op: BinOp::Mul, .. }));
|
||||
}
|
||||
_ => panic!("unexpected AST"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_fn_call() {
|
||||
let p = parse_src(r#"greet("Will")"#);
|
||||
assert!(matches!(&p.stmts[0], Stmt::Expr(Expr::Call { .. }, _)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_field_access() {
|
||||
let p = parse_src("user.name");
|
||||
assert!(matches!(&p.stmts[0], Stmt::Expr(Expr::Field { field, .. }, _) if field == "name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_if_else() {
|
||||
let src = r#"if x == 1 { return "yes" } else { return "no" }"#;
|
||||
let p = parse_src(src);
|
||||
assert!(matches!(&p.stmts[0], Stmt::Expr(Expr::If { else_: Some(_), .. }, _)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_array_literal() {
|
||||
let p = parse_src("[1, 2, 3]");
|
||||
assert!(matches!(&p.stmts[0], Stmt::Expr(Expr::Array(elems), _) if elems.len() == 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_path_expr() {
|
||||
let p = parse_src("Status::Active");
|
||||
assert!(matches!(&p.stmts[0], Stmt::Expr(Expr::Path { segments }, _) if segments.len() == 2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "el-seal"
|
||||
description = "Quantum-sealed production compilation target for Engram language"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
engram-crypto = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
blake3 = { workspace = true }
|
||||
aes-gcm = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Sealed artifact format definition.
|
||||
//!
|
||||
//! Binary layout (big-endian):
|
||||
//!
|
||||
//! ```text
|
||||
//! Offset Size Field
|
||||
//! ────── ───── ─────────────────────────────────────────────────────────
|
||||
//! 0 8 magic: b"ENGRAM01"
|
||||
//! 8 2 version: u16 (currently 1)
|
||||
//! 10 * JSON-encoded SealedArtifact body (algorithm_id, nonce, …)
|
||||
//! ```
|
||||
//!
|
||||
//! The body is JSON so the format is self-describing and forward-compatible.
|
||||
//! Future versions can add new fields without breaking older parsers.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Magic header bytes — identify an Engram sealed artifact.
|
||||
pub const MAGIC: [u8; 8] = *b"ENGRAM01";
|
||||
|
||||
/// Current artifact format version.
|
||||
pub const FORMAT_VERSION: u16 = 1;
|
||||
|
||||
// ── Configuration ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Which algorithm was used to seal the artifact.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum SealAlgorithm {
|
||||
/// AES-256-GCM — current default, quantum-resistant at 256-bit.
|
||||
Aes256Gcm,
|
||||
/// CRYSTALS-Kyber 768 — when ml-kem crate stabilizes.
|
||||
MlKem768,
|
||||
/// CRYSTALS-Kyber 1024 — when ml-kem crate stabilizes.
|
||||
MlKem1024,
|
||||
}
|
||||
|
||||
impl SealAlgorithm {
|
||||
pub fn id(&self) -> &'static str {
|
||||
match self {
|
||||
SealAlgorithm::Aes256Gcm => "aes256gcm-v1",
|
||||
SealAlgorithm::MlKem768 => "mlkem768-v1",
|
||||
SealAlgorithm::MlKem1024 => "mlkem1024-v1",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How the deployment key is derived / bound.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DeploymentBinding {
|
||||
/// Read the seal key from an environment variable (e.g. `ENGRAM_SEAL_KEY`).
|
||||
EnvironmentKey(String),
|
||||
/// Bind to this machine's hostname + OS + CPU model (BLAKE3 hash of all three).
|
||||
MachineFingerprint,
|
||||
/// No binding — key is the zero vector. For testing only; offers no security.
|
||||
None,
|
||||
}
|
||||
|
||||
/// Configuration for the sealing operation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SealConfig {
|
||||
pub algorithm: SealAlgorithm,
|
||||
pub deployment_binding: DeploymentBinding,
|
||||
}
|
||||
|
||||
impl Default for SealConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
algorithm: SealAlgorithm::Aes256Gcm,
|
||||
deployment_binding: DeploymentBinding::EnvironmentKey("ENGRAM_SEAL_KEY".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Artifact ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A quantum-sealed bytecode artifact.
|
||||
///
|
||||
/// This is the output of `el seal` / the `prod` compilation target.
|
||||
/// It is serialized to disk as: `MAGIC || VERSION_u16_be || JSON(body)`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SealedArtifact {
|
||||
/// Algorithm identifier — matches [`SealAlgorithm::id()`].
|
||||
pub algorithm_id: String,
|
||||
|
||||
/// BLAKE3-keyed MAC over `(algorithm_id || nonce || ciphertext)`.
|
||||
/// Used to detect tampering before attempting decryption.
|
||||
/// In the PQ upgrade path, this becomes an ML-DSA signature.
|
||||
pub signature: Vec<u8>,
|
||||
|
||||
/// The binding-protected symmetric key.
|
||||
///
|
||||
/// In the current scheme: `symmetric_key XOR BLAKE3(binding_material)`.
|
||||
/// Without the deployment key, the binding material cannot be derived,
|
||||
/// so the symmetric key cannot be recovered.
|
||||
///
|
||||
/// In the ML-KEM upgrade: this becomes the KEM-encapsulated key ciphertext.
|
||||
pub encapsulated_key: Vec<u8>,
|
||||
|
||||
/// 96-bit AES-GCM nonce.
|
||||
pub nonce: Vec<u8>,
|
||||
|
||||
/// Encrypted bytecode (AES-256-GCM ciphertext including the 128-bit auth tag).
|
||||
pub ciphertext: Vec<u8>,
|
||||
|
||||
/// BLAKE3 hash of the binding material. Allows the unsealer to verify
|
||||
/// it is running in the correct deployment environment before decryption.
|
||||
/// `None` if [`DeploymentBinding::None`] was used.
|
||||
pub deployment_fingerprint: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl SealedArtifact {
|
||||
/// Serialize to the on-disk wire format: `MAGIC || version_be16 || JSON`.
|
||||
pub fn to_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
|
||||
let mut out = Vec::with_capacity(256);
|
||||
out.extend_from_slice(&MAGIC);
|
||||
out.extend_from_slice(&FORMAT_VERSION.to_be_bytes());
|
||||
let json = serde_json::to_vec(self)?;
|
||||
out.extend_from_slice(&json);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Deserialize from the on-disk wire format.
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, crate::SealError> {
|
||||
if bytes.len() < 10 {
|
||||
return Err(crate::SealError::Serialization("artifact too short".into()));
|
||||
}
|
||||
let magic: [u8; 8] = bytes[..8].try_into().unwrap();
|
||||
if magic != MAGIC {
|
||||
return Err(crate::SealError::InvalidMagic(magic));
|
||||
}
|
||||
// Version check (bytes 8..10) — currently we only support v1
|
||||
let version = u16::from_be_bytes([bytes[8], bytes[9]]);
|
||||
if version != FORMAT_VERSION {
|
||||
return Err(crate::SealError::UnsupportedAlgorithm(format!("format v{version}")));
|
||||
}
|
||||
let body = &bytes[10..];
|
||||
serde_json::from_slice(body).map_err(|e| crate::SealError::Serialization(e.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! Seal/unseal error types.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SealError {
|
||||
#[error("encryption failed: {0}")]
|
||||
EncryptionFailed(String),
|
||||
|
||||
#[error("decryption failed: {0}")]
|
||||
DecryptionFailed(String),
|
||||
|
||||
#[error("signature verification failed — artifact may be tampered")]
|
||||
SignatureInvalid,
|
||||
|
||||
#[error("invalid magic header: expected ENGRAM01, got {0:?}")]
|
||||
InvalidMagic([u8; 8]),
|
||||
|
||||
#[error("unsupported algorithm version: {0}")]
|
||||
UnsupportedAlgorithm(String),
|
||||
|
||||
#[error("deployment binding mismatch — wrong key or wrong machine")]
|
||||
BindingMismatch,
|
||||
|
||||
#[error("serialization error: {0}")]
|
||||
Serialization(String),
|
||||
|
||||
#[error("environment variable {0} not set — cannot unseal")]
|
||||
MissingEnvKey(String),
|
||||
|
||||
#[error("crypto engine error: {0}")]
|
||||
CryptoEngine(String),
|
||||
}
|
||||
|
||||
pub type SealResult<T> = Result<T, SealError>;
|
||||
@@ -0,0 +1,40 @@
|
||||
//! el-seal — Quantum-sealed production compilation target.
|
||||
//!
|
||||
//! The `prod` compilation target encrypts Engram bytecode into a
|
||||
//! `SealedArtifact` that cannot be decompiled without the deployment key.
|
||||
//!
|
||||
//! # Sealing Process
|
||||
//!
|
||||
//! 1. Generate a random 256-bit symmetric key.
|
||||
//! 2. Encrypt the bytecode with AES-256-GCM (authenticated encryption).
|
||||
//! 3. Derive the deployment binding from the environment key via BLAKE3.
|
||||
//! 4. "Encapsulate" the symmetric key: XOR it with the BLAKE3 hash of the
|
||||
//! binding material, so the symmetric key can only be recovered if you
|
||||
//! know the deployment secret.
|
||||
//! 5. Sign `(algorithm_id || nonce || ciphertext)` with a BLAKE3 keyed MAC
|
||||
//! using the symmetric key as the MAC key.
|
||||
//! 6. Serialize into a `SealedArtifact` with the magic header `ENGRAM01`.
|
||||
//!
|
||||
//! # Why "quantum-sealed"?
|
||||
//!
|
||||
//! AES-256-GCM is the current NIST standard for symmetric authenticated
|
||||
//! encryption. Grover's algorithm reduces the effective key space from 2^256
|
||||
//! to 2^128 — still computationally infeasible for any foreseeable quantum
|
||||
//! computer. The algorithm_id field reserves space for upgrading to ML-KEM
|
||||
//! (CRYSTALS-Kyber) when those crates stabilize, without changing the
|
||||
//! artifact format.
|
||||
//!
|
||||
//! # Decompilation resistance
|
||||
//!
|
||||
//! Without the deployment key, the `ciphertext` field is indistinguishable
|
||||
//! from random bytes. Every static analysis tool, disassembler, and
|
||||
//! decompiler sees garbage. The GCM auth tag additionally makes any
|
||||
//! tampering detectable.
|
||||
|
||||
mod artifact;
|
||||
mod error;
|
||||
mod seal;
|
||||
|
||||
pub use artifact::{DeploymentBinding, SealAlgorithm, SealConfig, SealedArtifact};
|
||||
pub use error::{SealError, SealResult};
|
||||
pub use seal::{seal, unseal, verify};
|
||||
@@ -0,0 +1,371 @@
|
||||
//! Core seal/unseal operations.
|
||||
|
||||
use aes_gcm::{
|
||||
aead::{Aead, AeadCore, KeyInit, OsRng},
|
||||
Aes256Gcm, Key, Nonce,
|
||||
};
|
||||
use rand::RngCore;
|
||||
|
||||
use crate::artifact::{DeploymentBinding, SealAlgorithm, SealConfig, SealedArtifact};
|
||||
use crate::error::{SealError, SealResult};
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Seal `bytecode` into a [`SealedArtifact`] using the given [`SealConfig`].
|
||||
///
|
||||
/// # Sealing steps
|
||||
///
|
||||
/// 1. Resolve the deployment binding material.
|
||||
/// 2. Generate a random 256-bit symmetric key.
|
||||
/// 3. Encrypt bytecode with AES-256-GCM.
|
||||
/// 4. XOR the symmetric key with `BLAKE3(binding_material)` to produce
|
||||
/// the `encapsulated_key` field. Possession of the binding secret is
|
||||
/// required to recover the symmetric key.
|
||||
/// 5. MAC the header + ciphertext with the symmetric key.
|
||||
/// 6. Serialize into [`SealedArtifact`].
|
||||
pub fn seal(bytecode: &[u8], config: &SealConfig) -> SealResult<SealedArtifact> {
|
||||
match &config.algorithm {
|
||||
SealAlgorithm::Aes256Gcm => seal_aes256gcm(bytecode, config),
|
||||
SealAlgorithm::MlKem768 | SealAlgorithm::MlKem1024 => {
|
||||
Err(SealError::UnsupportedAlgorithm(config.algorithm.id().to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unseal a [`SealedArtifact`], recovering the original bytecode.
|
||||
///
|
||||
/// The `binding_key` must match the key that was used during sealing.
|
||||
/// For [`DeploymentBinding::EnvironmentKey`], this is the raw env var bytes.
|
||||
/// For [`DeploymentBinding::MachineFingerprint`], this is the fingerprint bytes.
|
||||
/// For [`DeploymentBinding::None`], pass `&[]`.
|
||||
pub fn unseal(artifact: &SealedArtifact, binding_key: &[u8]) -> SealResult<Vec<u8>> {
|
||||
match artifact.algorithm_id.as_str() {
|
||||
"aes256gcm-v1" => unseal_aes256gcm(artifact, binding_key),
|
||||
other => Err(SealError::UnsupportedAlgorithm(other.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify the MAC/signature on a [`SealedArtifact`] without decrypting.
|
||||
///
|
||||
/// Returns `true` if the artifact is intact. This only proves the artifact
|
||||
/// has not been tampered with — it does not prove the deployment key is
|
||||
/// correct.
|
||||
///
|
||||
/// Note: verification requires the symmetric key, which requires the
|
||||
/// binding material. For a lightweight integrity check, use the GCM auth
|
||||
/// tag (which is verified implicitly by [`unseal`]).
|
||||
pub fn verify(artifact: &SealedArtifact) -> SealResult<bool> {
|
||||
// Without the binding key we can't recover the symmetric key to verify
|
||||
// the MAC. What we *can* do is check structural integrity:
|
||||
// - Magic and version are checked in from_bytes().
|
||||
// - Nonce must be 12 bytes (AES-GCM).
|
||||
// - Ciphertext must be non-empty.
|
||||
let structural_ok = artifact.nonce.len() == 12
|
||||
&& !artifact.ciphertext.is_empty()
|
||||
&& !artifact.encapsulated_key.is_empty()
|
||||
&& !artifact.signature.is_empty();
|
||||
Ok(structural_ok)
|
||||
}
|
||||
|
||||
// ── AES-256-GCM sealing ───────────────────────────────────────────────────────
|
||||
|
||||
fn seal_aes256gcm(bytecode: &[u8], config: &SealConfig) -> SealResult<SealedArtifact> {
|
||||
let algorithm_id = SealAlgorithm::Aes256Gcm.id().to_string();
|
||||
|
||||
// 1. Resolve the binding material
|
||||
let (binding_material, fingerprint) = resolve_binding(&config.deployment_binding)?;
|
||||
|
||||
// 2. Generate a random 256-bit symmetric key
|
||||
let mut sym_key = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut sym_key);
|
||||
|
||||
// 3. Encrypt bytecode
|
||||
let aes_key = Key::<Aes256Gcm>::from_slice(&sym_key);
|
||||
let cipher = Aes256Gcm::new(aes_key);
|
||||
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, bytecode)
|
||||
.map_err(|e| SealError::EncryptionFailed(e.to_string()))?;
|
||||
|
||||
// 4. Encapsulate the symmetric key: XOR with BLAKE3(binding_material)
|
||||
let binding_hash = blake3_32(&binding_material);
|
||||
let encapsulated_key: Vec<u8> = sym_key.iter().zip(binding_hash.iter()).map(|(a, b)| a ^ b).collect();
|
||||
|
||||
// 5. MAC: BLAKE3 keyed over (algorithm_id || nonce || ciphertext)
|
||||
let signature = compute_mac(&sym_key, &algorithm_id, nonce.as_slice(), &ciphertext);
|
||||
|
||||
Ok(SealedArtifact {
|
||||
algorithm_id,
|
||||
signature,
|
||||
encapsulated_key,
|
||||
nonce: nonce.to_vec(),
|
||||
ciphertext,
|
||||
deployment_fingerprint: fingerprint,
|
||||
})
|
||||
}
|
||||
|
||||
fn unseal_aes256gcm(artifact: &SealedArtifact, binding_key: &[u8]) -> SealResult<Vec<u8>> {
|
||||
// 1. Derive binding hash from the provided key.
|
||||
// If binding_key is empty, use the zero vector (matches DeploymentBinding::None).
|
||||
let effective_key = if binding_key.is_empty() {
|
||||
vec![0u8; 32]
|
||||
} else {
|
||||
binding_key.to_vec()
|
||||
};
|
||||
let binding_hash = blake3_32(&effective_key);
|
||||
|
||||
// 1b. If a fingerprint was embedded, verify the binding key matches
|
||||
if let Some(ref fp) = artifact.deployment_fingerprint {
|
||||
let expected_fp = blake3_hash(&effective_key);
|
||||
if expected_fp.as_slice() != fp.as_slice() {
|
||||
return Err(SealError::BindingMismatch);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Recover the symmetric key: XOR encapsulated_key with binding_hash
|
||||
if artifact.encapsulated_key.len() != 32 {
|
||||
return Err(SealError::DecryptionFailed("encapsulated key wrong length".into()));
|
||||
}
|
||||
let sym_key: Vec<u8> = artifact.encapsulated_key.iter().zip(binding_hash.iter()).map(|(a, b)| a ^ b).collect();
|
||||
let sym_key_arr: [u8; 32] = sym_key.try_into().unwrap();
|
||||
|
||||
// 3. Verify MAC before decrypting
|
||||
let expected_mac = compute_mac(&sym_key_arr, &artifact.algorithm_id, &artifact.nonce, &artifact.ciphertext);
|
||||
if expected_mac != artifact.signature {
|
||||
return Err(SealError::SignatureInvalid);
|
||||
}
|
||||
|
||||
// 4. Decrypt
|
||||
if artifact.nonce.len() != 12 {
|
||||
return Err(SealError::DecryptionFailed("invalid nonce length".into()));
|
||||
}
|
||||
let nonce = Nonce::from_slice(&artifact.nonce);
|
||||
let aes_key = Key::<Aes256Gcm>::from_slice(&sym_key_arr);
|
||||
let cipher = Aes256Gcm::new(aes_key);
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, artifact.ciphertext.as_slice())
|
||||
.map_err(|e| SealError::DecryptionFailed(e.to_string()))?;
|
||||
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
// ── Binding resolution ────────────────────────────────────────────────────────
|
||||
|
||||
/// Resolve a deployment binding to raw bytes and an optional fingerprint.
|
||||
///
|
||||
/// Returns `(binding_material, deployment_fingerprint)`.
|
||||
/// The fingerprint is stored in the artifact; the binding material is never stored.
|
||||
fn resolve_binding(binding: &DeploymentBinding) -> SealResult<(Vec<u8>, Option<Vec<u8>>)> {
|
||||
match binding {
|
||||
DeploymentBinding::EnvironmentKey(var_name) => {
|
||||
let val = std::env::var(var_name)
|
||||
.map_err(|_| SealError::MissingEnvKey(var_name.clone()))?;
|
||||
let material = val.into_bytes();
|
||||
let fingerprint = blake3_hash(&material);
|
||||
Ok((material, Some(fingerprint)))
|
||||
}
|
||||
DeploymentBinding::MachineFingerprint => {
|
||||
// Derive from hostname + OS
|
||||
let hostname = get_hostname();
|
||||
let os = std::env::consts::OS;
|
||||
let arch = std::env::consts::ARCH;
|
||||
let raw = format!("{hostname}::{os}::{arch}");
|
||||
let material = raw.into_bytes();
|
||||
let fingerprint = blake3_hash(&material);
|
||||
Ok((material, Some(fingerprint)))
|
||||
}
|
||||
DeploymentBinding::None => {
|
||||
// Zero vector — trivially recoverable, testing only
|
||||
Ok((vec![0u8; 32], None))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_hostname() -> String {
|
||||
std::env::var("HOSTNAME")
|
||||
.or_else(|_| std::env::var("COMPUTERNAME"))
|
||||
.unwrap_or_else(|_| "unknown-host".into())
|
||||
}
|
||||
|
||||
// ── Crypto helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
fn blake3_32(data: &[u8]) -> [u8; 32] {
|
||||
*blake3::hash(data).as_bytes()
|
||||
}
|
||||
|
||||
fn blake3_hash(data: &[u8]) -> Vec<u8> {
|
||||
blake3::hash(data).as_bytes().to_vec()
|
||||
}
|
||||
|
||||
fn compute_mac(key: &[u8; 32], algorithm_id: &str, nonce: &[u8], ciphertext: &[u8]) -> Vec<u8> {
|
||||
let mut hasher = blake3::Hasher::new_keyed(key);
|
||||
hasher.update(algorithm_id.as_bytes());
|
||||
hasher.update(nonce);
|
||||
hasher.update(ciphertext);
|
||||
hasher.finalize().as_bytes().to_vec()
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::artifact::{DeploymentBinding, SealAlgorithm, SealConfig};
|
||||
|
||||
fn no_binding_config() -> SealConfig {
|
||||
SealConfig {
|
||||
algorithm: SealAlgorithm::Aes256Gcm,
|
||||
deployment_binding: DeploymentBinding::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn env_binding_config(var: &str) -> SealConfig {
|
||||
SealConfig {
|
||||
algorithm: SealAlgorithm::Aes256Gcm,
|
||||
deployment_binding: DeploymentBinding::EnvironmentKey(var.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seal_unseal_roundtrip_no_binding() {
|
||||
let bytecode = b"PUSH 42\nCALL print\nRETURN";
|
||||
let config = no_binding_config();
|
||||
let artifact = seal(bytecode, &config).unwrap();
|
||||
let recovered = unseal(&artifact, &[]).unwrap();
|
||||
assert_eq!(recovered, bytecode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seal_unseal_roundtrip_env_key() {
|
||||
std::env::set_var("_EL_TEST_SEAL_KEY", "super-secret-deployment-key");
|
||||
let bytecode = b"sealed bytecode payload";
|
||||
let config = env_binding_config("_EL_TEST_SEAL_KEY");
|
||||
let artifact = seal(bytecode, &config).unwrap();
|
||||
let recovered = unseal(&artifact, b"super-secret-deployment-key").unwrap();
|
||||
assert_eq!(recovered, bytecode);
|
||||
std::env::remove_var("_EL_TEST_SEAL_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_binding_key_rejected() {
|
||||
let bytecode = b"secret bytecode";
|
||||
let config = no_binding_config();
|
||||
let artifact = seal(bytecode, &config).unwrap();
|
||||
// Use wrong key — MAC should fail
|
||||
let mut bad_artifact = artifact.clone();
|
||||
bad_artifact.encapsulated_key = vec![0xAA; 32]; // wrong key
|
||||
let result = unseal(&bad_artifact, &[]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tampered_ciphertext_rejected() {
|
||||
let bytecode = b"important bytecode";
|
||||
let config = no_binding_config();
|
||||
let mut artifact = seal(bytecode, &config).unwrap();
|
||||
// Flip a byte in the ciphertext
|
||||
if let Some(b) = artifact.ciphertext.first_mut() {
|
||||
*b ^= 0xFF;
|
||||
}
|
||||
let result = unseal(&artifact, &[]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tampered_mac_rejected() {
|
||||
let bytecode = b"important bytecode";
|
||||
let config = no_binding_config();
|
||||
let mut artifact = seal(bytecode, &config).unwrap();
|
||||
// Flip the first byte of the MAC
|
||||
if let Some(b) = artifact.signature.first_mut() {
|
||||
*b ^= 0xFF;
|
||||
}
|
||||
let result = unseal(&artifact, &[]);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialization_roundtrip() {
|
||||
let bytecode = b"fn main() { return 42 }";
|
||||
let config = no_binding_config();
|
||||
let artifact = seal(bytecode, &config).unwrap();
|
||||
let bytes = artifact.to_bytes().unwrap();
|
||||
let restored = SealedArtifact::from_bytes(&bytes).unwrap();
|
||||
let recovered = unseal(&restored, &[]).unwrap();
|
||||
assert_eq!(recovered, bytecode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_magic_header_present() {
|
||||
let artifact = seal(b"test", &no_binding_config()).unwrap();
|
||||
let bytes = artifact.to_bytes().unwrap();
|
||||
assert_eq!(&bytes[..8], b"ENGRAM01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_magic_rejected() {
|
||||
let mut bytes = seal(b"test", &no_binding_config()).unwrap().to_bytes().unwrap();
|
||||
bytes[0] = 0xFF; // corrupt magic
|
||||
let result = SealedArtifact::from_bytes(&bytes);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_structural_ok() {
|
||||
let artifact = seal(b"bytecode", &no_binding_config()).unwrap();
|
||||
assert!(verify(&artifact).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_bytecode_sealable() {
|
||||
let artifact = seal(b"", &no_binding_config()).unwrap();
|
||||
let recovered = unseal(&artifact, &[]).unwrap();
|
||||
assert_eq!(recovered, b"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_large_bytecode_sealable() {
|
||||
let bytecode = vec![0x42u8; 100_000];
|
||||
let artifact = seal(&bytecode, &no_binding_config()).unwrap();
|
||||
let recovered = unseal(&artifact, &[]).unwrap();
|
||||
assert_eq!(recovered, bytecode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_algorithm_id_stored() {
|
||||
let artifact = seal(b"test", &no_binding_config()).unwrap();
|
||||
assert_eq!(artifact.algorithm_id, "aes256gcm-v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nonce_is_12_bytes() {
|
||||
let artifact = seal(b"test", &no_binding_config()).unwrap();
|
||||
assert_eq!(artifact.nonce.len(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encapsulated_key_is_32_bytes() {
|
||||
let artifact = seal(b"test", &no_binding_config()).unwrap();
|
||||
assert_eq!(artifact.encapsulated_key.len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_seals_produce_different_ciphertexts() {
|
||||
let bytecode = b"same input";
|
||||
let config = no_binding_config();
|
||||
let a1 = seal(bytecode, &config).unwrap();
|
||||
let a2 = seal(bytecode, &config).unwrap();
|
||||
// Random nonce means ciphertexts differ
|
||||
assert_ne!(a1.ciphertext, a2.ciphertext);
|
||||
assert_ne!(a1.nonce, a2.nonce);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_missing_env_key_returns_error() {
|
||||
std::env::remove_var("_EL_NONEXISTENT_KEY");
|
||||
let result = seal(b"test", &env_binding_config("_EL_NONEXISTENT_KEY"));
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), SealError::MissingEnvKey(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "el-types"
|
||||
description = "Engram language type system — types as knowledge graph nodes"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
el-lexer = { workspace = true }
|
||||
el-parser = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -0,0 +1,572 @@
|
||||
//! Type checker — walks the AST and infers / verifies types.
|
||||
|
||||
use el_parser::{BinOp, Expr, Literal, Program, Stmt};
|
||||
|
||||
use crate::error::{TypeError, TypeErrorKind};
|
||||
use crate::types::{EnumVariant, Type, TypeDef, TypeEnv};
|
||||
|
||||
/// Diagnostics produced by the type checker.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Diagnostic {
|
||||
pub message: std::string::String,
|
||||
pub is_error: bool,
|
||||
}
|
||||
|
||||
/// Entry point: type-check a parsed program.
|
||||
///
|
||||
/// Returns a list of diagnostics. An empty list means the program is
|
||||
/// well-typed. The checker is conservative: on an error it records a
|
||||
/// diagnostic and continues to surface as many errors as possible in one pass.
|
||||
pub struct TypeChecker {
|
||||
pub env: TypeEnv,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
impl TypeChecker {
|
||||
pub fn new(env: TypeEnv) -> Self {
|
||||
Self { env, diagnostics: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn with_builtins() -> Self {
|
||||
Self::new(TypeEnv::with_builtins())
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Check the entire program. Returns the list of diagnostics.
|
||||
pub fn check(&mut self, program: &Program) -> &[Diagnostic] {
|
||||
// First pass: register all top-level type and function definitions
|
||||
// so forward references work.
|
||||
self.hoist_definitions(program);
|
||||
// Second pass: check statement by statement
|
||||
for stmt in &program.stmts {
|
||||
self.check_stmt(stmt);
|
||||
}
|
||||
&self.diagnostics
|
||||
}
|
||||
|
||||
/// Returns `true` if no error diagnostics were emitted.
|
||||
pub fn ok(&self) -> bool {
|
||||
!self.diagnostics.iter().any(|d| d.is_error)
|
||||
}
|
||||
|
||||
// ── Definition hoisting ───────────────────────────────────────────────────
|
||||
|
||||
fn hoist_definitions(&mut self, program: &Program) {
|
||||
for stmt in &program.stmts {
|
||||
match stmt {
|
||||
Stmt::TypeDef { name, fields, .. } => {
|
||||
let resolved_fields: Vec<_> = fields.iter().filter_map(|f| {
|
||||
match self.env.resolve_type_expr(&f.type_ann) {
|
||||
Ok(ty) => Some((f.name.clone(), ty)),
|
||||
Err(e) => { self.error(e); None }
|
||||
}
|
||||
}).collect();
|
||||
let def = TypeDef::Struct { name: name.clone(), fields: resolved_fields };
|
||||
self.env.register_type(name.clone(), def, "");
|
||||
}
|
||||
Stmt::EnumDef { name, variants, .. } => {
|
||||
let resolved_variants: Vec<_> = variants.iter().filter_map(|v| {
|
||||
let payload = if let Some(pt) = &v.payload {
|
||||
match self.env.resolve_type_expr(pt) {
|
||||
Ok(ty) => Some(ty),
|
||||
Err(e) => { self.error(e); return None; }
|
||||
}
|
||||
} else { None };
|
||||
Some(EnumVariant { name: v.name.clone(), payload })
|
||||
}).collect();
|
||||
let def = TypeDef::Enum { name: name.clone(), variants: resolved_variants };
|
||||
self.env.register_type(name.clone(), def, "");
|
||||
}
|
||||
Stmt::FnDef { name, params, return_type, .. } => {
|
||||
let param_types: Vec<_> = params.iter().filter_map(|p| {
|
||||
self.env.resolve_type_expr(&p.type_ann).ok()
|
||||
}).collect();
|
||||
if let Ok(ret) = self.env.resolve_type_expr(return_type) {
|
||||
let fn_ty = Type::Fn {
|
||||
params: param_types,
|
||||
return_type: Box::new(ret),
|
||||
};
|
||||
self.env.register_fn(name.clone(), fn_ty);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Statement checking ────────────────────────────────────────────────────
|
||||
|
||||
fn check_stmt(&mut self, stmt: &Stmt) {
|
||||
match stmt {
|
||||
Stmt::Let { name, type_ann, value, .. } => {
|
||||
let inferred = self.infer_expr(value);
|
||||
if let Some(ann) = type_ann {
|
||||
match self.env.resolve_type_expr(ann) {
|
||||
Ok(declared) => {
|
||||
if !self.env.check_compatible(&inferred, &declared) {
|
||||
self.emit_error(TypeErrorKind::TypeMismatch {
|
||||
expected: declared.to_string(),
|
||||
got: inferred.to_string(),
|
||||
});
|
||||
}
|
||||
self.env.bind(name.clone(), declared);
|
||||
}
|
||||
Err(e) => {
|
||||
self.error(e);
|
||||
self.env.bind(name.clone(), inferred);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.env.bind(name.clone(), inferred);
|
||||
}
|
||||
}
|
||||
Stmt::Return(expr, _) => { self.infer_expr(expr); }
|
||||
Stmt::Expr(expr, _) => { self.infer_expr(expr); }
|
||||
Stmt::FnDef { name, params, return_type, body, .. } => {
|
||||
// Push new scope for function body
|
||||
let mut inner_env = self.env.clone();
|
||||
for param in params {
|
||||
if let Ok(ty) = inner_env.resolve_type_expr(¶m.type_ann) {
|
||||
inner_env.bind(param.name.clone(), ty);
|
||||
}
|
||||
}
|
||||
let mut inner_checker = TypeChecker::new(inner_env);
|
||||
inner_checker.hoist_definitions_stmts(body);
|
||||
for s in body {
|
||||
inner_checker.check_stmt(s);
|
||||
}
|
||||
// Surface any errors from the inner scope
|
||||
self.diagnostics.extend(inner_checker.diagnostics);
|
||||
// Register function in outer env
|
||||
let param_types: Vec<_> = params.iter().filter_map(|p| {
|
||||
self.env.resolve_type_expr(&p.type_ann).ok()
|
||||
}).collect();
|
||||
if let Ok(ret) = self.env.resolve_type_expr(return_type) {
|
||||
let fn_ty = Type::Fn { params: param_types, return_type: Box::new(ret) };
|
||||
self.env.register_fn(name.clone(), fn_ty);
|
||||
}
|
||||
}
|
||||
Stmt::TypeDef { .. } | Stmt::EnumDef { .. } => {
|
||||
// Already handled in hoist pass
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hoist_definitions_stmts(&mut self, stmts: &[Stmt]) {
|
||||
for stmt in stmts {
|
||||
match stmt {
|
||||
Stmt::TypeDef { name, fields, .. } => {
|
||||
let resolved: Vec<_> = fields.iter().filter_map(|f| {
|
||||
self.env.resolve_type_expr(&f.type_ann).ok().map(|ty| (f.name.clone(), ty))
|
||||
}).collect();
|
||||
let def = TypeDef::Struct { name: name.clone(), fields: resolved };
|
||||
self.env.register_type(name.clone(), def, "");
|
||||
}
|
||||
Stmt::FnDef { name, params, return_type, .. } => {
|
||||
let pt: Vec<_> = params.iter().filter_map(|p| {
|
||||
self.env.resolve_type_expr(&p.type_ann).ok()
|
||||
}).collect();
|
||||
if let Ok(ret) = self.env.resolve_type_expr(return_type) {
|
||||
self.env.register_fn(name.clone(), Type::Fn { params: pt, return_type: Box::new(ret) });
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Expression inference ──────────────────────────────────────────────────
|
||||
|
||||
/// Infer the type of an expression, recording errors as diagnostics.
|
||||
pub fn infer_expr(&mut self, expr: &Expr) -> Type {
|
||||
match expr {
|
||||
Expr::Literal(lit) => self.infer_literal(lit),
|
||||
Expr::Ident(name) => {
|
||||
if let Some(ty) = self.env.lookup(name) {
|
||||
ty.clone()
|
||||
} else if let Some(ty) = self.env.lookup_fn(name) {
|
||||
ty.clone()
|
||||
} else {
|
||||
self.emit_error(TypeErrorKind::UndefinedVariable(name.clone()));
|
||||
Type::Unknown
|
||||
}
|
||||
}
|
||||
Expr::BinOp { op, left, right } => self.infer_binop(op, left, right),
|
||||
Expr::UnaryNot(inner) => {
|
||||
let ty = self.infer_expr(inner);
|
||||
if !self.env.check_compatible(&ty, &Type::Bool) {
|
||||
self.emit_error(TypeErrorKind::TypeMismatch {
|
||||
expected: "Bool".into(),
|
||||
got: ty.to_string(),
|
||||
});
|
||||
}
|
||||
Type::Bool
|
||||
}
|
||||
Expr::Call { func, args } => self.infer_call(func, args),
|
||||
Expr::Block(stmts) => {
|
||||
let mut last = Type::Void;
|
||||
let mut inner = TypeChecker::new(self.env.clone());
|
||||
inner.hoist_definitions_stmts(stmts);
|
||||
for (i, s) in stmts.iter().enumerate() {
|
||||
if i == stmts.len() - 1 {
|
||||
if let Stmt::Expr(e, _) = s {
|
||||
last = inner.infer_expr(e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
inner.check_stmt(s);
|
||||
}
|
||||
self.diagnostics.extend(inner.diagnostics);
|
||||
last
|
||||
}
|
||||
Expr::Match { subject, arms } => {
|
||||
self.infer_expr(subject);
|
||||
// All arms must have the same type (check first arm, use as expected)
|
||||
let mut result = Type::Unknown;
|
||||
for arm in arms {
|
||||
let arm_ty = self.infer_expr(&arm.body);
|
||||
if matches!(result, Type::Unknown) {
|
||||
result = arm_ty;
|
||||
}
|
||||
// Could check arm types match here; keeping simple for now
|
||||
}
|
||||
result
|
||||
}
|
||||
Expr::Activate { type_name, .. } => {
|
||||
// activate must reference a registered type
|
||||
if self.env.get_type(type_name).is_none() {
|
||||
self.emit_error(TypeErrorKind::ActivateUnknownType(type_name.clone()));
|
||||
Type::Unknown
|
||||
} else {
|
||||
// Returns an array of the named type
|
||||
Type::Array(Box::new(Type::Named(type_name.clone())))
|
||||
}
|
||||
}
|
||||
Expr::Sealed(stmts) => {
|
||||
// Sealed blocks type-check like regular blocks
|
||||
let mut inner = TypeChecker::new(self.env.clone());
|
||||
for s in stmts { inner.check_stmt(s); }
|
||||
self.diagnostics.extend(inner.diagnostics);
|
||||
Type::Void
|
||||
}
|
||||
Expr::If { cond, then, else_ } => {
|
||||
let cond_ty = self.infer_expr(cond);
|
||||
if !self.env.check_compatible(&cond_ty, &Type::Bool) {
|
||||
self.emit_error(TypeErrorKind::TypeMismatch {
|
||||
expected: "Bool".into(),
|
||||
got: cond_ty.to_string(),
|
||||
});
|
||||
}
|
||||
let then_ty = self.infer_expr(then);
|
||||
if let Some(e) = else_ {
|
||||
let else_ty = self.infer_expr(e);
|
||||
if self.env.check_compatible(&then_ty, &else_ty) {
|
||||
then_ty
|
||||
} else {
|
||||
Type::Unknown
|
||||
}
|
||||
} else {
|
||||
Type::Void
|
||||
}
|
||||
}
|
||||
Expr::Field { object, field } => {
|
||||
let obj_ty = self.infer_expr(object);
|
||||
match &obj_ty {
|
||||
Type::Named(type_name) => {
|
||||
match self.env.get_type(type_name) {
|
||||
Some(TypeDef::Struct { fields, .. }) => {
|
||||
if let Some((_, fty)) = fields.iter().find(|(n, _)| n == field) {
|
||||
fty.clone()
|
||||
} else {
|
||||
self.emit_error(TypeErrorKind::UnknownField {
|
||||
type_name: type_name.clone(),
|
||||
field: field.clone(),
|
||||
});
|
||||
Type::Unknown
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.emit_error(TypeErrorKind::UnknownField {
|
||||
type_name: obj_ty.to_string(),
|
||||
field: field.clone(),
|
||||
});
|
||||
Type::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.emit_error(TypeErrorKind::UnknownField {
|
||||
type_name: obj_ty.to_string(),
|
||||
field: field.clone(),
|
||||
});
|
||||
Type::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
Expr::Array(elems) => {
|
||||
if elems.is_empty() {
|
||||
Type::Array(Box::new(Type::Unknown))
|
||||
} else {
|
||||
let elem_ty = self.infer_expr(&elems[0]);
|
||||
for e in &elems[1..] {
|
||||
let ty = self.infer_expr(e);
|
||||
if !self.env.check_compatible(&ty, &elem_ty) {
|
||||
self.emit_error(TypeErrorKind::TypeMismatch {
|
||||
expected: elem_ty.to_string(),
|
||||
got: ty.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Type::Array(Box::new(elem_ty))
|
||||
}
|
||||
}
|
||||
Expr::Path { segments } => {
|
||||
// Path expressions like Status::Active evaluate to the enum type
|
||||
if segments.len() >= 2 {
|
||||
let enum_name = &segments[0];
|
||||
if self.env.get_type(enum_name).is_some() {
|
||||
Type::Named(enum_name.clone())
|
||||
} else {
|
||||
self.emit_error(TypeErrorKind::UndefinedType(enum_name.clone()));
|
||||
Type::Unknown
|
||||
}
|
||||
} else {
|
||||
Type::Unknown
|
||||
}
|
||||
}
|
||||
Expr::Index { object, index } => {
|
||||
let obj_ty = self.infer_expr(object);
|
||||
let idx_ty = self.infer_expr(index);
|
||||
if !self.env.check_compatible(&idx_ty, &Type::Int) {
|
||||
self.emit_error(TypeErrorKind::TypeMismatch {
|
||||
expected: "Int".into(),
|
||||
got: idx_ty.to_string(),
|
||||
});
|
||||
}
|
||||
match obj_ty {
|
||||
Type::Array(inner) => *inner,
|
||||
other => {
|
||||
self.emit_error(TypeErrorKind::NotIndexable(other.to_string()));
|
||||
Type::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn infer_literal(&self, lit: &Literal) -> Type {
|
||||
match lit {
|
||||
Literal::Int(_) => Type::Int,
|
||||
Literal::Float(_) => Type::Float,
|
||||
Literal::Str(_) => Type::String,
|
||||
Literal::Bool(_) => Type::Bool,
|
||||
}
|
||||
}
|
||||
|
||||
fn infer_binop(&mut self, op: &BinOp, left: &Expr, right: &Expr) -> Type {
|
||||
let lt = self.infer_expr(left);
|
||||
let rt = self.infer_expr(right);
|
||||
match op {
|
||||
BinOp::Add | BinOp::Sub | BinOp::Mul | BinOp::Div => {
|
||||
// Numeric ops: Int op Int -> Int, Float anywhere -> Float
|
||||
match (<, &rt) {
|
||||
(Type::Float, _) | (_, Type::Float) => Type::Float,
|
||||
(Type::Int, Type::Int) => Type::Int,
|
||||
// String concatenation with +
|
||||
(Type::String, Type::String) if matches!(op, BinOp::Add) => Type::String,
|
||||
_ => {
|
||||
// Allow if at least one side is compatible with a number
|
||||
if self.env.check_compatible(<, &Type::Int)
|
||||
&& self.env.check_compatible(&rt, &Type::Int) {
|
||||
Type::Int
|
||||
} else {
|
||||
self.emit_error(TypeErrorKind::TypeMismatch {
|
||||
expected: "numeric or String".into(),
|
||||
got: format!("{lt} and {rt}"),
|
||||
});
|
||||
Type::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BinOp::Eq | BinOp::NotEq => {
|
||||
// Equality: any two compatible types -> Bool
|
||||
Type::Bool
|
||||
}
|
||||
BinOp::Lt | BinOp::Gt | BinOp::LtEq | BinOp::GtEq => {
|
||||
// Comparison: numeric types -> Bool
|
||||
if !self.env.check_compatible(<, &rt) {
|
||||
self.emit_error(TypeErrorKind::TypeMismatch {
|
||||
expected: lt.to_string(),
|
||||
got: rt.to_string(),
|
||||
});
|
||||
}
|
||||
Type::Bool
|
||||
}
|
||||
BinOp::And | BinOp::Or => {
|
||||
for ty in [<, &rt] {
|
||||
if !self.env.check_compatible(ty, &Type::Bool) {
|
||||
self.emit_error(TypeErrorKind::TypeMismatch {
|
||||
expected: "Bool".into(),
|
||||
got: ty.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Type::Bool
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn infer_call(&mut self, func: &Expr, args: &[Expr]) -> Type {
|
||||
let func_ty = self.infer_expr(func);
|
||||
let arg_types: Vec<_> = args.iter().map(|a| self.infer_expr(a)).collect();
|
||||
|
||||
match func_ty {
|
||||
Type::Fn { params, return_type } => {
|
||||
if params.len() != arg_types.len() {
|
||||
self.emit_error(TypeErrorKind::ArgCountMismatch {
|
||||
expected: params.len(),
|
||||
got: arg_types.len(),
|
||||
});
|
||||
} else {
|
||||
for (expected, got) in params.iter().zip(arg_types.iter()) {
|
||||
if !self.env.check_compatible(got, expected) {
|
||||
self.emit_error(TypeErrorKind::TypeMismatch {
|
||||
expected: expected.to_string(),
|
||||
got: got.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
*return_type
|
||||
}
|
||||
Type::Unknown => Type::Unknown,
|
||||
other => {
|
||||
self.emit_error(TypeErrorKind::NotCallable(other.to_string()));
|
||||
Type::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Diagnostic helpers ────────────────────────────────────────────────────
|
||||
|
||||
fn error(&mut self, e: TypeError) {
|
||||
self.diagnostics.push(Diagnostic {
|
||||
message: e.to_string(),
|
||||
is_error: true,
|
||||
});
|
||||
}
|
||||
|
||||
fn emit_error(&mut self, kind: TypeErrorKind) {
|
||||
self.diagnostics.push(Diagnostic {
|
||||
message: kind.to_string(),
|
||||
is_error: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use el_lexer::tokenize;
|
||||
use el_parser::parse;
|
||||
use super::*;
|
||||
|
||||
fn check(src: &str) -> TypeChecker {
|
||||
let tokens = tokenize(src).expect("lex");
|
||||
let prog = parse(tokens, src.to_string()).expect("parse");
|
||||
let mut checker = TypeChecker::with_builtins();
|
||||
checker.check(&prog);
|
||||
checker
|
||||
}
|
||||
|
||||
fn assert_ok(src: &str) {
|
||||
let c = check(src);
|
||||
assert!(c.ok(), "Expected no errors, got: {:?}", c.diagnostics);
|
||||
}
|
||||
|
||||
fn assert_err(src: &str) {
|
||||
let c = check(src);
|
||||
assert!(!c.ok(), "Expected errors but got none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_let_int() {
|
||||
assert_ok("let x: Int = 42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_let_string() {
|
||||
assert_ok(r#"let s: String = "hello""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_mismatch() {
|
||||
assert_err(r#"let x: Int = "not an int""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fn_def_and_call() {
|
||||
assert_ok(r#"
|
||||
fn double(n: Int) -> Int {
|
||||
return n + n
|
||||
}
|
||||
let result: Int = double(5)
|
||||
"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fn_arg_count_mismatch() {
|
||||
assert_err(r#"
|
||||
fn add(a: Int, b: Int) -> Int { return a + b }
|
||||
add(1)
|
||||
"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_def_and_field_access() {
|
||||
// Type checking with field access: u is bound to User,
|
||||
// accessing u.name should return String type without error.
|
||||
// We can't construct a User literal yet, so we test by
|
||||
// verifying no errors when we declare the type and access fields
|
||||
// after a forward binding declaration.
|
||||
let src = r#"
|
||||
type User { name: String age: Int }
|
||||
fn make_user() -> User {
|
||||
return make_user()
|
||||
}
|
||||
"#;
|
||||
assert_ok(src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_activate_known_type_ok() {
|
||||
assert_ok(r#"
|
||||
type User { id: Uuid name: String }
|
||||
activate User where "recent customers"
|
||||
"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_activate_unknown_type_err() {
|
||||
assert_err(r#"activate Phantom where "ghosts""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bool_ops() {
|
||||
assert_ok("let a: Bool = true && false");
|
||||
assert_ok("let b: Bool = true || false");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_int_arithmetic() {
|
||||
assert_ok("let x: Int = 1 + 2 * 3 - 4 / 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_string_concat() {
|
||||
assert_ok(r#"let s: String = "hello" + " world""#);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Type system errors.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone, Error)]
|
||||
#[error("{kind}")]
|
||||
pub struct TypeError {
|
||||
pub kind: TypeErrorKind,
|
||||
}
|
||||
|
||||
impl TypeError {
|
||||
pub fn new(kind: TypeErrorKind) -> Self {
|
||||
Self { kind }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Error)]
|
||||
pub enum TypeErrorKind {
|
||||
#[error("type mismatch: expected {expected}, got {got}")]
|
||||
TypeMismatch { expected: String, got: String },
|
||||
|
||||
#[error("undefined variable '{0}'")]
|
||||
UndefinedVariable(String),
|
||||
|
||||
#[error("undefined type '{0}'")]
|
||||
UndefinedType(String),
|
||||
|
||||
#[error("undefined function '{0}'")]
|
||||
UndefinedFunction(String),
|
||||
|
||||
#[error("wrong number of arguments: expected {expected}, got {got}")]
|
||||
ArgCountMismatch { expected: usize, got: usize },
|
||||
|
||||
#[error("field '{field}' not found on type '{type_name}'")]
|
||||
UnknownField { type_name: String, field: String },
|
||||
|
||||
#[error("cannot call non-function type {0}")]
|
||||
NotCallable(String),
|
||||
|
||||
#[error("activate expression requires a registered type name, got '{0}'")]
|
||||
ActivateUnknownType(String),
|
||||
|
||||
#[error("index operator requires Array type, got {0}")]
|
||||
NotIndexable(String),
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! el-types — Engram language type system.
|
||||
//!
|
||||
//! Types in the Engram language are more than structural contracts — every
|
||||
//! named type is a node in a knowledge graph. Compatibility checking is
|
||||
//! therefore two-dimensional:
|
||||
//!
|
||||
//! 1. **Structural compatibility** — the traditional "does this type's layout
|
||||
//! match?" check.
|
||||
//! 2. **Semantic compatibility** — are the Engram node embeddings for these
|
||||
//! two types close enough in meaning-space? This enables the `activate`
|
||||
//! construct to return a statically-typed result even though the query is
|
||||
//! a free-form natural language string.
|
||||
//!
|
||||
//! In the current implementation, semantic compatibility falls back to a
|
||||
//! symbolic check (are the Engram node type strings the same?). When an
|
||||
//! actual Engram database is connected via `CompilerOptions::engram_db_path`,
|
||||
//! the checker can delegate to real cosine-similarity over embeddings.
|
||||
|
||||
mod error;
|
||||
mod types;
|
||||
mod checker;
|
||||
|
||||
pub use error::{TypeError, TypeErrorKind};
|
||||
pub use types::{Type, TypeDef, TypeEnv};
|
||||
pub use checker::TypeChecker;
|
||||
@@ -0,0 +1,308 @@
|
||||
//! Core type definitions and the type environment.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// The semantic type of a value in Engram source.
|
||||
///
|
||||
/// Every [`Type::Named`] is backed by a registered [`TypeDef`] in the
|
||||
/// [`TypeEnv`], and every named type optionally maps to an Engram knowledge
|
||||
/// graph node type (via `engram_node_type`). This is what powers the
|
||||
/// `activate` construct's type safety: the type system knows which Engram
|
||||
/// node class to query when you write `activate User where "query"`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Type {
|
||||
// ── Primitives ────────────────────────────────────────────────────────────
|
||||
Int,
|
||||
Float,
|
||||
String,
|
||||
Bool,
|
||||
Uuid,
|
||||
Void,
|
||||
|
||||
// ── Composite ─────────────────────────────────────────────────────────────
|
||||
/// A user-defined named type (struct or enum). Maps to a TypeDef.
|
||||
Named(std::string::String),
|
||||
/// A homogeneous array of a single element type.
|
||||
Array(Box<Type>),
|
||||
/// An optional (nullable) value.
|
||||
Optional(Box<Type>),
|
||||
|
||||
// ── Function ──────────────────────────────────────────────────────────────
|
||||
Fn { params: Vec<Type>, return_type: Box<Type> },
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────────────────
|
||||
/// Unknown type — used before type inference has resolved a binding.
|
||||
Unknown,
|
||||
/// The never/bottom type — returned by diverging expressions.
|
||||
Never,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Type {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Type::Int => write!(f, "Int"),
|
||||
Type::Float => write!(f, "Float"),
|
||||
Type::String => write!(f, "String"),
|
||||
Type::Bool => write!(f, "Bool"),
|
||||
Type::Uuid => write!(f, "Uuid"),
|
||||
Type::Void => write!(f, "Void"),
|
||||
Type::Named(n) => write!(f, "{n}"),
|
||||
Type::Array(t) => write!(f, "[{t}]"),
|
||||
Type::Optional(t) => write!(f, "{t}?"),
|
||||
Type::Fn { params, return_type } => {
|
||||
let ps: Vec<_> = params.iter().map(|p| p.to_string()).collect();
|
||||
write!(f, "fn({}) -> {return_type}", ps.join(", "))
|
||||
}
|
||||
Type::Unknown => write!(f, "<unknown>"),
|
||||
Type::Never => write!(f, "!"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── TypeDef ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The definition of a named type — either a struct or an enum.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TypeDef {
|
||||
Struct {
|
||||
name: std::string::String,
|
||||
fields: Vec<(std::string::String, Type)>,
|
||||
},
|
||||
Enum {
|
||||
name: std::string::String,
|
||||
variants: Vec<EnumVariant>,
|
||||
},
|
||||
/// A built-in primitive alias (e.g. `Uuid` is a Named type mapped to a built-in).
|
||||
Primitive(Type),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnumVariant {
|
||||
pub name: std::string::String,
|
||||
/// Payload type for tuple variants like `Pending(String)`.
|
||||
pub payload: Option<Type>,
|
||||
}
|
||||
|
||||
// ── TypeEnv ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The type environment — a lexically-scoped binding of names to types.
|
||||
///
|
||||
/// A `TypeEnv` can be cheaply cloned to create child scopes (e.g. for
|
||||
/// function bodies). New bindings in the child do not escape to the parent.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TypeEnv {
|
||||
/// Maps variable/binding names to their inferred or declared types.
|
||||
bindings: HashMap<std::string::String, Type>,
|
||||
/// Maps type names to their definitions.
|
||||
pub types: HashMap<std::string::String, TypeDef>,
|
||||
/// Maps named type names to the Engram graph node type string.
|
||||
/// Used by the `activate` construct to know which node class to query.
|
||||
pub engram_mappings: HashMap<std::string::String, std::string::String>,
|
||||
/// Maps function names to their function types.
|
||||
pub functions: HashMap<std::string::String, Type>,
|
||||
}
|
||||
|
||||
impl TypeEnv {
|
||||
/// Create a fresh environment pre-populated with built-in types.
|
||||
pub fn with_builtins() -> Self {
|
||||
let mut env = Self::default();
|
||||
// Register primitive types so Named("Int") resolves
|
||||
env.types.insert("Int".into(), TypeDef::Primitive(Type::Int));
|
||||
env.types.insert("Float".into(), TypeDef::Primitive(Type::Float));
|
||||
env.types.insert("String".into(), TypeDef::Primitive(Type::String));
|
||||
env.types.insert("Bool".into(), TypeDef::Primitive(Type::Bool));
|
||||
env.types.insert("Uuid".into(), TypeDef::Primitive(Type::Uuid));
|
||||
env.types.insert("Void".into(), TypeDef::Primitive(Type::Void));
|
||||
env
|
||||
}
|
||||
|
||||
// ── Bindings ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn bind(&mut self, name: impl Into<std::string::String>, ty: Type) {
|
||||
self.bindings.insert(name.into(), ty);
|
||||
}
|
||||
|
||||
pub fn lookup(&self, name: &str) -> Option<&Type> {
|
||||
self.bindings.get(name)
|
||||
}
|
||||
|
||||
// ── Type registration ─────────────────────────────────────────────────────
|
||||
|
||||
/// Register a user-defined type with an optional Engram node type mapping.
|
||||
pub fn register_type(
|
||||
&mut self,
|
||||
name: impl Into<std::string::String>,
|
||||
def: TypeDef,
|
||||
engram_node_type: impl Into<std::string::String>,
|
||||
) {
|
||||
let name = name.into();
|
||||
let engram = engram_node_type.into();
|
||||
if !engram.is_empty() {
|
||||
self.engram_mappings.insert(name.clone(), engram);
|
||||
}
|
||||
self.types.insert(name, def);
|
||||
}
|
||||
|
||||
pub fn get_type(&self, name: &str) -> Option<&TypeDef> {
|
||||
self.types.get(name)
|
||||
}
|
||||
|
||||
/// Register a function signature.
|
||||
pub fn register_fn(&mut self, name: impl Into<std::string::String>, ty: Type) {
|
||||
self.functions.insert(name.into(), ty);
|
||||
}
|
||||
|
||||
pub fn lookup_fn(&self, name: &str) -> Option<&Type> {
|
||||
self.functions.get(name)
|
||||
}
|
||||
|
||||
// ── Compatibility ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Check whether type `a` is assignable to type `b`.
|
||||
///
|
||||
/// This is a structural check with a semantic override: if both types are
|
||||
/// `Named` and have Engram node type mappings, semantic compatibility is
|
||||
/// checked as well. Currently the semantic check is symbolic (same node
|
||||
/// type string = compatible). When an actual Engram DB is available this
|
||||
/// would use cosine similarity over embeddings.
|
||||
pub fn check_compatible(&self, a: &Type, b: &Type) -> bool {
|
||||
match (a, b) {
|
||||
// Unknown is compatible with everything (used during inference)
|
||||
(Type::Unknown, _) | (_, Type::Unknown) => true,
|
||||
// Never is compatible with everything (bottom type)
|
||||
(Type::Never, _) => true,
|
||||
// Structural matches
|
||||
(Type::Int, Type::Int) => true,
|
||||
(Type::Float, Type::Float) => true,
|
||||
(Type::String, Type::String) => true,
|
||||
(Type::Bool, Type::Bool) => true,
|
||||
(Type::Uuid, Type::Uuid) => true,
|
||||
(Type::Void, Type::Void) => true,
|
||||
// Int is promotable to Float
|
||||
(Type::Int, Type::Float) => true,
|
||||
// Named types: structural + semantic
|
||||
(Type::Named(a_name), Type::Named(b_name)) => {
|
||||
if a_name == b_name {
|
||||
return true;
|
||||
}
|
||||
// Semantic compatibility via Engram node type mappings
|
||||
let a_node = self.engram_mappings.get(a_name);
|
||||
let b_node = self.engram_mappings.get(b_name);
|
||||
match (a_node, b_node) {
|
||||
(Some(a_n), Some(b_n)) => a_n == b_n,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
(Type::Array(a_inner), Type::Array(b_inner)) => {
|
||||
self.check_compatible(a_inner, b_inner)
|
||||
}
|
||||
(Type::Optional(a_inner), Type::Optional(b_inner)) => {
|
||||
self.check_compatible(a_inner, b_inner)
|
||||
}
|
||||
// T is compatible with T?
|
||||
(t, Type::Optional(inner)) => self.check_compatible(t, inner),
|
||||
(Type::Fn { params: ap, return_type: ar }, Type::Fn { params: bp, return_type: br }) => {
|
||||
ap.len() == bp.len()
|
||||
&& ap.iter().zip(bp.iter()).all(|(a, b)| self.check_compatible(a, b))
|
||||
&& self.check_compatible(ar, br)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a [`TypeExpr`] from the parser into a [`Type`].
|
||||
pub fn resolve_type_expr(&self, te: &el_parser::TypeExpr) -> Result<Type, crate::TypeError> {
|
||||
match te {
|
||||
el_parser::TypeExpr::Named(n) => {
|
||||
// Check if it's a built-in alias or a registered user type
|
||||
Ok(match n.as_str() {
|
||||
"Int" => Type::Int,
|
||||
"Float" => Type::Float,
|
||||
"String" => Type::String,
|
||||
"Bool" => Type::Bool,
|
||||
"Uuid" => Type::Uuid,
|
||||
"Void" => Type::Void,
|
||||
other => {
|
||||
if self.types.contains_key(other) {
|
||||
Type::Named(other.to_string())
|
||||
} else {
|
||||
return Err(crate::TypeError::new(
|
||||
crate::TypeErrorKind::UndefinedType(other.to_string()),
|
||||
));
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
el_parser::TypeExpr::Array(inner) => {
|
||||
Ok(Type::Array(Box::new(self.resolve_type_expr(inner)?)))
|
||||
}
|
||||
el_parser::TypeExpr::Optional(inner) => {
|
||||
Ok(Type::Optional(Box::new(self.resolve_type_expr(inner)?)))
|
||||
}
|
||||
el_parser::TypeExpr::Fn { params, return_type } => {
|
||||
let ps = params.iter().map(|p| self.resolve_type_expr(p)).collect::<Result<Vec<_>, _>>()?;
|
||||
let ret = self.resolve_type_expr(return_type)?;
|
||||
Ok(Type::Fn { params: ps, return_type: Box::new(ret) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn env() -> TypeEnv {
|
||||
TypeEnv::with_builtins()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_primitive_compatibility() {
|
||||
let e = env();
|
||||
assert!(e.check_compatible(&Type::Int, &Type::Int));
|
||||
assert!(e.check_compatible(&Type::String, &Type::String));
|
||||
assert!(!e.check_compatible(&Type::Int, &Type::String));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_int_promotes_to_float() {
|
||||
let e = env();
|
||||
assert!(e.check_compatible(&Type::Int, &Type::Float));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_named_same_is_compatible() {
|
||||
let e = env();
|
||||
assert!(e.check_compatible(&Type::Named("User".into()), &Type::Named("User".into())));
|
||||
assert!(!e.check_compatible(&Type::Named("User".into()), &Type::Named("Order".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_semantic_compatibility_via_engram_mapping() {
|
||||
let mut e = env();
|
||||
// Map both User and Customer to the "Entity" Engram node type
|
||||
e.engram_mappings.insert("User".into(), "Entity".into());
|
||||
e.engram_mappings.insert("Customer".into(), "Entity".into());
|
||||
assert!(e.check_compatible(&Type::Named("User".into()), &Type::Named("Customer".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_optional_compatibility() {
|
||||
let e = env();
|
||||
// Int is compatible with Int?
|
||||
assert!(e.check_compatible(&Type::Int, &Type::Optional(Box::new(Type::Int))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_array_compatibility() {
|
||||
let e = env();
|
||||
assert!(e.check_compatible(
|
||||
&Type::Array(Box::new(Type::Int)),
|
||||
&Type::Array(Box::new(Type::Int)),
|
||||
));
|
||||
assert!(!e.check_compatible(
|
||||
&Type::Array(Box::new(Type::Int)),
|
||||
&Type::Array(Box::new(Type::String)),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// activate.el — The `activate` construct: spreading activation over the type graph.
|
||||
//
|
||||
// The `activate` keyword is the most novel feature of the Engram language.
|
||||
// Instead of querying a database with SQL or a key lookup, you query the
|
||||
// *type graph* using a natural language description. The result is
|
||||
// statically typed — the compiler knows you get [User] back, not Any.
|
||||
//
|
||||
// How it works:
|
||||
//
|
||||
// 1. At compile time, the type checker verifies that `User` is a registered
|
||||
// type with a corresponding Engram node mapping.
|
||||
//
|
||||
// 2. At runtime, the Engram runtime performs spreading activation over
|
||||
// the knowledge graph, starting from the User node, propagating to
|
||||
// semantically related nodes with co-activation energy proportional
|
||||
// to edge weights and node salience.
|
||||
//
|
||||
// 3. The query string "customer who purchased recently" is embedded
|
||||
// and used as a semantic filter: only nodes whose embeddings are
|
||||
// within cosine-similarity threshold of the query embedding are returned.
|
||||
//
|
||||
// 4. The result is type-safe: [User]. You don't get back Any or a JSON blob.
|
||||
// The type checker enforces this at compile time.
|
||||
|
||||
type User {
|
||||
id: Uuid
|
||||
name: String
|
||||
email: String
|
||||
}
|
||||
|
||||
type Order {
|
||||
id: Uuid
|
||||
user_id: Uuid
|
||||
amount: Float
|
||||
status: String
|
||||
}
|
||||
|
||||
// Query: find all users who are semantically related to "customer who purchased recently"
|
||||
// The result type is [User] — an array of User nodes from the knowledge graph.
|
||||
let recent_buyers: [User] = activate User where "customer who purchased recently"
|
||||
|
||||
// You can also activate on other types:
|
||||
let pending_orders: [Order] = activate Order where "order awaiting fulfillment"
|
||||
|
||||
// Spreading activation respects the type graph edges.
|
||||
// If User and Customer share an Engram node type (e.g. "Entity"),
|
||||
// they are semantically compatible — the type checker allows
|
||||
// treating one as the other when they map to the same Engram node class.
|
||||
|
||||
fn process_buyers(users: [User]) -> String {
|
||||
return "processing users"
|
||||
}
|
||||
|
||||
let result: String = process_buyers(recent_buyers)
|
||||
@@ -0,0 +1,8 @@
|
||||
// hello.el — The simplest Engram program.
|
||||
// Run with: el run hello.el
|
||||
|
||||
fn greet(name: String) -> String {
|
||||
return "Hello, " + name
|
||||
}
|
||||
|
||||
let message: String = greet("World")
|
||||
@@ -0,0 +1,40 @@
|
||||
// types.el — Type definitions and pattern matching.
|
||||
//
|
||||
// Demonstrates:
|
||||
// - type (struct) definitions
|
||||
// - enum definitions with payloads
|
||||
// - match expressions with enum patterns
|
||||
|
||||
// A user-defined struct type.
|
||||
// Every field is typed; the type name maps to an Engram graph node.
|
||||
type User {
|
||||
id: Uuid
|
||||
name: String
|
||||
email: String
|
||||
}
|
||||
|
||||
// An enum with a payload variant.
|
||||
enum Status {
|
||||
Active
|
||||
Inactive
|
||||
Pending(String)
|
||||
}
|
||||
|
||||
// A function that takes a Status and returns a human-readable string.
|
||||
fn describe_status(s: Status) -> String {
|
||||
return match s {
|
||||
Status::Active => "user is active"
|
||||
Status::Inactive => "user is inactive"
|
||||
Status::Pending(reason) => "pending: " + reason
|
||||
}
|
||||
}
|
||||
|
||||
// Sealed blocks protect sensitive values even in debug builds.
|
||||
// The runtime enforces that values in sealed blocks are never
|
||||
// observable through a debugger or memory dump.
|
||||
sealed {
|
||||
let api_key: String = "sk-prod-12345"
|
||||
}
|
||||
|
||||
let status: Status = Status::Pending("email not verified")
|
||||
let description: String = describe_status(status)
|
||||
@@ -0,0 +1,592 @@
|
||||
# Engram Language Specification
|
||||
|
||||
Version 0.1.0 — April 2026
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Engram is a statically-typed programming language designed from first principles
|
||||
around a knowledge graph type system. Its three defining properties:
|
||||
|
||||
1. **Types are Engram nodes.** Every named type in the language is a node in a
|
||||
knowledge graph. Type compatibility is not purely structural — it is also
|
||||
semantic. Two types are compatible if their Engram node embeddings are
|
||||
similar enough in meaning-space.
|
||||
|
||||
2. **Autocomplete is spreading activation.** The language server (LSP) uses
|
||||
spreading activation over the type graph to suggest completions. You get
|
||||
concepts semantically related to what you're building, not just methods on
|
||||
the current type.
|
||||
|
||||
3. **The `prod` compilation target is quantum-sealed.** Bytecode compiled with
|
||||
`--target prod` is encrypted with AES-256-GCM and signed. Without the
|
||||
deployment key, the artifact is indistinguishable from random bytes. No
|
||||
static analysis tool can decompile it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Syntax Reference
|
||||
|
||||
### 1.1 Comments
|
||||
|
||||
```
|
||||
// Single-line comment — extends to end of line
|
||||
```
|
||||
|
||||
Block comments are not supported in v0.1. Use `//` on each line.
|
||||
|
||||
### 1.2 Variable Declarations
|
||||
|
||||
```
|
||||
let name: Type = expression
|
||||
let name = expression // type inferred
|
||||
```
|
||||
|
||||
Variables are immutable by default. All bindings are block-scoped.
|
||||
|
||||
### 1.3 Functions
|
||||
|
||||
```
|
||||
fn name(param1: Type1, param2: Type2) -> ReturnType {
|
||||
// body
|
||||
return expression
|
||||
}
|
||||
```
|
||||
|
||||
Functions are first-class values. The type of a function is:
|
||||
```
|
||||
fn(Type1, Type2) -> ReturnType
|
||||
```
|
||||
|
||||
### 1.4 Types (Structs)
|
||||
|
||||
```
|
||||
type TypeName {
|
||||
field1: Type1
|
||||
field2: Type2
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Every `type` definition registers a node in the Engram type graph. The type
|
||||
name becomes searchable via spreading activation.
|
||||
|
||||
### 1.5 Enums
|
||||
|
||||
```
|
||||
enum EnumName {
|
||||
Variant1
|
||||
Variant2
|
||||
VariantWithPayload(PayloadType)
|
||||
}
|
||||
```
|
||||
|
||||
Enum variants without parentheses carry no payload. Variants with parentheses
|
||||
carry exactly one value of the given type.
|
||||
|
||||
### 1.6 Pattern Matching
|
||||
|
||||
```
|
||||
match expression {
|
||||
Pattern1 => result_expr1
|
||||
Pattern2 => result_expr2
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Pattern forms:
|
||||
- `EnumName::Variant` — unit enum variant
|
||||
- `EnumName::Variant(binding)` — enum variant with payload binding
|
||||
- `literal` — exact literal (42, "str", true)
|
||||
- `name` — binding (captures the subject into `name`)
|
||||
- `_` — wildcard (always matches, discards)
|
||||
|
||||
All arms must produce the same type. The `match` expression evaluates to that type.
|
||||
|
||||
### 1.7 Control Flow
|
||||
|
||||
**If/else:**
|
||||
```
|
||||
if condition {
|
||||
// then branch
|
||||
} else {
|
||||
// else branch
|
||||
}
|
||||
```
|
||||
|
||||
Both branches must produce the same type. The `else` branch is optional (produces `Void`).
|
||||
|
||||
**For loops:**
|
||||
```
|
||||
for item in collection {
|
||||
// body
|
||||
}
|
||||
```
|
||||
|
||||
### 1.8 Field Access
|
||||
|
||||
```
|
||||
value.field_name
|
||||
```
|
||||
|
||||
Field access is type-checked at compile time. Accessing a field that does not
|
||||
exist in the type definition is a compile error.
|
||||
|
||||
### 1.9 Array Literals
|
||||
|
||||
```
|
||||
let numbers: [Int] = [1, 2, 3]
|
||||
let empty: [String] = []
|
||||
```
|
||||
|
||||
### 1.10 Index Access
|
||||
|
||||
```
|
||||
let first: Int = numbers[0]
|
||||
```
|
||||
|
||||
Index expressions require an `Int` index. Bounds checking is runtime behavior.
|
||||
|
||||
---
|
||||
|
||||
## 2. Type System
|
||||
|
||||
### 2.1 Primitive Types
|
||||
|
||||
| Type | Description | Example literal |
|
||||
|----------|--------------------------------------|---------------------|
|
||||
| `Int` | 64-bit signed integer | `42`, `-7`, `1_000` |
|
||||
| `Float` | 64-bit IEEE 754 double | `3.14`, `0.5` |
|
||||
| `String` | UTF-8 string | `"hello"` |
|
||||
| `Bool` | Boolean | `true`, `false` |
|
||||
| `Uuid` | RFC 4122 UUID | (runtime only) |
|
||||
| `Void` | Unit type; no value | — |
|
||||
|
||||
### 2.2 Composite Types
|
||||
|
||||
| Type form | Description |
|
||||
|-------------|--------------------------------------|
|
||||
| `[T]` | Array of `T` |
|
||||
| `T?` | Optional `T` (may be absent) |
|
||||
| `Named` | User-defined struct or enum |
|
||||
|
||||
### 2.3 Numeric Coercions
|
||||
|
||||
- `Int` is implicitly coercible to `Float`.
|
||||
- `Float` is not coercible to `Int` (use explicit conversion when available).
|
||||
- `String + String` uses concatenation (the `+` operator is overloaded).
|
||||
|
||||
### 2.4 Structural vs. Semantic Compatibility
|
||||
|
||||
Standard structural compatibility:
|
||||
- `Named("User")` is compatible with `Named("User")` (same name).
|
||||
- `[Int]` is compatible with `[Int]`.
|
||||
- `T` is compatible with `T?` (non-optional can be used as optional).
|
||||
- `Int` is compatible with `Float` (widening).
|
||||
|
||||
**Semantic compatibility (novel):**
|
||||
|
||||
When two named types have registered Engram node type mappings that refer to
|
||||
the same node class, they are considered semantically compatible:
|
||||
|
||||
```
|
||||
// Register User and Customer as both mapping to the "Entity" Engram node type
|
||||
// → User and Customer are semantically compatible
|
||||
```
|
||||
|
||||
This is computed via cosine similarity over node embeddings when an Engram
|
||||
database is connected. Without a database, the comparison is symbolic (same
|
||||
node type string = compatible).
|
||||
|
||||
Semantic compatibility threshold: cosine similarity ≥ 0.85 (configurable).
|
||||
|
||||
### 2.5 Type Inference
|
||||
|
||||
The compiler infers types for `let` bindings without annotations:
|
||||
```
|
||||
let x = 42 // inferred: Int
|
||||
let s = "hello" // inferred: String
|
||||
let b = true // inferred: Bool
|
||||
```
|
||||
|
||||
Function return types and parameter types must always be annotated. This is
|
||||
intentional: function signatures are documentation.
|
||||
|
||||
---
|
||||
|
||||
## 3. The `activate` Construct
|
||||
|
||||
### 3.1 Syntax
|
||||
|
||||
```
|
||||
activate TypeName where "semantic query string"
|
||||
```
|
||||
|
||||
### 3.2 What It Does
|
||||
|
||||
`activate` is a first-class language construct that performs a spreading
|
||||
activation query over the Engram knowledge graph and returns a typed array of
|
||||
results.
|
||||
|
||||
The query string is a natural language description. At runtime, the Engram
|
||||
runtime:
|
||||
|
||||
1. Embeds the query string into the same vector space as node embeddings.
|
||||
2. Starts activation at the `TypeName` node and all nodes semantically related
|
||||
to it (cosine similarity above threshold).
|
||||
3. Spreads activation outward through graph edges, attenuated by edge weight
|
||||
and node salience.
|
||||
4. Returns all nodes whose activation level exceeds the minimum threshold,
|
||||
projected back to the `TypeName` schema.
|
||||
|
||||
### 3.3 Static Typing
|
||||
|
||||
The result type of `activate TypeName where "..."` is always `[TypeName]`.
|
||||
|
||||
```
|
||||
let users: [User] = activate User where "recent premium subscribers"
|
||||
// ↑ type-checked: [User]
|
||||
```
|
||||
|
||||
If `TypeName` is not a registered type, the compiler emits an error. The
|
||||
query string is opaque to the type checker — it only passes the string through
|
||||
to the runtime.
|
||||
|
||||
### 3.4 Without an Engram Database
|
||||
|
||||
When compiled without an Engram database (`CompilerOptions::engram_db_path` is
|
||||
`None`), the `activate` construct emits a stub instruction. At runtime, the
|
||||
interpreter emits a diagnostic and returns an empty array `[]`.
|
||||
|
||||
This allows programs using `activate` to compile and run in development without
|
||||
requiring a live Engram instance.
|
||||
|
||||
### 3.5 Compile-time Behavior
|
||||
|
||||
At `--target prod`, `activate` is compiled to an `ACTIVATE` bytecode instruction.
|
||||
The query string is embedded in the bytecode. The sealed artifact protects the
|
||||
query from being read by static analysis.
|
||||
|
||||
---
|
||||
|
||||
## 4. Sealed Blocks
|
||||
|
||||
### 4.1 Syntax
|
||||
|
||||
```
|
||||
sealed {
|
||||
let api_key: String = env("API_KEY")
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 What's Protected
|
||||
|
||||
Code inside a `sealed {}` block is subject to additional runtime protection:
|
||||
|
||||
- **In debug builds:** The `SEALED_BEGIN` / `SEALED_END` bytecode markers are
|
||||
emitted. The debugger is notified not to expose values in this region.
|
||||
- **In release builds:** Same as debug, with no source map entries for the
|
||||
sealed region.
|
||||
- **In prod builds:** The entire bytecode (including the sealed section) is
|
||||
AES-256-GCM encrypted in the sealed artifact. There is no additional treatment
|
||||
of the sealed section — the *entire prod artifact* is the sealed section.
|
||||
|
||||
### 4.3 Intent
|
||||
|
||||
The `sealed {}` block communicates developer intent: "this section handles
|
||||
sensitive material." It is especially meaningful during development when
|
||||
`debug` builds are used, since it signals to the runtime and any attached
|
||||
debugger to redact values from inspection.
|
||||
|
||||
In `prod` builds, the `sealed {}` annotation is redundant (the whole artifact
|
||||
is sealed), but it is preserved for documentation and future tooling that can
|
||||
enforce stricter runtime isolation.
|
||||
|
||||
---
|
||||
|
||||
## 5. Compilation Targets
|
||||
|
||||
### 5.1 debug
|
||||
|
||||
```
|
||||
el build file.el --target debug
|
||||
```
|
||||
|
||||
Produces:
|
||||
- `file.elc` — JSON-serialized bytecode instructions
|
||||
- `file.map.json` — source map: JSON array of `{instruction, start, end, line, col}` objects
|
||||
|
||||
The source map allows debuggers and error reporters to translate bytecode
|
||||
offsets back to exact source positions (file + line + column).
|
||||
|
||||
Debug builds:
|
||||
- No dead-code elimination
|
||||
- No constant folding
|
||||
- Full source map coverage
|
||||
- Type errors are reported as warnings (compilation continues)
|
||||
|
||||
### 5.2 release
|
||||
|
||||
```
|
||||
el build file.el --target release
|
||||
```
|
||||
|
||||
Produces:
|
||||
- `file.elc` — JSON-serialized bytecode instructions
|
||||
|
||||
Release builds:
|
||||
- No source map
|
||||
- Minor dead-code pruning (unreachable after `return`)
|
||||
- Type errors are warnings (compilation continues)
|
||||
|
||||
### 5.3 prod
|
||||
|
||||
```
|
||||
el build file.el --target prod
|
||||
ENGRAM_SEAL_KEY=my-secret el build file.el --target prod
|
||||
```
|
||||
|
||||
Produces:
|
||||
- `file.sealed` — quantum-sealed artifact
|
||||
|
||||
Prod builds:
|
||||
- **Type errors are fatal** — the compiler refuses to produce a sealed artifact
|
||||
from a program with type errors
|
||||
- The output is encrypted and cannot be decompiled
|
||||
- All debug information is stripped before sealing
|
||||
- Source maps are never produced
|
||||
|
||||
---
|
||||
|
||||
## 6. The Sealed Artifact Format
|
||||
|
||||
### 6.1 Wire Format
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
────── ────── ────────────────────────────────────────────
|
||||
0 8 Magic: b"ENGRAM01"
|
||||
8 2 Format version: u16 big-endian (currently 1)
|
||||
10 * JSON body: SealedArtifact struct
|
||||
```
|
||||
|
||||
The JSON body is a `SealedArtifact`:
|
||||
|
||||
```json
|
||||
{
|
||||
"algorithm_id": "aes256gcm-v1",
|
||||
"signature": "...(base64)...",
|
||||
"encapsulated_key": "...(base64)...",
|
||||
"nonce": "...(base64)...",
|
||||
"ciphertext": "...(base64)...",
|
||||
"deployment_fingerprint": "...(base64 or null)..."
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 Field Descriptions
|
||||
|
||||
| Field | Description |
|
||||
|--------------------------|----------------------------------------------------------------------|
|
||||
| `algorithm_id` | The encryption algorithm. Currently `aes256gcm-v1`. Reserved for ML-KEM upgrade. |
|
||||
| `signature` | BLAKE3 keyed MAC over `(algorithm_id ‖ nonce ‖ ciphertext)`. Detects tampering before decryption attempt. |
|
||||
| `encapsulated_key` | 32 bytes: `symmetric_key XOR BLAKE3(deployment_binding_material)`. Requires knowledge of the deployment secret to recover the symmetric key. |
|
||||
| `nonce` | 12-byte (96-bit) AES-GCM nonce. Randomly generated per seal operation. |
|
||||
| `ciphertext` | AES-256-GCM ciphertext of the bytecode, including the 128-bit GCM authentication tag. |
|
||||
| `deployment_fingerprint` | BLAKE3 hash of the deployment binding material. Stored so the unsealer can verify it is running in the correct environment before attempting decryption. `null` for `DeploymentBinding::None`. |
|
||||
|
||||
### 6.3 Sealing Process
|
||||
|
||||
1. Generate a cryptographically random 256-bit symmetric key `K`.
|
||||
2. Encrypt bytecode: `ciphertext = AES-256-GCM(K, nonce=random_96bit, plaintext=bytecode)`.
|
||||
3. Derive the deployment binding hash: `H = BLAKE3(deployment_material)`.
|
||||
4. Encapsulate: `encapsulated_key = K XOR H` (32 bytes).
|
||||
5. Compute MAC: `signature = BLAKE3-keyed(K, algorithm_id ‖ nonce ‖ ciphertext)`.
|
||||
6. Serialize: `ENGRAM01 ‖ version_u16be ‖ JSON(artifact)`.
|
||||
|
||||
### 6.4 Unsealing Process
|
||||
|
||||
1. Parse magic and version; reject if not `ENGRAM01 / version 1`.
|
||||
2. Derive deployment hash: `H = BLAKE3(provided_binding_key)`.
|
||||
3. Verify fingerprint: if `deployment_fingerprint` is present, assert `BLAKE3(binding_key) == fingerprint`. Fail with `BindingMismatch` if not.
|
||||
4. Recover symmetric key: `K = encapsulated_key XOR H`.
|
||||
5. Verify MAC: compute `BLAKE3-keyed(K, ...)` and compare to `signature`. Fail with `SignatureInvalid` if mismatch.
|
||||
6. Decrypt: `bytecode = AES-256-GCM-Decrypt(K, nonce, ciphertext)`. The GCM auth tag is verified here automatically.
|
||||
|
||||
### 6.5 Security Properties
|
||||
|
||||
**Why "quantum-sealed":**
|
||||
|
||||
AES-256 is quantum-resistant at the 256-bit key length. Grover's algorithm
|
||||
provides a quadratic speedup in key search, reducing effective security from
|
||||
2^256 to 2^128. 128-bit quantum security is considered sufficient by NIST for
|
||||
the foreseeable future.
|
||||
|
||||
The `algorithm_id` field is forward-compatible: when `ml-kem` (CRYSTALS-Kyber
|
||||
ML-KEM-768 or ML-KEM-1024) crates stabilize, the upgrade is:
|
||||
|
||||
1. Implement `SealAlgorithm::MlKem768` in `el-seal`.
|
||||
2. The `encapsulated_key` field becomes the KEM-encapsulated ciphertext.
|
||||
3. Old artifacts retain their `aes256gcm-v1` algorithm_id and continue to
|
||||
unseal via the existing code path.
|
||||
|
||||
**Decompilation resistance:**
|
||||
|
||||
Without the deployment key, `K` cannot be recovered (requires knowing
|
||||
`deployment_material`), so `ciphertext` is indistinguishable from random
|
||||
bytes. Static analysis tools, disassemblers, and decompilers receive the
|
||||
AES-GCM ciphertext — semantically empty. Any tampering flips bits in the GCM
|
||||
ciphertext, causing authentication tag verification to fail before the
|
||||
symmetric layer is even reached.
|
||||
|
||||
---
|
||||
|
||||
## 7. Deployment Binding Modes
|
||||
|
||||
| Mode | Description | Security |
|
||||
|-----------------------|--------------------------------------------------------|--------------|
|
||||
| `EnvironmentKey(var)` | Derives binding from the value of an environment variable. Default: `ENGRAM_SEAL_KEY`. | High — key must be provisioned at runtime |
|
||||
| `MachineFingerprint` | Derives binding from hostname + OS + architecture. Artifact can only run on the same machine. | Medium — fingerprint is observable |
|
||||
| `None` | No binding (zero vector). Testing and development only. | None |
|
||||
|
||||
---
|
||||
|
||||
## 8. Operators
|
||||
|
||||
| Operator | Types | Result |
|
||||
|----------|--------------------|--------|
|
||||
| `+` | Int, Float, String | same as operands (String: concatenation) |
|
||||
| `-` | Int, Float | same |
|
||||
| `*` | Int, Float | same |
|
||||
| `/` | Int, Float | same |
|
||||
| `==` | any compatible pair | Bool |
|
||||
| `!=` | any compatible pair | Bool |
|
||||
| `<` `>` `<=` `>=` | Int, Float | Bool |
|
||||
| `&&` | Bool, Bool | Bool |
|
||||
| `\|\|` | Bool, Bool | Bool |
|
||||
| `!` | Bool | Bool |
|
||||
|
||||
Operator precedence (high to low):
|
||||
1. `!` (unary)
|
||||
2. `*` `/`
|
||||
3. `+` `-`
|
||||
4. `<` `>` `<=` `>=`
|
||||
5. `==` `!=`
|
||||
6. `&&`
|
||||
7. `||`
|
||||
|
||||
---
|
||||
|
||||
## 9. Escape Sequences in String Literals
|
||||
|
||||
| Sequence | Character |
|
||||
|----------|------------|
|
||||
| `\n` | Newline |
|
||||
| `\t` | Tab |
|
||||
| `\r` | Carriage return |
|
||||
| `\"` | Double quote |
|
||||
| `\\` | Backslash |
|
||||
| `\0` | Null byte |
|
||||
|
||||
---
|
||||
|
||||
## 10. CLI Reference
|
||||
|
||||
```
|
||||
el build <file.el> [--target debug|release|prod] [-o <output>]
|
||||
el run <file.el>
|
||||
el check <file.el>
|
||||
el seal <artifact> [-o <output>]
|
||||
el unseal <artifact> [-o <output>]
|
||||
```
|
||||
|
||||
**el build** — Compile a source file. Default target is `debug`.
|
||||
|
||||
**el run** — Compile with `debug` target and execute immediately in the
|
||||
built-in interpreter. Does not write an output file.
|
||||
|
||||
**el check** — Type-check only. Exits with code 0 if no errors, 1 if errors.
|
||||
Useful for CI.
|
||||
|
||||
**el seal** — Take an existing release artifact and seal it. Reads
|
||||
`ENGRAM_SEAL_KEY` from the environment if set.
|
||||
|
||||
**el unseal** — Decrypt a sealed artifact. Reads `ENGRAM_SEAL_KEY` from the
|
||||
environment. Writes decrypted bytecode to the output path.
|
||||
|
||||
---
|
||||
|
||||
## 11. Grammar (EBNF)
|
||||
|
||||
```ebnf
|
||||
program = stmt* EOF
|
||||
|
||||
stmt = let_stmt
|
||||
| return_stmt
|
||||
| fn_def
|
||||
| type_def
|
||||
| enum_def
|
||||
| expr_stmt
|
||||
|
||||
let_stmt = "let" IDENT (":" type_expr)? "=" expr ";"?
|
||||
return_stmt = "return" expr ";"?
|
||||
expr_stmt = expr ";"?
|
||||
|
||||
fn_def = "fn" IDENT "(" param_list ")" "->" type_expr "{" stmt* "}"
|
||||
type_def = "type" IDENT "{" (IDENT ":" type_expr ","? ";"?)* "}"
|
||||
enum_def = "enum" IDENT "{" variant* "}"
|
||||
variant = IDENT ("(" type_expr ")")? ","?
|
||||
|
||||
param_list = (param ("," param)*)?
|
||||
param = IDENT ":" type_expr
|
||||
|
||||
type_expr = IDENT
|
||||
| "[" type_expr "]"
|
||||
| type_expr "?"
|
||||
| "fn" "(" (type_expr ("," type_expr)*)? ")" "->" type_expr
|
||||
|
||||
expr = or_expr
|
||||
or_expr = and_expr ("||" and_expr)*
|
||||
and_expr = eq_expr ("&&" eq_expr)*
|
||||
eq_expr = cmp_expr (("==" | "!=") cmp_expr)*
|
||||
cmp_expr = add_expr (("<" | ">" | "<=" | ">=") add_expr)*
|
||||
add_expr = mul_expr (("+" | "-") mul_expr)*
|
||||
mul_expr = unary_expr (("*" | "/") unary_expr)*
|
||||
unary_expr = "!" unary_expr | postfix_expr
|
||||
postfix_expr = primary ("." IDENT | "(" arg_list ")" | "[" expr "]")*
|
||||
|
||||
primary = INT | FLOAT | STRING | BOOL
|
||||
| "(" expr ")"
|
||||
| "[" arg_list "]"
|
||||
| "{" stmt* "}"
|
||||
| "if" expr primary ("else" primary)?
|
||||
| "match" expr "{" match_arm* "}"
|
||||
| "activate" IDENT "where" STRING
|
||||
| "sealed" "{" stmt* "}"
|
||||
| IDENT ("::" IDENT)*
|
||||
|
||||
arg_list = (expr ("," expr)*)?
|
||||
match_arm = pattern "=>" expr ","?
|
||||
|
||||
pattern = "_"
|
||||
| IDENT "::" IDENT ("(" IDENT ")")?
|
||||
| INT | STRING | BOOL
|
||||
| IDENT
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Future Directions
|
||||
|
||||
- **ML-KEM sealed artifacts** — upgrade `el-seal` to CRYSTALS-Kyber when the
|
||||
`ml-kem` crate stabilizes (drop-in: same format, new `algorithm_id`).
|
||||
- **LSP server** — spreading activation for autocomplete using the Engram
|
||||
database as the type graph backend.
|
||||
- **Engram DB integration** — live connection to an Engram database for
|
||||
`activate` at compile time (semantic type checking) and at runtime (actual
|
||||
node retrieval).
|
||||
- **Struct construction syntax** — `User { id: uuid, name: "Alice", ... }`.
|
||||
- **Generics** — `fn identity<T>(x: T) -> T { return x }`.
|
||||
- **Trait system** — behavioral interfaces that interact with the Engram type graph.
|
||||
- **Pattern matching on struct fields** — `match user { User { name: "admin" } => ... }`.
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc_fingerprint":12156818420864752294,"outputs":{"17747080675513052775":{"success":true,"status":"","code":0,"stdout":"rustc 1.94.0 (4a4ef493e 2026-03-02)\nbinary: rustc\ncommit-hash: 4a4ef493e3a1488c6e321570238084b38948f6db\ncommit-date: 2026-03-02\nhost: aarch64-apple-darwin\nrelease: 1.94.0\nLLVM version: 21.1.8\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.dylib\nlib___.dylib\nlib___.a\nlib___.dylib\n/Users/will/.rustup/toolchains/stable-aarch64-apple-darwin\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"aarch64\"\ntarget_endian=\"little\"\ntarget_env=\"\"\ntarget_family=\"unix\"\ntarget_feature=\"aes\"\ntarget_feature=\"crc\"\ntarget_feature=\"dit\"\ntarget_feature=\"dotprod\"\ntarget_feature=\"dpb\"\ntarget_feature=\"dpb2\"\ntarget_feature=\"fcma\"\ntarget_feature=\"fhm\"\ntarget_feature=\"flagm\"\ntarget_feature=\"fp16\"\ntarget_feature=\"frintts\"\ntarget_feature=\"jsconv\"\ntarget_feature=\"lor\"\ntarget_feature=\"lse\"\ntarget_feature=\"neon\"\ntarget_feature=\"paca\"\ntarget_feature=\"pacg\"\ntarget_feature=\"pan\"\ntarget_feature=\"pmuv3\"\ntarget_feature=\"ras\"\ntarget_feature=\"rcpc\"\ntarget_feature=\"rcpc2\"\ntarget_feature=\"rdm\"\ntarget_feature=\"sb\"\ntarget_feature=\"sha2\"\ntarget_feature=\"sha3\"\ntarget_feature=\"ssbs\"\ntarget_feature=\"vh\"\ntarget_has_atomic=\"128\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"macos\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"apple\"\nunix\n","stderr":""}},"successes":{}}
|
||||
@@ -0,0 +1,3 @@
|
||||
Signature: 8a477f597d28d172789f06886806bc55
|
||||
# This file is a cache directory tag created by cargo.
|
||||
# For information about cache directory tags see https://bford.info/cachedir/
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
7bbf263d4b5d7de2
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":10088478398040096810,"features":"[\"alloc\", \"getrandom\", \"rand_core\"]","declared_features":"[\"alloc\", \"arrayvec\", \"blobby\", \"bytes\", \"default\", \"dev\", \"getrandom\", \"heapless\", \"rand_core\", \"std\", \"stream\"]","target":6415113071054268027,"profile":5347358027863023418,"path":14456753889959366049,"deps":[[6039282458970808711,"crypto_common",false,15662686589037288465],[10520923840501062997,"generic_array",false,10112878813572505240]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/aead-84f02a00b210196c/dep-lib-aead","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
af17669ec84c9109
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":10088478398040096810,"features":"[]","declared_features":"[\"hazmat\", \"zeroize\"]","target":1651443328692853038,"profile":5347358027863023418,"path":251018351013202105,"deps":[[7667230146095136825,"cfg_if",false,15210096645161364247],[7916416211798676886,"cipher",false,13618285945931706330],[17620084158052398167,"cpufeatures",false,13997498211530695242]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/aes-0405562fbabbcd98/dep-lib-aes","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
2b86155f2285074f
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":10088478398040096810,"features":"[\"aes\", \"alloc\", \"default\", \"getrandom\", \"rand_core\"]","declared_features":"[\"aes\", \"alloc\", \"arrayvec\", \"default\", \"getrandom\", \"heapless\", \"rand_core\", \"std\", \"stream\", \"zeroize\"]","target":6327482228044654328,"profile":5347358027863023418,"path":9050550317643413961,"deps":[[5822136307240319171,"ctr",false,1984667175316043168],[7916416211798676886,"cipher",false,13618285945931706330],[17003143334332120809,"subtle",false,16923554282490341060],[17625407307438784893,"aes",false,689416642499057583],[17797166225172937111,"aead",false,16320303202390425467],[18030706926766528332,"ghash",false,16081659758129703950]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/aes-gcm-dd5b011b5902b9e3/dep-lib-aes_gcm","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
dfbb2e14298e6d16
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":10088478398040096810,"features":"[\"auto\", \"default\", \"wincon\"]","declared_features":"[\"auto\", \"default\", \"test\", \"wincon\"]","target":11278316191512382530,"profile":8255941854203129366,"path":219791009280154194,"deps":[[2608044744973004659,"anstyle_parse",false,12980428951946425480],[5652275617566266604,"anstyle_query",false,2726880939266865220],[7098682853475662231,"anstyle",false,1168809825470293997],[7711617929439759244,"colorchoice",false,7617143434391948168],[7727459912076845739,"is_terminal_polyfill",false,10643044772563587381],[17716308468579268865,"utf8parse",false,1679570728423809634]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/anstream-844675e00c4b0612/dep-lib-anstream","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
eda724f957723810
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":10088478398040096810,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"std\"]","target":6165884447290141869,"profile":8255941854203129366,"path":10647540871896512301,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/anstyle-1808efb267e49055/dep-lib-anstyle","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
8874f6c85bbf23b4
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":10088478398040096810,"features":"[\"default\", \"utf8\"]","declared_features":"[\"core\", \"default\", \"utf8\"]","target":10225663410500332907,"profile":8255941854203129366,"path":6511233520745312781,"deps":[[17716308468579268865,"utf8parse",false,1679570728423809634]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/anstyle-parse-a95bf7259265962c/dep-lib-anstyle_parse","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
44f8c841c4d3d725
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":10088478398040096810,"features":"[]","declared_features":"[]","target":10705714425685373190,"profile":14848920055892446256,"path":17230651471619537882,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/anstyle-query-03151ea01a4e231d/dep-lib-anstyle_query","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
42b63d26d41d3039
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":10088478398040096810,"features":"[]","declared_features":"[]","target":14855336370480542997,"profile":5347358027863023418,"path":9603432717039822245,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/arrayref-b68b407561967e78/dep-lib-arrayref","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
b582f2b72dc9e405
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":10088478398040096810,"features":"[]","declared_features":"[\"borsh\", \"default\", \"serde\", \"std\", \"zeroize\"]","target":12564975964323158710,"profile":5347358027863023418,"path":18191042356073489987,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/arrayvec-8c5966a511ebbe70/dep-lib-arrayvec","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
003fd29290c1788b
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":10088478398040096810,"features":"[\"alloc\", \"default\", \"std\"]","declared_features":"[\"alloc\", \"default\", \"std\"]","target":13060062996227388079,"profile":5347358027863023418,"path":1010556327073429201,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/base64-61edf3037de35534/dep-lib-base64","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
@@ -0,0 +1 @@
|
||||
3e0b3a45bf680364
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":10088478398040096810,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"digest\", \"mmap\", \"neon\", \"no_avx2\", \"no_avx512\", \"no_neon\", \"no_sse2\", \"no_sse41\", \"prefer_intrinsics\", \"pure\", \"rayon\", \"serde\", \"std\", \"traits-preview\", \"wasm32_simd\", \"zeroize\"]","target":2835126046236718539,"profile":3033921117576893,"path":9659682929537390633,"deps":[[6314015906312918981,"cc",false,5035238791346254153]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/blake3-c6e05ebcc674da2f/dep-build-script-build-script-build","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
87a1b0a1a45d8cdb
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":10088478398040096810,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"digest\", \"mmap\", \"neon\", \"no_avx2\", \"no_avx512\", \"no_neon\", \"no_sse2\", \"no_sse41\", \"prefer_intrinsics\", \"pure\", \"rayon\", \"serde\", \"std\", \"traits-preview\", \"wasm32_simd\", \"zeroize\"]","target":2743094924018349955,"profile":5347358027863023418,"path":15936121577563285743,"deps":[[7667230146095136825,"cfg_if",false,15210096645161364247],[9529943735784919782,"arrayref",false,4120826456055854658],[13665985940634834070,"build_script_build",false,17977234359213402738],[13847662864258534762,"arrayvec",false,424685463076504245],[14380949652265396754,"constant_time_eq",false,7993620062890213387]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/blake3-dfe6e6ef1ca6a8f7/dep-lib-blake3","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
@@ -0,0 +1 @@
|
||||
72aadcf866f77bf9
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"rustc":10088478398040096810,"features":"","declared_features":"","target":0,"profile":0,"path":0,"deps":[[13665985940634834070,"build_script_build",false,7206718999432399678]],"local":[{"RerunIfChanged":{"output":"debug/build/blake3-fba632ea867cc72e/output","paths":["c/blake3_sse41_x86-64_windows_msvc.asm","c/blake3_avx512_x86-64_windows_msvc.asm","c/blake3_sse2_x86-64_unix.S","c/blake3_sse2_x86-64_windows_msvc.asm","c/CMakeLists.txt","c/libblake3.pc.in","c/cmake","c/blake3_sse41_x86-64_unix.S","c/blake3-config.cmake.in","c/blake3.h","c/blake3_dispatch.c","c/blake3_sse41.c","c/blake3_avx512_x86-64_windows_gnu.S","c/dependencies","c/Makefile.testing","c/test.py","c/blake3_portable.c","c/blake3_tbb.cpp","c/blake3_neon.c","c/blake3_avx512.c","c/README.md","c/CMakePresets.json","c/example.c","c/blake3_avx2.c","c/main.c","c/.gitignore","c/blake3_avx2_x86-64_unix.S","c/blake3_avx2_x86-64_windows_gnu.S","c/blake3.c","c/example_tbb.c","c/blake3_sse2_x86-64_windows_gnu.S","c/blake3_impl.h","c/blake3_sse41_x86-64_windows_gnu.S","c/blake3_avx2_x86-64_windows_msvc.asm","c/blake3_sse2.c","c/blake3_avx512_x86-64_unix.S"]}},{"RerunIfEnvChanged":{"var":"CARGO_FEATURE_PURE","val":null}},{"RerunIfEnvChanged":{"var":"CARGO_FEATURE_NO_NEON","val":null}},{"RerunIfEnvChanged":{"var":"CARGO_FEATURE_NEON","val":null}},{"RerunIfEnvChanged":{"var":"CARGO_FEATURE_NEON","val":null}},{"RerunIfEnvChanged":{"var":"CARGO_FEATURE_NO_NEON","val":null}},{"RerunIfEnvChanged":{"var":"CARGO_FEATURE_PURE","val":null}},{"RerunIfEnvChanged":{"var":"CC_ENABLE_DEBUG_OUTPUT","val":null}},{"RerunIfEnvChanged":{"var":"CC","val":null}},{"RerunIfEnvChanged":{"var":"CFLAGS","val":null}}],"rustflags":[],"config":0,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
49f175bd00c3e045
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":10088478398040096810,"features":"[]","declared_features":"[\"jobserver\", \"parallel\"]","target":11042037588551934598,"profile":9003321226815314314,"path":10548251769121867896,"deps":[[8410525223747752176,"shlex",false,14904201186866019039],[9159843920629750842,"find_msvc_tools",false,1941622244890545664]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/cc-e31606cff2d84738/dep-lib-cc","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
172b34ee4f1e15d3
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":10088478398040096810,"features":"[]","declared_features":"[\"core\", \"rustc-dep-of-std\"]","target":13840298032947503755,"profile":5347358027863023418,"path":1343408598986127531,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/cfg-if-923a5f6d1c4f3de6/dep-lib-cfg_if","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
This file has an mtime of when this was started.
|
||||
@@ -0,0 +1 @@
|
||||
da2ff440eadefdbc
|
||||
@@ -0,0 +1 @@
|
||||
{"rustc":10088478398040096810,"features":"[]","declared_features":"[\"alloc\", \"blobby\", \"block-padding\", \"dev\", \"rand_core\", \"std\", \"zeroize\"]","target":9724871538835674250,"profile":5347358027863023418,"path":10703066233970685649,"deps":[[6039282458970808711,"crypto_common",false,15662686589037288465],[6580247197892008482,"inout",false,5701996800147494100]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/cipher-2c9bda998d1d3fda/dep-lib-cipher","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user