diff --git a/crates/ail/src/main.rs b/crates/ail/src/main.rs index aca1bf0..635a093 100644 --- a/crates/ail/src/main.rs +++ b/crates/ail/src/main.rs @@ -13,8 +13,9 @@ //! //! - `manifest` — compact symbol table of a module (or workspace); //! one line per def with type, effects, hash. -//! - `render` — pretty-print a `.ail.json` module as the textual -//! `.ail` projection. +//! - `render` — print a `.ail.json` module as form-(A) text. Exact +//! inverse of `parse`; piping `render | parse` round-trips to the +//! same canonical bytes. //! - `describe` — full detail of a single definition, JSON or text. //! - `deps` — static call edges per def (or for one named def); //! workspace mode emits cross-module edges. @@ -70,7 +71,10 @@ enum Cmd { #[arg(long)] workspace: bool, }, - /// Prints the module in text form (pretty-printer). + /// Prints the module as form (A) text — the canonical authoring + /// surface and the exact inverse of `parse`. Round-tripping + /// `render | parse` reproduces the input's canonical bytes + /// (gated by `crates/ailang-surface/tests/round_trip.rs`). Render { path: PathBuf }, /// Prints a single definition as JSON or pretty text. Describe { @@ -278,7 +282,7 @@ fn main() -> Result<()> { } Cmd::Render { path } => { let m = ailang_core::load_module(&path)?; - print!("{}", ailang_core::pretty::module(&m)); + print!("{}", ailang_surface::print(&m)); } Cmd::Describe { path, name, json, workspace } => { if workspace { @@ -310,7 +314,7 @@ fn main() -> Result<()> { let h = ailang_core::def_hash(def); println!("module: {}", mod_name); println!("hash: {h}"); - print!("{}", ailang_core::pretty::module(&one)); + print!("{}", ailang_surface::print(&one)); } } else { let m = ailang_core::load_module(&path)?; @@ -323,7 +327,7 @@ fn main() -> Result<()> { let s = serde_json::to_string_pretty(def)?; println!("{s}"); } else { - // Pretty form: render module with only this def. + // Form-(A) projection of a one-def module. let one = ailang_core::Module { schema: m.schema.clone(), name: m.name.clone(), @@ -332,7 +336,7 @@ fn main() -> Result<()> { }; let h = ailang_core::def_hash(def); println!("hash: {h}"); - print!("{}", ailang_core::pretty::module(&one)); + print!("{}", ailang_surface::print(&one)); } } } diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index 0caa87e..c5641f9 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -305,6 +305,49 @@ fn std_list_stress_1000_element_folds() { ); } +/// Iter 15e: `ail render | ail parse` round-trips canonical bytes. +/// Property protected: the CLI's `render` is the exact inverse of +/// `parse`, not the older human-pretty form. Regression of this +/// would mean piping `render` into `parse` no longer yields a +/// re-loadable module — a contract the help text now claims. +/// +/// Picks `std_either.ail.json` (richest fixture: 2-type-var data, +/// 3-type-var fn, recursive ctors irrelevant) but the round-trip +/// test in `ailang-surface` covers all 25 fixtures structurally. +/// This test specifically exercises the CLI shell pipeline. +#[test] +fn render_parse_round_trip_canonical() { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); + let src = workspace.join("examples").join("std_either.ail.json"); + let original = std::fs::read(&src).expect("read fixture"); + + let render = Command::new(ail_bin()) + .args(["render", src.to_str().unwrap()]) + .output() + .expect("run ail render"); + assert!(render.status.success(), "ail render failed"); + + let tmp = std::env::temp_dir().join(format!("ailang_render_e2e_{}.ailx", std::process::id())); + std::fs::write(&tmp, &render.stdout).expect("write rendered ailx"); + + let parsed = Command::new(ail_bin()) + .args(["parse", tmp.to_str().unwrap()]) + .output() + .expect("run ail parse"); + assert!(parsed.status.success(), "ail parse failed on rendered output"); + + let mut round = parsed.stdout; + while round.last() == Some(&b'\n') { + round.pop(); + } + let mut orig = original; + while orig.last() == Some(&b'\n') { + orig.pop(); + } + assert_eq!(orig, round, "render | parse must produce canonical bytes"); +} + /// Iter 15d: `std_either` end-to-end. First stdlib ADT with two type /// parameters (`Either`); first combinator with three type vars /// (`either : (e -> c) -> (a -> c) -> Either -> c`). diff --git a/crates/ailang-core/src/lib.rs b/crates/ailang-core/src/lib.rs index 7604ede..5d38c68 100644 --- a/crates/ailang-core/src/lib.rs +++ b/crates/ailang-core/src/lib.rs @@ -41,8 +41,12 @@ //! - Read an entry module plus all imports: [`load_workspace`]. //! - Hash a [`Def`]: [`def_hash`]. Hash a whole [`Module`]: //! [`workspace::module_hash`]. -//! - Render a [`Module`] for review: [`pretty::module`] / -//! [`pretty::manifest`]. +//! - Form-(A) text projection of a [`Module`]: see +//! `ailang-surface::print` (round-trippable; the inverse of +//! `ailang-surface::parse`). +//! - Diagnostic stringification (manifest summary, type/pattern +//! for error messages): [`pretty::manifest`], +//! [`pretty::type_to_string`], [`pretty::pattern_to_string`]. pub mod ast; pub mod canonical; diff --git a/crates/ailang-core/src/pretty.rs b/crates/ailang-core/src/pretty.rs index 55bbc07..60c275b 100644 --- a/crates/ailang-core/src/pretty.rs +++ b/crates/ailang-core/src/pretty.rs @@ -1,53 +1,25 @@ -//! Pretty-printer: AST → human-readable text form. +//! Diagnostic helpers that stringify AST fragments. //! -//! The text form is intended for diff and review (LLM and human eyes). -//! The canonical source remains the JSON form, and the pretty output -//! is deliberately one-way: there is no parser back from text into AST -//! in this crate. Every pretty output is deterministic. +//! Form (A) — the round-trippable authoring surface — lives in +//! `ailang-surface::print`. This module is the asymmetric, lossy +//! companion: stringification routines used inside error messages +//! and the manifest summary, where one-line ML-style notation +//! (`forall a. List -> Int`) reads better than form (A)'s +//! `(forall (vars a) (fn-type ...))`. //! -//! Two top-level renderers are provided: -//! - [`module`] — full S-expression dump of every definition. -//! - [`manifest`] — one-line summary per def with its type and -//! [`crate::def_hash`]. +//! Public entry points: +//! - [`manifest`] — one line per def, used by `ail manifest`. +//! - [`type_to_string`] — single-line ML-style type, used by +//! `ailang-check` diagnostics, `ailang-codegen` mismatch errors, +//! and `ail describe`'s text projection. +//! - [`pattern_to_string`] — single-line pattern, used by +//! `ailang-check` diagnostics. //! -//! Helpers [`pattern_to_string`] and [`type_to_string`] are exposed -//! because diagnostics in `ailang-check` reuse them in error messages. -//! -//! This module deliberately does **not** roundtrip text back to AST -//! and does not attempt to mirror canonical JSON byte-for-byte. +//! All output is deterministic. None of it round-trips back to AST. use crate::ast::*; use std::fmt::Write; -/// Render a complete [`Module`] as an S-expression-style text block. -/// -/// Output format is stable but not part of the on-disk schema — -/// consumers should treat it as documentation, not a serialization -/// format. Used by `ail print` and by the pretty-print review flow. -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 -} - /// Render a one-line-per-def manifest of a [`Module`]. /// /// Each line carries the def kind keyword (`fn` / `const` / `type`), @@ -106,173 +78,9 @@ pub fn manifest(m: &Module) -> String { 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 - } - Def::Type(t) => { - let header = if t.vars.is_empty() { - format!("{pad}(type {name}\n", pad = pad, name = t.name) - } else { - format!( - "{pad}(type {name} [{vars}]\n", - pad = pad, - name = t.name, - vars = t.vars.join(" "), - ) - }; - let mut s = header; - let inner = " ".repeat(indent + 2); - for ctor in &t.ctors { - if ctor.fields.is_empty() { - s.push_str(&format!("{inner}(| {})\n", ctor.name)); - } else { - let fs = ctor - .fields - .iter() - .map(type_to_string) - .collect::>() - .join(" "); - s.push_str(&format!("{inner}(| {name} {fs})\n", name = ctor.name)); - } - } - s.push_str(&pad); - 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 - } - Term::Ctor { type_name, ctor, args } => { - let mut s = format!("{pad}({type_name}/{ctor}"); - for a in args { - s.push(' '); - s.push_str(&term_inline(a)); - } - s.push(')'); - s - } - Term::Match { scrutinee, arms } => { - let mut s = format!("{pad}(match {}\n", term_inline(scrutinee)); - let inner = " ".repeat(indent + 2); - for arm in arms { - s.push_str(&format!( - "{inner}(case {} ->\n", - pattern_to_string(&arm.pat) - )); - s.push_str(&term_block(&arm.body, indent + 4)); - s.push_str(")\n"); - } - s.push_str(&pad); - s.push(')'); - s - } - Term::Lam { params, param_tys, ret_ty, effects, body } => { - // (\ [params] :: typed-sig . body) - let typed_params: Vec = params - .iter() - .zip(param_tys.iter()) - .map(|(n, t)| format!("{n}: {}", type_to_string(t))) - .collect(); - let eff = if effects.is_empty() { - String::new() - } else { - format!(" !{}", effects.join(",")) - }; - let mut s = format!( - "{pad}(\\ ({}) -> {}{}\n", - typed_params.join(" "), - type_to_string(ret_ty), - eff, - ); - s.push_str(&term_block(body, indent + 2)); - s.push(')'); - s - } - Term::Seq { lhs, rhs } => { - let mut s = format!("{pad}(seq\n"); - s.push_str(&term_block(lhs, indent + 2)); - s.push('\n'); - s.push_str(&term_block(rhs, indent + 2)); - s.push(')'); - s - } - } -} - /// Render a [`Pattern`] as a single-line string. /// -/// Used both internally by [`module`] for match arms and externally by -/// `ailang-check` when constructing diagnostic messages. +/// Used by `ailang-check` when constructing diagnostic messages. pub fn pattern_to_string(p: &Pattern) -> String { match p { Pattern::Wild => "_".into(), @@ -293,67 +101,6 @@ pub fn pattern_to_string(p: &Pattern) -> String { } } -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 - } - Term::Ctor { type_name, ctor, args } => { - let mut s = format!("({type_name}/{ctor}"); - for a in args { - s.push(' '); - s.push_str(&term_inline(a)); - } - s.push(')'); - s - } - Term::Match { scrutinee, .. } => { - // Don't render match inline — use a marker. - format!("(match {} ...)", term_inline(scrutinee)) - } - // Structural terms are tricky to render inline recursively; 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_) - ) - } - Term::Lam { params, .. } => { - format!("(\\ {} ...)", params.join(" ")) - } - Term::Seq { lhs, rhs } => { - format!("(seq {} {})", term_inline(lhs), term_inline(rhs)) - } - } -} - fn lit_to_string(l: &Literal) -> String { match l { Literal::Int { value } => value.to_string(), @@ -440,14 +187,6 @@ mod tests { } } - #[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()); diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index 838c749..a9e7c6e 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -2606,3 +2606,67 @@ the CLI. Logged as 15e. **Queue update.** 15d done; remaining queue: 15e (CLI render/parse symmetry — small, just discovered), then `std_pair` if more stdlib is wanted, then 16a/16b language gaps. + +--- + +## Iter 15e — `render` is the inverse of `parse` (CLI hygiene) + +**Trigger.** Discovered during 15d: `ail render | ail parse` did not +round-trip. The help text claimed they were inverses but `render` +called `ailang_core::pretty::module` (an old human-readable +S-expression-ish form), while `parse` consumes form (A). Two +different "text projections" lived in the codebase, one of them +exposed via the CLI under a name that promised inversion. + +The form-(A) printer in `ailang_surface::print` was already correct +and gated by `crates/ailang-surface/tests/round_trip.rs` across +every fixture (25 modules at the time of writing) — it was simply +not exposed at the CLI surface. + +**Changes.** + +1. `Cmd::Render` now calls `ailang_surface::print(&m)`. Help text + updated to state explicitly: form (A), exact inverse of `parse`, + round-trippable. +2. `Cmd::Describe` (both single-module and `--workspace` branches) + likewise switched to `ailang_surface::print` so that "text view + of one def" means the same thing everywhere in the CLI. +3. `ailang_core::pretty::module` deleted. With `module` gone its + helpers (`def_block`, `term_block`, `term_inline`) became dead + weight — also deleted. ~260 LOC of duplicate-purpose code gone; + `crates/ailang-core/src/pretty.rs` shrank from 457 to 196 LOC. +4. The remaining surface of `ailang_core::pretty` — `manifest`, + `type_to_string`, `pattern_to_string` — is the diagnostic + stringification surface (used in error messages and the + `ail manifest` summary, where one-line ML notation is more + readable than form (A)'s nested S-expression). Module-level + doc rewritten to make this single-purpose framing explicit; + no more "pretty-printer" / "render" overloading. +5. New e2e test `render_parse_round_trip_canonical` (in + `crates/ail/tests/e2e.rs`) gates the CLI shell pipeline: + shell out `ail render ` → write to tmp .ailx → + shell out `ail parse ` → assert canonical-byte equality + with the original fixture. The unit-level round-trip in + `ailang-surface/tests/round_trip.rs` covered the printer + function directly; this new test additionally guards the + CLI wiring. +6. The `ailang_core::pretty` unit test `pretty_print_does_not_panic` + exercised the deleted `module` fn — removed. `manifest_contains_ + type_and_hash` stays. + +**Doctrinal pin.** Form (A) is the only round-trippable text form. +The diagnostic stringification in `ailang_core::pretty` is +asymmetric and lossy by design — it exists for the *limited* +purpose of sticking a type or pattern into an error message in +human-friendlier shape than form (A). It does not roundtrip and +must not be used as an authoring or persistence target. Any +future add to it should add to that purpose, not back-fill toward +"another text projection". + +**Tests: 89/89.** (e2e went from 31 to 32; ailang-core unit +tests went from 11 to 10 — the deleted unit test exercised the +removed fn. Net same.) + +**Cumulative state, post-15e.** No new language features. Two +canonical text projections collapsed to one (form A). Internal +cleanup; no behavioural change for any program in the repo.