diff --git a/Cargo.lock b/Cargo.lock index e459c1e..5dfb876 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -377,6 +377,19 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "el-integration" +version = "0.1.0" +dependencies = [ + "el-compiler", + "el-lexer", + "el-parser", + "el-seal", + "el-stdlib", + "el-types", + "serde_json", +] + [[package]] name = "el-lexer" version = "0.1.0" @@ -430,6 +443,14 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "el-stdlib" +version = "0.1.0" +dependencies = [ + "el-parser", + "el-types", +] + [[package]] name = "el-test" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 6343cbc..cc7f4d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,8 @@ members = [ "crates/el-registry", "crates/el-build", "crates/el-test", + "crates/el-stdlib", + "crates/el-integration", "bin/el", ] resolver = "2" @@ -30,6 +32,8 @@ el-manifest = { path = "crates/el-manifest" } el-registry = { path = "crates/el-registry" } el-build = { path = "crates/el-build" } el-test = { path = "crates/el-test" } +el-stdlib = { path = "crates/el-stdlib" } +el-integration = { path = "crates/el-integration" } # Engram crypto (path dep — the sealed target depends on it) engram-crypto = { path = "../engram/crates/engram-crypto" } diff --git a/bin/el/src/main.rs b/bin/el/src/main.rs index 35ff94c..7132427 100644 --- a/bin/el/src/main.rs +++ b/bin/el/src/main.rs @@ -846,6 +846,108 @@ fn run_interpreter(instructions: &[el_compiler::Bytecode]) { run_interpreter_with_args(instructions, &[]); } +// ── JSON / Value conversion helpers ────────────────────────────────────────── + +/// Convert a serde_json Value to an el Value. +fn json_value_to_el_value(v: &serde_json::Value) -> el_compiler::Value { + use el_compiler::Value; + match v { + serde_json::Value::String(s) => Value::Str(s.clone()), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + Value::Int(i) + } else if let Some(f) = n.as_f64() { + Value::Float(f) + } else { + Value::Str(n.to_string()) + } + } + serde_json::Value::Bool(b) => Value::Bool(*b), + serde_json::Value::Null => Value::Nil, + serde_json::Value::Array(arr) => { + Value::List(arr.iter().map(json_value_to_el_value).collect()) + } + serde_json::Value::Object(obj) => { + let pairs = obj.iter() + .map(|(k, v)| (k.clone(), json_value_to_el_value(v))) + .collect(); + Value::Map(pairs) + } + } +} + +/// Convert an el Value to a serde_json Value. +fn el_value_to_json_value(v: &el_compiler::Value) -> serde_json::Value { + use el_compiler::Value; + match v { + Value::Int(n) => serde_json::Value::Number((*n).into()), + Value::Float(f) => serde_json::Number::from_f64(*f) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), + Value::Str(s) => serde_json::Value::String(s.clone()), + Value::Bool(b) => serde_json::Value::Bool(*b), + Value::Nil => serde_json::Value::Null, + Value::List(items) => { + serde_json::Value::Array(items.iter().map(el_value_to_json_value).collect()) + } + Value::Map(pairs) => { + let mut map = serde_json::Map::new(); + for (k, v) in pairs { + map.insert(k.clone(), el_value_to_json_value(v)); + } + serde_json::Value::Object(map) + } + Value::ResultOk(inner) => { + serde_json::json!({"ok": el_value_to_json_value(inner)}) + } + Value::ResultErr(inner) => { + serde_json::json!({"err": el_value_to_json_value(inner)}) + } + } +} + +/// Call the Engram /search endpoint and return results as a Vec of el Values. +fn engram_activate_search(type_name: &str, query: &str) -> Vec { + use el_compiler::Value; + let engram_url = std::env::var("ENGRAM_URL") + .unwrap_or_else(|_| "http://localhost:8742".to_string()); + let api_key = std::env::var("ENGRAM_API_KEY").unwrap_or_default(); + + let body = serde_json::json!({ + "query": query, + "limit": 20 + }) + .to_string(); + + let client = reqwest::blocking::Client::new(); + let mut req = client + .post(format!("{engram_url}/search")) + .header("Content-Type", "application/json") + .body(body); + + if !api_key.is_empty() { + req = req.header("Authorization", format!("Bearer {api_key}")); + } + + let result = req + .send() + .and_then(|r| r.json::()) + .ok(); + + let results: Vec = result + .and_then(|v| v.as_array().cloned()) + .unwrap_or_default() + .iter() + .map(json_value_to_el_value) + .collect(); + + eprintln!( + "[activate] {type_name} where \"{query}\" → {} results", + results.len() + ); + results +} + /// Run a sub-interpreter starting at the given function entry point. /// Returns the value left on the stack (the return value). fn run_sub_interpreter( @@ -979,8 +1081,15 @@ fn run_sub_interpreter( } } Bytecode::GetField(field) => { - stack.pop(); - stack.push(Value::Str(format!(""))); + let obj = stack.pop().unwrap_or(Value::Nil); + let v = match obj { + Value::Map(pairs) => pairs.iter() + .find(|(k, _)| k == field) + .map(|(_, v)| v.clone()) + .unwrap_or(Value::Nil), + _ => Value::Nil, + }; + stack.push(v); } Bytecode::GetIndex => { let idx = stack.pop().unwrap_or(Value::Nil); @@ -994,9 +1103,48 @@ fn run_sub_interpreter( }; stack.push(v); } + (Value::Map(pairs), Value::Str(key)) => { + let v = pairs.iter() + .find(|(k, _)| k == &key) + .map(|(_, v)| v.clone()) + .unwrap_or(Value::Nil); + stack.push(v); + } _ => stack.push(Value::Nil), } } + Bytecode::BuildMap(n) => { + let mut pairs = Vec::new(); + for _ in 0..*n { + let val = stack.pop().unwrap_or(Value::Nil); + let key = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + other => other.to_string(), + }; + pairs.push((key, val)); + } + pairs.reverse(); + stack.push(Value::Map(pairs)); + } + Bytecode::BuildStruct { fields, .. } => { + let mut pairs = Vec::new(); + for field in fields.iter().rev() { + let val = stack.pop().unwrap_or(Value::Nil); + pairs.push((field.clone(), val)); + } + pairs.reverse(); + stack.push(Value::Map(pairs)); + } + Bytecode::SetField(field) => { + let val = stack.pop().unwrap_or(Value::Nil); + if let Some(Value::Map(pairs)) = stack.last_mut() { + if let Some(entry) = pairs.iter_mut().find(|(k, _)| k == field) { + entry.1 = val; + } else { + pairs.push((field.clone(), val)); + } + } + } Bytecode::Jump(offset) => { let new_ip = (ip as i32 + 1 + offset) as usize; ip = new_ip; @@ -1030,8 +1178,9 @@ fn run_sub_interpreter( } } Bytecode::Halt => break, - Bytecode::Activate { .. } => { - stack.push(Value::List(vec![])); + Bytecode::Activate { type_name, query } => { + let results = engram_activate_search(type_name, query); + stack.push(Value::List(results)); } _ => {} } @@ -1242,9 +1391,15 @@ fn run_interpreter_with_args(instructions: &[el_compiler::Bytecode], program_arg } } Bytecode::GetField(field) => { - // Pop the object; for now just push Nil (structs not fully implemented) - stack.pop(); - stack.push(Value::Str(format!(""))); + let obj = stack.pop().unwrap_or(Value::Nil); + let v = match obj { + Value::Map(pairs) => pairs.iter() + .find(|(k, _)| k == field) + .map(|(_, v)| v.clone()) + .unwrap_or(Value::Nil), + _ => Value::Nil, + }; + stack.push(v); } Bytecode::GetIndex => { let idx = stack.pop().unwrap_or(Value::Nil); @@ -1258,6 +1413,13 @@ fn run_interpreter_with_args(instructions: &[el_compiler::Bytecode], program_arg }; stack.push(v); } + (Value::Map(pairs), Value::Str(key)) => { + let v = pairs.iter() + .find(|(k, _)| k == &key) + .map(|(_, v)| v.clone()) + .unwrap_or(Value::Nil); + stack.push(v); + } (Value::Str(s), Value::Int(i)) => { let c = s.chars().nth(i as usize) .map(|c| Value::Str(c.to_string())) @@ -1267,9 +1429,41 @@ fn run_interpreter_with_args(instructions: &[el_compiler::Bytecode], program_arg _ => stack.push(Value::Nil), } } + Bytecode::BuildMap(n) => { + let mut pairs = Vec::new(); + for _ in 0..*n { + let val = stack.pop().unwrap_or(Value::Nil); + let key = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + other => other.to_string(), + }; + pairs.push((key, val)); + } + pairs.reverse(); + stack.push(Value::Map(pairs)); + } + Bytecode::BuildStruct { fields, .. } => { + let mut pairs = Vec::new(); + for field in fields.iter().rev() { + let val = stack.pop().unwrap_or(Value::Nil); + pairs.push((field.clone(), val)); + } + pairs.reverse(); + stack.push(Value::Map(pairs)); + } + Bytecode::SetField(field) => { + let val = stack.pop().unwrap_or(Value::Nil); + if let Some(Value::Map(pairs)) = stack.last_mut() { + if let Some(entry) = pairs.iter_mut().find(|(k, _)| k == field) { + entry.1 = val; + } else { + pairs.push((field.clone(), val)); + } + } + } Bytecode::Activate { type_name, query } => { - println!("[activate] {type_name} where \"{query}\" (no DB connected)"); - stack.push(Value::List(vec![])); + let results = engram_activate_search(type_name, query); + stack.push(Value::List(results)); } Bytecode::Jump(offset) => { let new_ip = (ip as i32 + 1 + offset) as usize; @@ -1408,14 +1602,6 @@ fn dispatch_builtin( }; BuiltinResult::Exit(code) } - "str_len" => { - let s = match stack.pop().unwrap_or(Value::Nil) { - Value::Str(s) => s, - _ => String::new(), - }; - stack.push(Value::Int(s.len() as i64)); - BuiltinResult::Handled - } "str_contains" => { let needle = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, @@ -1826,6 +2012,16 @@ fn dispatch_builtin( continue; } + // GET / — web dashboard + if method == "GET" && (url == "/" || url == "/index.html") { + let html = include_str!("dashboard.html"); + let _ = request.respond( + tiny_http::Response::from_string(html) + .with_header("Content-Type: text/html; charset=utf-8".parse::().unwrap()) + ); + continue; + } + // Default 404 let _ = request.respond( tiny_http::Response::from_string(r#"{"error":"not found"}"#) @@ -2071,10 +2267,791 @@ fn dispatch_builtin( BuiltinResult::Handled } + // ── Array builtins ──────────────────────────────────────────────────── + + "array_length" | "array_len" => { + let list = match stack.pop().unwrap_or(Value::Nil) { + Value::List(l) => l, + _ => vec![], + }; + stack.push(Value::Int(list.len() as i64)); + BuiltinResult::Handled + } + "array_push" => { + let val = stack.pop().unwrap_or(Value::Nil); + let mut list = match stack.pop().unwrap_or(Value::Nil) { + Value::List(l) => l, + _ => vec![], + }; + list.push(val); + stack.push(Value::List(list)); + BuiltinResult::Handled + } + "array_pop" => { + let mut list = match stack.pop().unwrap_or(Value::Nil) { + Value::List(l) => l, + _ => vec![], + }; + let item = list.pop().unwrap_or(Value::Nil); + stack.push(Value::List(list)); + stack.push(item); + BuiltinResult::Handled + } + "array_first" => { + let list = match stack.pop().unwrap_or(Value::Nil) { + Value::List(l) => l, + _ => vec![], + }; + stack.push(list.into_iter().next().unwrap_or(Value::Nil)); + BuiltinResult::Handled + } + "array_last" => { + let list = match stack.pop().unwrap_or(Value::Nil) { + Value::List(l) => l, + _ => vec![], + }; + stack.push(list.into_iter().last().unwrap_or(Value::Nil)); + BuiltinResult::Handled + } + "array_reverse" => { + let mut list = match stack.pop().unwrap_or(Value::Nil) { + Value::List(l) => l, + _ => vec![], + }; + list.reverse(); + stack.push(Value::List(list)); + BuiltinResult::Handled + } + "array_concat" => { + let b = match stack.pop().unwrap_or(Value::Nil) { + Value::List(l) => l, + _ => vec![], + }; + let mut a = match stack.pop().unwrap_or(Value::Nil) { + Value::List(l) => l, + _ => vec![], + }; + a.extend(b); + stack.push(Value::List(a)); + BuiltinResult::Handled + } + "array_contains" => { + let val = stack.pop().unwrap_or(Value::Nil); + let list = match stack.pop().unwrap_or(Value::Nil) { + Value::List(l) => l, + _ => vec![], + }; + stack.push(Value::Bool(list.contains(&val))); + BuiltinResult::Handled + } + "array_slice" => { + let end = match stack.pop().unwrap_or(Value::Nil) { + Value::Int(n) => n as usize, + _ => 0, + }; + let start = match stack.pop().unwrap_or(Value::Nil) { + Value::Int(n) => n as usize, + _ => 0, + }; + let list = match stack.pop().unwrap_or(Value::Nil) { + Value::List(l) => l, + _ => vec![], + }; + let start = start.min(list.len()); + let end = end.min(list.len()); + stack.push(Value::List(list[start..end].to_vec())); + BuiltinResult::Handled + } + "array_join" => { + let sep = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + let list = match stack.pop().unwrap_or(Value::Nil) { + Value::List(l) => l, + _ => vec![], + }; + let strs: Vec = list.iter().map(|v| v.to_string()).collect(); + stack.push(Value::Str(strs.join(&sep))); + BuiltinResult::Handled + } + "array_sort" => { + let mut list = match stack.pop().unwrap_or(Value::Nil) { + Value::List(l) => l, + _ => vec![], + }; + list.sort_by(|a, b| match (a, b) { + (Value::Int(x), Value::Int(y)) => x.cmp(y), + (Value::Float(x), Value::Float(y)) => x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal), + (Value::Str(x), Value::Str(y)) => x.cmp(y), + _ => std::cmp::Ordering::Equal, + }); + stack.push(Value::List(list)); + BuiltinResult::Handled + } + "array_zip" => { + let b = match stack.pop().unwrap_or(Value::Nil) { + Value::List(l) => l, + _ => vec![], + }; + let a = match stack.pop().unwrap_or(Value::Nil) { + Value::List(l) => l, + _ => vec![], + }; + let zipped: Vec = a.into_iter().zip(b.into_iter()) + .map(|(x, y)| Value::List(vec![x, y])) + .collect(); + stack.push(Value::List(zipped)); + BuiltinResult::Handled + } + "array_enumerate" => { + let list = match stack.pop().unwrap_or(Value::Nil) { + Value::List(l) => l, + _ => vec![], + }; + let enumerated: Vec = list.into_iter().enumerate() + .map(|(i, v)| Value::List(vec![Value::Int(i as i64), v])) + .collect(); + stack.push(Value::List(enumerated)); + BuiltinResult::Handled + } + + // ── Math builtins ───────────────────────────────────────────────────── + + "math_abs" => { + let v = stack.pop().unwrap_or(Value::Nil); + stack.push(match v { + Value::Int(n) => Value::Int(n.abs()), + Value::Float(f) => Value::Float(f.abs()), + _ => Value::Nil, + }); + BuiltinResult::Handled + } + "math_max" => { + let b = stack.pop().unwrap_or(Value::Nil); + let a = stack.pop().unwrap_or(Value::Nil); + stack.push(match (a, b) { + (Value::Int(x), Value::Int(y)) => Value::Int(x.max(y)), + (Value::Float(x), Value::Float(y)) => Value::Float(x.max(y)), + _ => Value::Nil, + }); + BuiltinResult::Handled + } + "math_min" => { + let b = stack.pop().unwrap_or(Value::Nil); + let a = stack.pop().unwrap_or(Value::Nil); + stack.push(match (a, b) { + (Value::Int(x), Value::Int(y)) => Value::Int(x.min(y)), + (Value::Float(x), Value::Float(y)) => Value::Float(x.min(y)), + _ => Value::Nil, + }); + BuiltinResult::Handled + } + "math_floor" => { + let v = stack.pop().unwrap_or(Value::Nil); + stack.push(match v { + Value::Float(f) => Value::Int(f.floor() as i64), + Value::Int(n) => Value::Int(n), + _ => Value::Nil, + }); + BuiltinResult::Handled + } + "math_ceil" => { + let v = stack.pop().unwrap_or(Value::Nil); + stack.push(match v { + Value::Float(f) => Value::Int(f.ceil() as i64), + Value::Int(n) => Value::Int(n), + _ => Value::Nil, + }); + BuiltinResult::Handled + } + "math_round" => { + let v = stack.pop().unwrap_or(Value::Nil); + stack.push(match v { + Value::Float(f) => Value::Int(f.round() as i64), + Value::Int(n) => Value::Int(n), + _ => Value::Nil, + }); + BuiltinResult::Handled + } + "math_sqrt" => { + let v = stack.pop().unwrap_or(Value::Nil); + stack.push(match v { + Value::Float(f) => Value::Float(f.sqrt()), + Value::Int(n) => Value::Float((n as f64).sqrt()), + _ => Value::Nil, + }); + BuiltinResult::Handled + } + "math_pow" => { + let exp = stack.pop().unwrap_or(Value::Nil); + let base = stack.pop().unwrap_or(Value::Nil); + let base_f = match base { Value::Float(f) => f, Value::Int(n) => n as f64, _ => 0.0 }; + let exp_f = match exp { Value::Float(f) => f, Value::Int(n) => n as f64, _ => 0.0 }; + stack.push(Value::Float(base_f.powf(exp_f))); + BuiltinResult::Handled + } + + // ── String aliases and extras ───────────────────────────────────────── + + "string_len" | "str_len" => { + let s = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + stack.push(Value::Int(s.chars().count() as i64)); + BuiltinResult::Handled + } + "string_trim" => { + let s = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + stack.push(Value::Str(s.trim().to_string())); + BuiltinResult::Handled + } + "string_to_upper" | "str_upper" => { + let s = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + stack.push(Value::Str(s.to_uppercase())); + BuiltinResult::Handled + } + "string_to_lower" | "str_lower" => { + let s = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + stack.push(Value::Str(s.to_lowercase())); + BuiltinResult::Handled + } + "string_replace" => { + let replacement = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + let pattern = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + let source = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + stack.push(Value::Str(source.replace(&pattern, &replacement))); + BuiltinResult::Handled + } + "string_index_of" => { + let needle = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + let haystack = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + let idx = haystack.find(&needle).map(|i| i as i64).unwrap_or(-1); + stack.push(Value::Int(idx)); + BuiltinResult::Handled + } + "string_substring" => { + let end = match stack.pop().unwrap_or(Value::Nil) { + Value::Int(n) => n as usize, + _ => 0, + }; + let start = match stack.pop().unwrap_or(Value::Nil) { + Value::Int(n) => n as usize, + _ => 0, + }; + let s = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + let chars: Vec = s.chars().collect(); + let start = start.min(chars.len()); + let end = end.min(chars.len()); + stack.push(Value::Str(chars[start..end].iter().collect())); + BuiltinResult::Handled + } + "string_contains" => { + let needle = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + let haystack = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + stack.push(Value::Bool(haystack.contains(needle.as_str()))); + BuiltinResult::Handled + } + "string_starts_with" => { + let prefix = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + let s = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + stack.push(Value::Bool(s.starts_with(prefix.as_str()))); + BuiltinResult::Handled + } + "string_split" => { + let delim = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + let s = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + let parts: Vec = s.split(delim.as_str()) + .map(|p| Value::Str(p.to_string())) + .collect(); + stack.push(Value::List(parts)); + BuiltinResult::Handled + } + "string_concat" => { + let b = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + other => other.to_string(), + }; + let a = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + other => other.to_string(), + }; + stack.push(Value::Str(a + &b)); + BuiltinResult::Handled + } + "to_string" => { + let v = stack.pop().unwrap_or(Value::Nil); + stack.push(Value::Str(v.to_string())); + BuiltinResult::Handled + } + "str_to_int" | "parse_int" => { + let s = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + let result = s.trim().parse::().map(Value::Int).unwrap_or(Value::Nil); + stack.push(result); + BuiltinResult::Handled + } + "str_to_float" | "parse_float" => { + let s = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + let result = s.trim().parse::().map(Value::Float).unwrap_or(Value::Nil); + stack.push(result); + BuiltinResult::Handled + } + + // ── JSON native builtins ────────────────────────────────────────────── + + "json_stringify" => { + let v = stack.pop().unwrap_or(Value::Nil); + let jv = el_value_to_json_value(&v); + let s = serde_json::to_string(&jv).unwrap_or_else(|_| "null".to_string()); + stack.push(Value::Str(s)); + BuiltinResult::Handled + } + "json_parse" => { + let s = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => "null".to_string(), + }; + let jv: serde_json::Value = serde_json::from_str(&s).unwrap_or(serde_json::Value::Null); + stack.push(json_value_to_el_value(&jv)); + BuiltinResult::Handled + } + + // ── Map builtins ────────────────────────────────────────────────────── + + "map_new" => { + stack.push(Value::Map(vec![])); + BuiltinResult::Handled + } + "map_get" => { + let key = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + other => other.to_string(), + }; + let map = match stack.pop().unwrap_or(Value::Nil) { + Value::Map(pairs) => pairs, + _ => vec![], + }; + let v = map.iter().find(|(k, _)| k == &key).map(|(_, v)| v.clone()).unwrap_or(Value::Nil); + stack.push(v); + BuiltinResult::Handled + } + "map_set" => { + let val = stack.pop().unwrap_or(Value::Nil); + let key = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + other => other.to_string(), + }; + let mut pairs = match stack.pop().unwrap_or(Value::Nil) { + Value::Map(p) => p, + _ => vec![], + }; + if let Some(entry) = pairs.iter_mut().find(|(k, _)| k == &key) { + entry.1 = val; + } else { + pairs.push((key, val)); + } + stack.push(Value::Map(pairs)); + BuiltinResult::Handled + } + "map_remove" => { + let key = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + other => other.to_string(), + }; + let pairs = match stack.pop().unwrap_or(Value::Nil) { + Value::Map(p) => p, + _ => vec![], + }; + let new_pairs: Vec<_> = pairs.into_iter().filter(|(k, _)| k != &key).collect(); + stack.push(Value::Map(new_pairs)); + BuiltinResult::Handled + } + "map_contains" => { + let key = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + other => other.to_string(), + }; + let pairs = match stack.pop().unwrap_or(Value::Nil) { + Value::Map(p) => p, + _ => vec![], + }; + stack.push(Value::Bool(pairs.iter().any(|(k, _)| k == &key))); + BuiltinResult::Handled + } + "map_keys" => { + let pairs = match stack.pop().unwrap_or(Value::Nil) { + Value::Map(p) => p, + _ => vec![], + }; + let keys: Vec = pairs.into_iter().map(|(k, _)| Value::Str(k)).collect(); + stack.push(Value::List(keys)); + BuiltinResult::Handled + } + "map_values" => { + let pairs = match stack.pop().unwrap_or(Value::Nil) { + Value::Map(p) => p, + _ => vec![], + }; + let vals: Vec = pairs.into_iter().map(|(_, v)| v).collect(); + stack.push(Value::List(vals)); + BuiltinResult::Handled + } + "map_len" => { + let pairs = match stack.pop().unwrap_or(Value::Nil) { + Value::Map(p) => p, + _ => vec![], + }; + stack.push(Value::Int(pairs.len() as i64)); + BuiltinResult::Handled + } + + // ── Result builtins ─────────────────────────────────────────────────── + + "result_ok" => { + let v = stack.pop().unwrap_or(Value::Nil); + stack.push(Value::ResultOk(Box::new(v))); + BuiltinResult::Handled + } + "result_err" => { + let v = stack.pop().unwrap_or(Value::Nil); + stack.push(Value::ResultErr(Box::new(v))); + BuiltinResult::Handled + } + "result_is_ok" => { + let v = stack.pop().unwrap_or(Value::Nil); + stack.push(Value::Bool(matches!(v, Value::ResultOk(_)))); + BuiltinResult::Handled + } + "result_is_err" => { + let v = stack.pop().unwrap_or(Value::Nil); + stack.push(Value::Bool(matches!(v, Value::ResultErr(_)))); + BuiltinResult::Handled + } + "result_unwrap" => { + let v = stack.pop().unwrap_or(Value::Nil); + match v { + Value::ResultOk(inner) => stack.push(*inner), + Value::ResultErr(e) => panic!("result_unwrap called on Err: {e}"), + other => stack.push(other), + } + BuiltinResult::Handled + } + "result_unwrap_or" => { + let default = stack.pop().unwrap_or(Value::Nil); + let v = stack.pop().unwrap_or(Value::Nil); + match v { + Value::ResultOk(inner) => stack.push(*inner), + _ => stack.push(default), + } + BuiltinResult::Handled + } + + // ── Optional builtins ───────────────────────────────────────────────── + + "optional_some" => { + let v = stack.pop().unwrap_or(Value::Nil); + stack.push(v); + BuiltinResult::Handled + } + "optional_none" => { + stack.push(Value::Nil); + BuiltinResult::Handled + } + "optional_is_some" => { + let v = stack.pop().unwrap_or(Value::Nil); + stack.push(Value::Bool(!matches!(v, Value::Nil))); + BuiltinResult::Handled + } + "optional_is_none" => { + let v = stack.pop().unwrap_or(Value::Nil); + stack.push(Value::Bool(matches!(v, Value::Nil))); + BuiltinResult::Handled + } + "optional_unwrap" => { + let v = stack.pop().unwrap_or(Value::Nil); + if matches!(v, Value::Nil) { + panic!("optional_unwrap called on None"); + } + stack.push(v); + BuiltinResult::Handled + } + "optional_unwrap_or" => { + let default = stack.pop().unwrap_or(Value::Nil); + let v = stack.pop().unwrap_or(Value::Nil); + if matches!(v, Value::Nil) { + stack.push(default); + } else { + stack.push(v); + } + BuiltinResult::Handled + } + + // ── HTTP extended builtins ──────────────────────────────────────────── + + "http_put" => { + let body = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + let url = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + let result = reqwest::blocking::Client::new() + .put(&url) + .header("Content-Type", "application/json") + .body(body) + .send() + .and_then(|r| r.text()) + .unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}") ); + stack.push(Value::Str(result)); + BuiltinResult::Handled + } + "http_delete" => { + let url = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + let result = reqwest::blocking::Client::new() + .delete(&url) + .send() + .and_then(|r| r.text()) + .unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}") ); + stack.push(Value::Str(result)); + BuiltinResult::Handled + } + "http_patch" => { + let body = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + let url = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + let result = reqwest::blocking::Client::new() + .patch(&url) + .header("Content-Type", "application/json") + .body(body) + .send() + .and_then(|r| r.text()) + .unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}") ); + stack.push(Value::Str(result)); + BuiltinResult::Handled + } + "http_head" => { + let url = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + let result = reqwest::blocking::Client::new() + .head(&url) + .send() + .map(|r| Value::Int(r.status().as_u16() as i64)) + .unwrap_or(Value::Int(-1)); + stack.push(result); + BuiltinResult::Handled + } + + // ── Engram builtins ─────────────────────────────────────────────────── + + "engram_activate" => { + let query = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + other => other.to_string(), + }; + let type_name = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + other => other.to_string(), + }; + let results = engram_activate_search(&type_name, &query); + stack.push(Value::List(results)); + BuiltinResult::Handled + } + "engram_relate" => { + let weight = match stack.pop().unwrap_or(Value::Nil) { + Value::Float(f) => f, + Value::Int(n) => n as f64, + _ => 1.0, + }; + let relation = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + other => other.to_string(), + }; + let to_id = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + other => other.to_string(), + }; + let from_id = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + other => other.to_string(), + }; + let engram_url = std::env::var("ENGRAM_URL") + .unwrap_or_else(|_| "http://localhost:8742".to_string()); + let api_key = std::env::var("ENGRAM_API_KEY").unwrap_or_default(); + let body = serde_json::json!({ + "from_id": from_id, + "to_id": to_id, + "relation": relation, + "weight": weight + }) + .to_string(); + let mut req = reqwest::blocking::Client::new() + .post(format!("{engram_url}/edges")) + .header("Content-Type", "application/json") + .body(body); + if !api_key.is_empty() { + req = req.header("Authorization", format!("Bearer {api_key}")); + } + let ok = req.send().map(|r| r.status().is_success()).unwrap_or(false); + stack.push(Value::Bool(ok)); + BuiltinResult::Handled + } + "engram_neighbors" => { + let node_id = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + other => other.to_string(), + }; + let engram_url = std::env::var("ENGRAM_URL") + .unwrap_or_else(|_| "http://localhost:8742".to_string()); + let api_key = std::env::var("ENGRAM_API_KEY").unwrap_or_default(); + let mut req = reqwest::blocking::Client::new() + .get(format!("{engram_url}/nodes/{node_id}/edges")); + if !api_key.is_empty() { + req = req.header("Authorization", format!("Bearer {api_key}")); + } + let result = req + .send() + .and_then(|r| r.json::()) + .ok(); + let list: Vec = result + .and_then(|v| v.as_array().cloned()) + .unwrap_or_default() + .iter() + .map(json_value_to_el_value) + .collect(); + stack.push(Value::List(list)); + BuiltinResult::Handled + } + + // ── Process / system builtins ───────────────────────────────────────── + + "sleep_ms" => { + let ms = match stack.pop().unwrap_or(Value::Nil) { + Value::Int(n) => n as u64, + _ => 0, + }; + std::thread::sleep(std::time::Duration::from_millis(ms)); + stack.push(Value::Nil); + BuiltinResult::Handled + } + "timestamp" => { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + // Format as ISO-8601 approximation: seconds.millis + let secs = ts.as_secs(); + let millis = ts.subsec_millis(); + // Simple ISO-like: YYYY-MM-DDTHH:MM:SS.mmmZ via manual calc + // Use epoch-based formatting + let s = format_epoch_as_iso(secs, millis); + stack.push(Value::Str(s)); + BuiltinResult::Handled + } + _ => BuiltinResult::NotBuiltin, } } +/// Format a Unix timestamp (seconds + millis) as an ISO 8601 string. +/// This avoids pulling in chrono while still producing a readable timestamp. +fn format_epoch_as_iso(secs: u64, millis: u32) -> String { + // Days since epoch, accounting for leap years + let mut days = secs / 86400; + let time_of_day = secs % 86400; + let hours = time_of_day / 3600; + let minutes = (time_of_day % 3600) / 60; + let seconds = time_of_day % 60; + + // Gregorian calendar calculation starting from 1970-01-01 + let mut year = 1970u64; + loop { + let days_in_year = if is_leap(year) { 366 } else { 365 }; + if days < days_in_year { + break; + } + days -= days_in_year; + year += 1; + } + let months = [31u64, if is_leap(year) { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + let mut month = 1u64; + for &m in &months { + if days < m { + break; + } + days -= m; + month += 1; + } + let day = days + 1; + format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}.{millis:03}Z") +} + +fn is_leap(year: u64) -> bool { + (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) +} + /// Interpreter with debugger support — emits DebugEvents as it runs. fn run_interpreter_debug(instructions: &[el_compiler::Bytecode], debugger: &mut el_compiler::Debugger) { use el_compiler::{Bytecode, Value}; diff --git a/crates/el-build/src/build.rs b/crates/el-build/src/build.rs index 41db98f..e02ca7c 100644 --- a/crates/el-build/src/build.rs +++ b/crates/el-build/src/build.rs @@ -298,9 +298,9 @@ impl BuildSystem { let source = std::fs::read_to_string(&entry)?; let tokens = el_lexer::tokenize(&source) - .map_err(|e| el_compiler::CompileError::Lex(e))?; + .map_err(el_compiler::CompileError::Lex)?; let program = el_parser::parse(tokens, source.clone()) - .map_err(|e| el_compiler::CompileError::Parse(e))?; + .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()) diff --git a/crates/el-build/src/plugin.rs b/crates/el-build/src/plugin.rs index c5cbe57..b161ee8 100644 --- a/crates/el-build/src/plugin.rs +++ b/crates/el-build/src/plugin.rs @@ -117,7 +117,7 @@ impl PluginRegistry { manifest: &Manifest, plugin_dir: &Path, ) -> Result<(), PluginError> { - for (name, _version) in &manifest.plugins { + 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. diff --git a/crates/el-compiler/src/bytecode.rs b/crates/el-compiler/src/bytecode.rs index e5b111e..718b20c 100644 --- a/crates/el-compiler/src/bytecode.rs +++ b/crates/el-compiler/src/bytecode.rs @@ -16,6 +16,13 @@ pub enum Value { Nil, /// A list of values (used for `activate` results and array literals). List(Vec), + /// A key-value map — used for struct instances and Map literals. + /// Stored as a Vec of pairs to keep ordering and remain Serialize-friendly. + Map(Vec<(String, Value)>), + /// A Result value — Ok variant. + ResultOk(Box), + /// A Result value — Err variant. + ResultErr(Box), } impl std::fmt::Display for Value { @@ -30,6 +37,12 @@ impl std::fmt::Display for Value { 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})"), } } } @@ -89,6 +102,13 @@ pub enum Bytecode { 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 struct instance: pop N field values (named by fields in order), push Map. + BuildStruct { type_name: String, fields: Vec }, + /// Set a field on the Map on top of stack. + SetField(String), // ── Special ─────────────────────────────────────────────────────────────── /// `activate TypeName "query"` — emit a semantic query stub. @@ -132,6 +152,11 @@ impl std::fmt::Display for Bytecode { 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::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}\"") } diff --git a/crates/el-integration/Cargo.toml b/crates/el-integration/Cargo.toml new file mode 100644 index 0000000..aa1d0d0 --- /dev/null +++ b/crates/el-integration/Cargo.toml @@ -0,0 +1,19 @@ +[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 } diff --git a/crates/el-integration/src/lib.rs b/crates/el-integration/src/lib.rs new file mode 100644 index 0000000..2784fd7 --- /dev/null +++ b/crates/el-integration/src/lib.rs @@ -0,0 +1,48 @@ +//! 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 { + 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, String> { + let out = Compiler::compile(src, CompilerOptions::default()) + .map_err(|e| format!("compile error: {e}"))?; + Ok(out.artifact) +} diff --git a/crates/el-integration/src/tests/activate_typing.rs b/crates/el-integration/src/tests/activate_typing.rs new file mode 100644 index 0000000..3e98720 --- /dev/null +++ b/crates/el-integration/src/tests/activate_typing.rs @@ -0,0 +1,153 @@ +//! 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"); +} diff --git a/crates/el-integration/src/tests/compiler_pipeline.rs b/crates/el-integration/src/tests/compiler_pipeline.rs new file mode 100644 index 0000000..2f8086e --- /dev/null +++ b/crates/el-integration/src/tests/compiler_pipeline.rs @@ -0,0 +1,194 @@ +//! 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()); +} diff --git a/crates/el-integration/src/tests/decorator_codegen.rs b/crates/el-integration/src/tests/decorator_codegen.rs new file mode 100644 index 0000000..d8820b5 --- /dev/null +++ b/crates/el-integration/src/tests/decorator_codegen.rs @@ -0,0 +1,139 @@ +//! 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()); +} diff --git a/crates/el-integration/src/tests/error_propagation.rs b/crates/el-integration/src/tests/error_propagation.rs new file mode 100644 index 0000000..2279a1d --- /dev/null +++ b/crates/el-integration/src/tests/error_propagation.rs @@ -0,0 +1,220 @@ +//! Tests for Result type annotation, the `?` try operator, and closures. + +use crate::{parse_ok, pipeline_ok}; + +// ── Result type annotation parsing ───────────────────────────────────── + +#[test] +fn test_parse_result_return_type() { + let src = r#" +fn divide(a: Int, b: Int) -> Result { + return a +} +"#; + assert!(parse_ok(src).is_ok(), "Result return type should parse"); +} + +#[test] +fn test_parse_result_in_let_binding() { + let src = r#" +fn parse_int(s: String) -> Result { + return 0 +} +"#; + assert!(parse_ok(src).is_ok(), "Result in function signature should parse"); +} + +#[test] +fn test_parse_result_with_complex_types() { + let src = r#" +fn fetch_user(id: Int) -> Result { + return "user" +} +"#; + assert!(parse_ok(src).is_ok(), "Result should parse"); +} + +#[test] +fn test_parse_nested_result() { + let src = r#" +fn complex_op() -> Result, 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 { + return a +} +fn compute() -> Result { + 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) -> Result { + 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 { return 1 } +fn step2(n: Int) -> Result { return "ok" } +fn pipeline() -> Result { + 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 { + return x +} +"#; + assert!(pipeline_ok(src).is_ok(), "Result 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 { + return 1 +} +fn compute() -> Result { + 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 { + 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"); +} diff --git a/crates/el-integration/src/tests/mod.rs b/crates/el-integration/src/tests/mod.rs new file mode 100644 index 0000000..6f39dca --- /dev/null +++ b/crates/el-integration/src/tests/mod.rs @@ -0,0 +1,9 @@ +//! 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; diff --git a/crates/el-integration/src/tests/protocol_conformance.rs b/crates/el-integration/src/tests/protocol_conformance.rs new file mode 100644 index 0000000..30304f3 --- /dev/null +++ b/crates/el-integration/src/tests/protocol_conformance.rs @@ -0,0 +1,147 @@ +//! 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 +} +"#; + 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"); +} diff --git a/crates/el-integration/src/tests/stdlib_usage.rs b/crates/el-integration/src/tests/stdlib_usage.rs new file mode 100644 index 0000000..ae9ac08 --- /dev/null +++ b/crates/el-integration/src/tests/stdlib_usage.rs @@ -0,0 +1,187 @@ +//! 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 = 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 = 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 = 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"); +} diff --git a/crates/el-integration/src/tests/test_framework.rs b/crates/el-integration/src/tests/test_framework.rs new file mode 100644 index 0000000..5495184 --- /dev/null +++ b/crates/el-integration/src/tests/test_framework.rs @@ -0,0 +1,131 @@ +//! 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"); +} diff --git a/crates/el-lexer/src/lexer.rs b/crates/el-lexer/src/lexer.rs index fe500b3..03d608c 100644 --- a/crates/el-lexer/src/lexer.rs +++ b/crates/el-lexer/src/lexer.rs @@ -286,7 +286,7 @@ impl<'src> Lexer<'src> { if c.is_ascii_digit() || c == '_' { raw.push(c); self.advance(); - } else if c == '.' && self.peek2().map_or(false, |d| d.is_ascii_digit()) { + } else if c == '.' && self.peek2().is_some_and(|d| d.is_ascii_digit()) { is_float = true; raw.push(c); self.advance(); diff --git a/crates/el-manifest/src/lib.rs b/crates/el-manifest/src/lib.rs index 605a0a4..a626cb1 100644 --- a/crates/el-manifest/src/lib.rs +++ b/crates/el-manifest/src/lib.rs @@ -13,7 +13,7 @@ //! version = "0.1.0" //! edition = "2026" //! "#; -//! let manifest = Manifest::from_str(toml).unwrap(); +//! let manifest = Manifest::parse(toml).unwrap(); //! assert_eq!(manifest.package.name, "my-service"); //! ``` diff --git a/crates/el-manifest/src/manifest.rs b/crates/el-manifest/src/manifest.rs index 7734811..b05c7c5 100644 --- a/crates/el-manifest/src/manifest.rs +++ b/crates/el-manifest/src/manifest.rs @@ -342,14 +342,14 @@ pub struct Manifest { impl Manifest { /// Parse a manifest from a TOML string. - pub fn from_str(s: &str) -> crate::ManifestResult { + pub fn parse(s: &str) -> crate::ManifestResult { crate::parse::parse_manifest(s) } /// Parse a manifest from a file on disk. pub fn from_file(path: &std::path::Path) -> crate::ManifestResult { let text = std::fs::read_to_string(path).map_err(crate::ManifestError::Io)?; - Self::from_str(&text) + Self::parse(&text) } /// Walk up the directory tree from `from` until an `el.toml` is found. diff --git a/crates/el-parser/src/parser.rs b/crates/el-parser/src/parser.rs index 0adffb3..5b06e88 100644 --- a/crates/el-parser/src/parser.rs +++ b/crates/el-parser/src/parser.rs @@ -911,7 +911,7 @@ impl Parser { match self.peek() { Token::StringLiteral(_) => { // Check if next is Colon - self.tokens.get(self.pos + 1).map_or(false, |t| matches!(t.node, Token::Colon)) + self.tokens.get(self.pos + 1).is_some_and(|t| matches!(t.node, Token::Colon)) } Token::RBrace => false, // empty block `{}` _ => false, diff --git a/crates/el-stdlib/Cargo.toml b/crates/el-stdlib/Cargo.toml new file mode 100644 index 0000000..43c9615 --- /dev/null +++ b/crates/el-stdlib/Cargo.toml @@ -0,0 +1,10 @@ +[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 } diff --git a/crates/el-stdlib/src/array.rs b/crates/el-stdlib/src/array.rs new file mode 100644 index 0000000..f1073a0 --- /dev/null +++ b/crates/el-stdlib/src/array.rs @@ -0,0 +1,86 @@ +//! 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)); + // 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)); +} + +#[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()); + } +} diff --git a/crates/el-stdlib/src/engram.rs b/crates/el-stdlib/src/engram.rs new file mode 100644 index 0000000..d21a975 --- /dev/null +++ b/crates/el-stdlib/src/engram.rs @@ -0,0 +1,69 @@ +//! 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))); + } +} diff --git a/crates/el-stdlib/src/lib.rs b/crates/el-stdlib/src/lib.rs new file mode 100644 index 0000000..b038d83 --- /dev/null +++ b/crates/el-stdlib/src/lib.rs @@ -0,0 +1,113 @@ +//! 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 operations +//! - `std::optional`— T? operations +//! - `std::math` — numeric operations +//! - `std::map` — Map 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, 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()); + } +} diff --git a/crates/el-stdlib/src/map.rs b/crates/el-stdlib/src/map.rs new file mode 100644 index 0000000..28109be --- /dev/null +++ b/crates/el-stdlib/src/map.rs @@ -0,0 +1,56 @@ +//! Map 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()); + } +} diff --git a/crates/el-stdlib/src/math.rs b/crates/el-stdlib/src/math.rs new file mode 100644 index 0000000..c0d54cb --- /dev/null +++ b/crates/el-stdlib/src/math.rs @@ -0,0 +1,49 @@ +//! 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)); +} + +#[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))); + } +} diff --git a/crates/el-stdlib/src/optional.rs b/crates/el-stdlib/src/optional.rs new file mode 100644 index 0000000..c71f843 --- /dev/null +++ b/crates/el-stdlib/src/optional.rs @@ -0,0 +1,60 @@ +//! 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()); + } +} diff --git a/crates/el-stdlib/src/result.rs b/crates/el-stdlib/src/result.rs new file mode 100644 index 0000000..bcad825 --- /dev/null +++ b/crates/el-stdlib/src/result.rs @@ -0,0 +1,65 @@ +//! Result 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) -> 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? + env.register_fn("result_ok", fn_type(vec![result_unk.clone()], Type::Optional(Box::new(Type::Unknown)))); + // result_err(Result) -> E? + env.register_fn("result_err", fn_type(vec![result_unk.clone()], Type::Optional(Box::new(Type::Unknown)))); + // result_is_ok(Result) -> Bool + env.register_fn("result_is_ok", fn_type(vec![result_unk.clone()], Type::Bool)); + // result_is_err(Result) -> Bool + env.register_fn("result_is_err", fn_type(vec![result_unk.clone()], Type::Bool)); + // result_map(Result, fn(T) -> U) -> Result + 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, fn(T) -> Result) -> Result + 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 — wrap a value in Ok + env.register_fn("ok", fn_type(vec![Type::Unknown], result_unk.clone())); + // result_err_val(E) -> Result — 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()); + } +} diff --git a/crates/el-stdlib/src/string.rs b/crates/el-stdlib/src/string.rs new file mode 100644 index 0000000..9b2686b --- /dev/null +++ b/crates/el-stdlib/src/string.rs @@ -0,0 +1,62 @@ +//! 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)); +} + +#[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))); + } +} diff --git a/crates/el-test/src/eval.rs b/crates/el-test/src/eval.rs index 5143c3b..6c112bd 100644 --- a/crates/el-test/src/eval.rs +++ b/crates/el-test/src/eval.rs @@ -300,17 +300,17 @@ impl<'g> Evaluator<'g> { Ok(EvalValue::Nil) } - // New expression kinds — return Nil - _ => { - return Ok(EvalValue::Nil); - } - Expr::Sealed(stmts) => { for s in stmts { self.exec_stmt(s)?; } Ok(EvalValue::Nil) } + + // New expression kinds — return Nil + _ => { + Ok(EvalValue::Nil) + } } } diff --git a/crates/el-test/src/runner.rs b/crates/el-test/src/runner.rs index 8cb0a4f..b99d4d7 100644 --- a/crates/el-test/src/runner.rs +++ b/crates/el-test/src/runner.rs @@ -68,7 +68,7 @@ impl TestRunner { } /// Run only e2e tests. - pub fn run_e2e<'a>(&self, tests: &'a [TestCase], engram_url: &str) -> Vec { + pub fn run_e2e(&self, tests: &[TestCase], engram_url: &str) -> Vec { tests .iter() .filter(|t| matches!(t.target, TestTarget::E2e | TestTarget::Both)) diff --git a/crates/el-types/src/checker.rs b/crates/el-types/src/checker.rs index 7670d3d..87fbfd4 100644 --- a/crates/el-types/src/checker.rs +++ b/crates/el-types/src/checker.rs @@ -662,7 +662,9 @@ impl Printable for User { fn print(msg: String) -> Void { } } #[test] fn test_map_type_annotation() { - assert_ok(r#"let m: Map = m"#); + // Just test that Map type annotation parses and resolves without crashing + // Use a function body where a self-reference is valid + assert_ok(r#"fn get_map() -> Map { return get_map() }"#); } #[test]