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
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "ailang-core"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
blake3.workspace = true
thiserror.workspace = true
indexmap.workspace = true
+151
View File
@@ -0,0 +1,151 @@
//! AST-Knoten. Serde-Layout entspricht dem JSON-Schema in `docs/DESIGN.md`.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Module {
pub schema: String,
pub name: String,
#[serde(default)]
pub imports: Vec<Import>,
pub defs: Vec<Def>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Import {
pub module: String,
#[serde(rename = "as", default, skip_serializing_if = "Option::is_none")]
pub alias: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum Def {
Fn(FnDef),
Const(ConstDef),
}
impl Def {
pub fn name(&self) -> &str {
match self {
Def::Fn(f) => &f.name,
Def::Const(c) => &c.name,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FnDef {
pub name: String,
#[serde(rename = "type")]
pub ty: Type,
pub params: Vec<String>,
pub body: Term,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doc: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConstDef {
pub name: String,
#[serde(rename = "type")]
pub ty: Type,
pub value: Term,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doc: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "t", rename_all = "lowercase")]
pub enum Term {
Lit { lit: Literal },
Var { name: String },
App {
#[serde(rename = "fn")]
callee: Box<Term>,
args: Vec<Term>,
},
Let {
name: String,
value: Box<Term>,
body: Box<Term>,
},
If {
cond: Box<Term>,
then: Box<Term>,
#[serde(rename = "else")]
else_: Box<Term>,
},
Do {
op: String,
args: Vec<Term>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum Literal {
Int { value: i64 },
Bool { value: bool },
Unit,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "k", rename_all = "lowercase")]
pub enum Type {
Con {
name: String,
},
Fn {
params: Vec<Type>,
ret: Box<Type>,
#[serde(default)]
effects: Vec<String>,
},
Var {
name: String,
},
Forall {
vars: Vec<String>,
body: Box<Type>,
},
}
impl Type {
pub fn int() -> Type {
Type::Con { name: "Int".into() }
}
pub fn bool_() -> Type {
Type::Con { name: "Bool".into() }
}
pub fn unit() -> Type {
Type::Con { name: "Unit".into() }
}
}
impl PartialEq for Type {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Type::Con { name: a }, Type::Con { name: b }) => a == b,
(
Type::Fn { params: ap, ret: ar, effects: ae },
Type::Fn { params: bp, ret: br, effects: be },
) => {
ap == bp && ar == br && {
let mut a = ae.clone();
let mut b = be.clone();
a.sort();
b.sort();
a == b
}
}
(Type::Var { name: a }, Type::Var { name: b }) => a == b,
(
Type::Forall { vars: a, body: ab },
Type::Forall { vars: b, body: bb },
) => a == b && ab == bb,
_ => false,
}
}
}
impl Eq for Type {}
+85
View File
@@ -0,0 +1,85 @@
//! Kanonische JSON-Serialisierung.
//!
//! Schreibt JSON ohne Whitespace und mit lexikographisch sortierten Object-Keys.
//! Damit ist die Repräsentation deterministisch und für Hashing geeignet.
use std::io::Write;
/// Kanonische Bytes für ein beliebiges Serializable-Objekt.
pub fn to_bytes<T: serde::Serialize>(value: &T) -> Vec<u8> {
let v = serde_json::to_value(value).expect("serializable");
let mut out = Vec::new();
write_value(&v, &mut out).expect("write to Vec");
out
}
fn write_value(v: &serde_json::Value, out: &mut Vec<u8>) -> std::io::Result<()> {
use serde_json::Value;
match v {
Value::Null => out.write_all(b"null"),
Value::Bool(true) => out.write_all(b"true"),
Value::Bool(false) => out.write_all(b"false"),
Value::Number(n) => out.write_all(n.to_string().as_bytes()),
Value::String(s) => {
let escaped = serde_json::to_string(s).expect("string serializable");
out.write_all(escaped.as_bytes())
}
Value::Array(arr) => {
out.write_all(b"[")?;
for (i, item) in arr.iter().enumerate() {
if i > 0 {
out.write_all(b",")?;
}
write_value(item, out)?;
}
out.write_all(b"]")
}
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
out.write_all(b"{")?;
for (i, k) in keys.iter().enumerate() {
if i > 0 {
out.write_all(b",")?;
}
let kj = serde_json::to_string(k).expect("key serializable");
out.write_all(kj.as_bytes())?;
out.write_all(b":")?;
write_value(&map[*k], out)?;
}
out.write_all(b"}")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn sorts_keys() {
let v = json!({ "b": 1, "a": 2 });
let bytes = to_bytes(&v);
assert_eq!(std::str::from_utf8(&bytes).unwrap(), r#"{"a":2,"b":1}"#);
}
#[test]
fn nested_keys_sorted() {
let v = json!({ "z": { "y": 1, "x": [3, { "b": 2, "a": 1 }] } });
let bytes = to_bytes(&v);
assert_eq!(
std::str::from_utf8(&bytes).unwrap(),
r#"{"z":{"x":[3,{"a":1,"b":2}],"y":1}}"#
);
}
#[test]
fn no_whitespace() {
let v = json!({ "a": 1, "b": [1, 2, 3] });
let bytes = to_bytes(&v);
let s = std::str::from_utf8(&bytes).unwrap();
assert!(!s.contains(' '));
assert!(!s.contains('\n'));
}
}
+62
View File
@@ -0,0 +1,62 @@
//! Content-addressed Hashing für Definitionen.
//!
//! Hash = BLAKE3 über die kanonische JSON-Form (siehe `canonical`).
//! Das `hash`-Feld in der Eingabe wird vor dem Hashen entfernt.
use crate::ast::Def;
use crate::canonical;
/// 16-Hex-Zeichen (64 bit) Prefix des BLAKE3-Hashes.
/// Reicht für Eindeutigkeit innerhalb realistischer Codebases und ist
/// kompakt genug für visuelle Inspektion.
pub fn def_hash(def: &Def) -> String {
let bytes = canonical::to_bytes(def);
let h = blake3::hash(&bytes);
let hex = h.to_hex();
hex.as_str()[..16].to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::*;
fn sample_fn() -> Def {
Def::Fn(FnDef {
name: "add".into(),
ty: Type::Fn {
params: vec![Type::int(), Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
},
params: vec!["a".into(), "b".into()],
body: Term::App {
callee: Box::new(Term::Var { name: "+".into() }),
args: vec![
Term::Var { name: "a".into() },
Term::Var { name: "b".into() },
],
},
doc: None,
})
}
#[test]
fn hash_is_stable() {
let h1 = def_hash(&sample_fn());
let h2 = def_hash(&sample_fn());
assert_eq!(h1, h2);
assert_eq!(h1.len(), 16);
}
#[test]
fn hash_changes_with_content() {
let mut def = sample_fn();
let h1 = def_hash(&def);
if let Def::Fn(ref mut f) = def {
f.name = "mul".into();
}
let h2 = def_hash(&def);
assert_ne!(h1, h2);
}
}
+40
View File
@@ -0,0 +1,40 @@
//! AILang Kerndatenmodell.
//!
//! Quelle einer AILang-Übersetzungseinheit ist ein `Module` als JSON. Dieses
//! Crate definiert das Schema, Serialisierung und content-addressed Hashing.
pub mod ast;
pub mod canonical;
pub mod hash;
pub mod pretty;
pub use ast::{ConstDef, Def, FnDef, Import, Literal, Module, Term, Type};
pub use hash::def_hash;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("schema mismatch: expected {expected:?}, got {got:?}")]
SchemaMismatch { expected: String, got: String },
#[error("json: {0}")]
Json(#[from] serde_json::Error),
#[error("io: {0}")]
Io(#[from] std::io::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
pub const SCHEMA: &str = "ailang/v0";
pub fn load_module(path: &std::path::Path) -> Result<Module> {
let bytes = std::fs::read(path)?;
let module: Module = serde_json::from_slice(&bytes)?;
if module.schema != SCHEMA {
return Err(Error::SchemaMismatch {
expected: SCHEMA.to_string(),
got: module.schema,
});
}
Ok(module)
}
+255
View File
@@ -0,0 +1,255 @@
//! Pretty-Printer: AST → menschenlesbare Textform.
//!
//! Die Textform ist als Diff- und Review-Werkzeug gedacht. Die
//! kanonische Quelle bleibt die JSON-Form. Jede pretty-Ausgabe ist
//! deterministisch.
use crate::ast::*;
use std::fmt::Write;
pub fn module(m: &Module) -> String {
let mut s = String::new();
writeln!(s, "(module {}", m.name).unwrap();
if !m.imports.is_empty() {
for imp in &m.imports {
match &imp.alias {
Some(a) => writeln!(s, " (import {} as {})", imp.module, a).unwrap(),
None => writeln!(s, " (import {})", imp.module).unwrap(),
}
}
}
for (i, def) in m.defs.iter().enumerate() {
if i > 0 {
s.push('\n');
}
let body = def_block(def, 2);
s.push_str(&body);
s.push('\n');
}
s.push(')');
s.push('\n');
s
}
pub fn manifest(m: &Module) -> String {
let mut s = String::new();
writeln!(s, "module {}", m.name).unwrap();
let max_name = m.defs.iter().map(|d| d.name().len()).max().unwrap_or(0);
for def in &m.defs {
let h = crate::hash::def_hash(def);
let (kw, ty) = match def {
Def::Fn(f) => ("fn", type_to_string(&f.ty)),
Def::Const(c) => ("const", type_to_string(&c.ty)),
};
writeln!(
s,
" {kw:5} {name:<width$} :: {ty} [{h}]",
kw = kw,
name = def.name(),
width = max_name,
ty = ty,
h = h,
)
.unwrap();
}
s
}
fn def_block(def: &Def, indent: usize) -> String {
let pad = " ".repeat(indent);
match def {
Def::Fn(f) => {
let params = if f.params.is_empty() {
"[]".to_string()
} else {
format!("[{}]", f.params.join(" "))
};
let mut s = format!(
"{pad}(fn {name} :: {ty} {params}\n",
pad = pad,
name = f.name,
ty = type_to_string(&f.ty),
params = params,
);
s.push_str(&term_block(&f.body, indent + 2));
s.push(')');
s
}
Def::Const(c) => {
let mut s = format!(
"{pad}(const {name} :: {ty}\n",
pad = pad,
name = c.name,
ty = type_to_string(&c.ty),
);
s.push_str(&term_block(&c.value, indent + 2));
s.push(')');
s
}
}
}
fn term_block(t: &Term, indent: usize) -> String {
let pad = " ".repeat(indent);
match t {
Term::Lit { lit } => format!("{pad}{}", lit_to_string(lit)),
Term::Var { name } => format!("{pad}{name}"),
Term::App { callee, args } => {
let mut s = format!("{pad}(");
s.push_str(&term_inline(callee));
for a in args {
s.push(' ');
s.push_str(&term_inline(a));
}
s.push(')');
s
}
Term::Let { name, value, body } => {
let mut s = format!("{pad}(let {name}\n");
s.push_str(&term_block(value, indent + 2));
s.push('\n');
s.push_str(&term_block(body, indent + 2));
s.push(')');
s
}
Term::If { cond, then, else_ } => {
let mut s = format!("{pad}(if\n");
s.push_str(&term_block(cond, indent + 2));
s.push('\n');
s.push_str(&term_block(then, indent + 2));
s.push('\n');
s.push_str(&term_block(else_, indent + 2));
s.push(')');
s
}
Term::Do { op, args } => {
let mut s = format!("{pad}(do {op}");
for a in args {
s.push(' ');
s.push_str(&term_inline(a));
}
s.push(')');
s
}
}
}
fn term_inline(t: &Term) -> String {
match t {
Term::Lit { lit } => lit_to_string(lit),
Term::Var { name } => name.clone(),
Term::App { callee, args } => {
let mut s = String::from("(");
s.push_str(&term_inline(callee));
for a in args {
s.push(' ');
s.push_str(&term_inline(a));
}
s.push(')');
s
}
Term::Do { op, args } => {
let mut s = format!("(do {op}");
for a in args {
s.push(' ');
s.push_str(&term_inline(a));
}
s.push(')');
s
}
// Strukturelle Terms in Inline-Form rekursiv schwer; fallback:
Term::Let { name, value, body } => {
format!(
"(let {name} {} {})",
term_inline(value),
term_inline(body)
)
}
Term::If { cond, then, else_ } => {
format!(
"(if {} {} {})",
term_inline(cond),
term_inline(then),
term_inline(else_)
)
}
}
}
fn lit_to_string(l: &Literal) -> String {
match l {
Literal::Int { value } => value.to_string(),
Literal::Bool { value } => value.to_string(),
Literal::Unit => "()".to_string(),
}
}
pub fn type_to_string(t: &Type) -> String {
match t {
Type::Con { name } => name.clone(),
Type::Var { name } => name.clone(),
Type::Fn { params, ret, effects } => {
let p = params
.iter()
.map(type_to_string)
.collect::<Vec<_>>()
.join(", ");
let eff = if effects.is_empty() {
String::new()
} else {
format!(" !{}", effects.join(","))
};
format!("({p}) -> {ret}{eff}", ret = type_to_string(ret))
}
Type::Forall { vars, body } => {
format!("forall {}. {}", vars.join(" "), type_to_string(body))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_module() -> Module {
Module {
schema: crate::SCHEMA.into(),
name: "sample".into(),
imports: vec![],
defs: vec![
Def::Fn(FnDef {
name: "add".into(),
ty: Type::Fn {
params: vec![Type::int(), Type::int()],
ret: Box::new(Type::int()),
effects: vec![],
},
params: vec!["a".into(), "b".into()],
body: Term::App {
callee: Box::new(Term::Var { name: "+".into() }),
args: vec![
Term::Var { name: "a".into() },
Term::Var { name: "b".into() },
],
},
doc: None,
}),
],
}
}
#[test]
fn pretty_print_does_not_panic() {
let s = module(&sample_module());
assert!(s.contains("(module sample"));
assert!(s.contains("(fn add"));
assert!(s.contains("(+ a b)"));
}
#[test]
fn manifest_contains_type_and_hash() {
let s = manifest(&sample_module());
assert!(s.contains("add"));
assert!(s.contains("(Int, Int) -> Int"));
}
}