feat: engram-lang — new programming language, quantum-sealed prod target, spreading activation types
This commit is contained in:
@@ -0,0 +1,572 @@
|
||||
//! Type checker — walks the AST and infers / verifies types.
|
||||
|
||||
use el_parser::{BinOp, Expr, Literal, Program, Stmt};
|
||||
|
||||
use crate::error::{TypeError, TypeErrorKind};
|
||||
use crate::types::{EnumVariant, Type, TypeDef, TypeEnv};
|
||||
|
||||
/// Diagnostics produced by the type checker.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Diagnostic {
|
||||
pub message: std::string::String,
|
||||
pub is_error: bool,
|
||||
}
|
||||
|
||||
/// Entry point: type-check a parsed program.
|
||||
///
|
||||
/// Returns a list of diagnostics. An empty list means the program is
|
||||
/// well-typed. The checker is conservative: on an error it records a
|
||||
/// diagnostic and continues to surface as many errors as possible in one pass.
|
||||
pub struct TypeChecker {
|
||||
pub env: TypeEnv,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
impl TypeChecker {
|
||||
pub fn new(env: TypeEnv) -> Self {
|
||||
Self { env, diagnostics: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn with_builtins() -> Self {
|
||||
Self::new(TypeEnv::with_builtins())
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Check the entire program. Returns the list of diagnostics.
|
||||
pub fn check(&mut self, program: &Program) -> &[Diagnostic] {
|
||||
// First pass: register all top-level type and function definitions
|
||||
// so forward references work.
|
||||
self.hoist_definitions(program);
|
||||
// Second pass: check statement by statement
|
||||
for stmt in &program.stmts {
|
||||
self.check_stmt(stmt);
|
||||
}
|
||||
&self.diagnostics
|
||||
}
|
||||
|
||||
/// Returns `true` if no error diagnostics were emitted.
|
||||
pub fn ok(&self) -> bool {
|
||||
!self.diagnostics.iter().any(|d| d.is_error)
|
||||
}
|
||||
|
||||
// ── Definition hoisting ───────────────────────────────────────────────────
|
||||
|
||||
fn hoist_definitions(&mut self, program: &Program) {
|
||||
for stmt in &program.stmts {
|
||||
match stmt {
|
||||
Stmt::TypeDef { name, fields, .. } => {
|
||||
let resolved_fields: Vec<_> = fields.iter().filter_map(|f| {
|
||||
match self.env.resolve_type_expr(&f.type_ann) {
|
||||
Ok(ty) => Some((f.name.clone(), ty)),
|
||||
Err(e) => { self.error(e); None }
|
||||
}
|
||||
}).collect();
|
||||
let def = TypeDef::Struct { name: name.clone(), fields: resolved_fields };
|
||||
self.env.register_type(name.clone(), def, "");
|
||||
}
|
||||
Stmt::EnumDef { name, variants, .. } => {
|
||||
let resolved_variants: Vec<_> = variants.iter().filter_map(|v| {
|
||||
let payload = if let Some(pt) = &v.payload {
|
||||
match self.env.resolve_type_expr(pt) {
|
||||
Ok(ty) => Some(ty),
|
||||
Err(e) => { self.error(e); return None; }
|
||||
}
|
||||
} else { None };
|
||||
Some(EnumVariant { name: v.name.clone(), payload })
|
||||
}).collect();
|
||||
let def = TypeDef::Enum { name: name.clone(), variants: resolved_variants };
|
||||
self.env.register_type(name.clone(), def, "");
|
||||
}
|
||||
Stmt::FnDef { name, params, return_type, .. } => {
|
||||
let param_types: Vec<_> = params.iter().filter_map(|p| {
|
||||
self.env.resolve_type_expr(&p.type_ann).ok()
|
||||
}).collect();
|
||||
if let Ok(ret) = self.env.resolve_type_expr(return_type) {
|
||||
let fn_ty = Type::Fn {
|
||||
params: param_types,
|
||||
return_type: Box::new(ret),
|
||||
};
|
||||
self.env.register_fn(name.clone(), fn_ty);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Statement checking ────────────────────────────────────────────────────
|
||||
|
||||
fn check_stmt(&mut self, stmt: &Stmt) {
|
||||
match stmt {
|
||||
Stmt::Let { name, type_ann, value, .. } => {
|
||||
let inferred = self.infer_expr(value);
|
||||
if let Some(ann) = type_ann {
|
||||
match self.env.resolve_type_expr(ann) {
|
||||
Ok(declared) => {
|
||||
if !self.env.check_compatible(&inferred, &declared) {
|
||||
self.emit_error(TypeErrorKind::TypeMismatch {
|
||||
expected: declared.to_string(),
|
||||
got: inferred.to_string(),
|
||||
});
|
||||
}
|
||||
self.env.bind(name.clone(), declared);
|
||||
}
|
||||
Err(e) => {
|
||||
self.error(e);
|
||||
self.env.bind(name.clone(), inferred);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.env.bind(name.clone(), inferred);
|
||||
}
|
||||
}
|
||||
Stmt::Return(expr, _) => { self.infer_expr(expr); }
|
||||
Stmt::Expr(expr, _) => { self.infer_expr(expr); }
|
||||
Stmt::FnDef { name, params, return_type, body, .. } => {
|
||||
// Push new scope for function body
|
||||
let mut inner_env = self.env.clone();
|
||||
for param in params {
|
||||
if let Ok(ty) = inner_env.resolve_type_expr(¶m.type_ann) {
|
||||
inner_env.bind(param.name.clone(), ty);
|
||||
}
|
||||
}
|
||||
let mut inner_checker = TypeChecker::new(inner_env);
|
||||
inner_checker.hoist_definitions_stmts(body);
|
||||
for s in body {
|
||||
inner_checker.check_stmt(s);
|
||||
}
|
||||
// Surface any errors from the inner scope
|
||||
self.diagnostics.extend(inner_checker.diagnostics);
|
||||
// Register function in outer env
|
||||
let param_types: Vec<_> = params.iter().filter_map(|p| {
|
||||
self.env.resolve_type_expr(&p.type_ann).ok()
|
||||
}).collect();
|
||||
if let Ok(ret) = self.env.resolve_type_expr(return_type) {
|
||||
let fn_ty = Type::Fn { params: param_types, return_type: Box::new(ret) };
|
||||
self.env.register_fn(name.clone(), fn_ty);
|
||||
}
|
||||
}
|
||||
Stmt::TypeDef { .. } | Stmt::EnumDef { .. } => {
|
||||
// Already handled in hoist pass
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hoist_definitions_stmts(&mut self, stmts: &[Stmt]) {
|
||||
for stmt in stmts {
|
||||
match stmt {
|
||||
Stmt::TypeDef { name, fields, .. } => {
|
||||
let resolved: Vec<_> = fields.iter().filter_map(|f| {
|
||||
self.env.resolve_type_expr(&f.type_ann).ok().map(|ty| (f.name.clone(), ty))
|
||||
}).collect();
|
||||
let def = TypeDef::Struct { name: name.clone(), fields: resolved };
|
||||
self.env.register_type(name.clone(), def, "");
|
||||
}
|
||||
Stmt::FnDef { name, params, return_type, .. } => {
|
||||
let pt: Vec<_> = params.iter().filter_map(|p| {
|
||||
self.env.resolve_type_expr(&p.type_ann).ok()
|
||||
}).collect();
|
||||
if let Ok(ret) = self.env.resolve_type_expr(return_type) {
|
||||
self.env.register_fn(name.clone(), Type::Fn { params: pt, return_type: Box::new(ret) });
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Expression inference ──────────────────────────────────────────────────
|
||||
|
||||
/// Infer the type of an expression, recording errors as diagnostics.
|
||||
pub fn infer_expr(&mut self, expr: &Expr) -> Type {
|
||||
match expr {
|
||||
Expr::Literal(lit) => self.infer_literal(lit),
|
||||
Expr::Ident(name) => {
|
||||
if let Some(ty) = self.env.lookup(name) {
|
||||
ty.clone()
|
||||
} else if let Some(ty) = self.env.lookup_fn(name) {
|
||||
ty.clone()
|
||||
} else {
|
||||
self.emit_error(TypeErrorKind::UndefinedVariable(name.clone()));
|
||||
Type::Unknown
|
||||
}
|
||||
}
|
||||
Expr::BinOp { op, left, right } => self.infer_binop(op, left, right),
|
||||
Expr::UnaryNot(inner) => {
|
||||
let ty = self.infer_expr(inner);
|
||||
if !self.env.check_compatible(&ty, &Type::Bool) {
|
||||
self.emit_error(TypeErrorKind::TypeMismatch {
|
||||
expected: "Bool".into(),
|
||||
got: ty.to_string(),
|
||||
});
|
||||
}
|
||||
Type::Bool
|
||||
}
|
||||
Expr::Call { func, args } => self.infer_call(func, args),
|
||||
Expr::Block(stmts) => {
|
||||
let mut last = Type::Void;
|
||||
let mut inner = TypeChecker::new(self.env.clone());
|
||||
inner.hoist_definitions_stmts(stmts);
|
||||
for (i, s) in stmts.iter().enumerate() {
|
||||
if i == stmts.len() - 1 {
|
||||
if let Stmt::Expr(e, _) = s {
|
||||
last = inner.infer_expr(e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
inner.check_stmt(s);
|
||||
}
|
||||
self.diagnostics.extend(inner.diagnostics);
|
||||
last
|
||||
}
|
||||
Expr::Match { subject, arms } => {
|
||||
self.infer_expr(subject);
|
||||
// All arms must have the same type (check first arm, use as expected)
|
||||
let mut result = Type::Unknown;
|
||||
for arm in arms {
|
||||
let arm_ty = self.infer_expr(&arm.body);
|
||||
if matches!(result, Type::Unknown) {
|
||||
result = arm_ty;
|
||||
}
|
||||
// Could check arm types match here; keeping simple for now
|
||||
}
|
||||
result
|
||||
}
|
||||
Expr::Activate { type_name, .. } => {
|
||||
// activate must reference a registered type
|
||||
if self.env.get_type(type_name).is_none() {
|
||||
self.emit_error(TypeErrorKind::ActivateUnknownType(type_name.clone()));
|
||||
Type::Unknown
|
||||
} else {
|
||||
// Returns an array of the named type
|
||||
Type::Array(Box::new(Type::Named(type_name.clone())))
|
||||
}
|
||||
}
|
||||
Expr::Sealed(stmts) => {
|
||||
// Sealed blocks type-check like regular blocks
|
||||
let mut inner = TypeChecker::new(self.env.clone());
|
||||
for s in stmts { inner.check_stmt(s); }
|
||||
self.diagnostics.extend(inner.diagnostics);
|
||||
Type::Void
|
||||
}
|
||||
Expr::If { cond, then, else_ } => {
|
||||
let cond_ty = self.infer_expr(cond);
|
||||
if !self.env.check_compatible(&cond_ty, &Type::Bool) {
|
||||
self.emit_error(TypeErrorKind::TypeMismatch {
|
||||
expected: "Bool".into(),
|
||||
got: cond_ty.to_string(),
|
||||
});
|
||||
}
|
||||
let then_ty = self.infer_expr(then);
|
||||
if let Some(e) = else_ {
|
||||
let else_ty = self.infer_expr(e);
|
||||
if self.env.check_compatible(&then_ty, &else_ty) {
|
||||
then_ty
|
||||
} else {
|
||||
Type::Unknown
|
||||
}
|
||||
} else {
|
||||
Type::Void
|
||||
}
|
||||
}
|
||||
Expr::Field { object, field } => {
|
||||
let obj_ty = self.infer_expr(object);
|
||||
match &obj_ty {
|
||||
Type::Named(type_name) => {
|
||||
match self.env.get_type(type_name) {
|
||||
Some(TypeDef::Struct { fields, .. }) => {
|
||||
if let Some((_, fty)) = fields.iter().find(|(n, _)| n == field) {
|
||||
fty.clone()
|
||||
} else {
|
||||
self.emit_error(TypeErrorKind::UnknownField {
|
||||
type_name: type_name.clone(),
|
||||
field: field.clone(),
|
||||
});
|
||||
Type::Unknown
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.emit_error(TypeErrorKind::UnknownField {
|
||||
type_name: obj_ty.to_string(),
|
||||
field: field.clone(),
|
||||
});
|
||||
Type::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.emit_error(TypeErrorKind::UnknownField {
|
||||
type_name: obj_ty.to_string(),
|
||||
field: field.clone(),
|
||||
});
|
||||
Type::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
Expr::Array(elems) => {
|
||||
if elems.is_empty() {
|
||||
Type::Array(Box::new(Type::Unknown))
|
||||
} else {
|
||||
let elem_ty = self.infer_expr(&elems[0]);
|
||||
for e in &elems[1..] {
|
||||
let ty = self.infer_expr(e);
|
||||
if !self.env.check_compatible(&ty, &elem_ty) {
|
||||
self.emit_error(TypeErrorKind::TypeMismatch {
|
||||
expected: elem_ty.to_string(),
|
||||
got: ty.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Type::Array(Box::new(elem_ty))
|
||||
}
|
||||
}
|
||||
Expr::Path { segments } => {
|
||||
// Path expressions like Status::Active evaluate to the enum type
|
||||
if segments.len() >= 2 {
|
||||
let enum_name = &segments[0];
|
||||
if self.env.get_type(enum_name).is_some() {
|
||||
Type::Named(enum_name.clone())
|
||||
} else {
|
||||
self.emit_error(TypeErrorKind::UndefinedType(enum_name.clone()));
|
||||
Type::Unknown
|
||||
}
|
||||
} else {
|
||||
Type::Unknown
|
||||
}
|
||||
}
|
||||
Expr::Index { object, index } => {
|
||||
let obj_ty = self.infer_expr(object);
|
||||
let idx_ty = self.infer_expr(index);
|
||||
if !self.env.check_compatible(&idx_ty, &Type::Int) {
|
||||
self.emit_error(TypeErrorKind::TypeMismatch {
|
||||
expected: "Int".into(),
|
||||
got: idx_ty.to_string(),
|
||||
});
|
||||
}
|
||||
match obj_ty {
|
||||
Type::Array(inner) => *inner,
|
||||
other => {
|
||||
self.emit_error(TypeErrorKind::NotIndexable(other.to_string()));
|
||||
Type::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn infer_literal(&self, lit: &Literal) -> Type {
|
||||
match lit {
|
||||
Literal::Int(_) => Type::Int,
|
||||
Literal::Float(_) => Type::Float,
|
||||
Literal::Str(_) => Type::String,
|
||||
Literal::Bool(_) => Type::Bool,
|
||||
}
|
||||
}
|
||||
|
||||
fn infer_binop(&mut self, op: &BinOp, left: &Expr, right: &Expr) -> Type {
|
||||
let lt = self.infer_expr(left);
|
||||
let rt = self.infer_expr(right);
|
||||
match op {
|
||||
BinOp::Add | BinOp::Sub | BinOp::Mul | BinOp::Div => {
|
||||
// Numeric ops: Int op Int -> Int, Float anywhere -> Float
|
||||
match (<, &rt) {
|
||||
(Type::Float, _) | (_, Type::Float) => Type::Float,
|
||||
(Type::Int, Type::Int) => Type::Int,
|
||||
// String concatenation with +
|
||||
(Type::String, Type::String) if matches!(op, BinOp::Add) => Type::String,
|
||||
_ => {
|
||||
// Allow if at least one side is compatible with a number
|
||||
if self.env.check_compatible(<, &Type::Int)
|
||||
&& self.env.check_compatible(&rt, &Type::Int) {
|
||||
Type::Int
|
||||
} else {
|
||||
self.emit_error(TypeErrorKind::TypeMismatch {
|
||||
expected: "numeric or String".into(),
|
||||
got: format!("{lt} and {rt}"),
|
||||
});
|
||||
Type::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BinOp::Eq | BinOp::NotEq => {
|
||||
// Equality: any two compatible types -> Bool
|
||||
Type::Bool
|
||||
}
|
||||
BinOp::Lt | BinOp::Gt | BinOp::LtEq | BinOp::GtEq => {
|
||||
// Comparison: numeric types -> Bool
|
||||
if !self.env.check_compatible(<, &rt) {
|
||||
self.emit_error(TypeErrorKind::TypeMismatch {
|
||||
expected: lt.to_string(),
|
||||
got: rt.to_string(),
|
||||
});
|
||||
}
|
||||
Type::Bool
|
||||
}
|
||||
BinOp::And | BinOp::Or => {
|
||||
for ty in [<, &rt] {
|
||||
if !self.env.check_compatible(ty, &Type::Bool) {
|
||||
self.emit_error(TypeErrorKind::TypeMismatch {
|
||||
expected: "Bool".into(),
|
||||
got: ty.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Type::Bool
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn infer_call(&mut self, func: &Expr, args: &[Expr]) -> Type {
|
||||
let func_ty = self.infer_expr(func);
|
||||
let arg_types: Vec<_> = args.iter().map(|a| self.infer_expr(a)).collect();
|
||||
|
||||
match func_ty {
|
||||
Type::Fn { params, return_type } => {
|
||||
if params.len() != arg_types.len() {
|
||||
self.emit_error(TypeErrorKind::ArgCountMismatch {
|
||||
expected: params.len(),
|
||||
got: arg_types.len(),
|
||||
});
|
||||
} else {
|
||||
for (expected, got) in params.iter().zip(arg_types.iter()) {
|
||||
if !self.env.check_compatible(got, expected) {
|
||||
self.emit_error(TypeErrorKind::TypeMismatch {
|
||||
expected: expected.to_string(),
|
||||
got: got.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
*return_type
|
||||
}
|
||||
Type::Unknown => Type::Unknown,
|
||||
other => {
|
||||
self.emit_error(TypeErrorKind::NotCallable(other.to_string()));
|
||||
Type::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Diagnostic helpers ────────────────────────────────────────────────────
|
||||
|
||||
fn error(&mut self, e: TypeError) {
|
||||
self.diagnostics.push(Diagnostic {
|
||||
message: e.to_string(),
|
||||
is_error: true,
|
||||
});
|
||||
}
|
||||
|
||||
fn emit_error(&mut self, kind: TypeErrorKind) {
|
||||
self.diagnostics.push(Diagnostic {
|
||||
message: kind.to_string(),
|
||||
is_error: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use el_lexer::tokenize;
|
||||
use el_parser::parse;
|
||||
use super::*;
|
||||
|
||||
fn check(src: &str) -> TypeChecker {
|
||||
let tokens = tokenize(src).expect("lex");
|
||||
let prog = parse(tokens, src.to_string()).expect("parse");
|
||||
let mut checker = TypeChecker::with_builtins();
|
||||
checker.check(&prog);
|
||||
checker
|
||||
}
|
||||
|
||||
fn assert_ok(src: &str) {
|
||||
let c = check(src);
|
||||
assert!(c.ok(), "Expected no errors, got: {:?}", c.diagnostics);
|
||||
}
|
||||
|
||||
fn assert_err(src: &str) {
|
||||
let c = check(src);
|
||||
assert!(!c.ok(), "Expected errors but got none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_let_int() {
|
||||
assert_ok("let x: Int = 42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_let_string() {
|
||||
assert_ok(r#"let s: String = "hello""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_mismatch() {
|
||||
assert_err(r#"let x: Int = "not an int""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fn_def_and_call() {
|
||||
assert_ok(r#"
|
||||
fn double(n: Int) -> Int {
|
||||
return n + n
|
||||
}
|
||||
let result: Int = double(5)
|
||||
"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fn_arg_count_mismatch() {
|
||||
assert_err(r#"
|
||||
fn add(a: Int, b: Int) -> Int { return a + b }
|
||||
add(1)
|
||||
"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_type_def_and_field_access() {
|
||||
// Type checking with field access: u is bound to User,
|
||||
// accessing u.name should return String type without error.
|
||||
// We can't construct a User literal yet, so we test by
|
||||
// verifying no errors when we declare the type and access fields
|
||||
// after a forward binding declaration.
|
||||
let src = r#"
|
||||
type User { name: String age: Int }
|
||||
fn make_user() -> User {
|
||||
return make_user()
|
||||
}
|
||||
"#;
|
||||
assert_ok(src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_activate_known_type_ok() {
|
||||
assert_ok(r#"
|
||||
type User { id: Uuid name: String }
|
||||
activate User where "recent customers"
|
||||
"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_activate_unknown_type_err() {
|
||||
assert_err(r#"activate Phantom where "ghosts""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bool_ops() {
|
||||
assert_ok("let a: Bool = true && false");
|
||||
assert_ok("let b: Bool = true || false");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_int_arithmetic() {
|
||||
assert_ok("let x: Int = 1 + 2 * 3 - 4 / 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_string_concat() {
|
||||
assert_ok(r#"let s: String = "hello" + " world""#);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Type system errors.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone, Error)]
|
||||
#[error("{kind}")]
|
||||
pub struct TypeError {
|
||||
pub kind: TypeErrorKind,
|
||||
}
|
||||
|
||||
impl TypeError {
|
||||
pub fn new(kind: TypeErrorKind) -> Self {
|
||||
Self { kind }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Error)]
|
||||
pub enum TypeErrorKind {
|
||||
#[error("type mismatch: expected {expected}, got {got}")]
|
||||
TypeMismatch { expected: String, got: String },
|
||||
|
||||
#[error("undefined variable '{0}'")]
|
||||
UndefinedVariable(String),
|
||||
|
||||
#[error("undefined type '{0}'")]
|
||||
UndefinedType(String),
|
||||
|
||||
#[error("undefined function '{0}'")]
|
||||
UndefinedFunction(String),
|
||||
|
||||
#[error("wrong number of arguments: expected {expected}, got {got}")]
|
||||
ArgCountMismatch { expected: usize, got: usize },
|
||||
|
||||
#[error("field '{field}' not found on type '{type_name}'")]
|
||||
UnknownField { type_name: String, field: String },
|
||||
|
||||
#[error("cannot call non-function type {0}")]
|
||||
NotCallable(String),
|
||||
|
||||
#[error("activate expression requires a registered type name, got '{0}'")]
|
||||
ActivateUnknownType(String),
|
||||
|
||||
#[error("index operator requires Array type, got {0}")]
|
||||
NotIndexable(String),
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! el-types — Engram language type system.
|
||||
//!
|
||||
//! Types in the Engram language are more than structural contracts — every
|
||||
//! named type is a node in a knowledge graph. Compatibility checking is
|
||||
//! therefore two-dimensional:
|
||||
//!
|
||||
//! 1. **Structural compatibility** — the traditional "does this type's layout
|
||||
//! match?" check.
|
||||
//! 2. **Semantic compatibility** — are the Engram node embeddings for these
|
||||
//! two types close enough in meaning-space? This enables the `activate`
|
||||
//! construct to return a statically-typed result even though the query is
|
||||
//! a free-form natural language string.
|
||||
//!
|
||||
//! In the current implementation, semantic compatibility falls back to a
|
||||
//! symbolic check (are the Engram node type strings the same?). When an
|
||||
//! actual Engram database is connected via `CompilerOptions::engram_db_path`,
|
||||
//! the checker can delegate to real cosine-similarity over embeddings.
|
||||
|
||||
mod error;
|
||||
mod types;
|
||||
mod checker;
|
||||
|
||||
pub use error::{TypeError, TypeErrorKind};
|
||||
pub use types::{Type, TypeDef, TypeEnv};
|
||||
pub use checker::TypeChecker;
|
||||
@@ -0,0 +1,308 @@
|
||||
//! Core type definitions and the type environment.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// The semantic type of a value in Engram source.
|
||||
///
|
||||
/// Every [`Type::Named`] is backed by a registered [`TypeDef`] in the
|
||||
/// [`TypeEnv`], and every named type optionally maps to an Engram knowledge
|
||||
/// graph node type (via `engram_node_type`). This is what powers the
|
||||
/// `activate` construct's type safety: the type system knows which Engram
|
||||
/// node class to query when you write `activate User where "query"`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Type {
|
||||
// ── Primitives ────────────────────────────────────────────────────────────
|
||||
Int,
|
||||
Float,
|
||||
String,
|
||||
Bool,
|
||||
Uuid,
|
||||
Void,
|
||||
|
||||
// ── Composite ─────────────────────────────────────────────────────────────
|
||||
/// A user-defined named type (struct or enum). Maps to a TypeDef.
|
||||
Named(std::string::String),
|
||||
/// A homogeneous array of a single element type.
|
||||
Array(Box<Type>),
|
||||
/// An optional (nullable) value.
|
||||
Optional(Box<Type>),
|
||||
|
||||
// ── Function ──────────────────────────────────────────────────────────────
|
||||
Fn { params: Vec<Type>, return_type: Box<Type> },
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────────────────
|
||||
/// Unknown type — used before type inference has resolved a binding.
|
||||
Unknown,
|
||||
/// The never/bottom type — returned by diverging expressions.
|
||||
Never,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Type {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Type::Int => write!(f, "Int"),
|
||||
Type::Float => write!(f, "Float"),
|
||||
Type::String => write!(f, "String"),
|
||||
Type::Bool => write!(f, "Bool"),
|
||||
Type::Uuid => write!(f, "Uuid"),
|
||||
Type::Void => write!(f, "Void"),
|
||||
Type::Named(n) => write!(f, "{n}"),
|
||||
Type::Array(t) => write!(f, "[{t}]"),
|
||||
Type::Optional(t) => write!(f, "{t}?"),
|
||||
Type::Fn { params, return_type } => {
|
||||
let ps: Vec<_> = params.iter().map(|p| p.to_string()).collect();
|
||||
write!(f, "fn({}) -> {return_type}", ps.join(", "))
|
||||
}
|
||||
Type::Unknown => write!(f, "<unknown>"),
|
||||
Type::Never => write!(f, "!"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── TypeDef ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The definition of a named type — either a struct or an enum.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TypeDef {
|
||||
Struct {
|
||||
name: std::string::String,
|
||||
fields: Vec<(std::string::String, Type)>,
|
||||
},
|
||||
Enum {
|
||||
name: std::string::String,
|
||||
variants: Vec<EnumVariant>,
|
||||
},
|
||||
/// A built-in primitive alias (e.g. `Uuid` is a Named type mapped to a built-in).
|
||||
Primitive(Type),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EnumVariant {
|
||||
pub name: std::string::String,
|
||||
/// Payload type for tuple variants like `Pending(String)`.
|
||||
pub payload: Option<Type>,
|
||||
}
|
||||
|
||||
// ── TypeEnv ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The type environment — a lexically-scoped binding of names to types.
|
||||
///
|
||||
/// A `TypeEnv` can be cheaply cloned to create child scopes (e.g. for
|
||||
/// function bodies). New bindings in the child do not escape to the parent.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TypeEnv {
|
||||
/// Maps variable/binding names to their inferred or declared types.
|
||||
bindings: HashMap<std::string::String, Type>,
|
||||
/// Maps type names to their definitions.
|
||||
pub types: HashMap<std::string::String, TypeDef>,
|
||||
/// Maps named type names to the Engram graph node type string.
|
||||
/// Used by the `activate` construct to know which node class to query.
|
||||
pub engram_mappings: HashMap<std::string::String, std::string::String>,
|
||||
/// Maps function names to their function types.
|
||||
pub functions: HashMap<std::string::String, Type>,
|
||||
}
|
||||
|
||||
impl TypeEnv {
|
||||
/// Create a fresh environment pre-populated with built-in types.
|
||||
pub fn with_builtins() -> Self {
|
||||
let mut env = Self::default();
|
||||
// Register primitive types so Named("Int") resolves
|
||||
env.types.insert("Int".into(), TypeDef::Primitive(Type::Int));
|
||||
env.types.insert("Float".into(), TypeDef::Primitive(Type::Float));
|
||||
env.types.insert("String".into(), TypeDef::Primitive(Type::String));
|
||||
env.types.insert("Bool".into(), TypeDef::Primitive(Type::Bool));
|
||||
env.types.insert("Uuid".into(), TypeDef::Primitive(Type::Uuid));
|
||||
env.types.insert("Void".into(), TypeDef::Primitive(Type::Void));
|
||||
env
|
||||
}
|
||||
|
||||
// ── Bindings ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn bind(&mut self, name: impl Into<std::string::String>, ty: Type) {
|
||||
self.bindings.insert(name.into(), ty);
|
||||
}
|
||||
|
||||
pub fn lookup(&self, name: &str) -> Option<&Type> {
|
||||
self.bindings.get(name)
|
||||
}
|
||||
|
||||
// ── Type registration ─────────────────────────────────────────────────────
|
||||
|
||||
/// Register a user-defined type with an optional Engram node type mapping.
|
||||
pub fn register_type(
|
||||
&mut self,
|
||||
name: impl Into<std::string::String>,
|
||||
def: TypeDef,
|
||||
engram_node_type: impl Into<std::string::String>,
|
||||
) {
|
||||
let name = name.into();
|
||||
let engram = engram_node_type.into();
|
||||
if !engram.is_empty() {
|
||||
self.engram_mappings.insert(name.clone(), engram);
|
||||
}
|
||||
self.types.insert(name, def);
|
||||
}
|
||||
|
||||
pub fn get_type(&self, name: &str) -> Option<&TypeDef> {
|
||||
self.types.get(name)
|
||||
}
|
||||
|
||||
/// Register a function signature.
|
||||
pub fn register_fn(&mut self, name: impl Into<std::string::String>, ty: Type) {
|
||||
self.functions.insert(name.into(), ty);
|
||||
}
|
||||
|
||||
pub fn lookup_fn(&self, name: &str) -> Option<&Type> {
|
||||
self.functions.get(name)
|
||||
}
|
||||
|
||||
// ── Compatibility ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Check whether type `a` is assignable to type `b`.
|
||||
///
|
||||
/// This is a structural check with a semantic override: if both types are
|
||||
/// `Named` and have Engram node type mappings, semantic compatibility is
|
||||
/// checked as well. Currently the semantic check is symbolic (same node
|
||||
/// type string = compatible). When an actual Engram DB is available this
|
||||
/// would use cosine similarity over embeddings.
|
||||
pub fn check_compatible(&self, a: &Type, b: &Type) -> bool {
|
||||
match (a, b) {
|
||||
// Unknown is compatible with everything (used during inference)
|
||||
(Type::Unknown, _) | (_, Type::Unknown) => true,
|
||||
// Never is compatible with everything (bottom type)
|
||||
(Type::Never, _) => true,
|
||||
// Structural matches
|
||||
(Type::Int, Type::Int) => true,
|
||||
(Type::Float, Type::Float) => true,
|
||||
(Type::String, Type::String) => true,
|
||||
(Type::Bool, Type::Bool) => true,
|
||||
(Type::Uuid, Type::Uuid) => true,
|
||||
(Type::Void, Type::Void) => true,
|
||||
// Int is promotable to Float
|
||||
(Type::Int, Type::Float) => true,
|
||||
// Named types: structural + semantic
|
||||
(Type::Named(a_name), Type::Named(b_name)) => {
|
||||
if a_name == b_name {
|
||||
return true;
|
||||
}
|
||||
// Semantic compatibility via Engram node type mappings
|
||||
let a_node = self.engram_mappings.get(a_name);
|
||||
let b_node = self.engram_mappings.get(b_name);
|
||||
match (a_node, b_node) {
|
||||
(Some(a_n), Some(b_n)) => a_n == b_n,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
(Type::Array(a_inner), Type::Array(b_inner)) => {
|
||||
self.check_compatible(a_inner, b_inner)
|
||||
}
|
||||
(Type::Optional(a_inner), Type::Optional(b_inner)) => {
|
||||
self.check_compatible(a_inner, b_inner)
|
||||
}
|
||||
// T is compatible with T?
|
||||
(t, Type::Optional(inner)) => self.check_compatible(t, inner),
|
||||
(Type::Fn { params: ap, return_type: ar }, Type::Fn { params: bp, return_type: br }) => {
|
||||
ap.len() == bp.len()
|
||||
&& ap.iter().zip(bp.iter()).all(|(a, b)| self.check_compatible(a, b))
|
||||
&& self.check_compatible(ar, br)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a [`TypeExpr`] from the parser into a [`Type`].
|
||||
pub fn resolve_type_expr(&self, te: &el_parser::TypeExpr) -> Result<Type, crate::TypeError> {
|
||||
match te {
|
||||
el_parser::TypeExpr::Named(n) => {
|
||||
// Check if it's a built-in alias or a registered user type
|
||||
Ok(match n.as_str() {
|
||||
"Int" => Type::Int,
|
||||
"Float" => Type::Float,
|
||||
"String" => Type::String,
|
||||
"Bool" => Type::Bool,
|
||||
"Uuid" => Type::Uuid,
|
||||
"Void" => Type::Void,
|
||||
other => {
|
||||
if self.types.contains_key(other) {
|
||||
Type::Named(other.to_string())
|
||||
} else {
|
||||
return Err(crate::TypeError::new(
|
||||
crate::TypeErrorKind::UndefinedType(other.to_string()),
|
||||
));
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
el_parser::TypeExpr::Array(inner) => {
|
||||
Ok(Type::Array(Box::new(self.resolve_type_expr(inner)?)))
|
||||
}
|
||||
el_parser::TypeExpr::Optional(inner) => {
|
||||
Ok(Type::Optional(Box::new(self.resolve_type_expr(inner)?)))
|
||||
}
|
||||
el_parser::TypeExpr::Fn { params, return_type } => {
|
||||
let ps = params.iter().map(|p| self.resolve_type_expr(p)).collect::<Result<Vec<_>, _>>()?;
|
||||
let ret = self.resolve_type_expr(return_type)?;
|
||||
Ok(Type::Fn { params: ps, return_type: Box::new(ret) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn env() -> TypeEnv {
|
||||
TypeEnv::with_builtins()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_primitive_compatibility() {
|
||||
let e = env();
|
||||
assert!(e.check_compatible(&Type::Int, &Type::Int));
|
||||
assert!(e.check_compatible(&Type::String, &Type::String));
|
||||
assert!(!e.check_compatible(&Type::Int, &Type::String));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_int_promotes_to_float() {
|
||||
let e = env();
|
||||
assert!(e.check_compatible(&Type::Int, &Type::Float));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_named_same_is_compatible() {
|
||||
let e = env();
|
||||
assert!(e.check_compatible(&Type::Named("User".into()), &Type::Named("User".into())));
|
||||
assert!(!e.check_compatible(&Type::Named("User".into()), &Type::Named("Order".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_semantic_compatibility_via_engram_mapping() {
|
||||
let mut e = env();
|
||||
// Map both User and Customer to the "Entity" Engram node type
|
||||
e.engram_mappings.insert("User".into(), "Entity".into());
|
||||
e.engram_mappings.insert("Customer".into(), "Entity".into());
|
||||
assert!(e.check_compatible(&Type::Named("User".into()), &Type::Named("Customer".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_optional_compatibility() {
|
||||
let e = env();
|
||||
// Int is compatible with Int?
|
||||
assert!(e.check_compatible(&Type::Int, &Type::Optional(Box::new(Type::Int))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_array_compatibility() {
|
||||
let e = env();
|
||||
assert!(e.check_compatible(
|
||||
&Type::Array(Box::new(Type::Int)),
|
||||
&Type::Array(Box::new(Type::Int)),
|
||||
));
|
||||
assert!(!e.check_compatible(
|
||||
&Type::Array(Box::new(Type::Int)),
|
||||
&Type::Array(Box::new(Type::String)),
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user