MVP: AILang-Sprache mit JSON-AST, Typchecker, LLVM-IR-Backend

Erste lauffähige Iteration. examples/sum.ail.json wird zu nativem Binary
kompiliert und druckt 55 (Summe 1..10) als End-to-End-Test.

Architektur:
- ailang-core: hashbares JSON-AST + canonical-form + pretty-printer
- ailang-check: monomorpher HM-Subset + Effekt-Set-Tracking
- ailang-codegen: LLVM-IR-Text-Emitter (kein libllvm-link)
- ail: CLI mit check/manifest/render/describe/emit-ir/build/builtins

Designentscheidungen sind in docs/DESIGN.md dokumentiert; der Verlauf
in docs/JOURNAL.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 10:18:32 +02:00
commit 2fbcdba0b1
21 changed files with 2633 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "ailang-check"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
ailang-core.workspace = true
thiserror.workspace = true
indexmap.workspace = true
+75
View File
@@ -0,0 +1,75 @@
//! Built-in Operationen, die der Typchecker (und Codegen) kennen.
use ailang_core::ast::Type;
#[derive(Debug, Clone)]
pub struct EffectOpSig {
pub effect: String,
pub params: Vec<Type>,
pub ret: Type,
}
pub fn install(env: &mut crate::Env) {
let int_int_int = Type::Fn {
params: vec![Type::int(), Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
};
let int_int_bool = Type::Fn {
params: vec![Type::int(), Type::int()],
ret: Box::new(Type::bool_()),
effects: vec![],
};
for op in ["+", "-", "*", "/", "%"] {
env.globals.insert(op.into(), int_int_int.clone());
}
for op in ["==", "!=", "<", "<=", ">", ">="] {
env.globals.insert(op.into(), int_int_bool.clone());
}
env.globals.insert(
"not".into(),
Type::Fn {
params: vec![Type::bool_()],
ret: Box::new(Type::bool_()),
effects: vec![],
},
);
env.effect_ops.insert(
"io/print_int".into(),
EffectOpSig {
effect: "IO".into(),
params: vec![Type::int()],
ret: Type::unit(),
},
);
env.effect_ops.insert(
"io/print_bool".into(),
EffectOpSig {
effect: "IO".into(),
params: vec![Type::bool_()],
ret: Type::unit(),
},
);
}
/// Liefert die Liste aller registrierten Built-ins. Praktisch für CLI-Subcommand
/// `ail builtins`, wenn der LLM erwartete Signaturen prüfen will.
pub fn list() -> Vec<(&'static str, &'static str)> {
vec![
("+", "(Int, Int) -> Int"),
("-", "(Int, Int) -> Int"),
("*", "(Int, Int) -> Int"),
("/", "(Int, Int) -> Int"),
("%", "(Int, Int) -> Int"),
("==", "(Int, Int) -> Bool"),
("!=", "(Int, Int) -> Bool"),
("<", "(Int, Int) -> Bool"),
("<=", "(Int, Int) -> Bool"),
(">", "(Int, Int) -> Bool"),
(">=", "(Int, Int) -> Bool"),
("not", "(Bool) -> Bool"),
("io/print_int", "(Int) -> Unit !IO [effect op]"),
("io/print_bool", "(Bool) -> Unit !IO [effect op]"),
]
}
+433
View File
@@ -0,0 +1,433 @@
//! Typchecker für AILang (MVP).
//!
//! Monomorpher HM-Subset: keine Type-Variablen im Body, alle Top-Level-Defs
//! müssen vollständig annotiert sein. Effekte werden als Set propagiert
//! und mit der Annotation am Funktionstyp abgeglichen.
//!
//! Eingebaute Operationen werden über [`Builtins`] aufgelöst.
use ailang_core::ast::*;
use indexmap::IndexMap;
use std::collections::BTreeSet;
pub mod builtins;
#[derive(Debug, thiserror::Error)]
pub enum CheckError {
#[error("def `{0}`: {1}")]
Def(String, Box<CheckError>),
#[error("type mismatch: expected {expected}, got {got}")]
TypeMismatch { expected: String, got: String },
#[error("unknown identifier: `{0}`")]
UnknownIdent(String),
#[error("unknown effect operation: `{0}`")]
UnknownEffectOp(String),
#[error("`{0}` is not a function (got {1})")]
NotAFunction(String, String),
#[error("arity mismatch for `{name}`: expected {expected} args, got {got}")]
ArityMismatch {
name: String,
expected: usize,
got: usize,
},
#[error("undeclared effect `{0}` used in body")]
UndeclaredEffect(String),
#[error("function type required for fn `{0}`, got {1}")]
FnTypeRequired(String, String),
#[error("param count mismatch in `{name}`: type has {ty_count}, params has {param_count}")]
ParamCountMismatch {
name: String,
ty_count: usize,
param_count: usize,
},
#[error("polymorphic types not supported in MVP body of `{0}`")]
PolymorphicNotSupported(String),
#[error("const `{0}` may not have effects (got !{1:?})")]
ConstHasEffects(String, Vec<String>),
}
type Result<T> = std::result::Result<T, CheckError>;
/// Ergebnis der Typprüfung eines Moduls: Mapping vom Symbolnamen zum
/// (Typ, Hash) — bereit für `manifest`-Ausgabe.
#[derive(Debug, Clone)]
pub struct CheckedModule {
pub symbols: IndexMap<String, (Type, String)>,
}
pub fn check(m: &Module) -> Result<CheckedModule> {
let mut env = Env::new();
builtins::install(&mut env);
// Pass 1: alle Top-Level-Symbole registrieren (für Vorwärtsreferenzen).
for def in &m.defs {
match def {
Def::Fn(f) => {
env.globals.insert(f.name.clone(), f.ty.clone());
}
Def::Const(c) => {
env.globals.insert(c.name.clone(), c.ty.clone());
}
}
}
// Pass 2: jede Def prüfen.
let mut symbols = IndexMap::new();
for def in &m.defs {
check_def(def, &env).map_err(|e| CheckError::Def(def.name().to_string(), Box::new(e)))?;
let h = ailang_core::hash::def_hash(def);
let ty = match def {
Def::Fn(f) => f.ty.clone(),
Def::Const(c) => c.ty.clone(),
};
symbols.insert(def.name().to_string(), (ty, h));
}
Ok(CheckedModule { symbols })
}
fn check_def(def: &Def, env: &Env) -> Result<()> {
match def {
Def::Fn(f) => check_fn(f, env),
Def::Const(c) => check_const(c, env),
}
}
fn check_fn(f: &FnDef, env: &Env) -> Result<()> {
let (param_tys, ret_ty, declared_effs) = match &f.ty {
Type::Fn { params, ret, effects } => {
(params.clone(), (**ret).clone(), effects.clone())
}
other => {
return Err(CheckError::FnTypeRequired(
f.name.clone(),
ailang_core::pretty::type_to_string(other),
));
}
};
if f.params.len() != param_tys.len() {
return Err(CheckError::ParamCountMismatch {
name: f.name.clone(),
ty_count: param_tys.len(),
param_count: f.params.len(),
});
}
let mut locals = IndexMap::new();
for (n, t) in f.params.iter().zip(param_tys.iter()) {
locals.insert(n.clone(), t.clone());
}
let mut effects = BTreeSet::new();
let body_ty = synth(&f.body, env, &mut locals, &mut effects, &f.name)?;
expect_eq(&ret_ty, &body_ty)?;
let declared: BTreeSet<String> = declared_effs.into_iter().collect();
for e in &effects {
if !declared.contains(e) {
return Err(CheckError::UndeclaredEffect(e.clone()));
}
}
Ok(())
}
fn check_const(c: &ConstDef, env: &Env) -> Result<()> {
let mut locals = IndexMap::new();
let mut effects = BTreeSet::new();
let v = synth(&c.value, env, &mut locals, &mut effects, &c.name)?;
expect_eq(&c.ty, &v)?;
if !effects.is_empty() {
return Err(CheckError::ConstHasEffects(
c.name.clone(),
effects.into_iter().collect(),
));
}
Ok(())
}
fn synth(
t: &Term,
env: &Env,
locals: &mut IndexMap<String, Type>,
effects: &mut BTreeSet<String>,
in_def: &str,
) -> Result<Type> {
match t {
Term::Lit { lit } => Ok(match lit {
Literal::Int { .. } => Type::int(),
Literal::Bool { .. } => Type::bool_(),
Literal::Unit => Type::unit(),
}),
Term::Var { name } => {
if let Some(t) = locals.get(name) {
return Ok(t.clone());
}
if let Some(t) = env.globals.get(name) {
return Ok(t.clone());
}
Err(CheckError::UnknownIdent(name.clone()))
}
Term::App { callee, args } => {
let cty = synth(callee, env, locals, effects, in_def)?;
let (params, ret, fx) = match &cty {
Type::Fn { params, ret, effects: fx } => {
(params.clone(), (**ret).clone(), fx.clone())
}
Type::Forall { .. } => {
return Err(CheckError::PolymorphicNotSupported(in_def.to_string()));
}
other => {
return Err(CheckError::NotAFunction(
callee_name(callee),
ailang_core::pretty::type_to_string(other),
));
}
};
if args.len() != params.len() {
return Err(CheckError::ArityMismatch {
name: callee_name(callee),
expected: params.len(),
got: args.len(),
});
}
for (a, exp) in args.iter().zip(params.iter()) {
let actual = synth(a, env, locals, effects, in_def)?;
expect_eq(exp, &actual)?;
}
for e in fx {
effects.insert(e);
}
Ok(ret)
}
Term::Let { name, value, body } => {
let v = synth(value, env, locals, effects, in_def)?;
let prev = locals.insert(name.clone(), v);
let r = synth(body, env, locals, effects, in_def)?;
match prev {
Some(p) => {
locals.insert(name.clone(), p);
}
None => {
locals.shift_remove(name);
}
}
Ok(r)
}
Term::If { cond, then, else_ } => {
let c = synth(cond, env, locals, effects, in_def)?;
expect_eq(&Type::bool_(), &c)?;
let t1 = synth(then, env, locals, effects, in_def)?;
let t2 = synth(else_, env, locals, effects, in_def)?;
expect_eq(&t1, &t2)?;
Ok(t1)
}
Term::Do { op, args } => {
let sig = env
.effect_ops
.get(op)
.ok_or_else(|| CheckError::UnknownEffectOp(op.clone()))?
.clone();
if args.len() != sig.params.len() {
return Err(CheckError::ArityMismatch {
name: op.clone(),
expected: sig.params.len(),
got: args.len(),
});
}
for (a, exp) in args.iter().zip(sig.params.iter()) {
let actual = synth(a, env, locals, effects, in_def)?;
expect_eq(exp, &actual)?;
}
effects.insert(sig.effect.clone());
Ok(sig.ret)
}
}
}
fn callee_name(t: &Term) -> String {
match t {
Term::Var { name } => name.clone(),
_ => "<expr>".into(),
}
}
fn expect_eq(expected: &Type, got: &Type) -> Result<()> {
if expected == got {
Ok(())
} else {
Err(CheckError::TypeMismatch {
expected: ailang_core::pretty::type_to_string(expected),
got: ailang_core::pretty::type_to_string(got),
})
}
}
#[derive(Debug, Default)]
pub struct Env {
pub globals: IndexMap<String, Type>,
pub effect_ops: IndexMap<String, builtins::EffectOpSig>,
}
impl Env {
fn new() -> Self {
Self::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use ailang_core::SCHEMA;
fn fn_def(name: &str, ty: Type, params: Vec<&str>, body: Term) -> Def {
Def::Fn(FnDef {
name: name.into(),
ty,
params: params.into_iter().map(|s| s.into()).collect(),
body,
doc: None,
})
}
#[test]
fn checks_simple_arithmetic_fn() {
let m = Module {
schema: SCHEMA.into(),
name: "t".into(),
imports: vec![],
defs: vec![fn_def(
"add",
Type::Fn {
params: vec![Type::int(), Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
},
vec!["a", "b"],
Term::App {
callee: Box::new(Term::Var { name: "+".into() }),
args: vec![
Term::Var { name: "a".into() },
Term::Var { name: "b".into() },
],
},
)],
};
check(&m).expect("should typecheck");
}
#[test]
fn rejects_type_mismatch() {
let m = Module {
schema: SCHEMA.into(),
name: "t".into(),
imports: vec![],
defs: vec![fn_def(
"bad",
Type::Fn {
params: vec![],
ret: Box::new(Type::int()),
effects: vec![],
},
vec![],
Term::Lit {
lit: Literal::Bool { value: true },
},
)],
};
let err = check(&m).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("type mismatch"), "got: {msg}");
}
#[test]
fn requires_effect_to_be_declared() {
let m = Module {
schema: SCHEMA.into(),
name: "t".into(),
imports: vec![],
defs: vec![fn_def(
"leaks",
Type::Fn {
params: vec![],
ret: Box::new(Type::unit()),
effects: vec![], // !IO fehlt
},
vec![],
Term::Do {
op: "io/print_int".into(),
args: vec![Term::Lit {
lit: Literal::Int { value: 1 },
}],
},
)],
};
let err = check(&m).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("undeclared effect"), "got: {msg}");
}
#[test]
fn lets_local_shadow_global() {
let m = Module {
schema: SCHEMA.into(),
name: "t".into(),
imports: vec![],
defs: vec![fn_def(
"f",
Type::Fn {
params: vec![],
ret: Box::new(Type::int()),
effects: vec![],
},
vec![],
Term::Let {
name: "x".into(),
value: Box::new(Term::Lit {
lit: Literal::Int { value: 7 },
}),
body: Box::new(Term::Var { name: "x".into() }),
},
)],
};
check(&m).expect("should typecheck");
}
#[test]
fn if_branches_must_match() {
let m = Module {
schema: SCHEMA.into(),
name: "t".into(),
imports: vec![],
defs: vec![fn_def(
"f",
Type::Fn {
params: vec![],
ret: Box::new(Type::int()),
effects: vec![],
},
vec![],
Term::If {
cond: Box::new(Term::Lit {
lit: Literal::Bool { value: true },
}),
then: Box::new(Term::Lit {
lit: Literal::Int { value: 1 },
}),
else_: Box::new(Term::Lit { lit: Literal::Unit }),
},
)],
};
let err = check(&m).unwrap_err();
assert!(format!("{err}").contains("type mismatch"));
}
}