prose: infix + paren elision + doc-wrap + nested-match (iter 20b)

Eleven binary builtins (+ - * / % == != < <= > >=) render as
lhs op rhs when called with two args (tail-flagged keeps prefix).
Standard Rust-aligned 4-level precedence table elides parens.
not(x) renders as !x. Long /// doc strings wrap at 80 cols on
word boundaries.

bench_list_sum: 'if ==(n, 0)' becomes 'if n == 0', '+(acc, h)'
becomes 'acc + h', tail keyword still visible on tail calls.

19 new unit tests (47 total). 4 snapshots (3 re-rendered + 1 new
bench fixture). Public API unchanged.
This commit is contained in:
2026-05-08 18:13:43 +02:00
parent a9d57c5c81
commit 9cf0e3e81c
5 changed files with 645 additions and 9 deletions
+497 -7
View File
@@ -81,15 +81,59 @@ fn write_def(out: &mut String, def: &Def, level: usize) {
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');
// Polish 4 (Iter 20b): wrap long lines at 80 columns.
// Algorithm: split on explicit '\n' first (each piece is its own
// logical line). Then for each piece, greedy-wrap at word
// boundaries so that `<indent>/// <text>` fits in 80 cols.
// An empty logical line stays a single empty `///` line.
let prefix_cols = level * 2 + 4; // `<indent>/// ` width
let target = 80usize;
// If the prefix already eats the whole budget, fall back to no
// wrap (one word per line is worse than overflow).
let budget = target.saturating_sub(prefix_cols).max(1);
for piece in d.split('\n') {
for wrapped in wrap_words(piece, budget) {
indent(out, level);
out.push_str("/// ");
out.push_str(&wrapped);
out.push('\n');
}
}
}
}
/// Greedy word-boundary wrap. Returns at least one element (possibly
/// empty when `piece` is empty). Words longer than `budget` are placed
/// on their own line and overflow — splitting inside a word would
/// destroy identifiers.
fn wrap_words(piece: &str, budget: usize) -> Vec<String> {
if piece.is_empty() {
return vec![String::new()];
}
let mut out: Vec<String> = Vec::new();
let mut current = String::new();
for word in piece.split_whitespace() {
if current.is_empty() {
current.push_str(word);
} else if current.len() + 1 + word.len() <= budget {
current.push(' ');
current.push_str(word);
} else {
out.push(std::mem::take(&mut current));
current.push_str(word);
}
}
if !current.is_empty() {
out.push(current);
}
if out.is_empty() {
// `piece` was non-empty but all-whitespace. Preserve as one
// empty line rather than dropping it.
out.push(String::new());
}
out
}
fn write_type_def(out: &mut String, td: &TypeDef, level: usize) {
write_doc(out, &td.doc, level);
indent(out, level);
@@ -301,7 +345,116 @@ fn write_type(out: &mut String, t: &Type) {
// ---- terms ----------------------------------------------------------------
// Precedence ladder for paren elision (Iter 20b, Polish 2). Higher = binds
// tighter; an atomic form (var / literal / call / ctor / lambda / match /
// if / let / do / clone / reuse-as) sits at PREC_ATOMIC and never needs
// to wrap itself. PREC_NONE is the top-level (caller asks for no
// outer parens).
const PREC_NONE: u8 = 0;
const PREC_EQ: u8 = 1; // == != (non-assoc)
const PREC_REL: u8 = 2; // < <= > >= (non-assoc)
const PREC_ADD: u8 = 3; // + - (left-assoc)
const PREC_MUL: u8 = 4; // * / % (left-assoc)
const PREC_ATOMIC: u8 = 5;
/// Precedence + associativity of a canonical binary op name. Returns
/// `None` for any non-canonical name. Three-tuple: (level, left_assoc).
/// Non-assoc ops report `left_assoc = false`; for them we additionally
/// bump both sides to `level + 1` so same-level chains always wrap.
fn binop_info(name: &str) -> Option<(u8, bool)> {
Some(match name {
"*" | "/" | "%" => (PREC_MUL, true),
"+" | "-" => (PREC_ADD, true),
"<" | "<=" | ">" | ">=" => (PREC_REL, false),
"==" | "!=" => (PREC_EQ, false),
_ => return None,
})
}
/// Detect a `Term::App` of a canonical binary operator with exactly two
/// args and no `tail` flag. Returns the operator name + the two args.
/// A tail-flagged binary op is rendered the old prefix way so the
/// `tail ` keyword stays visible — that channel matters for the
/// reader.
fn as_binop(t: &Term) -> Option<(&str, &Term, &Term, u8, bool)> {
if let Term::App { callee, args, tail } = t {
if *tail || args.len() != 2 {
return None;
}
if let Term::Var { name } = callee.as_ref() {
if let Some((prec, left_assoc)) = binop_info(name) {
return Some((name.as_str(), &args[0], &args[1], prec, left_assoc));
}
}
}
None
}
/// Detect a `Term::App` of unary `not` (one arg, not tail). Returns the
/// argument.
fn as_unary_not(t: &Term) -> Option<&Term> {
if let Term::App { callee, args, tail } = t {
if *tail || args.len() != 1 {
return None;
}
if let Term::Var { name } = callee.as_ref() {
if name == "not" {
return Some(&args[0]);
}
}
}
None
}
/// Top-level entry into term rendering. The caller's precedence floor
/// is `PREC_NONE`, so a binary op at the root never wraps itself. All
/// internal recursion goes through this same fn with the appropriate
/// `parent_prec` for the sub-position.
fn write_term(out: &mut String, t: &Term, level: usize) {
write_term_prec(out, t, level, PREC_NONE);
}
fn write_term_prec(out: &mut String, t: &Term, level: usize, parent_prec: u8) {
// Polish 1+2: infix binary operators with paren elision.
if let Some((op, lhs, rhs, prec, left_assoc)) = as_binop(t) {
let need_parens = prec < parent_prec;
if need_parens {
out.push('(');
}
let (lhs_floor, rhs_floor) = if left_assoc {
// Left-assoc: same-prec on the left elides; same-prec on the
// right wraps. `a - b - c` reads as `(a - b) - c`, so the
// right side at the same level is a different parse tree
// and must be paren-flagged.
(prec, prec + 1)
} else {
// Non-assoc (==, !=, <, …): same-prec on either side wraps.
// `a < b < c` is ambiguous and Rust forbids it; we keep the
// parens to avoid implying we accept it.
(prec + 1, prec + 1)
};
write_term_prec(out, lhs, level, lhs_floor);
out.push(' ');
out.push_str(op);
out.push(' ');
write_term_prec(out, rhs, level, rhs_floor);
if need_parens {
out.push(')');
}
return;
}
// Polish 3: unary `not` → `!arg`. Argument renders at PREC_ATOMIC,
// so anything below atomic (i.e. another binary op) wraps.
if let Some(arg) = as_unary_not(t) {
out.push('!');
write_term_prec(out, arg, level, PREC_ATOMIC);
return;
}
// Everything below this point is atomic from the precedence pov:
// non-binary calls, ctors, literals, vars, control-flow blocks. We
// never need outer parens around them — they parse as a single unit.
match t {
Term::Lit { lit } => write_lit(out, lit),
Term::Var { name } => out.push_str(name),
@@ -309,13 +462,17 @@ fn write_term(out: &mut String, t: &Term, level: usize) {
if *tail {
out.push_str("tail ");
}
write_term(out, callee, level);
// Callee is rendered at PREC_ATOMIC: a binary op as callee
// (rare, but legal) would need parens.
write_term_prec(out, callee, level, PREC_ATOMIC);
out.push('(');
for (i, a) in args.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
write_term(out, a, level);
// Args sit in their own paren context — no outer prec
// floor. Pass NONE so binary ops inside don't wrap.
write_term_prec(out, a, level, PREC_NONE);
}
out.push(')');
}
@@ -921,4 +1078,337 @@ mod tests {
write_type_def(&mut out, &td, 0);
assert_eq!(out, "data IntList = Nil | Cons(Int, IntList)");
}
// ======================================================================
// Iter 20b: formatting polish
// ======================================================================
/// Convenience: two-arg App of a named callee. Used pervasively in
/// the 20b unit tests.
fn binop(op: &str, lhs: Term, rhs: Term) -> Term {
Term::App {
callee: Box::new(Term::Var { name: op.into() }),
args: vec![lhs, rhs],
tail: false,
}
}
fn ivar(name: &str) -> Term {
Term::Var { name: name.into() }
}
// ---- Polish 1: infix for each canonical binary operator ----
#[test]
fn binop_renders_infix_for_every_canonical_op() {
// All 11 operators must collapse to `lhs op rhs` shape.
let ops = ["+", "-", "*", "/", "%", "==", "!=", "<", "<=", ">", ">="];
for op in ops {
let t = binop(op, ivar("a"), ivar("b"));
let expected = format!("a {op} b");
assert_eq!(render_term(&t), expected, "op = {op}");
}
}
#[test]
fn binop_with_tail_flag_keeps_prefix_form() {
// `tail +(a, b)` keeps the `tail` keyword visible; collapsing it
// to infix would erase the contract that this is a tail call.
let t = Term::App {
callee: Box::new(ivar("+")),
args: vec![ivar("a"), ivar("b")],
tail: true,
};
assert_eq!(render_term(&t), "tail +(a, b)");
}
#[test]
fn three_arg_app_of_operator_name_keeps_prefix_form() {
// A user-defined `+` with three args must not trigger the infix
// renderer — the binary-op detector is arity-2 only.
let t = Term::App {
callee: Box::new(ivar("+")),
args: vec![ivar("a"), ivar("b"), ivar("c")],
tail: false,
};
assert_eq!(render_term(&t), "+(a, b, c)");
}
// ---- Polish 2: paren elision by precedence ----
#[test]
fn higher_prec_subexpr_elides_parens() {
// (a * b) + c → a * b + c (mul binds tighter than add)
let t = binop("+", binop("*", ivar("a"), ivar("b")), ivar("c"));
assert_eq!(render_term(&t), "a * b + c");
}
#[test]
fn lower_prec_subexpr_keeps_parens() {
// (a + b) * c → (a + b) * c (add binds looser than mul)
let t = binop("*", binop("+", ivar("a"), ivar("b")), ivar("c"));
assert_eq!(render_term(&t), "(a + b) * c");
}
#[test]
fn left_assoc_left_chain_elides_parens() {
// (a + b) + c is the natural parse for `a + b + c`, so the
// left-side parens disappear.
let t = binop("+", binop("+", ivar("a"), ivar("b")), ivar("c"));
assert_eq!(render_term(&t), "a + b + c");
}
#[test]
fn left_assoc_right_chain_keeps_parens() {
// `a + (b + c)` is a *different* parse tree from `a + b + c`,
// so the right-side parens stay to preserve the AST shape.
let t = binop("+", ivar("a"), binop("+", ivar("b"), ivar("c")));
assert_eq!(render_term(&t), "a + (b + c)");
}
#[test]
fn non_assoc_same_level_keeps_parens_on_both_sides() {
// `a < b < c` is forbidden in Rust; we keep parens regardless of
// which side carries the same-level sub.
let t = binop("<", binop("<", ivar("a"), ivar("b")), ivar("c"));
assert_eq!(render_term(&t), "(a < b) < c");
let t2 = binop("<", ivar("a"), binop("<", ivar("b"), ivar("c")));
assert_eq!(render_term(&t2), "a < (b < c)");
}
#[test]
fn atomic_subexpr_never_wraps() {
// A var on either side of any op never picks up parens.
let t = binop("==", ivar("n"), Term::Lit { lit: Literal::Int { value: 0 } });
assert_eq!(render_term(&t), "n == 0");
}
#[test]
fn function_call_subexpr_in_binop_does_not_wrap() {
// `f(x) + 1` — calls are atomic, so the call doesn't pick up
// outer parens even though it sits inside a binop.
let call = Term::App {
callee: Box::new(ivar("f")),
args: vec![ivar("x")],
tail: false,
};
let t = binop("+", call, Term::Lit { lit: Literal::Int { value: 1 } });
assert_eq!(render_term(&t), "f(x) + 1");
}
// ---- Polish 3: unary `not` ----
#[test]
fn not_of_var_renders_as_bang_var() {
let t = Term::App {
callee: Box::new(ivar("not")),
args: vec![ivar("x")],
tail: false,
};
assert_eq!(render_term(&t), "!x");
}
#[test]
fn not_of_binop_keeps_parens() {
// `not(a == b)` → `!(a == b)`. The arg's prec is 1; the unary
// floor is PREC_ATOMIC=5; 1 < 5 means wrap.
let inner = binop("==", ivar("a"), ivar("b"));
let t = Term::App {
callee: Box::new(ivar("not")),
args: vec![inner],
tail: false,
};
assert_eq!(render_term(&t), "!(a == b)");
}
#[test]
fn not_of_call_does_not_wrap() {
// `not(f(x))` → `!f(x)`. Calls are atomic.
let call = Term::App {
callee: Box::new(ivar("f")),
args: vec![ivar("x")],
tail: false,
};
let t = Term::App {
callee: Box::new(ivar("not")),
args: vec![call],
tail: false,
};
assert_eq!(render_term(&t), "!f(x)");
}
#[test]
fn not_with_tail_flag_keeps_prefix_form() {
// Tail-flagged `not` keeps `tail` visible (mirrors binary-op
// policy).
let t = Term::App {
callee: Box::new(ivar("not")),
args: vec![ivar("x")],
tail: true,
};
assert_eq!(render_term(&t), "tail not(x)");
}
// ---- Polish 4: long doc-string wrap ----
#[test]
fn long_doc_string_wraps_at_eighty_cols() {
// 110-char single line should wrap into multiple `///` lines,
// each ≤ 80 cols, breaking at word boundaries.
let long = "This is a deliberately long documentation string designed to overflow eighty \
columns and exercise the wrapping path."
.to_string();
let td = TypeDef {
name: "Foo".into(),
vars: vec![],
ctors: vec![Ctor { name: "MkFoo".into(), fields: vec![] }],
doc: Some(long),
drop_iterative: false,
};
let mut out = String::new();
write_type_def(&mut out, &td, 0);
let doc_lines: Vec<&str> = out.lines().take_while(|l| l.starts_with("///")).collect();
assert!(doc_lines.len() >= 2, "expected wrap, got:\n{out}");
for line in &doc_lines {
assert!(line.len() <= 80, "line over 80 cols: {line:?} ({} cols)", line.len());
}
// Sanity: re-joining the words should give back the original
// content.
let rejoined: String = doc_lines
.iter()
.map(|l| l.trim_start_matches("///").trim())
.collect::<Vec<_>>()
.join(" ");
assert!(
rejoined.contains("deliberately long") && rejoined.contains("wrapping path."),
"lost content during wrap; rejoined = {rejoined:?}"
);
}
#[test]
fn short_doc_string_stays_on_one_line() {
// Below the 80-col threshold: no wrapping.
let td = TypeDef {
name: "Foo".into(),
vars: vec![],
ctors: vec![Ctor { name: "MkFoo".into(), fields: vec![] }],
doc: Some("Short and snappy.".into()),
drop_iterative: false,
};
let mut out = String::new();
write_type_def(&mut out, &td, 0);
assert!(out.starts_with("/// Short and snappy.\n"), "got:\n{out}");
let doc_count = out.lines().filter(|l| l.starts_with("///")).count();
assert_eq!(doc_count, 1);
}
#[test]
fn doc_string_explicit_newlines_split_first_then_wrap() {
// A two-paragraph doc string: each \n-piece is its own logical
// line, then each piece wraps independently.
let long_first = "First paragraph that is short.";
let long_second = "Second paragraph is intentionally lengthy enough to require wrapping \
at the eighty column boundary.";
let doc = format!("{long_first}\n{long_second}");
let td = TypeDef {
name: "Foo".into(),
vars: vec![],
ctors: vec![Ctor { name: "MkFoo".into(), fields: vec![] }],
doc: Some(doc),
drop_iterative: false,
};
let mut out = String::new();
write_type_def(&mut out, &td, 0);
let doc_lines: Vec<&str> = out.lines().take_while(|l| l.starts_with("///")).collect();
// First line = unwrapped first paragraph; the rest = wrapped
// second paragraph (≥ 2 lines).
assert_eq!(doc_lines[0], "/// First paragraph that is short.");
assert!(doc_lines.len() >= 3, "expected at least 3 doc lines, got:\n{out}");
}
// ---- Polish 5: nested match across lines ----
#[test]
fn nested_match_in_arm_body_renders_multi_line() {
// The outer arm's body is itself a `Match`. The inner match
// must lay out across multiple lines, with the inner `}` at
// its own indent — not be collapsed onto the outer arm's line.
let inner = Term::Match {
scrutinee: Box::new(ivar("a")),
arms: vec![
Arm {
pat: Pattern::Ctor {
ctor: "MkCell".into(),
fields: vec![Pattern::Var { name: "w1".into() }, Pattern::Wild],
},
body: Term::Lit { lit: Literal::Int { value: 1 } },
},
],
};
let outer = Term::Match {
scrutinee: Box::new(ivar("p")),
arms: vec![
Arm {
pat: Pattern::Ctor {
ctor: "MkPair".into(),
fields: vec![Pattern::Var { name: "a".into() }, Pattern::Var { name: "b".into() }],
},
body: inner,
},
],
};
assert_eq!(
render_term(&outer),
"match p {\n \
MkPair(a, b) => match a {\n \
MkCell(w1, _) => 1\n \
}\n\
}"
);
}
#[test]
fn deeply_nested_match_keeps_each_level_at_its_own_indent() {
// Three-deep nesting: each level lands one indent step deeper.
// This is the case the spec calls out — earlier renderers might
// have flattened the inner-inner onto a single line.
let innermost = Term::Match {
scrutinee: Box::new(ivar("c")),
arms: vec![Arm {
pat: Pattern::Var { name: "x".into() },
body: ivar("x"),
}],
};
let mid = Term::Match {
scrutinee: Box::new(ivar("b")),
arms: vec![Arm {
pat: Pattern::Var { name: "c".into() },
body: innermost,
}],
};
let outer = Term::Match {
scrutinee: Box::new(ivar("a")),
arms: vec![Arm {
pat: Pattern::Var { name: "b".into() },
body: mid,
}],
};
let rendered = render_term(&outer);
// Innermost arm line lands at 6 spaces of indent (3 levels × 2).
assert!(
rendered.contains("\n x => x\n"),
"innermost arm not at expected 6-space indent, got:\n{rendered}"
);
// Innermost `}` lands at 4 spaces.
assert!(
rendered.contains("\n }\n"),
"innermost close brace not at 4-space indent, got:\n{rendered}"
);
// Mid `}` lands at 2 spaces, outer `}` at 0.
assert!(
rendered.contains("\n }\n"),
"mid close brace not at 2-space indent, got:\n{rendered}"
);
assert!(rendered.ends_with("\n}"), "outer close brace at col 0 expected, got:\n{rendered}");
}
}
+9
View File
@@ -62,3 +62,12 @@ fn snapshot_rc_match_arm_partial_drop_leak() {
fn snapshot_rc_app_let_partial_drop_leak() {
check_snapshot("rc_app_let_partial_drop_leak");
}
/// Iter 20b: this fixture exercises the infix renderer + paren elision.
/// Pre-20b, the body had `if ==(n, 0) { ... }` and `tail sum_acc(-(n,
/// 1), ICons(-(n, 1), acc))`; after 20b those collapse to `if n == 0`
/// and `n - 1`. If this regresses, the renderer is broken.
#[test]
fn snapshot_bench_list_sum() {
check_snapshot("bench_list_sum");
}