diff --git a/Cargo.lock b/Cargo.lock index c9a1be2..3d96f0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index c4de061..b9ecd7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/ail/Cargo.toml b/crates/ail/Cargo.toml index 5832605..3d643bd 100644 --- a/crates/ail/Cargo.toml +++ b/crates/ail/Cargo.toml @@ -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 diff --git a/crates/ail/src/main.rs b/crates/ail/src/main.rs index 7f9b58b..35e6723 100644 --- a/crates/ail/src/main.rs +++ b/crates/ail/src/main.rs @@ -200,6 +200,13 @@ enum Cmd { #[arg(short, long)] output: Option, }, + /// 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)?; diff --git a/crates/ailang-prose/Cargo.toml b/crates/ailang-prose/Cargo.toml new file mode 100644 index 0000000..b79c1e2 --- /dev/null +++ b/crates/ailang-prose/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "ailang-prose" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +ailang-core.workspace = true diff --git a/crates/ailang-prose/src/lib.rs b/crates/ailang-prose/src/lib.rs new file mode 100644 index 0000000..f9c355c --- /dev/null +++ b/crates/ailang-prose/src/lib.rs @@ -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, 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"); + } + + #[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"); + } + + // ---- 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)"); + } +} diff --git a/crates/ailang-prose/tests/snapshot.rs b/crates/ailang-prose/tests/snapshot.rs new file mode 100644 index 0000000..6a5b8e6 --- /dev/null +++ b/crates/ailang-prose/tests/snapshot.rs @@ -0,0 +1,64 @@ +//! Iter 20a snapshot tests. +//! +//! For each `examples/.ail.json`, the renderer's output is +//! compared against the committed `examples/.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 .ail.json > .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"); +} diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index 92b77f6..a9850eb 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -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 ` 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` (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. diff --git a/examples/rc_app_let_partial_drop_leak.prose.txt b/examples/rc_app_let_partial_drop_leak.prose.txt new file mode 100644 index 0000000..7448e3c --- /dev/null +++ b/examples/rc_app_let_partial_drop_leak.prose.txt @@ -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) + }) +} diff --git a/examples/rc_match_arm_partial_drop_leak.prose.txt b/examples/rc_match_arm_partial_drop_leak.prose.txt new file mode 100644 index 0000000..036b4c5 --- /dev/null +++ b/examples/rc_match_arm_partial_drop_leak.prose.txt @@ -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))) +} diff --git a/examples/rc_own_param_drop.prose.txt b/examples/rc_own_param_drop.prose.txt new file mode 100644 index 0000000..f9b6c40 --- /dev/null +++ b/examples/rc_own_param_drop.prose.txt @@ -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)) +}