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:
Generated
-5088
File diff suppressed because it is too large
Load Diff
@@ -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"] }
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
@@ -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, ¤t_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, ¤t_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(¶ms_str.join(", "));
|
||||
out.push('|');
|
||||
if let Some(rt) = return_type {
|
||||
out.push_str(&format!(" -> {}", self.fmt_type(rt)));
|
||||
}
|
||||
out.push(' ');
|
||||
self.fmt_expr(out, body, depth);
|
||||
}
|
||||
Expr::StructLit { type_name, fields, .. } => {
|
||||
out.push_str(type_name);
|
||||
out.push_str(" { ");
|
||||
let fields_str: Vec<String> = fields
|
||||
.iter()
|
||||
.map(|(name, val)| {
|
||||
let mut s = format!("{name}: ");
|
||||
self.fmt_expr(&mut s, val, depth);
|
||||
s
|
||||
})
|
||||
.collect();
|
||||
out.push_str(&fields_str.join(", "));
|
||||
out.push_str(" }");
|
||||
}
|
||||
|
||||
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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
#[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("<"));
|
||||
assert!(escaped.contains("&"));
|
||||
assert!(escaped.contains(">"));
|
||||
assert!(escaped.contains("""));
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user