Iter 15e: render is the exact inverse of parse — CLI hygiene

The CLI help text claimed `render` was symmetric to `parse`, but
`render` was wired to ailang_core::pretty::module (an older
human-pretty form) while `parse` consumed form (A). Two different
"text projections" coexisted under one name.

Fix: Cmd::Render and Cmd::Describe (both branches) now call
ailang_surface::print, the actual inverse of `parse`. Round-trip
gate via `ail render | ail parse` is now an e2e test in
crates/ail/tests/e2e.rs in addition to the existing unit-level
round-trip across all 25 fixtures in ailang-surface/tests.

Cleanup: `ailang_core::pretty::module` and its three private
helpers (`def_block`, `term_block`, `term_inline`) deleted —
~260 LOC of duplicate-purpose code removed. pretty.rs goes
from 457 to 196 LOC. Surviving surface is intentionally narrow:
manifest, type_to_string, pattern_to_string — the diagnostic
stringification used in error messages, where one-line ML
notation reads better than form (A)'s nested S-expressions.
Module-level doc rewritten to nail down the single-purpose
framing.

Tests: 89/89 (e2e +1, ailang-core unit -1 since the deleted
test exercised the deleted fn).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 18:54:07 +02:00
parent df5b76bd65
commit 0b1b39f829
5 changed files with 140 additions and 286 deletions
+11 -7
View File
@@ -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));
}
}
}
+43
View File
@@ -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<e, a>`); first combinator with three type vars
/// (`either : (e -> c) -> (a -> c) -> Either<e, a> -> c`).
+6 -2
View File
@@ -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;
+16 -277
View File
@@ -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<a> -> 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::<Vec<_>>()
.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<String> = 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());
+64
View File
@@ -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 <fixture>` → write to tmp .ailx →
shell out `ail parse <tmp>` → 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.