72d2d9c806
Follow-up to a fan-out audit of the whole test suite (742 tests, 21
read-only assessors + synthesis). Two clusters of the audit were
actioned here; the #66 hashing-removal cohort it surfaced is left for
the #66 scope, and the RED-first doc-rot sweep is deferred.
§2 — true duplicates removed (each kept test is a strict superset or
identical fixture+assertions of the deleted one):
- ailang-check param_in_reject_message_names_allowed_set (kept the
_renders_allowed_set_in_ailang_syntax superset)
- ailang-check cross_module_pat_ctor_typedriven_resolves (kept ct2_;
shared cross_module_ws helper retained, 2 other callers)
- ailang-core mq1_qualified_instancedef_class_accepted (kept ct1_)
- ail e2e bool_to_str_emits_true_branch (kept the rc-stats superset)
- ail loop_recur_str adt-leg guard (the standalone heap pin owns it;
dropped the dangling header reference too)
- ailang-check over_strict_mode_silent_when_param_is_borrow (its
assertion is subsumed by explicit_fn_with_no_uses_is_clean; its own
doc claimed a "borrowing body" the Lit body never provided)
§4 — meta-guards that had gone partially blind re-sighted:
- spec_drift::spec_mentions_every_def_kind now pushes Class/Instance
exemplars and asserts their `(class `/`(instance` anchors; the
"wait for 22b.4" exclusion was stale (anchors shipped in e809f45).
- schema_coverage now tracks Term::New (VariantTag + EXPECTED +
visitor insert); the new_*/raw_buf_* fixtures already exercise it,
so the "no fixture emits new" exclusion was stale.
- prose doc_wrap_widow_control_skips_when_combined_too_long was
tautological: its 32/32/42-char words gave combined=75<=80, so
widow control actually fired. Rebuilt the fixture (short head + two
40-char words → combined=81>80) so it hits the real skip branch,
and added the missing "last line stays 1 word" assertion. The
obvious 2-word fixture does NOT work — it skips via the
prev_words<2 branch, not the combined-over-budget branch.
ailang-check 124->121, ailang-core 55->54, e2e 102->101,
loop_recur_str 4->3; spec_drift 8 green, schema_coverage green,
prose lib 63 green.
2451 lines
88 KiB
Rust
2451 lines
88 KiB
Rust
//! 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 `design/contracts/0001-authoring-surface.md` for the prose-surface
|
||
//! invariants this crate must respect.
|
||
//!
|
||
//! # 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::{
|
||
ClassDef, ClassMethod, ConstDef, Ctor, Def, FnDef, Import, InstanceDef, InstanceMethod,
|
||
Literal, Module, NewArg, ParamMode, Pattern, Suppress, 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');
|
||
}
|
||
}
|
||
|
||
// The file's own module name is the owner-qualifier that the
|
||
// prose surface trims when it appears on intra-module
|
||
// `Type::Con.name`. Cross-module qualifiers round-trip verbatim.
|
||
// Threaded through every recursive write_* that can reach a
|
||
// `Type::Con`.
|
||
let owning_module = m.name.as_str();
|
||
|
||
for def in &m.defs {
|
||
out.push('\n');
|
||
write_def(out, def, 0, owning_module);
|
||
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, owning_module: &str) {
|
||
match def {
|
||
Def::Type(td) => write_type_def(out, td, level, owning_module),
|
||
Def::Fn(fd) => write_fn_def(out, fd, level, owning_module),
|
||
Def::Const(cd) => write_const_def(out, cd, level, owning_module),
|
||
Def::Class(c) => write_class_def(out, c, level, owning_module),
|
||
Def::Instance(i) => write_instance_def(out, i, level, owning_module),
|
||
}
|
||
}
|
||
|
||
/// render the [`FnDef::suppress`] list as one
|
||
/// `// @suppress <code>: <reason>` line per entry, indented to
|
||
/// `level`. Multiple entries stack in declaration order; an empty
|
||
/// list produces no output (and therefore no leading blank line).
|
||
///
|
||
/// The render is intentionally lossless. The LLM-reader of the
|
||
/// prose form needs to see *why* an annotation that looks
|
||
/// over-strict is correct on this def; eliding suppressions would
|
||
/// hide that contract metadata.
|
||
fn write_suppress_lines(out: &mut String, suppress: &[Suppress], level: usize) {
|
||
for s in suppress {
|
||
indent(out, level);
|
||
out.push_str("// @suppress ");
|
||
out.push_str(&s.code);
|
||
out.push_str(": ");
|
||
out.push_str(&s.because);
|
||
out.push('\n');
|
||
}
|
||
}
|
||
|
||
fn write_doc(out: &mut String, doc: &Option<String>, level: usize) {
|
||
if let Some(d) = doc {
|
||
// 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());
|
||
}
|
||
// Widow control: a 1-word orphan as the last line reads as a
|
||
// typographic blemish (e.g. "...captures outer's param\ni."). If
|
||
// the second-to-last line has ≥2 words and the merge fits in
|
||
// budget, pull its tail word down so the last line has 2 words
|
||
// and the penultimate line gives up one. Greedy fill never
|
||
// produces a line that overflows budget when shortened from the
|
||
// tail, so this never breaks the wrap invariant.
|
||
if out.len() >= 2 {
|
||
let last_word_count = out.last().unwrap().split_whitespace().count();
|
||
if last_word_count == 1 {
|
||
let prev_idx = out.len() - 2;
|
||
let prev_words: Vec<&str> = out[prev_idx].split_whitespace().collect();
|
||
if prev_words.len() >= 2 {
|
||
let pulled = prev_words.last().unwrap();
|
||
let last_line = out.last().unwrap();
|
||
if pulled.len() + 1 + last_line.len() <= budget {
|
||
let pulled_owned = pulled.to_string();
|
||
let prev_new = prev_words[..prev_words.len() - 1].join(" ");
|
||
let last_line_owned = last_line.clone();
|
||
let combined = format!("{pulled_owned} {last_line_owned}");
|
||
out[prev_idx] = prev_new;
|
||
let last_idx = out.len() - 1;
|
||
out[last_idx] = combined;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn write_type_def(out: &mut String, td: &TypeDef, level: usize, owning_module: &str) {
|
||
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, owning_module);
|
||
}
|
||
if td.drop_iterative {
|
||
out.push_str(" with drop-iterative");
|
||
}
|
||
}
|
||
|
||
fn write_ctor(out: &mut String, c: &Ctor, owning_module: &str) {
|
||
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, owning_module);
|
||
}
|
||
out.push(')');
|
||
}
|
||
}
|
||
|
||
fn write_fn_def(out: &mut String, fd: &FnDef, level: usize, owning_module: &str) {
|
||
// render `suppress` entries as `// @suppress <code>: <reason>`
|
||
// comment lines **above** the doc string. They are contract metadata
|
||
// that the LLM-reader needs to see to understand why an annotation
|
||
// that looks over-strict is correct here. One line per entry, in
|
||
// declaration order; never elided.
|
||
write_suppress_lines(out, &fd.suppress, level);
|
||
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, forall_constraints, fn_ty) = match &fd.ty {
|
||
Type::Forall { vars, constraints, body } => {
|
||
(Some(vars.clone()), constraints.clone(), body.as_ref())
|
||
}
|
||
other => (None, Vec::new(), 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('>');
|
||
if !forall_constraints.is_empty() {
|
||
out.push_str(" where ");
|
||
for (i, c) in forall_constraints.iter().enumerate() {
|
||
if i > 0 {
|
||
out.push_str(", ");
|
||
}
|
||
out.push_str(&c.class);
|
||
out.push(' ');
|
||
write_type(out, &c.type_, owning_module);
|
||
}
|
||
}
|
||
out.push('\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::Own);
|
||
write_mode_type(out, ty, mode, owning_module);
|
||
}
|
||
out.push_str(") -> ");
|
||
write_mode_type(out, ret, *ret_mode, owning_module);
|
||
write_effects_clause(out, effects);
|
||
} 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, owning_module);
|
||
}
|
||
out.push_str(" {\n");
|
||
indent(out, level + 1);
|
||
write_term(out, &fd.body, level + 1, owning_module);
|
||
out.push('\n');
|
||
indent(out, level);
|
||
out.push('}');
|
||
}
|
||
|
||
fn write_const_def(out: &mut String, cd: &ConstDef, level: usize, owning_module: &str) {
|
||
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, owning_module);
|
||
out.push_str(" = ");
|
||
write_term(out, &cd.value, level, owning_module);
|
||
}
|
||
|
||
/// Render a class declaration in Rust-flavoured Form-B:
|
||
/// `class Name a [extends Super] { <methods> }`.
|
||
fn write_class_def(out: &mut String, c: &ClassDef, level: usize, owning_module: &str) {
|
||
write_doc(out, &c.doc, level);
|
||
indent(out, level);
|
||
out.push_str("class ");
|
||
out.push_str(&c.name);
|
||
out.push(' ');
|
||
out.push_str(&c.param);
|
||
if let Some(sc) = &c.superclass {
|
||
out.push_str(" extends ");
|
||
out.push_str(&sc.class);
|
||
}
|
||
out.push_str(" {\n");
|
||
for m in &c.methods {
|
||
write_class_method(out, m, level + 1, owning_module);
|
||
}
|
||
indent(out, level);
|
||
out.push('}');
|
||
}
|
||
|
||
/// Render one method line of a class: signature plus optional
|
||
/// `default { <body> }`. Class methods carry no parameter names
|
||
/// in the AST, so slots are named `x`, `x1`, ….
|
||
fn write_class_method(out: &mut String, m: &ClassMethod, level: usize, owning_module: &str) {
|
||
indent(out, level);
|
||
out.push_str("fn ");
|
||
out.push_str(&m.name);
|
||
out.push('(');
|
||
if let Type::Fn { params, param_modes, ret, ret_mode, effects } = &m.ty {
|
||
for (i, p) in params.iter().enumerate() {
|
||
if i > 0 {
|
||
out.push_str(", ");
|
||
}
|
||
// Class methods carry types but no parameter names — name slots
|
||
// as `x`, `x1`, `x2`, … to keep the projection unambiguous
|
||
// without inventing names that could collide with the body.
|
||
if i == 0 {
|
||
out.push('x');
|
||
} else {
|
||
out.push_str(&format!("x{i}"));
|
||
}
|
||
out.push_str(": ");
|
||
let mode = param_modes.get(i).copied().unwrap_or(ParamMode::Own);
|
||
write_mode_type(out, p, mode, owning_module);
|
||
}
|
||
out.push_str(") -> ");
|
||
write_mode_type(out, ret, *ret_mode, owning_module);
|
||
write_effects_clause(out, effects);
|
||
} else {
|
||
// Defensive fallback: a non-Fn class-method type. Render the
|
||
// bare type so nothing crashes; this path is unreachable for
|
||
// typechecked input.
|
||
out.push_str(") -> ");
|
||
write_type(out, &m.ty, owning_module);
|
||
}
|
||
if let Some(body) = &m.default {
|
||
out.push_str(" default {\n");
|
||
indent(out, level + 1);
|
||
write_term(out, body, level + 1, owning_module);
|
||
out.push('\n');
|
||
indent(out, level);
|
||
out.push_str("}\n");
|
||
} else {
|
||
out.push('\n');
|
||
}
|
||
}
|
||
|
||
/// Render an instance declaration in Form-B:
|
||
/// `instance Class Type { <method bodies> }`.
|
||
fn write_instance_def(out: &mut String, i: &InstanceDef, level: usize, owning_module: &str) {
|
||
write_doc(out, &i.doc, level);
|
||
indent(out, level);
|
||
out.push_str("instance ");
|
||
out.push_str(&i.class);
|
||
out.push(' ');
|
||
write_type(out, &i.type_, owning_module);
|
||
out.push_str(" {\n");
|
||
for m in &i.methods {
|
||
write_instance_method(out, m, level + 1, owning_module);
|
||
}
|
||
indent(out, level);
|
||
out.push('}');
|
||
}
|
||
|
||
/// Render one method body of an instance: `fn name { <body> }`.
|
||
/// The signature is recoverable from the class declaration via
|
||
/// `InstanceDef.class` lookup, so it is intentionally elided.
|
||
fn write_instance_method(out: &mut String, m: &InstanceMethod, level: usize, owning_module: &str) {
|
||
indent(out, level);
|
||
out.push_str("fn ");
|
||
out.push_str(&m.name);
|
||
out.push_str(" {\n");
|
||
indent(out, level + 1);
|
||
write_term(out, &m.body, level + 1, owning_module);
|
||
out.push('\n');
|
||
indent(out, level);
|
||
out.push_str("}\n");
|
||
}
|
||
|
||
// ---- types ----------------------------------------------------------------
|
||
|
||
/// Render the trailing effects clause of a fn / method / lambda signature.
|
||
///
|
||
/// Emits nothing for an empty effect set; otherwise ` with ` followed by
|
||
/// the comma-separated effect names in AST order. Effects are
|
||
/// semantically a set, but re-sorting would erase author-asserted
|
||
/// ordering, so the AST order is preserved verbatim.
|
||
fn write_effects_clause(out: &mut String, effects: &[String]) {
|
||
if effects.is_empty() {
|
||
return;
|
||
}
|
||
out.push_str(" with ");
|
||
for (i, e) in effects.iter().enumerate() {
|
||
if i > 0 {
|
||
out.push_str(", ");
|
||
}
|
||
out.push_str(e);
|
||
}
|
||
}
|
||
|
||
fn write_mode_type(out: &mut String, t: &Type, mode: ParamMode, owning_module: &str) {
|
||
match mode {
|
||
ParamMode::Own => {
|
||
out.push_str("own ");
|
||
write_type(out, t, owning_module);
|
||
}
|
||
ParamMode::Borrow => {
|
||
out.push_str("borrow ");
|
||
write_type(out, t, owning_module);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn write_type(out: &mut String, t: &Type, owning_module: &str) {
|
||
match t {
|
||
Type::Var { name } => out.push_str(name),
|
||
Type::Con { name, args } => {
|
||
// Trim a qualifier whose owner matches the current file's
|
||
// module: `prelude.Ordering` printed from inside `prelude`
|
||
// becomes bare `Ordering`. Cross-module qualifiers survive
|
||
// verbatim. Mirrors the canonical-form rule "bare = local,
|
||
// qualified = cross-module" on the prose surface.
|
||
let display_name = match name.split_once('.') {
|
||
Some((owner, suffix)) if owner == owning_module => suffix,
|
||
_ => name.as_str(),
|
||
};
|
||
out.push_str(display_name);
|
||
if !args.is_empty() {
|
||
out.push('<');
|
||
for (i, a) in args.iter().enumerate() {
|
||
if i > 0 {
|
||
out.push_str(", ");
|
||
}
|
||
write_type(out, a, owning_module);
|
||
}
|
||
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::Own);
|
||
// 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, owning_module);
|
||
}
|
||
out.push_str(") -> ");
|
||
write_mode_type(out, ret, *ret_mode, owning_module);
|
||
write_effects_clause(out, effects);
|
||
}
|
||
Type::Forall { vars, constraints: _, 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, owning_module);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- terms ----------------------------------------------------------------
|
||
|
||
// Precedence ladder for paren elision. 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). After iter operator-routing-eq-ord.1 the only
|
||
// infix-rendered ops are the arithmetic builtins; comparison /
|
||
// equality go through the class-method `eq` / `compare` / `lt` /
|
||
// etc. and render as fn calls.
|
||
const PREC_NONE: u8 = 0;
|
||
const PREC_ADD: u8 = 1; // + - (left-assoc)
|
||
const PREC_MUL: u8 = 2; // * / % (left-assoc)
|
||
const PREC_ATOMIC: u8 = 3;
|
||
|
||
/// Precedence + associativity of a canonical binary op name. Returns
|
||
/// `None` for any non-canonical name. Two-tuple: (level, left_assoc).
|
||
/// After iter operator-routing-eq-ord.1, only the five arithmetic
|
||
/// ops `+` / `-` / `*` / `/` / `%` are infix-rendered; the six
|
||
/// comparator names (`==` / `!=` / `<` / `<=` / `>` / `>=`) are no
|
||
/// longer surface identifiers, so their AST forms (only reachable
|
||
/// via error paths) fall through to the standard fn-call rendering.
|
||
fn binop_info(name: &str) -> Option<(u8, bool)> {
|
||
Some(match name {
|
||
"*" | "/" | "%" => (PREC_MUL, true),
|
||
"+" | "-" => (PREC_ADD, true),
|
||
_ => 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, owning_module: &str) {
|
||
write_term_prec(out, t, level, PREC_NONE, owning_module);
|
||
}
|
||
|
||
fn write_term_prec(out: &mut String, t: &Term, level: usize, parent_prec: u8, owning_module: &str) {
|
||
// 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, owning_module);
|
||
out.push(' ');
|
||
out.push_str(op);
|
||
out.push(' ');
|
||
write_term_prec(out, rhs, level, rhs_floor, owning_module);
|
||
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, owning_module);
|
||
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),
|
||
Term::App { callee, args, tail } => {
|
||
if *tail {
|
||
out.push_str("tail ");
|
||
}
|
||
// 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, owning_module);
|
||
out.push('(');
|
||
for (i, a) in args.iter().enumerate() {
|
||
if i > 0 {
|
||
out.push_str(", ");
|
||
}
|
||
// 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, owning_module);
|
||
}
|
||
out.push(')');
|
||
}
|
||
Term::Let { name, value, body } => {
|
||
// Inline `let x = rhs;` when (a) rhs is not a `Term::Do`
|
||
// (keep effect sequencing visible), (b) `x` is used
|
||
// exactly once in body (no duplication of work or shape),
|
||
// and (c) the rhs renders on a single line (small enough
|
||
// to read at the use-site). The result is a lossy
|
||
// projection — the round-trip mediator (`ail merge-prose`)
|
||
// sees the original .ail.json and can re-introduce a let
|
||
// when the mode model demands it. For read-time, eliding
|
||
// the trivial binding is a clear win.
|
||
// RHS budget for inlining. Keeps the use-site readable: a
|
||
// 50-char Cons tower inlined into `f(g(h(_)))` blows the
|
||
// line out far past the surrounding lines. The threshold
|
||
// is tuned so trivial bindings (`let p = build_pair(1)`)
|
||
// collapse, while heavy literals (a 5-deep Cons) keep
|
||
// their own line.
|
||
const RHS_INLINE_BUDGET: usize = 40;
|
||
let inlinable = !matches!(value.as_ref(), Term::Do { .. })
|
||
&& count_free_var(name, body) == 1
|
||
&& {
|
||
let mut buf = String::new();
|
||
write_term(&mut buf, value, level, owning_module);
|
||
!buf.contains('\n') && buf.len() <= RHS_INLINE_BUDGET
|
||
};
|
||
if inlinable {
|
||
let inlined = subst_var_with_term(body, name, value);
|
||
write_term_prec(out, &inlined, level, parent_prec, owning_module);
|
||
} else {
|
||
out.push_str("let ");
|
||
out.push_str(name);
|
||
out.push_str(" = ");
|
||
write_term(out, value, level, owning_module);
|
||
out.push_str(";\n");
|
||
indent(out, level);
|
||
write_term(out, body, level, owning_module);
|
||
}
|
||
}
|
||
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::Own);
|
||
write_mode_type(out, pty, mode, owning_module);
|
||
}
|
||
out.push_str(") -> ");
|
||
write_mode_type(out, ret, *ret_mode, owning_module);
|
||
write_effects_clause(out, effects);
|
||
} else {
|
||
out.push_str(") -> ");
|
||
write_type(out, ty, owning_module);
|
||
}
|
||
out.push_str(" {\n");
|
||
indent(out, level + 1);
|
||
write_term(out, body, level + 1, owning_module);
|
||
out.push('\n');
|
||
indent(out, level);
|
||
out.push_str("};\n");
|
||
indent(out, level);
|
||
write_term(out, in_term, level, owning_module);
|
||
}
|
||
Term::If { cond, then, else_ } => {
|
||
out.push_str("if ");
|
||
write_term(out, cond, level, owning_module);
|
||
out.push_str(" {\n");
|
||
indent(out, level + 1);
|
||
write_term(out, then, level + 1, owning_module);
|
||
out.push('\n');
|
||
indent(out, level);
|
||
out.push_str("} else {\n");
|
||
indent(out, level + 1);
|
||
write_term(out, else_, level + 1, owning_module);
|
||
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, owning_module);
|
||
}
|
||
out.push(')');
|
||
}
|
||
Term::Ctor {
|
||
type_name: _,
|
||
ctor,
|
||
args,
|
||
} => {
|
||
// The Ctor `type_name` field is intentionally elided from
|
||
// the prose surface — the type is recoverable from
|
||
// typecheck context, and showing it on every Ctor would
|
||
// clutter the read. The qualifier-trim that applies to
|
||
// `Type::Con.name` therefore has no analogue here: there
|
||
// is no `out.push_str(type_name)` site to trim.
|
||
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, owning_module);
|
||
}
|
||
out.push(')');
|
||
}
|
||
}
|
||
Term::Match { scrutinee, arms } => {
|
||
out.push_str("match ");
|
||
write_term(out, scrutinee, level, owning_module);
|
||
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, owning_module);
|
||
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, owning_module);
|
||
}
|
||
out.push_str("| -> ");
|
||
write_type(out, ret_ty, owning_module);
|
||
write_effects_clause(out, effects);
|
||
out.push_str(" {\n");
|
||
indent(out, level + 1);
|
||
write_term(out, body, level + 1, owning_module);
|
||
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, owning_module);
|
||
out.push_str(";\n");
|
||
indent(out, level);
|
||
write_term(out, rhs, level, owning_module);
|
||
}
|
||
Term::Clone { value } => {
|
||
out.push_str("clone(");
|
||
write_term(out, value, level, owning_module);
|
||
out.push(')');
|
||
}
|
||
Term::ReuseAs { source, body } => {
|
||
// Render as `reuse <source> as <body>`. The split keyword
|
||
// reads as English subject-verb-object — the source is the
|
||
// allocation being reclaimed, the body is what it becomes.
|
||
// Braces only fire when the body's own rendering would span
|
||
// multiple lines (Let, Seq, nested Match, multi-line If);
|
||
// single-expression bodies (the 99% Ctor case) stay inline,
|
||
// collapsing the whole match arm to one line.
|
||
out.push_str("reuse ");
|
||
write_term(out, source, level, owning_module);
|
||
out.push_str(" as ");
|
||
let mut body_buf = String::new();
|
||
write_term(&mut body_buf, body, level + 1, owning_module);
|
||
if body_buf.contains('\n') {
|
||
out.push_str("{\n");
|
||
indent(out, level + 1);
|
||
out.push_str(&body_buf);
|
||
out.push('\n');
|
||
indent(out, level);
|
||
out.push('}');
|
||
} else {
|
||
out.push_str(&body_buf);
|
||
}
|
||
}
|
||
Term::Loop { binders, body } => {
|
||
out.push_str("loop(");
|
||
for (i, b) in binders.iter().enumerate() {
|
||
if i > 0 {
|
||
out.push_str(", ");
|
||
}
|
||
out.push_str(&b.name);
|
||
out.push_str(" = ");
|
||
write_term(out, &b.init, level, owning_module);
|
||
}
|
||
out.push_str(") {\n");
|
||
indent(out, level + 1);
|
||
write_term(out, body, level + 1, owning_module);
|
||
out.push('\n');
|
||
indent(out, level);
|
||
out.push('}');
|
||
}
|
||
Term::Recur { args } => {
|
||
out.push_str("recur(");
|
||
for (i, a) in args.iter().enumerate() {
|
||
if i > 0 {
|
||
out.push_str(", ");
|
||
}
|
||
write_term(out, a, level, owning_module);
|
||
}
|
||
out.push(')');
|
||
}
|
||
Term::New { type_name, args } => {
|
||
// prep.2 (kernel-extension-mechanics): functional construction
|
||
// `new T(args...)`. Form-B render is positional and uses
|
||
// square brackets for Type-positional args, parens for
|
||
// Value-positional args, mirroring the Form-A `(con T)` vs
|
||
// bare-term distinction at re-parse time.
|
||
out.push_str("new ");
|
||
out.push_str(type_name);
|
||
out.push('(');
|
||
for (i, arg) in args.iter().enumerate() {
|
||
if i > 0 {
|
||
out.push_str(", ");
|
||
}
|
||
match arg {
|
||
NewArg::Type(t) => write_type(out, t, owning_module),
|
||
NewArg::Value(v) => write_term(out, v, level, owning_module),
|
||
}
|
||
}
|
||
out.push(')');
|
||
}
|
||
Term::Intrinsic => out.push_str("compiler-supplied (intrinsic)"),
|
||
}
|
||
}
|
||
|
||
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(')');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Floats milestone iter 5: render a Float literal's bit pattern
|
||
/// as surface-style decimal text. Finite values use Grisu3
|
||
/// (`f64::to_string`) with a `.0` suffix when neither `.` nor
|
||
/// `e`/`E` appears, parallel to
|
||
/// `crates/ailang-surface/src/print.rs::write_float_lit`. Non-finite
|
||
/// values render as `NaN` / `+Inf` / `-Inf` — these are valid in
|
||
/// Form-A (the canonical bytes carry the bit pattern) but cannot
|
||
/// be expressed in surface lex; prose is one-way render, so naming
|
||
/// them after the Mainstream-language spellings is more useful
|
||
/// than a panic or a `(float-bits hex)` placeholder.
|
||
fn write_float_lit(out: &mut String, bits: u64) {
|
||
let f = f64::from_bits(bits);
|
||
if f.is_nan() {
|
||
out.push_str("NaN");
|
||
return;
|
||
}
|
||
if f == f64::INFINITY {
|
||
out.push_str("+Inf");
|
||
return;
|
||
}
|
||
if f == f64::NEG_INFINITY {
|
||
out.push_str("-Inf");
|
||
return;
|
||
}
|
||
let s = f.to_string();
|
||
out.push_str(&s);
|
||
if !s.contains('.') && !s.contains('e') && !s.contains('E') {
|
||
out.push_str(".0");
|
||
}
|
||
}
|
||
|
||
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("()"),
|
||
Literal::Float { bits } => write_float_lit(out, *bits),
|
||
}
|
||
}
|
||
|
||
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(" ");
|
||
}
|
||
}
|
||
|
||
// ---- let-inlining helpers (Iter 20b, polish 1) ----------------------------
|
||
//
|
||
// These support the `Term::Let` arm's "inline single-use bindings" path.
|
||
// They are render-only; the AST passed to the renderer is never mutated.
|
||
// Substitution operates on a freshly cloned subtree.
|
||
|
||
/// Collect every name a pattern binds. Used to detect that a pattern arm
|
||
/// shadows the binder we are tracking, so its body must NOT be counted /
|
||
/// substituted.
|
||
fn pattern_binders(p: &Pattern, out: &mut std::collections::BTreeSet<String>) {
|
||
match p {
|
||
Pattern::Wild | Pattern::Lit { .. } => {}
|
||
Pattern::Var { name } => {
|
||
out.insert(name.clone());
|
||
}
|
||
Pattern::Ctor { fields, .. } => {
|
||
for f in fields {
|
||
pattern_binders(f, out);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Count free occurrences of the variable `name` in `t`, respecting
|
||
/// inner shadowing introduced by `let`, `lam`, `match` arms, and
|
||
/// `let rec`.
|
||
fn count_free_var(name: &str, t: &Term) -> usize {
|
||
match t {
|
||
Term::Lit { .. } => 0,
|
||
Term::Var { name: n } => {
|
||
if n == name {
|
||
1
|
||
} else {
|
||
0
|
||
}
|
||
}
|
||
Term::App { callee, args, .. } => {
|
||
count_free_var(name, callee) + args.iter().map(|a| count_free_var(name, a)).sum::<usize>()
|
||
}
|
||
Term::Let { name: n, value, body } => {
|
||
let v = count_free_var(name, value);
|
||
let b = if n == name { 0 } else { count_free_var(name, body) };
|
||
v + b
|
||
}
|
||
Term::If { cond, then, else_ } => {
|
||
count_free_var(name, cond) + count_free_var(name, then) + count_free_var(name, else_)
|
||
}
|
||
Term::Do { args, .. } => args.iter().map(|a| count_free_var(name, a)).sum(),
|
||
Term::Ctor { args, .. } => args.iter().map(|a| count_free_var(name, a)).sum(),
|
||
Term::Match { scrutinee, arms } => {
|
||
let s = count_free_var(name, scrutinee);
|
||
let a: usize = arms
|
||
.iter()
|
||
.map(|arm| {
|
||
let mut binds = std::collections::BTreeSet::new();
|
||
pattern_binders(&arm.pat, &mut binds);
|
||
if binds.contains(name) {
|
||
0
|
||
} else {
|
||
count_free_var(name, &arm.body)
|
||
}
|
||
})
|
||
.sum();
|
||
s + a
|
||
}
|
||
Term::Lam { params, body, .. } => {
|
||
if params.iter().any(|p| p == name) {
|
||
0
|
||
} else {
|
||
count_free_var(name, body)
|
||
}
|
||
}
|
||
Term::Seq { lhs, rhs } => count_free_var(name, lhs) + count_free_var(name, rhs),
|
||
Term::LetRec { name: n, params, body, in_term, .. } => {
|
||
// Body is in the recursive scope of `n` and the params.
|
||
let body_shadowed = n == name || params.iter().any(|p| p == name);
|
||
let in_shadowed = n == name;
|
||
let bb = if body_shadowed { 0 } else { count_free_var(name, body) };
|
||
let ib = if in_shadowed { 0 } else { count_free_var(name, in_term) };
|
||
bb + ib
|
||
}
|
||
Term::Clone { value } => count_free_var(name, value),
|
||
Term::ReuseAs { source, body } => count_free_var(name, source) + count_free_var(name, body),
|
||
// loop-recur iter 1: a binder named `name` shadows the outer
|
||
// binding for later binder inits and the body. Inits before
|
||
// the shadowing binder still see the outer `name`.
|
||
Term::Loop { binders, body } => {
|
||
let mut total = 0usize;
|
||
let mut shadowed = false;
|
||
for b in binders {
|
||
if !shadowed {
|
||
total += count_free_var(name, &b.init);
|
||
}
|
||
if b.name == name {
|
||
shadowed = true;
|
||
}
|
||
}
|
||
if !shadowed {
|
||
total += count_free_var(name, body);
|
||
}
|
||
total
|
||
}
|
||
Term::Recur { args } => {
|
||
args.iter().map(|a| count_free_var(name, a)).sum()
|
||
}
|
||
Term::New { args, .. } => args
|
||
.iter()
|
||
.map(|arg| match arg {
|
||
NewArg::Value(v) => count_free_var(name, v),
|
||
NewArg::Type(_) => 0,
|
||
})
|
||
.sum(),
|
||
Term::Intrinsic => 0,
|
||
}
|
||
}
|
||
|
||
/// Substitute every free `Var{name}` occurrence in `t` with a clone of
|
||
/// `replacement`. Respects inner shadowing the same way `count_free_var`
|
||
/// does. Used only inside the renderer; never reaches the AST consumer.
|
||
fn subst_var_with_term(t: &Term, name: &str, replacement: &Term) -> Term {
|
||
match t {
|
||
Term::Lit { .. } => t.clone(),
|
||
Term::Var { name: n } => {
|
||
if n == name {
|
||
replacement.clone()
|
||
} else {
|
||
t.clone()
|
||
}
|
||
}
|
||
Term::App { callee, args, tail } => Term::App {
|
||
callee: Box::new(subst_var_with_term(callee, name, replacement)),
|
||
args: args.iter().map(|a| subst_var_with_term(a, name, replacement)).collect(),
|
||
tail: *tail,
|
||
},
|
||
Term::Let { name: n, value, body } => {
|
||
let v = subst_var_with_term(value, name, replacement);
|
||
let b = if n == name {
|
||
(**body).clone()
|
||
} else {
|
||
subst_var_with_term(body, name, replacement)
|
||
};
|
||
Term::Let {
|
||
name: n.clone(),
|
||
value: Box::new(v),
|
||
body: Box::new(b),
|
||
}
|
||
}
|
||
Term::If { cond, then, else_ } => Term::If {
|
||
cond: Box::new(subst_var_with_term(cond, name, replacement)),
|
||
then: Box::new(subst_var_with_term(then, name, replacement)),
|
||
else_: Box::new(subst_var_with_term(else_, name, replacement)),
|
||
},
|
||
Term::Do { op, args, tail } => Term::Do {
|
||
op: op.clone(),
|
||
args: args.iter().map(|a| subst_var_with_term(a, name, replacement)).collect(),
|
||
tail: *tail,
|
||
},
|
||
Term::Ctor { type_name, ctor, args } => Term::Ctor {
|
||
type_name: type_name.clone(),
|
||
ctor: ctor.clone(),
|
||
args: args.iter().map(|a| subst_var_with_term(a, name, replacement)).collect(),
|
||
},
|
||
Term::Match { scrutinee, arms } => Term::Match {
|
||
scrutinee: Box::new(subst_var_with_term(scrutinee, name, replacement)),
|
||
arms: arms
|
||
.iter()
|
||
.map(|arm| {
|
||
let mut binds = std::collections::BTreeSet::new();
|
||
pattern_binders(&arm.pat, &mut binds);
|
||
let body = if binds.contains(name) {
|
||
arm.body.clone()
|
||
} else {
|
||
subst_var_with_term(&arm.body, name, replacement)
|
||
};
|
||
ailang_core::ast::Arm { pat: arm.pat.clone(), body }
|
||
})
|
||
.collect(),
|
||
},
|
||
Term::Lam { params, param_tys, ret_ty, effects, body } => {
|
||
let body = if params.iter().any(|p| p == name) {
|
||
(**body).clone()
|
||
} else {
|
||
subst_var_with_term(body, name, replacement)
|
||
};
|
||
Term::Lam {
|
||
params: params.clone(),
|
||
param_tys: param_tys.clone(),
|
||
ret_ty: ret_ty.clone(),
|
||
effects: effects.clone(),
|
||
body: Box::new(body),
|
||
}
|
||
}
|
||
Term::Seq { lhs, rhs } => Term::Seq {
|
||
lhs: Box::new(subst_var_with_term(lhs, name, replacement)),
|
||
rhs: Box::new(subst_var_with_term(rhs, name, replacement)),
|
||
},
|
||
Term::LetRec { name: n, ty, params, body, in_term } => {
|
||
let body_shadowed = n == name || params.iter().any(|p| p == name);
|
||
let in_shadowed = n == name;
|
||
let body_rw = if body_shadowed {
|
||
(**body).clone()
|
||
} else {
|
||
subst_var_with_term(body, name, replacement)
|
||
};
|
||
let in_rw = if in_shadowed {
|
||
(**in_term).clone()
|
||
} else {
|
||
subst_var_with_term(in_term, name, replacement)
|
||
};
|
||
Term::LetRec {
|
||
name: n.clone(),
|
||
ty: ty.clone(),
|
||
params: params.clone(),
|
||
body: Box::new(body_rw),
|
||
in_term: Box::new(in_rw),
|
||
}
|
||
}
|
||
Term::Clone { value } => Term::Clone {
|
||
value: Box::new(subst_var_with_term(value, name, replacement)),
|
||
},
|
||
Term::ReuseAs { source, body } => Term::ReuseAs {
|
||
source: Box::new(subst_var_with_term(source, name, replacement)),
|
||
body: Box::new(subst_var_with_term(body, name, replacement)),
|
||
},
|
||
// loop-recur iter 1: lexical-shadow semantics — once a binder
|
||
// named `name` is declared, later inits and the body are not
|
||
// rewritten.
|
||
Term::Loop { binders, body } => {
|
||
let mut shadowed = false;
|
||
let new_binders: Vec<ailang_core::ast::LoopBinder> = binders
|
||
.iter()
|
||
.map(|b| {
|
||
let init = if shadowed {
|
||
b.init.clone()
|
||
} else {
|
||
subst_var_with_term(&b.init, name, replacement)
|
||
};
|
||
if b.name == name {
|
||
shadowed = true;
|
||
}
|
||
ailang_core::ast::LoopBinder {
|
||
name: b.name.clone(),
|
||
ty: b.ty.clone(),
|
||
init,
|
||
}
|
||
})
|
||
.collect();
|
||
let body_rw = if shadowed {
|
||
(**body).clone()
|
||
} else {
|
||
subst_var_with_term(body, name, replacement)
|
||
};
|
||
Term::Loop {
|
||
binders: new_binders,
|
||
body: Box::new(body_rw),
|
||
}
|
||
}
|
||
Term::Recur { args } => Term::Recur {
|
||
args: args
|
||
.iter()
|
||
.map(|a| subst_var_with_term(a, name, replacement))
|
||
.collect(),
|
||
},
|
||
Term::New { type_name, args } => Term::New {
|
||
type_name: type_name.clone(),
|
||
args: args
|
||
.iter()
|
||
.map(|arg| match arg {
|
||
NewArg::Value(v) => NewArg::Value(subst_var_with_term(v, name, replacement)),
|
||
NewArg::Type(t) => NewArg::Type(t.clone()),
|
||
})
|
||
.collect(),
|
||
},
|
||
Term::Intrinsic => t.clone(),
|
||
}
|
||
}
|
||
|
||
// ---- unit tests -----------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use ailang_core::ast::{Arm, Ctor, FnDef, Literal, Pattern, Term, Type};
|
||
use std::collections::BTreeMap;
|
||
|
||
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 }), "()");
|
||
}
|
||
|
||
/// Floats milestone iter 5.1: prose renders Float literals as
|
||
/// surface-style decimal text. Finite values use shortest
|
||
/// round-trippable decimal with `.0` suffix when neither `.` nor
|
||
/// `e`/`E` is in the rendered string (parallel to surface print).
|
||
/// Non-finite (NaN, ±Inf) render as the Mainstream-language
|
||
/// spellings — `NaN`, `+Inf`, `-Inf` — because prose is a one-way
|
||
/// render and the surface lex grammar's no-NaN/Inf restriction
|
||
/// does not apply to it.
|
||
#[test]
|
||
fn renders_float_literal_finite() {
|
||
use ailang_core::ast::*;
|
||
let t = Term::Lit { lit: Literal::Float { bits: 0x3ff8_0000_0000_0000u64 } };
|
||
let s = render_term(&t);
|
||
assert_eq!(s, "1.5");
|
||
|
||
let t = Term::Lit { lit: Literal::Float { bits: 0x4024_0000_0000_0000u64 } };
|
||
let s = render_term(&t);
|
||
assert_eq!(s, "10.0");
|
||
}
|
||
|
||
#[test]
|
||
fn renders_float_literal_signed_zero() {
|
||
use ailang_core::ast::*;
|
||
let pos = Term::Lit { lit: Literal::Float { bits: 0x0u64 } };
|
||
assert_eq!(render_term(&pos), "0.0");
|
||
let neg = Term::Lit { lit: Literal::Float { bits: 0x8000_0000_0000_0000u64 } };
|
||
assert_eq!(render_term(&neg), "-0.0");
|
||
}
|
||
|
||
#[test]
|
||
fn renders_float_literal_non_finite() {
|
||
use ailang_core::ast::*;
|
||
let nan = Term::Lit { lit: Literal::Float { bits: 0x7ff8_0000_0000_0000u64 } };
|
||
assert_eq!(render_term(&nan), "NaN");
|
||
let pos_inf = Term::Lit { lit: Literal::Float { bits: 0x7ff0_0000_0000_0000u64 } };
|
||
assert_eq!(render_term(&pos_inf), "+Inf");
|
||
let neg_inf = Term::Lit { lit: Literal::Float { bits: 0xfff0_0000_0000_0000u64 } };
|
||
assert_eq!(render_term(&neg_inf), "-Inf");
|
||
}
|
||
|
||
#[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_with_single_use_inlines_rhs() {
|
||
// `let x = 1; x` — single use of x, rhs is a literal, so the
|
||
// binding is elided and the body renders the rhs at the
|
||
// use-site.
|
||
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), "1");
|
||
}
|
||
|
||
#[test]
|
||
fn let_with_two_uses_keeps_binding() {
|
||
// `let x = 1; x + x` — two uses of x. Inlining would duplicate
|
||
// work, so we keep the let.
|
||
let t = Term::Let {
|
||
name: "x".into(),
|
||
value: Box::new(Term::Lit { lit: Literal::Int { value: 1 } }),
|
||
body: Box::new(Term::App {
|
||
callee: Box::new(Term::Var { name: "+".into() }),
|
||
args: vec![
|
||
Term::Var { name: "x".into() },
|
||
Term::Var { name: "x".into() },
|
||
],
|
||
tail: false,
|
||
}),
|
||
};
|
||
assert_eq!(render_term(&t), "let x = 1;\nx + x");
|
||
}
|
||
|
||
#[test]
|
||
fn let_with_zero_uses_keeps_binding() {
|
||
// `let x = 1; 42` — x is unused. We keep the let; eliding it
|
||
// would silently drop a binding the AST author chose to write.
|
||
let t = Term::Let {
|
||
name: "x".into(),
|
||
value: Box::new(Term::Lit { lit: Literal::Int { value: 1 } }),
|
||
body: Box::new(Term::Lit { lit: Literal::Int { value: 42 } }),
|
||
};
|
||
assert_eq!(render_term(&t), "let x = 1;\n42");
|
||
}
|
||
|
||
#[test]
|
||
fn let_with_do_rhs_keeps_binding() {
|
||
// `let r = do io/get(); use(r)` — rhs is a Do, which carries
|
||
// an effect. We keep effect sequencing visible; do not inline
|
||
// even though r is used exactly once.
|
||
let t = Term::Let {
|
||
name: "r".into(),
|
||
value: Box::new(Term::Do {
|
||
op: "io/get".into(),
|
||
args: vec![],
|
||
tail: false,
|
||
}),
|
||
body: Box::new(Term::App {
|
||
callee: Box::new(Term::Var { name: "use".into() }),
|
||
args: vec![Term::Var { name: "r".into() }],
|
||
tail: false,
|
||
}),
|
||
};
|
||
assert_eq!(render_term(&t), "let r = do io/get();\nuse(r)");
|
||
}
|
||
|
||
#[test]
|
||
fn let_with_multiline_rhs_keeps_binding() {
|
||
// `let x = (a; b); x` — single use, but the rhs renders as
|
||
// two lines (a Seq), so inlining would smear a multiline
|
||
// expression into the use-site. Keep the let.
|
||
let t = Term::Let {
|
||
name: "x".into(),
|
||
value: Box::new(Term::Seq {
|
||
lhs: Box::new(Term::Var { name: "a".into() }),
|
||
rhs: Box::new(Term::Var { name: "b".into() }),
|
||
}),
|
||
body: Box::new(Term::Var { name: "x".into() }),
|
||
};
|
||
// Outer rendering keeps the let. The rhs is a Seq, which
|
||
// semicolon-separates a and b across lines.
|
||
let rendered = render_term(&t);
|
||
assert!(rendered.starts_with("let x ="), "got: {rendered}");
|
||
assert!(rendered.ends_with("x"), "got: {rendered}");
|
||
}
|
||
|
||
#[test]
|
||
fn let_with_long_rhs_keeps_binding_even_with_single_use() {
|
||
// `let xs = <50-char Cons tower>; head(xs)` — single use,
|
||
// single-line render, but the rhs exceeds the inline budget.
|
||
// We keep the let: inlining a long literal smears the
|
||
// surrounding line.
|
||
// Build a 5-level Cons.
|
||
let mk_cons = |h: i64, t: Term| -> Term {
|
||
Term::Ctor {
|
||
type_name: "L".into(),
|
||
ctor: "Cons".into(),
|
||
args: vec![Term::Lit { lit: Literal::Int { value: h } }, t],
|
||
}
|
||
};
|
||
let nil = Term::Ctor {
|
||
type_name: "L".into(),
|
||
ctor: "Nil".into(),
|
||
args: vec![],
|
||
};
|
||
let big = mk_cons(11, mk_cons(22, mk_cons(33, mk_cons(44, mk_cons(55, nil)))));
|
||
let t = Term::Let {
|
||
name: "xs".into(),
|
||
value: Box::new(big),
|
||
body: Box::new(Term::App {
|
||
callee: Box::new(Term::Var { name: "head".into() }),
|
||
args: vec![Term::Var { name: "xs".into() }],
|
||
tail: false,
|
||
}),
|
||
};
|
||
let rendered = render_term(&t);
|
||
assert!(rendered.starts_with("let xs ="), "got: {rendered}");
|
||
assert!(rendered.contains("head(xs)"), "got: {rendered}");
|
||
}
|
||
|
||
#[test]
|
||
fn let_with_shadowing_inner_is_not_counted() {
|
||
// `let x = 7; (let x = 99; x)` — outer x is never freely
|
||
// referenced (the inner `let x` shadows it). Free-occurrence
|
||
// count for the outer name is 0, so we keep the binding.
|
||
let t = Term::Let {
|
||
name: "x".into(),
|
||
value: Box::new(Term::Lit { lit: Literal::Int { value: 7 } }),
|
||
body: Box::new(Term::Let {
|
||
name: "x".into(),
|
||
value: Box::new(Term::Lit { lit: Literal::Int { value: 99 } }),
|
||
body: Box::new(Term::Var { name: "x".into() }),
|
||
}),
|
||
};
|
||
// Outer let is preserved (count == 0). Inner let is itself
|
||
// inlinable (single use), so the body collapses to `99`.
|
||
assert_eq!(render_term(&t), "let x = 7;\n99");
|
||
}
|
||
|
||
#[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_inline_when_body_fits_on_one_line() {
|
||
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 xs as Nil");
|
||
}
|
||
|
||
#[test]
|
||
fn reuse_as_uses_braces_when_body_is_multiline() {
|
||
// A Seq body forces a newline (`a;\nb`), so braces must fire.
|
||
let t = Term::ReuseAs {
|
||
source: Box::new(Term::Var { name: "xs".into() }),
|
||
body: Box::new(Term::Seq {
|
||
lhs: Box::new(Term::Var { name: "a".into() }),
|
||
rhs: Box::new(Term::Var { name: "b".into() }),
|
||
}),
|
||
};
|
||
assert_eq!(render_term(&t), "reuse xs as {\n a;\n b\n}");
|
||
}
|
||
|
||
#[test]
|
||
fn do_renders_with_keyword_and_op() {
|
||
let t = Term::Do {
|
||
op: "io/print_str".into(),
|
||
args: vec![Term::Lit {
|
||
lit: Literal::Str {
|
||
value: "5".into(),
|
||
},
|
||
}],
|
||
tail: false,
|
||
};
|
||
assert_eq!(render_term(&t), "do io/print_str(\"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_owned(vec![Type::int()], Type::int(), vec![]);
|
||
assert_eq!(render_type(&t), "(own Int) -> own Int");
|
||
}
|
||
|
||
#[test]
|
||
fn type_fn_with_effects() {
|
||
let t = Type::fn_owned(vec![], Type::unit(), vec!["IO".into()]);
|
||
assert_eq!(render_type(&t), "() -> own 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()],
|
||
constraints: vec![],
|
||
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::Own,
|
||
effects: vec![],
|
||
},
|
||
params: vec!["xs".into()],
|
||
body: Term::Lit { lit: Literal::Int { value: 0 } },
|
||
suppress: vec![],
|
||
doc: None,
|
||
export: 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(") -> own Int "), "got:\n{out}");
|
||
}
|
||
|
||
#[test]
|
||
fn fn_def_with_effects_appends_with_clause() {
|
||
let fd = FnDef {
|
||
name: "main".into(),
|
||
ty: Type::fn_owned(vec![], Type::unit(), vec!["IO".into()]),
|
||
params: vec![],
|
||
body: Term::Lit { lit: Literal::Unit },
|
||
suppress: vec![],
|
||
doc: None,
|
||
export: None,
|
||
};
|
||
let mut out = String::new();
|
||
write_fn_def(&mut out, &fd, 0, "");
|
||
assert!(out.contains("() -> own Unit with IO {"), "got:\n{out}");
|
||
}
|
||
|
||
/// a FnDef with a single `Suppress` entry renders the
|
||
/// `// @suppress <code>: <reason>` line **above** the doc string,
|
||
/// preserving both the suppression visibility and the doc content.
|
||
/// The suppress line must appear before the `///`-prefixed doc
|
||
/// lines, not after; the contract metadata leads.
|
||
#[test]
|
||
fn fn_def_renders_single_suppress_above_doc() {
|
||
let fd = FnDef {
|
||
name: "head_or_zero".into(),
|
||
ty: Type::Fn {
|
||
params: vec![Type::Con {
|
||
name: "IntList".into(),
|
||
args: vec![],
|
||
}],
|
||
param_modes: vec![ailang_core::ast::ParamMode::Own],
|
||
ret: Box::new(Type::int()),
|
||
ret_mode: ailang_core::ast::ParamMode::Own,
|
||
effects: vec![],
|
||
},
|
||
params: vec!["xs".into()],
|
||
body: Term::Lit { lit: Literal::Int { value: 0 } },
|
||
suppress: vec![ailang_core::ast::Suppress {
|
||
code: "over-strict-mode".into(),
|
||
because: "RC codegen test fixture".into(),
|
||
}],
|
||
doc: Some("Take ownership.".into()),
|
||
export: None,
|
||
};
|
||
let mut out = String::new();
|
||
write_fn_def(&mut out, &fd, 0, "");
|
||
// Suppress line is the FIRST line, before the doc.
|
||
let first_line = out.lines().next().unwrap();
|
||
assert_eq!(
|
||
first_line, "// @suppress over-strict-mode: RC codegen test fixture",
|
||
"first line must be the suppress comment; got:\n{out}"
|
||
);
|
||
// Doc string follows on the next line.
|
||
let lines: Vec<&str> = out.lines().collect();
|
||
assert_eq!(lines[1], "/// Take ownership.", "doc line should follow; got:\n{out}");
|
||
// Fn signature follows the doc.
|
||
assert!(
|
||
out.contains("fn head_or_zero(xs: own IntList) -> own Int"),
|
||
"fn signature should follow; got:\n{out}"
|
||
);
|
||
}
|
||
|
||
/// multiple `Suppress` entries render as one
|
||
/// `// @suppress <code>: <reason>` line each, in declaration
|
||
/// order, all above the doc string.
|
||
#[test]
|
||
fn fn_def_renders_multiple_suppress_in_order() {
|
||
let fd = FnDef {
|
||
name: "f".into(),
|
||
ty: Type::fn_owned(vec![], Type::int(), vec![]),
|
||
params: vec![],
|
||
body: Term::Lit { lit: Literal::Int { value: 0 } },
|
||
suppress: vec![
|
||
ailang_core::ast::Suppress {
|
||
code: "over-strict-mode".into(),
|
||
because: "first reason".into(),
|
||
},
|
||
ailang_core::ast::Suppress {
|
||
code: "some-other-code".into(),
|
||
because: "second reason".into(),
|
||
},
|
||
],
|
||
doc: None,
|
||
export: None,
|
||
};
|
||
let mut out = String::new();
|
||
write_fn_def(&mut out, &fd, 0, "");
|
||
let lines: Vec<&str> = out.lines().collect();
|
||
assert_eq!(lines[0], "// @suppress over-strict-mode: first reason");
|
||
assert_eq!(lines[1], "// @suppress some-other-code: second reason");
|
||
// Order: declaration order is preserved.
|
||
}
|
||
|
||
/// a FnDef with an empty `suppress` Vec emits no
|
||
/// `// @suppress` line at all (and therefore no extra leading
|
||
/// blank line). Pre-19b prose snapshots stay byte-identical when
|
||
/// re-rendered through the 19b code.
|
||
#[test]
|
||
fn fn_def_with_empty_suppress_emits_no_suppress_lines() {
|
||
let fd = FnDef {
|
||
name: "f".into(),
|
||
ty: Type::fn_owned(vec![], Type::int(), vec![]),
|
||
params: vec![],
|
||
body: Term::Lit { lit: Literal::Int { value: 0 } },
|
||
suppress: vec![],
|
||
doc: Some("just a doc".into()),
|
||
export: None,
|
||
};
|
||
let mut out = String::new();
|
||
write_fn_def(&mut out, &fd, 0, "");
|
||
assert!(
|
||
!out.contains("// @suppress"),
|
||
"no @suppress line for empty Vec; got:\n{out}"
|
||
);
|
||
// The doc still leads.
|
||
let first_line = out.lines().next().unwrap();
|
||
assert_eq!(first_line, "/// just a doc");
|
||
}
|
||
|
||
// ---- 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,
|
||
param_in: BTreeMap::new(),
|
||
};
|
||
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,
|
||
param_in: BTreeMap::new(),
|
||
};
|
||
let mut out = String::new();
|
||
write_type_def(&mut out, &td, 0, "");
|
||
assert_eq!(out, "data IntList = Nil | Cons(Int, IntList)");
|
||
}
|
||
|
||
// ======================================================================
|
||
// 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() {
|
||
// 2026-05-21 operator-routing-eq-ord: the six comparator
|
||
// names are no longer surface identifiers; only the five
|
||
// arithmetic ops infix-render.
|
||
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)");
|
||
}
|
||
|
||
// 2026-05-21 operator-routing-eq-ord: removed
|
||
// `non_assoc_same_level_keeps_parens_on_both_sides` —
|
||
// `< < <` chains were the only sub-case where non-assoc
|
||
// precedence mattered; with `<` no longer a surface op, the
|
||
// remaining (arithmetic) infix ops are all left-assoc so the
|
||
// non-assoc path is no longer reachable from any AST a user
|
||
// could produce.
|
||
|
||
#[test]
|
||
fn atomic_subexpr_never_wraps() {
|
||
// A var on either side of an arithmetic op never picks up
|
||
// parens. (Pre-eq-ord this fixture used `==`; with `==` no
|
||
// longer infix-rendered, the same property holds for `+`.)
|
||
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 below the
|
||
// unary floor `PREC_ATOMIC`, so the wrap stays. (Pre-eq-ord
|
||
// this exercised `==`; with `==` no longer infix-rendered,
|
||
// `+` is the parallel case for arithmetic.)
|
||
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,
|
||
param_in: BTreeMap::new(),
|
||
};
|
||
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,
|
||
param_in: BTreeMap::new(),
|
||
};
|
||
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,
|
||
param_in: BTreeMap::new(),
|
||
};
|
||
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}");
|
||
}
|
||
|
||
#[test]
|
||
fn doc_wrap_widow_control_pulls_one_word_back() {
|
||
// A doc string whose greedy wrap leaves a 1-word orphan as
|
||
// the last line should pull the previous line's tail word
|
||
// down so the orphan has at least 2 words. This is exactly
|
||
// the `nested_let_rec` case where "outer's param i." used
|
||
// to wrap to "outer's param" / "i.".
|
||
let doc = "Sum 1 + 2 + ... + n by nested LetRec helpers; inner captures \
|
||
outer's param i.";
|
||
let td = TypeDef {
|
||
name: "Foo".into(),
|
||
vars: vec![],
|
||
ctors: vec![Ctor { name: "MkFoo".into(), fields: vec![] }],
|
||
doc: Some(doc.into()),
|
||
drop_iterative: false,
|
||
param_in: BTreeMap::new(),
|
||
};
|
||
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();
|
||
// The last doc line MUST have ≥ 2 words.
|
||
let last = doc_lines.last().unwrap();
|
||
let last_word_count = last
|
||
.trim_start_matches("///")
|
||
.split_whitespace()
|
||
.count();
|
||
assert!(
|
||
last_word_count >= 2,
|
||
"expected widow control to leave ≥ 2 words on the last line; got {last:?}"
|
||
);
|
||
// No line over the 80-col budget.
|
||
for line in &doc_lines {
|
||
assert!(line.len() <= 80, "line over 80 cols: {line:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn doc_wrap_widow_control_skips_when_combined_too_long() {
|
||
// Widow control pulls the penultimate line's tail word down to
|
||
// join a 1-word orphan — but only when (pulled + " " + orphan)
|
||
// still fits the budget. When it does NOT fit, the budget
|
||
// invariant wins and the orphan stays a 1-word last line.
|
||
//
|
||
// Construct that case at budget 80: a short head word forces the
|
||
// penultimate line to hold two words ("tail" + 40×'b'), and the
|
||
// orphan is a 40×'c' word. Greedy wrap yields
|
||
// ["tail bbb…"(45), "ccc…"(40)]; the merge candidate is
|
||
// pulled(40) + 1 + orphan(40) = 81 > 80, so widow control must
|
||
// skip and the last line keeps its single word.
|
||
let pieces = format!("tail {} {}", "b".repeat(40), "c".repeat(40));
|
||
let lines = wrap_words(&pieces, 80);
|
||
// Sanity: the wrap produced multiple lines.
|
||
assert!(lines.len() >= 2);
|
||
// No line exceeds budget.
|
||
for l in &lines {
|
||
assert!(l.len() <= 80, "line over budget: {l:?}");
|
||
}
|
||
// Widow control did NOT fire: the orphan stays a 1-word last line.
|
||
assert_eq!(
|
||
lines.last().unwrap().split_whitespace().count(),
|
||
1,
|
||
"widow control should have skipped (merge over budget); got {lines:?}"
|
||
);
|
||
}
|
||
|
||
// ---- 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\
|
||
}"
|
||
);
|
||
}
|
||
|
||
/// When a `Type::Con.name` is qualified with the owning
|
||
/// module's own name (e.g. `prelude.Ordering` inside
|
||
/// `prelude`'s own file), the prose printer must trim the
|
||
/// qualifier and emit bare `Ordering`. Cross-module qualified
|
||
/// refs (`prelude.Ordering` inside any non-prelude file)
|
||
/// round-trip verbatim.
|
||
#[test]
|
||
fn type_con_qualifier_trimmed_when_owner_matches_module() {
|
||
use ailang_core::ast::{Module, Def, FnDef, Type, Term, ParamMode};
|
||
|
||
let m = Module {
|
||
schema: "ailang/v0".into(),
|
||
name: "prelude".into(),
|
||
kernel: false,
|
||
imports: vec![],
|
||
defs: vec![Def::Fn(FnDef {
|
||
name: "noop".into(),
|
||
ty: Type::Fn {
|
||
params: vec![],
|
||
ret: Box::new(Type::Con {
|
||
name: "prelude.Ordering".into(),
|
||
args: vec![],
|
||
}),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Own,
|
||
},
|
||
params: vec![],
|
||
body: Term::Ctor {
|
||
type_name: "prelude.Ordering".into(),
|
||
ctor: "EQ".into(),
|
||
args: vec![],
|
||
},
|
||
doc: None,
|
||
suppress: vec![],
|
||
export: None,
|
||
})],
|
||
};
|
||
|
||
let prose = module_to_prose(&m);
|
||
assert!(
|
||
prose.contains("-> own Ordering"),
|
||
"expected bare `Ordering` in fn return type (owner == file's \
|
||
module); got prose:\n{}",
|
||
prose
|
||
);
|
||
assert!(
|
||
!prose.contains("prelude.Ordering"),
|
||
"expected NO `prelude.Ordering` after trim; got prose:\n{}",
|
||
prose
|
||
);
|
||
}
|
||
|
||
/// A cross-module Type::Con (owner != current file's module)
|
||
/// must round-trip verbatim — no trim.
|
||
#[test]
|
||
fn type_con_qualifier_preserved_when_owner_differs() {
|
||
use ailang_core::ast::{Module, Def, FnDef, Type, Term, ParamMode, Import};
|
||
|
||
let m = Module {
|
||
schema: "ailang/v0".into(),
|
||
name: "user".into(),
|
||
kernel: false,
|
||
imports: vec![Import { module: "prelude".into(), alias: None }],
|
||
defs: vec![Def::Fn(FnDef {
|
||
name: "lt".into(),
|
||
ty: Type::Fn {
|
||
params: vec![],
|
||
ret: Box::new(Type::Con {
|
||
name: "prelude.Ordering".into(),
|
||
args: vec![],
|
||
}),
|
||
effects: vec![],
|
||
param_modes: vec![],
|
||
ret_mode: ParamMode::Own,
|
||
},
|
||
params: vec![],
|
||
body: Term::Ctor {
|
||
type_name: "prelude.Ordering".into(),
|
||
ctor: "LT".into(),
|
||
args: vec![],
|
||
},
|
||
doc: None,
|
||
suppress: vec![],
|
||
export: None,
|
||
})],
|
||
};
|
||
|
||
let prose = module_to_prose(&m);
|
||
assert!(
|
||
prose.contains("prelude.Ordering"),
|
||
"expected qualified `prelude.Ordering` preserved (owner != file's \
|
||
module); got prose:\n{}",
|
||
prose
|
||
);
|
||
}
|
||
|
||
#[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}");
|
||
}
|
||
}
|