feat: add schema evolution type system integration and HTTP server effect

Schema Evolution:
- Preserve version info in type resolution (Type::Versioned)
- Track versioned type declarations in typechecker
- Detect version mismatches at compile time (@v1 vs @v2 errors)
- Support @v2+ (at least) and @latest version constraints
- Store migrations for future auto-migration support
- Fix let bindings to preserve declared type annotations

HTTP Server Effect:
- Add HttpServer effect with listen, accept, respond, respondWithHeaders, stop
- Implement blocking request handling via tiny_http
- Request record includes method, path, body, headers
- Add http_server.lux example with routing via pattern matching
- Add type-checking test for HttpServer effect

Tests: 222 passing (up from 217)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 22:06:31 -05:00
parent 554a1e7c3e
commit 086552b7a4
9 changed files with 1153 additions and 21 deletions

View File

@@ -13,8 +13,9 @@ use crate::diagnostics::{find_similar_names, format_did_you_mean, Diagnostic, Se
use crate::exhaustiveness::{check_exhaustiveness, missing_patterns_hint};
use crate::modules::ModuleLoader;
use crate::types::{
self, unify, EffectDef, EffectOpDef, EffectSet, HandlerDef, PropertySet, TraitBoundDef,
TraitDef, TraitImpl, TraitMethodDef, Type, TypeEnv, TypeScheme, VariantDef, VariantFieldsDef,
self, unify, EffectDef, EffectOpDef, EffectSet, HandlerDef, Property, PropertySet,
TraitBoundDef, TraitDef, TraitImpl, TraitMethodDef, Type, TypeEnv, TypeScheme, VariantDef,
VariantFieldsDef, VersionInfo,
};
/// Type checking error
@@ -100,6 +101,388 @@ fn categorize_type_error(message: &str) -> (String, Vec<String>) {
}
}
/// Check if an operator is commutative
fn is_commutative_op(op: BinaryOp) -> bool {
matches!(
op,
BinaryOp::Add | BinaryOp::Mul | BinaryOp::Eq | BinaryOp::Ne | BinaryOp::And | BinaryOp::Or
)
}
/// Check if a function body represents a commutative operation on its two parameters
fn is_commutative_body(body: &Expr, param1: &str, param2: &str) -> bool {
match body {
Expr::BinaryOp { op, left, right, .. } => {
if !is_commutative_op(*op) {
return false;
}
// Check if it's (param1 op param2) or (param2 op param1)
let left_is_p1 = matches!(left.as_ref(), Expr::Var(id) if id.name == param1);
let left_is_p2 = matches!(left.as_ref(), Expr::Var(id) if id.name == param2);
let right_is_p1 = matches!(right.as_ref(), Expr::Var(id) if id.name == param1);
let right_is_p2 = matches!(right.as_ref(), Expr::Var(id) if id.name == param2);
(left_is_p1 && right_is_p2) || (left_is_p2 && right_is_p1)
}
// Handle block expressions - check last expression
Expr::Block { statements, result, .. } => {
if !statements.is_empty() {
return false;
}
is_commutative_body(result, param1, param2)
}
_ => false,
}
}
/// Check if an expression references any of the given parameter names
fn references_params(expr: &Expr, params: &[&str]) -> bool {
match expr {
Expr::Var(id) => params.contains(&id.name.as_str()),
Expr::BinaryOp { left, right, .. } => {
references_params(left, params) || references_params(right, params)
}
Expr::UnaryOp { operand, .. } => references_params(operand, params),
Expr::If {
condition,
then_branch,
else_branch,
..
} => {
references_params(condition, params)
|| references_params(then_branch, params)
|| references_params(else_branch, params)
}
Expr::Call { func, args, .. } => {
references_params(func, params) || args.iter().any(|a| references_params(a, params))
}
Expr::Block { statements, result, .. } => {
statements.iter().any(|s| match s {
Statement::Let { value, .. } => references_params(value, params),
Statement::Expr(e) => references_params(e, params),
}) || references_params(result, params)
}
Expr::Field { object, .. } => references_params(object, params),
Expr::Lambda { body, .. } => references_params(body, params),
Expr::Tuple { elements, .. } => elements.iter().any(|e| references_params(e, params)),
Expr::List { elements, .. } => elements.iter().any(|e| references_params(e, params)),
Expr::Record { fields, .. } => fields.iter().any(|(_, e)| references_params(e, params)),
Expr::Match { scrutinee, arms, .. } => {
references_params(scrutinee, params)
|| arms.iter().any(|a| references_params(&a.body, params))
}
_ => false,
}
}
/// Check if a function body is idempotent (f(f(x)) == f(x))
/// Uses conservative pattern recognition
fn is_idempotent_body(body: &Expr, params: &[Parameter]) -> bool {
let param_names: Vec<&str> = params.iter().map(|p| p.name.name.as_str()).collect();
// Pattern 1: Constant - body doesn't reference any parameters
if !references_params(body, &param_names) {
return true;
}
// Pattern 2: Identity function (single param, body is just that param)
if params.len() == 1 {
if let Expr::Var(id) = body {
if id.name == params[0].name.name {
return true;
}
}
}
// Pattern 3: Field projection on single param (e.g., person.name)
if params.len() == 1 {
if let Expr::Field { object, .. } = body {
if let Expr::Var(id) = object.as_ref() {
if id.name == params[0].name.name {
return true;
}
}
}
}
// Pattern 4: Clamping pattern - if x < min then min else if x > max then max else x
// or simpler: if x < 0 then 0 else x
if params.len() == 1 && is_clamping_pattern(body, &params[0].name.name) {
return true;
}
// Pattern 5: Absolute value - if x < 0 then -x else x
if params.len() == 1 && is_abs_pattern(body, &params[0].name.name) {
return true;
}
// Handle block wrapper
if let Expr::Block { statements, result, .. } = body {
if statements.is_empty() {
return is_idempotent_body(result, params);
}
}
false
}
/// Check if body matches: if x < bound then bound else x (or similar clamping patterns)
fn is_clamping_pattern(body: &Expr, param: &str) -> bool {
if let Expr::If {
condition,
then_branch,
else_branch,
..
} = body
{
// Check if then_branch is a constant and else_branch is the param (or recursive)
let else_is_param = matches!(else_branch.as_ref(), Expr::Var(id) if id.name == param);
let else_is_clamp = is_clamping_pattern(else_branch, param);
if else_is_param || else_is_clamp {
// Check if condition is a comparison involving the param
if let Expr::BinaryOp { left, right, op, .. } = condition.as_ref() {
let left_is_param = matches!(left.as_ref(), Expr::Var(id) if id.name == param);
let right_is_param = matches!(right.as_ref(), Expr::Var(id) if id.name == param);
if (left_is_param || right_is_param)
&& matches!(
op,
BinaryOp::Lt | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge
)
{
return true;
}
}
}
}
false
}
/// Check if body matches: if x < 0 then -x else x
fn is_abs_pattern(body: &Expr, param: &str) -> bool {
if let Expr::If {
condition,
then_branch,
else_branch,
..
} = body
{
let else_is_param = matches!(else_branch.as_ref(), Expr::Var(id) if id.name == param);
if !else_is_param {
return false;
}
// Check if then_branch is -param
if let Expr::UnaryOp {
op: ast::UnaryOp::Neg,
operand,
..
} = then_branch.as_ref()
{
if matches!(operand.as_ref(), Expr::Var(id) if id.name == param) {
// Check condition is param < 0
if let Expr::BinaryOp {
op: BinaryOp::Lt,
left,
right,
..
} = condition.as_ref()
{
let left_is_param = matches!(left.as_ref(), Expr::Var(id) if id.name == param);
let right_is_zero =
matches!(right.as_ref(), Expr::Literal(lit) if matches!(lit.kind, LiteralKind::Int(0)));
if left_is_param && right_is_zero {
return true;
}
}
}
}
}
false
}
/// Check if a function body contains recursive calls to the function
fn has_recursive_calls(func_name: &str, body: &Expr) -> bool {
match body {
Expr::Call { func, args, .. } => {
// Check if this call is to the function itself
if let Expr::Var(id) = func.as_ref() {
if id.name == func_name {
return true;
}
}
// Check in function expression and arguments
has_recursive_calls(func_name, func)
|| args.iter().any(|a| has_recursive_calls(func_name, a))
}
Expr::BinaryOp { left, right, .. } => {
has_recursive_calls(func_name, left) || has_recursive_calls(func_name, right)
}
Expr::UnaryOp { operand, .. } => has_recursive_calls(func_name, operand),
Expr::If {
condition,
then_branch,
else_branch,
..
} => {
has_recursive_calls(func_name, condition)
|| has_recursive_calls(func_name, then_branch)
|| has_recursive_calls(func_name, else_branch)
}
Expr::Block { statements, result, .. } => {
statements.iter().any(|s| match s {
Statement::Let { value, .. } => has_recursive_calls(func_name, value),
Statement::Expr(e) => has_recursive_calls(func_name, e),
}) || has_recursive_calls(func_name, result)
}
Expr::Match { scrutinee, arms, .. } => {
has_recursive_calls(func_name, scrutinee)
|| arms.iter().any(|a| has_recursive_calls(func_name, &a.body))
}
Expr::Lambda { body, .. } => has_recursive_calls(func_name, body),
Expr::Tuple { elements, .. } | Expr::List { elements, .. } => {
elements.iter().any(|e| has_recursive_calls(func_name, e))
}
Expr::Record { fields, .. } => {
fields.iter().any(|(_, e)| has_recursive_calls(func_name, e))
}
Expr::Field { object, .. } => has_recursive_calls(func_name, object),
Expr::Let { value, body, .. } => {
has_recursive_calls(func_name, value) || has_recursive_calls(func_name, body)
}
Expr::Run { expr, handlers, .. } => {
has_recursive_calls(func_name, expr)
|| handlers.iter().any(|(_, e)| has_recursive_calls(func_name, e))
}
_ => false,
}
}
/// Find all recursive call arguments: returns Vec of argument lists
fn find_recursive_call_args<'a>(func_name: &str, body: &'a Expr) -> Vec<&'a [Expr]> {
let mut result = Vec::new();
collect_recursive_call_args(func_name, body, &mut result);
result
}
fn collect_recursive_call_args<'a>(func_name: &str, body: &'a Expr, result: &mut Vec<&'a [Expr]>) {
match body {
Expr::Call { func, args, .. } => {
if let Expr::Var(id) = func.as_ref() {
if id.name == func_name {
result.push(args.as_slice());
}
}
collect_recursive_call_args(func_name, func, result);
for arg in args {
collect_recursive_call_args(func_name, arg, result);
}
}
Expr::BinaryOp { left, right, .. } => {
collect_recursive_call_args(func_name, left, result);
collect_recursive_call_args(func_name, right, result);
}
Expr::UnaryOp { operand, .. } => {
collect_recursive_call_args(func_name, operand, result);
}
Expr::If {
condition,
then_branch,
else_branch,
..
} => {
collect_recursive_call_args(func_name, condition, result);
collect_recursive_call_args(func_name, then_branch, result);
collect_recursive_call_args(func_name, else_branch, result);
}
Expr::Block { statements, result: res, .. } => {
for s in statements {
match s {
Statement::Let { value, .. } => {
collect_recursive_call_args(func_name, value, result)
}
Statement::Expr(e) => collect_recursive_call_args(func_name, e, result),
}
}
collect_recursive_call_args(func_name, res, result);
}
Expr::Match { scrutinee, arms, .. } => {
collect_recursive_call_args(func_name, scrutinee, result);
for arm in arms {
collect_recursive_call_args(func_name, &arm.body, result);
}
}
Expr::Lambda { body, .. } => collect_recursive_call_args(func_name, body, result),
Expr::Let { value, body, .. } => {
collect_recursive_call_args(func_name, value, result);
collect_recursive_call_args(func_name, body, result);
}
_ => {}
}
}
/// Check if an argument is structurally decreasing from a parameter
/// Recognizes patterns like: n - 1, n - k (for positive k)
fn is_structurally_decreasing(arg: &Expr, param_name: &str) -> bool {
match arg {
// Pattern: n - 1, n - k for positive k
Expr::BinaryOp {
op: BinaryOp::Sub,
left,
right,
..
} => {
if let Expr::Var(id) = left.as_ref() {
if id.name == param_name {
// Check if right side is a positive integer literal
if let Expr::Literal(lit) = right.as_ref() {
if let LiteralKind::Int(n) = lit.kind {
return n > 0;
}
}
}
}
false
}
_ => false,
}
}
/// Check if a function terminates (structural recursion check)
fn check_termination(func: &FunctionDecl) -> Result<(), String> {
// Non-recursive functions always terminate
if !has_recursive_calls(&func.name.name, &func.body) {
return Ok(());
}
// For recursive functions, check structural recursion
let param_names: Vec<&str> = func.params.iter().map(|p| p.name.name.as_str()).collect();
let recursive_calls = find_recursive_call_args(&func.name.name, &func.body);
for call_args in recursive_calls {
// At least one argument must be structurally decreasing
let has_decreasing = call_args.iter().enumerate().any(|(i, arg)| {
if i < param_names.len() {
is_structurally_decreasing(arg, param_names[i])
} else {
false
}
});
if !has_decreasing {
return Err("Recursive call has no provably decreasing argument".to_string());
}
}
Ok(())
}
/// Property constraint on a function parameter
#[derive(Debug, Clone)]
pub struct ParamPropertyConstraint {
pub param_name: String,
pub required_properties: PropertySet,
}
/// Type checker
pub struct TypeChecker {
env: TypeEnv,
@@ -111,6 +494,14 @@ pub struct TypeChecker {
errors: Vec<TypeError>,
/// Type parameters in scope (maps "T" -> Type::Var(n) for generics)
type_params: HashMap<String, Type>,
/// Property constraints from where clauses: func_name -> Vec<(param_name, properties)>
property_constraints: HashMap<String, Vec<ParamPropertyConstraint>>,
/// Versioned type definitions: type_name -> version -> TypeDef
versioned_types: HashMap<String, HashMap<u32, types::TypeDef>>,
/// Latest version for each versioned type: type_name -> highest_version
latest_versions: HashMap<String, u32>,
/// Migrations: type_name -> source_version -> migration_body
migrations: HashMap<String, HashMap<u32, Expr>>,
}
impl TypeChecker {
@@ -122,6 +513,10 @@ impl TypeChecker {
inferring_effects: false,
errors: Vec::new(),
type_params: HashMap::new(),
property_constraints: HashMap::new(),
versioned_types: HashMap::new(),
latest_versions: HashMap::new(),
migrations: HashMap::new(),
}
}
@@ -280,6 +675,32 @@ impl TypeChecker {
let type_def = self.type_def(type_decl);
self.env.types.insert(type_decl.name.name.clone(), type_def.clone());
// Track versioned types for schema evolution
if let Some(version) = &type_decl.version {
let type_name = type_decl.name.name.clone();
let version_num = version.number;
// Store the type definition for this version
self.versioned_types
.entry(type_name.clone())
.or_default()
.insert(version_num, type_def.clone());
// Update latest version if this is higher
let current_latest = self.latest_versions.get(&type_name).copied().unwrap_or(0);
if version_num > current_latest {
self.latest_versions.insert(type_name.clone(), version_num);
}
// Register migrations (Phase 4)
for migration in &type_decl.migrations {
self.migrations
.entry(type_name.clone())
.or_default()
.insert(migration.from_version.number, migration.body.clone());
}
}
// Register ADT constructors as values with polymorphic types
if let ast::TypeDef::Enum(variants) = &type_decl.definition {
for variant in variants {
@@ -438,6 +859,89 @@ impl TypeChecker {
});
}
// Deterministic functions cannot use non-deterministic effects (Random, Time)
if properties.contains(Property::Deterministic) {
let non_det_effects: Vec<_> = effective_effects
.effects
.iter()
.filter(|e| matches!(e.as_str(), "Random" | "Time"))
.cloned()
.collect();
if !non_det_effects.is_empty() {
self.errors.push(TypeError {
message: format!(
"Function '{}' is declared as deterministic but uses non-deterministic effects: {{{}}}",
func.name.name,
non_det_effects.join(", ")
),
span: func.span,
});
}
}
// Commutative functions must have 2 params and use a commutative operation
if properties.contains(Property::Commutative) {
if func.params.len() != 2 {
self.errors.push(TypeError {
message: format!(
"Function '{}' is declared as commutative but has {} parameters (expected 2)",
func.name.name,
func.params.len()
),
span: func.span,
});
} else if !is_commutative_body(&func.body, &func.params[0].name.name, &func.params[1].name.name) {
self.errors.push(TypeError {
message: format!(
"Function '{}' is declared as commutative but its body is not a commutative operation on its parameters",
func.name.name
),
span: func.span,
});
}
}
// Idempotent functions must satisfy f(f(x)) == f(x)
// We verify this through pattern recognition
if properties.contains(Property::Idempotent) {
if !is_idempotent_body(&func.body, &func.params) {
self.errors.push(TypeError {
message: format!(
"Function '{}' is declared as idempotent but could not be verified. \
Recognized patterns: identity, constants, clamping, projections, abs. \
Use 'assume is idempotent' if you're certain it is idempotent.",
func.name.name
),
span: func.span,
});
}
}
// Total functions must terminate and cannot fail
if properties.is_total() {
// Check 1: Cannot use Fail effect
if effective_effects.contains("Fail") {
self.errors.push(TypeError {
message: format!(
"Function '{}' is declared as total but uses the Fail effect",
func.name.name
),
span: func.span,
});
}
// Check 2: Must terminate (structural recursion)
if let Err(reason) = check_termination(func) {
self.errors.push(TypeError {
message: format!(
"Function '{}' is declared as total but may not terminate: {}",
func.name.name, reason
),
span: func.span,
});
}
}
// If effects were declared, verify that inferred effects are a subset
if explicit_effects && !inferred.is_subset(&declared_effects) {
let missing: Vec<_> = inferred
@@ -465,9 +969,35 @@ impl TypeChecker {
property,
span,
} => {
// Record the constraint for later checking when the function is called
// For now, we just validate that the type parameter exists
if !func.type_params.iter().any(|p| p.name == type_param.name)
// Find which parameter has this type and record the constraint
let param_with_type = func.params.iter().find(|p| {
// Check if param's type is the type parameter
if let TypeExpr::Named(name) = &p.typ {
name.name == type_param.name
} else {
false
}
});
if let Some(param) = param_with_type {
// Record the constraint for checking at call sites
let constraints = self
.property_constraints
.entry(func.name.name.clone())
.or_insert_with(Vec::new);
// Check if we already have a constraint for this param
if let Some(existing) = constraints.iter_mut().find(|c| c.param_name == param.name.name) {
existing.required_properties.insert(Property::from(*property));
} else {
let mut props = PropertySet::empty();
props.insert(Property::from(*property));
constraints.push(ParamPropertyConstraint {
param_name: param.name.name.clone(),
required_properties: props,
});
}
} else if !func.type_params.iter().any(|p| p.name == type_param.name)
&& !func.params.iter().any(|p| p.name.name == type_param.name)
{
self.errors.push(TypeError {
@@ -525,7 +1055,8 @@ impl TypeChecker {
fn check_let_decl(&mut self, let_decl: &LetDecl) {
let inferred = self.infer_expr(&let_decl.value);
if let Some(ref type_expr) = let_decl.typ {
// Use the declared type if present, otherwise use inferred
let final_type = if let Some(ref type_expr) = let_decl.typ {
let declared = self.resolve_type(type_expr);
if let Err(e) = unify(&inferred, &declared) {
self.errors.push(TypeError {
@@ -536,10 +1067,14 @@ impl TypeChecker {
span: let_decl.span,
});
}
}
// Use declared type (preserves version annotations)
declared
} else {
inferred
};
// Update the binding with the inferred type
let scheme = self.env.generalize(&inferred);
// Update the binding with the final type
let scheme = self.env.generalize(&final_type);
self.env.bind(&let_decl.name.name, scheme);
}
@@ -870,6 +1405,48 @@ impl TypeChecker {
let func_type = self.infer_expr(func);
let arg_types: Vec<Type> = args.iter().map(|a| self.infer_expr(a)).collect();
// Check property constraints from where clauses
if let Expr::Var(func_id) = func {
if let Some(constraints) = self.property_constraints.get(&func_id.name).cloned() {
// Get parameter names from the function declaration (if available in env)
// We'll match by position since we have the constraints by param name
if let Some(scheme) = self.env.lookup(&func_id.name) {
let func_typ = scheme.instantiate();
if let Type::Function { params: param_types, .. } = &func_typ {
for constraint in &constraints {
// Find which argument position corresponds to this param
// For now, match by position based on stored param names
for (i, arg) in args.iter().enumerate() {
// Get the properties of the argument
let arg_props = self.get_expr_properties(arg);
// Check if this argument corresponds to a constrained param
// We check all constraints and verify the arg satisfies them
if !arg_props.satisfies(&constraint.required_properties) {
// Only report if this argument could be the constrained one
// (simple heuristic: function type argument)
if i < param_types.len() {
if let Type::Function { .. } = &param_types[i] {
self.errors.push(TypeError {
message: format!(
"Argument to '{}' does not satisfy property constraint: \
expected {:?}, but argument has {:?}",
func_id.name,
constraint.required_properties,
arg_props
),
span: arg.span(),
});
}
}
}
}
}
}
}
}
}
let result_type = Type::var();
// Include current effects in the expected function type
// This allows calling functions that require effects when those effects are available
@@ -891,6 +1468,26 @@ impl TypeChecker {
}
}
/// Get the behavioral properties of an expression (conservative)
fn get_expr_properties(&self, expr: &Expr) -> PropertySet {
match expr {
Expr::Var(id) => {
// Look up the function and get its properties
if let Some(scheme) = self.env.lookup(&id.name) {
let typ = scheme.instantiate();
if let Type::Function { properties, .. } = typ {
return properties;
}
}
PropertySet::empty()
}
// Lambdas: could analyze but for now be conservative
Expr::Lambda { .. } => PropertySet::empty(),
// Other expressions: no properties
_ => PropertySet::empty(),
}
}
fn infer_effect_op(
&mut self,
effect: &Ident,
@@ -935,7 +1532,7 @@ impl TypeChecker {
}
// Built-in effects are always available
let builtin_effects = ["Console", "Fail", "State", "Reader", "Random", "Time", "File", "Process", "Http"];
let builtin_effects = ["Console", "Fail", "State", "Reader", "Random", "Time", "File", "Process", "Http", "HttpServer"];
let is_builtin = builtin_effects.contains(&effect.name.as_str());
// Track this effect for inference
@@ -1440,7 +2037,7 @@ impl TypeChecker {
// Built-in effects are always available in run blocks (they have runtime implementations)
let builtin_effects: EffectSet =
EffectSet::from_iter(["Console", "Fail", "State", "Reader", "Random", "Time", "File", "Process", "Http"].iter().map(|s| s.to_string()));
EffectSet::from_iter(["Console", "Fail", "State", "Reader", "Random", "Time", "File", "Process", "Http", "HttpServer"].iter().map(|s| s.to_string()));
// Extend current effects with handled ones and built-in effects
let combined = self.current_effects.union(&handled_effects).union(&builtin_effects);
@@ -1796,13 +2393,18 @@ impl TypeChecker {
.collect(),
),
TypeExpr::Unit => Type::Unit,
TypeExpr::Versioned {
base,
constraint: _,
} => {
// For now, resolve the base type and ignore versioning
// Full version tracking will be added in the type system
self.resolve_type(base)
TypeExpr::Versioned { base, constraint } => {
// Resolve the base type and preserve version information
let base_type = self.resolve_type(base);
let version_info = match constraint {
ast::VersionConstraint::Exact(v) => VersionInfo::Exact(v.number),
ast::VersionConstraint::AtLeast(v) => VersionInfo::AtLeast(v.number),
ast::VersionConstraint::Latest(_) => VersionInfo::Latest,
};
Type::Versioned {
base: Box::new(base_type),
version: version_info,
}
}
}
}