feat: engram-lang — new programming language, quantum-sealed prod target, spreading activation types

This commit is contained in:
Will Anderson
2026-04-27 18:46:51 -05:00
commit 9ced941590
5569 changed files with 8153 additions and 0 deletions
+15
View File
@@ -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 }
+161
View File
@@ -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)
}
}
+384
View File
@@ -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)));
}
}
+249
View File
@@ -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());
}
}
+29
View File
@@ -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>;
+33
View File
@@ -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;
+57
View File
@@ -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())
}
}
+11
View File
@@ -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]
+35
View File
@@ -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,
}
+544
View File
@@ -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);
}
}
+19
View File
@@ -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};
+201
View File
@@ -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>"),
}
}
}
+10
View File
@@ -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 }
+156
View File
@@ -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,
}
+54
View File
@@ -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)
}
}
+17
View File
@@ -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;
+685
View File
@@ -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));
}
}
+15
View File
@@ -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 }
+139
View File
@@ -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()))
}
}
+35
View File
@@ -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>;
+40
View File
@@ -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};
+371
View File
@@ -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(_)));
}
}
+11
View File
@@ -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 }
+572
View File
@@ -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(&param.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 (&lt, &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(&lt, &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(&lt, &rt) {
self.emit_error(TypeErrorKind::TypeMismatch {
expected: lt.to_string(),
got: rt.to_string(),
});
}
Type::Bool
}
BinOp::And | BinOp::Or => {
for ty in [&lt, &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""#);
}
}
+45
View File
@@ -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),
}
+25
View File
@@ -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;
+308
View File
@@ -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)),
));
}
}