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
Generated
+8
View File
@@ -9,6 +9,7 @@ dependencies = [
"ailang-check",
"ailang-codegen",
"ailang-core",
"ailang-prose",
"ailang-surface",
"anyhow",
"clap",
@@ -48,6 +49,13 @@ dependencies = [
"thiserror",
]
[[package]]
name = "ailang-prose"
version = "0.0.1"
dependencies = [
"ailang-core",
]
[[package]]
name = "ailang-surface"
version = "0.0.1"
+2
View File
@@ -5,6 +5,7 @@ members = [
"crates/ailang-check",
"crates/ailang-codegen",
"crates/ailang-surface",
"crates/ailang-prose",
"crates/ail",
]
@@ -28,6 +29,7 @@ ailang-core = { path = "crates/ailang-core" }
ailang-check = { path = "crates/ailang-check" }
ailang-codegen = { path = "crates/ailang-codegen" }
ailang-surface = { path = "crates/ailang-surface" }
ailang-prose = { path = "crates/ailang-prose" }
[profile.release]
lto = "thin"
+1
View File
@@ -13,6 +13,7 @@ ailang-core.workspace = true
ailang-check.workspace = true
ailang-codegen.workspace = true
ailang-surface.workspace = true
ailang-prose.workspace = true
serde_json.workspace = true
clap.workspace = true
anyhow.workspace = true
+14
View File
@@ -200,6 +200,13 @@ enum Cmd {
#[arg(short, long)]
output: Option<PathBuf>,
},
/// Iter 20a: prints the module as human-readable prose (form B).
///
/// One-way projection: the renderer is deterministic, but no
/// parser exists for the prose surface. The canonical authoring
/// surface remains form (A) (`render` / `parse`) and the canonical
/// hashable artefact remains the JSON-AST.
Prose { path: PathBuf },
}
fn main() -> Result<()> {
@@ -298,6 +305,13 @@ fn main() -> Result<()> {
let m = ailang_core::load_module(&path)?;
print!("{}", ailang_surface::print(&m));
}
Cmd::Prose { path } => {
// Iter 20a: load via `load_module` (single-module mode,
// matching how `render` / `parse` work). Workspace-wide
// prose is not in scope for 20a.
let m = ailang_core::load_module(&path)?;
print!("{}", ailang_prose::module_to_prose(&m));
}
Cmd::Describe { path, name, json, workspace } => {
if workspace {
let ws = ailang_core::load_workspace(&path)?;
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "ailang-prose"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
ailang-core.workspace = true
+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)");
}
}
+64
View File
@@ -0,0 +1,64 @@
//! Iter 20a snapshot tests.
//!
//! For each `examples/<name>.ail.json`, the renderer's output is
//! compared against the committed `examples/<name>.prose.txt`. The
//! snapshot files are part of the repo: changing the renderer requires
//! a deliberate snapshot update, which is the point of this test.
//!
//! Update protocol (manual): regenerate the file by running
//! `cargo run -p ail -- prose <fixture>.ail.json > <fixture>.prose.txt`
//! once the renderer change is agreed; commit alongside the change.
//! There is no `UPDATE_SNAPSHOTS=1` environment short-cut here on
//! purpose — Iter 20a is the seeding round, and we want every change
//! after this to be visible in a diff.
use std::path::PathBuf;
fn examples_dir() -> PathBuf {
// tests/ runs with CARGO_MANIFEST_DIR == crates/ailang-prose.
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.pop();
p.pop();
p.push("examples");
p
}
fn check_snapshot(stem: &str) {
let dir = examples_dir();
let json_path = dir.join(format!("{stem}.ail.json"));
let prose_path = dir.join(format!("{stem}.prose.txt"));
let module = ailang_core::load_module(&json_path)
.unwrap_or_else(|e| panic!("failed to load {json_path:?}: {e}"));
let actual = ailang_prose::module_to_prose(&module);
let expected = std::fs::read_to_string(&prose_path)
.unwrap_or_else(|e| panic!("failed to read snapshot {prose_path:?}: {e}"));
if actual != expected {
// Write `.actual` for inspection, then fail.
let actual_path = dir.join(format!("{stem}.prose.txt.actual"));
std::fs::write(&actual_path, &actual).expect("write .actual");
panic!(
"prose snapshot mismatch for {stem}.\n\
Expected: {prose_path:?}\n\
Actual: {actual_path:?}\n\
To accept the new output, replace the expected file with the .actual file."
);
}
}
#[test]
fn snapshot_rc_own_param_drop() {
check_snapshot("rc_own_param_drop");
}
#[test]
fn snapshot_rc_match_arm_partial_drop_leak() {
check_snapshot("rc_match_arm_partial_drop_leak");
}
#[test]
fn snapshot_rc_app_let_partial_drop_leak() {
check_snapshot("rc_app_let_partial_drop_leak");
}
+85
View File
@@ -8879,3 +8879,88 @@ is the form-B *projection*).
- Whether snapshot tests live in `crates/ailang-prose/tests/`
or `examples/` (as `.prose.txt` siblings to the .ail.json).
## 2026-05-08 — Iter 20a: prose renderer skeleton shipped
The renderer-side of the family-20 design pinning. New crate
`ailang-prose` with one public fn `module_to_prose(&Module) -> String`,
plus a `ail prose <file.ail.json>` CLI subcommand that prints the
projection to stdout.
### Style commitments (orchestrator-fixed for 20a, not up to renderer)
- Rust-flavour with braces and `=>` for match arms.
- Mode keywords `own T` / `borrow T` (not sigils — Decision-10 consistency).
- Effects trailing the return type: `with IO`.
- Constructor application as `Cons(1, Nil)` (no `(term-ctor ...)` wrap).
- Types as bare names: `Int`, `List<Int>` (no `(con ...)` wrap).
- Doc strings as `///` lines above the def.
- `tail` flag on calls renders as a `tail ` prefix.
- All other load-bearing semantic detail stays visible: `clone`,
`reuse-as`, effects, doc strings, type annotations on signatures
and lambdas.
- No infix yet; arithmetic primitives stay as `i64-add(a, b)`.
That's 20b.
### Snapshot coverage
Three fixtures got `.prose.txt` siblings, asserted by
`crates/ailang-prose/tests/snapshot.rs`:
- `examples/rc_own_param_drop.prose.txt`
- `examples/rc_match_arm_partial_drop_leak.prose.txt`
- `examples/rc_app_let_partial_drop_leak.prose.txt`
Sample (`rc_own_param_drop.prose.txt`):
```
fn head_or_zero(xs: own IntList) -> Int {
match xs {
Nil => 0,
Cons(h, t) => h
}
}
```
vs. the 215-line `.ail.json` it projects from.
The `head_or_zero` body is exactly the kind of thing the user
named: dramatically less syntactic noise, immediately legible to
a human, with `own` mode and `Cons(h, t)` as conventional source
forms.
### Visible nits queued for 20b
- Long doc strings stay on one wrap-less line.
- `do io/print_int(x)` could be `print(x)` for the IO/Int-print
default.
- Nested match arms could break across lines.
- Arithmetic primitives are still prefix.
- No precedence-aware paren elision yet.
All explicitly out of 20a's scope; pinned for 20b.
### Files
- New crate `crates/ailang-prose/` (`Cargo.toml`, `src/lib.rs`
~924 lines, `tests/snapshot.rs` 64 lines).
- `Cargo.toml` (root) + `crates/ail/Cargo.toml`: workspace + dep wiring.
- `crates/ail/src/main.rs`: `Cmd::Prose { path }` variant + dispatch.
- Three new `examples/*.prose.txt` snapshots.
### Build/test status
- `cargo build --workspace` — green, no warnings.
- `cargo test --workspace` — 28 prose-unit + 3 prose-snapshot pass;
existing 69 e2e + 53 ailang-check + others all unchanged-green.
### What stays queued
- **Iter 20b** — formatting polish: infix for arithmetic, paren
elision by precedence, let-inlining, prettier `do`, line-wrap
for long doc strings.
- **Iter 20c** — the CLI subcommand was bundled into 20a (one
natural unit). Originally pinned as a separate iter; collapsed.
- **Iter 20d** — the round-trip mediator (prose-edit → updated
AIL via LLM call). Specification + prompt template, not a
compiler pass.
@@ -0,0 +1,24 @@
// module rc_app_let_partial_drop_leak
data Wrap = MkWrap(Int)
data Cell = MkCell(Wrap, Wrap)
data Pair = MkPair(Cell, Cell)
fn build_pair(n: Int) -> own Pair {
MkPair(MkCell(MkWrap(n), MkWrap(2)), MkCell(MkWrap(3), MkWrap(4)))
}
fn use_cell(c: own Cell) -> Int {
match c {
MkCell(w1, w2) => 1
}
}
fn main() -> Unit with IO {
let p = build_pair(1);
do io/print_int(match p {
MkPair(a, _) => use_cell(a)
})
}
@@ -0,0 +1,23 @@
// module rc_match_arm_partial_drop_leak
data Wrap = MkWrap(Int)
data Cell = MkCell(Wrap, Wrap)
data Pair = MkPair(Cell, Cell)
fn build_pair(n: Int) -> own Pair {
MkPair(MkCell(MkWrap(n), MkWrap(2)), MkCell(MkWrap(3), MkWrap(4)))
}
fn use_first(p: own Pair) -> Int {
match p {
MkPair(a, b) => match a {
MkCell(w1, _) => 1
}
}
}
fn main() -> Unit with IO {
do io/print_int(use_first(build_pair(1)))
}
+18
View File
@@ -0,0 +1,18 @@
// module rc_own_param_drop
/// Recursive Int list — boxed.
data IntList = Nil | Cons(Int, IntList)
/// Take ownership of an IntList; return its head if Cons, else 0. The tail is loaded as a pattern binder but never consumed — iter A dec's it at arm close. The outer cell is dec'd at fn return via iter B's Own-param emission.
fn head_or_zero(xs: own IntList) -> Int {
match xs {
Nil => 0,
Cons(h, t) => h
}
}
/// Build a 5-element IntList; pass to head_or_zero (transferring ownership); print the result.
fn main() -> Unit with IO {
let xs = Cons(11, Cons(22, Cons(33, Cons(44, Cons(55, Nil)))));
do io/print_int(head_or_zero(xs))
}