prose: human-readable projection renderer (iter 20a)

New crate ailang-prose with one public fn module_to_prose(&Module)
-> String. Rust-flavour with braces, =>-match-arms, mode keywords
(own/borrow), effects as trailing 'with IO', Cons(1, Nil) ctor
form, /// doc strings.

Lossy projection where the LLM can re-derive: (con T) wrap,
(fn-type ...) wrap, (term-ctor ...) wrap. Load-bearing semantic
detail stays visible (modes, effects, clone, reuse-as, doc strings,
type annotations, tail flag).

CLI: 'ail prose <file.ail.json>' prints the projection.

3 snapshot fixtures + 28 unit tests. 215-line .ail.json reduces
to ~18 lines of legible source.

20b queued: infix arithmetic, paren elision, let-inlining, do
prettify.
This commit is contained in:
2026-05-08 18:06:16 +02:00
parent d8e80cb9c5
commit a9d57c5c81
11 changed files with 1171 additions and 0 deletions
+924
View File
@@ -0,0 +1,924 @@
//! AILang prose projection — human-readable presentation layer.
//!
//! This crate is the form-(B) projection of [`ailang_core::ast::Module`]:
//! a Rust-flavoured, brace-and-comma text rendering optimised for human
//! reading. It is a *one-way* projection — there is no parser. Editing
//! the prose and re-integrating the changes is the round-trip-mediator's
//! job (Iter 20d), not this crate's.
//!
//! The canonical authoring surface remains form (A) (`ailang-surface`)
//! and the canonical hashable artefact remains the JSON-AST
//! (`ailang-core`). Prose is deliberately lossy where the LLM can
//! re-derive the dropped machinery from typecheck context (the `(con T)`
//! wrap, the `(fn-type ...)` wrap, the `(term-ctor T C ...)` collapsing
//! to `C(...)`); every load-bearing semantic detail (mode annotations,
//! effects, `clone`, `reuse-as`, doc strings, type annotations on
//! signatures and lambdas, the `tail` flag) stays visible.
//!
//! See `docs/JOURNAL.md` "Pinned: human-readable prose surface
//! (Family 20 candidate)" for the design pinning, and "Iter 20a" for
//! this iter's spec.
//!
//! # Public API
//!
//! [`module_to_prose`] is the only entry point. It is deterministic and
//! never fails on a well-formed AST.
use ailang_core::ast::{
ConstDef, Ctor, Def, FnDef, Import, Literal, Module, ParamMode, Pattern, Term, Type, TypeDef,
};
/// Render a [`Module`] as human-readable prose.
///
/// The output is deterministic; identical input AST always yields
/// identical bytes. The renderer never fails on a well-formed AST.
pub fn module_to_prose(m: &Module) -> String {
let mut out = String::new();
write_module(&mut out, m);
out
}
// ---- module ---------------------------------------------------------------
fn write_module(out: &mut String, m: &Module) {
out.push_str("// module ");
out.push_str(&m.name);
out.push('\n');
if !m.imports.is_empty() {
out.push('\n');
for imp in &m.imports {
write_import(out, imp);
out.push('\n');
}
}
for def in &m.defs {
out.push('\n');
write_def(out, def, 0);
out.push('\n');
}
}
fn write_import(out: &mut String, imp: &Import) {
out.push_str("import ");
out.push_str(&imp.module);
if let Some(alias) = &imp.alias {
out.push_str(" as ");
out.push_str(alias);
}
}
// ---- defs -----------------------------------------------------------------
fn write_def(out: &mut String, def: &Def, level: usize) {
match def {
Def::Type(td) => write_type_def(out, td, level),
Def::Fn(fd) => write_fn_def(out, fd, level),
Def::Const(cd) => write_const_def(out, cd, level),
}
}
fn write_doc(out: &mut String, doc: &Option<String>, level: usize) {
if let Some(d) = doc {
for line in d.split('\n') {
indent(out, level);
out.push_str("/// ");
out.push_str(line);
out.push('\n');
}
}
}
fn write_type_def(out: &mut String, td: &TypeDef, level: usize) {
write_doc(out, &td.doc, level);
indent(out, level);
out.push_str("data ");
out.push_str(&td.name);
if !td.vars.is_empty() {
out.push('<');
for (i, v) in td.vars.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
out.push_str(v);
}
out.push('>');
}
out.push_str(" = ");
for (i, c) in td.ctors.iter().enumerate() {
if i > 0 {
out.push_str(" | ");
}
write_ctor(out, c);
}
if td.drop_iterative {
out.push_str(" with drop-iterative");
}
}
fn write_ctor(out: &mut String, c: &Ctor) {
out.push_str(&c.name);
if !c.fields.is_empty() {
out.push('(');
for (i, f) in c.fields.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
write_type(out, f);
}
out.push(')');
}
}
fn write_fn_def(out: &mut String, fd: &FnDef, level: usize) {
write_doc(out, &fd.doc, level);
// The FnDef carries `params` (names) plus the type. The signature
// surface combines them slot-for-slot. `fd.ty` is either a
// `Type::Fn` or a `Type::Forall` wrapping one — in the latter case
// we render the forall on the line above and then the inner fn
// signature.
let (forall_vars, fn_ty) = match &fd.ty {
Type::Forall { vars, body } => (Some(vars.clone()), body.as_ref()),
other => (None, other),
};
if let Some(vars) = &forall_vars {
indent(out, level);
out.push_str("forall<");
for (i, v) in vars.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
out.push_str(v);
}
out.push_str(">\n");
}
indent(out, level);
out.push_str("fn ");
out.push_str(&fd.name);
out.push('(');
if let Type::Fn {
params,
param_modes,
ret,
ret_mode,
effects,
} = fn_ty
{
for (i, ty) in params.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
// Param name from FnDef.params, type+mode from Type::Fn.
let pname = fd.params.get(i).map(String::as_str).unwrap_or("_");
out.push_str(pname);
out.push_str(": ");
let mode = param_modes
.get(i)
.copied()
.unwrap_or(ParamMode::Implicit);
write_mode_type(out, ty, mode);
}
out.push_str(") -> ");
write_mode_type(out, ret, *ret_mode);
if !effects.is_empty() {
out.push_str(" with ");
// Stable order: as written in the AST. Effects are
// semantically a set, but re-sorting would erase
// author-asserted ordering; preserve the AST order.
for (i, e) in effects.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
out.push_str(e);
}
}
} else {
// Defensive fallback: a non-Fn fn-type. Render the bare type so
// nothing crashes; this path is unreachable for typechecked
// input.
out.push_str(") -> ");
write_type(out, fn_ty);
}
out.push_str(" {\n");
indent(out, level + 1);
write_term(out, &fd.body, level + 1);
out.push('\n');
indent(out, level);
out.push('}');
}
fn write_const_def(out: &mut String, cd: &ConstDef, level: usize) {
write_doc(out, &cd.doc, level);
indent(out, level);
out.push_str("const ");
out.push_str(&cd.name);
out.push_str(": ");
write_type(out, &cd.ty);
out.push_str(" = ");
write_term(out, &cd.value, level);
}
// ---- types ----------------------------------------------------------------
fn write_mode_type(out: &mut String, t: &Type, mode: ParamMode) {
match mode {
ParamMode::Implicit => write_type(out, t),
ParamMode::Own => {
out.push_str("own ");
write_type(out, t);
}
ParamMode::Borrow => {
out.push_str("borrow ");
write_type(out, t);
}
}
}
fn write_type(out: &mut String, t: &Type) {
match t {
Type::Var { name } => out.push_str(name),
Type::Con { name, args } => {
out.push_str(name);
if !args.is_empty() {
out.push('<');
for (i, a) in args.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
write_type(out, a);
}
out.push('>');
}
}
Type::Fn {
params,
param_modes,
ret,
ret_mode,
effects,
} => {
out.push('(');
for (i, p) in params.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
let mode = param_modes
.get(i)
.copied()
.unwrap_or(ParamMode::Implicit);
// In a bare `Type::Fn` outside a fn signature we have no
// parameter names, so render `mode T` only (no `name:`).
write_mode_type(out, p, mode);
}
out.push_str(") -> ");
write_mode_type(out, ret, *ret_mode);
if !effects.is_empty() {
out.push_str(" with ");
for (i, e) in effects.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
out.push_str(e);
}
}
}
Type::Forall { vars, body } => {
out.push_str("forall<");
for (i, v) in vars.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
out.push_str(v);
}
out.push_str("> ");
write_type(out, body);
}
}
}
// ---- terms ----------------------------------------------------------------
fn write_term(out: &mut String, t: &Term, level: usize) {
match t {
Term::Lit { lit } => write_lit(out, lit),
Term::Var { name } => out.push_str(name),
Term::App { callee, args, tail } => {
if *tail {
out.push_str("tail ");
}
write_term(out, callee, level);
out.push('(');
for (i, a) in args.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
write_term(out, a, level);
}
out.push(')');
}
Term::Let { name, value, body } => {
// `let x = value;` then body on the next line at the same
// level. Both lines are emitted by the *enclosing* context's
// indentation; the caller indented us once already, so for
// the body we re-indent.
out.push_str("let ");
out.push_str(name);
out.push_str(" = ");
write_term(out, value, level);
out.push_str(";\n");
indent(out, level);
write_term(out, body, level);
}
Term::LetRec {
name,
ty,
params,
body,
in_term,
} => {
// Local recursive fn-binding. Render as a nested fn-shape
// followed by the body.
out.push_str("let-rec ");
out.push_str(name);
out.push('(');
if let Type::Fn {
params: ptys,
param_modes,
ret,
ret_mode,
effects,
} = ty
{
for (i, pty) in ptys.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
let pname = params.get(i).map(String::as_str).unwrap_or("_");
out.push_str(pname);
out.push_str(": ");
let mode = param_modes
.get(i)
.copied()
.unwrap_or(ParamMode::Implicit);
write_mode_type(out, pty, mode);
}
out.push_str(") -> ");
write_mode_type(out, ret, *ret_mode);
if !effects.is_empty() {
out.push_str(" with ");
for (i, e) in effects.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
out.push_str(e);
}
}
} else {
out.push_str(") -> ");
write_type(out, ty);
}
out.push_str(" {\n");
indent(out, level + 1);
write_term(out, body, level + 1);
out.push('\n');
indent(out, level);
out.push_str("};\n");
indent(out, level);
write_term(out, in_term, level);
}
Term::If { cond, then, else_ } => {
out.push_str("if ");
write_term(out, cond, level);
out.push_str(" {\n");
indent(out, level + 1);
write_term(out, then, level + 1);
out.push('\n');
indent(out, level);
out.push_str("} else {\n");
indent(out, level + 1);
write_term(out, else_, level + 1);
out.push('\n');
indent(out, level);
out.push('}');
}
Term::Do { op, args, tail } => {
if *tail {
out.push_str("tail ");
}
out.push_str("do ");
out.push_str(op);
out.push('(');
for (i, a) in args.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
write_term(out, a, level);
}
out.push(')');
}
Term::Ctor {
type_name: _,
ctor,
args,
} => {
out.push_str(ctor);
if !args.is_empty() {
out.push('(');
for (i, a) in args.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
write_term(out, a, level);
}
out.push(')');
}
}
Term::Match { scrutinee, arms } => {
out.push_str("match ");
write_term(out, scrutinee, level);
out.push_str(" {\n");
for (i, arm) in arms.iter().enumerate() {
indent(out, level + 1);
write_pattern(out, &arm.pat);
out.push_str(" => ");
write_term(out, &arm.body, level + 1);
if i + 1 < arms.len() {
out.push(',');
}
out.push('\n');
}
indent(out, level);
out.push('}');
}
Term::Lam {
params,
param_tys,
ret_ty,
effects,
body,
} => {
// Rust-closure flavour: `|p1: T1, p2: T2| -> R with EFF { body }`
out.push('|');
for (i, (pname, pty)) in params.iter().zip(param_tys.iter()).enumerate() {
if i > 0 {
out.push_str(", ");
}
out.push_str(pname);
out.push_str(": ");
write_type(out, pty);
}
out.push_str("| -> ");
write_type(out, ret_ty);
if !effects.is_empty() {
out.push_str(" with ");
for (i, e) in effects.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
out.push_str(e);
}
}
out.push_str(" {\n");
indent(out, level + 1);
write_term(out, body, level + 1);
out.push('\n');
indent(out, level);
out.push('}');
}
Term::Seq { lhs, rhs } => {
// Semicolon-as-discard. `lhs;` on its own line, `rhs` on
// the next at the same indent. The caller already indented us.
write_term(out, lhs, level);
out.push_str(";\n");
indent(out, level);
write_term(out, rhs, level);
}
Term::Clone { value } => {
out.push_str("clone(");
write_term(out, value, level);
out.push(')');
}
Term::ReuseAs { source, body } => {
out.push_str("reuse-as ");
write_term(out, source, level);
out.push_str(" {\n");
indent(out, level + 1);
write_term(out, body, level + 1);
out.push('\n');
indent(out, level);
out.push('}');
}
}
}
fn write_pattern(out: &mut String, p: &Pattern) {
match p {
Pattern::Wild => out.push('_'),
Pattern::Var { name } => out.push_str(name),
Pattern::Lit { lit } => write_lit(out, lit),
Pattern::Ctor { ctor, fields } => {
out.push_str(ctor);
if !fields.is_empty() {
out.push('(');
for (i, f) in fields.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
write_pattern(out, f);
}
out.push(')');
}
}
}
}
fn write_lit(out: &mut String, lit: &Literal) {
match lit {
Literal::Int { value } => out.push_str(&value.to_string()),
Literal::Bool { value } => out.push_str(if *value { "true" } else { "false" }),
Literal::Str { value } => write_string_lit(out, value),
Literal::Unit => out.push_str("()"),
}
}
fn write_string_lit(out: &mut String, s: &str) {
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\t' => out.push_str("\\t"),
'\r' => out.push_str("\\r"),
other => out.push(other),
}
}
out.push('"');
}
// ---- helpers --------------------------------------------------------------
fn indent(out: &mut String, level: usize) {
for _ in 0..level {
out.push_str(" ");
}
}
// ---- unit tests -----------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use ailang_core::ast::{Arm, Ctor, FnDef, Literal, Pattern, Term, Type};
fn render_term(t: &Term) -> String {
let mut out = String::new();
write_term(&mut out, t, 0);
out
}
fn render_type(t: &Type) -> String {
let mut out = String::new();
write_type(&mut out, t);
out
}
// ---- Lit / Var ----
#[test]
fn lit_int_renders_as_decimal() {
assert_eq!(render_term(&Term::Lit { lit: Literal::Int { value: 42 } }), "42");
}
#[test]
fn lit_bool_renders_as_keyword() {
assert_eq!(
render_term(&Term::Lit { lit: Literal::Bool { value: true } }),
"true"
);
assert_eq!(
render_term(&Term::Lit { lit: Literal::Bool { value: false } }),
"false"
);
}
#[test]
fn lit_str_renders_with_escapes() {
assert_eq!(
render_term(&Term::Lit { lit: Literal::Str { value: "hi\n".into() } }),
"\"hi\\n\""
);
}
#[test]
fn lit_unit_renders_as_unit_pair() {
assert_eq!(render_term(&Term::Lit { lit: Literal::Unit }), "()");
}
#[test]
fn var_renders_as_name() {
assert_eq!(render_term(&Term::Var { name: "x".into() }), "x");
}
// ---- App ----
#[test]
fn app_two_args_renders_as_call() {
let t = Term::App {
callee: Box::new(Term::Var { name: "f".into() }),
args: vec![
Term::Lit { lit: Literal::Int { value: 1 } },
Term::Lit { lit: Literal::Int { value: 2 } },
],
tail: false,
};
assert_eq!(render_term(&t), "f(1, 2)");
}
#[test]
fn app_tail_renders_with_keyword() {
let t = Term::App {
callee: Box::new(Term::Var { name: "f".into() }),
args: vec![],
tail: true,
};
assert_eq!(render_term(&t), "tail f()");
}
// ---- Let / If ----
#[test]
fn let_renders_as_binding_and_body() {
let t = Term::Let {
name: "x".into(),
value: Box::new(Term::Lit { lit: Literal::Int { value: 1 } }),
body: Box::new(Term::Var { name: "x".into() }),
};
assert_eq!(render_term(&t), "let x = 1;\nx");
}
#[test]
fn if_renders_with_braces_and_else() {
let t = 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::Int { value: 2 } }),
};
assert_eq!(
render_term(&t),
"if true {\n 1\n} else {\n 2\n}"
);
}
// ---- Match ----
#[test]
fn match_two_arms_renders_with_arrows_and_commas() {
let t = Term::Match {
scrutinee: Box::new(Term::Var { name: "xs".into() }),
arms: vec![
Arm {
pat: Pattern::Ctor { ctor: "Nil".into(), fields: vec![] },
body: Term::Lit { lit: Literal::Int { value: 0 } },
},
Arm {
pat: Pattern::Ctor {
ctor: "Cons".into(),
fields: vec![
Pattern::Var { name: "h".into() },
Pattern::Var { name: "t".into() },
],
},
body: Term::Var { name: "h".into() },
},
],
};
assert_eq!(
render_term(&t),
"match xs {\n Nil => 0,\n Cons(h, t) => h\n}"
);
}
// ---- Ctor ----
#[test]
fn ctor_nullary_renders_as_bare_name() {
let t = Term::Ctor {
type_name: "List".into(),
ctor: "Nil".into(),
args: vec![],
};
assert_eq!(render_term(&t), "Nil");
}
#[test]
fn ctor_with_args_renders_as_call() {
let t = Term::Ctor {
type_name: "List".into(),
ctor: "Cons".into(),
args: vec![
Term::Lit { lit: Literal::Int { value: 1 } },
Term::Ctor {
type_name: "List".into(),
ctor: "Nil".into(),
args: vec![],
},
],
};
assert_eq!(render_term(&t), "Cons(1, Nil)");
}
// ---- Lam ----
#[test]
fn lam_renders_as_rust_closure() {
let t = Term::Lam {
params: vec!["x".into()],
param_tys: vec![Type::int()],
ret_ty: Box::new(Type::int()),
effects: vec![],
body: Box::new(Term::Var { name: "x".into() }),
};
assert_eq!(render_term(&t), "|x: Int| -> Int {\n x\n}");
}
// ---- Seq / Clone / ReuseAs / Do ----
#[test]
fn seq_renders_with_semicolon_and_newline() {
let t = Term::Seq {
lhs: Box::new(Term::Var { name: "a".into() }),
rhs: Box::new(Term::Var { name: "b".into() }),
};
assert_eq!(render_term(&t), "a;\nb");
}
#[test]
fn clone_renders_explicitly() {
let t = Term::Clone {
value: Box::new(Term::Var { name: "x".into() }),
};
assert_eq!(render_term(&t), "clone(x)");
}
#[test]
fn reuse_as_renders_with_keyword_and_braces() {
let t = Term::ReuseAs {
source: Box::new(Term::Var { name: "xs".into() }),
body: Box::new(Term::Ctor {
type_name: "List".into(),
ctor: "Nil".into(),
args: vec![],
}),
};
assert_eq!(render_term(&t), "reuse-as xs {\n Nil\n}");
}
#[test]
fn do_renders_with_keyword_and_op() {
let t = Term::Do {
op: "io/print_int".into(),
args: vec![Term::Lit { lit: Literal::Int { value: 5 } }],
tail: false,
};
assert_eq!(render_term(&t), "do io/print_int(5)");
}
// ---- Types ----
#[test]
fn type_con_no_args_drops_wrap() {
assert_eq!(render_type(&Type::int()), "Int");
}
#[test]
fn type_con_with_args_renders_as_generic() {
let t = Type::Con {
name: "List".into(),
args: vec![Type::int()],
};
assert_eq!(render_type(&t), "List<Int>");
}
#[test]
fn type_var_renders_as_name() {
assert_eq!(render_type(&Type::Var { name: "a".into() }), "a");
}
#[test]
fn type_fn_no_modes_no_effects() {
let t = Type::fn_implicit(vec![Type::int()], Type::int(), vec![]);
assert_eq!(render_type(&t), "(Int) -> Int");
}
#[test]
fn type_fn_with_effects() {
let t = Type::fn_implicit(vec![], Type::unit(), vec!["IO".into()]);
assert_eq!(render_type(&t), "() -> Unit with IO");
}
#[test]
fn type_fn_with_modes_renders_keywords_before_type() {
let t = Type::Fn {
params: vec![Type::int(), Type::bool_()],
param_modes: vec![ParamMode::Own, ParamMode::Borrow],
ret: Box::new(Type::int()),
ret_mode: ParamMode::Own,
effects: vec![],
};
assert_eq!(render_type(&t), "(own Int, borrow Bool) -> own Int");
}
#[test]
fn type_forall_renders_with_angle_vars() {
let t = Type::Forall {
vars: vec!["a".into()],
body: Box::new(Type::Var { name: "a".into() }),
};
assert_eq!(render_type(&t), "forall<a> a");
}
// ---- FnDef integration: modes BEFORE the type, in fn signature ----
#[test]
fn fn_def_signature_renders_modes_before_type() {
let fd = FnDef {
name: "head_or_zero".into(),
ty: Type::Fn {
params: vec![Type::Con {
name: "IntList".into(),
args: vec![],
}],
param_modes: vec![ParamMode::Own],
ret: Box::new(Type::int()),
ret_mode: ParamMode::Implicit,
effects: vec![],
},
params: vec!["xs".into()],
body: Term::Lit { lit: Literal::Int { value: 0 } },
doc: None,
};
let mut out = String::new();
write_fn_def(&mut out, &fd, 0);
assert!(out.contains("xs: own IntList"), "got:\n{out}");
assert!(out.contains(") -> Int "), "got:\n{out}");
}
#[test]
fn fn_def_with_effects_appends_with_clause() {
let fd = FnDef {
name: "main".into(),
ty: Type::fn_implicit(vec![], Type::unit(), vec!["IO".into()]),
params: vec![],
body: Term::Lit { lit: Literal::Unit },
doc: None,
};
let mut out = String::new();
write_fn_def(&mut out, &fd, 0);
assert!(out.contains("() -> Unit with IO {"), "got:\n{out}");
}
// ---- Doc string lines ----
#[test]
fn doc_string_renders_as_triple_slash_lines() {
let td = TypeDef {
name: "Foo".into(),
vars: vec![],
ctors: vec![Ctor {
name: "MkFoo".into(),
fields: vec![],
}],
doc: Some("line one\nline two".into()),
drop_iterative: false,
};
let mut out = String::new();
write_type_def(&mut out, &td, 0);
assert!(out.starts_with("/// line one\n/// line two\n"), "got:\n{out}");
}
// ---- TypeDef ----
#[test]
fn type_def_renders_with_pipe_separated_ctors() {
let td = TypeDef {
name: "IntList".into(),
vars: vec![],
ctors: vec![
Ctor { name: "Nil".into(), fields: vec![] },
Ctor {
name: "Cons".into(),
fields: vec![Type::int(), Type::Con { name: "IntList".into(), args: vec![] }],
},
],
doc: None,
drop_iterative: false,
};
let mut out = String::new();
write_type_def(&mut out, &td, 0);
assert_eq!(out, "data IntList = Nil | Cons(Int, IntList)");
}
}