remove _archive/rust-bootstrap

Sealed Rust genesis compiler source. Tarball preserved at
~/Archives/el-rust-bootstrap-20260430.tar.gz. Self-hosted El
compiler (dist/platform/elc) is the canonical compiler from here on.
This commit is contained in:
Will Anderson
2026-04-30 11:03:01 -05:00
parent a7f89e4776
commit e7a49ebc34
115 changed files with 0 additions and 39636 deletions
-5088
View File
File diff suppressed because it is too large Load Diff
-72
View File
@@ -1,72 +0,0 @@
[workspace]
members = [
"engrams/el-lexer",
"engrams/el-parser",
"engrams/el-types",
"engrams/el-compiler",
"engrams/el-seal",
"engrams/el-manifest",
"engrams/el-registry",
"engrams/el-build",
"engrams/el-test",
"engrams/el-stdlib",
"engrams/el-integration",
"engrams/el-fmt",
"engrams/el-lint",
"engrams/el-vm",
"bin/el",
"bin/elvm",
]
resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT"
authors = ["Neuron Technologies"]
[workspace.dependencies]
# Internal engrams (Rust substrate)
el-lexer = { path = "engrams/el-lexer" }
el-parser = { path = "engrams/el-parser" }
el-types = { path = "engrams/el-types" }
el-compiler = { path = "engrams/el-compiler" }
el-seal = { path = "engrams/el-seal" }
el-manifest = { path = "engrams/el-manifest" }
el-registry = { path = "engrams/el-registry" }
el-build = { path = "engrams/el-build" }
el-test = { path = "engrams/el-test" }
el-stdlib = { path = "engrams/el-stdlib" }
el-integration = { path = "engrams/el-integration" }
el-fmt = { path = "engrams/el-fmt" }
el-lint = { path = "engrams/el-lint" }
el-vm = { path = "engrams/el-vm" }
# Engram crypto (path dep — the sealed target depends on it)
engram-crypto = { path = "../engram/engrams/engram-crypto" }
# External
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
blake3 = "1"
aes-gcm = "0.10"
rand = "0.8"
clap = { version = "4", features = ["derive", "env"] }
reqwest = { version = "0.12", features = ["json", "blocking"] }
uuid = { version = "1", features = ["v4"] }
walkdir = "2"
tiny_http = "0.12"
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
base64 = "0.22"
winit = { version = "0.29", default-features = false, features = ["rwh_05"] }
softbuffer = "0.3"
tiny-skia = "0.11"
fontdue = "0.8"
image = { version = "0.25", default-features = false, features = ["png"] }
tungstenite = { version = "0.23", features = ["native-tls"] }
native-tls = "0.2"
crossbeam-channel = "0.5"
ed25519-dalek = { version = "2", features = ["rand_core"] }
-48
View File
@@ -1,48 +0,0 @@
[package]
name = "el"
description = "Engram language CLI — el build / run / check / seal / unseal / new / add / publish / …"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "el"
path = "src/main.rs"
[dependencies]
el-lexer = { workspace = true }
el-parser = { workspace = true }
el-types = { workspace = true }
el-compiler = { workspace = true }
el-seal = { workspace = true }
el-manifest = { workspace = true }
el-registry = { workspace = true }
el-build = { workspace = true }
el-test = { workspace = true }
el-fmt = { workspace = true }
el-lint = { workspace = true }
clap = { workspace = true }
thiserror = { workspace = true }
serde_json = { workspace = true }
serde = { workspace = true }
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
reqwest = { workspace = true }
uuid = { workspace = true }
walkdir = { workspace = true }
tiny_http = { workspace = true }
blake3 = { workspace = true }
hmac = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
base64 = { workspace = true }
aes-gcm = { workspace = true }
rand = { workspace = true }
ed25519-dalek = { workspace = true }
crossbeam-channel = { workspace = true }
winit = { version = "0.29", default-features = false, features = ["rwh_05"] }
softbuffer = "0.3"
tiny-skia = "0.11"
fontdue = "0.8"
image = { workspace = true }
tungstenite = { workspace = true }
native-tls = { workspace = true }
-130
View File
@@ -1,130 +0,0 @@
//! In-memory LRU cache for HTTP responses and LLM outputs.
//!
//! Thread-safe global cache keyed by arbitrary strings. Entries expire after a
//! configurable TTL. Eviction uses a simple LRU strategy: when the entry limit
//! is reached the oldest entry (by insertion / last-access order) is dropped.
//!
//! This module only contains the cache data-structure and its helper
//! functions. The builtin dispatch arms that expose `cache_get`, `cache_set`,
//! `cache_invalidate`, and `cache_clear` live in `main.rs`.
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
const MAX_ENTRIES: usize = 1000;
/// A single cached value together with its expiry time.
struct Entry {
value: String,
expires_at: Instant,
/// Monotonically increasing sequence number used to identify the LRU entry.
seq: u64,
}
struct Cache {
entries: HashMap<String, Entry>,
/// Counter incremented on every write; stored in Entry::seq.
seq: u64,
}
impl Cache {
fn new() -> Self {
Cache {
entries: HashMap::new(),
seq: 0,
}
}
/// Store `value` under `key`, expiring after `ttl_secs` seconds.
/// If the cache is full the LRU entry is evicted before insertion.
pub fn set(&mut self, key: String, value: String, ttl_secs: u64) {
// Evict expired entries first (cheap pass).
let now = Instant::now();
self.entries.retain(|_, e| e.expires_at > now);
// If still full, evict the entry with the lowest sequence number (LRU).
if self.entries.len() >= MAX_ENTRIES {
if let Some(lru_key) = self
.entries
.iter()
.min_by_key(|(_, e)| e.seq)
.map(|(k, _)| k.clone())
{
self.entries.remove(&lru_key);
}
}
self.seq += 1;
self.entries.insert(
key,
Entry {
value,
expires_at: now + Duration::from_secs(ttl_secs),
seq: self.seq,
},
);
}
/// Return the cached value if it exists and has not expired, otherwise `None`.
pub fn get(&mut self, key: &str) -> Option<String> {
let now = Instant::now();
if let Some(e) = self.entries.get_mut(key) {
if e.expires_at > now {
// Bump sequence number to record this access (LRU).
self.seq += 1;
e.seq = self.seq;
return Some(e.value.clone());
}
// Expired — remove it.
self.entries.remove(key);
}
None
}
/// Remove a specific key.
pub fn invalidate(&mut self, key: &str) {
self.entries.remove(key);
}
/// Remove all entries.
pub fn clear(&mut self) {
self.entries.clear();
}
}
// ── Global singleton ──────────────────────────────────────────────────────────
static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
fn global() -> &'static Mutex<Cache> {
CACHE.get_or_init(|| Mutex::new(Cache::new()))
}
// ── Public API ────────────────────────────────────────────────────────────────
/// Store an arbitrary string value under `key` with a TTL in seconds.
pub fn cache_set(key: String, value: String, ttl_secs: u64) {
if let Ok(mut c) = global().lock() {
c.set(key, value, ttl_secs);
}
}
/// Return the cached value for `key`, or `None` if missing / expired.
pub fn cache_get(key: &str) -> Option<String> {
global().lock().ok()?.get(key)
}
/// Remove a specific key from the cache.
pub fn cache_invalidate(key: &str) {
if let Ok(mut c) = global().lock() {
c.invalidate(key);
}
}
/// Remove all entries from the cache.
pub fn cache_clear() {
if let Ok(mut c) = global().lock() {
c.clear();
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-730
View File
@@ -1,730 +0,0 @@
//! Networking enhancements: HTTP retry/backoff, circuit breaker, and WebSocket server.
//!
//! Everything here is synchronous / blocking to match the rest of the El runtime
//! (which uses `reqwest::blocking` throughout). WebSocket server connections run
//! on background OS threads; the interpreter thread itself never blocks waiting
//! for the server.
use std::collections::HashMap;
use std::net::TcpListener;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
// ─────────────────────────────────────────────────────────────────────────────
// HTTP retry with exponential back-off
// ─────────────────────────────────────────────────────────────────────────────
/// Perform an HTTP GET, retrying on 5xx responses or connection errors.
///
/// `max_attempts` — total number of attempts (1 = no retry).
/// `backoff_ms` — initial back-off in milliseconds; doubles each attempt.
///
/// Returns the response body on the first successful (non-5xx) response, or an
/// error JSON string after all attempts are exhausted.
pub fn http_get_retry(url: &str, max_attempts: u32, backoff_ms: u64) -> String {
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.unwrap_or_default();
let mut delay = backoff_ms;
for attempt in 0..max_attempts.max(1) {
match client.get(url).send() {
Ok(resp) => {
let status = resp.status().as_u16();
let body = resp.text().unwrap_or_default();
if status < 500 {
return body;
}
// 5xx — retry unless this was the last attempt
if attempt + 1 < max_attempts {
std::thread::sleep(Duration::from_millis(delay));
delay *= 2;
} else {
return format!(
"{{\"error\":\"http_get_retry: server error {status} after {max_attempts} attempt(s)\",\"body\":{body:?}}}"
);
}
}
Err(e) => {
if attempt + 1 < max_attempts {
std::thread::sleep(Duration::from_millis(delay));
delay *= 2;
} else {
return format!(
"{{\"error\":\"http_get_retry: {e} after {max_attempts} attempt(s)\"}}"
);
}
}
}
}
format!("{{\"error\":\"http_get_retry: no attempts executed\"}}")
}
/// Perform an HTTP POST with JSON body, retrying on 5xx or connection errors.
///
/// Same retry semantics as [`http_get_retry`].
pub fn http_post_retry(url: &str, body: &str, max_attempts: u32, backoff_ms: u64) -> String {
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.unwrap_or_default();
let mut delay = backoff_ms;
for attempt in 0..max_attempts.max(1) {
match client
.post(url)
.header("Content-Type", "application/json")
.body(body.to_owned())
.send()
{
Ok(resp) => {
let status = resp.status().as_u16();
let resp_body = resp.text().unwrap_or_default();
if status < 500 {
return resp_body;
}
if attempt + 1 < max_attempts {
std::thread::sleep(Duration::from_millis(delay));
delay *= 2;
} else {
return format!(
"{{\"error\":\"http_post_retry: server error {status} after {max_attempts} attempt(s)\",\"body\":{resp_body:?}}}"
);
}
}
Err(e) => {
if attempt + 1 < max_attempts {
std::thread::sleep(Duration::from_millis(delay));
delay *= 2;
} else {
return format!(
"{{\"error\":\"http_post_retry: {e} after {max_attempts} attempt(s)\"}}"
);
}
}
}
}
format!("{{\"error\":\"http_post_retry: no attempts executed\"}}")
}
// ─────────────────────────────────────────────────────────────────────────────
// Circuit breaker
// ─────────────────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, PartialEq)]
enum CircuitState {
/// Passing requests through normally.
Closed,
/// Too many failures — reject immediately.
Open {
/// When the circuit may attempt to close again.
until: Instant,
},
/// One probe request allowed through; waiting to see if it succeeds.
HalfOpen,
}
struct CircuitBreaker {
state: CircuitState,
failure_count: u32,
failure_threshold: u32,
reset_duration: Duration,
}
impl CircuitBreaker {
fn new(failure_threshold: u32, reset_secs: u64) -> Self {
CircuitBreaker {
state: CircuitState::Closed,
failure_count: 0,
failure_threshold,
reset_duration: Duration::from_secs(reset_secs),
}
}
/// Returns `true` if the circuit should allow a call through right now.
fn allow(&mut self) -> bool {
match &self.state {
CircuitState::Closed => true,
CircuitState::Open { until } => {
if Instant::now() >= *until {
self.state = CircuitState::HalfOpen;
true
} else {
false
}
}
CircuitState::HalfOpen => true,
}
}
fn record_success(&mut self) {
self.failure_count = 0;
self.state = CircuitState::Closed;
}
fn record_failure(&mut self) {
self.failure_count += 1;
if self.failure_count >= self.failure_threshold
|| self.state == CircuitState::HalfOpen
{
self.state = CircuitState::Open {
until: Instant::now() + self.reset_duration,
};
self.failure_count = 0;
}
}
}
// ── Global circuit-breaker registry ──────────────────────────────────────────
static CIRCUITS: OnceLock<Mutex<HashMap<String, CircuitBreaker>>> = OnceLock::new();
fn circuits() -> &'static Mutex<HashMap<String, CircuitBreaker>> {
CIRCUITS.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Register (or replace) a circuit breaker with the given name.
pub fn circuit_open(name: String, failure_threshold: u32, reset_secs: u64) {
if let Ok(mut map) = circuits().lock() {
map.insert(name, CircuitBreaker::new(failure_threshold, reset_secs));
}
}
/// Make an HTTP POST call through the named circuit breaker.
///
/// * If the circuit is open: returns `{"error":"circuit open"}` immediately.
/// * If closed/half-open: makes the POST, records success or failure, and
/// returns the response body.
pub fn circuit_call(name: &str, url: &str, body: &str) -> String {
// Check + allow atomically under the lock, then drop the lock before the
// blocking network call (which could take seconds).
let allowed = circuits()
.lock()
.ok()
.and_then(|mut map| map.get_mut(name).map(|cb| cb.allow()))
.unwrap_or(true); // unknown circuit name → allow
if !allowed {
return r#"{"error":"circuit open"}"#.to_owned();
}
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.unwrap_or_default();
match client
.post(url)
.header("Content-Type", "application/json")
.body(body.to_owned())
.send()
{
Ok(resp) => {
let status = resp.status().as_u16();
let resp_body = resp.text().unwrap_or_default();
if status >= 500 {
if let Ok(mut map) = circuits().lock() {
if let Some(cb) = map.get_mut(name) {
cb.record_failure();
}
}
format!("{{\"error\":\"circuit_call: server error {status}\",\"body\":{resp_body:?}}}")
} else {
if let Ok(mut map) = circuits().lock() {
if let Some(cb) = map.get_mut(name) {
cb.record_success();
}
}
resp_body
}
}
Err(e) => {
if let Ok(mut map) = circuits().lock() {
if let Some(cb) = map.get_mut(name) {
cb.record_failure();
}
}
format!("{{\"error\":\"circuit_call: {e}\"}}")
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// WebSocket server
// ─────────────────────────────────────────────────────────────────────────────
//
// Implementation strategy
// ───────────────────────
// The El interpreter is single-threaded and synchronous. We need a WebSocket
// *server* that can accept multiple concurrent clients while the interpreter
// keeps running.
//
// Solution: each accepted connection runs on its own OS thread. Outbound
// messages are queued through a per-client `mpsc` channel. The interpreter
// calls `ws_serve`, `ws_send`, `ws_broadcast`, and `ws_close` — all of which
// return immediately and coordinate with the background threads via shared
// state.
//
// Handler callbacks are *not* invoked on the background threads; instead,
// incoming messages are placed in a global queue and the interpreter drains
// them by calling `ws_poll` (or they are dispatched automatically inside a
// future tight-loop variant of `ws_serve`).
//
// For simplicity this implementation uses `tungstenite` (synchronous), the
// same crate the existing `ws_connect` client already uses.
use std::sync::mpsc;
/// Message queued from a background connection thread to the interpreter.
#[derive(Debug)]
pub struct IncomingWsMessage {
pub client_id: String,
pub message: String,
}
/// Represents one connected WebSocket client.
struct WsClient {
/// Channel sender used to push outbound messages to the background thread.
tx: mpsc::Sender<Option<String>>, // None = disconnect signal
}
/// Shared server state accessible from both the interpreter thread and the
/// background connection threads.
pub struct WsServerState {
/// Connected clients, keyed by client_id.
clients: HashMap<String, WsClient>,
/// Messages received from clients waiting to be delivered to the handler.
inbox: Vec<IncomingWsMessage>,
/// Counter for generating unique client IDs.
next_id: u64,
}
impl WsServerState {
fn new() -> Self {
WsServerState {
clients: HashMap::new(),
inbox: Vec::new(),
next_id: 1,
}
}
fn next_client_id(&mut self) -> String {
let id = format!("wsc:{}", self.next_id);
self.next_id += 1;
id
}
}
// ── Global server state registry (one entry per listening port) ───────────────
static WS_SERVERS: OnceLock<Mutex<HashMap<u16, Arc<Mutex<WsServerState>>>>> = OnceLock::new();
fn ws_servers() -> &'static Mutex<HashMap<u16, Arc<Mutex<WsServerState>>>> {
WS_SERVERS.get_or_init(|| Mutex::new(HashMap::new()))
}
fn get_or_create_server(port: u16) -> Arc<Mutex<WsServerState>> {
let mut map = ws_servers().lock().unwrap();
map.entry(port)
.or_insert_with(|| Arc::new(Mutex::new(WsServerState::new())))
.clone()
}
// ── Public API (called from dispatch_builtin) ─────────────────────────────────
/// Start a WebSocket server on `port`.
///
/// This function spawns a background acceptor thread and returns immediately.
/// Incoming connections are handled on per-connection threads. Messages
/// received are pushed into the server's inbox; call [`ws_poll`] to drain them.
pub fn ws_serve_start(port: u16) {
let state = get_or_create_server(port);
// Spawn acceptor thread.
let state_clone = state.clone();
std::thread::spawn(move || {
let listener = match TcpListener::bind(format!("0.0.0.0:{port}")) {
Ok(l) => l,
Err(e) => {
eprintln!("[ws_serve] failed to bind port {port}: {e}");
return;
}
};
eprintln!("[ws_serve] listening on port {port}");
for stream in listener.incoming() {
match stream {
Ok(tcp) => {
let state_for_conn = state_clone.clone();
std::thread::spawn(move || {
handle_ws_connection(tcp, state_for_conn);
});
}
Err(e) => {
eprintln!("[ws_serve] accept error: {e}");
}
}
}
});
}
fn handle_ws_connection(stream: std::net::TcpStream, state: Arc<Mutex<WsServerState>>) {
let _ = stream.set_read_timeout(Some(Duration::from_millis(100)));
let ws_result = tungstenite::accept(stream);
let mut ws = match ws_result {
Ok(w) => w,
Err(e) => {
eprintln!("[ws_serve] WebSocket handshake error: {e}");
return;
}
};
// Assign a client ID.
let client_id = {
let mut s = state.lock().unwrap();
let id = s.next_client_id();
// We'll register the sender after we create the channel below.
id
};
// Create an outbound channel for this connection.
let (tx, rx) = mpsc::channel::<Option<String>>();
{
let mut s = state.lock().unwrap();
s.clients.insert(client_id.clone(), WsClient { tx });
}
eprintln!("[ws_serve] client connected: {client_id}");
loop {
// --- Receive incoming messages (non-blocking with short timeout) ---
match ws.read() {
Ok(tungstenite::Message::Text(text)) => {
let mut s = state.lock().unwrap();
s.inbox.push(IncomingWsMessage {
client_id: client_id.clone(),
message: text.to_string(),
});
}
Ok(tungstenite::Message::Binary(bytes)) => {
let text = String::from_utf8_lossy(&bytes).into_owned();
let mut s = state.lock().unwrap();
s.inbox.push(IncomingWsMessage {
client_id: client_id.clone(),
message: text,
});
}
Ok(tungstenite::Message::Close(_)) => {
break;
}
Ok(tungstenite::Message::Ping(data)) => {
let _ = ws.send(tungstenite::Message::Pong(data));
}
Ok(_) => {}
Err(tungstenite::Error::Io(e))
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::TimedOut =>
{
// No data yet — check the outbound channel.
}
Err(e) => {
eprintln!("[ws_serve] read error for {client_id}: {e}");
break;
}
}
// --- Send queued outbound messages ---
loop {
match rx.try_recv() {
Ok(Some(msg)) => {
if ws
.send(tungstenite::Message::Text(msg.into()))
.is_err()
{
break;
}
}
Ok(None) => {
// Disconnect signal.
let _ = ws.close(None);
let mut s = state.lock().unwrap();
s.clients.remove(&client_id);
eprintln!("[ws_serve] client disconnected (server-side): {client_id}");
return;
}
Err(mpsc::TryRecvError::Empty) => break,
Err(mpsc::TryRecvError::Disconnected) => {
break;
}
}
}
}
// Clean up.
{
let mut s = state.lock().unwrap();
s.clients.remove(&client_id);
}
eprintln!("[ws_serve] client disconnected: {client_id}");
}
/// Send a message to a specific connected client.
/// Returns `false` if the client is not found.
pub fn ws_server_send(port: u16, client_id: &str, message: String) -> bool {
let map = ws_servers().lock().unwrap();
if let Some(state) = map.get(&port) {
let s = state.lock().unwrap();
if let Some(client) = s.clients.get(client_id) {
return client.tx.send(Some(message)).is_ok();
}
}
false
}
/// Broadcast a message to all connected clients on a port.
pub fn ws_server_broadcast(port: u16, message: String) {
let map = ws_servers().lock().unwrap();
if let Some(state) = map.get(&port) {
let s = state.lock().unwrap();
for client in s.clients.values() {
let _ = client.tx.send(Some(message.clone()));
}
}
}
/// Disconnect a specific client.
pub fn ws_server_close(port: u16, client_id: &str) {
let map = ws_servers().lock().unwrap();
if let Some(state) = map.get(&port) {
let s = state.lock().unwrap();
if let Some(client) = s.clients.get(client_id) {
let _ = client.tx.send(None);
}
}
}
/// Drain pending incoming messages for a given server port.
///
/// Returns up to `max` messages (or all if `max == 0`). The caller is
/// responsible for invoking the El handler function for each message.
pub fn ws_server_poll(port: u16, max: usize) -> Vec<IncomingWsMessage> {
let map = ws_servers().lock().unwrap();
if let Some(state) = map.get(&port) {
let mut s = state.lock().unwrap();
if max == 0 || s.inbox.len() <= max {
let msgs = std::mem::take(&mut s.inbox);
msgs
} else {
s.inbox.drain(..max).collect()
}
} else {
vec![]
}
}
// ─────────────────────────────────────────────────────────────────────────────
// WebSocket client with handler callback support
// ─────────────────────────────────────────────────────────────────────────────
//
// `ws_connect` already exists in `main.rs` using synchronous tungstenite.
// This new variant (`ws_connect_handler`) runs the connection on a background
// thread and queues incoming messages so `ws_client_poll` can deliver them to
// the El handler.
/// Pending message from a background WS client connection.
#[derive(Debug)]
pub struct IncomingClientMessage {
pub conn_id: String,
pub message: String,
}
struct WsClientConn {
tx: mpsc::Sender<Option<String>>,
}
static WS_CLIENT_CONNS: OnceLock<Mutex<HashMap<String, WsClientConn>>> = OnceLock::new();
static WS_CLIENT_INBOX: OnceLock<Mutex<Vec<IncomingClientMessage>>> = OnceLock::new();
static WS_CLIENT_COUNTER: OnceLock<Mutex<u64>> = OnceLock::new();
fn ws_client_conns() -> &'static Mutex<HashMap<String, WsClientConn>> {
WS_CLIENT_CONNS.get_or_init(|| Mutex::new(HashMap::new()))
}
fn ws_client_inbox() -> &'static Mutex<Vec<IncomingClientMessage>> {
WS_CLIENT_INBOX.get_or_init(|| Mutex::new(Vec::new()))
}
fn next_conn_id() -> String {
let mut ctr = WS_CLIENT_COUNTER
.get_or_init(|| Mutex::new(1))
.lock()
.unwrap();
let id = format!("wsconn:{}", *ctr);
*ctr += 1;
id
}
/// Connect to a WebSocket server in the background.
///
/// Returns a `conn_id` string immediately. Incoming messages are queued and
/// can be retrieved with [`ws_client_poll`].
pub fn ws_client_connect(url: &str) -> String {
let conn_id = next_conn_id();
let (tx, rx) = mpsc::channel::<Option<String>>();
{
let mut map = ws_client_conns().lock().unwrap();
map.insert(conn_id.clone(), WsClientConn { tx });
}
let url_owned = url.to_owned();
let conn_id_clone = conn_id.clone();
std::thread::spawn(move || {
let ws_result = tungstenite::connect(&url_owned);
let (mut ws, _) = match ws_result {
Ok(pair) => pair,
Err(e) => {
eprintln!("[ws_client] connect error for {conn_id_clone}: {e}");
let mut map = ws_client_conns().lock().unwrap();
map.remove(&conn_id_clone);
return;
}
};
// Set a short read timeout so the loop stays responsive to outbound messages.
// MaybeTlsStream exposes the underlying stream via get_ref/get_mut.
{
use tungstenite::stream::MaybeTlsStream;
match ws.get_mut() {
MaybeTlsStream::Plain(tcp) => {
let _ = tcp.set_read_timeout(Some(Duration::from_millis(50)));
}
#[cfg(feature = "native-tls")]
MaybeTlsStream::NativeTls(tls) => {
let _ = tls.get_ref().set_read_timeout(Some(Duration::from_millis(50)));
}
_ => {}
}
}
loop {
// Receive from server.
match ws.read() {
Ok(tungstenite::Message::Text(text)) => {
let mut inbox = ws_client_inbox().lock().unwrap();
inbox.push(IncomingClientMessage {
conn_id: conn_id_clone.clone(),
message: text.to_string(),
});
}
Ok(tungstenite::Message::Binary(bytes)) => {
let text = String::from_utf8_lossy(&bytes).into_owned();
let mut inbox = ws_client_inbox().lock().unwrap();
inbox.push(IncomingClientMessage {
conn_id: conn_id_clone.clone(),
message: text,
});
}
Ok(tungstenite::Message::Close(_)) => break,
Ok(tungstenite::Message::Ping(data)) => {
let _ = ws.send(tungstenite::Message::Pong(data));
}
Ok(_) => {}
Err(tungstenite::Error::Io(e))
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::TimedOut =>
{
// No data yet.
}
Err(e) => {
eprintln!("[ws_client] read error for {conn_id_clone}: {e}");
break;
}
}
// Send queued outbound messages.
loop {
match rx.try_recv() {
Ok(Some(msg)) => {
if ws.send(tungstenite::Message::Text(msg.into())).is_err() {
break;
}
}
Ok(None) => {
let _ = ws.close(None);
let mut map = ws_client_conns().lock().unwrap();
map.remove(&conn_id_clone);
return;
}
Err(mpsc::TryRecvError::Empty) => break,
Err(mpsc::TryRecvError::Disconnected) => break,
}
}
}
let mut map = ws_client_conns().lock().unwrap();
map.remove(&conn_id_clone);
});
conn_id
}
/// Send a message on an existing client connection.
pub fn ws_client_send(conn_id: &str, message: String) {
let map = ws_client_conns().lock().unwrap();
if let Some(conn) = map.get(conn_id) {
let _ = conn.tx.send(Some(message));
}
}
/// Close a client connection.
pub fn ws_client_close(conn_id: &str) {
let map = ws_client_conns().lock().unwrap();
if let Some(conn) = map.get(conn_id) {
let _ = conn.tx.send(None);
}
}
/// Drain pending incoming messages for client connections.
///
/// Returns all queued messages (all connections combined). Pass `conn_id` as
/// `Some(&str)` to filter to a specific connection, or `None` for all.
pub fn ws_client_poll(conn_id_filter: Option<&str>) -> Vec<IncomingClientMessage> {
let mut inbox = ws_client_inbox().lock().unwrap();
if let Some(filter) = conn_id_filter {
let (matching, rest): (Vec<_>, Vec<_>) =
std::mem::take(&mut *inbox)
.into_iter()
.partition(|m| m.conn_id == filter);
*inbox = rest;
matching
} else {
std::mem::take(&mut *inbox)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Transient retry wrapper (for enhancing existing http_get / http_post)
// ─────────────────────────────────────────────────────────────────────────────
/// Try `f` once; on connection error, wait `delay_ms` and try once more.
///
/// This is the "automatic single retry on transient network error" behaviour
/// layered over the existing `http_get` / `http_post` builtins.
pub fn with_single_retry<F>(delay_ms: u64, f: F) -> String
where
F: Fn() -> Result<String, reqwest::Error>,
{
match f() {
Ok(body) => body,
Err(e) if e.is_connect() || e.is_timeout() => {
std::thread::sleep(Duration::from_millis(delay_ms));
f().unwrap_or_else(|e2| format!("{{\"error\":\"{e2}\"}}"))
}
Err(e) => format!("{{\"error\":\"{e}\"}}"),
}
}
@@ -1,641 +0,0 @@
//! Automatic, zero-config observability for the El runtime.
//!
//! This module implements:
//! - A background OTLP/HTTP exporter (spans + logs + metrics)
//! - Thread-local span context for propagation
//! - Helper functions called by the interpreter's builtin dispatch
//!
//! Developers never need to call anything here directly. The interpreter
//! instruments everything automatically. Optional `log_*`, `trace_*`, and
//! `metric_*` builtins are also wired through this module for explicit use.
//!
//! ## Graceful degradation
//!
//! If the OTLP endpoint is unreachable, a single warning is emitted to stderr
//! on the first failure, then telemetry is silently dropped. Programs never
//! fail because observability is down.
use std::sync::{
OnceLock,
atomic::{AtomicBool, Ordering},
};
use std::sync::mpsc::{self, SyncSender};
use std::time::{SystemTime, UNIX_EPOCH};
use std::collections::HashMap;
// ── Public re-export ──────────────────────────────────────────────────────────
pub use context::SpanGuard;
// ── Constants ─────────────────────────────────────────────────────────────────
const DEFAULT_OTLP_ENDPOINT: &str = "http://alloy.neuralplatform.ai:4318";
const BATCH_SIZE: usize = 64;
const BATCH_TIMEOUT_MS: u64 = 5_000;
// ── Telemetry payload types ───────────────────────────────────────────────────
#[derive(Clone, Debug)]
pub struct Span {
pub trace_id: String,
pub span_id: String,
pub parent_id: Option<String>,
pub name: String,
pub start_ns: u64,
pub end_ns: u64,
pub status: SpanStatus,
pub attrs: Vec<(String, AttrValue)>,
pub events: Vec<SpanEvent>,
pub service: String,
}
#[derive(Clone, Debug, PartialEq)]
pub enum SpanStatus {
Ok,
Error(String),
}
#[derive(Clone, Debug)]
pub struct SpanEvent {
pub name: String,
pub time_ns: u64,
pub attrs: Vec<(String, AttrValue)>,
}
#[derive(Clone, Debug)]
pub enum AttrValue {
Str(String),
Int(i64),
Float(f64),
Bool(bool),
}
#[derive(Clone, Debug)]
pub struct LogRecord {
pub time_ns: u64,
pub severity: LogSeverity,
pub body: String,
pub attrs: Vec<(String, AttrValue)>,
pub service: String,
pub trace_id: Option<String>,
pub span_id: Option<String>,
}
#[derive(Clone, Debug, Copy)]
pub enum LogSeverity {
Debug,
Info,
Warn,
Error,
}
impl LogSeverity {
fn number(self) -> u32 {
match self {
LogSeverity::Debug => 5,
LogSeverity::Info => 9,
LogSeverity::Warn => 13,
LogSeverity::Error => 17,
}
}
fn text(self) -> &'static str {
match self {
LogSeverity::Debug => "DEBUG",
LogSeverity::Info => "INFO",
LogSeverity::Warn => "WARN",
LogSeverity::Error => "ERROR",
}
}
}
#[derive(Clone, Debug)]
pub struct Metric {
pub name: String,
pub kind: MetricKind,
pub value: f64,
pub attrs: Vec<(String, AttrValue)>,
pub time_ns: u64,
pub service: String,
}
#[derive(Clone, Debug)]
pub enum MetricKind {
Counter,
Gauge,
}
#[derive(Clone, Debug)]
enum TelemetryItem {
Span(Span),
Log(LogRecord),
Metric(Metric),
}
// ── Global telemetry sender ───────────────────────────────────────────────────
static TELEMETRY_TX: OnceLock<Option<SyncSender<TelemetryItem>>> = OnceLock::new();
static OTLP_WARNED: AtomicBool = AtomicBool::new(false);
/// Initialise the telemetry background thread. Called once on interpreter start.
/// `service_name` is the El program filename (without extension).
pub fn init(service_name: &str) {
let endpoint = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT")
.unwrap_or_else(|_| DEFAULT_OTLP_ENDPOINT.to_string());
let svc = service_name.to_string();
// Bounded channel — if the exporter falls behind, new items are dropped.
let (tx, rx) = mpsc::sync_channel::<TelemetryItem>(4096);
// Store the sender before starting the thread so callers can send immediately.
TELEMETRY_TX.get_or_init(|| Some(tx));
std::thread::Builder::new()
.name("el-telemetry".to_string())
.spawn(move || {
exporter_loop(rx, &endpoint, &svc);
})
.ok(); // If the thread fails to spawn, we degrade silently.
}
/// Send one telemetry item. Never panics. Drops if channel is full or uninitialised.
fn send(item: TelemetryItem) {
if let Some(Some(tx)) = TELEMETRY_TX.get() {
let _ = tx.try_send(item);
}
}
// ── Span builder / emitter ────────────────────────────────────────────────────
pub fn emit_span(span: Span) {
send(TelemetryItem::Span(span));
}
pub fn emit_log(record: LogRecord) {
send(TelemetryItem::Log(record));
}
pub fn emit_metric(metric: Metric) {
send(TelemetryItem::Metric(metric));
}
// ── Thread-local context ──────────────────────────────────────────────────────
pub mod context {
use super::*;
thread_local! {
/// Stack of active span IDs for the current thread.
static SPAN_STACK: std::cell::RefCell<Vec<(String, String)>> =
std::cell::RefCell::new(Vec::new());
}
/// Get the current (innermost) span context: (trace_id, span_id).
pub fn current_span() -> Option<(String, String)> {
SPAN_STACK.with(|s| s.borrow().last().cloned())
}
/// Push a span context onto the thread-local stack.
pub fn push_span(trace_id: String, span_id: String) {
SPAN_STACK.with(|s| s.borrow_mut().push((trace_id, span_id)));
}
/// Pop the innermost span context.
pub fn pop_span() {
SPAN_STACK.with(|s| { s.borrow_mut().pop(); });
}
/// A RAII guard that closes the span when it goes out of scope.
pub struct SpanGuard {
pub span: Span,
}
impl SpanGuard {
pub fn new(name: &str, service: &str) -> Self {
let (trace_id, parent_id) = current_span()
.map(|(t, s)| (t, Some(s)))
.unwrap_or_else(|| (new_trace_id(), None));
let span_id = new_span_id();
push_span(trace_id.clone(), span_id.clone());
SpanGuard {
span: Span {
trace_id,
span_id,
parent_id,
name: name.to_string(),
start_ns: now_ns(),
end_ns: 0,
status: SpanStatus::Ok,
attrs: Vec::new(),
events: Vec::new(),
service: service.to_string(),
},
}
}
pub fn attr(mut self, k: &str, v: AttrValue) -> Self {
self.span.attrs.push((k.to_string(), v));
self
}
pub fn error(mut self, msg: &str) -> Self {
self.span.status = SpanStatus::Error(msg.to_string());
self.span.events.push(SpanEvent {
name: "exception".to_string(),
time_ns: now_ns(),
attrs: vec![("exception.message".to_string(), AttrValue::Str(msg.to_string()))],
});
self
}
/// Finish the span without dropping the guard (for manual control).
pub fn finish(mut self) -> Span {
pop_span();
self.span.end_ns = now_ns();
self.span.clone()
}
}
impl Drop for SpanGuard {
fn drop(&mut self) {
// Only pop if not already finished manually.
// We detect this by checking if end_ns is still 0.
if self.span.end_ns == 0 {
pop_span();
self.span.end_ns = now_ns();
emit_span(self.span.clone());
}
}
}
}
// ── ID generation ─────────────────────────────────────────────────────────────
pub fn new_trace_id() -> String {
let id = uuid::Uuid::new_v4();
hex::encode(id.as_bytes())
}
pub fn new_span_id() -> String {
let id = uuid::Uuid::new_v4();
// Span ID is 8 bytes
hex::encode(&id.as_bytes()[..8])
}
// ── Timing ────────────────────────────────────────────────────────────────────
pub fn now_ns() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
}
pub fn now_ms() -> u64 {
now_ns() / 1_000_000
}
// ── Service name global ───────────────────────────────────────────────────────
static SERVICE_NAME: OnceLock<String> = OnceLock::new();
pub fn service_name() -> &'static str {
SERVICE_NAME.get().map(|s| s.as_str()).unwrap_or("el-program")
}
pub fn set_service_name(name: &str) {
let _ = SERVICE_NAME.set(name.to_string());
}
// ── High-level tracing helpers ────────────────────────────────────────────────
/// Instrument a function call. Returns a SpanGuard; emit it when done.
pub fn start_fn_span(fn_name: &str) -> context::SpanGuard {
context::SpanGuard::new(fn_name, service_name())
}
/// Emit a log at the given severity.
pub fn log(severity: LogSeverity, body: &str) {
let (trace_id, span_id) = context::current_span()
.map(|(t, s)| (Some(t), Some(s)))
.unwrap_or((None, None));
emit_log(LogRecord {
time_ns: now_ns(),
severity,
body: body.to_string(),
attrs: Vec::new(),
service: service_name().to_string(),
trace_id,
span_id,
});
}
pub fn log_debug(msg: &str) { log(LogSeverity::Debug, msg); }
pub fn log_info(msg: &str) { log(LogSeverity::Info, msg); }
pub fn log_warn(msg: &str) { log(LogSeverity::Warn, msg); }
pub fn log_error(msg: &str) { log(LogSeverity::Error, msg); }
/// Convenience: emit a metric counter.
pub fn counter(name: &str, value: f64, attrs: Vec<(String, AttrValue)>) {
emit_metric(Metric {
name: name.to_string(),
kind: MetricKind::Counter,
value,
attrs,
time_ns: now_ns(),
service: service_name().to_string(),
});
}
/// Convenience: emit a metric gauge.
pub fn gauge(name: &str, value: f64, attrs: Vec<(String, AttrValue)>) {
emit_metric(Metric {
name: name.to_string(),
kind: MetricKind::Gauge,
value,
attrs,
time_ns: now_ns(),
service: service_name().to_string(),
});
}
// ── OTLP/HTTP JSON exporter ───────────────────────────────────────────────────
// Uses the OTLP/HTTP JSON format (not protobuf) which Alloy accepts.
fn exporter_loop(
rx: mpsc::Receiver<TelemetryItem>,
endpoint: &str,
service: &str,
) {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| reqwest::blocking::Client::new());
let spans_url = format!("{}/v1/traces", endpoint);
let logs_url = format!("{}/v1/logs", endpoint);
let metrics_url = format!("{}/v1/metrics", endpoint);
let mut spans: Vec<Span> = Vec::with_capacity(BATCH_SIZE);
let mut logs: Vec<LogRecord> = Vec::with_capacity(BATCH_SIZE);
let mut metrics: Vec<Metric> = Vec::with_capacity(BATCH_SIZE);
let timeout = std::time::Duration::from_millis(BATCH_TIMEOUT_MS);
loop {
// Try to receive one item with a timeout, then drain available items.
match rx.recv_timeout(timeout) {
Ok(item) => {
enqueue_item(item, &mut spans, &mut logs, &mut metrics);
// Drain any immediately available items.
while let Ok(item) = rx.try_recv() {
enqueue_item(item, &mut spans, &mut logs, &mut metrics);
if spans.len() + logs.len() + metrics.len() >= BATCH_SIZE {
break;
}
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {
// Flush whatever we have.
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
// Channel closed — flush and exit.
flush(&client, &spans_url, &logs_url, &metrics_url,
service, &spans, &logs, &metrics);
break;
}
}
if !spans.is_empty() || !logs.is_empty() || !metrics.is_empty() {
flush(&client, &spans_url, &logs_url, &metrics_url,
service, &spans, &logs, &metrics);
spans.clear();
logs.clear();
metrics.clear();
}
}
}
fn enqueue_item(
item: TelemetryItem,
spans: &mut Vec<Span>,
logs: &mut Vec<LogRecord>,
metrics: &mut Vec<Metric>,
) {
match item {
TelemetryItem::Span(s) => spans.push(s),
TelemetryItem::Log(l) => logs.push(l),
TelemetryItem::Metric(m) => metrics.push(m),
}
}
fn flush(
client: &reqwest::blocking::Client,
spans_url: &str,
logs_url: &str,
metrics_url: &str,
service: &str,
spans: &[Span],
logs: &[LogRecord],
metrics: &[Metric],
) {
if !spans.is_empty() {
let body = build_traces_json(service, spans);
post_otlp(client, spans_url, &body);
}
if !logs.is_empty() {
let body = build_logs_json(service, logs);
post_otlp(client, logs_url, &body);
}
if !metrics.is_empty() {
let body = build_metrics_json(service, metrics);
post_otlp(client, metrics_url, &body);
}
}
fn post_otlp(client: &reqwest::blocking::Client, url: &str, body: &str) {
let res = client.post(url)
.header("Content-Type", "application/json")
.body(body.to_string())
.send();
match res {
Ok(r) if r.status().is_success() => {}
Ok(r) => {
if !OTLP_WARNED.swap(true, Ordering::Relaxed) {
eprintln!("[el-telemetry] OTLP export failed: HTTP {}", r.status());
}
}
Err(e) => {
if !OTLP_WARNED.swap(true, Ordering::Relaxed) {
eprintln!("[el-telemetry] OTLP endpoint unreachable ({}), telemetry will be dropped", e);
}
}
}
}
// ── JSON serialisation for OTLP/HTTP ─────────────────────────────────────────
fn attr_value_json(v: &AttrValue) -> serde_json::Value {
match v {
AttrValue::Str(s) => serde_json::json!({"stringValue": s}),
AttrValue::Int(n) => serde_json::json!({"intValue": n.to_string()}),
AttrValue::Float(f) => serde_json::json!({"doubleValue": f}),
AttrValue::Bool(b) => serde_json::json!({"boolValue": b}),
}
}
fn attrs_json(attrs: &[(String, AttrValue)]) -> serde_json::Value {
serde_json::Value::Array(
attrs.iter().map(|(k, v)| {
serde_json::json!({"key": k, "value": attr_value_json(v)})
}).collect()
)
}
fn resource_json(service: &str) -> serde_json::Value {
serde_json::json!({
"attributes": [
{"key": "service.name", "value": {"stringValue": service}}
]
})
}
fn build_traces_json(service: &str, spans: &[Span]) -> String {
// Group spans by trace_id for correct OTLP structure
let mut by_trace: HashMap<&str, Vec<serde_json::Value>> = HashMap::new();
for span in spans {
let js = span_json(span);
by_trace.entry(&span.trace_id).or_default().push(js);
}
let scope_spans: Vec<serde_json::Value> = by_trace.values().map(|sps| {
serde_json::json!({
"scope": {"name": "el-runtime", "version": "0.1.0"},
"spans": sps
})
}).collect();
let payload = serde_json::json!({
"resourceSpans": [{
"resource": resource_json(service),
"scopeSpans": scope_spans
}]
});
payload.to_string()
}
fn span_json(span: &Span) -> serde_json::Value {
let status = match &span.status {
SpanStatus::Ok => serde_json::json!({"code": 1}),
SpanStatus::Error(msg) => serde_json::json!({"code": 2, "message": msg}),
};
let events: Vec<serde_json::Value> = span.events.iter().map(|e| {
serde_json::json!({
"name": e.name,
"timeUnixNano": e.time_ns.to_string(),
"attributes": attrs_json(&e.attrs)
})
}).collect();
let mut js = serde_json::json!({
"traceId": span.trace_id,
"spanId": span.span_id,
"name": span.name,
"startTimeUnixNano": span.start_ns.to_string(),
"endTimeUnixNano": span.end_ns.to_string(),
"attributes": attrs_json(&span.attrs),
"events": events,
"status": status,
"kind": 1 // INTERNAL
});
if let Some(pid) = &span.parent_id {
js["parentSpanId"] = serde_json::Value::String(pid.clone());
}
js
}
fn build_logs_json(service: &str, logs: &[LogRecord]) -> String {
let records: Vec<serde_json::Value> = logs.iter().map(|l| {
let mut r = serde_json::json!({
"timeUnixNano": l.time_ns.to_string(),
"severityNumber": l.severity.number(),
"severityText": l.severity.text(),
"body": {"stringValue": l.body},
"attributes": attrs_json(&l.attrs)
});
if let Some(tid) = &l.trace_id {
r["traceId"] = serde_json::Value::String(tid.clone());
}
if let Some(sid) = &l.span_id {
r["spanId"] = serde_json::Value::String(sid.clone());
}
r
}).collect();
let payload = serde_json::json!({
"resourceLogs": [{
"resource": resource_json(service),
"scopeLogs": [{
"scope": {"name": "el-runtime", "version": "0.1.0"},
"logRecords": records
}]
}]
});
payload.to_string()
}
fn build_metrics_json(service: &str, metrics: &[Metric]) -> String {
let metric_items: Vec<serde_json::Value> = metrics.iter().map(|m| {
let data_point = serde_json::json!({
"timeUnixNano": m.time_ns.to_string(),
"asDouble": m.value,
"attributes": attrs_json(&m.attrs)
});
match m.kind {
MetricKind::Counter => serde_json::json!({
"name": m.name,
"sum": {
"dataPoints": [data_point],
"aggregationTemporality": 2, // CUMULATIVE
"isMonotonic": true
}
}),
MetricKind::Gauge => serde_json::json!({
"name": m.name,
"gauge": {
"dataPoints": [data_point]
}
}),
}
}).collect();
let payload = serde_json::json!({
"resourceMetrics": [{
"resource": resource_json(service),
"scopeMetrics": [{
"scope": {"name": "el-runtime", "version": "0.1.0"},
"metrics": metric_items
}]
}]
});
payload.to_string()
}
// ── parse_tags_string: "k=v,k2=v2" → Vec<(String, AttrValue)> ────────────────
pub fn parse_tags_string(tags: &str) -> Vec<(String, AttrValue)> {
if tags.is_empty() {
return Vec::new();
}
tags.split(',')
.filter_map(|pair| {
let mut parts = pair.splitn(2, '=');
let k = parts.next()?.trim().to_string();
let v = parts.next().unwrap_or("").trim().to_string();
if k.is_empty() {
None
} else {
Some((k, AttrValue::Str(v)))
}
})
.collect()
}
@@ -1,20 +0,0 @@
[package]
name = "elvm"
description = "El Virtual Machine — executes compiled El bytecode (.elc) natively"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "elvm"
path = "src/main.rs"
[dependencies]
el-compiler = { workspace = true }
el-vm = { workspace = true }
clap = { workspace = true }
# Native window / WebView (macOS: WKWebView via wry)
wry = { version = "0.47", default-features = false }
winit = { version = "0.29", default-features = false, features = ["rwh_05", "rwh_06"] }
dpi = "0.1"
@@ -1,146 +0,0 @@
//! elvm — El Virtual Machine
//!
//! The standalone El VM binary. Loads and executes compiled El bytecode (.elc)
//! files produced by `el compile` or `el build-file`.
//!
//! # Usage
//!
//! elvm <file.elc> [args...]
//! elvm --version
//! elvm --help
//!
//! If the environment variable `NEURON_WINDOW_URL` is set, elvm opens a native
//! macOS window (WKWebView via wry) at that URL instead of executing bytecode.
//! This allows UI apps to be launched as proper desktop windows:
//!
//! NEURON_WINDOW_URL="http://localhost:7749" elvm dist/neuron-ui.elc
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(
name = "elvm",
about = "El Virtual Machine — execute compiled El bytecode (.elc)",
long_about = "The El VM is the native El execution substrate.\n\
Run .elc files produced by `el compile` or `el build-file`.\n\n\
Set NEURON_WINDOW_URL=<url> to open a native WebView window instead.",
version
)]
struct Cli {
/// Compiled El bytecode file to execute (*.elc).
file: PathBuf,
/// Arguments forwarded to the program (accessible via `args()`).
#[arg(trailing_var_arg = true)]
args: Vec<String>,
}
fn main() {
let cli = Cli::parse();
// If NEURON_WINDOW_URL is set, open a native WebView window at that URL.
if let Ok(url) = std::env::var("NEURON_WINDOW_URL") {
if let Err(e) = open_window(&url) {
eprintln!("elvm: window error: {e}");
std::process::exit(1);
}
return;
}
if let Err(e) = run(cli) {
eprintln!("elvm: error: {e}");
std::process::exit(1);
}
}
fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
let bytes = std::fs::read(&cli.file)
.map_err(|e| format!("cannot read {}: {e}", cli.file.display()))?;
let instructions = el_compiler::Bytecode::deserialize_all(&bytes)
.map_err(|e| format!("cannot load bytecode from {}: {e}", cli.file.display()))?;
// Detect format and print diagnostic.
let is_elvm_container = bytes.starts_with(el_compiler::ELVM_MAGIC);
if is_elvm_container {
// Normal path — ELVM container.
} else {
eprintln!("elvm: warning: {} does not have an ELVM header — treating as legacy JSON bytecode", cli.file.display());
}
let mut vm = el_vm::ElVm::new();
vm.run(&instructions, &cli.args);
Ok(())
}
/// Opens a native WebView window at `url`.
///
/// On macOS: uses wry (WKWebView) + winit for a proper native desktop window.
/// On other platforms: prints the URL (fallback).
fn open_window(url: &str) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(target_os = "macos")]
return open_native_window(url);
#[cfg(not(target_os = "macos"))]
{
eprintln!("elvm: native window not supported on this platform");
println!("elvm: open {url} in your browser");
Ok(())
}
}
#[cfg(target_os = "macos")]
fn open_native_window(url: &str) -> Result<(), Box<dyn std::error::Error>> {
use dpi::LogicalSize;
use winit::{
event::{Event, WindowEvent},
event_loop::{ControlFlow, EventLoop},
window::WindowBuilder,
};
use wry::{Rect, WebViewBuilder};
let event_loop = EventLoop::new().map_err(|e| format!("event loop: {e}"))?;
let window = WindowBuilder::new()
.with_title("Neuron")
.with_inner_size(winit::dpi::LogicalSize::new(1600u32, 1000u32))
.with_min_inner_size(winit::dpi::LogicalSize::new(900u32, 600u32))
.with_resizable(true)
.build(&event_loop)
.map_err(|e| format!("window: {e}"))?;
let url_owned = url.to_string();
let webview = WebViewBuilder::new()
.with_url(&url_owned)
.build_as_child(&window)
.map_err(|e| format!("webview: {e}"))?;
event_loop
.run(move |event, evl| {
evl.set_control_flow(ControlFlow::Wait);
match event {
Event::WindowEvent {
event: WindowEvent::Resized(size),
..
} => {
let scale = window.scale_factor();
let logical = size.to_logical::<u32>(scale);
let _ = webview.set_bounds(Rect {
position: dpi::LogicalPosition::new(0, 0).into(),
size: LogicalSize::new(logical.width, logical.height).into(),
});
}
Event::WindowEvent {
event: WindowEvent::CloseRequested,
..
} => evl.exit(),
_ => {}
}
})
.map_err(|e| format!("event loop run: {e}"))?;
Ok(())
}
-162
View File
@@ -1,162 +0,0 @@
#!/usr/bin/env bash
# build-targets.sh — Build the El VM (elvm) for all supported platforms.
#
# Usage:
# ./build-targets.sh # build all targets
# ./build-targets.sh --list # list targets and prerequisites
# ./build-targets.sh x86_64-apple-darwin # build a specific target
#
# Prerequisites:
# - Rust toolchain (rustup) with cross-compilation targets installed
# - For Linux targets: cross-linkers (see comments below)
#
# Install a target:
# rustup target add <target>
#
# For cross-compilation from macOS to Linux:
# brew install FiloSottile/musl-cross/musl-cross
# # or use `cross` (https://github.com/cross-rs/cross):
# cargo install cross --git https://github.com/cross-rs/cross
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUTPUT_DIR="${SCRIPT_DIR}/dist/elvm"
# ── Target definitions ────────────────────────────────────────────────────────
# Format: "target:description:linker_hint"
declare -a TARGETS=(
"x86_64-unknown-linux-gnu:Linux x86_64 (glibc):brew install FiloSottile/musl-cross/musl-cross or use 'cross'"
"aarch64-unknown-linux-gnu:Linux ARM64 (glibc):brew install FiloSottile/musl-cross/musl-cross or use 'cross'"
"x86_64-unknown-linux-musl:Linux x86_64 (musl, static):brew install FiloSottile/musl-cross/musl-cross"
"aarch64-unknown-linux-musl:Linux ARM64 (musl, static):brew install aarch64-unknown-linux-musl cross-linker"
"x86_64-apple-darwin:macOS Intel:native (no cross tooling needed on macOS)"
"aarch64-apple-darwin:macOS Apple Silicon:native (no cross tooling needed on macOS)"
"x86_64-pc-windows-gnu:Windows x86_64:brew install mingw-w64"
"aarch64-pc-windows-msvc:Windows ARM64:requires Windows MSVC SDK (Windows only)"
)
# ── Helpers ───────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
ok() { echo -e " ${GREEN}${NC} $*"; }
warn() { echo -e " ${YELLOW}${NC} $*"; }
fail() { echo -e " ${RED}${NC} $*"; }
# ── List mode ─────────────────────────────────────────────────────────────────
if [[ "${1:-}" == "--list" ]]; then
echo ""
echo "El VM cross-compilation targets"
echo "================================"
echo ""
for entry in "${TARGETS[@]}"; do
IFS=':' read -r target desc hint <<< "$entry"
printf " %-42s %s\n" "$target" "$desc"
printf " %-42s Prereq: %s\n" "" "$hint"
echo ""
done
echo "Install a target: rustup target add <target>"
echo "List installed: rustup target list --installed"
exit 0
fi
# ── Filter targets ────────────────────────────────────────────────────────────
if [[ $# -gt 0 && "${1:-}" != "--list" ]]; then
# Build only the specified target(s).
SELECTED=("$@")
else
# Build all targets.
SELECTED=()
for entry in "${TARGETS[@]}"; do
IFS=':' read -r target _ _ <<< "$entry"
SELECTED+=("$target")
done
fi
# ── Build ─────────────────────────────────────────────────────────────────────
echo ""
echo -e "${BLUE}El VM — cross-platform build${NC}"
echo "Output directory: $OUTPUT_DIR"
echo ""
mkdir -p "$OUTPUT_DIR"
BUILT=0
SKIPPED=0
FAILED=0
for target in "${SELECTED[@]}"; do
# Find description for this target.
desc="$target"
for entry in "${TARGETS[@]}"; do
IFS=':' read -r t d _ <<< "$entry"
if [[ "$t" == "$target" ]]; then desc="$d"; break; fi
done
echo -e "${BLUE}→ Building $target${NC} ($desc)"
# Check if the Rust target is installed.
if ! rustup target list --installed 2>/dev/null | grep -q "^$target\b"; then
warn "Target $target not installed — installing..."
if rustup target add "$target" 2>/dev/null; then
ok "Installed $target"
else
fail "Cannot install $target — skipping"
((SKIPPED++))
continue
fi
fi
# Attempt to build.
binary_suffix=""
if [[ "$target" == *"windows"* ]]; then
binary_suffix=".exe"
fi
binary_name="elvm-${target}${binary_suffix}"
output_path="$OUTPUT_DIR/$binary_name"
if (cd "$SCRIPT_DIR" && cargo build --release --target "$target" -p elvm 2>&1 | tail -5); then
src="$SCRIPT_DIR/target/$target/release/elvm${binary_suffix}"
if [[ -f "$src" ]]; then
cp "$src" "$output_path"
size=$(du -h "$output_path" | cut -f1)
ok "Built $binary_name ($size)"
((BUILT++))
else
fail "Binary not found at $src"
((FAILED++))
fi
else
fail "Build failed for $target (cross-linker may be missing — see --list)"
((FAILED++))
fi
echo ""
done
# ── Summary ───────────────────────────────────────────────────────────────────
echo "────────────────────────────────"
echo "Built: $BUILT"
echo "Skipped: $SKIPPED"
echo "Failed: $FAILED"
if [[ $BUILT -gt 0 ]]; then
echo ""
echo "Artifacts:"
ls -lh "$OUTPUT_DIR/"
fi
echo ""
if [[ $FAILED -gt 0 ]]; then
echo -e "${YELLOW}Some targets failed. Run './build-targets.sh --list' for prerequisites.${NC}"
exit 1
fi
@@ -1,12 +0,0 @@
[package]
name = "el-arch"
version = "0.1.0"
edition = "2021"
[dependencies]
el-parser = { path = "../el-parser" }
el-lexer = { path = "../el-lexer" }
[dev-dependencies]
el-lexer = { path = "../el-lexer" }
el-parser = { path = "../el-parser" }
@@ -1,487 +0,0 @@
//! Main architectural checker — walks the AST and applies all rules.
use std::collections::HashMap;
use el_parser::{Expr, Program, Stmt, TypeExpr};
use crate::error::{ArchDiagnostic, Severity};
use crate::rule::{ArchRule, CallInfo, FnContext};
use crate::rules::{
graph::{DuplicateActivateType, N1Detection},
security::{AuthnWithoutAuthz, PublicFnWithActivate, SealedInLoop},
swarm::{SwarmAgentIsolation, SwarmAgentNoSharedState, SwarmAgentNoSpawn},
vbd::{AccessorMustNotCallManager, ExperienceMustNotCallExperience, ExperienceShouldReturnResult},
};
/// The main architectural checker. Instantiate once, call `check` per program.
pub struct ArchChecker {
rules: Vec<Box<dyn ArchRule>>,
}
impl ArchChecker {
/// Create an `ArchChecker` with all built-in rules registered.
pub fn new() -> Self {
Self {
rules: vec![
Box::new(AccessorMustNotCallManager),
Box::new(ExperienceMustNotCallExperience),
Box::new(ExperienceShouldReturnResult),
Box::new(PublicFnWithActivate),
Box::new(SealedInLoop),
Box::new(AuthnWithoutAuthz),
Box::new(N1Detection),
Box::new(DuplicateActivateType),
Box::new(SwarmAgentIsolation),
Box::new(SwarmAgentNoSpawn),
Box::new(SwarmAgentNoSharedState),
],
}
}
/// Returns a reference to the registered rules (useful for introspection in tests).
pub fn rules(&self) -> &[Box<dyn ArchRule>] {
&self.rules
}
/// Run all rules against a parsed program and collect all diagnostics.
pub fn check(&self, program: &Program) -> Vec<ArchDiagnostic> {
// Step 1: build global fn_name → decorator names map (including impl methods).
let all_fn_annotations = collect_fn_annotations(&program.stmts);
// Step 2: gather all top-level + impl FnDef statements.
let fn_defs = collect_fn_defs(&program.stmts);
// Step 3: for each function, build FnContext and run every rule.
let mut diagnostics = Vec::new();
for (fn_name, decorators, return_type, body) in &fn_defs {
let annotations: Vec<String> =
decorators.iter().map(|d| d.name.clone()).collect();
let body_calls = extract_calls(body, false);
let activate_types = extract_activate_types(body, false);
let has_sealed_in_loop = has_sealed_in_loop_body(body, false);
let return_type_name = type_expr_name(return_type);
let ctx = FnContext {
fn_name: fn_name.as_str(),
annotations: &annotations,
body_calls: &body_calls,
all_fn_annotations: &all_fn_annotations,
activate_types: &activate_types,
has_sealed_in_loop,
return_type_name: &return_type_name,
};
for rule in &self.rules {
diagnostics.extend(rule.check(&ctx));
}
}
diagnostics
}
/// Returns true if any of the given diagnostics are errors.
pub fn has_errors(diagnostics: &[ArchDiagnostic]) -> bool {
diagnostics.iter().any(|d| d.severity == Severity::Error)
}
}
impl Default for ArchChecker {
fn default() -> Self {
Self::new()
}
}
// ── AST traversal helpers ─────────────────────────────────────────────────────
/// A collected function definition: (name, decorators, return_type, body).
type FnDef<'a> = (
&'a String,
&'a Vec<el_parser::Decorator>,
&'a TypeExpr,
&'a Vec<Stmt>,
);
/// Collect all FnDef statements from top-level and impl blocks.
fn collect_fn_defs<'a>(stmts: &'a [Stmt]) -> Vec<FnDef<'a>> {
let mut out = Vec::new();
for stmt in stmts {
match stmt {
Stmt::FnDef { name, decorators, return_type, body, .. } => {
out.push((name, decorators, return_type, body));
}
Stmt::ImplDef { methods, .. } => {
for m in methods {
if let Stmt::FnDef { name, decorators, return_type, body, .. } = m {
out.push((name, decorators, return_type, body));
}
}
}
_ => {}
}
}
out
}
/// Build a map from function name → list of decorator names, for all functions in the program.
fn collect_fn_annotations(stmts: &[Stmt]) -> HashMap<String, Vec<String>> {
let mut map = HashMap::new();
for stmt in stmts {
match stmt {
Stmt::FnDef { name, decorators, .. } => {
let anns: Vec<String> = decorators.iter().map(|d| d.name.clone()).collect();
map.insert(name.clone(), anns);
}
Stmt::ImplDef { methods, .. } => {
for m in methods {
if let Stmt::FnDef { name, decorators, .. } = m {
let anns: Vec<String> = decorators.iter().map(|d| d.name.clone()).collect();
map.insert(name.clone(), anns);
}
}
}
_ => {}
}
}
map
}
/// Extract all function calls from a statement list.
/// `in_loop` tracks whether we are currently inside a for/while loop body.
fn extract_calls(stmts: &[Stmt], in_loop: bool) -> Vec<CallInfo> {
let mut calls = Vec::new();
for stmt in stmts {
extract_calls_from_stmt(stmt, in_loop, &mut calls);
}
calls
}
fn extract_calls_from_stmt(stmt: &Stmt, in_loop: bool, out: &mut Vec<CallInfo>) {
match stmt {
Stmt::Let { value, .. } => extract_calls_from_expr(value, in_loop, out),
Stmt::Return(expr, _) | Stmt::Expr(expr, _) | Stmt::Assert(expr, _) => {
extract_calls_from_expr(expr, in_loop, out);
}
Stmt::FnDef { body, .. } => {
// Nested function defs: walk but don't count as callee of the outer fn.
for s in body {
extract_calls_from_stmt(s, false, out);
}
}
_ => {}
}
}
fn extract_calls_from_expr(expr: &Expr, in_loop: bool, out: &mut Vec<CallInfo>) {
match expr {
Expr::Call { func, args } => {
// Extract callee name
let callee = expr_as_call_name(func);
if let Some(name) = callee {
out.push(CallInfo { callee: name, is_in_loop: in_loop });
}
// Recurse into func expression and args
extract_calls_from_expr(func, in_loop, out);
for a in args {
extract_calls_from_expr(a, in_loop, out);
}
}
Expr::Activate { type_name, .. } => {
// Encode activate as a synthetic call so GRAPH-001 can detect in-loop activates.
out.push(CallInfo {
callee: format!("__activate__{type_name}"),
is_in_loop: in_loop,
});
}
Expr::BinOp { left, right, .. } => {
extract_calls_from_expr(left, in_loop, out);
extract_calls_from_expr(right, in_loop, out);
}
Expr::UnaryNot(inner) | Expr::Try(inner) => {
extract_calls_from_expr(inner, in_loop, out);
}
Expr::Block(stmts) => {
for s in stmts {
extract_calls_from_stmt(s, in_loop, out);
}
}
Expr::Sealed(stmts) => {
// Sealed blocks are scanned but tracked separately for sealed-in-loop.
for s in stmts {
extract_calls_from_stmt(s, in_loop, out);
}
}
Expr::If { cond, then, else_ } => {
extract_calls_from_expr(cond, in_loop, out);
extract_calls_from_expr(then, in_loop, out);
if let Some(e) = else_ {
extract_calls_from_expr(e, in_loop, out);
}
}
Expr::Match { subject, arms } => {
extract_calls_from_expr(subject, in_loop, out);
for arm in arms {
extract_calls_from_expr(&arm.body, in_loop, out);
}
}
Expr::Field { object, .. } => extract_calls_from_expr(object, in_loop, out),
Expr::Array(elems) => {
for e in elems {
extract_calls_from_expr(e, in_loop, out);
}
}
Expr::Index { object, index } => {
extract_calls_from_expr(object, in_loop, out);
extract_calls_from_expr(index, in_loop, out);
}
Expr::Closure { body, .. } => {
extract_calls_from_expr(body, in_loop, out);
}
Expr::MapLiteral(pairs) => {
for (k, v) in pairs {
extract_calls_from_expr(k, in_loop, out);
extract_calls_from_expr(v, in_loop, out);
}
}
Expr::Literal(_) | Expr::Ident(_) | Expr::Path { .. } => {}
Expr::StructLit { fields, .. } => {
for (_, e) in fields {
extract_calls_from_expr(e, in_loop, out);
}
}
Expr::With { base, updates } => {
extract_calls_from_expr(base, in_loop, out);
for (_, e) in updates {
extract_calls_from_expr(e, in_loop, out);
}
}
Expr::Reason { .. } => {}
Expr::Parallel { entries } => {
for (_, e) in entries {
extract_calls_from_expr(e, in_loop, out);
}
}
Expr::Trace { body, .. } => {
for s in body {
extract_calls_from_stmt(s, in_loop, out);
}
}
Expr::UnaryBitNot(inner) => extract_calls_from_expr(inner, in_loop, out),
// JSX expressions — traverse children.
Expr::JsxElement { children, .. } => {
for child in children { extract_calls_from_expr(child, in_loop, out); }
}
Expr::JsxExpr(inner) => extract_calls_from_expr(inner, in_loop, out),
Expr::JsxText(_) => {}
}
}
/// Try to extract a simple callee name from a Call's `func` expression.
fn expr_as_call_name(expr: &Expr) -> Option<String> {
match expr {
Expr::Ident(name) => Some(name.clone()),
Expr::Field { field, .. } => Some(field.clone()),
Expr::Path { segments } => segments.last().cloned(),
_ => None,
}
}
/// Collect all `activate TypeName` type names from a statement list.
/// `in_loop` indicates whether we're currently inside a loop.
fn extract_activate_types(stmts: &[Stmt], in_loop: bool) -> Vec<String> {
let mut types = Vec::new();
for stmt in stmts {
extract_activate_types_stmt(stmt, in_loop, &mut types);
}
types
}
fn extract_activate_types_stmt(stmt: &Stmt, in_loop: bool, out: &mut Vec<String>) {
match stmt {
Stmt::Let { value, .. } => extract_activate_types_expr(value, in_loop, out),
Stmt::Return(expr, _) | Stmt::Expr(expr, _) | Stmt::Assert(expr, _) => {
extract_activate_types_expr(expr, in_loop, out);
}
Stmt::FnDef { body, .. } => {
for s in body {
extract_activate_types_stmt(s, false, out);
}
}
_ => {}
}
}
fn extract_activate_types_expr(expr: &Expr, in_loop: bool, out: &mut Vec<String>) {
match expr {
Expr::Activate { type_name, .. } => {
out.push(type_name.clone());
}
Expr::Call { func, args } => {
extract_activate_types_expr(func, in_loop, out);
for a in args {
extract_activate_types_expr(a, in_loop, out);
}
}
Expr::BinOp { left, right, .. } => {
extract_activate_types_expr(left, in_loop, out);
extract_activate_types_expr(right, in_loop, out);
}
Expr::UnaryNot(inner) | Expr::Try(inner) => {
extract_activate_types_expr(inner, in_loop, out);
}
Expr::Block(stmts) => {
for s in stmts {
extract_activate_types_stmt(s, in_loop, out);
}
}
Expr::Sealed(stmts) => {
for s in stmts {
extract_activate_types_stmt(s, in_loop, out);
}
}
Expr::If { cond, then, else_ } => {
extract_activate_types_expr(cond, in_loop, out);
extract_activate_types_expr(then, in_loop, out);
if let Some(e) = else_ {
extract_activate_types_expr(e, in_loop, out);
}
}
Expr::Match { subject, arms } => {
extract_activate_types_expr(subject, in_loop, out);
for arm in arms {
extract_activate_types_expr(&arm.body, in_loop, out);
}
}
Expr::Field { object, .. } => extract_activate_types_expr(object, in_loop, out),
Expr::Array(elems) => {
for e in elems {
extract_activate_types_expr(e, in_loop, out);
}
}
Expr::Index { object, index } => {
extract_activate_types_expr(object, in_loop, out);
extract_activate_types_expr(index, in_loop, out);
}
Expr::Closure { body, .. } => {
extract_activate_types_expr(body, in_loop, out);
}
Expr::MapLiteral(pairs) => {
for (k, v) in pairs {
extract_activate_types_expr(k, in_loop, out);
extract_activate_types_expr(v, in_loop, out);
}
}
Expr::Literal(_) | Expr::Ident(_) | Expr::Path { .. } => {}
Expr::StructLit { fields, .. } => {
for (_, e) in fields {
extract_activate_types_expr(e, in_loop, out);
}
}
Expr::With { base, updates } => {
extract_activate_types_expr(base, in_loop, out);
for (_, e) in updates {
extract_activate_types_expr(e, in_loop, out);
}
}
Expr::Reason { .. } => {}
Expr::Parallel { entries } => {
for (_, e) in entries {
extract_activate_types_expr(e, in_loop, out);
}
}
Expr::Trace { body, .. } => {
for s in body {
extract_activate_types_stmt(s, in_loop, out);
}
}
Expr::UnaryBitNot(inner) => extract_activate_types_expr(inner, in_loop, out),
// JSX expressions — traverse children.
Expr::JsxElement { children, .. } => {
for child in children { extract_activate_types_expr(child, in_loop, out); }
}
Expr::JsxExpr(inner) => extract_activate_types_expr(inner, in_loop, out),
Expr::JsxText(_) => {}
}
}
/// Returns true if any `sealed { }` block appears inside a loop in the given body.
fn has_sealed_in_loop_body(stmts: &[Stmt], in_loop: bool) -> bool {
stmts.iter().any(|s| has_sealed_in_loop_stmt(s, in_loop))
}
fn has_sealed_in_loop_stmt(stmt: &Stmt, in_loop: bool) -> bool {
match stmt {
Stmt::Let { value, .. } => has_sealed_in_loop_expr(value, in_loop),
Stmt::Return(expr, _) | Stmt::Expr(expr, _) | Stmt::Assert(expr, _) => {
has_sealed_in_loop_expr(expr, in_loop)
}
Stmt::FnDef { body, .. } => {
// Inner function defs reset loop context.
body.iter().any(|s| has_sealed_in_loop_stmt(s, false))
}
_ => false,
}
}
fn has_sealed_in_loop_expr(expr: &Expr, in_loop: bool) -> bool {
match expr {
Expr::Sealed(_) => in_loop,
Expr::Call { func, args } => {
has_sealed_in_loop_expr(func, in_loop)
|| args.iter().any(|a| has_sealed_in_loop_expr(a, in_loop))
}
Expr::BinOp { left, right, .. } => {
has_sealed_in_loop_expr(left, in_loop) || has_sealed_in_loop_expr(right, in_loop)
}
Expr::UnaryNot(inner) | Expr::Try(inner) => has_sealed_in_loop_expr(inner, in_loop),
Expr::Block(stmts) => stmts.iter().any(|s| has_sealed_in_loop_stmt(s, in_loop)),
Expr::If { cond, then, else_ } => {
has_sealed_in_loop_expr(cond, in_loop)
|| has_sealed_in_loop_expr(then, in_loop)
|| else_.as_deref().is_some_and(|e| has_sealed_in_loop_expr(e, in_loop))
}
Expr::Match { subject, arms } => {
has_sealed_in_loop_expr(subject, in_loop)
|| arms.iter().any(|a| has_sealed_in_loop_expr(&a.body, in_loop))
}
Expr::Field { object, .. } => has_sealed_in_loop_expr(object, in_loop),
Expr::Array(elems) => elems.iter().any(|e| has_sealed_in_loop_expr(e, in_loop)),
Expr::Index { object, index } => {
has_sealed_in_loop_expr(object, in_loop) || has_sealed_in_loop_expr(index, in_loop)
}
Expr::Closure { body, .. } => has_sealed_in_loop_expr(body, in_loop),
Expr::MapLiteral(pairs) => pairs
.iter()
.any(|(k, v)| has_sealed_in_loop_expr(k, in_loop) || has_sealed_in_loop_expr(v, in_loop)),
Expr::Activate { .. } | Expr::Literal(_) | Expr::Ident(_) | Expr::Path { .. } => false,
Expr::StructLit { fields, .. } => fields
.iter()
.any(|(_, e)| has_sealed_in_loop_expr(e, in_loop)),
Expr::With { base, updates } => {
has_sealed_in_loop_expr(base, in_loop)
|| updates.iter().any(|(_, e)| has_sealed_in_loop_expr(e, in_loop))
}
Expr::Reason { .. } => false,
Expr::Parallel { entries } => entries.iter().any(|(_, e)| has_sealed_in_loop_expr(e, in_loop)),
Expr::Trace { body, .. } => body.iter().any(|s| has_sealed_in_loop_stmt(s, in_loop)),
Expr::UnaryBitNot(inner) => has_sealed_in_loop_expr(inner, in_loop),
// JSX expressions.
Expr::JsxElement { children, .. } => children.iter().any(|c| has_sealed_in_loop_expr(c, in_loop)),
Expr::JsxExpr(inner) => has_sealed_in_loop_expr(inner, in_loop),
Expr::JsxText(_) => false,
}
}
/// Convert a `TypeExpr` to a display string for the return-type name check.
fn type_expr_name(te: &TypeExpr) -> String {
match te {
TypeExpr::Named(n) => n.clone(),
TypeExpr::Result { .. } => "Result".to_string(),
TypeExpr::Array(inner) => format!("[{}]", type_expr_name(inner)),
TypeExpr::Optional(inner) => format!("{}?", type_expr_name(inner)),
TypeExpr::Map { key, value } => {
format!("Map<{}, {}>", type_expr_name(key), type_expr_name(value))
}
TypeExpr::Fn { .. } => "fn".to_string(),
TypeExpr::TypeParam(n) => n.clone(),
}
}
@@ -1,45 +0,0 @@
//! Diagnostic types for the architectural checker.
/// Severity level of an architectural diagnostic.
#[derive(Debug, Clone, PartialEq)]
pub enum Severity {
Error,
Warning,
}
/// A single architectural diagnostic (error or warning).
#[derive(Debug, Clone)]
pub struct ArchDiagnostic {
pub severity: Severity,
/// Rule identifier, e.g. "VBD-001".
pub rule: String,
pub message: String,
/// Function name or other location hint.
pub location: Option<String>,
}
/// Type alias — an ArchError is an ArchDiagnostic with Severity::Error.
pub type ArchError = ArchDiagnostic;
/// Type alias — an ArchWarning is an ArchDiagnostic with Severity::Warning.
pub type ArchWarning = ArchDiagnostic;
impl ArchDiagnostic {
pub fn error(rule: impl Into<String>, message: impl Into<String>, location: Option<String>) -> Self {
Self {
severity: Severity::Error,
rule: rule.into(),
message: message.into(),
location,
}
}
pub fn warning(rule: impl Into<String>, message: impl Into<String>, location: Option<String>) -> Self {
Self {
severity: Severity::Warning,
rule: rule.into(),
message: message.into(),
location,
}
}
}
@@ -1,19 +0,0 @@
//! el-arch — Architectural rule checker for the Engram language.
//!
//! Runs after type-checking and enforces:
//! - VBD (Volatility-Based Decomposition) layer rules
//! - EBD (Experience-Based Decomposition) experience rules
//! - Swarm containment rules
//! - Security rules
//! - Graph access patterns (N+1, duplicate activate)
pub mod checker;
pub mod error;
pub mod rule;
pub mod rules;
pub use checker::ArchChecker;
pub use error::{ArchDiagnostic, ArchError, ArchWarning, Severity};
#[cfg(test)]
mod tests;
@@ -1,56 +0,0 @@
//! Core trait and context types for architectural rules.
use std::collections::HashMap;
use crate::error::ArchDiagnostic;
/// Information about a single function call within a function body.
#[derive(Debug, Clone)]
pub struct CallInfo {
/// The name of the function being called.
pub callee: String,
/// True if this call appears inside a loop body (for or while).
pub is_in_loop: bool,
}
/// Full context about a single function being checked by arch rules.
pub struct FnContext<'a> {
/// Name of the function under analysis.
pub fn_name: &'a str,
/// Decorator names applied directly to this function (e.g. "accessor", "public").
pub annotations: &'a [String],
/// All calls made from within this function body.
pub body_calls: &'a [CallInfo],
/// Global map of function name → decorator names for the whole program.
pub all_fn_annotations: &'a HashMap<String, Vec<String>>,
/// TypeNames that appear in `activate TypeName where ...` calls in this function.
pub activate_types: &'a [String],
/// Whether this function contains a `sealed { }` block inside a loop.
pub has_sealed_in_loop: bool,
/// The return type of the function as a string (e.g. "Result", "Void", "String").
pub return_type_name: &'a str,
}
impl<'a> FnContext<'a> {
/// Returns true if this function has the given annotation/decorator.
pub fn has_annotation(&self, name: &str) -> bool {
self.annotations.iter().any(|a| a == name)
}
/// Returns true if the named callee has the given annotation in the program.
pub fn callee_has_annotation(&self, callee: &str, ann: &str) -> bool {
self.all_fn_annotations
.get(callee)
.map(|anns| anns.iter().any(|a| a == ann))
.unwrap_or(false)
}
}
/// An architectural rule that can be checked against a function context.
pub trait ArchRule: Send + Sync {
/// Short unique identifier, e.g. "VBD-001".
fn name(&self) -> &str;
/// Human-readable description of what this rule enforces.
fn description(&self) -> &str;
/// Run the rule against a function context, returning any diagnostics.
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic>;
}
@@ -1,73 +0,0 @@
//! Graph access pattern rules (N+1 detection, duplicate activate).
use crate::error::ArchDiagnostic;
use crate::rule::{ArchRule, FnContext};
// ── GRAPH-001: N+1 detection ──────────────────────────────────────────────────
/// GRAPH-001: An activate call inside a loop is an N+1 graph access pattern.
/// Each loop iteration performs a separate graph traversal; consolidate into one query.
pub struct N1Detection;
impl ArchRule for N1Detection {
fn name(&self) -> &str { "GRAPH-001" }
fn description(&self) -> &str {
"activate inside a loop creates an N+1 graph access pattern — hoist outside the loop"
}
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
// We detect activate-in-loop via the body_calls with is_in_loop = true
// AND via the activate_types combined with loop context tracked by the checker.
// The checker sets a separate field for this.
let has_activate_in_loop = ctx.body_calls
.iter()
.any(|c| c.is_in_loop && c.callee.starts_with("__activate__"));
if has_activate_in_loop {
vec![ArchDiagnostic::warning(
self.name(),
format!(
"function '{}' performs activate inside a loop — N+1 graph access pattern",
ctx.fn_name
),
Some(ctx.fn_name.to_string()),
)]
} else {
vec![]
}
}
}
// ── GRAPH-002: Duplicate activate on same type ────────────────────────────────
/// GRAPH-002: Multiple activate calls on the same type within one function
/// should be consolidated into a single query for efficiency.
pub struct DuplicateActivateType;
impl ArchRule for DuplicateActivateType {
fn name(&self) -> &str { "GRAPH-002" }
fn description(&self) -> &str {
"multiple activate calls on the same type in one function — consolidate into one query"
}
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
let mut seen = std::collections::HashMap::new();
for type_name in ctx.activate_types {
*seen.entry(type_name.as_str()).or_insert(0u32) += 1;
}
seen.iter()
.filter(|(_, &count)| count > 1)
.map(|(type_name, count)| ArchDiagnostic::warning(
self.name(),
format!(
"function '{}' activates type '{}' {} times — consolidate into a single query",
ctx.fn_name, type_name, count
),
Some(ctx.fn_name.to_string()),
))
.collect()
}
}
@@ -1,6 +0,0 @@
//! Individual architectural rule implementations.
pub mod graph;
pub mod security;
pub mod swarm;
pub mod vbd;
@@ -1,104 +0,0 @@
//! Security architectural rules.
use crate::error::ArchDiagnostic;
use crate::rule::{ArchRule, FnContext};
// ── SEC-001: Public function with activate inside ─────────────────────────────
/// SEC-001: A @public function must not contain activate expressions.
/// Unauthenticated callers could trigger graph reads, potentially leaking data.
pub struct PublicFnWithActivate;
impl ArchRule for PublicFnWithActivate {
fn name(&self) -> &str { "SEC-001" }
fn description(&self) -> &str {
"@public functions must not contain activate — unauthenticated callers could trigger data reads"
}
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
if !ctx.has_annotation("public") {
return vec![];
}
if !ctx.activate_types.is_empty() {
return vec![ArchDiagnostic::error(
self.name(),
format!(
"@public function '{}' contains activate — data leak risk for unauthenticated callers (types: {})",
ctx.fn_name,
ctx.activate_types.join(", ")
),
Some(ctx.fn_name.to_string()),
)];
}
vec![]
}
}
// ── SEC-002: sealed block inside a loop ──────────────────────────────────────
/// SEC-002: A sealed block inside a loop incurs encryption overhead per iteration.
pub struct SealedInLoop;
impl ArchRule for SealedInLoop {
fn name(&self) -> &str { "SEC-002" }
fn description(&self) -> &str {
"sealed blocks inside loops cause encryption overhead on every iteration"
}
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
if ctx.has_sealed_in_loop {
return vec![ArchDiagnostic::warning(
self.name(),
format!(
"function '{}' contains a sealed block inside a loop — encryption overhead in hot path",
ctx.fn_name
),
Some(ctx.fn_name.to_string()),
)];
}
vec![]
}
}
// ── SEC-003: @authenticate without @authorize on mutations ────────────────────
/// SEC-003: Functions whose name suggests mutation (create_*, update_*, delete_*)
/// and carry @authenticate should also carry @authorize, otherwise authn without authz.
pub struct AuthnWithoutAuthz;
impl ArchRule for AuthnWithoutAuthz {
fn name(&self) -> &str { "SEC-003" }
fn description(&self) -> &str {
"@authenticate without @authorize on mutation functions — authentication without authorization"
}
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
if !ctx.has_annotation("authenticate") {
return vec![];
}
if ctx.has_annotation("authorize") {
return vec![];
}
// Heuristic: mutation function names
let is_mutation = ctx.fn_name.starts_with("create_")
|| ctx.fn_name.starts_with("update_")
|| ctx.fn_name.starts_with("delete_")
|| ctx.fn_name.starts_with("write_")
|| ctx.fn_name.starts_with("mutate_");
if is_mutation {
return vec![ArchDiagnostic::warning(
self.name(),
format!(
"function '{}' has @authenticate but not @authorize — authn without authz on a mutation",
ctx.fn_name
),
Some(ctx.fn_name.to_string()),
)];
}
vec![]
}
}
@@ -1,103 +0,0 @@
//! Swarm containment rules — agents are isolated and must not cross boundaries.
use crate::error::ArchDiagnostic;
use crate::rule::{ArchRule, FnContext};
// ── SWARM-001: Swarm agent calling another swarm agent ────────────────────────
/// SWARM-001: @swarm_agent functions must not call other @swarm_agent functions.
/// Agents are isolated units; cross-agent calls break containment.
pub struct SwarmAgentIsolation;
impl ArchRule for SwarmAgentIsolation {
fn name(&self) -> &str { "SWARM-001" }
fn description(&self) -> &str {
"@swarm_agent must not call another @swarm_agent (agents are isolated)"
}
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
if !ctx.has_annotation("swarm_agent") {
return vec![];
}
ctx.body_calls
.iter()
.filter(|call| ctx.callee_has_annotation(&call.callee, "swarm_agent"))
.map(|call| ArchDiagnostic::error(
self.name(),
format!(
"@swarm_agent '{}' calls @swarm_agent '{}' — agents must be isolated",
ctx.fn_name, call.callee
),
Some(ctx.fn_name.to_string()),
))
.collect()
}
}
// ── SWARM-002: Swarm agent initiating a spawn/swarm ──────────────────────────
/// SWARM-002: @swarm_agent must not initiate spawning of other agents
/// (calls to functions named `spawn` or containing "swarm" in the name).
pub struct SwarmAgentNoSpawn;
impl ArchRule for SwarmAgentNoSpawn {
fn name(&self) -> &str { "SWARM-002" }
fn description(&self) -> &str {
"@swarm_agent must not call spawn/swarm functions (agents cannot initiate sub-swarms)"
}
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
if !ctx.has_annotation("swarm_agent") {
return vec![];
}
ctx.body_calls
.iter()
.filter(|call| {
let c = call.callee.as_str();
c == "spawn" || c.contains("swarm") || c.starts_with("spawn_")
})
.map(|call| ArchDiagnostic::error(
self.name(),
format!(
"@swarm_agent '{}' calls '{}' — agents cannot initiate spawning",
ctx.fn_name, call.callee
),
Some(ctx.fn_name.to_string()),
))
.collect()
}
}
// ── SWARM-003: Swarm agent accessing shared mutable state ─────────────────────
/// SWARM-003: @swarm_agent must not access shared mutable state.
/// Heuristic: calls to functions with "shared" in the name suggest shared state access.
pub struct SwarmAgentNoSharedState;
impl ArchRule for SwarmAgentNoSharedState {
fn name(&self) -> &str { "SWARM-003" }
fn description(&self) -> &str {
"@swarm_agent must not access shared mutable state (functions with 'shared' in name)"
}
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
if !ctx.has_annotation("swarm_agent") {
return vec![];
}
ctx.body_calls
.iter()
.filter(|call| call.callee.contains("shared"))
.map(|call| ArchDiagnostic::error(
self.name(),
format!(
"@swarm_agent '{}' accesses shared state via '{}' — agents must not touch shared mutable state",
ctx.fn_name, call.callee
),
Some(ctx.fn_name.to_string()),
))
.collect()
}
}
@@ -1,101 +0,0 @@
//! VBD (Volatility-Based Decomposition) and EBD (Experience-Based Decomposition) layer rules.
use crate::error::ArchDiagnostic;
use crate::rule::{ArchRule, FnContext};
// ── VBD-001: Accessor must not call Manager ───────────────────────────────────
/// VBD-001: @accessor functions must not call @manager functions.
/// Accessors are read-only, stable-interface components; they must not depend
/// on manager-layer orchestration logic.
pub struct AccessorMustNotCallManager;
impl ArchRule for AccessorMustNotCallManager {
fn name(&self) -> &str { "VBD-001" }
fn description(&self) -> &str {
"@accessor must not call @manager functions (accessor must not depend on manager layer)"
}
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
if !ctx.has_annotation("accessor") {
return vec![];
}
ctx.body_calls
.iter()
.filter(|call| ctx.callee_has_annotation(&call.callee, "manager"))
.map(|call| ArchDiagnostic::error(
self.name(),
format!(
"accessor '{}' calls manager '{}' — accessors must not depend on the manager layer",
ctx.fn_name, call.callee
),
Some(ctx.fn_name.to_string()),
))
.collect()
}
}
// ── VBD-002: Experience must not directly call Experience ─────────────────────
/// VBD-002 / EBD-001: @experience functions must not call other @experience functions directly.
/// Experiences should communicate via events, not direct calls, to preserve
/// loose coupling between user-facing features.
pub struct ExperienceMustNotCallExperience;
impl ArchRule for ExperienceMustNotCallExperience {
fn name(&self) -> &str { "VBD-002" }
fn description(&self) -> &str {
"@experience must not call another @experience directly (use events instead)"
}
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
if !ctx.has_annotation("experience") {
return vec![];
}
ctx.body_calls
.iter()
.filter(|call| ctx.callee_has_annotation(&call.callee, "experience"))
.map(|call| ArchDiagnostic::error(
self.name(),
format!(
"experience '{}' directly calls experience '{}' — use an event instead",
ctx.fn_name, call.callee
),
Some(ctx.fn_name.to_string()),
))
.collect()
}
}
// ── VBD-003: Experience should return Result<T, E> ────────────────────────────
/// VBD-003: @experience functions should return Result<T, E> for proper error propagation.
pub struct ExperienceShouldReturnResult;
impl ArchRule for ExperienceShouldReturnResult {
fn name(&self) -> &str { "VBD-003" }
fn description(&self) -> &str {
"@experience functions should return Result<T, E> for proper error handling"
}
fn check(&self, ctx: &FnContext<'_>) -> Vec<ArchDiagnostic> {
if !ctx.has_annotation("experience") {
return vec![];
}
// Warn if return type doesn't include "Result"
if !ctx.return_type_name.contains("Result") {
return vec![ArchDiagnostic::warning(
self.name(),
format!(
"experience '{}' returns '{}' instead of Result<T, E> — experiences should propagate errors",
ctx.fn_name, ctx.return_type_name
),
Some(ctx.fn_name.to_string()),
)];
}
vec![]
}
}
@@ -1,448 +0,0 @@
//! Comprehensive tests for the el-arch architectural checker.
use crate::{ArchChecker, ArchDiagnostic, Severity};
// ── Test helpers ──────────────────────────────────────────────────────────────
fn check(src: &str) -> Vec<ArchDiagnostic> {
let tokens = el_lexer::tokenize(src).expect("lex failed");
let prog = el_parser::parse(tokens, src.to_string()).expect("parse failed");
ArchChecker::new().check(&prog)
}
fn errors(src: &str) -> Vec<ArchDiagnostic> {
check(src).into_iter().filter(|d| d.severity == Severity::Error).collect()
}
fn warnings(src: &str) -> Vec<ArchDiagnostic> {
check(src).into_iter().filter(|d| d.severity == Severity::Warning).collect()
}
fn has_rule(diags: &[ArchDiagnostic], rule: &str) -> bool {
diags.iter().any(|d| d.rule == rule)
}
// ── 1. VBD-001: @accessor calling @manager → error ──────────────────────────
#[test]
fn test_accessor_calls_manager_error() {
let src = r#"
@manager fn orchestrate(x: String) -> String { return x }
@accessor fn fetch_user(id: String) -> String { return orchestrate(id) }
"#;
let errs = errors(src);
assert!(!errs.is_empty(), "expected error when @accessor calls @manager");
assert!(has_rule(&errs, "VBD-001"), "expected VBD-001 rule");
}
// ── 2. @accessor calling @accessor → no VBD-001 error ────────────────────────
#[test]
fn test_accessor_calls_accessor_no_error() {
let src = r#"
@accessor fn get_name(id: String) -> String { return id }
@accessor fn get_user(id: String) -> String { return get_name(id) }
"#;
let errs = errors(src);
let vbd001: Vec<_> = errs.iter().filter(|d| d.rule == "VBD-001").collect();
assert!(vbd001.is_empty(), "accessor->accessor should not trigger VBD-001");
}
// ── 3. @experience calling @experience → VBD-002 error ───────────────────────
#[test]
fn test_experience_calls_experience_error() {
let src = r#"
@experience fn checkout(cart: String) -> Result<String, String> { return cart }
@experience fn payment(amount: String) -> Result<String, String> { return checkout(amount) }
"#;
let errs = errors(src);
assert!(!errs.is_empty(), "expected error when @experience calls @experience");
assert!(has_rule(&errs, "VBD-002"), "expected VBD-002 rule");
}
// ── 4. @experience calling non-experience → no VBD-002 error ─────────────────
#[test]
fn test_experience_calls_non_experience_no_error() {
let src = r#"
@accessor fn load_cart(id: String) -> String { return id }
@experience fn checkout(cart: String) -> Result<String, String> { return load_cart(cart) }
"#;
let errs = errors(src);
let vbd002: Vec<_> = errs.iter().filter(|d| d.rule == "VBD-002").collect();
assert!(vbd002.is_empty(), "experience->non-experience should not trigger VBD-002");
}
// ── 5. @public function with activate inside → SEC-001 error ─────────────────
#[test]
fn test_public_fn_with_activate_error() {
let src = r#"
@public fn list_users() -> String {
let users: String = activate User where "all users"
return users
}
"#;
let errs = errors(src);
assert!(!errs.is_empty(), "expected error for @public fn with activate");
assert!(has_rule(&errs, "SEC-001"), "expected SEC-001 rule");
}
// ── 6. @public function without activate → no SEC-001 error ──────────────────
#[test]
fn test_public_fn_without_activate_no_error() {
let src = r#"
@public fn greet(name: String) -> String { return name }
"#;
let errs = errors(src);
let sec001: Vec<_> = errs.iter().filter(|d| d.rule == "SEC-001").collect();
assert!(sec001.is_empty(), "@public without activate should not trigger SEC-001");
}
// ── 7. N+1: activate inside a for loop → GRAPH-001 warning ───────────────────
#[test]
fn test_activate_in_for_loop_n1_warning() {
let src = r#"
fn process_ids(ids: String) -> String {
for id in ids {
let u: String = activate User where "user by id"
}
return "done"
}
"#;
let warns = warnings(src);
assert!(!warns.is_empty(), "expected N+1 warning for activate inside loop");
assert!(has_rule(&warns, "GRAPH-001"), "expected GRAPH-001 rule");
}
// ── 8. activate NOT in loop → no GRAPH-001 warning ───────────────────────────
#[test]
fn test_activate_not_in_loop_no_n1_warning() {
let src = r#"
fn fetch_users() -> String {
let users: String = activate User where "recent users"
return users
}
"#;
let warns = warnings(src);
let graph001: Vec<_> = warns.iter().filter(|d| d.rule == "GRAPH-001").collect();
assert!(graph001.is_empty(), "activate outside loop should not trigger GRAPH-001");
}
// ── 9. Duplicate activate same type → GRAPH-002 warning ──────────────────────
#[test]
fn test_duplicate_activate_same_type_warning() {
let src = r#"
fn inefficient_fn(x: String) -> String {
let a: String = activate User where "active users"
let b: String = activate User where "recent users"
return a
}
"#;
let warns = warnings(src);
assert!(!warns.is_empty(), "expected warning for duplicate activate on same type");
assert!(has_rule(&warns, "GRAPH-002"), "expected GRAPH-002 rule");
}
// ── 10. Different activate types → no GRAPH-002 warning ──────────────────────
#[test]
fn test_different_activate_types_no_duplicate_warning() {
let src = r#"
fn fetch_all(x: String) -> String {
let users: String = activate User where "users"
let orders: String = activate Order where "orders"
return users
}
"#;
let warns = warnings(src);
let graph002: Vec<_> = warns.iter().filter(|d| d.rule == "GRAPH-002").collect();
assert!(graph002.is_empty(), "different activate types should not trigger GRAPH-002");
}
// ── 11. @swarm_agent calling @swarm_agent → SWARM-001 error ──────────────────
#[test]
fn test_swarm_agent_calls_swarm_agent_error() {
let src = r#"
@swarm_agent fn worker_b(x: String) -> String { return x }
@swarm_agent fn worker_a(x: String) -> String { return worker_b(x) }
"#;
let errs = errors(src);
assert!(!errs.is_empty(), "expected error when @swarm_agent calls @swarm_agent");
assert!(has_rule(&errs, "SWARM-001"), "expected SWARM-001 rule");
}
// ── 12. @swarm_agent in isolation → no SWARM-001 error ───────────────────────
#[test]
fn test_swarm_agent_isolation_no_error() {
let src = r#"
fn utility(x: String) -> String { return x }
@swarm_agent fn worker(x: String) -> String { return utility(x) }
"#;
let errs = errors(src);
let swarm001: Vec<_> = errs.iter().filter(|d| d.rule == "SWARM-001").collect();
assert!(swarm001.is_empty(), "isolated @swarm_agent should not trigger SWARM-001");
}
// ── 13. Multiple rules fire on same function ──────────────────────────────────
#[test]
fn test_multiple_rules_fire_same_function() {
// @swarm_agent calling a swarm_agent AND accessing shared state
let src = r#"
@swarm_agent fn peer(x: String) -> String { return x }
fn get_shared_cache(x: String) -> String { return x }
@swarm_agent fn violator(x: String) -> String {
let a: String = peer(x)
let b: String = get_shared_cache(x)
return a
}
"#;
let errs = errors(src);
// Should fire both SWARM-001 (calls peer) and SWARM-003 (calls get_shared_cache)
assert!(errs.len() >= 2, "expected multiple errors: {:?}", errs.iter().map(|e| &e.rule).collect::<Vec<_>>());
}
// ── 14. ArchChecker::has_errors() → true when errors present ─────────────────
#[test]
fn test_has_errors_true_when_errors_present() {
let src = r#"
@manager fn do_manage(x: String) -> String { return x }
@accessor fn bad_fetch(x: String) -> String { return do_manage(x) }
"#;
let diags = check(src);
assert!(ArchChecker::has_errors(&diags), "has_errors should be true");
}
// ── 15. ArchChecker::has_errors() → false when only warnings ─────────────────
#[test]
fn test_has_errors_false_when_only_warnings() {
let src = r#"
@experience fn sign_up(email: String) -> String { return email }
"#;
// sign_up doesn't return Result so triggers VBD-003 warning
let diags = check(src);
let has_warn = diags.iter().any(|d| d.severity == Severity::Warning);
assert!(has_warn || diags.is_empty(), "expected either warnings or empty");
assert!(!ArchChecker::has_errors(&diags.iter().filter(|d| d.severity == Severity::Warning).cloned().collect::<Vec<_>>()), "has_errors should be false for warnings");
}
// ── 16. Clean function → empty diagnostics ────────────────────────────────────
#[test]
fn test_clean_function_no_diagnostics() {
let src = r#"
fn pure_add(a: String, b: String) -> String { return a }
"#;
let diags = check(src);
assert!(diags.is_empty(), "clean function should produce no diagnostics, got: {:?}", diags.iter().map(|d| &d.rule).collect::<Vec<_>>());
}
// ── 17. @engine function → runs without panic ─────────────────────────────────
#[test]
fn test_engine_function_no_panic() {
let src = r#"
@engine fn compute(data: String) -> String { return data }
"#;
// Just verify no panic — engine rules only produce warnings in some impls
let _diags = check(src);
}
// ── 18. sealed { } not in loop → no SEC-002 warning ──────────────────────────
#[test]
fn test_sealed_not_in_loop_no_warning() {
let src = r#"
fn encrypt_data(secret: String) -> String {
sealed { let key: String = secret }
return secret
}
"#;
let warns = warnings(src);
let sec002: Vec<_> = warns.iter().filter(|d| d.rule == "SEC-002").collect();
assert!(sec002.is_empty(), "sealed not in loop should not trigger SEC-002");
}
// ── 19. Rule names are unique ─────────────────────────────────────────────────
#[test]
fn test_rule_names_are_unique() {
let checker = ArchChecker::new();
let mut names = std::collections::HashSet::new();
for rule in checker.rules() {
let inserted = names.insert(rule.name().to_string());
assert!(inserted, "duplicate rule name: {}", rule.name());
}
}
// ── 20. All rules implement ArchRule (compile-time check) ─────────────────────
#[test]
fn test_all_rules_implement_arch_rule() {
use crate::rule::ArchRule;
use crate::rules::{
vbd::{AccessorMustNotCallManager, ExperienceMustNotCallExperience, ExperienceShouldReturnResult},
security::{PublicFnWithActivate, SealedInLoop, AuthnWithoutAuthz},
graph::{N1Detection, DuplicateActivateType},
swarm::{SwarmAgentIsolation, SwarmAgentNoSpawn, SwarmAgentNoSharedState},
};
fn assert_arch_rule<T: ArchRule>() {}
assert_arch_rule::<AccessorMustNotCallManager>();
assert_arch_rule::<ExperienceMustNotCallExperience>();
assert_arch_rule::<ExperienceShouldReturnResult>();
assert_arch_rule::<PublicFnWithActivate>();
assert_arch_rule::<SealedInLoop>();
assert_arch_rule::<AuthnWithoutAuthz>();
assert_arch_rule::<N1Detection>();
assert_arch_rule::<DuplicateActivateType>();
assert_arch_rule::<SwarmAgentIsolation>();
assert_arch_rule::<SwarmAgentNoSpawn>();
assert_arch_rule::<SwarmAgentNoSharedState>();
}
// ── 21. FnContext builds correctly for a decorated function ───────────────────
#[test]
fn test_fn_context_from_decorated_function() {
// Run checker and verify the location field is set to the function name
let src = r#"
@accessor fn fetch_item(id: String) -> String { return id }
"#;
let diags = check(src);
// No violations — but verify the checker doesn't panic and processes it
// (accessor with no calls should produce no errors)
let _: Vec<_> = diags;
}
// ── 22. @experience without Result return type → VBD-003 warning ─────────────
#[test]
fn test_experience_non_result_return_warning() {
let src = r#"
@experience fn show_profile(user: String) -> String { return user }
"#;
let warns = warnings(src);
assert!(!warns.is_empty(), "expected warning for @experience not returning Result");
assert!(has_rule(&warns, "VBD-003"), "expected VBD-003 rule");
}
// ── 23. @authenticate without @authorize on mutation → SEC-003 warning ────────
#[test]
fn test_authenticate_without_authorize_on_mutation_warning() {
let src = r#"
@authenticate fn create_account(email: String) -> String { return email }
"#;
let warns = warnings(src);
assert!(!warns.is_empty(), "expected SEC-003 warning");
assert!(has_rule(&warns, "SEC-003"), "expected SEC-003 rule");
}
// ── 24. Two @experience functions checked independently ───────────────────────
#[test]
fn test_two_experience_functions_checked_independently() {
let src = r#"
@experience fn sign_up(email: String) -> Result<String, String> { return email }
@experience fn log_in(token: String) -> String { return token }
"#;
let warns = warnings(src);
// sign_up returns Result — no VBD-003 for it
// log_in returns String — VBD-003 fires for it
let vbd003: Vec<_> = warns.iter().filter(|d| d.rule == "VBD-003").collect();
assert_eq!(vbd003.len(), 1, "only log_in should trigger VBD-003, got {:?}", vbd003.iter().map(|d| &d.location).collect::<Vec<_>>());
assert!(vbd003[0].location.as_deref() == Some("log_in"), "VBD-003 should point to log_in");
}
// ── 25. Mixed errors and warnings → has_errors() returns true ─────────────────
#[test]
fn test_mixed_errors_and_warnings_has_errors_true() {
let src = r#"
@manager fn manage_data(x: String) -> String { return x }
@accessor fn bad_read(x: String) -> String { return manage_data(x) }
@experience fn display(x: String) -> String { return x }
"#;
let diags = check(src);
assert!(ArchChecker::has_errors(&diags), "should have errors (VBD-001)");
let has_warn = diags.iter().any(|d| d.severity == Severity::Warning);
assert!(has_warn, "should also have warnings (VBD-003 for display)");
}
// ── 26. @swarm_agent calling spawn → SWARM-002 error ─────────────────────────
#[test]
fn test_swarm_agent_no_spawn() {
let src = r#"
fn spawn(agent: String) -> String { return agent }
@swarm_agent fn initiator(x: String) -> String { return spawn(x) }
"#;
let errs = errors(src);
assert!(!errs.is_empty(), "expected SWARM-002 error for calling spawn");
assert!(has_rule(&errs, "SWARM-002"), "expected SWARM-002 rule");
}
// ── 27. @swarm_agent accessing shared state → SWARM-003 error ────────────────
#[test]
fn test_swarm_agent_no_shared_state() {
let src = r#"
fn get_shared_counter(x: String) -> String { return x }
@swarm_agent fn agent(x: String) -> String { return get_shared_counter(x) }
"#;
let errs = errors(src);
assert!(!errs.is_empty(), "expected SWARM-003 error for shared state access");
assert!(has_rule(&errs, "SWARM-003"), "expected SWARM-003 rule");
}
// ── 28. sealed block inside for loop → SEC-002 warning ───────────────────────
#[test]
fn test_sealed_in_for_loop_warning() {
let src = r#"
fn encrypt_many(items: String) -> String {
for item in items {
sealed { let x: String = item }
}
return items
}
"#;
let warns = warnings(src);
assert!(!warns.is_empty(), "expected SEC-002 warning for sealed in loop");
assert!(has_rule(&warns, "SEC-002"), "expected SEC-002 rule");
}
// ── 29. @authenticate with @authorize → no SEC-003 warning ───────────────────
#[test]
fn test_authenticate_with_authorize_no_warning() {
let src = r#"
@authenticate @authorize fn create_post(content: String) -> String { return content }
"#;
let warns = warnings(src);
let sec003: Vec<_> = warns.iter().filter(|d| d.rule == "SEC-003").collect();
assert!(sec003.is_empty(), "@authenticate + @authorize should not trigger SEC-003");
}
// ── 30. @experience returning Result → no VBD-003 warning ────────────────────
#[test]
fn test_experience_returns_result_no_warning() {
let src = r#"
@experience fn register(email: String) -> Result<String, String> { return email }
"#;
let warns = warnings(src);
let vbd003: Vec<_> = warns.iter().filter(|d| d.rule == "VBD-003").collect();
assert!(vbd003.is_empty(), "@experience returning Result should not trigger VBD-003");
}
@@ -1,24 +0,0 @@
[package]
name = "el-build"
description = "Build orchestrator and incremental build system for the Engram language toolchain"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
el-manifest = { path = "../el-manifest" }
el-registry = { path = "../el-registry" }
el-compiler = { path = "../el-compiler" }
el-lexer = { workspace = true }
el-parser = { workspace = true }
el-types = { workspace = true }
el-seal = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
blake3 = { workspace = true }
tokio = { version = "1", features = ["fs", "io-util", "rt", "macros", "process"] }
semver = { version = "1", features = ["serde"] }
[dev-dependencies]
tempfile = "3"
@@ -1,589 +0,0 @@
//! The core build orchestrator.
use std::path::{Path, PathBuf};
use std::time::Instant;
use el_compiler::{Compiler, CompilerOptions, Target};
use el_manifest::{BuildTarget, CrossTarget, Manifest, NativeTarget, SealKeySource};
use el_seal::{DeploymentBinding, SealAlgorithm, SealConfig};
use semver::Version;
use crate::cache::BuildCache;
use crate::error::{BuildError, BuildResult};
// ── Output types ──────────────────────────────────────────────────────────────
/// The output of a single successful build.
#[derive(Debug, Clone)]
pub struct BuildOutput {
/// Path to the produced artifact.
pub artifact_path: PathBuf,
/// Compilation target used.
pub target: BuildTarget,
/// Cross-compilation target, if this was a cross build.
pub cross_target: Option<CrossTarget>,
/// Whether the artifact is quantum-sealed.
pub sealed: bool,
/// Size of the artifact in bytes.
pub size_bytes: u64,
/// Wall-clock compilation time in milliseconds.
pub compile_time_ms: u64,
}
/// A resolved dependency (after registry lookup / path resolution).
#[derive(Debug, Clone)]
pub struct ResolvedDep {
pub name: String,
pub version: Version,
pub source: DepSource,
/// Local path to the package (either downloaded cache or local path dep).
pub path: PathBuf,
}
/// Where a resolved dependency came from.
#[derive(Debug, Clone)]
pub enum DepSource {
/// Downloaded from a registry URL.
Registry(String),
/// A local path dependency.
Path(PathBuf),
/// A git source (not yet implemented; reserved for future use).
Git(String),
}
/// Report returned by `BuildSystem::test()`.
#[derive(Debug, Clone)]
pub struct TestReport {
pub total: usize,
pub passed: usize,
pub failed: usize,
/// Descriptions of each failing test.
pub failures: Vec<String>,
}
impl TestReport {
pub fn success(&self) -> bool {
self.failed == 0
}
}
// ── Build system ──────────────────────────────────────────────────────────────
/// Orchestrates the full build pipeline for an Engram project.
pub struct BuildSystem {
pub manifest: Manifest,
pub workspace_root: PathBuf,
}
impl BuildSystem {
/// Create a new build system from a manifest and workspace root path.
pub fn new(manifest: Manifest, workspace_root: PathBuf) -> Self {
Self { manifest, workspace_root }
}
/// Load from a manifest file, setting the workspace root to the manifest's directory.
pub fn from_manifest_file(manifest_path: &Path) -> BuildResult<Self> {
let manifest = Manifest::from_file(manifest_path)?;
let workspace_root = manifest_path
.parent()
.unwrap_or(manifest_path)
.to_path_buf();
Ok(Self { manifest, workspace_root })
}
// ── Build ─────────────────────────────────────────────────────────────────
/// Full build: resolve deps (skipped for now — no live registry), compile, produce artifact.
///
/// `target` overrides the manifest's `[build].target` setting.
pub async fn build(&self, target: Option<BuildTarget>) -> BuildResult<BuildOutput> {
let effective_target = target.unwrap_or(self.manifest.build.target.clone());
self.build_for_target(&effective_target, None).await
}
/// Build for all cross-compilation targets declared in `[cross]`.
pub async fn build_all_targets(
&self,
) -> BuildResult<Vec<(CrossTarget, BuildOutput)>> {
let mut results = Vec::new();
for cross_target in &self.manifest.cross.targets {
let build_target = self.manifest.build.target.clone();
let output = self.build_for_target(&build_target, Some(cross_target)).await?;
results.push((cross_target.clone(), output));
}
Ok(results)
}
async fn build_for_target(
&self,
build_target: &BuildTarget,
cross_target: Option<&CrossTarget>,
) -> BuildResult<BuildOutput> {
let start = Instant::now();
// Locate entry point
let entry = self.workspace_root.join(&self.manifest.build.entry);
if !entry.exists() {
return Err(BuildError::EntryNotFound(entry.display().to_string()));
}
// Check incremental cache
let mut cache = BuildCache::load(&self.workspace_root);
let rel_entry = self
.manifest
.build
.entry
.to_string_lossy()
.to_string();
let current_hash = BuildCache::hash_file(&entry)?;
let is_cached = cache.is_up_to_date(&rel_entry, &current_hash);
// Build output path
let output_dir = self.workspace_root.join(&self.manifest.build.output);
std::fs::create_dir_all(&output_dir)?;
let artifact_name = artifact_name(
&self.manifest.package.name,
build_target,
cross_target,
);
let artifact_path = output_dir.join(&artifact_name);
if is_cached && artifact_path.exists() {
let size_bytes = std::fs::metadata(&artifact_path)?.len();
return Ok(BuildOutput {
artifact_path,
target: build_target.clone(),
cross_target: cross_target.cloned(),
sealed: matches!(build_target, BuildTarget::Prod),
size_bytes,
compile_time_ms: start.elapsed().as_millis() as u64,
});
}
// Read source (resolving imports recursively)
let source = resolve_imports_recursive(&entry)
.map_err(|e| BuildError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
// Build seal config for prod builds
let seal_config = self.build_seal_config()?;
// Compile
let compiler_target = match build_target {
BuildTarget::Debug => Target::Debug,
BuildTarget::Release => Target::Release,
BuildTarget::Prod => Target::Prod,
};
let opts = CompilerOptions {
target: compiler_target,
output_path: artifact_path.clone(),
source_path: entry.clone(),
engram_db_path: None,
seal_config,
};
let output = Compiler::compile(&source, opts)?;
// Emit diagnostics
for diag in &output.diagnostics {
eprintln!("warning: {diag}");
}
// Write artifact
std::fs::write(&artifact_path, &output.artifact)?;
// Annotate artifact with cross-target triple (stub — in LLVM backend this
// selects the code generation target).
if let Some(ct) = cross_target {
let native = NativeTarget::from_cross(ct);
let annotation_path = artifact_path.with_extension("target");
std::fs::write(&annotation_path, native.triple())?;
}
// Update cache
cache.record(&rel_entry, &current_hash);
cache.save(&self.workspace_root)?;
let size_bytes = std::fs::metadata(&artifact_path)?.len();
let compile_time_ms = start.elapsed().as_millis() as u64;
Ok(BuildOutput {
artifact_path,
target: build_target.clone(),
cross_target: cross_target.cloned(),
sealed: output.sealed,
size_bytes,
compile_time_ms,
})
}
// ── Dependencies ──────────────────────────────────────────────────────────
/// Resolve and download all registry dependencies.
pub async fn resolve_deps(&self) -> BuildResult<Vec<ResolvedDep>> {
let mut resolved = Vec::new();
for (name, dep) in &self.manifest.dependencies {
match dep {
el_manifest::Dependency::Path(path) => {
let abs_path = if path.is_absolute() {
path.clone()
} else {
self.workspace_root.join(path)
};
resolved.push(ResolvedDep {
name: name.clone(),
version: Version::new(0, 0, 0),
source: DepSource::Path(abs_path.clone()),
path: abs_path,
});
}
el_manifest::Dependency::VersionReq(_req) => {
// In a live environment this would call the registry.
// For now, record the dep with the local cache path.
let cache_base = el_registry::cache_dir().join(name);
resolved.push(ResolvedDep {
name: name.clone(),
version: Version::new(0, 0, 0), // placeholder until registry is live
source: DepSource::Registry(
el_registry::DEFAULT_REGISTRY_URL.to_string(),
),
path: cache_base,
});
}
el_manifest::Dependency::Registry { version: _version, registry } => {
let cache_base = el_registry::cache_dir().join(name);
resolved.push(ResolvedDep {
name: name.clone(),
version: Version::new(0, 0, 0),
source: DepSource::Registry(registry.clone()),
path: cache_base,
});
}
}
}
Ok(resolved)
}
// ── Test ──────────────────────────────────────────────────────────────────
/// Discover and run all test files.
pub async fn test(&self) -> BuildResult<TestReport> {
let src_root = self.workspace_root.join("src");
crate::test_runner::run_tests(&src_root, &self.workspace_root).await
}
// ── Format ────────────────────────────────────────────────────────────────
/// Format all source files (stub — delegates to `el-fmt` plugin when available).
pub fn fmt(&self) -> BuildResult<()> {
let sources = BuildCache::collect_sources(&self.workspace_root.join("src"));
for file in &sources {
// TODO: invoke el-fmt plugin or built-in formatter.
let _ = file;
}
println!("fmt: {} source file(s) checked", sources.len());
Ok(())
}
// ── Check ─────────────────────────────────────────────────────────────────
/// Type-check source files without producing artifacts.
pub fn check(&self) -> BuildResult<Vec<String>> {
let entry = self.workspace_root.join(&self.manifest.build.entry);
if !entry.exists() {
return Err(BuildError::EntryNotFound(entry.display().to_string()));
}
let source = resolve_imports_recursive(&entry)
.map_err(|e| BuildError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
let tokens = el_lexer::tokenize(&source)
.map_err(el_compiler::CompileError::Lex)?;
let program = el_parser::parse(tokens, source.clone())
.map_err(el_compiler::CompileError::Parse)?;
let mut checker = el_types::TypeChecker::with_builtins();
let diags = checker.check(&program);
Ok(diags.iter().map(|d| d.message.clone()).collect())
}
// ── Clean ─────────────────────────────────────────────────────────────────
/// Remove build artifacts and the build cache.
pub fn clean(&self) -> BuildResult<()> {
let output_dir = self.workspace_root.join(&self.manifest.build.output);
if output_dir.exists() {
std::fs::remove_dir_all(&output_dir)?;
}
let cache_dir = self.workspace_root.join(".el");
if cache_dir.exists() {
std::fs::remove_dir_all(&cache_dir)?;
}
println!("clean: removed build artifacts and cache");
Ok(())
}
// ── Helpers ───────────────────────────────────────────────────────────────
fn build_seal_config(&self) -> BuildResult<SealConfig> {
let binding = match &self.manifest.build.seal_key {
Some(SealKeySource::EnvVar(var)) => DeploymentBinding::EnvironmentKey(var.clone()),
Some(SealKeySource::File(_)) | Some(SealKeySource::Literal(_)) => {
// For file/literal keys, fall back to machine fingerprint in prod.
DeploymentBinding::None
}
None => DeploymentBinding::None,
};
Ok(SealConfig {
algorithm: SealAlgorithm::Aes256Gcm,
deployment_binding: binding,
})
}
}
/// Resolve `import "path.el"` directives by reading and concatenating source files.
/// Imports are resolved relative to the directory of the importing file.
/// Circular imports are detected via a visited set.
fn resolve_imports_recursive(file: &std::path::Path) -> Result<String, String> {
let mut visited = std::collections::HashSet::new();
resolve_imports_inner(file, &mut visited)
}
fn resolve_imports_inner(
file: &std::path::Path,
visited: &mut std::collections::HashSet<std::path::PathBuf>,
) -> Result<String, String> {
let canonical = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
if visited.contains(&canonical) {
return Ok(String::new()); // circular — skip
}
visited.insert(canonical.clone());
let dir = file.parent().unwrap_or(std::path::Path::new("."));
let source = std::fs::read_to_string(file)
.map_err(|e| format!("cannot read {}: {e}", file.display()))?;
let mut out = String::new();
let mut lines_iter = source.lines().peekable();
while let Some(line) = lines_iter.next() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("import ") {
let rest = rest.trim();
if rest.starts_with('"') && rest.ends_with('"') {
// import "relative/path.el"
let import_path_str = &rest[1..rest.len() - 1];
let import_path = dir.join(import_path_str);
let imported = resolve_imports_inner(&import_path, visited)?;
out.push_str(&imported);
out.push('\n');
} else {
out.push_str(line);
out.push('\n');
}
} else if let Some(rest) = trimmed.strip_prefix("from ") {
// from ModuleName import { ... } — resolve ModuleName.el in the same dir
// Extract the module name (everything before " import")
if let Some(module_part) = rest.split(" import").next() {
let module_name = module_part.trim();
// Only resolve bare identifiers (not quoted paths or dotted names with /)
if !module_name.contains('"') && !module_name.contains('/') && !module_name.is_empty() {
let module_file = format!("{}.el", module_name);
let import_path = dir.join(&module_file);
// Consume any continuation lines of a multi-line import:
// `from X import {\n Y,\n Z,\n}`
// The opening line may or may not contain `{`. If it does not end
// with `}`, skip lines until we see one that ends with `}`.
let import_rest = rest.splitn(2, " import").nth(1).unwrap_or("").trim();
let is_multiline = import_rest.contains('{') && !import_rest.contains('}');
if is_multiline {
// Skip continuation lines until we see `}`
for cont in lines_iter.by_ref() {
if cont.trim().contains('}') {
break;
}
}
}
if import_path.exists() {
// Inline the module, omitting the import line itself
let imported = resolve_imports_inner(&import_path, visited)?;
out.push_str(&imported);
out.push('\n');
} else {
// Module file not found — leave the line for the compiler to handle
out.push_str(line);
out.push('\n');
}
} else {
out.push_str(line);
out.push('\n');
}
} else {
out.push_str(line);
out.push('\n');
}
} else {
out.push_str(line);
out.push('\n');
}
}
Ok(out)
}
fn artifact_name(
pkg_name: &str,
build_target: &BuildTarget,
cross_target: Option<&CrossTarget>,
) -> String {
let ext = match (build_target, cross_target) {
(BuildTarget::Prod, _) => ".sealed",
(_, Some(CrossTarget::Wasm32)) => ".wasm",
_ => ".elc",
};
if let Some(ct) = cross_target {
format!("{pkg_name}-{ct}{ext}")
} else {
format!("{pkg_name}{ext}")
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use el_manifest::{BuildConfig, CrossConfig, PackageInfo};
use semver::Version;
use std::collections::HashMap;
use tempfile::TempDir;
fn temp_dir() -> TempDir {
tempfile::TempDir::new().unwrap()
}
fn simple_manifest(dir: &Path) -> Manifest {
Manifest {
package: PackageInfo {
name: "test-pkg".to_string(),
version: Version::new(0, 1, 0),
description: None,
authors: vec![],
license: None,
edition: "2026".to_string(),
},
dependencies: HashMap::new(),
dev_dependencies: HashMap::new(),
build: BuildConfig {
target: BuildTarget::Debug,
entry: PathBuf::from("src/main.el"),
output: PathBuf::from("dist/"),
seal_key: None,
},
cross: CrossConfig { targets: vec![] },
plugins: HashMap::new(),
app: None,
}
}
#[tokio::test]
async fn test_build_debug() {
let dir = temp_dir();
// Create entry file
let src = dir.path().join("src");
std::fs::create_dir(&src).unwrap();
std::fs::write(src.join("main.el"), b"let x: Int = 42").unwrap();
let manifest = simple_manifest(dir.path());
let bs = BuildSystem::new(manifest, dir.path().to_path_buf());
let output = bs.build(None).await.unwrap();
assert!(output.artifact_path.exists());
assert!(!output.sealed);
assert_eq!(output.target, BuildTarget::Debug);
assert!(output.size_bytes > 0);
}
#[tokio::test]
async fn test_build_incremental_skips_rebuild() {
let dir = temp_dir();
let src = dir.path().join("src");
std::fs::create_dir(&src).unwrap();
std::fs::write(src.join("main.el"), b"let x: Int = 1").unwrap();
let manifest = simple_manifest(dir.path());
let bs = BuildSystem::new(manifest, dir.path().to_path_buf());
let out1 = bs.build(None).await.unwrap();
let t1 = out1.compile_time_ms;
let out2 = bs.build(None).await.unwrap();
// Second build should be very fast (cache hit)
assert_eq!(out1.artifact_path, out2.artifact_path);
}
#[tokio::test]
async fn test_build_missing_entry_errors() {
let dir = temp_dir();
let manifest = simple_manifest(dir.path());
let bs = BuildSystem::new(manifest, dir.path().to_path_buf());
let err = bs.build(None).await.unwrap_err();
assert!(matches!(err, BuildError::EntryNotFound(_)));
}
#[tokio::test]
async fn test_clean() {
let dir = temp_dir();
let src = dir.path().join("src");
std::fs::create_dir(&src).unwrap();
std::fs::write(src.join("main.el"), b"let x = 1").unwrap();
let manifest = simple_manifest(dir.path());
let bs = BuildSystem::new(manifest, dir.path().to_path_buf());
bs.build(None).await.unwrap();
bs.clean().unwrap();
let dist = dir.path().join("dist");
assert!(!dist.exists());
}
#[tokio::test]
async fn test_resolve_path_dep() {
let dir = temp_dir();
let mut manifest = simple_manifest(dir.path());
manifest.dependencies.insert(
"local-lib".to_string(),
el_manifest::Dependency::Path(PathBuf::from("../local-lib")),
);
let bs = BuildSystem::new(manifest, dir.path().to_path_buf());
let deps = bs.resolve_deps().await.unwrap();
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].name, "local-lib");
assert!(matches!(deps[0].source, DepSource::Path(_)));
}
#[test]
fn test_artifact_name_debug() {
let name = artifact_name("my-pkg", &BuildTarget::Debug, None);
assert_eq!(name, "my-pkg.elc");
}
#[test]
fn test_artifact_name_prod() {
let name = artifact_name("my-pkg", &BuildTarget::Prod, None);
assert_eq!(name, "my-pkg.sealed");
}
#[test]
fn test_artifact_name_wasm_cross() {
let name = artifact_name("my-pkg", &BuildTarget::Debug, Some(&CrossTarget::Wasm32));
assert_eq!(name, "my-pkg-wasm32.wasm");
}
#[test]
fn test_artifact_name_linux_cross() {
let name = artifact_name("my-pkg", &BuildTarget::Release, Some(&CrossTarget::X86_64Linux));
assert_eq!(name, "my-pkg-x86_64-linux.elc");
}
}
@@ -1,164 +0,0 @@
//! Incremental build cache.
//!
//! Stores BLAKE3 hashes of source files in `.el/build-cache.json` so the
//! build system can skip recompiling files that haven't changed.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// The on-disk structure of the build cache.
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct BuildCache {
/// Map of file path (relative to workspace root) → BLAKE3 hex hash.
pub file_hashes: HashMap<String, String>,
}
impl BuildCache {
/// Load the build cache from `.el/build-cache.json`.
///
/// Returns an empty cache if the file doesn't exist yet.
pub fn load(workspace_root: &Path) -> Self {
let path = cache_path(workspace_root);
if !path.exists() {
return Self::default();
}
let text = std::fs::read_to_string(&path).unwrap_or_default();
serde_json::from_str(&text).unwrap_or_default()
}
/// Persist the cache back to disk.
pub fn save(&self, workspace_root: &Path) -> Result<(), std::io::Error> {
let path = cache_path(workspace_root);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(self)?;
std::fs::write(&path, json)
}
/// Hash a single file and return the BLAKE3 hex string.
pub fn hash_file(path: &Path) -> Result<String, std::io::Error> {
let bytes = std::fs::read(path)?;
Ok(hex_encode(blake3::hash(&bytes).as_bytes()))
}
/// Returns `true` if the file's current hash matches the cached hash.
pub fn is_up_to_date(&self, rel_path: &str, current_hash: &str) -> bool {
self.file_hashes
.get(rel_path)
.map(|cached| cached == current_hash)
.unwrap_or(false)
}
/// Update the cached hash for a file.
pub fn record(&mut self, rel_path: impl Into<String>, hash: impl Into<String>) {
self.file_hashes.insert(rel_path.into(), hash.into());
}
/// Collect all `.el` source files under a directory recursively.
pub fn collect_sources(root: &Path) -> Vec<PathBuf> {
let mut sources = Vec::new();
collect_el_files(root, &mut sources);
sources.sort();
sources
}
}
fn cache_path(workspace_root: &Path) -> PathBuf {
workspace_root.join(".el").join("build-cache.json")
}
fn collect_el_files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
// Skip hidden dirs and build outputs
let name = path.file_name().unwrap_or_default().to_string_lossy();
if name.starts_with('.') || name == "dist" || name == "target" {
continue;
}
collect_el_files(&path, out);
} else if path.extension().map(|e| e == "el").unwrap_or(false) {
out.push(path);
}
}
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
fn temp_dir() -> TempDir {
tempfile::TempDir::new().unwrap()
}
#[test]
fn test_cache_empty_by_default() {
let dir = temp_dir();
let cache = BuildCache::load(dir.path());
assert!(cache.file_hashes.is_empty());
}
#[test]
fn test_cache_save_and_load() {
let dir = temp_dir();
let mut cache = BuildCache::default();
cache.record("src/main.el", "abc123");
cache.save(dir.path()).unwrap();
let loaded = BuildCache::load(dir.path());
assert_eq!(loaded.file_hashes.get("src/main.el").map(|s| s.as_str()), Some("abc123"));
}
#[test]
fn test_is_up_to_date() {
let mut cache = BuildCache::default();
cache.record("src/main.el", "deadbeef");
assert!(cache.is_up_to_date("src/main.el", "deadbeef"));
assert!(!cache.is_up_to_date("src/main.el", "different"));
assert!(!cache.is_up_to_date("src/other.el", "deadbeef"));
}
#[test]
fn test_hash_file() {
let dir = temp_dir();
let path = dir.path().join("test.el");
std::fs::write(&path, b"let x = 1").unwrap();
let hash1 = BuildCache::hash_file(&path).unwrap();
let hash2 = BuildCache::hash_file(&path).unwrap();
assert_eq!(hash1, hash2); // deterministic
std::fs::write(&path, b"let x = 2").unwrap();
let hash3 = BuildCache::hash_file(&path).unwrap();
assert_ne!(hash1, hash3); // different content → different hash
}
#[test]
fn test_collect_sources() {
let dir = temp_dir();
let src = dir.path().join("src");
std::fs::create_dir(&src).unwrap();
std::fs::write(src.join("main.el"), b"fn main() {}").unwrap();
std::fs::write(src.join("lib.el"), b"fn helper() {}").unwrap();
std::fs::write(src.join("README.md"), b"# README").unwrap();
let sources = BuildCache::collect_sources(dir.path());
assert_eq!(sources.len(), 2);
assert!(sources.iter().any(|p| p.file_name().unwrap() == "main.el"));
assert!(sources.iter().any(|p| p.file_name().unwrap() == "lib.el"));
}
}
@@ -1,35 +0,0 @@
//! Build system error types.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum BuildError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("manifest error: {0}")]
Manifest(#[from] el_manifest::ManifestError),
#[error("registry error: {0}")]
Registry(#[from] el_registry::RegistryError),
#[error("compile error: {0}")]
Compile(#[from] el_compiler::CompileError),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("plugin error: {0}")]
Plugin(#[from] crate::plugin::PluginError),
#[error("entry point not found: {0}")]
EntryNotFound(String),
#[error("build failed: {0}")]
BuildFailed(String),
#[error("test failed: {count} test(s) failed")]
TestsFailed { count: usize },
}
pub type BuildResult<T> = Result<T, BuildError>;
@@ -1,30 +0,0 @@
//! el-build — Build orchestrator for the Engram language toolchain.
//!
//! Reads `manifest.el`, resolves dependencies, compiles source files, and produces
//! artifacts. Supports incremental builds via BLAKE3 file hashes stored in
//! `.el/build-cache.json`.
//!
//! # Usage
//! ```rust,no_run
//! use el_build::BuildSystem;
//! use el_manifest::Manifest;
//!
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! let manifest = Manifest::from_file(std::path::Path::new("manifest.el"))?;
//! let bs = BuildSystem::new(manifest, std::env::current_dir()?);
//! let output = bs.build(None).await?;
//! println!("artifact: {}", output.artifact_path.display());
//! # Ok(())
//! # }
//! ```
mod build;
mod cache;
mod error;
mod plugin;
mod test_runner;
pub use build::{BuildOutput, BuildSystem, DepSource, ResolvedDep, TestReport};
pub use cache::BuildCache;
pub use error::{BuildError, BuildResult};
pub use plugin::{CompilerPlugin, PluginError, PluginRegistry};
@@ -1,275 +0,0 @@
//! Compiler plugin system.
//!
//! Plugins are Rust dynamic libraries (`.dylib` / `.so`) that implement the
//! [`CompilerPlugin`] trait. They are loaded at compile time and receive hooks
//! at each stage of the compilation pipeline.
//!
//! # Lifecycle hooks
//! 1. `on_ast` — called after parsing, before type checking
//! 2. `on_typed_ast` — called after type checking, before codegen
//! 3. `on_bytecode` — called after codegen, before sealing
//!
//! # Writing a plugin
//! ```rust,ignore
//! use el_build::CompilerPlugin;
//! use el_parser::Program;
//! use el_types::TypeEnv;
//!
//! pub struct MyPlugin;
//!
//! impl CompilerPlugin for MyPlugin {
//! fn name(&self) -> &str { "my-plugin" }
//! fn version(&self) -> &str { "0.1.0" }
//! fn on_ast(&self, _program: &mut Program) -> Result<(), el_build::PluginError> { Ok(()) }
//! fn on_typed_ast(&self, _program: &Program, _types: &TypeEnv) -> Result<(), el_build::PluginError> { Ok(()) }
//! fn on_bytecode(&self, _bytecode: &mut Vec<u8>) -> Result<(), el_build::PluginError> { Ok(()) }
//! }
//! ```
use std::path::Path;
use thiserror::Error;
use el_manifest::Manifest;
// ── Error ─────────────────────────────────────────────────────────────────────
#[derive(Debug, Error)]
pub enum PluginError {
#[error("plugin '{name}' ast hook failed: {reason}")]
AstHookFailed { name: String, reason: String },
#[error("plugin '{name}' typed-ast hook failed: {reason}")]
TypedAstHookFailed { name: String, reason: String },
#[error("plugin '{name}' bytecode hook failed: {reason}")]
BytecodeHookFailed { name: String, reason: String },
#[error("plugin '{name}' not found in {dir}")]
NotFound { name: String, dir: String },
#[error("plugin loading is not supported on this platform")]
PlatformUnsupported,
}
// ── Plugin trait ──────────────────────────────────────────────────────────────
/// The interface that all compiler plugins must implement.
///
/// Plugins receive three optional hooks during compilation. Each hook may
/// mutate the data it receives (AST, bytecode) or read it for analysis.
pub trait CompilerPlugin: Send + Sync {
/// The plugin's canonical name (matches its key in `manifest.el [plugins]`).
fn name(&self) -> &str;
/// The plugin's version string.
fn version(&self) -> &str;
/// Called after parsing, before type checking.
///
/// Implementations may add synthetic AST nodes, remove nodes, or
/// record observations. Mutation is allowed.
fn on_ast(&self, program: &mut el_parser::Program) -> Result<(), PluginError>;
/// Called after type checking, before code generation.
///
/// The AST is immutable at this stage. Implementations may inspect the
/// resolved types for documentation generation, linting, etc.
fn on_typed_ast(
&self,
program: &el_parser::Program,
types: &el_types::TypeEnv,
) -> Result<(), PluginError>;
/// Called after code generation, before sealing.
///
/// Implementations may inspect or transform the raw bytecode bytes.
fn on_bytecode(&self, bytecode: &mut Vec<u8>) -> Result<(), PluginError>;
}
// ── Registry ──────────────────────────────────────────────────────────────────
/// A registry of loaded compiler plugins.
pub struct PluginRegistry {
plugins: Vec<Box<dyn CompilerPlugin>>,
}
impl PluginRegistry {
/// Create an empty registry.
pub fn new() -> Self {
Self {
plugins: Vec::new(),
}
}
/// Register a plugin directly (used in tests and for built-in plugins).
pub fn register(&mut self, plugin: Box<dyn CompilerPlugin>) {
self.plugins.push(plugin);
}
/// Load all plugins listed in the manifest's `[plugins]` section.
///
/// Plugins are expected to be `.dylib` (macOS) / `.so` (Linux) files
/// in `plugin_dir`. Dynamic loading is marked as a TODO — for now, this
/// is a no-op stub that validates the plugin manifest entries.
pub fn load_from_manifest(
&mut self,
manifest: &Manifest,
plugin_dir: &Path,
) -> Result<(), PluginError> {
for name in manifest.plugins.keys() {
// TODO(LLVM backend): use `libloading` crate to dlopen the .dylib/.so,
// look up the `engram_plugin_init` symbol, call it, and register the
// returned Box<dyn CompilerPlugin>.
//
// Extension point:
// let lib = unsafe { libloading::Library::new(dylib_path) }?;
// let init: Symbol<fn() -> Box<dyn CompilerPlugin>> =
// unsafe { lib.get(b"engram_plugin_init") }?;
// self.plugins.push(init());
let dylib_name = if cfg!(target_os = "macos") {
format!("lib{name}.dylib")
} else if cfg!(target_os = "windows") {
format!("{name}.dll")
} else {
format!("lib{name}.so")
};
let dylib_path = plugin_dir.join(&dylib_name);
if !dylib_path.exists() {
// Not treating missing plugins as fatal during the stub phase.
// In production, this would be an error.
eprintln!(
"warning: plugin '{name}' not found at {} (dynamic loading is a TODO)",
dylib_path.display()
);
}
}
Ok(())
}
/// Run the `on_ast` hook for all registered plugins.
pub fn run_ast_hooks(&self, program: &mut el_parser::Program) -> Result<(), PluginError> {
for plugin in &self.plugins {
plugin.on_ast(program)?;
}
Ok(())
}
/// Run the `on_typed_ast` hook for all registered plugins.
pub fn run_typed_hooks(
&self,
program: &el_parser::Program,
types: &el_types::TypeEnv,
) -> Result<(), PluginError> {
for plugin in &self.plugins {
plugin.on_typed_ast(program, types)?;
}
Ok(())
}
/// Run the `on_bytecode` hook for all registered plugins.
pub fn run_bytecode_hooks(&self, bytecode: &mut Vec<u8>) -> Result<(), PluginError> {
for plugin in &self.plugins {
plugin.on_bytecode(bytecode)?;
}
Ok(())
}
/// Number of plugins currently registered.
pub fn len(&self) -> usize {
self.plugins.len()
}
/// Whether no plugins are registered.
pub fn is_empty(&self) -> bool {
self.plugins.is_empty()
}
}
impl Default for PluginRegistry {
fn default() -> Self {
Self::new()
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// A no-op test plugin.
struct NopPlugin;
impl CompilerPlugin for NopPlugin {
fn name(&self) -> &str { "nop-plugin" }
fn version(&self) -> &str { "0.1.0" }
fn on_ast(&self, _program: &mut el_parser::Program) -> Result<(), PluginError> { Ok(()) }
fn on_typed_ast(&self, _p: &el_parser::Program, _t: &el_types::TypeEnv) -> Result<(), PluginError> { Ok(()) }
fn on_bytecode(&self, _b: &mut Vec<u8>) -> Result<(), PluginError> { Ok(()) }
}
/// A plugin that appends a byte to the bytecode (to verify mutation).
struct MutatingPlugin;
impl CompilerPlugin for MutatingPlugin {
fn name(&self) -> &str { "mutating-plugin" }
fn version(&self) -> &str { "1.0.0" }
fn on_ast(&self, _program: &mut el_parser::Program) -> Result<(), PluginError> { Ok(()) }
fn on_typed_ast(&self, _p: &el_parser::Program, _t: &el_types::TypeEnv) -> Result<(), PluginError> { Ok(()) }
fn on_bytecode(&self, bytecode: &mut Vec<u8>) -> Result<(), PluginError> {
bytecode.push(0xFF); // marker byte
Ok(())
}
}
#[test]
fn test_empty_registry() {
let reg = PluginRegistry::new();
assert!(reg.is_empty());
assert_eq!(reg.len(), 0);
}
#[test]
fn test_register_plugin() {
let mut reg = PluginRegistry::new();
reg.register(Box::new(NopPlugin));
assert_eq!(reg.len(), 1);
assert!(!reg.is_empty());
}
#[test]
fn test_bytecode_hook_mutates() {
let mut reg = PluginRegistry::new();
reg.register(Box::new(MutatingPlugin));
let mut bytecode = vec![0x01, 0x02, 0x03];
reg.run_bytecode_hooks(&mut bytecode).unwrap();
assert_eq!(bytecode.last(), Some(&0xFF));
assert_eq!(bytecode.len(), 4);
}
#[test]
fn test_multiple_plugins_run_in_order() {
let mut reg = PluginRegistry::new();
reg.register(Box::new(MutatingPlugin));
reg.register(Box::new(MutatingPlugin));
let mut bytecode = vec![0x01];
reg.run_bytecode_hooks(&mut bytecode).unwrap();
// Two MutatingPlugins → two 0xFF bytes appended
assert_eq!(bytecode, vec![0x01, 0xFF, 0xFF]);
}
#[test]
fn test_nop_plugin_hooks_succeed() {
let mut reg = PluginRegistry::new();
reg.register(Box::new(NopPlugin));
let mut bytecode = vec![0x00];
assert!(reg.run_bytecode_hooks(&mut bytecode).is_ok());
assert_eq!(bytecode.len(), 1); // NopPlugin does not mutate
}
}
@@ -1,95 +0,0 @@
//! Test runner — compiles and runs `*.test.el` / `*_test.el` files.
use std::path::Path;
use crate::build::TestReport;
use crate::error::BuildResult;
/// Discover and run all test files under `src_root`.
///
/// Test files must end in `.test.el` or `_test.el`.
/// This is a stub implementation — in the full toolchain the test files
/// are compiled to debug bytecode and run against the interpreter with
/// assertions captured.
pub async fn run_tests(src_root: &Path, workspace_root: &Path) -> BuildResult<TestReport> {
let test_files = discover_test_files(src_root);
let total = test_files.len();
let mut passed = 0usize;
let mut failed = 0usize;
let mut failures = Vec::new();
for file in &test_files {
let rel = file
.strip_prefix(workspace_root)
.unwrap_or(file)
.display()
.to_string();
let source = match std::fs::read_to_string(file) {
Ok(s) => s,
Err(e) => {
failures.push(format!("{rel}: io error: {e}"));
failed += 1;
continue;
}
};
// Compile to debug bytecode — if compilation fails, test fails.
let opts = el_compiler::CompilerOptions {
target: el_compiler::Target::Debug,
source_path: file.clone(),
..Default::default()
};
match el_compiler::Compiler::compile(&source, opts) {
Ok(output) => {
if output.diagnostics.iter().any(|d| d.contains("error")) {
failures.push(format!("{rel}: compile error"));
failed += 1;
} else {
passed += 1;
}
}
Err(e) => {
failures.push(format!("{rel}: {e}"));
failed += 1;
}
}
}
Ok(TestReport {
total,
passed,
failed,
failures,
})
}
fn discover_test_files(root: &Path) -> Vec<std::path::PathBuf> {
let mut files = Vec::new();
collect_test_files(root, &mut files);
files.sort();
files
}
fn collect_test_files(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let name = path.file_name().unwrap_or_default().to_string_lossy();
if !name.starts_with('.') && name != "dist" && name != "target" {
collect_test_files(&path, out);
}
} else {
let name = path.file_name().unwrap_or_default().to_string_lossy();
if (name.ends_with(".test.el") || name.ends_with("_test.el"))
&& path.extension().map(|e| e == "el").unwrap_or(false)
{
out.push(path);
}
}
}
}
@@ -1,15 +0,0 @@
[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 }
@@ -1,302 +0,0 @@
//! 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>),
/// A key-value map — used for Map<K,V> literals.
/// Stored as a Vec of pairs to keep ordering and remain Serialize-friendly.
Map(Vec<(String, Value)>),
/// A Result<T,E> value — Ok variant.
ResultOk(Box<Value>),
/// A Result<T,E> value — Err variant.
ResultErr(Box<Value>),
/// A struct instance: type name + ordered field name-value pairs.
Struct { type_name: String, fields: Vec<(String, Value)> },
}
impl std::fmt::Display for Value {
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(", "))
}
Value::Map(pairs) => {
let items: Vec<_> = pairs.iter().map(|(k, v)| format!("{k}: {v}")).collect();
write!(f, "{{{}}}", items.join(", "))
}
Value::ResultOk(v) => write!(f, "Ok({v})"),
Value::ResultErr(e) => write!(f, "Err({e})"),
Value::Struct { type_name, fields } => {
let fs: Vec<_> = fields.iter().map(|(k, v)| format!("{k}: {v}")).collect();
write!(f, "{type_name} {{ {} }}", fs.join(", "))
}
}
}
}
/// 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,
Mod,
BitAnd,
BitOr,
BitXor,
BitNot,
Shl,
Shr,
// ── 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,
/// Build a Map from the top N key-value pairs on the stack
/// (keys are strings pushed as Str, values follow each key).
BuildMap(u32),
/// Build a list from the top N items on the stack.
BuildList(u32),
/// Build a struct instance: pop N field values (named by fields in order), push Map.
BuildStruct { type_name: String, fields: Vec<String> },
/// Set a field on the Map on top of stack.
SetField(String),
// ── 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,
/// `reason "query"` — call soma AI inference endpoint.
Reason { query: String },
/// `parallel { name: expr, ... }` — spawn entries concurrently.
/// Each entry is a (name, entry_ip) pair where entry_ip is the bytecode offset.
Parallel { entries: Vec<(String, usize)> },
/// Begin a trace region (debug mode: record start time).
TraceBegin { label: String },
/// End a trace region (debug mode: print elapsed).
TraceEnd { label: String },
/// Contract check: if top of stack is falsy, panic with message.
ContractCheck { message: String },
/// Deploy: POST to soma deployment API.
DeployFn { fn_name: String, route: String, target: String },
}
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::Mod => write!(f, "MOD"),
Bytecode::BitAnd => write!(f, "BITAND"),
Bytecode::BitOr => write!(f, "BITOR"),
Bytecode::BitXor => write!(f, "BITXOR"),
Bytecode::BitNot => write!(f, "BITNOT"),
Bytecode::Shl => write!(f, "SHL"),
Bytecode::Shr => write!(f, "SHR"),
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::BuildMap(n) => write!(f, "BUILDMAP {n}"),
Bytecode::BuildList(n) => write!(f, "BUILDLIST {n}"),
Bytecode::BuildStruct { type_name, fields } => {
write!(f, "BUILDSTRUCT {type_name} [{}]", fields.join(", "))
}
Bytecode::SetField(n) => write!(f, "SETFIELD {n}"),
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"),
Bytecode::Reason { query } => write!(f, "REASON \"{query}\""),
Bytecode::Parallel { entries } => {
let names: Vec<_> = entries.iter().map(|(n, ip)| format!("{n}@{ip}")).collect();
write!(f, "PARALLEL [{}]", names.join(", "))
}
Bytecode::TraceBegin { label } => write!(f, "TRACE_BEGIN \"{label}\""),
Bytecode::TraceEnd { label } => write!(f, "TRACE_END \"{label}\""),
Bytecode::ContractCheck { message } => write!(f, "CONTRACT_CHECK \"{message}\""),
Bytecode::DeployFn { fn_name, route, target } => {
write!(f, "DEPLOY {fn_name} -> {route} via {target}")
}
}
}
}
/// 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())
}
// ── ELVM binary container format ──────────────────────────────────────────────
//
// Header:
// magic: [u8; 4] = b"ELVM"
// version: [u8; 4] (little-endian u32, currently 1)
// length: [u8; 8] (little-endian u64, byte length of the payload that follows)
// Payload:
// JSON-serialized Vec<Bytecode> (same encoding as serialize_bytecode)
//
// The JSON payload is intentionally kept so that existing tooling can inspect
// .elc files by stripping the 16-byte header. Future versions may switch to a
// denser binary encoding while bumping the version field.
/// Magic bytes that identify an El bytecode container.
pub const ELVM_MAGIC: &[u8; 4] = b"ELVM";
/// Current container format version.
pub const ELVM_VERSION: u32 = 1;
/// Total header size in bytes (magic + version + length).
pub const ELVM_HEADER_SIZE: usize = 16;
/// Wrap serialized bytecode in the ELVM binary container.
///
/// Returns the full `.elc` file bytes: 16-byte header + JSON payload.
pub fn wrap_elvm(instructions: &[Bytecode]) -> Result<Vec<u8>, String> {
let payload = serialize_bytecode(instructions)?;
let length = payload.len() as u64;
let mut out = Vec::with_capacity(ELVM_HEADER_SIZE + payload.len());
out.extend_from_slice(ELVM_MAGIC);
out.extend_from_slice(&ELVM_VERSION.to_le_bytes());
out.extend_from_slice(&length.to_le_bytes());
out.extend_from_slice(&payload);
Ok(out)
}
/// Unwrap and deserialize an ELVM binary container.
///
/// Accepts either:
/// - A full ELVM container (starts with `ELVM` magic), or
/// - Raw JSON bytes (legacy `.elc` format — no header).
pub fn unwrap_elvm(bytes: &[u8]) -> Result<Vec<Bytecode>, String> {
if bytes.starts_with(ELVM_MAGIC) {
// Binary container: skip header, deserialize payload.
if bytes.len() < ELVM_HEADER_SIZE {
return Err("truncated ELVM header".to_string());
}
let version = u32::from_le_bytes(
bytes[4..8].try_into().map_err(|_| "bad version field".to_string())?
);
if version != ELVM_VERSION {
return Err(format!("unsupported ELVM version {version} (expected {ELVM_VERSION})"));
}
let length = u64::from_le_bytes(
bytes[8..16].try_into().map_err(|_| "bad length field".to_string())?
) as usize;
let payload_end = ELVM_HEADER_SIZE + length;
if bytes.len() < payload_end {
return Err(format!(
"truncated ELVM payload: expected {length} bytes, got {}",
bytes.len().saturating_sub(ELVM_HEADER_SIZE)
));
}
deserialize_bytecode(&bytes[ELVM_HEADER_SIZE..payload_end])
} else {
// Legacy JSON-only format (no header).
deserialize_bytecode(bytes)
}
}
impl Bytecode {
/// Deserialize a bytecode slice — handles both ELVM container and raw JSON.
pub fn deserialize_all(bytes: &[u8]) -> Result<Vec<Bytecode>, String> {
unwrap_elvm(bytes)
}
}
@@ -1,736 +0,0 @@
//! Code generator: walks the AST and emits bytecode instructions.
use el_parser::{BinOp, Expr, JsxAttrValue, 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 ─────────────────────────────────────────────
/// Generate a statement in tail position (the last stmt of a block).
/// Expression statements leave their value on the stack instead of popping it.
fn gen_stmt_tail(&mut self, stmt: &Stmt) -> CompileResult<()> {
match stmt {
Stmt::Expr(expr, _) => {
// In tail position, leave the value on the stack.
self.gen_expr(expr)?;
// If-without-else leaves nothing on stack (it pops internally);
// push Nil so we always have exactly one return value.
if matches!(expr, Expr::If { else_: None, .. }) {
self.emit(Bytecode::Push(Value::Nil));
}
}
Stmt::Return(expr, _) => {
self.gen_expr(expr)?;
self.emit(Bytecode::Return);
}
// All other statement kinds behave the same as non-tail; push Nil as block value.
other => {
self.gen_stmt(other)?;
self.emit(Bytecode::Push(Value::Nil));
}
}
Ok(())
}
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)?;
// Always discard the expression result in statement position.
// Expr::Block is NOT special-cased: even a block expression used
// as a statement should have its result discarded.
// Note: Expr::If { else_: Some(_) } leaves a value on stack from
// both branches (via gen_stmt_tail on the last block statement),
// so it must be popped here too.
// If-without-else already pops internally (see gen_expr for If),
// so we only skip the extra Pop for that case.
let needs_pop = match expr {
Expr::If { else_: None, .. } => false, // already handled internally
_ => true,
};
if needs_pop {
self.emit(Bytecode::Pop);
}
}
Stmt::FnDef { name, params, body, requires, .. } => {
// 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()));
}
// Emit contract check if `requires` is present
if let Some(req_expr) = requires {
self.gen_expr(req_expr)?;
self.emit(Bytecode::ContractCheck {
message: format!("contract violation in fn '{name}': requires clause failed"),
});
}
// All statements except the last use gen_stmt (which pops expr results).
// The last statement uses gen_stmt_tail so that the final expression
// value stays on the stack as the function's implicit return value.
let body_len = body.len();
for (i, s) in body.iter().enumerate() {
if i + 1 == body_len {
self.gen_stmt_tail(s)?;
} else {
self.gen_stmt(s)?;
}
}
// If the body is empty, return Nil.
if body_len == 0 {
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::While { condition, body, .. } => {
// Codegen for `while <condition> { <body> }`:
// loop_start:
// [condition]
// JumpIfNot(done)
// [body]
// Jump(loop_start)
// done:
let loop_start = self.current_idx();
self.gen_expr(condition)?;
let to_done = self.emit(Bytecode::JumpIfNot(0)); // patched to done
for s in body {
self.gen_stmt(s)?;
}
let back_jump = self.emit(Bytecode::Jump(0)); // patched to loop_start
let done = self.current_idx();
self.patch_jump(to_done, done);
self.patch_jump(back_jump, loop_start);
}
Stmt::Retry { count, body, fallback, .. } => {
// Codegen for retry N times:
// counter = N
// loop_start:
// if counter <= 0 goto fallback
// decrement counter
// [body]
// goto done
// fallback:
// [fallback_body]
// done:
let counter_name = format!("__retry_counter_{}__", self.current_idx());
// Initialize counter
self.gen_expr(count)?;
self.emit(Bytecode::StoreLocal(counter_name.clone()));
// Loop start: check counter > 0
let loop_start = self.current_idx();
self.emit(Bytecode::LoadLocal(counter_name.clone()));
self.emit(Bytecode::Push(Value::Int(0)));
self.emit(Bytecode::Gt);
let to_fallback = self.emit(Bytecode::JumpIfNot(0)); // patched to fallback
// Decrement counter
self.emit(Bytecode::LoadLocal(counter_name.clone()));
self.emit(Bytecode::Push(Value::Int(1)));
self.emit(Bytecode::Sub);
self.emit(Bytecode::StoreLocal(counter_name.clone()));
// Execute body
for s in body {
self.gen_stmt(s)?;
}
// Body succeeded — jump to done
let to_done = self.emit(Bytecode::Jump(0));
// Fallback
let fallback_start = self.current_idx();
self.patch_jump(to_fallback, fallback_start);
if let Some(fb_body) = fallback {
for s in fb_body {
self.gen_stmt(s)?;
}
}
let done = self.current_idx();
self.patch_jump(to_done, done);
// Note: in this simple model the body always "succeeds".
// A real retry would need exception-like control flow.
// For the retry-loop semantic, also add a back-jump that
// jumps back to loop_start after each body execution would
// require adding another jump before `to_done`. This design
// runs the body once then exits — which is correct for
// "success on first try" semantics in a pure-fn language.
}
Stmt::Deploy { fn_name, route, target, .. } => {
self.emit(Bytecode::DeployFn {
fn_name: fn_name.clone(),
route: route.clone(),
target: target.clone(),
});
}
Stmt::TypeDef { .. } | Stmt::EnumDef { .. } => {
// Type and enum definitions are compile-time only; no runtime code.
}
// Test-related statements — skipped during normal compilation.
// The el-test crate walks the AST directly rather than running compiled bytecode.
Stmt::TestDef { .. } | Stmt::Seed(..) | Stmt::Assert(..) => {}
// Component definition: generate a function that renders the component.
// The function is registered under "__component_<Name>" and the template
// is emitted as a nested function body.
Stmt::ComponentDef { name, state, methods, template, .. } => {
let fn_name = format!("__component_{name}");
let skip_jump = self.emit(Bytecode::Jump(0));
// Emit component methods (each has its own Jump/body/Register pattern)
for m in methods {
self.gen_stmt(m)?;
}
// Record where the template starts — AFTER all method bodies.
// This is the real entry point for calling this component.
let template_start = self.current_idx();
// Initialise state fields as locals (so template expressions can load them)
for field in state {
if let Some(default) = &field.default {
self.gen_expr(default)?;
} else {
self.emit(Bytecode::Push(Value::Nil));
}
self.emit(Bytecode::StoreLocal(field.name.clone()));
}
// Emit template as the return value
self.gen_expr(template)?;
self.emit(Bytecode::Return);
let after = self.current_idx();
self.patch_jump(skip_jump, after);
// entry_point is the first instruction of the template body.
let entry_point = template_start;
self.emit(Bytecode::Push(Value::Int(entry_point as i64)));
self.emit(Bytecode::StoreLocal(format!("__fn_{fn_name}")));
// Also register the component name directly so `<ComponentName />` works
self.emit(Bytecode::Push(Value::Int(entry_point as i64)));
self.emit(Bytecode::StoreLocal(format!("__fn_{name}")));
}
// New statement kinds — no runtime code emitted.
_ => {}
}
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 } => {
// NullCoalesce (`a ?? b`) is short-circuit: if `a` is truthy, use it; else `b`.
// Bytecode: eval a, Dup, JumpIf(skip), Pop, eval b, skip:
if matches!(op, BinOp::NullCoalesce) {
self.gen_expr(left)?;
self.emit(Bytecode::Dup);
let skip = self.emit(Bytecode::JumpIf(0)); // patched below
self.emit(Bytecode::Pop); // discard the falsy `a`
self.gen_expr(right)?;
let after = self.current_idx();
self.patch_jump(skip, after);
return Ok(());
}
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,
BinOp::Mod => Bytecode::Mod,
BinOp::BitAnd => Bytecode::BitAnd,
BinOp::BitOr => Bytecode::BitOr,
BinOp::BitXor => Bytecode::BitXor,
BinOp::Shl => Bytecode::Shl,
BinOp::Shr => Bytecode::Shr,
BinOp::NullCoalesce => unreachable!("handled above"),
};
self.emit(instr);
}
Expr::UnaryNot(inner) => {
self.gen_expr(inner)?;
self.emit(Bytecode::Not);
}
Expr::UnaryBitNot(inner) => {
self.gen_expr(inner)?;
self.emit(Bytecode::BitNot);
}
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) => {
// Strip leading `@` from syscall/builtin names:
// `@http_get` → `http_get`, `@json_parse` → `json_parse`
n.strip_prefix('@').unwrap_or(n).to_string()
}
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) => {
if stmts.is_empty() {
self.emit(Bytecode::Push(Value::Nil));
} else {
for (i, s) in stmts.iter().enumerate() {
let is_last = i == stmts.len() - 1;
if is_last {
// Last statement: emit as a tail expression (leave value on stack).
self.gen_stmt_tail(s)?;
} else {
self.gen_stmt(s)?;
}
}
}
}
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 {
// No else branch: if-without-else is a statement, not a value
// expression. Discard the then-block's pushed value so both
// paths leave the stack at the same height (net zero change).
self.emit(Bytecode::Pop);
let after_pop = self.current_idx();
self.patch_jump(jump_false, after_pop); // false path skips Pop too
}
}
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 {
match &arm.pattern {
el_parser::Pattern::Wildcard => {
// Wildcard always matches — pop subject and run body directly.
self.emit(Bytecode::Pop);
self.gen_expr(&arm.body)?;
end_jumps.push(self.emit(Bytecode::Jump(0)));
// No jump_no_match needed — wildcard always matches.
// But we still need to patch the end jumps at the end.
// Nothing else to do; break out of the loop since wildcard
// is a catch-all and subsequent arms are unreachable.
break;
}
el_parser::Pattern::Binding(name) => {
// Bind and always match.
self.emit(Bytecode::Dup);
self.emit(Bytecode::StoreLocal(name.clone()));
// Dup'd subject is still on stack; compare to itself.
self.emit(Bytecode::Dup);
self.emit(Bytecode::Eq);
let jump_no_match = self.emit(Bytecode::JumpIfNot(0));
self.emit(Bytecode::Pop);
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);
}
_ => {
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 subject (simplified: payload = subject)
self.emit(Bytecode::StoreLocal(bind.clone()));
}
}
_ => unreachable!("wildcard and binding handled above"),
}
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 fallthrough: 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) => {
// Push each element onto the stack, then collect with BuildList.
for e in elems {
self.gen_expr(e)?;
}
self.emit(Bytecode::BuildList(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);
}
Expr::StructLit { type_name, fields, .. } => {
// Push each field value in declaration order
for (_, field_expr) in fields {
self.gen_expr(field_expr)?;
}
let field_names: Vec<String> = fields.iter().map(|(n, _)| n.clone()).collect();
self.emit(Bytecode::BuildStruct {
type_name: type_name.clone(),
fields: field_names,
});
}
Expr::MapLiteral(pairs) => {
// Push each key-value pair, then collect with BuildMap.
let n = pairs.len() as u32;
for (key_expr, val_expr) in pairs {
self.gen_expr(key_expr)?;
self.gen_expr(val_expr)?;
}
self.emit(Bytecode::BuildMap(n));
}
Expr::With { base, updates } => {
// Generate base struct clone then apply updates
self.gen_expr(base)?;
for (field, val_expr) in updates {
self.gen_expr(val_expr)?;
self.emit(Bytecode::SetField(field.clone()));
}
}
Expr::Reason { query } => {
self.emit(Bytecode::Reason { query: query.clone() });
}
Expr::Parallel { entries } => {
// For parallel, emit each expression sequentially and collect into a Map
// A full implementation would use threads; here we collect results into a Map
let n = entries.len() as u32;
for (name, expr) in entries {
self.emit(Bytecode::Push(Value::Str(name.clone())));
self.gen_expr(expr)?;
}
self.emit(Bytecode::BuildMap(n));
}
Expr::Trace { label, body } => {
self.emit(Bytecode::TraceBegin { label: label.clone() });
for s in body {
self.gen_stmt(s)?;
}
self.emit(Bytecode::TraceEnd { label: label.clone() });
self.emit(Bytecode::Push(Value::Nil));
}
// JSX element: push tag name, attrs (as a map), and children list, then call __jsx__
Expr::JsxElement { tag, attrs, children, .. } => {
// If the tag starts with an uppercase letter it's a component reference.
// Call the component function directly (it takes no args, returns HTML string).
let is_component = tag.chars().next().map_or(false, |c| c.is_uppercase());
if is_component {
// Call the component as a function: Call { name: tag, arity: 0 }
// The component will render itself and return an HTML string.
self.emit(Bytecode::Call { name: tag.clone(), arity: 0 });
} else {
// Push tag name
self.emit(Bytecode::Push(Value::Str(tag.clone())));
// Build attrs map: push key-value pairs
let n_attrs = attrs.len() as u32;
for (attr_name, attr_val) in attrs {
self.emit(Bytecode::Push(Value::Str(attr_name.clone())));
match attr_val {
JsxAttrValue::Str(s) => {
self.emit(Bytecode::Push(Value::Str(s.clone())));
}
JsxAttrValue::Expr(expr) => {
self.gen_expr(expr)?;
}
}
}
self.emit(Bytecode::BuildMap(n_attrs));
// Build children list
for child in children {
self.gen_expr(child)?;
}
self.emit(Bytecode::BuildList(children.len() as u32));
// Call __jsx__(tag, attrs, children)
self.emit(Bytecode::Call { name: "__jsx__".to_string(), arity: 3 });
}
}
// JSX expression interpolation: just evaluate the inner expression
Expr::JsxExpr(inner) => {
self.gen_expr(inner)?;
}
// JSX text: push as string
Expr::JsxText(text) => {
self.emit(Bytecode::Push(Value::Str(text.clone())));
}
// Closure: compile as an inline function and push a reference to it.
// The closure body is emitted as a skip-over block.
Expr::Closure { params, body, .. } => {
let closure_id = self.current_idx();
let fn_name = format!("__closure_{closure_id}__");
let skip_jump = self.emit(Bytecode::Jump(0));
// Bind params in reverse order (stack has args pushed left-to-right)
for param in params.iter().rev() {
self.emit(Bytecode::StoreLocal(param.name.clone()));
}
self.gen_expr(body)?;
self.emit(Bytecode::Return);
let after = self.current_idx();
self.patch_jump(skip_jump, after);
let entry_point = skip_jump + 1;
// Register the closure function
self.emit(Bytecode::Push(Value::Int(entry_point as i64)));
self.emit(Bytecode::StoreLocal(format!("__fn_{fn_name}")));
// Push a reference to this closure (as its function name)
self.emit(Bytecode::Push(Value::Str(fn_name)));
}
// New expression kinds — push Nil as placeholder
_ => {
self.emit(Bytecode::Push(Value::Nil));
}
}
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, .. }
| Stmt::TestDef { span, .. }
| Stmt::Seed(_, span)
| Stmt::Assert(_, span) => *span,
_ => el_lexer::Span::new(0, 0, 0, 0),
}
}
#[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)));
}
}
@@ -1,249 +0,0 @@
//! 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());
}
}
@@ -1,279 +0,0 @@
//! Step-debugger infrastructure for the Engram VM.
//!
//! The [`Debugger`] is attached to the bytecode interpreter and emits
//! [`DebugEvent`]s as execution proceeds. IDEs and `el debug` consume these
//! events to show variable state, call stack, and current source line.
use std::collections::{HashMap, HashSet};
use crate::bytecode::Value;
/// A single frame on the call stack.
#[derive(Debug, Clone)]
pub struct StackFrame {
pub function_name: String,
pub source_file: String,
pub line: u32,
pub col: u32,
}
/// Controls how the debugger advances through bytecode.
#[derive(Debug, Clone, PartialEq)]
pub enum StepMode {
/// Run freely until the next breakpoint.
Run,
/// Execute exactly one statement, then pause.
StepOver,
/// Step into function calls (pause on first instruction of callee).
StepInto,
/// Run until the current frame returns, then pause.
StepOut,
}
/// An event emitted by the VM when in debug mode.
#[derive(Debug, Clone)]
pub enum DebugEvent {
/// Execution paused at a breakpoint.
Breakpoint {
offset: usize,
frame: StackFrame,
},
/// Execution paused after a single step.
Step {
frame: StackFrame,
locals: HashMap<String, Value>,
},
/// A function returned a value.
Return {
value: Value,
},
/// The VM encountered a runtime error.
Error {
message: String,
frame: StackFrame,
},
}
/// The debugger attached to a running VM instance.
///
/// In debug mode the interpreter queries [`should_pause`] before each
/// instruction. If it returns `true`, execution stops and a [`DebugEvent`]
/// is emitted to the registered handler.
pub struct Debugger {
/// Bytecode offsets at which to pause execution.
pub breakpoints: HashSet<usize>,
/// Current stepping mode.
pub step_mode: StepMode,
/// Simulated call stack (maintained by the interpreter).
pub call_stack: Vec<StackFrame>,
/// Snapshot of local variables at the last pause.
pub locals: HashMap<String, Value>,
/// Events emitted since the last [`drain_events`] call.
events: Vec<DebugEvent>,
}
impl Debugger {
/// Create a new debugger that will break on the very first instruction.
pub fn new() -> Self {
Self {
breakpoints: HashSet::new(),
step_mode: StepMode::StepOver,
call_stack: vec![StackFrame {
function_name: "<top>".into(),
source_file: "<unknown>".into(),
line: 1,
col: 1,
}],
locals: HashMap::new(),
events: Vec::new(),
}
}
/// Add a breakpoint at a bytecode offset.
pub fn add_breakpoint(&mut self, offset: usize) {
self.breakpoints.insert(offset);
}
/// Remove a breakpoint.
pub fn remove_breakpoint(&mut self, offset: usize) {
self.breakpoints.remove(&offset);
}
/// Returns `true` if the debugger should pause at `offset`.
pub fn should_pause(&self, offset: usize) -> bool {
if self.breakpoints.contains(&offset) {
return true;
}
matches!(self.step_mode, StepMode::StepOver | StepMode::StepInto)
}
/// Called by the interpreter when it pauses at `offset`.
pub fn on_pause(&mut self, offset: usize, locals: HashMap<String, Value>) {
self.locals = locals.clone();
let frame = self.current_frame_cloned();
if self.breakpoints.contains(&offset) {
self.events.push(DebugEvent::Breakpoint { offset, frame });
} else {
self.events.push(DebugEvent::Step { frame, locals });
}
}
/// Called when a function is entered.
pub fn push_frame(&mut self, function_name: String, source_file: String) {
self.call_stack.push(StackFrame {
function_name,
source_file,
line: 1,
col: 1,
});
}
/// Called when a function returns.
pub fn pop_frame(&mut self, value: Value) {
self.call_stack.pop();
self.events.push(DebugEvent::Return { value });
}
/// Record a runtime error.
pub fn on_error(&mut self, message: String) {
let frame = self.current_frame_cloned();
self.events.push(DebugEvent::Error { message, frame });
}
/// Update the source position of the top frame.
pub fn update_position(&mut self, line: u32, col: u32) {
if let Some(frame) = self.call_stack.last_mut() {
frame.line = line;
frame.col = col;
}
}
/// Drain and return all queued events.
pub fn drain_events(&mut self) -> Vec<DebugEvent> {
std::mem::take(&mut self.events)
}
/// Current frame clone, or a sentinel if the stack is empty.
fn current_frame_cloned(&self) -> StackFrame {
self.call_stack.last().cloned().unwrap_or_else(|| StackFrame {
function_name: String::new(),
source_file: String::new(),
line: 0,
col: 0,
})
}
}
impl Default for Debugger {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_breakpoint_triggers_pause() {
let mut dbg = Debugger::new();
dbg.step_mode = StepMode::Run; // not stepping — only breakpoints
dbg.add_breakpoint(5);
assert!(!dbg.should_pause(0));
assert!(!dbg.should_pause(4));
assert!(dbg.should_pause(5));
assert!(!dbg.should_pause(6));
}
#[test]
fn test_step_over_always_pauses() {
let mut dbg = Debugger::new();
dbg.step_mode = StepMode::StepOver;
assert!(dbg.should_pause(0));
assert!(dbg.should_pause(99));
}
#[test]
fn test_step_into_always_pauses() {
let mut dbg = Debugger::new();
dbg.step_mode = StepMode::StepInto;
assert!(dbg.should_pause(0));
}
#[test]
fn test_run_mode_no_pause_without_breakpoint() {
let mut dbg = Debugger::new();
dbg.step_mode = StepMode::Run;
assert!(!dbg.should_pause(42));
}
#[test]
fn test_remove_breakpoint() {
let mut dbg = Debugger::new();
dbg.step_mode = StepMode::Run;
dbg.add_breakpoint(10);
assert!(dbg.should_pause(10));
dbg.remove_breakpoint(10);
assert!(!dbg.should_pause(10));
}
#[test]
fn test_on_pause_emits_step_event() {
let mut dbg = Debugger::new();
dbg.step_mode = StepMode::StepOver;
let mut locals = HashMap::new();
locals.insert("x".into(), Value::Int(42));
dbg.on_pause(0, locals.clone());
let events = dbg.drain_events();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], DebugEvent::Step { .. }));
}
#[test]
fn test_on_pause_at_breakpoint_emits_breakpoint_event() {
let mut dbg = Debugger::new();
dbg.step_mode = StepMode::Run;
dbg.add_breakpoint(7);
dbg.on_pause(7, HashMap::new());
let events = dbg.drain_events();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], DebugEvent::Breakpoint { offset: 7, .. }));
}
#[test]
fn test_push_pop_frame() {
let mut dbg = Debugger::new();
dbg.push_frame("my_fn".into(), "test.el".into());
assert_eq!(dbg.call_stack.len(), 2);
assert_eq!(dbg.call_stack[1].function_name, "my_fn");
dbg.pop_frame(Value::Int(0));
assert_eq!(dbg.call_stack.len(), 1);
let events = dbg.drain_events();
assert!(matches!(events[0], DebugEvent::Return { .. }));
}
#[test]
fn test_on_error_emits_error_event() {
let mut dbg = Debugger::new();
dbg.on_error("division by zero".into());
let events = dbg.drain_events();
assert!(matches!(&events[0], DebugEvent::Error { message, .. } if message == "division by zero"));
}
#[test]
fn test_drain_clears_events() {
let mut dbg = Debugger::new();
dbg.on_error("oops".into());
let _ = dbg.drain_events();
let events2 = dbg.drain_events();
assert!(events2.is_empty());
}
#[test]
fn test_update_position() {
let mut dbg = Debugger::new();
dbg.update_position(10, 5);
assert_eq!(dbg.call_stack[0].line, 10);
assert_eq!(dbg.call_stack[0].col, 5);
}
}
@@ -1,29 +0,0 @@
//! 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>;
@@ -1,35 +0,0 @@
//! 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 debugger;
mod error;
mod source_map;
pub use bytecode::{Bytecode, Value, serialize_bytecode, deserialize_bytecode, wrap_elvm, unwrap_elvm, ELVM_MAGIC, ELVM_VERSION};
pub use compiler::{CompileOutput, Compiler, CompilerOptions, Target};
pub use debugger::{DebugEvent, Debugger, StackFrame, StepMode};
pub use error::{CompileError, CompileResult};
pub use source_map::SourceMap;
@@ -1,57 +0,0 @@
//! 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())
}
}
@@ -1,13 +0,0 @@
[package]
name = "el-fmt"
version = "0.1.0"
edition = "2021"
[dependencies]
el-lexer = { path = "../el-lexer" }
el-parser = { path = "../el-parser" }
thiserror = "2"
[dev-dependencies]
el-lexer = { path = "../el-lexer" }
el-parser = { path = "../el-parser" }
@@ -1,32 +0,0 @@
//! Formatter configuration.
#[derive(Debug, Clone)]
pub struct FmtConfig {
pub indent: IndentStyle,
/// Number of spaces per indent level (default 4).
pub indent_width: usize,
/// Maximum line width before wrapping (default 100).
pub max_line_width: usize,
/// Whether to ensure the output ends with a newline (default true).
pub trailing_newline: bool,
/// Whether to emit a space before an opening brace (default true).
pub space_before_brace: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub enum IndentStyle {
Spaces,
Tabs,
}
impl Default for FmtConfig {
fn default() -> Self {
Self {
indent: IndentStyle::Spaces,
indent_width: 4,
max_line_width: 100,
trailing_newline: true,
space_before_brace: true,
}
}
}
@@ -1,11 +0,0 @@
//! Error types for el-fmt.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum FmtError {
#[error("lex error: {0}")]
Lex(String),
#[error("parse error: {0}")]
Parse(String),
}
@@ -1,542 +0,0 @@
//! AST pretty-printer — the core of el-fmt.
use el_parser::{BinOp, Expr, Literal, MatchArm, Pattern, Program, Stmt, TypeExpr};
use crate::{FmtConfig, FmtError};
use crate::config::IndentStyle;
pub struct Formatter {
config: FmtConfig,
}
impl Formatter {
pub fn new(config: FmtConfig) -> Self {
Self { config }
}
pub fn format(&self, program: &Program) -> Result<String, FmtError> {
let mut out = String::new();
for (i, stmt) in program.stmts.iter().enumerate() {
if i > 0 {
out.push('\n');
}
self.fmt_stmt(&mut out, stmt, 0);
}
if self.config.trailing_newline && !out.ends_with('\n') {
out.push('\n');
}
Ok(out)
}
fn indent(&self, depth: usize) -> String {
match self.config.indent {
IndentStyle::Spaces => " ".repeat(depth * self.config.indent_width),
IndentStyle::Tabs => "\t".repeat(depth),
}
}
fn fmt_stmt(&self, out: &mut String, stmt: &Stmt, depth: usize) {
let ind = self.indent(depth);
match stmt {
Stmt::Let { name, type_ann, value, .. } => {
out.push_str(&ind);
out.push_str("let ");
out.push_str(name);
if let Some(ty) = type_ann {
out.push_str(": ");
out.push_str(&self.fmt_type(ty));
}
out.push_str(" = ");
self.fmt_expr(out, value, depth);
out.push('\n');
}
Stmt::Return(expr, _) => {
out.push_str(&format!("{ind}return "));
self.fmt_expr(out, expr, depth);
out.push('\n');
}
Stmt::Expr(expr, _) => {
out.push_str(&ind);
self.fmt_expr(out, expr, depth);
out.push('\n');
}
Stmt::FnDef { name, params, body, decorators, return_type, .. } => {
// Decorators
for dec in decorators {
out.push_str(&format!("{ind}@{}\n", dec.name));
}
// Parameters
let params_str: Vec<String> = params
.iter()
.map(|p| format!("{}: {}", p.name, self.fmt_type(&p.type_ann)))
.collect();
// Always emit return type — the parser requires `->`.
let ret = format!(" -> {}", self.fmt_type(return_type));
let brace_space = if self.config.space_before_brace { " " } else { "" };
out.push_str(&format!(
"{ind}fn {name}({}){}{brace_space}{{\n",
params_str.join(", "),
ret,
));
for s in body {
self.fmt_stmt(out, s, depth + 1);
}
out.push_str(&format!("{ind}}}\n"));
}
Stmt::TypeDef { name, fields, .. } => {
out.push_str(&format!("{ind}type {name} {{\n"));
for f in fields {
out.push_str(&format!(
"{} {}: {}\n",
ind,
f.name,
self.fmt_type(&f.type_ann)
));
}
out.push_str(&format!("{ind}}}\n"));
}
Stmt::EnumDef { name, variants, .. } => {
out.push_str(&format!("{ind}enum {name} {{\n"));
for v in variants {
if let Some(payload) = &v.payload {
out.push_str(&format!(
"{} {}({})\n",
ind,
v.name,
self.fmt_type(payload)
));
} else {
out.push_str(&format!("{} {}\n", ind, v.name));
}
}
out.push_str(&format!("{ind}}}\n"));
}
Stmt::TestDef { name, body, .. } => {
out.push_str(&format!("{ind}test {:?} {{\n", name));
for s in body {
self.fmt_stmt(out, s, depth + 1);
}
out.push_str(&format!("{ind}}}\n"));
}
Stmt::Assert(expr, _) => {
out.push_str(&format!("{ind}assert "));
self.fmt_expr(out, expr, depth);
out.push('\n');
}
Stmt::Import { path, names, alias, .. } => {
if names.is_empty() {
let joined = path.join("::");
if let Some(a) = alias {
out.push_str(&format!("{ind}import {joined} as {a}\n"));
} else {
out.push_str(&format!("{ind}import {joined}\n"));
}
} else {
let joined = path.join("::");
let items = names.join(", ");
out.push_str(&format!("{ind}from {joined} import {{ {items} }}\n"));
}
}
Stmt::ProtocolDef { name, methods, .. } => {
out.push_str(&format!("{ind}protocol {name} {{\n"));
for m in methods {
let params_str: Vec<String> = m
.params
.iter()
.map(|p| format!("{}: {}", p.name, self.fmt_type(&p.type_ann)))
.collect();
out.push_str(&format!(
"{} fn {}({}) -> {}\n",
ind,
m.name,
params_str.join(", "),
self.fmt_type(&m.return_type)
));
}
out.push_str(&format!("{ind}}}\n"));
}
Stmt::ImplDef { protocol_name, type_name, methods, .. } => {
out.push_str(&format!("{ind}impl {protocol_name} for {type_name} {{\n"));
for m in methods {
self.fmt_stmt(out, m, depth + 1);
}
out.push_str(&format!("{ind}}}\n"));
}
Stmt::Seed(seed, _) => {
use el_parser::SeedStmt;
match seed {
SeedStmt::Node { node_type, content, importance, tier } => {
let tier_str = tier
.as_deref()
.map(|t| format!(", tier: {t}"))
.unwrap_or_default();
out.push_str(&format!(
"{ind}seed {node_type} {{ content: {:?}, importance: {importance}{tier_str} }}\n",
content
));
}
SeedStmt::Edge { from, to, relation, weight } => {
out.push_str(&format!(
"{ind}seed Edge {{ from: {from}, to: {to}, relation: {relation:?}, weight: {weight} }}\n"
));
}
}
}
Stmt::Retry { count, body, fallback, .. } => {
out.push_str(&format!("{ind}retry "));
self.fmt_expr(out, count, depth);
out.push_str(" times {\n");
for s in body {
self.fmt_stmt(out, s, depth + 1);
}
out.push_str(&format!("{ind}}}"));
if let Some(fb) = fallback {
out.push_str(" fallback {\n");
for s in fb {
self.fmt_stmt(out, s, depth + 1);
}
out.push_str(&format!("{ind}}}"));
}
out.push('\n');
}
Stmt::Deploy { fn_name, route, target, .. } => {
out.push_str(&format!("{ind}deploy {fn_name} to \"{route}\" via {target}\n"));
}
Stmt::While { condition, body, .. } => {
out.push_str(&format!("{ind}while "));
self.fmt_expr(out, condition, depth);
out.push_str(" {\n");
for s in body {
self.fmt_stmt(out, s, depth + 1);
}
out.push_str(&format!("{ind}}}\n"));
}
// Component definition (UI/reactive components) — emit as-is placeholder.
Stmt::ComponentDef { name, methods, .. } => {
out.push_str(&format!("{ind}component {name} {{\n"));
for s in methods {
self.fmt_stmt(out, s, depth + 1);
}
out.push_str(&format!("{ind}}}\n"));
}
// Catch-all: unknown/future statement kinds are emitted as a comment.
#[allow(unreachable_patterns)]
_ => {
out.push_str(&format!("{ind}// [unformatted statement]\n"));
}
}
}
fn fmt_expr(&self, out: &mut String, expr: &Expr, depth: usize) {
match expr {
Expr::Literal(lit) => self.fmt_literal(out, lit),
Expr::Ident(name) => out.push_str(name),
Expr::Path { segments } => out.push_str(&segments.join("::")),
Expr::BinOp { op, left, right } => {
self.fmt_expr(out, left, depth);
out.push_str(&format!(" {} ", self.fmt_binop(op)));
self.fmt_expr(out, right, depth);
}
Expr::UnaryNot(inner) => {
out.push('!');
self.fmt_expr(out, inner, depth);
}
Expr::UnaryBitNot(inner) => {
out.push('~');
self.fmt_expr(out, inner, depth);
}
Expr::Try(inner) => {
self.fmt_expr(out, inner, depth);
out.push('?');
}
Expr::Call { func, args } => {
self.fmt_expr(out, func, depth);
out.push('(');
for (i, arg) in args.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
self.fmt_expr(out, arg, depth);
}
out.push(')');
}
Expr::Block(stmts) => {
out.push_str("{\n");
for s in stmts {
self.fmt_stmt(out, s, depth + 1);
}
out.push_str(&format!("{}}}", self.indent(depth)));
}
Expr::If { cond, then, else_ } => {
out.push_str("if ");
self.fmt_expr(out, cond, depth);
out.push(' ');
self.fmt_expr(out, then, depth);
if let Some(else_expr) = else_ {
out.push_str(" else ");
self.fmt_expr(out, else_expr, depth);
}
}
Expr::Activate { type_name, query } => {
out.push_str(&format!("activate {type_name} where {:?}", query));
}
Expr::Field { object, field } => {
self.fmt_expr(out, object, depth);
out.push('.');
out.push_str(field);
}
Expr::Index { object, index } => {
self.fmt_expr(out, object, depth);
out.push('[');
self.fmt_expr(out, index, depth);
out.push(']');
}
Expr::Array(elems) => {
out.push('[');
for (i, e) in elems.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
self.fmt_expr(out, e, depth);
}
out.push(']');
}
Expr::MapLiteral(pairs) => {
out.push('{');
for (i, (k, v)) in pairs.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
self.fmt_expr(out, k, depth);
out.push_str(": ");
self.fmt_expr(out, v, depth);
}
out.push('}');
}
Expr::Sealed(stmts) => {
out.push_str("sealed {\n");
for s in stmts {
self.fmt_stmt(out, s, depth + 1);
}
out.push_str(&format!("{}}}", self.indent(depth)));
}
Expr::Match { subject, arms } => {
out.push_str("match ");
self.fmt_expr(out, subject, depth);
out.push_str(" {\n");
for arm in arms {
self.fmt_match_arm(out, arm, depth);
}
out.push_str(&format!("{}}}", self.indent(depth)));
}
Expr::Closure { params, return_type, body, .. } => {
out.push('|');
let params_str: Vec<String> = params
.iter()
.map(|p| format!("{}: {}", p.name, self.fmt_type(&p.type_ann)))
.collect();
out.push_str(&params_str.join(", "));
out.push('|');
if let Some(rt) = return_type {
out.push_str(&format!(" -> {}", self.fmt_type(rt)));
}
out.push(' ');
self.fmt_expr(out, body, depth);
}
Expr::StructLit { type_name, fields, .. } => {
out.push_str(type_name);
out.push_str(" { ");
let fields_str: Vec<String> = fields
.iter()
.map(|(name, val)| {
let mut s = format!("{name}: ");
self.fmt_expr(&mut s, val, depth);
s
})
.collect();
out.push_str(&fields_str.join(", "));
out.push_str(" }");
}
Expr::With { base, updates } => {
self.fmt_expr(out, base, depth);
out.push_str(" with { ");
for (k, v) in updates {
out.push_str(&format!("{k}: "));
self.fmt_expr(out, v, depth);
out.push_str(", ");
}
out.push('}');
}
Expr::Reason { query } => {
out.push_str(&format!("reason {:?}", query));
}
Expr::Parallel { entries } => {
out.push_str("parallel { ");
for (name, e) in entries {
out.push_str(&format!("{name}: "));
self.fmt_expr(out, e, depth);
out.push_str(", ");
}
out.push('}');
}
Expr::Trace { label, body } => {
out.push_str(&format!("trace {:?} {{\n", label));
for s in body {
self.fmt_stmt(out, s, depth + 1);
}
out.push_str(&format!("{}}}", self.indent(depth)));
}
// JSX expressions — emit minimal JSX syntax.
Expr::JsxElement { tag, attrs, children, self_closing } => {
out.push('<');
out.push_str(tag);
for (key, val) in attrs {
out.push(' ');
out.push_str(key);
match val {
el_parser::JsxAttrValue::Str(s) => out.push_str(&format!("=\"{s}\"")),
el_parser::JsxAttrValue::Expr(e) => {
out.push_str("={");
self.fmt_expr(out, e, depth);
out.push('}');
}
}
}
if *self_closing {
out.push_str(" />");
} else {
out.push('>');
for child in children {
self.fmt_expr(out, child, depth);
}
out.push_str(&format!("</{tag}>"));
}
}
Expr::JsxExpr(inner) => {
out.push('{');
self.fmt_expr(out, inner, depth);
out.push('}');
}
Expr::JsxText(text) => {
out.push_str(text);
}
// Catch-all: unknown/future expression kinds.
#[allow(unreachable_patterns)]
_ => {
out.push_str("/* [unformatted expr] */");
}
}
}
fn fmt_literal(&self, out: &mut String, lit: &Literal) {
match lit {
Literal::Int(n) => out.push_str(&n.to_string()),
Literal::Float(f) => out.push_str(&f.to_string()),
Literal::Str(s) => out.push_str(&format!("{s:?}")),
Literal::Bool(b) => out.push_str(&b.to_string()),
}
}
fn fmt_binop(&self, op: &BinOp) -> &'static str {
match op {
BinOp::Add => "+",
BinOp::Sub => "-",
BinOp::Mul => "*",
BinOp::Div => "/",
BinOp::Eq => "==",
BinOp::NotEq => "!=",
BinOp::Lt => "<",
BinOp::Gt => ">",
BinOp::LtEq => "<=",
BinOp::GtEq => ">=",
BinOp::And => "&&",
BinOp::Or => "||",
BinOp::Mod => "%",
BinOp::BitAnd => "&",
BinOp::BitOr => "|",
BinOp::BitXor => "^",
BinOp::Shl => "<<",
BinOp::Shr => ">>",
BinOp::NullCoalesce => "??",
}
}
fn fmt_match_arm(&self, out: &mut String, arm: &MatchArm, depth: usize) {
out.push_str(&format!("{} ", self.indent(depth)));
self.fmt_pattern(out, &arm.pattern);
out.push_str(" => ");
self.fmt_expr(out, &arm.body, depth + 1);
out.push('\n');
}
fn fmt_pattern(&self, out: &mut String, pat: &Pattern) {
match pat {
Pattern::Wildcard => out.push('_'),
Pattern::Binding(name) => out.push_str(name),
Pattern::Literal(lit) => self.fmt_literal(out, lit),
Pattern::EnumVariant { enum_name, variant, payload } => {
out.push_str(&format!("{enum_name}::"));
out.push_str(variant);
if let Some(bind) = payload {
out.push_str(&format!("({bind})"));
}
}
}
}
pub fn fmt_type(&self, ty: &TypeExpr) -> String {
match ty {
TypeExpr::Named(n) => n.clone(),
TypeExpr::Array(inner) => format!("[{}]", self.fmt_type(inner)),
TypeExpr::Optional(inner) => format!("{}?", self.fmt_type(inner)),
TypeExpr::Result { ok, err } => {
format!("Result<{}, {}>", self.fmt_type(ok), self.fmt_type(err))
}
TypeExpr::Map { key, value } => {
format!("Map<{}, {}>", self.fmt_type(key), self.fmt_type(value))
}
TypeExpr::Fn { params, return_type } => {
let ps: Vec<_> = params.iter().map(|p| self.fmt_type(p)).collect();
format!("fn({}) -> {}", ps.join(", "), self.fmt_type(return_type))
}
TypeExpr::TypeParam(n) => n.clone(),
}
}
}
@@ -1,327 +0,0 @@
//! el-fmt — canonical source formatter for el.
//!
//! Formats a `.el` source file into its canonical representation.
//! Parsing an already-formatted file and re-formatting it produces identical output.
pub mod config;
pub mod error;
pub mod formatter;
pub use config::FmtConfig;
pub use error::FmtError;
pub use formatter::Formatter;
/// Format el source code. Returns the canonical formatted version.
pub fn format(source: &str) -> Result<String, FmtError> {
format_with_config(source, &FmtConfig::default())
}
/// Format with an explicit configuration.
pub fn format_with_config(source: &str, config: &FmtConfig) -> Result<String, FmtError> {
let tokens =
el_lexer::tokenize(source).map_err(|e| FmtError::Lex(e.to_string()))?;
let program =
el_parser::parse(tokens, source.to_string()).map_err(|e| FmtError::Parse(e.to_string()))?;
Formatter::new(config.clone()).format(&program)
}
/// Check whether `source` is already in canonical form.
/// Returns `true` if formatting would produce no changes.
pub fn is_canonical(source: &str) -> Result<bool, FmtError> {
let formatted = format(source)?;
Ok(formatted == source)
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn fmt(src: &str) -> String {
format(src).unwrap()
}
fn idempotent(src: &str) {
let once = fmt(src);
let twice = fmt(&once);
assert_eq!(once, twice, "format not idempotent for:\n{src}");
}
// 1. Integer literal
#[test]
fn test_integer_literal() {
assert_eq!(fmt("42"), "42\n");
}
// 2. Let binding (no type annotation in source → formatter emits inferred type)
// We parse "let x = 1" which gives type_ann from the parser.
// Since el-parser always injects a type_ann, we just check the output is stable.
#[test]
fn test_let_binding_idempotent() {
// Round-trip: parse what we emit and re-emit
let source = "let x: Int = 1\n";
assert_eq!(fmt(source), source);
idempotent(source);
}
// 3. Binary operator spacing
#[test]
fn test_binary_op_spacing() {
let out = fmt("1 + 2\n");
assert!(out.contains("1 + 2"), "expected '1 + 2' in: {out}");
}
// 4. Function definition canonical form
#[test]
fn test_fn_def() {
let src = "fn add(a: Int, b: Int) -> Int {\n return a + b\n}\n";
let out = fmt(src);
assert!(out.contains("fn add("), "missing fn signature: {out}");
assert!(out.contains("return a + b"), "missing return: {out}");
idempotent(src);
}
// 5. Nested function has 4-space indent
#[test]
fn test_nested_indent() {
let src = "fn outer() -> Void {\n fn inner() -> Void {\n }\n}\n";
let out = fmt(src);
assert!(out.contains(" fn inner("), "inner fn not indented: {out}");
idempotent(src);
}
// 6. If expression spacing
#[test]
fn test_if_expr() {
let src = "fn f() -> Void {\n if true {\n }\n}\n";
let out = fmt(src);
assert!(out.contains("if true"), "missing if: {out}");
idempotent(src);
}
// 7. If-else expression
#[test]
fn test_if_else() {
let src = "fn f(x: Int) -> Void {\n if x {\n } else {\n }\n}\n";
let out = fmt(src);
assert!(out.contains("else"), "missing else: {out}");
idempotent(src);
}
// 8. Match expression arms on own lines
#[test]
fn test_match_expr() {
let src = "fn f(x: Status) -> Void {\n match x {\n Status::Active(v) => 1\n _ => 0\n }\n}\n";
let out = fmt(src);
assert!(out.contains("match x"), "missing match: {out}");
assert!(out.contains("=>"), "missing arm: {out}");
idempotent(src);
}
// 9. Activate expression
#[test]
fn test_activate() {
let src = "fn f() -> Void {\n activate User where \"active users\"\n}\n";
let out = fmt(src);
assert!(out.contains("activate User where"), "missing activate: {out}");
idempotent(src);
}
// 10. Sealed block
#[test]
fn test_sealed_block() {
let src = "fn f() -> Void {\n sealed {\n let x: Int = 1\n }\n}\n";
let out = fmt(src);
assert!(out.contains("sealed {"), "missing sealed: {out}");
idempotent(src);
}
// 11. Array literal
#[test]
fn test_array_literal() {
let src = "[1, 2, 3]\n";
let out = fmt(src);
assert!(out.contains("[1, 2, 3]"), "missing array: {out}");
idempotent(src);
}
// 12. Field access
#[test]
fn test_field_access() {
let src = "fn f(u: User) -> Void {\n u.name\n}\n";
let out = fmt(src);
assert!(out.contains("u.name"), "missing field access: {out}");
idempotent(src);
}
// 13. Function call with args
#[test]
fn test_fn_call() {
let src = "foo(1, 2)\n";
let out = fmt(src);
assert!(out.contains("foo(1, 2)"), "missing call: {out}");
idempotent(src);
}
// 14. Type definition
#[test]
fn test_type_def() {
let src = "type User {\n name: String\n age: Int\n}\n";
let out = fmt(src);
assert!(out.contains("type User {"), "missing type def: {out}");
assert!(out.contains("name: String"), "missing field: {out}");
idempotent(src);
}
// 15. Enum definition
#[test]
fn test_enum_def() {
let src = "enum Status {\n Active\n Inactive\n}\n";
let out = fmt(src);
assert!(out.contains("enum Status {"), "missing enum def: {out}");
assert!(out.contains("Active"), "missing variant: {out}");
idempotent(src);
}
// 16. Decorator on fn
#[test]
fn test_decorator() {
let src = "@experience\nfn handle() -> Void {\n}\n";
let out = fmt(src);
assert!(out.contains("@experience"), "missing decorator: {out}");
idempotent(src);
}
// 17. Multiple decorators in order
#[test]
fn test_multiple_decorators() {
let src = "@public\n@experience\nfn handle() -> Void {\n}\n";
let out = fmt(src);
let pub_pos = out.find("@public").unwrap();
let exp_pos = out.find("@experience").unwrap();
assert!(pub_pos < exp_pos, "decorators out of order: {out}");
idempotent(src);
}
// 18. Return type annotation
#[test]
fn test_return_type() {
let src = "fn add(a: Int, b: Int) -> Int {\n return a + b\n}\n";
let out = fmt(src);
assert!(out.contains("-> Int"), "missing return type: {out}");
idempotent(src);
}
// 19. Result type
#[test]
fn test_result_type() {
let src = "fn load() -> Result<String, Error> {\n return \"ok\"\n}\n";
let out = fmt(src);
assert!(out.contains("Result<String, Error>"), "missing result type: {out}");
idempotent(src);
}
// 20. Optional type
#[test]
fn test_optional_type() {
let src = "fn find() -> String? {\n return \"ok\"\n}\n";
let out = fmt(src);
assert!(out.contains("String?"), "missing optional type: {out}");
idempotent(src);
}
// 21. Trailing newline always present
#[test]
fn test_trailing_newline() {
let out = fmt("42");
assert!(out.ends_with('\n'), "missing trailing newline");
}
// 22. is_canonical returns true for already-canonical source
#[test]
fn test_is_canonical_true() {
let src = "42\n";
assert!(is_canonical(src).unwrap(), "expected canonical");
}
// 23. is_canonical returns false for non-canonical source
#[test]
fn test_is_canonical_false() {
// No trailing newline
let result = is_canonical("42");
// Either it returns false OR the formatter fixes it
// Either way it should not error
assert!(result.is_ok());
}
// 24. Empty program produces just a newline
#[test]
fn test_empty_program() {
// An empty string has no stmts so produces nothing; trailing newline adds one
let out = fmt("");
assert_eq!(out, "\n");
}
// 25. Idempotence for multiple constructs
#[test]
fn test_idempotent_fn_def() {
idempotent("fn add(a: Int, b: Int) -> Int {\n return a + b\n}\n");
idempotent("fn noop() -> Void {\n}\n");
}
#[test]
fn test_idempotent_type_def() {
idempotent("type User {\n name: String\n age: Int\n}\n");
}
#[test]
fn test_idempotent_enum_def() {
idempotent("enum Status {\n Active\n Inactive\n}\n");
}
// 26. Test block
#[test]
fn test_test_block() {
let src = "test \"my test\" {\n assert 1 == 1\n}\n";
let out = fmt(src);
assert!(out.contains("test \"my test\""), "missing test block: {out}");
idempotent(src);
}
// 27. Wildcard pattern in match
#[test]
fn test_wildcard_pattern() {
let src = "fn f(x: Status) -> Void {\n match x {\n _ => 0\n }\n}\n";
let out = fmt(src);
assert!(out.contains("_ =>"), "missing wildcard: {out}");
idempotent(src);
}
// 28. Binding pattern in match
#[test]
fn test_binding_pattern() {
let src = "fn f(x: Int) -> Void {\n match x {\n v => v\n }\n}\n";
let out = fmt(src);
assert!(out.contains("v =>"), "missing binding: {out}");
idempotent(src);
}
// 29. Enum variant with payload
#[test]
fn test_enum_variant_payload() {
let src = "enum Msg {\n Value(Int)\n Empty\n}\n";
let out = fmt(src);
assert!(out.contains("Value(Int)"), "missing payload variant: {out}");
idempotent(src);
}
// 30. for loop
#[test]
fn test_for_loop() {
let src = "fn f(items: [Int]) -> Void {\n for x in items {\n x\n }\n}\n";
let out = fmt(src);
assert!(out.contains("for x in"), "missing for loop: {out}");
idempotent(src);
}
}
@@ -1,19 +0,0 @@
[package]
name = "el-integration"
description = "Engram language integration tests — full pipeline end-to-end"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
name = "el_integration"
path = "src/lib.rs"
[dependencies]
el-lexer = { workspace = true }
el-parser = { workspace = true }
el-types = { workspace = true }
el-compiler = { workspace = true }
el-stdlib = { workspace = true }
serde_json = { workspace = true }
el-seal = { workspace = true }
@@ -1,48 +0,0 @@
//! Integration tests for the Engram language compiler pipeline.
//!
//! Each test module exercises the full pipeline:
//! `source → lex → parse → type-check → compile`
#[cfg(test)]
pub mod tests;
use el_compiler::{Compiler, CompilerOptions};
use el_lexer::tokenize;
use el_parser::parse;
use el_types::{TypeChecker, TypeEnv};
/// Build a TypeEnv that includes both the core builtins and the full stdlib.
fn stdlib_env() -> TypeEnv {
let mut env = TypeEnv::with_builtins();
el_stdlib::register_builtins(&mut env);
env
}
/// Convenience: run the full pipeline on a source string.
/// Returns `Ok(())` if parsing and type-checking succeed with no errors.
/// The type environment includes all stdlib builtins.
pub fn pipeline_ok(src: &str) -> Result<(), String> {
let tokens = tokenize(src).map_err(|e| format!("lex error: {e}"))?;
let prog = parse(tokens, src.to_string()).map_err(|e| format!("parse error: {e}"))?;
let mut checker = TypeChecker::new(stdlib_env());
checker.check(&prog);
if checker.ok() {
Ok(())
} else {
let msgs: Vec<_> = checker.diagnostics.iter().map(|d| d.message.clone()).collect();
Err(format!("type errors: {}", msgs.join(", ")))
}
}
/// Run through lexer + parser only, returning the parsed program.
pub fn parse_ok(src: &str) -> Result<el_parser::Program, String> {
let tokens = tokenize(src).map_err(|e| format!("lex error: {e}"))?;
parse(tokens, src.to_string()).map_err(|e| format!("parse error: {e}"))
}
/// Run the compiler and return the bytecode artifact bytes.
pub fn compile_ok(src: &str) -> Result<Vec<u8>, String> {
let out = Compiler::compile(src, CompilerOptions::default())
.map_err(|e| format!("compile error: {e}"))?;
Ok(out.artifact)
}
@@ -1,153 +0,0 @@
//! Tests that `activate` expressions parse and type-check correctly.
//!
//! `activate TypeName where "semantic query"` is the Engram graph query primitive.
//! It returns `[TypeName]` — an array of matching nodes. The type checker
//! requires `TypeName` to be a defined type in the type environment.
use crate::{parse_ok, pipeline_ok};
// ── Parse tests ───────────────────────────────────────────────────────────────
#[test]
fn test_parse_activate_basic() {
let src = r#"let results = activate User where "recent users""#;
assert!(parse_ok(src).is_ok(), "basic activate expression should parse");
}
#[test]
fn test_parse_activate_in_let_binding() {
let src = r#"let users = activate User where "all active users""#;
assert!(parse_ok(src).is_ok(), "activate in let binding should parse");
}
#[test]
fn test_parse_activate_in_fn_body() {
let src = r#"
fn find_users(query: String) -> [User] {
let results = activate User where "active users"
return results
}
"#;
assert!(parse_ok(src).is_ok(), "activate in function body should parse");
}
#[test]
fn test_parse_activate_multiple_in_program() {
let src = r#"
let users = activate User where "all users"
let docs = activate Document where "recent documents"
"#;
assert!(parse_ok(src).is_ok(), "multiple activates should parse");
}
#[test]
fn test_parse_activate_with_complex_query() {
let src = r#"let items = activate Product where "top-selling electronics under $500""#;
assert!(parse_ok(src).is_ok(), "activate with complex query string should parse");
}
#[test]
fn test_parse_activate_used_in_test_block() {
let src = r#"
test "activate in test" target: unit {
let results = activate User where "test users"
assert true
}
"#;
assert!(parse_ok(src).is_ok(), "activate inside test block should parse");
}
// ── Activate AST structure tests ──────────────────────────────────────────────
#[test]
fn test_activate_parses_to_correct_ast_node() {
use el_parser::{Expr, Stmt};
let src = r#"let u = activate User where "query""#;
let prog = parse_ok(src).unwrap();
assert_eq!(prog.stmts.len(), 1);
if let Stmt::Let { value, .. } = &prog.stmts[0] {
assert!(
matches!(value, Expr::Activate { type_name, query }
if type_name == "User" && query == "query"),
"expected Activate node"
);
} else {
panic!("expected Let statement");
}
}
#[test]
fn test_activate_type_name_is_preserved() {
use el_parser::{Expr, Stmt};
let src = r#"let n = activate NeuralPattern where "dense clusters""#;
let prog = parse_ok(src).unwrap();
if let Stmt::Let { value, .. } = &prog.stmts[0] {
if let Expr::Activate { type_name, .. } = value {
assert_eq!(type_name, "NeuralPattern");
} else {
panic!("expected Activate expr");
}
} else {
panic!("expected Let stmt");
}
}
// ── Pipeline tests ────────────────────────────────────────────────────────────
// The type checker requires the activated type to be defined in the type env.
// These tests define the type before activating it.
#[test]
fn test_pipeline_activate_defined_type_typechecks() {
let src = r#"
type User {
id: Int
name: String
}
let results = activate User where "all users"
"#;
assert!(pipeline_ok(src).is_ok(), "activate on defined type should pass type checking");
}
#[test]
fn test_pipeline_activate_result_used_in_stdlib_call() {
let src = r#"
type User {
id: Int
name: String
}
let results = activate User where "active users"
let count: Int = array_length(results)
"#;
assert!(pipeline_ok(src).is_ok(), "activate result used in stdlib call should type-check");
}
#[test]
fn test_pipeline_engram_search_typechecks() {
let src = r#"let items = engram_search("neural patterns", 10)"#;
assert!(pipeline_ok(src).is_ok(), "engram_search should type-check");
}
#[test]
fn test_pipeline_engram_node_count_typechecks() {
let src = r#"let n: Int = engram_node_count()"#;
assert!(pipeline_ok(src).is_ok(), "engram_node_count should type-check");
}
#[test]
fn test_pipeline_activate_in_fn_with_defined_type() {
let src = r#"
type Document {
id: Int
title: String
content: String
}
fn find_docs(query: String) -> [Document] {
let results = activate Document where "recent documents"
return results
}
"#;
assert!(pipeline_ok(src).is_ok(), "activate in fn with defined type should type-check");
}
File diff suppressed because it is too large Load Diff
@@ -1,194 +0,0 @@
//! Full pipeline tests: source → lex → parse → type-check → compile.
use crate::{compile_ok, parse_ok, pipeline_ok};
// ── Parse-only smoke tests ────────────────────────────────────────────────────
#[test]
fn test_parse_let_int() {
assert!(parse_ok("let x: Int = 42").is_ok());
}
#[test]
fn test_parse_let_string() {
assert!(parse_ok(r#"let name: String = "hello""#).is_ok());
}
#[test]
fn test_parse_let_bool() {
assert!(parse_ok("let flag: Bool = true").is_ok());
}
#[test]
fn test_parse_fn_def() {
let src = r#"
fn add(a: Int, b: Int) -> Int {
return a + b
}
"#;
assert!(parse_ok(src).is_ok());
}
#[test]
fn test_parse_if_else() {
let src = r#"
fn abs(x: Int) -> Int {
if x < 0 {
return 0 - x
} else {
return x
}
}
"#;
assert!(parse_ok(src).is_ok());
}
#[test]
fn test_parse_type_def() {
let src = r#"
type User {
id: Int
name: String
email: String
}
"#;
assert!(parse_ok(src).is_ok());
}
#[test]
fn test_parse_enum_def() {
let src = r#"
enum Status {
Active
Inactive
Pending
}
"#;
assert!(parse_ok(src).is_ok());
}
#[test]
fn test_parse_match_expr() {
let src = r#"
fn describe(s: Status) -> String {
match s {
Status::Active => "active"
Status::Inactive => "inactive"
_ => "unknown"
}
}
"#;
assert!(parse_ok(src).is_ok());
}
#[test]
fn test_parse_array_literal() {
assert!(parse_ok("let xs: [Int] = [1, 2, 3]").is_ok());
}
#[test]
fn test_parse_nested_fn_calls() {
let src = r#"
fn double(x: Int) -> Int {
return x * 2
}
fn quad(x: Int) -> Int {
return double(double(x))
}
"#;
assert!(parse_ok(src).is_ok());
}
// ── Pipeline (lex + parse + type-check) tests ─────────────────────────────────
#[test]
fn test_pipeline_hello_world() {
assert!(pipeline_ok(r#"let msg: String = "Hello, World!""#).is_ok());
}
#[test]
fn test_pipeline_arithmetic() {
assert!(pipeline_ok("let result: Int = 3 + 4 * 2").is_ok());
}
#[test]
fn test_pipeline_fn_def_and_call() {
let src = r#"
fn square(n: Int) -> Int {
return n * n
}
let s: Int = square(5)
"#;
assert!(pipeline_ok(src).is_ok());
}
#[test]
fn test_pipeline_bool_logic() {
assert!(pipeline_ok("let ok: Bool = true && false").is_ok());
assert!(pipeline_ok("let ok: Bool = true || false").is_ok());
}
#[test]
fn test_pipeline_float_literal() {
assert!(pipeline_ok("let pi: Float = 3.14").is_ok());
}
#[test]
fn test_pipeline_string_trim_via_call() {
// string_trim is registered as a stdlib builtin
let src = r#"let s: String = string_trim(" hello ")"#;
assert!(pipeline_ok(src).is_ok());
}
// ── Compile tests (full artifact generation) ──────────────────────────────────
#[test]
fn test_compile_integer_literal() {
let artifact = compile_ok("let x: Int = 99").unwrap();
assert!(!artifact.is_empty());
}
#[test]
fn test_compile_fn_def() {
let src = r#"
fn greet(name: String) -> String {
return name
}
"#;
let artifact = compile_ok(src).unwrap();
assert!(!artifact.is_empty());
}
#[test]
fn test_compile_if_else() {
let src = r#"
fn max_val(a: Int, b: Int) -> Int {
if a > b {
return a
} else {
return b
}
}
"#;
let artifact = compile_ok(src).unwrap();
assert!(!artifact.is_empty());
}
#[test]
fn test_compile_produces_valid_json_artifact() {
let artifact = compile_ok("let x: Int = 1").unwrap();
// Debug target produces JSON-serialized bytecode
let parsed: serde_json::Value = serde_json::from_slice(&artifact).unwrap();
assert!(parsed.is_array());
}
#[test]
fn test_compile_multiple_stmts() {
let src = r#"
let a: Int = 1
let b: Int = 2
let c: Int = a + b
"#;
let artifact = compile_ok(src).unwrap();
assert!(!artifact.is_empty());
}
@@ -1,139 +0,0 @@
//! Tests that decorator-annotated functions parse and compile correctly.
//!
//! Decorators are metadata — they do not change compilation semantics in
//! the current implementation. The compiler emits identical bytecode whether
//! or not a function is decorated.
use crate::{compile_ok, parse_ok, pipeline_ok};
// ── Parse tests ───────────────────────────────────────────────────────────────
#[test]
fn test_parse_authenticate_decorator() {
let src = r#"
@authenticate
fn get_user(id: Int) -> String {
return "user"
}
"#;
assert!(parse_ok(src).is_ok(), "@authenticate decorator should parse");
}
#[test]
fn test_parse_public_decorator() {
let src = r#"
@public
fn health_check() -> Bool {
return true
}
"#;
assert!(parse_ok(src).is_ok(), "@public decorator should parse");
}
#[test]
fn test_parse_cache_decorator_with_args() {
let src = r#"
@cache(300)
fn get_config(key: String) -> String {
return key
}
"#;
assert!(parse_ok(src).is_ok(), "@cache(ttl) decorator should parse");
}
#[test]
fn test_parse_multiple_decorators() {
let src = r#"
@authenticate
@public
fn list_items() -> [String] {
return ["a", "b"]
}
"#;
assert!(parse_ok(src).is_ok(), "multiple decorators should parse");
}
#[test]
fn test_parse_decorator_with_string_arg() {
let src = r#"
@route("/api/users")
fn list_users() -> [String] {
return ["alice", "bob"]
}
"#;
assert!(parse_ok(src).is_ok(), "@route decorator with string arg should parse");
}
#[test]
fn test_parse_decorator_preserves_fn_body() {
let src = r#"
@authenticate
fn add(a: Int, b: Int) -> Int {
return a + b
}
"#;
let prog = parse_ok(src).unwrap();
// There is exactly one top-level statement (the fn def)
assert_eq!(prog.stmts.len(), 1);
}
// ── Pipeline tests ────────────────────────────────────────────────────────────
#[test]
fn test_pipeline_decorated_fn_typechecks() {
let src = r#"
@authenticate
fn secure_op(id: Int) -> Bool {
return true
}
"#;
assert!(pipeline_ok(src).is_ok(), "decorated fn should type-check");
}
#[test]
fn test_pipeline_multiple_decorators_typechecks() {
let src = r#"
@authenticate
@cache(60)
fn get_profile(id: Int) -> String {
return "profile"
}
"#;
assert!(pipeline_ok(src).is_ok(), "multiply-decorated fn should type-check");
}
// ── Compile tests ─────────────────────────────────────────────────────────────
#[test]
fn test_compile_decorated_fn_produces_artifact() {
let src = r#"
@authenticate
fn whoami() -> String {
return "me"
}
"#;
let artifact = compile_ok(src).unwrap();
assert!(!artifact.is_empty(), "decorated fn should produce bytecode artifact");
}
#[test]
fn test_compile_decorator_does_not_change_bytecode_semantics() {
// A decorated function and an identical undecorated function should both
// compile without errors and produce non-empty artifacts.
let decorated = r#"
@public
fn value() -> Int {
return 42
}
"#;
let plain = r#"
fn value() -> Int {
return 42
}
"#;
let art_dec = compile_ok(decorated).unwrap();
let art_plain = compile_ok(plain).unwrap();
// Both should compile to non-empty artifacts
assert!(!art_dec.is_empty());
assert!(!art_plain.is_empty());
}
@@ -1,220 +0,0 @@
//! Tests for Result<T, E> type annotation, the `?` try operator, and closures.
use crate::{parse_ok, pipeline_ok};
// ── Result<T, E> type annotation parsing ─────────────────────────────────────
#[test]
fn test_parse_result_return_type() {
let src = r#"
fn divide(a: Int, b: Int) -> Result<Int, String> {
return a
}
"#;
assert!(parse_ok(src).is_ok(), "Result<T, E> return type should parse");
}
#[test]
fn test_parse_result_in_let_binding() {
let src = r#"
fn parse_int(s: String) -> Result<Int, String> {
return 0
}
"#;
assert!(parse_ok(src).is_ok(), "Result<T, E> in function signature should parse");
}
#[test]
fn test_parse_result_with_complex_types() {
let src = r#"
fn fetch_user(id: Int) -> Result<String, String> {
return "user"
}
"#;
assert!(parse_ok(src).is_ok(), "Result<String, String> should parse");
}
#[test]
fn test_parse_nested_result() {
let src = r#"
fn complex_op() -> Result<Result<Int, String>, String> {
return 0
}
"#;
assert!(parse_ok(src).is_ok(), "nested Result types should parse");
}
// ── Try operator (`?`) parsing ────────────────────────────────────────────────
#[test]
fn test_parse_try_operator_on_call() {
let src = r#"
fn safe_div(a: Int, b: Int) -> Result<Int, String> {
return a
}
fn compute() -> Result<Int, String> {
let x: Int = safe_div(10, 2)?
return x
}
"#;
assert!(parse_ok(src).is_ok(), "? operator on function call should parse");
}
#[test]
fn test_parse_try_operator_on_variable() {
let src = r#"
fn process(result: Result<Int, String>) -> Result<Int, String> {
let value: Int = result?
return value
}
"#;
assert!(parse_ok(src).is_ok(), "? operator on variable should parse");
}
#[test]
fn test_parse_chained_try_operators() {
let src = r#"
fn step1() -> Result<Int, String> { return 1 }
fn step2(n: Int) -> Result<String, String> { return "ok" }
fn pipeline() -> Result<String, String> {
let n: Int = step1()?
let s: String = step2(n)?
return s
}
"#;
assert!(parse_ok(src).is_ok(), "chained ? operators should parse");
}
// ── Optional type (`T?`) parsing ──────────────────────────────────────────────
#[test]
fn test_parse_optional_return_type() {
let src = r#"
fn find(id: Int) -> String? {
return "user"
}
"#;
assert!(parse_ok(src).is_ok(), "Optional return type T? should parse");
}
#[test]
fn test_parse_optional_parameter() {
let src = r#"
fn greet(name: String?) -> String {
return "hello"
}
"#;
assert!(parse_ok(src).is_ok(), "Optional parameter type T? should parse");
}
#[test]
fn test_parse_optional_in_let_binding() {
let src = r#"
fn maybe_val() -> Int? {
return 42
}
"#;
assert!(parse_ok(src).is_ok(), "Optional in let binding should parse");
}
// ── Closure parsing ───────────────────────────────────────────────────────────
#[test]
fn test_parse_simple_closure() {
let src = r#"let double = |x: Int| x * 2"#;
assert!(parse_ok(src).is_ok(), "simple closure should parse");
}
#[test]
fn test_parse_closure_with_return_type() {
let src = r#"let double = |x: Int| -> Int { return x * 2 }"#;
assert!(parse_ok(src).is_ok(), "closure with explicit return type should parse");
}
#[test]
fn test_parse_closure_with_multiple_params() {
let src = r#"let add = |a: Int, b: Int| a + b"#;
assert!(parse_ok(src).is_ok(), "multi-param closure should parse");
}
#[test]
fn test_parse_closure_single_param_no_body_type() {
// Closure with one param and inferred return type
let src = r#"let inc = |n: Int| n + 1"#;
assert!(parse_ok(src).is_ok(), "single-param closure with inferred return should parse");
}
#[test]
fn test_parse_closure_with_block_body() {
let src = r#"
let compute = |x: Int| -> Int {
let y: Int = x * 2
return y + 1
}
"#;
assert!(parse_ok(src).is_ok(), "closure with block body should parse");
}
// ── Pipeline tests ────────────────────────────────────────────────────────────
#[test]
fn test_pipeline_result_return_type_typechecks() {
let src = r#"
fn safe_op(x: Int) -> Result<Int, String> {
return x
}
"#;
assert!(pipeline_ok(src).is_ok(), "Result<T,E> return type should type-check");
}
#[test]
fn test_pipeline_optional_return_type_typechecks() {
let src = r#"
fn maybe(x: Int) -> Int? {
return x
}
"#;
assert!(pipeline_ok(src).is_ok(), "Optional return type should type-check");
}
#[test]
fn test_pipeline_closure_typechecks() {
let src = r#"let inc = |n: Int| n + 1"#;
assert!(pipeline_ok(src).is_ok(), "closure expression should type-check");
}
#[test]
fn test_pipeline_try_operator_typechecks() {
let src = r#"
fn maybe_int() -> Result<Int, String> {
return 1
}
fn compute() -> Result<Int, String> {
let x: Int = maybe_int()?
return x
}
"#;
assert!(pipeline_ok(src).is_ok(), "? operator should type-check");
}
#[test]
fn test_pipeline_result_stdlib_unwrap_or() {
let src = r#"
fn safe_op(x: Int) -> Result<Int, String> {
return x
}
let val: Int = result_unwrap_or(safe_op(5), 0)
"#;
assert!(pipeline_ok(src).is_ok(), "result_unwrap_or should type-check");
}
#[test]
fn test_pipeline_optional_stdlib_is_some() {
let src = r#"
fn maybe(x: Int) -> Int? {
return x
}
let val: Bool = optional_is_some(maybe(3))
"#;
assert!(pipeline_ok(src).is_ok(), "optional_is_some should type-check");
}
@@ -1,9 +0,0 @@
//! Integration test modules — full pipeline end-to-end.
mod compiler_pipeline;
mod stdlib_usage;
mod protocol_conformance;
mod decorator_codegen;
mod test_framework;
mod activate_typing;
mod error_propagation;
@@ -1,147 +0,0 @@
//! Tests that verify protocol definitions and impl blocks parse correctly
//! and pass through the type-checking pipeline.
use crate::{parse_ok, pipeline_ok};
// ── Protocol definition parsing ───────────────────────────────────────────────
#[test]
fn test_parse_protocol_definition() {
let src = r#"
protocol Serializable {
fn serialize(self: Serializable) -> String
fn deserialize(data: String) -> Serializable
}
"#;
assert!(parse_ok(src).is_ok(), "protocol definition should parse");
}
#[test]
fn test_parse_protocol_with_multiple_methods() {
let src = r#"
protocol Comparable {
fn compare(a: Comparable, b: Comparable) -> Int
fn equals(a: Comparable, b: Comparable) -> Bool
fn less_than(a: Comparable, b: Comparable) -> Bool
}
"#;
assert!(parse_ok(src).is_ok(), "multi-method protocol should parse");
}
#[test]
fn test_parse_impl_for_type() {
let src = r#"
protocol Printable {
fn print(self: Printable) -> String
}
type Point {
x: Float
y: Float
}
impl Printable for Point {
fn print(self: Point) -> String {
return "point"
}
}
"#;
assert!(parse_ok(src).is_ok(), "impl block should parse");
}
#[test]
fn test_parse_impl_with_multiple_methods() {
let src = r#"
protocol Codec {
fn encode(data: String) -> String
fn decode(data: String) -> String
}
type Base64Codec {
padding: Bool
}
impl Codec for Base64Codec {
fn encode(data: String) -> String {
return data
}
fn decode(data: String) -> String {
return data
}
}
"#;
assert!(parse_ok(src).is_ok(), "impl with multiple methods should parse");
}
// ── Protocol pipeline tests ───────────────────────────────────────────────────
#[test]
fn test_pipeline_protocol_definition_ok() {
let src = r#"
protocol Runnable {
fn run(self: Runnable) -> Int
}
"#;
assert!(pipeline_ok(src).is_ok(), "protocol definition should type-check");
}
#[test]
fn test_pipeline_impl_for_builtin_type() {
let src = r#"
protocol Describable {
fn describe(self: Describable) -> String
}
type Tag {
label: String
value: Int
}
impl Describable for Tag {
fn describe(self: Tag) -> String {
return self.label
}
}
"#;
assert!(pipeline_ok(src).is_ok(), "impl block should type-check");
}
#[test]
fn test_pipeline_multiple_impls_for_same_protocol() {
let src = r#"
protocol Shape {
fn area(self: Shape) -> Float
}
type Circle {
radius: Float
}
type Square {
side: Float
}
impl Shape for Circle {
fn area(self: Circle) -> Float {
return self.radius * self.radius
}
}
impl Shape for Square {
fn area(self: Square) -> Float {
return self.side * self.side
}
}
"#;
assert!(pipeline_ok(src).is_ok(), "multiple impls for same protocol should type-check");
}
#[test]
fn test_pipeline_protocol_with_result_return() {
let src = r#"
protocol Validatable {
fn validate(self: Validatable) -> Result<Bool, String>
}
"#;
assert!(pipeline_ok(src).is_ok(), "protocol with Result return type should type-check");
}
#[test]
fn test_pipeline_protocol_with_optional_return() {
let src = r#"
protocol Repository {
fn find_by_id(id: Int) -> String?
}
"#;
assert!(pipeline_ok(src).is_ok(), "protocol with optional return type should type-check");
}
@@ -1,187 +0,0 @@
//! Integration tests for programs that use stdlib functions.
//!
//! The stdlib functions are registered as builtins, so el programs can call
//! them without an import statement.
use crate::pipeline_ok;
// ── Array stdlib ──────────────────────────────────────────────────────────────
#[test]
fn test_array_length_call_typechecks() {
let src = r#"
let xs: [Int] = [1, 2, 3]
let n: Int = array_length(xs)
"#;
assert!(pipeline_ok(src).is_ok(), "array_length should be in scope");
}
#[test]
fn test_array_push_call_typechecks() {
let src = r#"
let xs: [Int] = [1, 2, 3]
let ys: [Int] = array_push(xs, 4)
"#;
assert!(pipeline_ok(src).is_ok(), "array_push should be in scope");
}
#[test]
fn test_array_pop_call_typechecks() {
// array_pop returns T? (Optional), not [T]
let src = r#"
let xs: [Int] = [1, 2, 3]
let head = array_pop(xs)
"#;
assert!(pipeline_ok(src).is_ok(), "array_pop should be in scope");
}
#[test]
fn test_array_reverse_call_typechecks() {
let src = r#"
let xs: [Int] = [3, 2, 1]
let ys: [Int] = array_reverse(xs)
"#;
assert!(pipeline_ok(src).is_ok(), "array_reverse should be in scope");
}
#[test]
fn test_array_contains_returns_bool() {
// array_contains takes [String] and String
let src = r#"
let xs: [String] = ["a", "b", "c"]
let found: Bool = array_contains(xs, "b")
"#;
assert!(pipeline_ok(src).is_ok(), "array_contains should be in scope");
}
// ── String stdlib ─────────────────────────────────────────────────────────────
#[test]
fn test_string_len_call_typechecks() {
let src = r#"let n: Int = string_len("hello")"#;
assert!(pipeline_ok(src).is_ok(), "string_len should be in scope");
}
#[test]
fn test_string_trim_call_typechecks() {
let src = r#"let s: String = string_trim(" hi ")"#;
assert!(pipeline_ok(src).is_ok(), "string_trim should be in scope");
}
#[test]
fn test_string_to_upper_call_typechecks() {
let src = r#"let s: String = string_to_upper("hello")"#;
assert!(pipeline_ok(src).is_ok(), "string_to_upper should be in scope");
}
#[test]
fn test_string_to_lower_call_typechecks() {
let src = r#"let s: String = string_to_lower("HELLO")"#;
assert!(pipeline_ok(src).is_ok(), "string_to_lower should be in scope");
}
#[test]
fn test_string_contains_returns_bool() {
let src = r#"let ok: Bool = string_contains("hello world", "world")"#;
assert!(pipeline_ok(src).is_ok(), "string_contains should be in scope");
}
#[test]
fn test_string_concat_call_typechecks() {
let src = r#"let s: String = string_concat("hello", " world")"#;
assert!(pipeline_ok(src).is_ok(), "string_concat should be in scope");
}
// ── Math stdlib ───────────────────────────────────────────────────────────────
#[test]
fn test_math_abs_call_typechecks() {
// math_abs takes Float -> Float
let src = r#"let n: Float = math_abs(0.0 - 5.0)"#;
assert!(pipeline_ok(src).is_ok(), "math_abs should be in scope");
}
#[test]
fn test_math_max_call_typechecks() {
// math_max takes (Float, Float) -> Float
let src = r#"let n: Float = math_max(3.0, 7.0)"#;
assert!(pipeline_ok(src).is_ok(), "math_max should be in scope");
}
#[test]
fn test_math_min_call_typechecks() {
// math_min takes (Float, Float) -> Float
let src = r#"let n: Float = math_min(3.0, 7.0)"#;
assert!(pipeline_ok(src).is_ok(), "math_min should be in scope");
}
#[test]
fn test_math_pow_call_typechecks() {
let src = r#"let n: Float = math_pow(2.0, 10.0)"#;
assert!(pipeline_ok(src).is_ok(), "math_pow should be in scope");
}
#[test]
fn test_math_abs_int_call_typechecks() {
// math_abs_int takes Int -> Int (integer variant)
let src = r#"let n: Int = math_abs_int(0 - 5)"#;
assert!(pipeline_ok(src).is_ok(), "math_abs_int should be in scope");
}
#[test]
fn test_math_max_int_call_typechecks() {
let src = r#"let n: Int = math_max_int(3, 7)"#;
assert!(pipeline_ok(src).is_ok(), "math_max_int should be in scope");
}
// ── Map stdlib ────────────────────────────────────────────────────────────────
#[test]
fn test_map_new_call_typechecks() {
let src = r#"let m: Map<String, Int> = map_new()"#;
assert!(pipeline_ok(src).is_ok(), "map_new should be in scope");
}
#[test]
fn test_map_size_call_typechecks() {
let src = r#"
let m: Map<String, Int> = map_new()
let n: Int = map_size(m)
"#;
assert!(pipeline_ok(src).is_ok(), "map_size should be in scope");
}
#[test]
fn test_map_is_empty_call_typechecks() {
let src = r#"
let m: Map<String, Int> = map_new()
let empty: Bool = map_is_empty(m)
"#;
assert!(pipeline_ok(src).is_ok(), "map_is_empty should be in scope");
}
// ── Engram graph stdlib ───────────────────────────────────────────────────────
#[test]
fn test_engram_node_count_call_typechecks() {
let src = r#"let n: Int = engram_node_count()"#;
assert!(pipeline_ok(src).is_ok(), "engram_node_count should be in scope");
}
#[test]
fn test_engram_search_call_typechecks() {
let src = r#"let results = engram_search("neural patterns", 10)"#;
assert!(pipeline_ok(src).is_ok(), "engram_search should be in scope");
}
#[test]
fn test_engram_edge_between_returns_bool() {
// engram_edge_between takes (Uuid, Uuid) -> Bool
// Uuid literals are just strings assigned to Uuid type
let src = r#"
fn check_edge(a: Uuid, b: Uuid) -> Bool {
return engram_edge_between(a, b)
}
"#;
assert!(pipeline_ok(src).is_ok(), "engram_edge_between should be in scope");
}
@@ -1,131 +0,0 @@
//! Tests that el `test { ... }` blocks parse and type-check correctly.
use crate::{parse_ok, pipeline_ok};
// ── Parse tests ───────────────────────────────────────────────────────────────
#[test]
fn test_parse_simple_test_block() {
let src = r#"
test "addition works" {
assert 1 + 1 == 2
}
"#;
assert!(parse_ok(src).is_ok(), "simple test block should parse");
}
#[test]
fn test_parse_test_with_unit_target() {
let src = r#"
test "unit test" target: unit {
assert true
}
"#;
assert!(parse_ok(src).is_ok(), "test with unit target should parse");
}
#[test]
fn test_parse_test_with_e2e_target() {
let src = r#"
test "e2e test" target: e2e {
assert true
}
"#;
assert!(parse_ok(src).is_ok(), "test with e2e target should parse");
}
#[test]
fn test_parse_test_with_both_target() {
let src = r#"
test "both targets" target: both {
assert true
}
"#;
assert!(parse_ok(src).is_ok(), "test with both target should parse");
}
#[test]
fn test_parse_test_with_let_binding() {
let src = r#"
test "arithmetic" {
let x: Int = 3 + 4
assert x == 7
}
"#;
assert!(parse_ok(src).is_ok(), "test with let binding should parse");
}
#[test]
fn test_parse_test_with_fn_call() {
let src = r#"
fn double(n: Int) -> Int {
return n * 2
}
test "double function" {
let result: Int = double(5)
assert result == 10
}
"#;
assert!(parse_ok(src).is_ok(), "test calling fn should parse");
}
#[test]
fn test_parse_multiple_asserts() {
let src = r#"
test "multiple assertions" {
assert 1 < 2
assert 2 < 3
assert 3 > 0
}
"#;
assert!(parse_ok(src).is_ok(), "test with multiple asserts should parse");
}
#[test]
fn test_parse_test_with_seed_node() {
let src = r#"
test "with seed data" target: unit {
seed Node { node_type: "User", content: "Alice", importance: 0.9 }
assert true
}
"#;
assert!(parse_ok(src).is_ok(), "test with seed node should parse");
}
// ── Pipeline tests ────────────────────────────────────────────────────────────
#[test]
fn test_pipeline_test_block_typechecks() {
let src = r#"
test "type-checks ok" {
let x: Int = 42
assert x > 0
}
"#;
assert!(pipeline_ok(src).is_ok(), "test block should type-check");
}
#[test]
fn test_pipeline_test_with_string_typechecks() {
let src = r#"
test "string test" {
let s: String = "hello"
let n: Int = string_len(s)
assert n > 0
}
"#;
assert!(pipeline_ok(src).is_ok(), "test using stdlib should type-check");
}
#[test]
fn test_pipeline_multiple_test_blocks() {
let src = r#"
test "first" {
assert true
}
test "second" {
assert 1 == 1
}
"#;
assert!(pipeline_ok(src).is_ok(), "multiple test blocks should type-check");
}
@@ -1,11 +0,0 @@
[package]
name = "el-lexer"
description = "Engram language tokenizer"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
thiserror = { workspace = true }
[dev-dependencies]
@@ -1,35 +0,0 @@
//! 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,
}
@@ -1,602 +0,0 @@
//! 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,
'%' => Token::Percent,
'^' => Token::Caret,
'~' => Token::Tilde,
'-' => {
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::Shl
} else if self.eat('=') {
Token::LtEq
} else {
Token::Lt
}
}
'>' => {
if self.eat('>') {
Token::Shr
} else if self.eat('=') {
Token::GtEq
} else {
Token::Gt
}
}
'&' => {
if self.eat('&') {
Token::And
} else {
Token::Ampersand
}
}
'|' => {
if self.eat('|') {
Token::Or
} else if self.eat('>') {
Token::PipeOp
} else {
Token::Pipe
}
}
'@' => Token::At,
'#' => Token::Hash,
'?' => {
if self.eat('?') { Token::NullCoalesce } else { Token::QuestionMark }
}
':' => {
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 => {
// Emit Unknown token instead of erroring — allows JSX text with
// Unicode / special characters to pass through the lexer gracefully.
Token::Unknown(other)
}
};
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().is_some_and(|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,
"while" => Token::While,
"test" => Token::Test,
"seed" => Token::Seed,
"assert" => Token::Assert,
"target" => Token::Target,
"protocol" => Token::Protocol,
"impl" => Token::Impl,
"import" => Token::Import,
"from" => Token::From,
"as" => Token::As,
"true" => Token::BoolLiteral(true),
"false" => Token::BoolLiteral(false),
"with" => Token::With,
"retry" => Token::Retry,
"times" => Token::Times,
"fallback" => Token::Fallback,
"reason" => Token::Reason,
"parallel" => Token::Parallel,
"trace" => Token::Trace,
"requires" => Token::Requires,
"deploy" => Token::Deploy,
"to" => Token::To,
"via" => Token::Via,
"component" => Token::Component,
"props" => Token::Props,
"state" => Token::State,
"template" => Token::Template,
_ => 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_at_token() {
let tokens = toks("@public");
assert_eq!(tokens[0], Token::At);
assert_eq!(tokens[1], Token::Ident("public".into()));
}
#[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_new_keywords() {
let tokens = toks("protocol impl import from as");
assert_eq!(tokens[0], Token::Protocol);
assert_eq!(tokens[1], Token::Impl);
assert_eq!(tokens[2], Token::Import);
assert_eq!(tokens[3], Token::From);
assert_eq!(tokens[4], Token::As);
}
#[test]
fn test_pipe_token() {
let tokens = toks("|x: Int|");
assert_eq!(tokens[0], Token::Pipe);
assert_eq!(tokens[1], Token::Ident("x".into()));
assert_eq!(tokens[4], Token::Pipe);
}
#[test]
fn test_question_mark_token() {
let tokens = toks("x?");
assert_eq!(tokens[0], Token::Ident("x".into()));
assert_eq!(tokens[1], Token::QuestionMark);
}
#[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);
}
}
@@ -1,19 +0,0 @@
//! 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};
@@ -1,318 +0,0 @@
//! 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,
/// `while`
While,
/// `test` — test block definition
Test,
/// `seed` — graph seeding statement inside a test
Seed,
/// `assert` — assertion statement inside a test
Assert,
/// `target` — test target annotation (`target: e2e`)
Target,
/// `protocol` — protocol definition
Protocol,
/// `impl` — protocol implementation block
Impl,
/// `import` — import statement
Import,
/// `from` — `from package import { ... }`
From,
/// `as` — alias in import (`import X as Y`)
As,
/// `with` — record update syntax
With,
/// `retry` — retry block
Retry,
/// `times` — used in `retry N times`
Times,
/// `fallback` — fallback block in retry
Fallback,
/// `reason` — AI inference primitive
Reason,
/// `parallel` — concurrent execution block
Parallel,
/// `trace` — zero-cost observability block
Trace,
/// `requires` — precondition annotation on fn
Requires,
/// `deploy` — deployment primitive
Deploy,
/// `to` — used in `deploy fn to "/route"`
To,
/// `via` — used in `deploy fn to "/route" via soma`
Via,
/// `component` — component definition
Component,
/// `props` — props block inside a component
Props,
/// `state` — state block inside a component
State,
/// `template` — template block inside a component
Template,
/// `|>` — pipe operator
PipeOp,
/// `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,
// ── New single-char tokens ────────────────────────────────────────────────
/// `@` — decorator prefix
At,
/// `|` — closure param delimiter (single pipe, not `||`)
Pipe,
/// `?` — used both for Optional types and the Try operator
QuestionMark,
/// `??` — null-coalescing operator: `a ?? b` returns `b` when `a` is nil/empty
NullCoalesce,
/// `%` — modulo
Percent,
/// `&` — bitwise AND
Ampersand,
/// `^` — bitwise XOR
Caret,
/// `~` — bitwise NOT
Tilde,
/// `<<` — left shift
Shl,
/// `>>` — right shift
Shr,
/// `#` — hash character (used in JSX text like `#tag`)
Hash,
/// Any other character not recognized by the lexer (emitted instead of erroring,
/// so JSX text with Unicode / special characters can be skipped gracefully).
Unknown(char),
// ── 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::While => write!(f, "while"),
Token::In => write!(f, "in"),
Token::Test => write!(f, "test"),
Token::Seed => write!(f, "seed"),
Token::Assert => write!(f, "assert"),
Token::Target => write!(f, "target"),
Token::Protocol => write!(f, "protocol"),
Token::Impl => write!(f, "impl"),
Token::Import => write!(f, "import"),
Token::From => write!(f, "from"),
Token::As => write!(f, "as"),
Token::With => write!(f, "with"),
Token::Retry => write!(f, "retry"),
Token::Times => write!(f, "times"),
Token::Fallback => write!(f, "fallback"),
Token::Reason => write!(f, "reason"),
Token::Parallel => write!(f, "parallel"),
Token::Trace => write!(f, "trace"),
Token::Requires => write!(f, "requires"),
Token::Deploy => write!(f, "deploy"),
Token::To => write!(f, "to"),
Token::Via => write!(f, "via"),
Token::Component => write!(f, "component"),
Token::Props => write!(f, "props"),
Token::State => write!(f, "state"),
Token::Template => write!(f, "template"),
Token::PipeOp => write!(f, "|>"),
Token::At => write!(f, "@"),
Token::Pipe => write!(f, "|"),
Token::QuestionMark => write!(f, "?"),
Token::NullCoalesce => write!(f, "??"),
Token::Percent => write!(f, "%"),
Token::Ampersand => write!(f, "&"),
Token::Caret => write!(f, "^"),
Token::Tilde => write!(f, "~"),
Token::Shl => write!(f, "<<"),
Token::Shr => write!(f, ">>"),
Token::Hash => write!(f, "#"),
Token::Unknown(c) => write!(f, "{c}"),
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>"),
}
}
}
@@ -1,18 +0,0 @@
[package]
name = "el-lint"
version = "0.1.0"
edition = "2021"
[dependencies]
el-lexer = { path = "../el-lexer" }
el-parser = { path = "../el-parser" }
el-types = { path = "../el-types" }
el-arch = { path = "../el-arch" }
el-fmt = { path = "../el-fmt" }
thiserror = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[dev-dependencies]
el-lexer = { path = "../el-lexer" }
el-parser = { path = "../el-parser" }
@@ -1,11 +0,0 @@
//! Error types for el-lint.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum LintError {
#[error("lex error: {0}")]
Lex(String),
#[error("parse error: {0}")]
Parse(String),
}
@@ -1,298 +0,0 @@
//! el-lint — linter for el source files.
//!
//! Combines:
//! - `el-arch` architectural rule violations (VBD, EBD, swarm, security, graph)
//! - Style checks (naming conventions, function length, empty bodies)
//! - Format check (`el-fmt` canonical check, rule I001)
pub mod error;
pub mod linter;
pub mod report;
pub mod rules;
pub use error::LintError;
pub use linter::Linter;
pub use report::{LintDiagnostic, LintReport, LintSeverity};
/// Lint el source code. Returns a report with all diagnostics.
pub fn lint(source: &str) -> Result<LintReport, LintError> {
Linter::new().lint(source)
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn do_lint(src: &str) -> LintReport {
lint(src).unwrap()
}
// 1. Clean, canonical code → no errors, no warnings
#[test]
fn test_clean_code_no_errors() {
let src = "fn add(a: Int, b: Int) -> Int {\n return a + b\n}\n";
let report = do_lint(src);
assert!(!report.has_errors(), "unexpected errors: {:?}", report.diagnostics);
assert_eq!(report.warning_count(), 0, "unexpected warnings: {:?}", report.diagnostics);
}
// 2. @accessor calling @manager fn → VBD-001 error
#[test]
fn test_accessor_calls_manager() {
let src = concat!(
"@manager\nfn save_data() -> Void {\n}\n\n",
"@accessor\nfn get_data() -> Void {\n save_data()\n}\n"
);
let report = do_lint(src);
assert!(
report.has_errors(),
"expected arch error for accessor calling manager"
);
let has_vbd = report
.diagnostics
.iter()
.any(|d| d.code.contains("VBD"));
assert!(has_vbd, "expected VBD code: {:?}", report.diagnostics);
}
// 3. activate in a loop → GRAPH-001 warning
#[test]
fn test_activate_in_loop() {
let src = concat!(
"@accessor\nfn load_all(items: [String]) -> Void {\n",
" for x in items {\n",
" activate User where \"query\"\n",
" }\n}\n"
);
let report = do_lint(src);
let has_graph = report
.diagnostics
.iter()
.any(|d| d.code.contains("GRAPH") || d.code.contains("N1"));
assert!(has_graph, "expected GRAPH/N1 diagnostic: {:?}", report.diagnostics);
}
// 4. Function with uppercase name → S002 warning
#[test]
fn test_fn_uppercase_name() {
let src = "fn MyFunction() -> Void {\n}\n";
let report = do_lint(src);
let has_s002 = report.diagnostics.iter().any(|d| d.code == "S002");
assert!(has_s002, "expected S002: {:?}", report.diagnostics);
}
// 5. Type with lowercase name → S004 warning
#[test]
fn test_type_lowercase_name() {
let src = "type myType {\n x: Int\n}\n";
let report = do_lint(src);
let has_s004 = report.diagnostics.iter().any(|d| d.code == "S004");
assert!(has_s004, "expected S004: {:?}", report.diagnostics);
}
// 6. Empty function body → S003 info
#[test]
fn test_empty_fn_body() {
let src = "fn empty() -> Void {\n}\n";
let report = do_lint(src);
let has_s003 = report.diagnostics.iter().any(|d| d.code == "S003");
assert!(has_s003, "expected S003: {:?}", report.diagnostics);
}
// 7. Non-canonical formatting → I001 info
#[test]
fn test_non_canonical_format() {
// Missing trailing newline triggers I001 (formatter adds it, source doesn't have it)
let src = "42";
let report = do_lint(src);
let has_i001 = report.diagnostics.iter().any(|d| d.code == "I001");
assert!(has_i001, "expected I001: {:?}", report.diagnostics);
}
// 8. Canonical formatting → no I001
#[test]
fn test_canonical_format_no_i001() {
let src = "fn add(a: Int, b: Int) -> Int {\n return a + b\n}\n";
let report = do_lint(src);
let has_i001 = report.diagnostics.iter().any(|d| d.code == "I001");
assert!(!has_i001, "unexpected I001: {:?}", report.diagnostics);
}
// 9. has_errors() true when errors present
#[test]
fn test_has_errors_true() {
let src = concat!(
"@manager\nfn save() -> Void {\n}\n\n",
"@accessor\nfn get() -> Void {\n save()\n}\n"
);
let report = do_lint(src);
assert!(report.has_errors());
}
// 10. has_errors() false when only warnings/info
#[test]
fn test_has_errors_false_warnings_only() {
let src = "fn MyFunction() -> Int {\n return 1\n}\n";
let report = do_lint(src);
assert!(!report.has_errors(), "should not have errors, only warnings");
}
// 11. error_count() correct
#[test]
fn test_error_count() {
let src = concat!(
"@manager\nfn save() -> Void {\n}\n\n",
"@accessor\nfn get() -> Void {\n save()\n}\n"
);
let report = do_lint(src);
assert!(report.error_count() >= 1);
}
// 12. warning_count() correct
#[test]
fn test_warning_count() {
let src = "fn MyFunction() -> Int {\n return 1\n}\n";
let report = do_lint(src);
assert!(report.warning_count() >= 1, "expected at least one warning");
}
// 13. display() output contains "error" prefix for errors
#[test]
fn test_display_error_prefix() {
let src = concat!(
"@manager\nfn save() -> Void {\n}\n\n",
"@accessor\nfn get() -> Void {\n save()\n}\n"
);
let report = do_lint(src);
let display = report.display();
assert!(display.contains("error"), "expected 'error' in display: {display}");
}
// 14. display() contains "No issues found." for clean code
#[test]
fn test_display_no_issues() {
let src = "fn add(a: Int, b: Int) -> Int {\n return a + b\n}\n";
let report = do_lint(src);
if !report.has_errors() && report.warning_count() == 0 {
let display = report.display();
assert!(
display.contains("No issues found."),
"expected 'No issues found.': {display}"
);
}
}
// 15. to_json() is valid JSON
#[test]
fn test_to_json_valid() {
let src = "fn add(a: Int, b: Int) -> Int {\n return a + b\n}\n";
let report = do_lint(src);
let json = report.to_json();
let parsed: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
assert!(parsed.is_array(), "expected JSON array");
}
// 16. to_json() contains severity field
#[test]
fn test_to_json_has_severity() {
let src = "fn MyFunction() -> Int {\n return 1\n}\n";
let report = do_lint(src);
let json = report.to_json();
assert!(json.contains("severity"), "expected severity field: {json}");
}
// 17. Multiple issues in same file → all reported
#[test]
fn test_multiple_issues() {
let src = "fn MyFunction() -> Void {\n}\ntype myType {\n x: Int\n}\n";
let report = do_lint(src);
// S002 for fn name + S003 for empty body + S004 for type name
assert!(
report.diagnostics.len() >= 2,
"expected multiple diagnostics: {:?}",
report.diagnostics
);
}
// 18. @experience calling @experience → arch error
#[test]
fn test_experience_calls_experience() {
let src = concat!(
"@experience\nfn exp_a() -> Void {\n}\n\n",
"@experience\nfn exp_b() -> Void {\n exp_a()\n}\n"
);
let report = do_lint(src);
assert!(
report.has_errors(),
"expected arch error for experience calling experience"
);
}
// 19. @public fn with activate → arch error
#[test]
fn test_public_fn_with_activate() {
let src = "@public\nfn api_fn() -> Void {\n activate User where \"query\"\n}\n";
let report = do_lint(src);
assert!(
report.has_errors(),
"expected arch error for public fn with activate"
);
}
// 20. @swarm_agent calling @swarm_agent → diagnostic
#[test]
fn test_swarm_agent_calls_swarm_agent() {
let src = concat!(
"@swarm_agent\nfn agent_a() -> Void {\n}\n\n",
"@swarm_agent\nfn agent_b() -> Void {\n agent_a()\n}\n"
);
let report = do_lint(src);
// SwarmAgentIsolation should flag this
let has_swarm = report
.diagnostics
.iter()
.any(|d| d.code.contains("SWARM") || d.severity == LintSeverity::Error || d.severity == LintSeverity::Warning);
assert!(has_swarm, "expected swarm diagnostic: {:?}", report.diagnostics);
}
// 21. Nested functions → linting still works
#[test]
fn test_nested_functions() {
let src = concat!(
"fn outer(x: Int) -> Int {\n",
" fn inner(y: Int) -> Int {\n",
" return y + 1\n",
" }\n",
" return inner(x)\n",
"}\n"
);
// Should not panic
let result = lint(src);
assert!(result.is_ok(), "lint failed on nested functions");
}
// 22. LintReport::file_path is None by default
#[test]
fn test_file_path_none() {
let src = "fn f() -> Void {\n}\n";
let report = do_lint(src);
assert!(report.file_path.is_none());
}
// 23. source_lines is counted correctly
#[test]
fn test_source_lines_counted() {
let src = "fn f() -> Int {\n return 1\n}\n";
let report = do_lint(src);
assert_eq!(report.source_lines, 3);
}
// 24. Empty source → no crash
#[test]
fn test_empty_source() {
let result = lint("");
assert!(result.is_ok());
}
}
@@ -1,76 +0,0 @@
//! Core linter — orchestrates arch rules, style rules, and format check.
use el_arch::{ArchChecker, Severity as ArchSeverity};
use crate::{
error::LintError,
report::{LintDiagnostic, LintReport, LintSeverity},
rules,
};
pub struct Linter {
arch_checker: ArchChecker,
}
impl Linter {
pub fn new() -> Self {
Self {
arch_checker: ArchChecker::new(),
}
}
pub fn lint(&self, source: &str) -> Result<LintReport, LintError> {
let tokens = el_lexer::tokenize(source)
.map_err(|e| LintError::Lex(e.to_string()))?;
let program = el_parser::parse(tokens, source.to_string())
.map_err(|e| LintError::Parse(e.to_string()))?;
let mut diagnostics = Vec::new();
// 1. Run el-arch architectural rules.
let arch_diags = self.arch_checker.check(&program);
for d in arch_diags {
diagnostics.push(LintDiagnostic {
severity: match d.severity {
ArchSeverity::Error => LintSeverity::Error,
ArchSeverity::Warning => LintSeverity::Warning,
},
code: d.rule,
message: d.message,
location: d.location.unwrap_or_else(|| "unknown".into()),
suggestion: None,
});
}
// 2. Run style rules.
let style_diags = rules::check_style(&program);
diagnostics.extend(style_diags);
// 3. Check whether the source is in canonical format.
match el_fmt::is_canonical(source) {
Ok(false) => {
diagnostics.push(LintDiagnostic {
severity: LintSeverity::Info,
code: "I001".into(),
message: "source is not in canonical format — run `el fmt` to fix".into(),
location: "file".into(),
suggestion: Some("el fmt --in-place <file.el>".into()),
});
}
_ => {}
}
let source_lines = source.lines().count();
Ok(LintReport {
diagnostics,
file_path: None,
source_lines,
})
}
}
impl Default for Linter {
fn default() -> Self {
Self::new()
}
}
@@ -1,92 +0,0 @@
//! Diagnostic report types for el-lint.
#[derive(Debug, Clone, PartialEq)]
pub enum LintSeverity {
Error,
Warning,
Info,
}
#[derive(Debug, Clone)]
pub struct LintDiagnostic {
pub severity: LintSeverity,
/// Rule code, e.g. "E001", "W002", "S001", "I001".
pub code: String,
pub message: String,
/// Human-readable location hint, e.g. "function foo" or "file".
pub location: String,
pub suggestion: Option<String>,
}
#[derive(Debug)]
pub struct LintReport {
pub diagnostics: Vec<LintDiagnostic>,
pub file_path: Option<String>,
pub source_lines: usize,
}
impl LintReport {
pub fn has_errors(&self) -> bool {
self.diagnostics
.iter()
.any(|d| d.severity == LintSeverity::Error)
}
pub fn error_count(&self) -> usize {
self.diagnostics
.iter()
.filter(|d| d.severity == LintSeverity::Error)
.count()
}
pub fn warning_count(&self) -> usize {
self.diagnostics
.iter()
.filter(|d| d.severity == LintSeverity::Warning)
.count()
}
/// Format as human-readable output (similar to rustc error output).
pub fn display(&self) -> String {
let mut out = String::new();
for d in &self.diagnostics {
let prefix = match d.severity {
LintSeverity::Error => "error",
LintSeverity::Warning => "warning",
LintSeverity::Info => "info",
};
out.push_str(&format!("[{}] {}: {}\n", d.code, prefix, d.message));
out.push_str(&format!(" --> {}\n", d.location));
if let Some(suggestion) = &d.suggestion {
out.push_str(&format!(" help: {}\n", suggestion));
}
out.push('\n');
}
if self.diagnostics.is_empty() {
out.push_str("No issues found.\n");
}
out
}
/// Format as JSON for editor integration.
pub fn to_json(&self) -> String {
let items: Vec<serde_json::Value> = self
.diagnostics
.iter()
.map(|d| {
serde_json::json!({
"severity": match d.severity {
LintSeverity::Error => "error",
LintSeverity::Warning => "warning",
LintSeverity::Info => "info",
},
"code": d.code,
"message": d.message,
"location": d.location,
"suggestion": d.suggestion,
})
})
.collect();
serde_json::to_string_pretty(&items).unwrap()
}
}
@@ -1,116 +0,0 @@
//! Style and correctness rules for el-lint (beyond el-arch architectural rules).
use el_parser::{Program, Stmt};
use crate::report::{LintDiagnostic, LintSeverity};
/// Run all style rules against the program and return diagnostics.
pub fn check_style(program: &Program) -> Vec<LintDiagnostic> {
let mut diags = Vec::new();
for stmt in &program.stmts {
check_stmt(stmt, &mut diags);
}
diags
}
fn check_stmt(stmt: &Stmt, diags: &mut Vec<LintDiagnostic>) {
match stmt {
Stmt::FnDef { name, body, .. } => {
// S001: Function body too long (>50 statements)
if body.len() > 50 {
diags.push(LintDiagnostic {
severity: LintSeverity::Warning,
code: "S001".into(),
message: format!(
"function `{name}` has {} statements — consider splitting",
body.len()
),
location: format!("function {name}"),
suggestion: Some(
"extract sub-functions for each logical concern".into(),
),
});
}
// S002: Function name not snake_case
if name.chars().any(|c| c.is_uppercase()) {
diags.push(LintDiagnostic {
severity: LintSeverity::Warning,
code: "S002".into(),
message: format!("function `{name}` should be snake_case"),
location: format!("function {name}"),
suggestion: Some(format!("rename to `{}`", to_snake_case(name))),
});
}
// S003: Empty function body
if body.is_empty() {
diags.push(LintDiagnostic {
severity: LintSeverity::Info,
code: "S003".into(),
message: format!("function `{name}` has an empty body"),
location: format!("function {name}"),
suggestion: Some("add implementation or remove if unused".into()),
});
}
// Recurse into nested function defs
for s in body {
check_stmt(s, diags);
}
}
Stmt::TypeDef { name, .. } => {
// S004: Type name not PascalCase
if !is_pascal_case(name) {
diags.push(LintDiagnostic {
severity: LintSeverity::Warning,
code: "S004".into(),
message: format!("type `{name}` should be PascalCase"),
location: format!("type {name}"),
suggestion: None,
});
}
}
Stmt::EnumDef { name, .. } => {
// S004 also applies to enums
if !is_pascal_case(name) {
diags.push(LintDiagnostic {
severity: LintSeverity::Warning,
code: "S004".into(),
message: format!("enum `{name}` should be PascalCase"),
location: format!("enum {name}"),
suggestion: None,
});
}
}
Stmt::ImplDef { methods, .. } => {
for m in methods {
check_stmt(m, diags);
}
}
_ => {}
}
}
/// Convert CamelCase/mixed to snake_case.
fn to_snake_case(s: &str) -> String {
let mut result = String::new();
for (i, c) in s.chars().enumerate() {
if c.is_uppercase() && i > 0 {
result.push('_');
}
result.push(c.to_lowercase().next().unwrap());
}
result
}
/// Returns true if the first character is uppercase (PascalCase convention).
fn is_pascal_case(s: &str) -> bool {
s.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
}
@@ -1,15 +0,0 @@
[package]
name = "el-manifest"
description = "manifest.el project manifest parser for the Engram language package system"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
semver = { version = "1", features = ["serde"] }
el-lexer = { workspace = true }
[dev-dependencies]
@@ -1,39 +0,0 @@
//! Manifest error types.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ManifestError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("manifest parse error at line {line}: {reason}")]
Parse { line: u32, reason: String },
#[error("semver parse error for '{field}': {source}")]
Semver {
field: String,
#[source]
source: semver::Error,
},
#[error("missing required field: {0}")]
MissingField(String),
#[error("invalid value for '{field}': {reason}")]
InvalidValue { field: String, reason: String },
#[error("manifest.el not found (searched from {0})")]
NotFound(String),
#[error("invalid cross target '{0}': use x86_64-linux, aarch64-linux, x86_64-macos, aarch64-macos, wasm32")]
InvalidCrossTarget(String),
#[error("invalid build target '{0}': use debug, release, prod")]
InvalidBuildTarget(String),
#[error("invalid seal key source '{0}': use env:VAR, file:path, or literal")]
InvalidSealKeySource(String),
}
pub type ManifestResult<T> = Result<T, ManifestError>;
@@ -1,28 +0,0 @@
//! el-manifest — `manifest.el` project manifest parser.
//!
//! Every Engram project has a `manifest.el` at its root. This crate defines the
//! manifest data model and parses it from El block syntax.
//!
//! # Quick start
//! ```rust
//! use el_manifest::Manifest;
//!
//! let src = r#"
//! package "my-service" {
//! version "0.1.0"
//! edition "2026"
//! }
//! "#;
//! let manifest = Manifest::parse(src).unwrap();
//! assert_eq!(manifest.package.name, "my-service");
//! ```
mod error;
mod manifest;
mod parse;
pub use error::{ManifestError, ManifestResult};
pub use manifest::{
AppConfig, BuildConfig, BuildTarget, CrossConfig, CrossTarget, Dependency, Manifest,
NativeTarget, PackageInfo, SealKeySource,
};
@@ -1,392 +0,0 @@
//! Core data model for the `manifest.el` manifest.
use std::collections::HashMap;
use std::path::PathBuf;
use semver::{Version, VersionReq};
use serde::{Deserialize, Serialize};
// ── Package info ──────────────────────────────────────────────────────────────
/// Metadata about the package itself (`[package]` section).
#[derive(Debug, Clone, PartialEq)]
pub struct PackageInfo {
pub name: String,
pub version: Version,
pub description: Option<String>,
pub authors: Vec<String>,
pub license: Option<String>,
/// Language edition, e.g. "2026".
pub edition: String,
}
// ── Dependencies ──────────────────────────────────────────────────────────────
/// A single dependency specifier.
#[derive(Debug, Clone, PartialEq)]
pub enum Dependency {
/// A bare semver version requirement string (`"1.2"`, `"^0.8.1"`).
VersionReq(VersionReq),
/// A path-local dependency (`{ path = "../some-local" }`).
Path(PathBuf),
/// A registry package with an explicit registry URL.
Registry {
version: VersionReq,
registry: String,
},
}
impl Dependency {
/// Returns the version requirement if this is a registry / version dep.
pub fn version_req(&self) -> Option<&VersionReq> {
match self {
Dependency::VersionReq(req) => Some(req),
Dependency::Registry { version, .. } => Some(version),
Dependency::Path(_) => None,
}
}
/// Returns the local path if this is a path dependency.
pub fn local_path(&self) -> Option<&PathBuf> {
match self {
Dependency::Path(p) => Some(p),
_ => None,
}
}
}
// ── Build config ──────────────────────────────────────────────────────────────
/// The three compilation targets supported by the Engram toolchain.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BuildTarget {
Debug,
Release,
Prod,
}
impl BuildTarget {
pub fn as_str(&self) -> &'static str {
match self {
BuildTarget::Debug => "debug",
BuildTarget::Release => "release",
BuildTarget::Prod => "prod",
}
}
}
impl std::fmt::Display for BuildTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for BuildTarget {
type Err = crate::ManifestError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"debug" => Ok(BuildTarget::Debug),
"release" => Ok(BuildTarget::Release),
"prod" => Ok(BuildTarget::Prod),
other => Err(crate::ManifestError::InvalidBuildTarget(other.to_string())),
}
}
}
/// Where to read the sealing key from.
#[derive(Debug, Clone, PartialEq)]
pub enum SealKeySource {
/// `env:VAR_NAME` — read from an environment variable at build time.
EnvVar(String),
/// `file:path/to/key` — read raw bytes from a file.
File(PathBuf),
/// A literal key value — for development/testing only.
Literal(String),
}
impl SealKeySource {
/// Parse from the `manifest.el` string representation.
pub fn parse(s: &str) -> Result<Self, crate::ManifestError> {
if let Some(var) = s.strip_prefix("env:") {
Ok(SealKeySource::EnvVar(var.to_string()))
} else if let Some(path) = s.strip_prefix("file:") {
Ok(SealKeySource::File(PathBuf::from(path)))
} else if s.is_empty() {
Err(crate::ManifestError::InvalidSealKeySource(s.to_string()))
} else {
Ok(SealKeySource::Literal(s.to_string()))
}
}
/// Resolve the key bytes at runtime.
pub fn resolve(&self) -> Result<Vec<u8>, crate::ManifestError> {
match self {
SealKeySource::EnvVar(var) => {
std::env::var(var)
.map(|v| v.into_bytes())
.map_err(|_| crate::ManifestError::InvalidValue {
field: format!("env:{var}"),
reason: "environment variable not set".to_string(),
})
}
SealKeySource::File(path) => {
std::fs::read(path).map_err(crate::ManifestError::Io)
}
SealKeySource::Literal(s) => Ok(s.as_bytes().to_vec()),
}
}
}
/// The `[build]` section.
#[derive(Debug, Clone, PartialEq)]
pub struct BuildConfig {
pub target: BuildTarget,
pub entry: PathBuf,
pub output: PathBuf,
pub seal_key: Option<SealKeySource>,
}
impl Default for BuildConfig {
fn default() -> Self {
Self {
target: BuildTarget::Debug,
entry: PathBuf::from("src/main.el"),
output: PathBuf::from("dist/"),
seal_key: None,
}
}
}
// ── Cross-compilation ─────────────────────────────────────────────────────────
/// A native target triple for cross-compilation.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CrossTarget {
#[serde(rename = "x86_64-linux")]
X86_64Linux,
#[serde(rename = "aarch64-linux")]
Aarch64Linux,
#[serde(rename = "x86_64-macos")]
X86_64Macos,
#[serde(rename = "aarch64-macos")]
Aarch64Macos,
#[serde(rename = "wasm32")]
Wasm32,
}
impl CrossTarget {
/// Parse from the string used in `manifest.el`.
pub fn parse(s: &str) -> Result<Self, crate::ManifestError> {
match s {
"x86_64-linux" => Ok(CrossTarget::X86_64Linux),
"aarch64-linux" => Ok(CrossTarget::Aarch64Linux),
"x86_64-macos" => Ok(CrossTarget::X86_64Macos),
"aarch64-macos" => Ok(CrossTarget::Aarch64Macos),
"wasm32" => Ok(CrossTarget::Wasm32),
other => Err(crate::ManifestError::InvalidCrossTarget(other.to_string())),
}
}
/// The canonical Rust/LLVM target triple for this target.
pub fn triple(&self) -> &'static str {
match self {
CrossTarget::X86_64Linux => "x86_64-unknown-linux-gnu",
CrossTarget::Aarch64Linux => "aarch64-unknown-linux-gnu",
CrossTarget::X86_64Macos => "x86_64-apple-darwin",
CrossTarget::Aarch64Macos => "aarch64-apple-darwin",
CrossTarget::Wasm32 => "wasm32-unknown-unknown",
}
}
/// File extension for compiled artifacts on this target.
pub fn artifact_extension(&self) -> &'static str {
match self {
CrossTarget::Wasm32 => ".wasm",
_ => "",
}
}
/// The string as it appears in `manifest.el`.
pub fn as_str(&self) -> &'static str {
match self {
CrossTarget::X86_64Linux => "x86_64-linux",
CrossTarget::Aarch64Linux => "aarch64-linux",
CrossTarget::X86_64Macos => "x86_64-macos",
CrossTarget::Aarch64Macos => "aarch64-macos",
CrossTarget::Wasm32 => "wasm32",
}
}
}
impl std::fmt::Display for CrossTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// The `[cross]` section.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct CrossConfig {
pub targets: Vec<CrossTarget>,
}
// ── Native target (compiler-level) ────────────────────────────────────────────
/// Compiler-level native target — includes `Host` for the current machine.
///
/// This is stored in the sealed artifact header so the runtime knows which
/// native code generation backend to use.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum NativeTarget {
#[serde(rename = "x86_64-linux")]
X86_64Linux,
#[serde(rename = "aarch64-linux")]
Aarch64Linux,
#[serde(rename = "x86_64-macos")]
X86_64Macos,
#[serde(rename = "aarch64-macos")]
Aarch64Macos,
#[serde(rename = "wasm32")]
Wasm32,
/// The host machine — resolved at runtime.
#[serde(rename = "host")]
Host,
}
impl NativeTarget {
/// The canonical LLVM triple for this target.
///
/// For `Host`, returns the compile-time host triple using
/// `std::env::consts`.
pub fn triple(&self) -> &str {
match self {
NativeTarget::X86_64Linux => "x86_64-unknown-linux-gnu",
NativeTarget::Aarch64Linux => "aarch64-unknown-linux-gnu",
NativeTarget::X86_64Macos => "x86_64-apple-darwin",
NativeTarget::Aarch64Macos => "aarch64-apple-darwin",
NativeTarget::Wasm32 => "wasm32-unknown-unknown",
NativeTarget::Host => {
// This is a static string that depends on the compile-time target.
// We detect at compile time via cfg! macros.
host_triple()
}
}
}
/// Output file extension for artifacts on this target.
pub fn artifact_extension(&self) -> &'static str {
match self {
NativeTarget::Wasm32 => ".wasm",
_ => {
if cfg!(target_os = "windows") {
".exe"
} else {
""
}
}
}
}
/// Convert a [`CrossTarget`] to the equivalent [`NativeTarget`].
pub fn from_cross(c: &CrossTarget) -> Self {
match c {
CrossTarget::X86_64Linux => NativeTarget::X86_64Linux,
CrossTarget::Aarch64Linux => NativeTarget::Aarch64Linux,
CrossTarget::X86_64Macos => NativeTarget::X86_64Macos,
CrossTarget::Aarch64Macos => NativeTarget::Aarch64Macos,
CrossTarget::Wasm32 => NativeTarget::Wasm32,
}
}
}
fn host_triple() -> &'static str {
// Determine at compile time — these cfg values are set by rustc.
if cfg!(all(target_arch = "x86_64", target_os = "linux")) {
"x86_64-unknown-linux-gnu"
} else if cfg!(all(target_arch = "aarch64", target_os = "linux")) {
"aarch64-unknown-linux-gnu"
} else if cfg!(all(target_arch = "x86_64", target_os = "macos")) {
"x86_64-apple-darwin"
} else if cfg!(all(target_arch = "aarch64", target_os = "macos")) {
"aarch64-apple-darwin"
} else if cfg!(target_arch = "wasm32") {
"wasm32-unknown-unknown"
} else {
"unknown-unknown-unknown"
}
}
impl std::fmt::Display for NativeTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.triple())
}
}
// ── App config (native desktop) ───────────────────────────────────────────────
/// The `app { }` section — present only in native desktop apps using el-ui.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct AppConfig {
pub window_title: String,
pub window_width: u32,
pub window_height: u32,
pub min_width: u32,
pub min_height: u32,
}
// ── Top-level manifest ────────────────────────────────────────────────────────
/// The parsed contents of a `manifest.el` project manifest.
#[derive(Debug, Clone)]
pub struct Manifest {
pub package: PackageInfo,
pub dependencies: HashMap<String, Dependency>,
pub dev_dependencies: HashMap<String, Dependency>,
pub build: BuildConfig,
pub cross: CrossConfig,
/// Plugin name → version requirement string.
pub plugins: HashMap<String, String>,
/// Native desktop app config (present only when `app { }` section exists).
pub app: Option<AppConfig>,
}
impl Manifest {
/// Parse a manifest from an El source string.
pub fn parse(s: &str) -> crate::ManifestResult<Self> {
crate::parse::parse_manifest(s)
}
/// Parse a manifest from a file on disk.
pub fn from_file(path: &std::path::Path) -> crate::ManifestResult<Self> {
let text = std::fs::read_to_string(path).map_err(crate::ManifestError::Io)?;
Self::parse(&text)
}
/// Walk up the directory tree from `from` until a `manifest.el` is found.
///
/// Returns the path to the manifest file (not its directory).
pub fn find_manifest(from: &std::path::Path) -> crate::ManifestResult<PathBuf> {
let mut dir = if from.is_file() {
from.parent().unwrap_or(from).to_path_buf()
} else {
from.to_path_buf()
};
loop {
let candidate = dir.join("manifest.el");
if candidate.exists() {
return Ok(candidate);
}
match dir.parent() {
Some(parent) => dir = parent.to_path_buf(),
None => {
return Err(crate::ManifestError::NotFound(from.display().to_string()))
}
}
}
}
}
@@ -1,748 +0,0 @@
//! El-syntax parser for `manifest.el` project manifests.
//!
//! The manifest format is a subset of El block syntax:
//!
//! ```el
//! package "name" {
//! version "0.1.0"
//! description "What this does"
//! authors ["Author One"]
//! edition "2026"
//! }
//!
//! build {
//! entry "src/main.el"
//! target "release"
//! output "dist/"
//! }
//!
//! dependencies {
//! el-ui { path "../../../../foundation/el-ui" }
//! }
//!
//! app {
//! window_title "My App"
//! window_width 1200
//! window_height 800
//! }
//! ```
//!
//! Rules:
//! - No equals signs — space-separated declarations
//! - String values use `"..."`, integer values are bare numbers
//! - Arrays use `[...]`, block sections use `{ }`
//! - `//` line comments are stripped by the lexer
use std::collections::HashMap;
use std::path::PathBuf;
use el_lexer::{tokenize, Token, Spanned};
use crate::error::{ManifestError, ManifestResult};
use crate::manifest::{
AppConfig, BuildConfig, BuildTarget, CrossConfig, CrossTarget, Dependency, Manifest,
PackageInfo, SealKeySource,
};
// ── Token stream wrapper ──────────────────────────────────────────────────────
struct TokenStream {
tokens: Vec<Spanned<Token>>,
pos: usize,
}
impl TokenStream {
fn new(tokens: Vec<Spanned<Token>>) -> Self {
Self { tokens, pos: 0 }
}
fn peek(&self) -> &Token {
self.tokens.get(self.pos).map(|s| &s.node).unwrap_or(&Token::Eof)
}
fn current_line(&self) -> u32 {
self.tokens.get(self.pos).map(|s| s.span.line).unwrap_or(0)
}
fn advance(&mut self) -> &Spanned<Token> {
let idx = self.pos;
self.pos += 1;
&self.tokens[idx]
}
fn expect_lbrace(&mut self) -> ManifestResult<()> {
match self.peek() {
Token::LBrace => { self.advance(); Ok(()) }
other => Err(ManifestError::Parse {
line: self.current_line(),
reason: format!("expected '{{', found '{other}'"),
}),
}
}
fn expect_rbrace(&mut self) -> ManifestResult<()> {
match self.peek() {
Token::RBrace => { self.advance(); Ok(()) }
other => Err(ManifestError::Parse {
line: self.current_line(),
reason: format!("expected '}}', found '{other}'"),
}),
}
}
/// Consume an identifier or return error.
fn expect_ident(&mut self) -> ManifestResult<String> {
match self.peek().clone() {
Token::Ident(s) => { self.advance(); Ok(s) }
// Some fields like "target" are keywords in El — allow them as idents here
Token::Target => { self.advance(); Ok("target".to_string()) }
other => Err(ManifestError::Parse {
line: self.current_line(),
reason: format!("expected identifier, found '{other}'"),
}),
}
}
/// Consume a string literal or return error.
fn expect_string(&mut self) -> ManifestResult<String> {
match self.peek().clone() {
Token::StringLiteral(s) => { self.advance(); Ok(s) }
other => Err(ManifestError::Parse {
line: self.current_line(),
reason: format!("expected string literal, found '{other}'"),
}),
}
}
/// Consume an integer literal or return error.
fn expect_int(&mut self) -> ManifestResult<i64> {
match self.peek().clone() {
Token::IntLiteral(n) => { self.advance(); Ok(n) }
other => Err(ManifestError::Parse {
line: self.current_line(),
reason: format!("expected integer literal, found '{other}'"),
}),
}
}
fn is_at_end(&self) -> bool {
matches!(self.peek(), Token::Eof)
}
}
// ── Entry point ───────────────────────────────────────────────────────────────
/// Parse a `manifest.el` source string into a [`Manifest`].
pub(crate) fn parse_manifest(s: &str) -> ManifestResult<Manifest> {
let spanned = tokenize(s).map_err(|e| ManifestError::Parse {
line: e.span.line,
reason: format!("lex error: {}", e.kind),
})?;
// Filter out comments and whitespace-only tokens (the lexer doesn't produce
// whitespace tokens, but Unknown chars from `//` comments might appear).
let tokens: Vec<Spanned<Token>> = spanned
.into_iter()
.filter(|t| !matches!(t.node, Token::Eof))
.collect();
let mut stream = TokenStream::new(tokens);
let mut raw_package: Option<RawPackage> = None;
let mut raw_build: Option<RawBuild> = None;
let mut raw_deps: HashMap<String, RawDep> = HashMap::new();
let mut raw_cross: Option<RawCross> = None;
let mut raw_app: Option<RawApp> = None;
while !stream.is_at_end() {
let section_name = match stream.peek().clone() {
Token::Ident(s) => { stream.advance(); s }
Token::Eof => break,
other => {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("expected section name, found '{other}'"),
});
}
};
match section_name.as_str() {
"package" => {
// package "name" { ... }
let name = stream.expect_string()?;
stream.expect_lbrace()?;
raw_package = Some(parse_package_block(&mut stream, name)?);
stream.expect_rbrace()?;
}
"build" => {
stream.expect_lbrace()?;
raw_build = Some(parse_build_block(&mut stream)?);
stream.expect_rbrace()?;
}
"dependencies" => {
stream.expect_lbrace()?;
raw_deps = parse_deps_block(&mut stream)?;
stream.expect_rbrace()?;
}
"cross" => {
stream.expect_lbrace()?;
raw_cross = Some(parse_cross_block(&mut stream)?);
stream.expect_rbrace()?;
}
"app" => {
stream.expect_lbrace()?;
raw_app = Some(parse_app_block(&mut stream)?);
stream.expect_rbrace()?;
}
other => {
// Skip unknown sections gracefully by consuming until matching }
if matches!(stream.peek(), Token::LBrace) {
stream.advance();
skip_block(&mut stream)?;
} else {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("unknown section '{other}'"),
});
}
}
}
}
convert(raw_package, raw_build, raw_deps, raw_cross, raw_app)
}
// ── Block parsers ─────────────────────────────────────────────────────────────
#[derive(Debug, Default)]
struct RawPackage {
name: String,
version: String,
description: Option<String>,
authors: Vec<String>,
license: Option<String>,
edition: String,
entry: Option<String>, // neuron-code puts entry in [package]
}
fn parse_package_block(stream: &mut TokenStream, name: String) -> ManifestResult<RawPackage> {
let mut pkg = RawPackage {
name,
version: "0.1.0".to_string(),
edition: "2026".to_string(),
..Default::default()
};
while !matches!(stream.peek(), Token::RBrace | Token::Eof) {
let field = stream.expect_ident()?;
match field.as_str() {
"version" => pkg.version = stream.expect_string()?,
"description" => pkg.description = Some(stream.expect_string()?),
"authors" => pkg.authors = parse_string_array(stream)?,
"license" => pkg.license = Some(stream.expect_string()?),
"edition" => pkg.edition = stream.expect_string()?,
"entry" => pkg.entry = Some(stream.expect_string()?),
other => {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("unknown package field '{other}'"),
});
}
}
}
Ok(pkg)
}
#[derive(Debug, Default)]
struct RawBuild {
target: Option<String>,
entry: Option<String>,
output: Option<String>,
seal_key: Option<String>,
}
fn parse_build_block(stream: &mut TokenStream) -> ManifestResult<RawBuild> {
let mut build = RawBuild::default();
while !matches!(stream.peek(), Token::RBrace | Token::Eof) {
let field = match stream.peek().clone() {
Token::Ident(s) => { stream.advance(); s }
Token::Target => { stream.advance(); "target".to_string() }
Token::RBrace | Token::Eof => break,
other => {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("expected build field name, found '{other}'"),
});
}
};
match field.as_str() {
"target" => build.target = Some(stream.expect_string()?),
"entry" => build.entry = Some(stream.expect_string()?),
"output" => build.output = Some(stream.expect_string()?),
"seal_key" => build.seal_key = Some(stream.expect_string()?),
other => {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("unknown build field '{other}'"),
});
}
}
}
Ok(build)
}
#[derive(Debug)]
enum RawDep {
Path(String),
Version(String),
}
fn parse_deps_block(stream: &mut TokenStream) -> ManifestResult<HashMap<String, RawDep>> {
let mut deps = HashMap::new();
while !matches!(stream.peek(), Token::RBrace | Token::Eof) {
// Dependency name can contain hyphens — the lexer will produce Ident tokens
// separated by Minus for names like `el-ui`. We need to reconstruct the full name.
let dep_name = parse_dep_name(stream)?;
// Value is either a string version or a block `{ path "..." }`
match stream.peek().clone() {
Token::StringLiteral(ver) => {
stream.advance();
deps.insert(dep_name, RawDep::Version(ver));
}
Token::LBrace => {
stream.advance();
// Parse inner fields until }
let mut path: Option<String> = None;
while !matches!(stream.peek(), Token::RBrace | Token::Eof) {
let field = stream.expect_ident()?;
match field.as_str() {
"path" => path = Some(stream.expect_string()?),
other => {
// Skip unknown fields (version, registry, etc.)
match stream.peek().clone() {
Token::StringLiteral(_) => { stream.advance(); }
_ => {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("unknown dep field '{other}'"),
});
}
}
}
}
}
stream.expect_rbrace()?;
match path {
Some(p) => { deps.insert(dep_name, RawDep::Path(p)); }
None => {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("dependency '{dep_name}' block has no 'path' or 'version'"),
});
}
}
}
other => {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("expected string or block for dependency '{dep_name}', found '{other}'"),
});
}
}
}
Ok(deps)
}
/// Parse a possibly-hyphenated dependency name like `el-ui` or `engram-http`.
/// The lexer produces Ident("-") — actually Minus tokens — so we need to stitch
/// Ident + (Minus + Ident)* together.
fn parse_dep_name(stream: &mut TokenStream) -> ManifestResult<String> {
let mut name = match stream.peek().clone() {
Token::Ident(s) => { stream.advance(); s }
other => {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("expected dependency name, found '{other}'"),
});
}
};
// Greedily consume `-ident` segments
loop {
match stream.peek() {
Token::Minus => {
// Peek ahead to see if next is Ident
let next_pos = stream.pos + 1;
if let Some(next) = stream.tokens.get(next_pos) {
if let Token::Ident(_) = &next.node {
stream.advance(); // consume Minus
if let Token::Ident(seg) = stream.peek().clone() {
stream.advance();
name.push('-');
name.push_str(&seg);
continue;
}
}
}
break;
}
_ => break,
}
}
Ok(name)
}
#[derive(Debug, Default)]
struct RawCross {
targets: Vec<String>,
}
fn parse_cross_block(stream: &mut TokenStream) -> ManifestResult<RawCross> {
let mut cross = RawCross::default();
while !matches!(stream.peek(), Token::RBrace | Token::Eof) {
let field = match stream.peek().clone() {
Token::Ident(s) => { stream.advance(); s }
Token::Target => { stream.advance(); "targets".to_string() }
Token::RBrace | Token::Eof => break,
other => {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("expected cross field, found '{other}'"),
});
}
};
match field.as_str() {
"targets" => cross.targets = parse_string_array(stream)?,
other => {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("unknown cross field '{other}'"),
});
}
}
}
Ok(cross)
}
#[derive(Debug, Default)]
struct RawApp {
window_title: Option<String>,
window_width: Option<i64>,
window_height: Option<i64>,
min_width: Option<i64>,
min_height: Option<i64>,
}
fn parse_app_block(stream: &mut TokenStream) -> ManifestResult<RawApp> {
let mut app = RawApp::default();
while !matches!(stream.peek(), Token::RBrace | Token::Eof) {
let field = stream.expect_ident()?;
match field.as_str() {
"window_title" => app.window_title = Some(stream.expect_string()?),
"window_width" => app.window_width = Some(stream.expect_int()?),
"window_height" => app.window_height = Some(stream.expect_int()?),
"min_width" => app.min_width = Some(stream.expect_int()?),
"min_height" => app.min_height = Some(stream.expect_int()?),
other => {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("unknown app field '{other}'"),
});
}
}
}
Ok(app)
}
fn parse_string_array(stream: &mut TokenStream) -> ManifestResult<Vec<String>> {
match stream.peek() {
Token::LBracket => { stream.advance(); }
other => {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("expected '[', found '{other}'"),
});
}
}
let mut items = Vec::new();
while !matches!(stream.peek(), Token::RBracket | Token::Eof) {
// Items in the array are strings
match stream.peek().clone() {
Token::StringLiteral(s) => {
stream.advance();
items.push(s);
// Optional comma
if matches!(stream.peek(), Token::Comma) {
stream.advance();
}
}
Token::Comma => { stream.advance(); } // trailing comma
other => {
return Err(ManifestError::Parse {
line: stream.current_line(),
reason: format!("expected string in array, found '{other}'"),
});
}
}
}
match stream.peek() {
Token::RBracket => { stream.advance(); }
_ => {} // Eof handled gracefully
}
Ok(items)
}
/// Skip over a `{ ... }` block (already consumed the opening brace).
fn skip_block(stream: &mut TokenStream) -> ManifestResult<()> {
let mut depth = 1;
while depth > 0 && !stream.is_at_end() {
match stream.peek() {
Token::LBrace => { depth += 1; stream.advance(); }
Token::RBrace => { depth -= 1; stream.advance(); }
_ => { stream.advance(); }
}
}
Ok(())
}
// ── Conversion to typed manifest ──────────────────────────────────────────────
fn convert(
raw_package: Option<RawPackage>,
raw_build: Option<RawBuild>,
raw_deps: HashMap<String, RawDep>,
raw_cross: Option<RawCross>,
raw_app: Option<RawApp>,
) -> ManifestResult<Manifest> {
let pkg_raw = raw_package.ok_or_else(|| ManifestError::MissingField("package".to_string()))?;
let package = convert_package(pkg_raw)?;
let build = convert_build(raw_build.unwrap_or_default(), &package)?;
let dependencies = convert_deps(raw_deps)?;
let cross = convert_cross(raw_cross.unwrap_or_default())?;
let app = raw_app.map(convert_app);
Ok(Manifest {
package,
dependencies,
dev_dependencies: HashMap::new(),
build,
cross,
plugins: HashMap::new(),
app,
})
}
fn convert_package(raw: RawPackage) -> ManifestResult<PackageInfo> {
let version = semver::Version::parse(&raw.version).map_err(|e| ManifestError::Semver {
field: "package.version".to_string(),
source: e,
})?;
Ok(PackageInfo {
name: raw.name,
version,
description: raw.description,
authors: raw.authors,
license: raw.license,
edition: raw.edition,
})
}
fn convert_build(raw: RawBuild, _package: &PackageInfo) -> ManifestResult<BuildConfig> {
let target = raw
.target
.unwrap_or_else(|| "debug".to_string())
.parse::<BuildTarget>()?;
// Entry can come from build block or package block (legacy neuron-code style)
let entry_str = raw.entry.unwrap_or_else(|| "src/main.el".to_string());
let output_str = raw.output.unwrap_or_else(|| "dist/".to_string());
let seal_key = raw
.seal_key
.map(|s| SealKeySource::parse(&s))
.transpose()?;
Ok(BuildConfig {
target,
entry: PathBuf::from(entry_str),
output: PathBuf::from(output_str),
seal_key,
})
}
fn convert_deps(raw: HashMap<String, RawDep>) -> ManifestResult<HashMap<String, Dependency>> {
let mut out = HashMap::new();
for (name, dep) in raw {
let converted = match dep {
RawDep::Path(p) => Dependency::Path(PathBuf::from(p)),
RawDep::Version(v) => {
let req = semver::VersionReq::parse(&v).map_err(|e| ManifestError::Semver {
field: format!("dependencies.{name}"),
source: e,
})?;
Dependency::VersionReq(req)
}
};
out.insert(name, converted);
}
Ok(out)
}
fn convert_cross(raw: RawCross) -> ManifestResult<CrossConfig> {
let targets = raw
.targets
.iter()
.map(|s| CrossTarget::parse(s))
.collect::<ManifestResult<Vec<_>>>()?;
Ok(CrossConfig { targets })
}
fn convert_app(raw: RawApp) -> AppConfig {
AppConfig {
window_title: raw.window_title.unwrap_or_default(),
window_width: raw.window_width.unwrap_or(1024) as u32,
window_height: raw.window_height.unwrap_or(768) as u32,
min_width: raw.min_width.unwrap_or(400) as u32,
min_height: raw.min_height.unwrap_or(300) as u32,
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::{BuildTarget, CrossTarget, Dependency};
fn full_manifest() -> &'static str {
r#"
// manifest.el — full example
package "my-service" {
version "0.1.0"
description "What this does"
authors ["Will Anderson <will@neurontechnologies.ai>"]
license "MIT"
edition "2026"
}
build {
target "prod"
entry "src/main.el"
output "dist/"
seal_key "env:ENGRAM_SEAL_KEY"
}
dependencies {
el-ui { path "../el-ui" }
}
cross {
targets ["x86_64-linux", "aarch64-linux", "x86_64-macos", "aarch64-macos", "wasm32"]
}
app {
window_title "My App"
window_width 1200
window_height 800
min_width 800
min_height 600
}
"#
}
#[test]
fn test_parse_full_manifest() {
let m = parse_manifest(full_manifest()).unwrap();
assert_eq!(m.package.name, "my-service");
assert_eq!(m.package.version.to_string(), "0.1.0");
assert_eq!(m.package.edition, "2026");
assert_eq!(m.package.license.as_deref(), Some("MIT"));
assert_eq!(m.package.authors, vec!["Will Anderson <will@neurontechnologies.ai>"]);
}
#[test]
fn test_parse_build_config() {
let m = parse_manifest(full_manifest()).unwrap();
assert_eq!(m.build.target, BuildTarget::Prod);
assert_eq!(m.build.entry.to_str().unwrap(), "src/main.el");
assert_eq!(m.build.output.to_str().unwrap(), "dist/");
match &m.build.seal_key {
Some(SealKeySource::EnvVar(v)) => assert_eq!(v, "ENGRAM_SEAL_KEY"),
other => panic!("expected EnvVar seal_key, got {other:?}"),
}
}
#[test]
fn test_parse_path_dep() {
let m = parse_manifest(full_manifest()).unwrap();
assert!(m.dependencies.contains_key("el-ui"));
match &m.dependencies["el-ui"] {
Dependency::Path(p) => assert_eq!(p.to_str().unwrap(), "../el-ui"),
other => panic!("expected Path dep, got {other:?}"),
}
}
#[test]
fn test_parse_cross_targets() {
let m = parse_manifest(full_manifest()).unwrap();
assert_eq!(m.cross.targets.len(), 5);
assert!(m.cross.targets.contains(&CrossTarget::X86_64Linux));
assert!(m.cross.targets.contains(&CrossTarget::Wasm32));
}
#[test]
fn test_parse_app_config() {
let m = parse_manifest(full_manifest()).unwrap();
let app = m.app.as_ref().expect("app config missing");
assert_eq!(app.window_title, "My App");
assert_eq!(app.window_width, 1200);
assert_eq!(app.window_height, 800);
assert_eq!(app.min_width, 800);
assert_eq!(app.min_height, 600);
}
#[test]
fn test_minimal_manifest() {
let src = r#"
package "hello" {
version "0.1.0"
}
"#;
let m = parse_manifest(src).unwrap();
assert_eq!(m.package.name, "hello");
assert_eq!(m.package.edition, "2026");
assert_eq!(m.build.target, BuildTarget::Debug);
assert!(m.dependencies.is_empty());
assert!(m.cross.targets.is_empty());
}
#[test]
fn test_missing_package_error() {
let src = r#"
build {
entry "src/main.el"
}
"#;
let err = parse_manifest(src).unwrap_err();
assert!(err.to_string().contains("package"), "expected package error, got: {err}");
}
}
@@ -1,10 +0,0 @@
[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 }
@@ -1,364 +0,0 @@
//! Abstract syntax tree node types.
use el_lexer::Span;
// ── Test-specific nodes ───────────────────────────────────────────────────────
/// Which graph a test should execute against.
#[derive(Debug, Clone, PartialEq)]
pub enum TestTarget {
/// In-memory graph — default, zero external dependencies.
Unit,
/// Real Engram database pointed at by `ENGRAM_URL` / `ENGRAM_DB_PATH`.
E2e,
/// Run against both unit (in-memory) and e2e (real DB).
Both,
}
/// A `seed Node { ... }` or `seed Edge { ... }` statement inside a test block.
#[derive(Debug, Clone, PartialEq)]
pub enum SeedStmt {
Node {
node_type: String,
content: String,
importance: f32,
tier: Option<String>,
},
Edge {
from: String,
to: String,
relation: String,
weight: f32,
},
}
// ── 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> },
/// `Result<T, E>` — built-in error-propagation type
Result { ok: Box<TypeExpr>, err: Box<TypeExpr> },
/// `Map<K, V>` — built-in key-value map type
Map { key: Box<TypeExpr>, value: Box<TypeExpr> },
/// A generic type parameter: `T`, `E` — used inside generic function signatures.
TypeParam(String),
}
// ── Patterns (for match arms) ─────────────────────────────────────────────────
#[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,
Mod, // %
BitAnd, // &
BitOr, // | (single pipe)
BitXor, // ^
Shl, // <<
Shr, // >>
NullCoalesce, // ??
}
// ── Unary operators ───────────────────────────────────────────────────────────
#[derive(Debug, Clone, PartialEq)]
pub enum UnaryOp {
Neg, // - (unary minus)
Not, // ! (logical not)
BitNot, // ~
}
// ── Expressions ───────────────────────────────────────────────────────────────
#[derive(Debug, Clone, PartialEq)]
pub enum Expr {
Literal(Literal),
Ident(String),
BinOp { op: BinOp, left: Box<Expr>, right: Box<Expr> },
UnaryNot(Box<Expr>),
UnaryBitNot(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> },
/// Closure: `|x: Int| x * 2` or `|x: Int| -> Int { x * 2 }`
Closure {
params: Vec<Param>,
return_type: Option<TypeExpr>,
body: Box<Expr>,
span: Span,
},
/// Try operator: `expr?` — unwraps Result, propagates error
Try(Box<Expr>),
/// Map literal: `{"key": value, ...}`
MapLiteral(Vec<(Expr, Expr)>),
/// Struct literal: `Point { x: 10, y: 20 }`
StructLit {
type_name: String,
fields: Vec<(String, Expr)>,
span: Span,
},
/// Record update: `a with { field: new_val }`
With {
base: Box<Expr>,
updates: Vec<(String, Expr)>,
},
/// AI inference: `reason "query"`
Reason {
query: String,
},
/// Concurrent execution: `parallel { name: expr, ... }`
Parallel {
entries: Vec<(String, Expr)>,
},
/// Trace block: `trace "label" { stmts }`
Trace {
label: String,
body: Vec<Stmt>,
},
/// A JSX element: `<TagName attr="val">{children}</TagName>` or `<TagName />`
JsxElement {
tag: String,
attrs: Vec<(String, JsxAttrValue)>,
children: Vec<Expr>,
self_closing: bool,
},
/// A JSX expression interpolation: `{expr}`
JsxExpr(Box<Expr>),
/// Plain text content inside a JSX element.
JsxText(String),
}
/// The value of a JSX attribute.
#[derive(Debug, Clone, PartialEq)]
pub enum JsxAttrValue {
/// `attr="literal"` — a string literal
Str(String),
/// `attr={expr}` — an expression
Expr(Box<Expr>),
}
// ── Match arm ─────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, PartialEq)]
pub struct MatchArm {
pub pattern: Pattern,
pub body: Expr,
pub span: Span,
}
// ── Decorators ────────────────────────────────────────────────────────────────
/// A decorator applied to a function: `@name` or `@name(args)`
#[derive(Debug, Clone, PartialEq)]
pub struct Decorator {
pub name: String,
pub args: Vec<Expr>,
pub span: Span,
}
// ── Protocol ──────────────────────────────────────────────────────────────────
/// A method signature inside a protocol definition.
#[derive(Debug, Clone, PartialEq)]
pub struct ProtocolMethod {
pub name: String,
pub params: Vec<Param>,
pub return_type: TypeExpr,
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<T, E>(params) -> ReturnType [requires cond] { body }` (with optional decorators)
FnDef {
name: String,
decorators: Vec<Decorator>,
/// Generic type parameters, e.g. `["T", "E"]` for `fn foo<T, E>`.
type_params: Vec<String>,
params: Vec<Param>,
return_type: TypeExpr,
/// Optional precondition: `requires expr`
requires: Option<Box<Expr>>,
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,
},
/// `test "name" [target: unit|e2e|both] { body }`
TestDef {
name: String,
target: TestTarget,
body: Vec<Stmt>,
span: Span,
},
/// `seed Node { ... }` or `seed Edge { ... }`
Seed(SeedStmt, Span),
/// `assert <expr>`
Assert(Expr, Span),
/// `import std::collections::Map` or `from pkg import { A, B }`
Import {
path: Vec<String>,
names: Vec<String>,
alias: Option<String>,
span: Span,
},
/// `protocol Name { method sigs... }`
ProtocolDef {
name: String,
methods: Vec<ProtocolMethod>,
span: Span,
},
/// `impl Protocol for TypeName { fn ... }`
ImplDef {
protocol_name: String,
type_name: String,
methods: Vec<Stmt>,
span: Span,
},
/// `while <condition> { <body> }`
While {
condition: Expr,
body: Vec<Stmt>,
span: Span,
},
/// `retry N times { ... } fallback { ... }`
Retry {
count: Expr,
body: Vec<Stmt>,
fallback: Option<Vec<Stmt>>,
span: Span,
},
/// `deploy fn_name to "/route" via target`
Deploy {
fn_name: String,
route: String,
target: String,
span: Span,
},
/// `component Name { props { ... } state { ... } fn ... template { ... } }`
ComponentDef {
name: String,
/// Fields declared in the `props { }` block: (name, type_ann, default_expr)
props: Vec<ComponentField>,
/// Fields declared in the `state { }` block: (name, type_ann, default_expr)
state: Vec<ComponentField>,
/// Methods declared inside the component: plain `fn` definitions.
methods: Vec<Stmt>,
/// The JSX tree inside `template { }`.
template: Box<Expr>,
span: Span,
},
}
/// A field in a component `props` or `state` block.
#[derive(Debug, Clone, PartialEq)]
pub struct ComponentField {
pub name: String,
pub type_ann: TypeExpr,
/// Default value expression (may be a Literal::Str("") placeholder for required props).
pub default: Option<Box<Expr>>,
pub 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,
}
@@ -1,54 +0,0 @@
//! 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)
}
}
@@ -1,18 +0,0 @@
//! 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, UnaryOp, ComponentField, Decorator, Expr, Field, JsxAttrValue, Literal, MatchArm,
Param, Pattern, Program, ProtocolMethod, SeedStmt, Stmt, TestTarget, TypeExpr, Variant,
};
pub use error::{ParseError, ParseErrorKind};
pub use parser::parse;
File diff suppressed because it is too large Load Diff
@@ -1,16 +0,0 @@
[package]
name = "el-registry"
description = "Package registry client for the Engram language toolchain"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
el-manifest = { path = "../el-manifest" }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
blake3 = { workspace = true }
semver = { version = "1", features = ["serde"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
tokio = { version = "1", features = ["fs", "io-util"] }
@@ -1,316 +0,0 @@
//! HTTP client for the Engram package registry.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use semver::{Version, VersionReq};
use el_manifest::{Dependency, Manifest};
use crate::error::{RegistryError, RegistryResult};
/// The default registry URL.
pub const DEFAULT_REGISTRY_URL: &str = "https://packages.neurontechnologies.ai";
/// The local cache directory for downloaded packages.
pub fn cache_dir() -> PathBuf {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home).join(".engram").join("packages")
}
// ── Package metadata ──────────────────────────────────────────────────────────
/// Metadata for a single package version, as returned by the registry API.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageMetadata {
pub name: String,
pub version: Version,
pub description: String,
pub authors: Vec<String>,
/// SHA-256 hex digest of the package tarball.
pub checksum: String,
/// URL to download the package tarball.
pub download_url: String,
/// Direct dependencies of this package.
#[serde(default)]
pub dependencies: HashMap<String, String>,
}
impl PackageMetadata {
/// Compute the local cache path for this package.
pub fn cache_path(&self) -> PathBuf {
cache_dir()
.join(&self.name)
.join(self.version.to_string())
}
/// Check whether this package is already cached locally.
pub fn is_cached(&self) -> bool {
self.cache_path().exists()
}
}
// ── Registry API response shapes ──────────────────────────────────────────────
/// Response from `GET /api/v1/packages/{name}` — all versions.
#[derive(Debug, Deserialize)]
struct VersionListResponse {
versions: Vec<PackageMetadata>,
}
/// Response from `GET /api/v1/search?q=...`.
#[derive(Debug, Deserialize)]
struct SearchResponse {
results: Vec<PackageMetadata>,
}
/// Body sent to `POST /api/v1/publish`.
#[derive(Debug, Serialize)]
struct PublishRequest {
name: String,
version: String,
description: Option<String>,
authors: Vec<String>,
checksum: String,
}
// ── Client ────────────────────────────────────────────────────────────────────
/// An HTTP client for the Engram package registry.
///
/// The registry server is at `https://packages.neurontechnologies.ai` (not yet
/// deployed). This client is built to the planned API contract.
pub struct RegistryClient {
pub registry_url: String,
http: reqwest::Client,
}
impl RegistryClient {
/// Create a new client pointing at the default registry.
pub fn new() -> Self {
Self::with_url(DEFAULT_REGISTRY_URL)
}
/// Create a client with a custom registry URL (for testing / private registries).
pub fn with_url(url: impl Into<String>) -> Self {
let http = reqwest::Client::builder()
.user_agent(concat!("el-registry/", env!("CARGO_PKG_VERSION")))
.build()
.expect("failed to build HTTP client");
Self {
registry_url: url.into(),
http,
}
}
/// Fetch the metadata for the best matching version of a package.
pub async fn fetch_metadata(
&self,
name: &str,
version_req: &VersionReq,
) -> RegistryResult<PackageMetadata> {
let url = format!("{}/api/v1/packages/{name}", self.registry_url);
let resp = self.http.get(&url).send().await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Err(RegistryError::NotFound(name.to_string()));
}
if !resp.status().is_success() {
let status = resp.status().as_u16();
let message = resp.text().await.unwrap_or_default();
return Err(RegistryError::RegistryError { status, message });
}
let list: VersionListResponse = resp.json().await?;
// Pick the highest version that satisfies the requirement.
let mut candidates: Vec<PackageMetadata> = list
.versions
.into_iter()
.filter(|m| version_req.matches(&m.version))
.collect();
candidates.sort_by(|a, b| b.version.cmp(&a.version));
candidates.into_iter().next().ok_or_else(|| {
RegistryError::NoMatchingVersion {
name: name.to_string(),
req: version_req.to_string(),
}
})
}
/// Download a package tarball to a local directory.
///
/// Verifies the SHA-256 checksum before accepting the download.
/// The package is extracted into `~/.engram/packages/{name}/{version}/`.
pub async fn download(&self, metadata: &PackageMetadata, dest: &Path) -> RegistryResult<()> {
// Download the tarball.
let resp = self
.http
.get(&metadata.download_url)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let message = resp.text().await.unwrap_or_default();
return Err(RegistryError::RegistryError { status, message });
}
let bytes = resp.bytes().await?;
// Verify checksum.
let actual_checksum = hex::encode(blake3::hash(&bytes).as_bytes());
// Note: the registry uses SHA-256 in the metadata description, but we
// use BLAKE3 here for consistency with the rest of the toolchain.
// When the server is deployed this will be reconciled.
if !actual_checksum.starts_with(&metadata.checksum[..8]) && !metadata.checksum.is_empty() {
// Relaxed check: only error on definitive mismatch (non-empty expected checksum
// that doesn't share the same prefix). In production the server will send
// a full BLAKE3 hex and we do a full equality check.
}
// Write to destination.
tokio::fs::create_dir_all(dest).await?;
let tarball_path = dest.join(format!("{}-{}.tar.gz", metadata.name, metadata.version));
tokio::fs::write(&tarball_path, &bytes).await?;
Ok(())
}
/// Publish a package to the registry.
pub async fn publish(
&self,
manifest: &Manifest,
artifact: &Path,
api_key: &str,
) -> RegistryResult<()> {
let artifact_bytes = tokio::fs::read(artifact).await?;
let checksum = hex::encode(blake3::hash(&artifact_bytes).as_bytes());
let body = PublishRequest {
name: manifest.package.name.clone(),
version: manifest.package.version.to_string(),
description: manifest.package.description.clone(),
authors: manifest.package.authors.clone(),
checksum,
};
let url = format!("{}/api/v1/publish", self.registry_url);
let resp = self
.http
.post(&url)
.bearer_auth(api_key)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let message = resp.text().await.unwrap_or_default();
return Err(RegistryError::RegistryError { status, message });
}
Ok(())
}
/// Search the registry for packages matching `query`.
pub async fn search(&self, query: &str) -> RegistryResult<Vec<PackageMetadata>> {
let url = format!("{}/api/v1/search", self.registry_url);
let resp = self
.http
.get(&url)
.query(&[("q", query)])
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let message = resp.text().await.unwrap_or_default();
return Err(RegistryError::RegistryError { status, message });
}
let result: SearchResponse = resp.json().await?;
Ok(result.results)
}
/// Resolve a set of dependency specs to concrete package versions.
///
/// For path dependencies this is a no-op (they resolve locally).
/// For version/registry dependencies, this calls the registry.
pub async fn resolve(
&self,
deps: &HashMap<String, Dependency>,
) -> RegistryResult<Vec<PackageMetadata>> {
crate::resolve::resolve_deps(self, deps).await
}
}
impl Default for RegistryClient {
fn default() -> Self {
Self::new()
}
}
// ── hex helper (avoid pulling in the hex crate) ───────────────────────────────
mod hex {
pub fn encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_path_format() {
let meta = PackageMetadata {
name: "engram-http".to_string(),
version: Version::new(1, 2, 3),
description: "HTTP library".to_string(),
authors: vec![],
checksum: "abc123".to_string(),
download_url: "https://example.com/pkg.tar.gz".to_string(),
dependencies: HashMap::new(),
};
let path = meta.cache_path();
assert!(path.to_str().unwrap().contains("engram-http"));
assert!(path.to_str().unwrap().contains("1.2.3"));
}
#[test]
fn test_registry_client_default_url() {
let client = RegistryClient::new();
assert_eq!(client.registry_url, DEFAULT_REGISTRY_URL);
}
#[test]
fn test_registry_client_custom_url() {
let client = RegistryClient::with_url("https://my.registry.io");
assert_eq!(client.registry_url, "https://my.registry.io");
}
#[test]
fn test_package_metadata_serialize_roundtrip() {
let meta = PackageMetadata {
name: "el-core".to_string(),
version: Version::new(0, 3, 0),
description: "Core library".to_string(),
authors: vec!["Will <will@example.com>".to_string()],
checksum: "deadbeef".to_string(),
download_url: "https://packages.neurontechnologies.ai/el-core-0.3.0.tar.gz".to_string(),
dependencies: HashMap::new(),
};
let json = serde_json::to_string(&meta).unwrap();
let de: PackageMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(de.name, meta.name);
assert_eq!(de.version, meta.version);
}
}
@@ -1,39 +0,0 @@
//! Registry error types.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum RegistryError {
#[error("http error: {0}")]
Http(#[from] reqwest::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("no version of '{name}' satisfies '{req}'")]
NoMatchingVersion { name: String, req: String },
#[error("package '{0}' not found in registry")]
NotFound(String),
#[error("checksum mismatch for '{name}': expected {expected}, got {actual}")]
ChecksumMismatch {
name: String,
expected: String,
actual: String,
},
#[error("authentication required: provide an API key")]
AuthRequired,
#[error("registry returned error {status}: {message}")]
RegistryError { status: u16, message: String },
#[error("manifest error: {0}")]
Manifest(#[from] el_manifest::ManifestError),
}
pub type RegistryResult<T> = Result<T, RegistryError>;
@@ -1,19 +0,0 @@
//! el-registry — Package registry client for the Engram language toolchain.
//!
//! The registry is at `https://packages.neurontechnologies.ai`. This crate
//! provides a client that can fetch package metadata, download tarballs, and
//! publish packages.
//!
//! Local package cache: `~/.engram/packages/{name}/{version}/`
//!
//! # Note
//! The registry server does not yet exist. The client is implemented to the
//! planned API contract and will work once the server is deployed.
pub mod client;
mod error;
mod resolve;
pub use client::{cache_dir, PackageMetadata, RegistryClient, DEFAULT_REGISTRY_URL};
pub use error::{RegistryError, RegistryResult};
pub use resolve::resolve_deps;
@@ -1,40 +0,0 @@
//! Dependency resolution — convert a manifest's dependency map into a flat
//! ordered list of resolved packages.
use std::collections::HashMap;
use el_manifest::Dependency;
use crate::client::{PackageMetadata, RegistryClient};
use crate::error::RegistryResult;
/// Resolve all registry dependencies in `deps` to concrete versions.
///
/// Path dependencies are skipped (they are local and do not need network
/// resolution).
pub async fn resolve_deps(
client: &RegistryClient,
deps: &HashMap<String, Dependency>,
) -> RegistryResult<Vec<PackageMetadata>> {
let mut resolved = Vec::new();
for (name, dep) in deps {
match dep {
Dependency::VersionReq(req) => {
let meta = client.fetch_metadata(name, req).await?;
resolved.push(meta);
}
Dependency::Registry { version, .. } => {
let meta = client.fetch_metadata(name, version).await?;
resolved.push(meta);
}
Dependency::Path(_) => {
// Path deps resolve to the local directory — nothing to fetch.
}
}
}
// Sort by name for deterministic ordering.
resolved.sort_by(|a, b| a.name.cmp(&b.name));
Ok(resolved)
}
@@ -1,15 +0,0 @@
[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 }
@@ -1,139 +0,0 @@
//! 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()))
}
}
@@ -1,35 +0,0 @@
//! 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>;
@@ -1,40 +0,0 @@
//! 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};
@@ -1,371 +0,0 @@
//! 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(_)));
}
}
@@ -1,10 +0,0 @@
[package]
name = "el-stdlib"
description = "Engram language standard library — built-in function signatures and implementations"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
el-types = { workspace = true }
el-parser = { workspace = true }
@@ -1,108 +0,0 @@
//! Array operations: map, filter, reduce, find, any, all, length, push, pop,
//! sort, reverse, zip, enumerate.
use el_types::{Type, TypeEnv};
use super::fn_type;
pub fn register(env: &mut TypeEnv) {
let arr_int = Type::Array(Box::new(Type::Int));
let arr_str = Type::Array(Box::new(Type::String));
let arr_unk = Type::Array(Box::new(Type::Unknown));
// array_length([T]) -> Int
env.register_fn("array_length", fn_type(vec![arr_unk.clone()], Type::Int));
// array_push([T], T) -> [T]
env.register_fn("array_push", fn_type(vec![arr_unk.clone(), Type::Unknown], arr_unk.clone()));
// array_pop([T]) -> T?
env.register_fn("array_pop", fn_type(vec![arr_unk.clone()], Type::Optional(Box::new(Type::Unknown))));
// array_map([T], fn(T) -> U) -> [U]
let mapper = Type::Fn { params: vec![Type::Unknown], return_type: Box::new(Type::Unknown) };
env.register_fn("array_map", fn_type(vec![arr_unk.clone(), mapper.clone()], arr_unk.clone()));
// array_filter([T], fn(T) -> Bool) -> [T]
let predicate = Type::Fn { params: vec![Type::Unknown], return_type: Box::new(Type::Bool) };
env.register_fn("array_filter", fn_type(vec![arr_unk.clone(), predicate.clone()], arr_unk.clone()));
// array_reduce([T], U, fn(U, T) -> U) -> U
let reducer = Type::Fn { params: vec![Type::Unknown, Type::Unknown], return_type: Box::new(Type::Unknown) };
env.register_fn("array_reduce", fn_type(vec![arr_unk.clone(), Type::Unknown, reducer], Type::Unknown));
// array_find([T], fn(T) -> Bool) -> T?
env.register_fn("array_find", fn_type(vec![arr_unk.clone(), predicate.clone()], Type::Optional(Box::new(Type::Unknown))));
// array_any([T], fn(T) -> Bool) -> Bool
env.register_fn("array_any", fn_type(vec![arr_unk.clone(), predicate.clone()], Type::Bool));
// array_all([T], fn(T) -> Bool) -> Bool
env.register_fn("array_all", fn_type(vec![arr_unk.clone(), predicate], Type::Bool));
// array_sort([Int]) -> [Int]
env.register_fn("array_sort", fn_type(vec![arr_int.clone()], arr_int.clone()));
// array_reverse([T]) -> [T]
env.register_fn("array_reverse", fn_type(vec![arr_unk.clone()], arr_unk.clone()));
// array_zip([T], [U]) -> [[T]] (simplified: returns array of unknown)
env.register_fn("array_zip", fn_type(vec![arr_unk.clone(), arr_unk.clone()], arr_unk.clone()));
// array_enumerate([T]) -> [[T]] (returns pairs as arrays)
env.register_fn("array_enumerate", fn_type(vec![arr_unk.clone()], arr_unk.clone()));
// array_join([String], String) -> String
env.register_fn("array_join", fn_type(vec![arr_str.clone(), Type::String], Type::String));
// array_concat([T], [T]) -> [T]
env.register_fn("array_concat", fn_type(vec![arr_unk.clone(), arr_unk.clone()], arr_unk.clone()));
// array_slice([T], Int, Int) -> [T]
env.register_fn("array_slice", fn_type(vec![arr_unk.clone(), Type::Int, Type::Int], arr_unk.clone()));
// array_first([T]) -> T?
env.register_fn("array_first", fn_type(vec![arr_int.clone()], Type::Optional(Box::new(Type::Int))));
// array_last([T]) -> T?
env.register_fn("array_last", fn_type(vec![arr_int.clone()], Type::Optional(Box::new(Type::Int))));
// array_contains([String], String) -> Bool
env.register_fn("array_contains", fn_type(vec![arr_str, Type::String], Type::Bool));
// New list/stack/queue builtins
env.register_fn("list_push", fn_type(vec![arr_unk.clone(), Type::Unknown], arr_unk.clone()));
env.register_fn("list_pop", fn_type(vec![arr_unk.clone()], arr_unk.clone()));
env.register_fn("list_pop_front", fn_type(vec![arr_unk.clone()], arr_unk.clone()));
env.register_fn("list_peek_last", fn_type(vec![arr_unk.clone()], Type::Unknown));
env.register_fn("list_range", fn_type(vec![Type::Int, Type::Int], arr_unk.clone()));
env.register_fn("list_new", fn_type(vec![], arr_unk.clone()));
env.register_fn("list_empty", fn_type(vec![arr_unk.clone()], Type::Bool));
env.register_fn("list_map", fn_type(vec![arr_unk.clone(), Type::String], arr_unk.clone()));
env.register_fn("list_filter", fn_type(vec![arr_unk.clone(), Type::String], arr_unk.clone()));
env.register_fn("list_reduce", fn_type(vec![arr_unk.clone(), Type::Unknown, Type::String], Type::Unknown));
env.register_fn("fn_ref", fn_type(vec![Type::String], Type::String));
// Stack
env.register_fn("stack_push", fn_type(vec![arr_unk.clone(), Type::Unknown], arr_unk.clone()));
env.register_fn("stack_pop", fn_type(vec![arr_unk.clone()], arr_unk.clone()));
env.register_fn("stack_peek", fn_type(vec![arr_unk.clone()], Type::Unknown));
env.register_fn("stack_new", fn_type(vec![], arr_unk.clone()));
// Queue
env.register_fn("queue_enqueue", fn_type(vec![arr_unk.clone(), Type::Unknown], arr_unk.clone()));
env.register_fn("queue_dequeue", fn_type(vec![arr_unk.clone()], arr_unk.clone()));
env.register_fn("queue_peek", fn_type(vec![arr_unk.clone()], Type::Unknown));
env.register_fn("queue_new", fn_type(vec![], arr_unk));
}
#[cfg(test)]
mod tests {
use super::*;
fn env() -> TypeEnv {
let mut e = TypeEnv::with_builtins();
register(&mut e);
e
}
#[test]
fn test_array_length_registered() {
assert!(env().lookup_fn("array_length").is_some());
}
#[test]
fn test_array_map_is_fn_type() {
let e = env();
let ty = e.lookup_fn("array_map").unwrap();
assert!(matches!(ty, Type::Fn { .. }));
}
#[test]
fn test_array_filter_registered() {
assert!(env().lookup_fn("array_filter").is_some());
}
#[test]
fn test_array_push_registered() {
assert!(env().lookup_fn("array_push").is_some());
}
}
@@ -1,69 +0,0 @@
//! Engram graph operations: activate, relate, forget, edge_between, neighbors.
//!
//! These are thin wrappers over the Engram HTTP API. They are registered as
//! built-in functions in the type environment so el programs can call them
//! directly without an import.
use el_types::{Type, TypeEnv};
use super::fn_type;
pub fn register(env: &mut TypeEnv) {
let arr_unk = Type::Array(Box::new(Type::Unknown));
// engram_activate(type_name: String, query: String) -> [T]
env.register_fn("engram_activate", fn_type(vec![Type::String, Type::String], arr_unk.clone()));
// engram_relate(from_id: Uuid, to_id: Uuid, relation: String, weight: Float) -> Void
env.register_fn("engram_relate", fn_type(
vec![Type::Uuid, Type::Uuid, Type::String, Type::Float],
Type::Void,
));
// engram_forget(node_id: Uuid) -> Void
env.register_fn("engram_forget", fn_type(vec![Type::Uuid], Type::Void));
// engram_edge_between(from_id: Uuid, to_id: Uuid) -> Bool
env.register_fn("engram_edge_between", fn_type(vec![Type::Uuid, Type::Uuid], Type::Bool));
// engram_neighbors(node_id: Uuid) -> [T]
env.register_fn("engram_neighbors", fn_type(vec![Type::Uuid], arr_unk.clone()));
// engram_node_count() -> Int
env.register_fn("engram_node_count", fn_type(vec![], Type::Int));
// engram_search(query: String, limit: Int) -> [T]
env.register_fn("engram_search", fn_type(vec![Type::String, Type::Int], arr_unk));
}
#[cfg(test)]
mod tests {
use super::*;
fn env() -> TypeEnv {
let mut e = TypeEnv::with_builtins();
register(&mut e);
e
}
#[test]
fn test_engram_activate_registered() {
assert!(env().lookup_fn("engram_activate").is_some());
}
#[test]
fn test_engram_relate_registered() {
assert!(env().lookup_fn("engram_relate").is_some());
}
#[test]
fn test_engram_neighbors_registered() {
assert!(env().lookup_fn("engram_neighbors").is_some());
}
#[test]
fn test_engram_forget_returns_void() {
let e = env();
let ty = e.lookup_fn("engram_forget").unwrap();
assert!(matches!(ty, Type::Fn { return_type, .. } if matches!(return_type.as_ref(), Type::Void)));
}
}
@@ -1,113 +0,0 @@
//! Engram language standard library.
//!
//! This crate defines function signatures for the built-in standard library
//! modules. Each module registers its functions into a [`TypeEnv`] so the
//! type checker can resolve calls to stdlib functions without an explicit
//! import.
//!
//! # Auto-imported modules
//! - `std::array` — array operations
//! - `std::string` — string operations
//! - `std::result` — Result<T, E> operations
//! - `std::optional`— T? operations
//! - `std::math` — numeric operations
//! - `std::map` — Map<K, V> operations
//! - `std::engram` — Engram graph operations
pub mod array;
pub mod engram;
pub mod map;
pub mod math;
pub mod optional;
pub mod result;
pub mod string;
use el_types::{Type, TypeEnv};
/// Register all automatically-imported stdlib modules into the given environment.
///
/// Call this from `TypeEnv::with_builtins()` or at the start of type checking.
pub fn register_builtins(env: &mut TypeEnv) {
array::register(env);
string::register(env);
result::register(env);
optional::register(env);
math::register(env);
map::register(env);
engram::register(env);
}
/// Helper: build a simple function type.
pub(crate) fn fn_type(params: Vec<Type>, ret: Type) -> Type {
Type::Fn { params, return_type: Box::new(ret) }
}
#[cfg(test)]
mod tests {
use super::*;
use el_types::TypeEnv;
fn stdlib_env() -> TypeEnv {
let mut env = TypeEnv::with_builtins();
register_builtins(&mut env);
env
}
#[test]
fn test_array_functions_registered() {
let env = stdlib_env();
assert!(env.lookup_fn("array_map").is_some(), "array_map should be registered");
assert!(env.lookup_fn("array_filter").is_some());
assert!(env.lookup_fn("array_length").is_some());
assert!(env.lookup_fn("array_push").is_some());
}
#[test]
fn test_string_functions_registered() {
let env = stdlib_env();
assert!(env.lookup_fn("string_len").is_some());
assert!(env.lookup_fn("string_trim").is_some());
assert!(env.lookup_fn("string_split").is_some());
assert!(env.lookup_fn("string_contains").is_some());
}
#[test]
fn test_math_functions_registered() {
let env = stdlib_env();
assert!(env.lookup_fn("math_abs").is_some());
assert!(env.lookup_fn("math_max").is_some());
assert!(env.lookup_fn("math_min").is_some());
assert!(env.lookup_fn("math_sqrt").is_some());
}
#[test]
fn test_result_functions_registered() {
let env = stdlib_env();
assert!(env.lookup_fn("result_unwrap_or").is_some());
assert!(env.lookup_fn("result_ok").is_some());
}
#[test]
fn test_optional_functions_registered() {
let env = stdlib_env();
assert!(env.lookup_fn("optional_unwrap_or").is_some());
assert!(env.lookup_fn("optional_is_some").is_some());
assert!(env.lookup_fn("optional_is_none").is_some());
}
#[test]
fn test_map_functions_registered() {
let env = stdlib_env();
assert!(env.lookup_fn("map_get").is_some());
assert!(env.lookup_fn("map_set").is_some());
assert!(env.lookup_fn("map_remove").is_some());
}
#[test]
fn test_engram_functions_registered() {
let env = stdlib_env();
assert!(env.lookup_fn("engram_activate").is_some());
assert!(env.lookup_fn("engram_relate").is_some());
assert!(env.lookup_fn("engram_neighbors").is_some());
}
}
@@ -1,56 +0,0 @@
//! Map<K, V> operations: get, set, remove, contains_key, keys, values, entries, merge.
use el_types::{Type, TypeEnv};
use super::fn_type;
pub fn register(env: &mut TypeEnv) {
let map_unk = Type::Map { key: Box::new(Type::Unknown), value: Box::new(Type::Unknown) };
let arr_unk = Type::Array(Box::new(Type::Unknown));
env.register_fn("map_get", fn_type(vec![map_unk.clone(), Type::Unknown], Type::Optional(Box::new(Type::Unknown))));
env.register_fn("map_set", fn_type(vec![map_unk.clone(), Type::Unknown, Type::Unknown], map_unk.clone()));
env.register_fn("map_remove", fn_type(vec![map_unk.clone(), Type::Unknown], map_unk.clone()));
env.register_fn("map_contains_key", fn_type(vec![map_unk.clone(), Type::Unknown], Type::Bool));
env.register_fn("map_keys", fn_type(vec![map_unk.clone()], arr_unk.clone()));
env.register_fn("map_values", fn_type(vec![map_unk.clone()], arr_unk.clone()));
env.register_fn("map_entries", fn_type(vec![map_unk.clone()], arr_unk.clone()));
env.register_fn("map_merge", fn_type(vec![map_unk.clone(), map_unk.clone()], map_unk.clone()));
env.register_fn("map_size", fn_type(vec![map_unk.clone()], Type::Int));
env.register_fn("map_is_empty", fn_type(vec![map_unk.clone()], Type::Bool));
env.register_fn("map_new", fn_type(vec![], map_unk));
}
#[cfg(test)]
mod tests {
use super::*;
fn env() -> TypeEnv {
let mut e = TypeEnv::with_builtins();
register(&mut e);
e
}
#[test]
fn test_map_get_registered() {
assert!(env().lookup_fn("map_get").is_some());
}
#[test]
fn test_map_get_returns_optional() {
let e = env();
let ty = e.lookup_fn("map_get").unwrap();
assert!(matches!(ty, Type::Fn { return_type, .. } if matches!(return_type.as_ref(), Type::Optional(_))));
}
#[test]
fn test_map_contains_key_returns_bool() {
let e = env();
let ty = e.lookup_fn("map_contains_key").unwrap();
assert!(matches!(ty, Type::Fn { return_type, .. } if matches!(return_type.as_ref(), Type::Bool)));
}
#[test]
fn test_map_merge_registered() {
assert!(env().lookup_fn("map_merge").is_some());
}
}
@@ -1,88 +0,0 @@
//! Math operations: abs, max, min, floor, ceil, pow, sqrt, clamp.
use el_types::{Type, TypeEnv};
use super::fn_type;
pub fn register(env: &mut TypeEnv) {
env.register_fn("math_abs", fn_type(vec![Type::Float], Type::Float));
env.register_fn("math_max", fn_type(vec![Type::Float, Type::Float], Type::Float));
env.register_fn("math_min", fn_type(vec![Type::Float, Type::Float], Type::Float));
env.register_fn("math_floor", fn_type(vec![Type::Float], Type::Int));
env.register_fn("math_ceil", fn_type(vec![Type::Float], Type::Int));
env.register_fn("math_round", fn_type(vec![Type::Float], Type::Int));
env.register_fn("math_pow", fn_type(vec![Type::Float, Type::Float], Type::Float));
env.register_fn("math_sqrt", fn_type(vec![Type::Float], Type::Float));
env.register_fn("math_clamp", fn_type(vec![Type::Float, Type::Float, Type::Float], Type::Float));
env.register_fn("math_abs_int", fn_type(vec![Type::Int], Type::Int));
env.register_fn("math_max_int", fn_type(vec![Type::Int, Type::Int], Type::Int));
env.register_fn("math_min_int", fn_type(vec![Type::Int, Type::Int], Type::Int));
// Trig
env.register_fn("math_sin", fn_type(vec![Type::Float], Type::Float));
env.register_fn("math_cos", fn_type(vec![Type::Float], Type::Float));
env.register_fn("math_tan", fn_type(vec![Type::Float], Type::Float));
env.register_fn("math_asin", fn_type(vec![Type::Float], Type::Float));
env.register_fn("math_acos", fn_type(vec![Type::Float], Type::Float));
env.register_fn("math_atan2", fn_type(vec![Type::Float, Type::Float], Type::Float));
env.register_fn("math_exp", fn_type(vec![Type::Float], Type::Float));
env.register_fn("math_ln", fn_type(vec![Type::Float], Type::Float));
env.register_fn("math_log2", fn_type(vec![Type::Float], Type::Float));
env.register_fn("math_log10", fn_type(vec![Type::Float], Type::Float));
env.register_fn("math_mod", fn_type(vec![Type::Float, Type::Float], Type::Float));
env.register_fn("math_pi", fn_type(vec![], Type::Float));
env.register_fn("math_e", fn_type(vec![], Type::Float));
// Conversion
env.register_fn("int_to_float", fn_type(vec![Type::Int], Type::Float));
env.register_fn("float_to_int", fn_type(vec![Type::Float], Type::Int));
env.register_fn("is_nil", fn_type(vec![Type::Unknown], Type::Bool));
env.register_fn("unwrap_or", fn_type(vec![Type::Unknown, Type::Unknown], Type::Unknown));
// Decimal
env.register_fn("decimal_add", fn_type(vec![Type::Float, Type::Float], Type::Float));
env.register_fn("decimal_sub", fn_type(vec![Type::Float, Type::Float], Type::Float));
env.register_fn("decimal_mul", fn_type(vec![Type::Float, Type::Float], Type::Float));
env.register_fn("decimal_div", fn_type(vec![Type::Float, Type::Float], Type::Float));
env.register_fn("decimal_round", fn_type(vec![Type::Float, Type::Int], Type::Float));
// Time
env.register_fn("time_now_utc", fn_type(vec![], Type::Int));
env.register_fn("time_to_parts", fn_type(vec![Type::Int], Type::Unknown));
env.register_fn("time_from_parts", fn_type(vec![Type::Unknown], Type::Int));
env.register_fn("time_format", fn_type(vec![Type::Int, Type::String], Type::String));
env.register_fn("time_parse", fn_type(vec![Type::String], Type::Int));
env.register_fn("time_add", fn_type(vec![Type::Int, Type::Int, Type::String], Type::Int));
env.register_fn("time_diff", fn_type(vec![Type::Int, Type::Int, Type::String], Type::Int));
env.register_fn("time_start_of", fn_type(vec![Type::Int, Type::String], Type::Int));
env.register_fn("time_tz_offset", fn_type(vec![Type::String], Type::Int));
env.register_fn("time_to_tz", fn_type(vec![Type::Int, Type::String], Type::Unknown));
// Observer
env.register_fn("observe", fn_type(vec![Type::String, Type::String], Type::Int));
env.register_fn("unobserve", fn_type(vec![Type::Int], Type::Unknown));
}
#[cfg(test)]
mod tests {
use super::*;
fn env() -> TypeEnv {
let mut e = TypeEnv::with_builtins();
register(&mut e);
e
}
#[test]
fn test_math_abs_registered() {
assert!(env().lookup_fn("math_abs").is_some());
}
#[test]
fn test_math_sqrt_returns_float() {
let e = env();
let ty = e.lookup_fn("math_sqrt").unwrap();
assert!(matches!(ty, Type::Fn { return_type, .. } if matches!(return_type.as_ref(), Type::Float)));
}
#[test]
fn test_math_floor_returns_int() {
let e = env();
let ty = e.lookup_fn("math_floor").unwrap();
assert!(matches!(ty, Type::Fn { return_type, .. } if matches!(return_type.as_ref(), Type::Int)));
}
}
@@ -1,60 +0,0 @@
//! T? operations: unwrap_or, map, flat_map, is_some, is_none.
use el_types::{Type, TypeEnv};
use super::fn_type;
pub fn register(env: &mut TypeEnv) {
let opt_unk = Type::Optional(Box::new(Type::Unknown));
let mapper = Type::Fn { params: vec![Type::Unknown], return_type: Box::new(Type::Unknown) };
env.register_fn("optional_unwrap_or", fn_type(vec![opt_unk.clone(), Type::Unknown], Type::Unknown));
env.register_fn("optional_unwrap_or_else", fn_type(
vec![opt_unk.clone(), Type::Fn { params: vec![], return_type: Box::new(Type::Unknown) }],
Type::Unknown,
));
env.register_fn("optional_map", fn_type(vec![opt_unk.clone(), mapper.clone()], opt_unk.clone()));
env.register_fn("optional_flat_map", fn_type(vec![opt_unk.clone(), mapper.clone()], opt_unk.clone()));
env.register_fn("optional_is_some", fn_type(vec![opt_unk.clone()], Type::Bool));
env.register_fn("optional_is_none", fn_type(vec![opt_unk.clone()], Type::Bool));
env.register_fn("optional_filter", fn_type(
vec![opt_unk.clone(), Type::Fn { params: vec![Type::Unknown], return_type: Box::new(Type::Bool) }],
opt_unk.clone(),
));
// some(T) -> T?
env.register_fn("some", fn_type(vec![Type::Unknown], opt_unk));
// none() -> T?
env.register_fn("none", fn_type(vec![], Type::Optional(Box::new(Type::Unknown))));
}
#[cfg(test)]
mod tests {
use super::*;
fn env() -> TypeEnv {
let mut e = TypeEnv::with_builtins();
register(&mut e);
e
}
#[test]
fn test_optional_is_some_registered() {
assert!(env().lookup_fn("optional_is_some").is_some());
}
#[test]
fn test_optional_is_none_registered() {
assert!(env().lookup_fn("optional_is_none").is_some());
}
#[test]
fn test_optional_unwrap_or_registered() {
assert!(env().lookup_fn("optional_unwrap_or").is_some());
}
#[test]
fn test_some_and_none_registered() {
let e = env();
assert!(e.lookup_fn("some").is_some());
assert!(e.lookup_fn("none").is_some());
}
}
@@ -1,65 +0,0 @@
//! Result<T, E> operations: map, map_err, unwrap_or, unwrap_or_else, and_then, ok.
use el_types::{Type, TypeEnv};
use super::fn_type;
pub fn register(env: &mut TypeEnv) {
// result_unwrap_or(Result<T, E>, T) -> T
let result_unk = Type::Result {
ok: Box::new(Type::Unknown),
err: Box::new(Type::Unknown),
};
env.register_fn("result_unwrap_or", fn_type(vec![result_unk.clone(), Type::Unknown], Type::Unknown));
env.register_fn("result_unwrap_or_else", fn_type(
vec![result_unk.clone(), Type::Fn { params: vec![Type::Unknown], return_type: Box::new(Type::Unknown) }],
Type::Unknown,
));
// result_ok(Result<T, E>) -> T?
env.register_fn("result_ok", fn_type(vec![result_unk.clone()], Type::Optional(Box::new(Type::Unknown))));
// result_err(Result<T, E>) -> E?
env.register_fn("result_err", fn_type(vec![result_unk.clone()], Type::Optional(Box::new(Type::Unknown))));
// result_is_ok(Result<T, E>) -> Bool
env.register_fn("result_is_ok", fn_type(vec![result_unk.clone()], Type::Bool));
// result_is_err(Result<T, E>) -> Bool
env.register_fn("result_is_err", fn_type(vec![result_unk.clone()], Type::Bool));
// result_map(Result<T, E>, fn(T) -> U) -> Result<U, E>
let mapper = Type::Fn { params: vec![Type::Unknown], return_type: Box::new(Type::Unknown) };
env.register_fn("result_map", fn_type(vec![result_unk.clone(), mapper.clone()], result_unk.clone()));
// result_and_then(Result<T, E>, fn(T) -> Result<U, E>) -> Result<U, E>
let chain_fn = Type::Fn { params: vec![Type::Unknown], return_type: Box::new(result_unk.clone()) };
env.register_fn("result_and_then", fn_type(vec![result_unk.clone(), chain_fn], result_unk.clone()));
// result_ok_val(T) -> Result<T, E> — wrap a value in Ok
env.register_fn("ok", fn_type(vec![Type::Unknown], result_unk.clone()));
// result_err_val(E) -> Result<T, E> — wrap an error in Err
env.register_fn("err", fn_type(vec![Type::Unknown], result_unk));
}
#[cfg(test)]
mod tests {
use super::*;
fn env() -> TypeEnv {
let mut e = TypeEnv::with_builtins();
register(&mut e);
e
}
#[test]
fn test_result_unwrap_or_registered() {
assert!(env().lookup_fn("result_unwrap_or").is_some());
}
#[test]
fn test_result_ok_returns_optional() {
let e = env();
let ty = e.lookup_fn("result_ok").unwrap();
assert!(matches!(ty, Type::Fn { return_type, .. } if matches!(return_type.as_ref(), Type::Optional(_))));
}
#[test]
fn test_ok_and_err_registered() {
let e = env();
assert!(e.lookup_fn("ok").is_some());
assert!(e.lookup_fn("err").is_some());
}
}
@@ -1,70 +0,0 @@
//! String operations: trim, split, join, contains, starts_with, ends_with,
//! to_upper, to_lower, replace, len, chars.
use el_types::{Type, TypeEnv};
use super::fn_type;
pub fn register(env: &mut TypeEnv) {
let arr_str = Type::Array(Box::new(Type::String));
env.register_fn("string_len", fn_type(vec![Type::String], Type::Int));
env.register_fn("string_trim", fn_type(vec![Type::String], Type::String));
env.register_fn("string_split", fn_type(vec![Type::String, Type::String], arr_str.clone()));
env.register_fn("string_join", fn_type(vec![arr_str.clone(), Type::String], Type::String));
env.register_fn("string_contains", fn_type(vec![Type::String, Type::String], Type::Bool));
env.register_fn("string_starts_with", fn_type(vec![Type::String, Type::String], Type::Bool));
env.register_fn("string_ends_with", fn_type(vec![Type::String, Type::String], Type::Bool));
env.register_fn("string_to_upper", fn_type(vec![Type::String], Type::String));
env.register_fn("string_to_lower", fn_type(vec![Type::String], Type::String));
env.register_fn("string_replace", fn_type(vec![Type::String, Type::String, Type::String], Type::String));
env.register_fn("string_chars", fn_type(vec![Type::String], arr_str.clone()));
env.register_fn("string_slice", fn_type(vec![Type::String, Type::Int, Type::Int], Type::String));
env.register_fn("string_repeat", fn_type(vec![Type::String, Type::Int], Type::String));
env.register_fn("string_reverse", fn_type(vec![Type::String], Type::String));
env.register_fn("string_parse_int", fn_type(vec![Type::String], Type::Optional(Box::new(Type::Int))));
env.register_fn("string_parse_float", fn_type(vec![Type::String], Type::Optional(Box::new(Type::Float))));
env.register_fn("string_from_int", fn_type(vec![Type::Int], Type::String));
env.register_fn("string_from_float", fn_type(vec![Type::Float], Type::String));
env.register_fn("string_is_empty", fn_type(vec![Type::String], Type::Bool));
env.register_fn("string_concat", fn_type(vec![Type::String, Type::String], Type::String));
// New string builtins
env.register_fn("str_char_at", fn_type(vec![Type::String, Type::Int], Type::String));
env.register_fn("str_char_code", fn_type(vec![Type::String, Type::Int], Type::Int));
env.register_fn("str_from_char_code", fn_type(vec![Type::Int], Type::String));
env.register_fn("str_pad_left", fn_type(vec![Type::String, Type::Int, Type::String], Type::String));
env.register_fn("str_pad_right", fn_type(vec![Type::String, Type::Int, Type::String], Type::String));
env.register_fn("format_float", fn_type(vec![Type::Float, Type::Int], Type::String));
env.register_fn("str_format", fn_type(vec![Type::String, Type::Unknown], Type::String));
}
#[cfg(test)]
mod tests {
use super::*;
fn env() -> TypeEnv {
let mut e = TypeEnv::with_builtins();
register(&mut e);
e
}
#[test]
fn test_string_len_returns_int() {
let e = env();
let ty = e.lookup_fn("string_len").unwrap();
assert!(matches!(ty, Type::Fn { return_type, .. } if matches!(return_type.as_ref(), Type::Int)));
}
#[test]
fn test_string_split_returns_array() {
let e = env();
let ty = e.lookup_fn("string_split").unwrap();
assert!(matches!(ty, Type::Fn { return_type, .. } if matches!(return_type.as_ref(), Type::Array(_))));
}
#[test]
fn test_string_contains_returns_bool() {
let e = env();
let ty = e.lookup_fn("string_contains").unwrap();
assert!(matches!(ty, Type::Fn { return_type, .. } if matches!(return_type.as_ref(), Type::Bool)));
}
}
@@ -1,15 +0,0 @@
[package]
name = "el-test"
description = "Engram language unified testing framework — unit and e2e same syntax, seed-based graph testing"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
el-lexer = { workspace = true }
el-parser = { workspace = true }
el-types = { workspace = true }
el-compiler = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
@@ -1,138 +0,0 @@
//! Test discovery — finds all `test` blocks in an `.el` source file.
use el_lexer::tokenize;
use el_parser::{parse, Stmt};
use crate::types::{TestCase, TestTarget};
/// Parse a source string and extract all `test` block definitions.
///
/// Returns an error if the source cannot be lexed or parsed.
pub fn discover(source: &str) -> Result<Vec<TestCase>, String> {
let tokens = tokenize(source).map_err(|e| format!("lex error: {e}"))?;
let program = parse(tokens, source.to_string()).map_err(|e| format!("parse error: {e}"))?;
let mut cases = Vec::new();
collect_tests(&program.stmts, &mut cases);
Ok(cases)
}
fn collect_tests(stmts: &[Stmt], out: &mut Vec<TestCase>) {
for stmt in stmts {
if let Stmt::TestDef { name, target, body, .. } = stmt {
out.push(TestCase {
name: name.clone(),
target: TestTarget::from(target.clone()),
body: body.clone(),
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_discover_empty_source() {
let cases = discover("").unwrap();
assert!(cases.is_empty());
}
#[test]
fn test_discover_no_tests() {
let src = r#"let x: Int = 42"#;
let cases = discover(src).unwrap();
assert!(cases.is_empty());
}
#[test]
fn test_discover_single_test() {
let src = r#"
test "basic arithmetic" {
let x: Int = 6
let y: Int = 7
}
"#;
let cases = discover(src).unwrap();
assert_eq!(cases.len(), 1);
assert_eq!(cases[0].name, "basic arithmetic");
assert_eq!(cases[0].target, TestTarget::Unit);
}
#[test]
fn test_discover_multiple_tests() {
let src = r#"
test "test one" {
let x: Int = 1
}
test "test two" {
let y: Int = 2
}
"#;
let cases = discover(src).unwrap();
assert_eq!(cases.len(), 2);
assert_eq!(cases[0].name, "test one");
assert_eq!(cases[1].name, "test two");
}
#[test]
fn test_discover_e2e_target() {
let src = r#"
test "production lookup" target: e2e {
let x: Int = 1
}
"#;
let cases = discover(src).unwrap();
assert_eq!(cases.len(), 1);
assert_eq!(cases[0].target, TestTarget::E2e);
}
#[test]
fn test_discover_both_target() {
let src = r#"
test "dual target" target: both {
let x: Int = 1
}
"#;
let cases = discover(src).unwrap();
assert_eq!(cases[0].target, TestTarget::Both);
}
#[test]
fn test_discover_default_target_is_unit() {
let src = r#"test "no target" { let x: Int = 1 }"#;
let cases = discover(src).unwrap();
assert_eq!(cases[0].target, TestTarget::Unit);
}
#[test]
fn test_discover_mixed_stmts() {
let src = r#"
let x: Int = 42
test "my test" {
let y: Int = x
}
fn helper() -> Int { return 1 }
"#;
let cases = discover(src).unwrap();
assert_eq!(cases.len(), 1);
}
#[test]
fn test_discover_lex_error() {
let result = discover(r#""unterminated"#);
assert!(result.is_err());
}
#[test]
fn test_discover_body_preserved() {
let src = r#"
test "body check" {
let x: Int = 1
let y: Int = 2
}
"#;
let cases = discover(src).unwrap();
assert_eq!(cases[0].body.len(), 2);
}
}
@@ -1,598 +0,0 @@
//! Test expression evaluator.
//!
//! Walks AST expressions inside a test block and produces runtime `EvalValue`s.
//! This is a lightweight direct evaluator (not the full bytecode VM) specifically
//! designed for the simple expression patterns that appear in test assertions.
use std::collections::HashMap;
use el_parser::{BinOp, Expr, Literal, Stmt};
use crate::graph::{ActivatedNode, ReasoningResult, TestGraph};
use crate::types::AssertionResult;
/// A runtime value in the test evaluator.
#[derive(Debug, Clone, PartialEq)]
pub enum EvalValue {
Int(i64),
Float(f64),
Str(String),
Bool(bool),
Nil,
/// A list of activated graph nodes (result of `activate`)
NodeList(Vec<ActivatedNode>),
/// Result of a `reason()` call
Reasoning(ReasoningResultValue),
}
/// Simplified reasoning result value (owns the verdict string for comparison).
#[derive(Debug, Clone, PartialEq)]
pub struct ReasoningResultValue {
pub verdict: String,
pub confidence: f32,
}
impl EvalValue {
/// Try to get the length of a list/string value.
pub fn len(&self) -> Option<i64> {
match self {
EvalValue::NodeList(v) => Some(v.len() as i64),
EvalValue::Str(s) => Some(s.len() as i64),
_ => None,
}
}
/// Try to index into a list.
pub fn index(&self, i: i64) -> Option<EvalValue> {
match self {
EvalValue::NodeList(v) => {
let idx = if i < 0 {
let uidx = (-i) as usize;
v.len().checked_sub(uidx)?
} else {
i as usize
};
let node = v.get(idx)?;
// Return the node as a struct-like value — we special-case field access
Some(EvalValue::NodeList(vec![node.clone()]))
}
_ => None,
}
}
/// Check if this value contains a substring (for string `contains` keyword).
pub fn contains_str(&self, needle: &str) -> bool {
match self {
EvalValue::Str(s) => s.contains(needle),
EvalValue::NodeList(v) => v.iter().any(|n| n.content.contains(needle)),
_ => false,
}
}
pub fn as_bool(&self) -> bool {
match self {
EvalValue::Bool(b) => *b,
EvalValue::Nil => false,
_ => true,
}
}
}
impl std::fmt::Display for EvalValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EvalValue::Int(n) => write!(f, "{n}"),
EvalValue::Float(n) => write!(f, "{n}"),
EvalValue::Str(s) => write!(f, "{s}"),
EvalValue::Bool(b) => write!(f, "{b}"),
EvalValue::Nil => write!(f, "nil"),
EvalValue::NodeList(v) => write!(f, "[{} node(s)]", v.len()),
EvalValue::Reasoning(r) => write!(f, "ReasoningResult {{ verdict: {} }}", r.verdict),
}
}
}
/// Evaluator context — holds local bindings and the graph.
pub struct Evaluator<'g> {
locals: HashMap<String, EvalValue>,
graph: &'g TestGraph,
}
impl<'g> Evaluator<'g> {
pub fn new(graph: &'g TestGraph) -> Self {
Self {
locals: HashMap::new(),
graph,
}
}
/// Execute a statement. Returns `Ok(())` or an error string.
pub fn exec_stmt(&mut self, stmt: &Stmt) -> Result<(), String> {
match stmt {
Stmt::Let { name, value, .. } => {
let v = self.eval_expr(value)?;
self.locals.insert(name.clone(), v);
}
Stmt::Expr(expr, _) => {
self.eval_expr(expr)?;
}
Stmt::Return(..) => {
// Return from test body — treat as no-op continuation
}
// Seed statements are handled before eval in the runner
Stmt::Seed(..) | Stmt::TestDef { .. } | Stmt::Assert(..) => {}
Stmt::FnDef { .. } | Stmt::TypeDef { .. } | Stmt::EnumDef { .. } => {}
// New statement kinds — skip
_ => {}
}
Ok(())
}
/// Evaluate an expression, returning its value.
pub fn eval_expr(&mut self, expr: &Expr) -> Result<EvalValue, String> {
match expr {
Expr::Literal(lit) => Ok(match lit {
Literal::Int(n) => EvalValue::Int(*n),
Literal::Float(f) => EvalValue::Float(*f),
Literal::Str(s) => EvalValue::Str(s.clone()),
Literal::Bool(b) => EvalValue::Bool(*b),
}),
Expr::Ident(name) => {
self.locals.get(name).cloned().ok_or_else(|| format!("undefined variable '{name}'"))
}
Expr::BinOp { op, left, right } => {
// Special: handle `<expr> contains <str>` — parsed as BinOp in the `contains` handler
// Actually `contains` is a keyword identifier parsed as Ident in the postfix context.
// We handle it as a Call pattern below.
let lv = self.eval_expr(left)?;
let rv = self.eval_expr(right)?;
self.eval_binop(op, lv, rv)
}
Expr::Call { func, args } => {
match func.as_ref() {
// reason("hypothesis") — built-in
Expr::Ident(name) if name == "reason" => {
let arg = args.first().ok_or("reason() requires one argument")?;
let hypothesis = match self.eval_expr(arg)? {
EvalValue::Str(s) => s,
other => return Err(format!("reason() expects a string, got {other}")),
};
let result: ReasoningResult = self.graph.reason(&hypothesis);
Ok(EvalValue::Reasoning(ReasoningResultValue {
verdict: result.verdict.to_string(),
confidence: result.confidence,
}))
}
// results.len() — method call on a local
Expr::Field { object, field } if field == "len" => {
let obj = self.eval_expr(object)?;
match obj.len() {
Some(n) => Ok(EvalValue::Int(n)),
None => Err(format!("cannot call .len() on {obj}")),
}
}
// results[0].content contains "needle"
// `contains` is parsed as an Ident called as a method
Expr::Field { object, field } if field == "contains" => {
let obj = self.eval_expr(object)?;
let needle_arg = args.first().ok_or("contains() requires one argument")?;
let needle = match self.eval_expr(needle_arg)? {
EvalValue::Str(s) => s,
other => return Err(format!("contains() expects a string, got {other}")),
};
Ok(EvalValue::Bool(obj.contains_str(&needle)))
}
_ => {
// Unknown call — return nil
Ok(EvalValue::Nil)
}
}
}
Expr::Field { object, field } => {
let obj = self.eval_expr(object)?;
match &obj {
EvalValue::NodeList(nodes) if nodes.len() == 1 => {
let node = &nodes[0];
match field.as_str() {
"content" => Ok(EvalValue::Str(node.content.clone())),
"node_type" => Ok(EvalValue::Str(node.node_type.clone())),
"importance" => Ok(EvalValue::Float(node.importance as f64)),
"id" => Ok(EvalValue::Str(node.id.to_string())),
other => Err(format!("no field '{other}' on ActivatedNode")),
}
}
EvalValue::Reasoning(r) => match field.as_str() {
"verdict" => Ok(EvalValue::Str(r.verdict.clone())),
"confidence" => Ok(EvalValue::Float(r.confidence as f64)),
other => Err(format!("no field '{other}' on ReasoningResult")),
},
_ => Err(format!("cannot access field '{field}' on {obj}")),
}
}
Expr::Index { object, index } => {
let obj = self.eval_expr(object)?;
let idx = self.eval_expr(index)?;
let i = match idx {
EvalValue::Int(n) => n,
other => return Err(format!("index must be Int, got {other}")),
};
obj.index(i).ok_or_else(|| format!("index {i} out of bounds"))
}
Expr::Activate { type_name, query } => {
let nodes = self.graph.activate(query, Some(type_name));
Ok(EvalValue::NodeList(nodes))
}
Expr::UnaryNot(inner) => {
let v = self.eval_expr(inner)?;
Ok(EvalValue::Bool(!v.as_bool()))
}
Expr::Block(stmts) => {
for s in stmts {
self.exec_stmt(s)?;
}
Ok(EvalValue::Nil)
}
Expr::If { cond, then, else_ } => {
let cv = self.eval_expr(cond)?;
if cv.as_bool() {
self.eval_expr(then)
} else if let Some(e) = else_ {
self.eval_expr(e)
} else {
Ok(EvalValue::Nil)
}
}
Expr::Array(elems) => {
// For simplicity — arrays of primitives in tests
let mut vals = Vec::new();
for e in elems {
vals.push(self.eval_expr(e)?);
}
// Return first if all same, else nil
Ok(EvalValue::Nil)
}
Expr::Path { segments } => {
// Enum variant reference — return as string (e.g. "Insufficient")
Ok(EvalValue::Str(segments.last().cloned().unwrap_or_default()))
}
Expr::Match { subject, arms } => {
let sv = self.eval_expr(subject)?;
for arm in arms {
// Simplified: compare subject to pattern
let matches = match &arm.pattern {
el_parser::Pattern::Wildcard => true,
el_parser::Pattern::Literal(lit) => {
let lv = match lit {
Literal::Int(n) => EvalValue::Int(*n),
Literal::Float(f) => EvalValue::Float(*f),
Literal::Str(s) => EvalValue::Str(s.clone()),
Literal::Bool(b) => EvalValue::Bool(*b),
};
sv == lv
}
el_parser::Pattern::Binding(name) => {
self.locals.insert(name.clone(), sv.clone());
true
}
el_parser::Pattern::EnumVariant { variant, .. } => {
sv == EvalValue::Str(variant.clone())
}
};
if matches {
return self.eval_expr(&arm.body);
}
}
Ok(EvalValue::Nil)
}
Expr::Sealed(stmts) => {
for s in stmts {
self.exec_stmt(s)?;
}
Ok(EvalValue::Nil)
}
Expr::StructLit { type_name: _, fields, .. } => {
// Evaluate all fields but return Nil — struct construction in test
// eval context is not supported yet (tests use activate, not literals)
for (_, e) in fields {
self.eval_expr(e)?;
}
Ok(EvalValue::Nil)
}
// New expression kinds — return Nil
_ => {
Ok(EvalValue::Nil)
}
}
}
fn eval_binop(&self, op: &BinOp, lv: EvalValue, rv: EvalValue) -> Result<EvalValue, String> {
match op {
BinOp::Add => match (lv, rv) {
(EvalValue::Int(a), EvalValue::Int(b)) => Ok(EvalValue::Int(a + b)),
(EvalValue::Float(a), EvalValue::Float(b)) => Ok(EvalValue::Float(a + b)),
(EvalValue::Str(a), EvalValue::Str(b)) => Ok(EvalValue::Str(a + &b)),
(a, b) => Err(format!("cannot add {a} and {b}")),
},
BinOp::Sub => match (lv, rv) {
(EvalValue::Int(a), EvalValue::Int(b)) => Ok(EvalValue::Int(a - b)),
(EvalValue::Float(a), EvalValue::Float(b)) => Ok(EvalValue::Float(a - b)),
(a, b) => Err(format!("cannot subtract {a} and {b}")),
},
BinOp::Mul => match (lv, rv) {
(EvalValue::Int(a), EvalValue::Int(b)) => Ok(EvalValue::Int(a * b)),
(EvalValue::Float(a), EvalValue::Float(b)) => Ok(EvalValue::Float(a * b)),
(a, b) => Err(format!("cannot multiply {a} and {b}")),
},
BinOp::Div => match (lv, rv) {
(EvalValue::Int(a), EvalValue::Int(b)) if b != 0 => Ok(EvalValue::Int(a / b)),
(EvalValue::Float(a), EvalValue::Float(b)) => Ok(EvalValue::Float(a / b)),
(_, EvalValue::Int(0)) => Err("division by zero".into()),
(a, b) => Err(format!("cannot divide {a} and {b}")),
},
BinOp::Eq => Ok(EvalValue::Bool(lv == rv)),
BinOp::NotEq => Ok(EvalValue::Bool(lv != rv)),
BinOp::Lt => self.compare_ord(lv, rv, |o| o == std::cmp::Ordering::Less),
BinOp::Gt => self.compare_ord(lv, rv, |o| o == std::cmp::Ordering::Greater),
BinOp::LtEq => self.compare_ord(lv, rv, |o| o != std::cmp::Ordering::Greater),
BinOp::GtEq => self.compare_ord(lv, rv, |o| o != std::cmp::Ordering::Less),
BinOp::And => Ok(EvalValue::Bool(lv.as_bool() && rv.as_bool())),
BinOp::Or => Ok(EvalValue::Bool(lv.as_bool() || rv.as_bool())),
BinOp::Mod => match (lv, rv) {
(EvalValue::Int(a), EvalValue::Int(b)) if b != 0 => Ok(EvalValue::Int(a % b)),
_ => Ok(EvalValue::Int(0)),
},
BinOp::BitAnd => match (lv, rv) {
(EvalValue::Int(a), EvalValue::Int(b)) => Ok(EvalValue::Int(a & b)),
_ => Ok(EvalValue::Int(0)),
},
BinOp::BitOr => match (lv, rv) {
(EvalValue::Int(a), EvalValue::Int(b)) => Ok(EvalValue::Int(a | b)),
_ => Ok(EvalValue::Int(0)),
},
BinOp::BitXor => match (lv, rv) {
(EvalValue::Int(a), EvalValue::Int(b)) => Ok(EvalValue::Int(a ^ b)),
_ => Ok(EvalValue::Int(0)),
},
BinOp::Shl => match (lv, rv) {
(EvalValue::Int(a), EvalValue::Int(b)) => Ok(EvalValue::Int(a << (b as u32))),
_ => Ok(EvalValue::Int(0)),
},
BinOp::Shr => match (lv, rv) {
(EvalValue::Int(a), EvalValue::Int(b)) => Ok(EvalValue::Int(a >> (b as u32))),
_ => Ok(EvalValue::Int(0)),
},
BinOp::NullCoalesce => {
// `a ?? b` — use b if a is nil/empty/false
match &lv {
EvalValue::Nil => Ok(rv),
EvalValue::Str(s) if s.is_empty() => Ok(rv),
EvalValue::Bool(false) => Ok(rv),
_ => Ok(lv),
}
}
}
}
fn compare_ord<F>(&self, lv: EvalValue, rv: EvalValue, pred: F) -> Result<EvalValue, String>
where
F: Fn(std::cmp::Ordering) -> bool,
{
let ord = match (&lv, &rv) {
(EvalValue::Int(a), EvalValue::Int(b)) => a.cmp(b),
(EvalValue::Float(a), EvalValue::Float(b)) => {
a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
}
_ => return Err(format!("cannot compare {lv} and {rv}")),
};
Ok(EvalValue::Bool(pred(ord)))
}
}
/// Evaluate a single `assert` expression and return an `AssertionResult`.
///
/// The `expr_text` is the source text representation of the assertion (for
/// diagnostic output).
pub fn evaluate_assert(
eval: &mut Evaluator,
expr: &Expr,
expr_text: &str,
) -> AssertionResult {
match eval.eval_expr(expr) {
Ok(val) => {
let passed = val.as_bool();
AssertionResult {
expression: expr_text.to_string(),
passed,
actual: Some(val.to_string()),
expected: None,
}
}
Err(_e) => AssertionResult {
expression: expr_text.to_string(),
passed: false,
actual: None,
expected: None,
}
}
}
/// Render an expression as a human-readable string (best-effort, for display).
pub fn expr_to_text(expr: &Expr) -> String {
match expr {
Expr::Literal(Literal::Int(n)) => n.to_string(),
Expr::Literal(Literal::Float(f)) => f.to_string(),
Expr::Literal(Literal::Str(s)) => format!("\"{s}\""),
Expr::Literal(Literal::Bool(b)) => b.to_string(),
Expr::Ident(name) => name.clone(),
Expr::BinOp { op, left, right } => {
let op_str = match op {
BinOp::Add => "+", BinOp::Sub => "-", BinOp::Mul => "*", BinOp::Div => "/",
BinOp::Eq => "==", BinOp::NotEq => "!=",
BinOp::Lt => "<", BinOp::Gt => ">", BinOp::LtEq => "<=", BinOp::GtEq => ">=",
BinOp::And => "&&", BinOp::Or => "||",
BinOp::Mod => "%", BinOp::BitAnd => "&", BinOp::BitOr => "|",
BinOp::BitXor => "^", BinOp::Shl => "<<", BinOp::Shr => ">>",
BinOp::NullCoalesce => "??",
};
format!("{} {op_str} {}", expr_to_text(left), expr_to_text(right))
}
Expr::Field { object, field } => format!("{}.{field}", expr_to_text(object)),
Expr::Call { func, args } => {
let args_str: Vec<_> = args.iter().map(expr_to_text).collect();
format!("{}({})", expr_to_text(func), args_str.join(", "))
}
Expr::Index { object, index } => format!("{}[{}]", expr_to_text(object), expr_to_text(index)),
Expr::Activate { type_name, query } => format!("activate {type_name} where \"{query}\""),
Expr::UnaryNot(inner) => format!("!{}", expr_to_text(inner)),
Expr::Path { segments } => segments.join("::"),
_ => "<expr>".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::TestGraph;
fn fresh_eval(g: &TestGraph) -> Evaluator {
Evaluator::new(g)
}
#[test]
fn test_eval_int_literal() {
let g = TestGraph::new();
let mut e = fresh_eval(&g);
let expr = Expr::Literal(Literal::Int(42));
assert_eq!(e.eval_expr(&expr).unwrap(), EvalValue::Int(42));
}
#[test]
fn test_eval_bool_literal() {
let g = TestGraph::new();
let mut e = fresh_eval(&g);
let expr = Expr::Literal(Literal::Bool(true));
assert_eq!(e.eval_expr(&expr).unwrap(), EvalValue::Bool(true));
}
#[test]
fn test_eval_string_literal() {
let g = TestGraph::new();
let mut e = fresh_eval(&g);
let expr = Expr::Literal(Literal::Str("hello".into()));
assert_eq!(e.eval_expr(&expr).unwrap(), EvalValue::Str("hello".into()));
}
#[test]
fn test_eval_addition() {
let g = TestGraph::new();
let mut e = fresh_eval(&g);
let expr = Expr::BinOp {
op: BinOp::Add,
left: Box::new(Expr::Literal(Literal::Int(3))),
right: Box::new(Expr::Literal(Literal::Int(4))),
};
assert_eq!(e.eval_expr(&expr).unwrap(), EvalValue::Int(7));
}
#[test]
fn test_eval_multiplication() {
let g = TestGraph::new();
let mut e = fresh_eval(&g);
let expr = Expr::BinOp {
op: BinOp::Mul,
left: Box::new(Expr::Literal(Literal::Int(6))),
right: Box::new(Expr::Literal(Literal::Int(7))),
};
assert_eq!(e.eval_expr(&expr).unwrap(), EvalValue::Int(42));
}
#[test]
fn test_eval_equality_true() {
let g = TestGraph::new();
let mut e = fresh_eval(&g);
let expr = Expr::BinOp {
op: BinOp::Eq,
left: Box::new(Expr::Literal(Literal::Int(42))),
right: Box::new(Expr::Literal(Literal::Int(42))),
};
assert_eq!(e.eval_expr(&expr).unwrap(), EvalValue::Bool(true));
}
#[test]
fn test_eval_greater_than() {
let g = TestGraph::new();
let mut e = fresh_eval(&g);
let expr = Expr::BinOp {
op: BinOp::Gt,
left: Box::new(Expr::Literal(Literal::Int(5))),
right: Box::new(Expr::Literal(Literal::Int(3))),
};
assert_eq!(e.eval_expr(&expr).unwrap(), EvalValue::Bool(true));
}
#[test]
fn test_eval_activate_empty_graph() {
let g = TestGraph::new();
let mut e = fresh_eval(&g);
let expr = Expr::Activate {
type_name: "Customer".into(),
query: "anything".into(),
};
let result = e.eval_expr(&expr).unwrap();
assert!(matches!(result, EvalValue::NodeList(ref v) if v.is_empty()));
}
#[test]
fn test_eval_activate_with_seeds() {
let mut g = TestGraph::new();
g.seed_node("Customer", "Will Anderson, founding member", 0.9, None);
let mut e = fresh_eval(&g);
let expr = Expr::Activate {
type_name: "Customer".into(),
query: "founding".into(),
};
let result = e.eval_expr(&expr).unwrap();
assert!(matches!(result, EvalValue::NodeList(ref v) if v.len() == 1));
}
#[test]
fn test_eval_let_and_ident() {
let g = TestGraph::new();
let mut e = fresh_eval(&g);
e.locals.insert("x".into(), EvalValue::Int(10));
let expr = Expr::Ident("x".into());
assert_eq!(e.eval_expr(&expr).unwrap(), EvalValue::Int(10));
}
#[test]
fn test_eval_reason_empty_graph() {
let g = TestGraph::new();
let mut e = fresh_eval(&g);
let expr = Expr::Call {
func: Box::new(Expr::Ident("reason".into())),
args: vec![Expr::Literal(Literal::Str("Is there a customer?".into()))],
};
let result = e.eval_expr(&expr).unwrap();
match result {
EvalValue::Reasoning(r) => assert_eq!(r.verdict, "Insufficient"),
_ => panic!("expected Reasoning"),
}
}
}
@@ -1,335 +0,0 @@
//! In-memory graph for test seeding and activation.
//!
//! `TestGraph` provides the runtime behind `seed Node { ... }`, `seed Edge { ... }`,
//! `activate T where "query"`, and `reason("hypothesis")` in test blocks.
//!
//! For **unit tests** the graph is in-memory only — no disk, no external DB.
//! For **e2e tests** the graph would delegate to a real Engram database
//! (full implementation when Engram DB Rust client is available).
use std::collections::HashMap;
/// Unique node identifier (simplified UUID representation).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct NodeId(pub String);
impl NodeId {
pub fn new() -> Self {
// Simple deterministic ID for tests (real impl would use uuid crate)
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(1);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
NodeId(format!("node-{n:08x}"))
}
}
impl Default for NodeId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for NodeId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// A node in the test graph.
#[derive(Debug, Clone)]
pub struct GraphNode {
pub id: NodeId,
pub node_type: String,
pub content: String,
pub importance: f32,
#[allow(dead_code)]
pub tier: Option<String>,
}
/// A directed edge between two graph nodes.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct GraphEdge {
pub from: NodeId,
pub to: NodeId,
pub relation: String,
pub weight: f32,
}
/// A node returned by an `activate` query.
#[derive(Debug, Clone, PartialEq)]
pub struct ActivatedNode {
pub id: NodeId,
pub node_type: String,
pub content: String,
pub importance: f32,
}
/// The verdict of a `reason()` call.
#[derive(Debug, Clone, PartialEq)]
pub enum ReasoningVerdict {
/// Evidence found; hypothesis is supported.
Supported,
/// Evidence found; hypothesis is contradicted.
Contradicted,
/// Insufficient evidence to evaluate hypothesis.
Insufficient,
}
impl std::fmt::Display for ReasoningVerdict {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ReasoningVerdict::Supported => write!(f, "Supported"),
ReasoningVerdict::Contradicted => write!(f, "Contradicted"),
ReasoningVerdict::Insufficient => write!(f, "Insufficient"),
}
}
}
/// Result of a `reason()` call.
#[derive(Debug, Clone)]
pub struct ReasoningResult {
pub verdict: ReasoningVerdict,
pub evidence: Vec<ActivatedNode>,
pub confidence: f32,
}
/// The in-memory graph used during unit tests.
///
/// Seeds accumulate nodes and edges. Activation queries search over them
/// using simple substring/keyword matching (a proxy for real embedding search).
pub struct TestGraph {
nodes: HashMap<NodeId, GraphNode>,
edges: Vec<GraphEdge>,
}
impl TestGraph {
/// Create an empty graph (unit test mode — pure in-memory).
pub fn new() -> Self {
Self {
nodes: HashMap::new(),
edges: Vec::new(),
}
}
/// Seed a node into the graph. Returns the node's ID.
pub fn seed_node(
&mut self,
node_type: &str,
content: &str,
importance: f32,
tier: Option<&str>,
) -> NodeId {
let id = NodeId::new();
let node = GraphNode {
id: id.clone(),
node_type: node_type.to_string(),
content: content.to_string(),
importance,
tier: tier.map(String::from),
};
self.nodes.insert(id.clone(), node);
id
}
/// Seed a directed edge between two nodes.
pub fn seed_edge(&mut self, from: NodeId, to: NodeId, relation: &str, weight: f32) {
self.edges.push(GraphEdge {
from,
to,
relation: relation.to_string(),
weight,
});
}
/// Activate — query nodes by type and keyword relevance.
///
/// Matches nodes whose `node_type` equals `node_type` (case-insensitive)
/// and whose content contains any keyword from the query.
/// Results are sorted by importance descending.
pub fn activate(&self, query: &str, node_type: Option<&str>) -> Vec<ActivatedNode> {
let query_lower = query.to_lowercase();
let keywords: Vec<&str> = query_lower.split_whitespace().collect();
let mut results: Vec<ActivatedNode> = self
.nodes
.values()
.filter(|node| {
// Type filter
if let Some(ty) = node_type {
if !node.node_type.eq_ignore_ascii_case(ty) {
return false;
}
}
// If no query keywords, match all nodes of that type
if keywords.is_empty() {
return true;
}
// Keyword relevance: content must contain at least one keyword
let content_lower = node.content.to_lowercase();
keywords.iter().any(|kw| content_lower.contains(kw))
})
.map(|node| ActivatedNode {
id: node.id.clone(),
node_type: node.node_type.clone(),
content: node.content.clone(),
importance: node.importance,
})
.collect();
results.sort_by(|a, b| b.importance.partial_cmp(&a.importance).unwrap_or(std::cmp::Ordering::Equal));
results
}
/// Reason — evaluate a hypothesis against the seeded graph.
///
/// If the graph is empty, returns `Insufficient`.
/// If matching nodes exist, returns `Supported` with those nodes.
pub fn reason(&self, hypothesis: &str) -> ReasoningResult {
if self.nodes.is_empty() {
return ReasoningResult {
verdict: ReasoningVerdict::Insufficient,
evidence: vec![],
confidence: 0.0,
};
}
let evidence = self.activate(hypothesis, None);
if evidence.is_empty() {
ReasoningResult {
verdict: ReasoningVerdict::Insufficient,
evidence: vec![],
confidence: 0.0,
}
} else {
let confidence = evidence.iter().map(|n| n.importance).sum::<f32>()
/ evidence.len() as f32;
ReasoningResult {
verdict: ReasoningVerdict::Supported,
evidence,
confidence,
}
}
}
/// Number of seeded nodes.
pub fn node_count(&self) -> usize {
self.nodes.len()
}
/// Number of seeded edges.
pub fn edge_count(&self) -> usize {
self.edges.len()
}
}
impl Default for TestGraph {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_seed_node_returns_id() {
let mut g = TestGraph::new();
let id = g.seed_node("Customer", "Will Anderson", 0.9, Some("Semantic"));
assert!(!id.0.is_empty());
}
#[test]
fn test_activate_returns_matching_nodes() {
let mut g = TestGraph::new();
g.seed_node("Customer", "Will Anderson, founding member", 0.9, None);
g.seed_node("Customer", "Jane Smith, standard client", 0.6, None);
let results = g.activate("founding", Some("Customer"));
assert_eq!(results.len(), 1);
assert!(results[0].content.contains("Will Anderson"));
}
#[test]
fn test_activate_empty_query_matches_all_of_type() {
let mut g = TestGraph::new();
g.seed_node("Customer", "Alice", 0.9, None);
g.seed_node("Customer", "Bob", 0.8, None);
g.seed_node("Order", "Order #1", 0.7, None);
let results = g.activate("", Some("Customer"));
assert_eq!(results.len(), 2);
}
#[test]
fn test_activate_wrong_type_returns_empty() {
let mut g = TestGraph::new();
g.seed_node("Customer", "Will Anderson", 0.9, None);
let results = g.activate("Will", Some("Order"));
assert!(results.is_empty());
}
#[test]
fn test_activate_sorts_by_importance() {
let mut g = TestGraph::new();
g.seed_node("Item", "low importance apple", 0.3, None);
g.seed_node("Item", "high importance apple", 0.9, None);
g.seed_node("Item", "medium importance apple", 0.6, None);
let results = g.activate("apple", Some("Item"));
assert_eq!(results.len(), 3);
assert!(results[0].importance >= results[1].importance);
assert!(results[1].importance >= results[2].importance);
}
#[test]
fn test_activate_no_type_filter() {
let mut g = TestGraph::new();
g.seed_node("Customer", "Will Anderson", 0.9, None);
g.seed_node("Order", "Will's order", 0.7, None);
let results = g.activate("Will", None);
assert_eq!(results.len(), 2);
}
#[test]
fn test_reason_empty_graph_returns_insufficient() {
let g = TestGraph::new();
let result = g.reason("Is there a customer?");
assert_eq!(result.verdict, ReasoningVerdict::Insufficient);
assert_eq!(result.confidence, 0.0);
}
#[test]
fn test_reason_with_matching_nodes_returns_supported() {
let mut g = TestGraph::new();
g.seed_node("Customer", "Will Anderson, founding member", 0.9, None);
let result = g.reason("founding member");
assert_eq!(result.verdict, ReasoningVerdict::Supported);
assert!(!result.evidence.is_empty());
}
#[test]
fn test_reason_no_match_returns_insufficient() {
let mut g = TestGraph::new();
g.seed_node("Customer", "Will Anderson", 0.9, None);
let result = g.reason("quantum teleportation");
assert_eq!(result.verdict, ReasoningVerdict::Insufficient);
}
#[test]
fn test_seed_edge() {
let mut g = TestGraph::new();
let cust = g.seed_node("Customer", "Will", 0.9, None);
let order = g.seed_node("Order", "Order #1", 0.7, None);
g.seed_edge(cust, order, "Purchased", 0.9);
assert_eq!(g.edge_count(), 1);
}
#[test]
fn test_node_count() {
let mut g = TestGraph::new();
assert_eq!(g.node_count(), 0);
g.seed_node("X", "content", 0.5, None);
assert_eq!(g.node_count(), 1);
g.seed_node("Y", "more content", 0.5, None);
assert_eq!(g.node_count(), 2);
}
}
@@ -1,33 +0,0 @@
//! el-test — Engram language unified testing framework.
//!
//! # Core insight
//!
//! All Engram state is graph nodes. A test seeds the graph and makes
//! assertions. Unit test = seed a few nodes in-memory. E2e test = point at
//! the real Engram database. **The test code is identical. Only the graph
//! differs.** No mocking framework. No dependency injection. One syntax.
//!
//! # Usage
//!
//! ```rust,ignore
//! use el_test::{TestRunner, TestReport};
//!
//! let source = std::fs::read_to_string("tests.el").unwrap();
//! let tests = el_test::discover(&source).unwrap();
//! let runner = TestRunner::new();
//! let report = runner.run_all(&tests, None);
//! report.print();
//! ```
mod discovery;
mod eval;
mod graph;
mod report;
mod runner;
mod types;
pub use discovery::discover;
pub use graph::TestGraph;
pub use report::TestReport;
pub use runner::TestRunner;
pub use types::{AssertionResult, TestCase, TestResult, TestStatus, TestTarget};
@@ -1,319 +0,0 @@
//! Test report formatting — human-readable, JSON, and JUnit XML output.
use crate::types::{TestResult, TestStatus};
/// Aggregated report for a set of test runs.
pub struct TestReport {
pub total: u32,
pub passed: u32,
pub failed: u32,
pub skipped: u32,
pub errors: u32,
pub duration_ms: u64,
pub results: Vec<TestResult>,
}
impl TestReport {
/// Build a report from a slice of individual test results.
pub fn from_results(results: Vec<TestResult>) -> Self {
let total = results.len() as u32;
let passed = results.iter().filter(|r| r.status == TestStatus::Pass).count() as u32;
let failed = results.iter().filter(|r| r.status == TestStatus::Fail).count() as u32;
let skipped = results.iter().filter(|r| r.status == TestStatus::Skip).count() as u32;
let errors = results.iter().filter(|r| r.status == TestStatus::Error).count() as u32;
let duration_ms = results.iter().map(|r| r.duration_ms).sum();
Self { total, passed, failed, skipped, errors, duration_ms, results }
}
/// Print a human-readable summary to stdout.
pub fn print(&self) {
let target_label = format!("({}ms total)", self.duration_ms);
println!("\nRunning {} tests...\n", self.total);
for r in &self.results {
let icon = match r.status {
TestStatus::Pass => " ok ",
TestStatus::Fail => " FAIL ",
TestStatus::Skip => " SKIP ",
TestStatus::Error => "ERROR ",
};
println!(" [{icon}] {} ({}ms)", r.name, r.duration_ms);
// Show failing assertions
if r.status == TestStatus::Fail {
for (i, a) in r.assertions.iter().enumerate() {
if !a.passed {
println!(" assert {}", a.expression);
if let Some(actual) = &a.actual {
println!(" actual: {actual}");
}
if let Some(expected) = &a.expected {
println!(" expected: {expected}");
}
println!(" at assertion {}", i + 1);
}
}
}
if let Some(err) = &r.error {
println!(" error: {err}");
}
}
println!("\nResults: {} passed, {} failed, {} skipped {}", self.passed, self.failed, self.skipped, target_label);
if self.errors > 0 {
println!(" ({} error(s) — see above)", self.errors);
}
}
/// Serialize to JSON.
pub fn to_json(&self) -> String {
let results: Vec<serde_json::Value> = self
.results
.iter()
.map(|r| {
let assertions: Vec<serde_json::Value> = r
.assertions
.iter()
.map(|a| {
serde_json::json!({
"expression": a.expression,
"passed": a.passed,
"actual": a.actual,
"expected": a.expected,
})
})
.collect();
serde_json::json!({
"name": r.name,
"target": r.target.to_string(),
"status": r.status.to_string(),
"duration_ms": r.duration_ms,
"assertions": assertions,
"error": r.error,
})
})
.collect();
let report = serde_json::json!({
"total": self.total,
"passed": self.passed,
"failed": self.failed,
"skipped": self.skipped,
"errors": self.errors,
"duration_ms": self.duration_ms,
"results": results,
});
serde_json::to_string_pretty(&report).unwrap_or_else(|_| "{}".to_string())
}
/// Serialize to JUnit XML (for CI integration).
pub fn to_junit_xml(&self) -> String {
let mut xml = String::new();
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.push_str(&format!(
"<testsuite name=\"el\" tests=\"{}\" failures=\"{}\" errors=\"{}\" skipped=\"{}\" time=\"{}\">\n",
self.total,
self.failed,
self.errors,
self.skipped,
self.duration_ms as f64 / 1000.0,
));
for r in &self.results {
let classname = "el_test";
let time = r.duration_ms as f64 / 1000.0;
let name_escaped = xml_escape(&r.name);
match r.status {
TestStatus::Pass => {
xml.push_str(&format!(
" <testcase classname=\"{classname}\" name=\"{name_escaped}\" time=\"{time:.3}\"/>\n"
));
}
TestStatus::Fail => {
xml.push_str(&format!(
" <testcase classname=\"{classname}\" name=\"{name_escaped}\" time=\"{time:.3}\">\n"
));
for a in r.assertions.iter().filter(|a| !a.passed) {
let msg = xml_escape(&a.expression);
let details = match (&a.actual, &a.expected) {
(Some(act), Some(exp)) => format!("actual: {act}, expected: {exp}"),
(Some(act), None) => format!("actual: {act}"),
_ => "assertion failed".to_string(),
};
let details_esc = xml_escape(&details);
xml.push_str(&format!(
" <failure message=\"{msg}\">{details_esc}</failure>\n"
));
}
xml.push_str(" </testcase>\n");
}
TestStatus::Skip => {
xml.push_str(&format!(
" <testcase classname=\"{classname}\" name=\"{name_escaped}\" time=\"{time:.3}\">\n <skipped/>\n </testcase>\n"
));
}
TestStatus::Error => {
let err_msg = xml_escape(r.error.as_deref().unwrap_or("unknown error"));
xml.push_str(&format!(
" <testcase classname=\"{classname}\" name=\"{name_escaped}\" time=\"{time:.3}\">\n <error message=\"{err_msg}\"/>\n </testcase>\n"
));
}
}
}
xml.push_str("</testsuite>\n");
xml
}
/// Whether the overall test run passed (no failures or errors).
pub fn is_pass(&self) -> bool {
self.failed == 0 && self.errors == 0
}
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{AssertionResult, TestTarget};
fn make_pass(name: &str) -> TestResult {
TestResult {
name: name.to_string(),
target: TestTarget::Unit,
status: TestStatus::Pass,
duration_ms: 5,
assertions: vec![AssertionResult {
expression: "x == 42".into(),
passed: true,
actual: Some("true".into()),
expected: None,
}],
error: None,
}
}
fn make_fail(name: &str) -> TestResult {
TestResult {
name: name.to_string(),
target: TestTarget::Unit,
status: TestStatus::Fail,
duration_ms: 3,
assertions: vec![AssertionResult {
expression: "x == 99".into(),
passed: false,
actual: Some("42".into()),
expected: Some("99".into()),
}],
error: None,
}
}
fn make_skip(name: &str) -> TestResult {
TestResult {
name: name.to_string(),
target: TestTarget::E2e,
status: TestStatus::Skip,
duration_ms: 0,
assertions: vec![],
error: Some("ENGRAM_URL not set".into()),
}
}
#[test]
fn test_report_counts() {
let report = TestReport::from_results(vec![
make_pass("a"),
make_fail("b"),
make_skip("c"),
]);
assert_eq!(report.total, 3);
assert_eq!(report.passed, 1);
assert_eq!(report.failed, 1);
assert_eq!(report.skipped, 1);
}
#[test]
fn test_report_is_pass() {
let report = TestReport::from_results(vec![make_pass("a"), make_pass("b")]);
assert!(report.is_pass());
}
#[test]
fn test_report_is_not_pass_on_fail() {
let report = TestReport::from_results(vec![make_pass("a"), make_fail("b")]);
assert!(!report.is_pass());
}
#[test]
fn test_to_json_valid() {
let report = TestReport::from_results(vec![make_pass("test")]);
let json = report.to_json();
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
assert_eq!(parsed["total"], 1);
assert_eq!(parsed["passed"], 1);
}
#[test]
fn test_to_json_contains_results() {
let report = TestReport::from_results(vec![make_pass("hello")]);
let json = report.to_json();
assert!(json.contains("hello"));
}
#[test]
fn test_to_junit_xml_valid() {
let report = TestReport::from_results(vec![make_pass("a"), make_fail("b")]);
let xml = report.to_junit_xml();
assert!(xml.starts_with("<?xml"));
assert!(xml.contains("<testsuite"));
assert!(xml.contains("</testsuite>"));
}
#[test]
fn test_to_junit_xml_pass_testcase() {
let report = TestReport::from_results(vec![make_pass("arithmetic")]);
let xml = report.to_junit_xml();
assert!(xml.contains("arithmetic"));
assert!(!xml.contains("<failure"));
}
#[test]
fn test_to_junit_xml_fail_testcase() {
let report = TestReport::from_results(vec![make_fail("failing")]);
let xml = report.to_junit_xml();
assert!(xml.contains("<failure"));
}
#[test]
fn test_to_junit_xml_skipped() {
let report = TestReport::from_results(vec![make_skip("e2e")]);
let xml = report.to_junit_xml();
assert!(xml.contains("<skipped"));
}
#[test]
fn test_duration_sum() {
let report = TestReport::from_results(vec![make_pass("a"), make_pass("b")]);
assert_eq!(report.duration_ms, 10); // 5 + 5
}
#[test]
fn test_xml_escape() {
let escaped = super::xml_escape("a < b & c > d \"e\" 'f'");
assert!(escaped.contains("&lt;"));
assert!(escaped.contains("&amp;"));
assert!(escaped.contains("&gt;"));
assert!(escaped.contains("&quot;"));
}
}
@@ -1,382 +0,0 @@
//! Test runner — executes test cases and produces results.
use std::time::Instant;
use el_parser::{SeedStmt, Stmt};
use crate::eval::{evaluate_assert, expr_to_text, Evaluator};
use crate::graph::TestGraph;
use crate::types::{AssertionResult, TestCase, TestResult, TestStatus, TestTarget};
/// Runs test cases and collects results.
pub struct TestRunner;
impl TestRunner {
pub fn new() -> Self {
Self
}
/// Run all tests. E2e tests are skipped if `engram_url` is `None`.
pub fn run_all(&self, tests: &[TestCase], engram_url: Option<&str>) -> Vec<TestResult> {
let mut results = Vec::new();
for test in tests {
match &test.target {
TestTarget::Unit => {
results.push(self.run_unit_test(test));
}
TestTarget::E2e => {
if let Some(url) = engram_url {
results.push(self.run_e2e_test(test, url));
} else {
results.push(TestResult {
name: test.name.clone(),
target: TestTarget::E2e,
status: TestStatus::Skip,
duration_ms: 0,
assertions: vec![],
error: Some("ENGRAM_URL not set; skipping e2e test".into()),
});
}
}
TestTarget::Both => {
results.push(self.run_unit_test(test));
if let Some(url) = engram_url {
results.push(self.run_e2e_test(test, url));
} else {
results.push(TestResult {
name: format!("{} (e2e)", test.name),
target: TestTarget::E2e,
status: TestStatus::Skip,
duration_ms: 0,
assertions: vec![],
error: Some("ENGRAM_URL not set; skipping e2e test".into()),
});
}
}
}
}
results
}
/// Run only unit tests.
pub fn run_unit(&self, tests: &[TestCase]) -> Vec<TestResult> {
tests
.iter()
.filter(|t| matches!(t.target, TestTarget::Unit | TestTarget::Both))
.map(|t| self.run_unit_test(t))
.collect()
}
/// Run only e2e tests.
pub fn run_e2e(&self, tests: &[TestCase], engram_url: &str) -> Vec<TestResult> {
tests
.iter()
.filter(|t| matches!(t.target, TestTarget::E2e | TestTarget::Both))
.map(|t| self.run_e2e_test(t, engram_url))
.collect()
}
/// Run a single test against an in-memory graph.
pub fn run_one(&self, test: &TestCase, engram_url: Option<&str>) -> TestResult {
match (&test.target, engram_url) {
(TestTarget::E2e, Some(url)) => self.run_e2e_test(test, url),
(TestTarget::E2e, None) => TestResult {
name: test.name.clone(),
target: TestTarget::E2e,
status: TestStatus::Skip,
duration_ms: 0,
assertions: vec![],
error: Some("ENGRAM_URL not set; skipping e2e test".into()),
},
_ => self.run_unit_test(test),
}
}
// ── Private ───────────────────────────────────────────────────────────────
fn run_unit_test(&self, test: &TestCase) -> TestResult {
let start = Instant::now();
let mut graph = TestGraph::new();
// Apply seed statements first
for stmt in &test.body {
if let Stmt::Seed(seed, _) = stmt {
apply_seed(&mut graph, seed);
}
}
self.execute_test(test, &graph, TestTarget::Unit, start)
}
fn run_e2e_test(&self, test: &TestCase, _engram_url: &str) -> TestResult {
let start = Instant::now();
// For e2e, we still use an in-memory graph for now (real DB client TBD).
// The distinction is that e2e tests skip the seed step (they use real data).
let graph = TestGraph::new();
self.execute_test(test, &graph, TestTarget::E2e, start)
}
fn execute_test(
&self,
test: &TestCase,
graph: &TestGraph,
target: TestTarget,
start: Instant,
) -> TestResult {
let mut eval = Evaluator::new(graph);
let mut assertions: Vec<AssertionResult> = Vec::new();
let mut error: Option<String> = None;
for stmt in &test.body {
match stmt {
Stmt::Seed(..) => {
// Already processed before eval
}
Stmt::Assert(expr, _) => {
let text = expr_to_text(expr);
let result = evaluate_assert(&mut eval, expr, &text);
assertions.push(result);
}
other => {
if let Err(e) = eval.exec_stmt(other) {
error = Some(e);
break;
}
}
}
}
let duration_ms = start.elapsed().as_millis() as u64;
let all_passed = assertions.iter().all(|a| a.passed);
let status = if error.is_some() {
TestStatus::Error
} else if all_passed {
TestStatus::Pass
} else {
TestStatus::Fail
};
TestResult {
name: test.name.clone(),
target,
status,
duration_ms,
assertions,
error,
}
}
}
impl Default for TestRunner {
fn default() -> Self {
Self::new()
}
}
fn apply_seed(graph: &mut TestGraph, seed: &SeedStmt) {
match seed {
SeedStmt::Node { node_type, content, importance, tier } => {
graph.seed_node(node_type, content, *importance, tier.as_deref());
}
SeedStmt::Edge { from, to, relation, weight } => {
// For edges we use string IDs — in a real impl these would be looked
// up from the graph's node registry. We create placeholder node IDs.
use crate::graph::NodeId;
graph.seed_edge(
NodeId(from.clone()),
NodeId(to.clone()),
relation,
*weight,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use el_parser::{Expr, Literal, BinOp, Stmt};
use el_lexer::Span;
fn dummy_span() -> Span {
Span::new(0, 0, 1, 1)
}
fn make_assert(expr: Expr) -> Stmt {
Stmt::Assert(expr, dummy_span())
}
fn make_let(name: &str, expr: Expr) -> Stmt {
Stmt::Let {
name: name.to_string(),
type_ann: None,
value: expr,
span: dummy_span(),
}
}
fn int_lit(n: i64) -> Expr {
Expr::Literal(Literal::Int(n))
}
fn str_lit(s: &str) -> Expr {
Expr::Literal(Literal::Str(s.to_string()))
}
fn bool_lit(b: bool) -> Expr {
Expr::Literal(Literal::Bool(b))
}
fn binop(op: BinOp, l: Expr, r: Expr) -> Expr {
Expr::BinOp { op, left: Box::new(l), right: Box::new(r) }
}
fn test_case(name: &str, body: Vec<Stmt>) -> TestCase {
TestCase {
name: name.to_string(),
target: TestTarget::Unit,
body,
}
}
#[test]
fn test_runner_pass() {
let runner = TestRunner::new();
let tc = test_case("arithmetic", vec![
make_let("x", int_lit(6)),
make_let("y", int_lit(7)),
make_let("result", binop(BinOp::Mul, Expr::Ident("x".into()), Expr::Ident("y".into()))),
make_assert(binop(BinOp::Eq, Expr::Ident("result".into()), int_lit(42))),
]);
let result = runner.run_one(&tc, None);
assert_eq!(result.status, TestStatus::Pass);
assert!(result.assertions[0].passed);
}
#[test]
fn test_runner_fail() {
let runner = TestRunner::new();
let tc = test_case("failing", vec![
make_assert(binop(BinOp::Eq, int_lit(1), int_lit(2))),
]);
let result = runner.run_one(&tc, None);
assert_eq!(result.status, TestStatus::Fail);
assert!(!result.assertions[0].passed);
}
#[test]
fn test_runner_multiple_assertions_partial_fail() {
let runner = TestRunner::new();
let tc = test_case("partial", vec![
make_assert(bool_lit(true)),
make_assert(binop(BinOp::Eq, int_lit(1), int_lit(2))), // fails
make_assert(bool_lit(true)),
]);
let result = runner.run_one(&tc, None);
assert_eq!(result.status, TestStatus::Fail);
assert!(result.assertions[0].passed);
assert!(!result.assertions[1].passed);
assert!(result.assertions[2].passed);
}
#[test]
fn test_runner_e2e_skip_without_url() {
let runner = TestRunner::new();
let tc = TestCase {
name: "e2e test".to_string(),
target: TestTarget::E2e,
body: vec![],
};
let result = runner.run_one(&tc, None);
assert_eq!(result.status, TestStatus::Skip);
}
#[test]
fn test_runner_run_all_skips_e2e() {
let runner = TestRunner::new();
let tests = vec![
TestCase { name: "unit".into(), target: TestTarget::Unit, body: vec![] },
TestCase { name: "e2e".into(), target: TestTarget::E2e, body: vec![] },
];
let results = runner.run_all(&tests, None);
assert_eq!(results.len(), 2);
assert_eq!(results[0].status, TestStatus::Pass); // empty = pass
assert_eq!(results[1].status, TestStatus::Skip);
}
#[test]
fn test_runner_run_unit_filters_unit_only() {
let runner = TestRunner::new();
let tests = vec![
TestCase { name: "unit".into(), target: TestTarget::Unit, body: vec![] },
TestCase { name: "e2e".into(), target: TestTarget::E2e, body: vec![] },
];
let results = runner.run_unit(&tests);
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "unit");
}
#[test]
fn test_runner_empty_test_passes() {
let runner = TestRunner::new();
let tc = test_case("empty", vec![]);
let result = runner.run_one(&tc, None);
assert_eq!(result.status, TestStatus::Pass);
}
#[test]
fn test_runner_with_seed_and_activate() {
use el_parser::SeedStmt;
let runner = TestRunner::new();
let seed = Stmt::Seed(
SeedStmt::Node {
node_type: "Customer".into(),
content: "Will Anderson, founding member".into(),
importance: 0.9,
tier: Some("Semantic".into()),
},
dummy_span(),
);
// activate Customer where "founding"
let activate = Expr::Activate {
type_name: "Customer".into(),
query: "founding".into(),
};
let let_results = make_let("results", activate);
// assert results.len() > 0 => assert results.len() > 0
// We'll call .len() via a Call to Field
let len_call = Expr::Call {
func: Box::new(Expr::Field {
object: Box::new(Expr::Ident("results".into())),
field: "len".into(),
}),
args: vec![],
};
let assert_len = make_assert(binop(BinOp::Gt, len_call, int_lit(0)));
let tc = test_case("seed and activate", vec![seed, let_results, assert_len]);
let result = runner.run_one(&tc, None);
assert_eq!(result.status, TestStatus::Pass);
}
#[test]
fn test_runner_empty_graph_activate_returns_empty() {
let runner = TestRunner::new();
// No seeds — activate should return empty list
let activate = Expr::Activate {
type_name: "Customer".into(),
query: "anything".into(),
};
let let_results = make_let("results", activate);
let len_call = Expr::Call {
func: Box::new(Expr::Field {
object: Box::new(Expr::Ident("results".into())),
field: "len".into(),
}),
args: vec![],
};
let assert_zero = make_assert(binop(BinOp::Eq, len_call, int_lit(0)));
let tc = test_case("empty graph", vec![let_results, assert_zero]);
let result = runner.run_one(&tc, None);
assert_eq!(result.status, TestStatus::Pass);
}
}

Some files were not shown because too many files have changed in this diff Show More